jank-client-fork/src/webpage/voice.ts
2025-05-15 15:45:09 -05:00

828 lines
22 KiB
TypeScript

import {memberjson, sdpback, voiceserverupdate, voiceStatus, webRTCSocket} from "./jsontypes.js";
class VoiceFactory {
settings: {id: string};
handleGateway: (obj: Object) => void;
constructor(
usersettings: VoiceFactory["settings"],
handleGateway: VoiceFactory["handleGateway"],
) {
this.settings = usersettings;
this.handleGateway = handleGateway;
}
voices = new Map<string, Map<string, Voice>>();
voiceChannels = new Map<string, Voice>();
currentVoice?: Voice;
guildUrlMap = new Map<
string,
{url?: string; token?: string; geturl: Promise<void>; gotUrl: () => void}
>();
makeVoice(guildid: string, channelId: string, settings: Voice["settings"]) {
let guild = this.voices.get(guildid);
if (!guild) {
this.setUpGuild(guildid);
guild = new Map();
this.voices.set(guildid, guild);
}
const urlobj = this.guildUrlMap.get(guildid);
if (!urlobj) throw new Error("url Object doesn't exist (InternalError)");
const voice = new Voice(this.settings.id, settings, urlobj, this);
this.voiceChannels.set(channelId, voice);
guild.set(channelId, voice);
return voice;
}
onJoin = (_voice: Voice) => {};
onLeave = (_voice: Voice) => {};
private imute = false;
get mute() {
return this.imute;
}
set mute(s) {
const changed = this.imute !== s;
this.imute = s;
if (this.currentVoice && changed) {
this.currentVoice.updateMute();
this.updateSelf();
}
}
updateSelf() {
if (this.currentVoice && this.currentVoice.open) {
this.handleGateway({
op: 4,
d: {
guild_id: this.curGuild,
channel_id: this.curChan,
self_mute: this.imute,
self_deaf: false,
self_video: false,
flags: 3,
},
});
}
}
curGuild?: string;
curChan?: string;
joinVoice(channelId: string, guildId: string, self_mute = false) {
const voice = this.voiceChannels.get(channelId);
this.mute = self_mute;
if (this.currentVoice && this.currentVoice.ws) {
this.currentVoice.leave();
}
this.curChan = channelId;
this.curGuild = guildId;
if (!voice) throw new Error(`Voice ${channelId} does not exist`);
voice.join();
this.currentVoice = voice;
this.onJoin(voice);
return {
d: {
guild_id: guildId,
channel_id: channelId,
self_mute,
self_deaf: false, //todo
self_video: false, //What is this? I have some guesses
flags: 2, //?????
},
op: 4,
};
}
userMap = new Map<string, Voice>();
voiceStateUpdate(update: voiceStatus) {
const prev = this.userMap.get(update.user_id);
console.log(prev, this.userMap);
if (prev && update.channel_id !== this.curChan) {
prev.disconnect(update.user_id);
this.onLeave(prev);
}
const voice = this.voiceChannels.get(update.channel_id);
if (voice) {
this.userMap.set(update.user_id, voice);
voice.voiceupdate(update);
}
}
private setUpGuild(id: string) {
const obj: {url?: string; geturl?: Promise<void>; gotUrl?: () => void} = {};
obj.geturl = new Promise<void>((res) => {
obj.gotUrl = res;
});
this.guildUrlMap.set(id, obj as {geturl: Promise<void>; gotUrl: () => void});
}
voiceServerUpdate(update: voiceserverupdate) {
const obj = this.guildUrlMap.get(update.d.guild_id);
if (!obj) return;
obj.url = update.d.endpoint;
obj.token = update.d.token;
obj.gotUrl();
}
}
class Voice {
private pstatus: string = "not connected";
public onSatusChange: (e: string) => unknown = () => {};
set status(e: string) {
console.log("state changed: " + e);
this.pstatus = e;
this.onSatusChange(e);
}
get status() {
return this.pstatus;
}
readonly userid: string;
settings: {bitrate: number};
urlobj: {url?: string; token?: string; geturl: Promise<void>; gotUrl: () => void};
owner: VoiceFactory;
constructor(
userid: string,
settings: Voice["settings"],
urlobj: Voice["urlobj"],
owner: VoiceFactory,
) {
this.userid = userid;
this.settings = settings;
this.urlobj = urlobj;
this.owner = owner;
}
pc?: RTCPeerConnection;
ws?: WebSocket;
timeout: number = 30000;
interval: NodeJS.Timeout = 0 as unknown as NodeJS.Timeout;
time: number = 0;
seq: number = 0;
sendAlive() {
if (this.ws) {
this.ws.send(JSON.stringify({op: 3, d: 10}));
}
}
readonly users = new Map<number, string>();
readonly speakingMap = new Map<string, number>();
onSpeakingChange = (_userid: string, _speaking: number) => {};
disconnect(userid: string) {
console.warn(userid);
if (userid === this.userid) {
this.leave();
}
const ssrc = this.speakingMap.get(userid);
if (ssrc) {
this.users.set(ssrc, "");
for (const thing of this.ssrcMap) {
if (thing[1] === ssrc) {
this.ssrcMap.delete(thing[0]);
}
}
}
this.speakingMap.delete(userid);
this.userids.delete(userid);
console.log(this.userids, userid);
//there's more for sure, but this is "good enough" for now
this.onMemberChange(userid, false);
}
async packet(message: MessageEvent) {
const data = message.data;
if (typeof data === "string") {
const json: webRTCSocket = JSON.parse(data);
switch (json.op) {
case 2:
this.startWebRTC();
break;
case 4:
this.continueWebRTC(json);
break;
case 5:
this.speakingMap.set(json.d.user_id, json.d.speaking);
this.onSpeakingChange(json.d.user_id, json.d.speaking);
break;
case 6:
this.time = json.d.t;
setTimeout(this.sendAlive.bind(this), this.timeout);
break;
case 8:
this.timeout = json.d.heartbeat_interval;
setTimeout(this.sendAlive.bind(this), 1000);
break;
case 12:
await this.figureRecivers();
if (!this.users.has(json.d.audio_ssrc)) {
console.log("redo 12!");
this.makeOp12();
}
if (this.pc) {
this.pc.addTransceiver(json.d.audio_ssrc ? "audio" : "video", {
direction: "recvonly",
sendEncodings: [{active: true}],
});
this.getAudioTrans(this.users.size + 1).direction = "recvonly";
}
this.users.set(json.d.audio_ssrc, json.d.user_id);
break;
}
}
}
getAudioTrans(id: number) {
if (!this.pc) throw new Error("no pc");
let i = 0;
for (const thing of this.pc.getTransceivers()) {
if (thing.receiver.track.kind === "audio") {
if (id === i) {
return thing;
}
i++;
}
}
throw new Error("none by that id");
}
hoffer?: string;
get offer() {
return this.hoffer;
}
set offer(e: string | undefined) {
this.hoffer = e;
}
fingerprint?: string;
cleanServerSDP(sdp: string): string {
const pc = this.pc;
if (!pc) throw new Error("pc isn't defined");
const ld = pc.localDescription;
if (!ld) throw new Error("localDescription isn't defined");
const parsed = Voice.parsesdp(ld.sdp);
const group = parsed.atr.get("group");
if (!group) throw new Error("group isn't in sdp");
const [_, ...bundles] = (group.entries().next().value as [string, string])[0].split(" ");
bundles[bundles.length - 1] = bundles[bundles.length - 1].replace("\r", "");
console.log(bundles);
if (!this.offer) throw new Error("Offer is missing :P");
let cline = sdp.split("\n").find((line) => line.startsWith("c="));
if (!cline) throw new Error("c line wasn't found");
const parsed1 = Voice.parsesdp(sdp).medias[0];
//const parsed2=Voice.parsesdp(this.offer);
const rtcport = (parsed1.atr.get("rtcp") as Set<string>).values().next().value as string;
const ICE_UFRAG = (parsed1.atr.get("ice-ufrag") as Set<string>).values().next().value as string;
const ICE_PWD = (parsed1.atr.get("ice-pwd") as Set<string>).values().next().value as string;
const FINGERPRINT =
this.fingerprint ||
((parsed1.atr.get("fingerprint") as Set<string>).values().next().value as string);
this.fingerprint = FINGERPRINT;
const candidate = (parsed1.atr.get("candidate") as Set<string>).values().next().value as string;
const audioUsers = [...this.users];
console.warn(audioUsers);
let build = `v=0\r
o=- 1420070400000 0 IN IP4 ${this.urlobj.url}\r
s=-\r
t=0 0\r
a=msid-semantic: WMS *\r
a=group:BUNDLE ${bundles.join(" ")}\r`;
let ai = -1;
let i = 0;
for (const grouping of parsed.medias) {
let mode = "inactive";
for (const _ of this.senders) {
if (i < 2) {
mode = "sendrecv";
}
}
if (grouping.media === "audio") {
build += `
m=audio ${parsed1.port} UDP/TLS/RTP/SAVPF 111\r
${cline}\r
a=rtpmap:111 opus/48000/2\r
a=fmtp:111 minptime=10;useinbandfec=1;usedtx=1\r
a=rtcp:${rtcport}\r
a=rtcp-fb:111 transport-cc\r
a=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\r
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=setup:passive\r
a=mid:${bundles[i]}${audioUsers[ai] && audioUsers[ai][1] ? `\r\na=msid:${audioUsers[ai][1]}-${audioUsers[ai][0]} a${audioUsers[ai][1]}-${audioUsers[ai][0]}\r` : "\r"}
a=maxptime:60\r
a=${audioUsers[ai] && audioUsers[ai][1] ? "sendonly" : mode}\r
a=ice-ufrag:${ICE_UFRAG}\r
a=ice-pwd:${ICE_PWD}\r
a=fingerprint:${FINGERPRINT}\r
a=candidate:${candidate}${audioUsers[ai] && audioUsers[ai][1] ? `\r\na=ssrc:${audioUsers[ai][0]} cname:${audioUsers[ai][1]}-${audioUsers[ai][0]}\r` : "\r"}
a=rtcp-mux\r`;
ai++;
} else {
build += `
m=video ${parsed1.port} UDP/TLS/RTP/SAVPF 103 104\r
${cline}\r
a=rtpmap:103 H264/90000\r
a=rtpmap:104 rtx/90000\r
a=fmtp:103 x-google-max-bitrate=2500;level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\r
a=fmtp:104 apt=103\r
a=rtcp:${rtcport}\r
a=rtcp-fb:103 ccm fir\r
a=rtcp-fb:103 nack\r
a=rtcp-fb:103 nack pli\r
a=rtcp-fb:103 goog-remb\r
a=rtcp-fb:103 transport-cc\r
a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time
a=extmap:3 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01
a=extmap:14 urn:ietf:params:rtp-hdrext:toffset\r
a=extmap:13 urn:3gpp:video-orientation\r
a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay
a=setup:passive
a=mid:${bundles[i]}\r
a=${mode}\r
a=ice-ufrag:${ICE_UFRAG}\r
a=ice-pwd:${ICE_PWD}\r
a=fingerprint:${FINGERPRINT}\r
a=candidate:${candidate}\r
a=rtcp-mux\r`;
}
i++;
}
build += "\n";
return build;
}
counter?: string;
negotationneeded() {
if (this.pc) {
const pc = this.pc;
const sendOffer = async () => {
console.trace("neg need");
await pc.setLocalDescription();
const senders = this.senders.difference(this.ssrcMap);
for (const sender of senders) {
for (const thing of (await sender.getStats()) as Map<string, any>) {
if (thing[1].ssrc) {
this.ssrcMap.set(sender, thing[1].ssrc);
this.makeOp12(sender);
}
}
}
};
pc.addEventListener("negotiationneeded", async () => {
await sendOffer();
console.log(this.ssrcMap);
});
pc.addEventListener("signalingstatechange", async () => {
while (!this.counter) await new Promise((res) => setTimeout(res, 100));
if (this.pc && this.counter) {
if (pc.signalingState === "have-local-offer") {
const counter = this.counter;
const remote: {sdp: string; type: RTCSdpType} = {
sdp: this.cleanServerSDP(counter),
type: "answer",
};
console.log(remote);
await pc.setRemoteDescription(remote);
}
}
});
pc.addEventListener("connectionstatechange", async () => {
if (pc.connectionState === "connecting") {
await pc.setLocalDescription();
}
});
pc.addEventListener("icegatheringstatechange", async () => {
console.log("icegatheringstatechange", pc.iceGatheringState, this.pc, this.counter);
if (this.pc && this.counter) {
if (pc.iceGatheringState === "complete") {
console.log("icegatheringstatechange");
const counter = this.counter;
const remote: {sdp: string; type: RTCSdpType} = {
sdp: this.cleanServerSDP(counter),
type: "answer",
};
await pc.setRemoteDescription(remote);
}
}
});
}
}
async makeOp12(
sender: RTCRtpSender | undefined | [RTCRtpSender, number] = this.ssrcMap.entries().next().value,
) {
if (!sender) throw new Error("sender doesn't exist");
if (sender instanceof Array) {
sender = sender[0];
}
if (this.ws) {
console.log(this.ssrcMap);
this.ws.send(
JSON.stringify({
op: 12,
d: {
audio_ssrc: this.ssrcMap.get(sender),
video_ssrc: 0,
rtx_ssrc: 0,
streams: [
{
type: "video",
rid: "100",
ssrc: 0, //TODO
active: false,
quality: 100,
rtx_ssrc: 0, //TODO
max_bitrate: 2500000, //TODO
max_framerate: 0, //TODO
max_resolution: {
type: "fixed",
width: 0, //TODO
height: 0, //TODO
},
},
],
},
}),
);
this.status = "Sending audio streams";
}
}
senders: Set<RTCRtpSender> = new Set();
recivers = new Set<RTCRtpReceiver>();
ssrcMap: Map<RTCRtpSender, number> = new Map();
speaking = false;
async setupMic(audioStream: MediaStream) {
const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(audioStream);
analyser.smoothingTimeConstant = 0;
analyser.fftSize = 32;
microphone.connect(analyser);
const array = new Float32Array(1);
const interval = setInterval(() => {
if (!this.ws) {
clearInterval(interval);
}
analyser.getFloatFrequencyData(array);
const value = array[0] + 65;
if (value < 0) {
if (this.speaking) {
this.speaking = false;
this.sendSpeaking();
console.log("not speaking");
}
} else if (!this.speaking) {
console.log("speaking");
this.speaking = true;
this.sendSpeaking();
}
}, 500);
}
async sendSpeaking() {
if (!this.ws) return;
const pair = this.ssrcMap.entries().next().value;
if (!pair) return;
this.onSpeakingChange(this.userid, +this.speaking);
this.ws.send(
JSON.stringify({
op: 5,
d: {
speaking: this.speaking,
delay: 5, //not sure
ssrc: pair[1],
},
}),
);
}
async continueWebRTC(data: sdpback) {
if (this.pc && this.offer) {
this.counter = data.d.sdp;
} else {
this.status = "Connection failed";
}
}
reciverMap = new Map<number, RTCRtpReceiver>();
off?: Promise<RTCSessionDescriptionInit>;
async makeOffer() {
if (this.pc?.localDescription?.sdp) return {sdp: this.pc?.localDescription?.sdp};
if (this.off) return this.off;
return (this.off = new Promise<RTCSessionDescriptionInit>(async (res) => {
if (!this.pc) throw new Error("stupid");
console.error("stupid!");
const offer = await this.pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true,
});
res(offer);
}));
}
async figureRecivers() {
await new Promise((res) => setTimeout(res, 500));
for (const reciver of this.recivers) {
const stats = (await reciver.getStats()) as Map<string, any>;
for (const thing of stats) {
if (thing[1].ssrc) {
this.reciverMap.set(thing[1].ssrc, reciver);
}
}
}
console.log(this.reciverMap);
}
updateMute() {
if (!this.micTrack) return;
this.micTrack.enabled = !this.owner.mute;
}
mic?: RTCRtpSender;
micTrack?: MediaStreamTrack;
async startWebRTC() {
this.status = "Making offer";
const pc = new RTCPeerConnection();
pc.ontrack = async (e) => {
this.status = "Done";
if (e.track.kind === "video") {
console.log("gotVideo?");
return;
}
const media = e.streams[0];
console.log("got audio:", e);
for (const track of media.getTracks()) {
console.log(track);
}
const context = new AudioContext();
console.log(context);
await context.resume();
const ss = context.createMediaStreamSource(media);
console.log(media, ss);
new Audio().srcObject = media; //weird I know, but it's for chromium/webkit bug
ss.connect(context.destination);
this.recivers.add(e.receiver);
console.log(this.recivers);
};
const audioStream = await navigator.mediaDevices.getUserMedia({video: false, audio: true});
const [track] = audioStream.getAudioTracks();
//Add track
this.setupMic(audioStream);
const sender = pc.addTrack(track);
this.mic = sender;
this.micTrack = track;
track.enabled = !this.owner.mute;
this.senders.add(sender);
console.log(sender);
for (let i = 0; i < 10; i++) {
pc.addTransceiver("audio", {
direction: "inactive",
streams: [],
sendEncodings: [{active: true, maxBitrate: this.settings.bitrate}],
});
}
for (let i = 0; i < 10; i++) {
pc.addTransceiver("video", {
direction: "inactive",
streams: [],
sendEncodings: [{active: true, maxBitrate: this.settings.bitrate}],
});
}
this.pc = pc;
this.negotationneeded();
await new Promise((res) => setTimeout(res, 100));
let sdp = this.offer;
if (!sdp) {
const offer = await this.makeOffer();
this.status = "Starting RTC connection";
sdp = offer.sdp;
this.offer = sdp;
}
await pc.setLocalDescription();
if (!sdp) {
this.status = "No SDP";
this.ws?.close();
return;
}
const parsed = Voice.parsesdp(sdp);
const video = new Map<string, [number, number]>();
const audio = new Map<string, number>();
let cur: [number, number] | undefined;
let i = 0;
for (const thing of parsed.medias) {
try {
if (thing.media === "video") {
const rtpmap = thing.atr.get("rtpmap");
if (!rtpmap) continue;
for (const codecpair of rtpmap) {
const [port, codec] = codecpair.split(" ");
if (cur && codec.split("/")[0] === "rtx") {
cur[1] = Number(port);
cur = undefined;
continue;
}
if (video.has(codec.split("/")[0])) continue;
cur = [Number(port), -1];
video.set(codec.split("/")[0], cur);
}
} else if (thing.media === "audio") {
const rtpmap = thing.atr.get("rtpmap");
if (!rtpmap) continue;
for (const codecpair of rtpmap) {
const [port, codec] = codecpair.split(" ");
if (audio.has(codec.split("/")[0])) {
continue;
}
audio.set(codec.split("/")[0], Number(port));
}
}
} finally {
i++;
}
}
const codecs: {
name: string;
type: "video" | "audio";
priority: number;
payload_type: number;
rtx_payload_type: number | null;
}[] = [];
const include = new Set<string>();
const audioAlloweds = new Map([["opus", {priority: 1000}]]);
for (const thing of audio) {
if (audioAlloweds.has(thing[0])) {
include.add(thing[0]);
codecs.push({
name: thing[0],
type: "audio",
priority: audioAlloweds.get(thing[0])?.priority as number,
payload_type: thing[1],
rtx_payload_type: null,
});
}
}
const videoAlloweds = new Map([
["H264", {priority: 1000}],
["VP8", {priority: 2000}],
["VP9", {priority: 3000}],
]);
for (const thing of video) {
if (videoAlloweds.has(thing[0])) {
include.add(thing[0]);
codecs.push({
name: thing[0],
type: "video",
priority: videoAlloweds.get(thing[0])?.priority as number,
payload_type: thing[1][0],
rtx_payload_type: thing[1][1],
});
}
}
let sendsdp = "a=extmap-allow-mixed";
let first = true;
for (const media of parsed.medias) {
for (const thing of first
? ["ice-ufrag", "ice-pwd", "ice-options", "fingerprint", "extmap", "rtpmap"]
: ["extmap", "rtpmap"]) {
const thing2 = media.atr.get(thing);
if (!thing2) continue;
for (const thing3 of thing2) {
if (thing === "rtpmap") {
const name = thing3.split(" ")[1].split("/")[0];
if (include.has(name)) {
include.delete(name);
} else {
continue;
}
}
sendsdp += `\na=${thing}:${thing3}`;
}
}
first = false;
}
if (this.ws) {
this.ws.send(
JSON.stringify({
d: {
codecs,
protocol: "webrtc",
data: sendsdp,
sdp: sendsdp,
},
op: 1,
}),
);
}
}
static parsesdp(sdp: string) {
let currentA = new Map<string, Set<string>>();
const out: {
version?: number;
medias: {
media: string;
port: number;
proto: string;
ports: number[];
atr: Map<string, Set<string>>;
}[];
atr: Map<string, Set<string>>;
} = {medias: [], atr: currentA};
for (const line of sdp.split("\n")) {
const [code, setinfo] = line.split("=");
switch (code) {
case "v":
out.version = Number(setinfo);
break;
case "o":
case "s":
case "t":
break;
case "m":
currentA = new Map();
const [media, port, proto, ...ports] = setinfo.split(" ");
const portnums = ports.map(Number);
out.medias.push({media, port: Number(port), proto, ports: portnums, atr: currentA});
break;
case "a":
const [key, ...value] = setinfo.split(":");
if (!currentA.has(key)) {
currentA.set(key, new Set());
}
currentA.get(key)?.add(value.join(":"));
break;
}
}
return out;
}
open = false;
async join() {
console.warn("Joining");
this.open = true;
this.status = "waiting for main WS";
}
onMemberChange = (_member: memberjson | string, _joined: boolean) => {};
userids = new Map<string, {}>();
async voiceupdate(update: voiceStatus) {
console.log("Update!");
if (!this.userids.has(update.user_id)) {
this.onMemberChange(update?.member || update.user_id, true);
}
this.userids.set(update.user_id, {deaf: update.deaf, muted: update.mute});
if (update.user_id === this.userid && this.open && !(this.status === "Done")) {
if (!update) {
this.status = "bad responce from WS";
return;
}
if (!this.urlobj.url) {
this.status = "waiting for Voice URL";
await this.urlobj.geturl;
if (!this.open) {
this.leave();
return;
}
}
const ws = new WebSocket(("ws://" + this.urlobj.url) as string);
this.ws = ws;
ws.onclose = () => {
this.leave();
};
this.status = "waiting for WS to open";
ws.addEventListener("message", (m) => {
this.packet(m);
});
await new Promise<void>((res) => {
ws.addEventListener("open", () => {
res();
});
});
if (!this.ws) {
this.leave();
return;
}
this.status = "waiting for WS to authorize";
ws.send(
JSON.stringify({
op: 0,
d: {
server_id: update.guild_id,
user_id: update.user_id,
session_id: update.session_id,
token: this.urlobj.token,
video: false,
streams: [
{
type: "video",
rid: "100",
quality: 100,
},
],
},
}),
);
}
}
async leave() {
console.warn("leave");
this.open = false;
this.status = "Left voice chat";
this.onMemberChange(this.userid, false);
this.userids.delete(this.userid);
if (this.ws) {
this.ws.close();
this.ws = undefined;
}
if (this.pc) {
this.pc.close();
this.pc = undefined;
}
}
}
export {Voice, VoiceFactory};