diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-10-29 19:58:05 -0700 |
| commit | 96bfe2e1505b4ae250f2d5201f9541ceb01cec84 (patch) | |
| tree | b2164a76dc3aa5b69e160d1135d3385bcbe6108d /gateway/src/voice-session.js | |
| parent | 150858067059ba615c548f826bb343589993b4bb (diff) | |
ts web slopify code
Diffstat (limited to 'gateway/src/voice-session.js')
| -rw-r--r-- | gateway/src/voice-session.js | 500 |
1 files changed, 500 insertions, 0 deletions
diff --git a/gateway/src/voice-session.js b/gateway/src/voice-session.js new file mode 100644 index 0000000..039df7b --- /dev/null +++ b/gateway/src/voice-session.js @@ -0,0 +1,500 @@ +// One browser <-> TeamSpeak voice session. +// +// Wire protocol on /ws/voice (no WebRTC; everything rides on this WebSocket): +// browser -> gateway JSON {type:"join", nickname, password, channelId?, channelPassword?, identity?, serverDecode?} +// JSON {type:"move", channelId, password?} | {type:"mute", muted} | {type:"deafen", deafened} +// {type:"chat", scope?:"channel"|"server"|"private", message, targetId?} +// {type:"whisper", targetIds:number[], active:boolean} +// {type:"leave"} +// binary 1920 bytes = 20 ms of 48 kHz mono Int16LE PCM from the microphone +// gateway -> browser JSON {type:"joined"|"state"|"chatMessage"|"whisperTargets"|"error"|"disconnected", ...} +// binary [kind u8][clientId u16BE][payload] +// kind 4/5 = TeamSpeak Opus (voice/music) frame, decoded by WebCodecs in the browser +// kind 0xF0 = 48 kHz mono Int16LE PCM (gateway-side decode fallback) +// an empty payload marks the end of that speaker's transmission +import { WebSocket } from "ws"; +import { + asChannelID, + asClientID, + moveClient, + sendChannelMessage, + sendPrivateMessage, + sendServerMessage, + updateClient, +} from "@honeybbq/teamspeak"; +import { Client, generateIdentity, parseIdentity, subscribeAllChannels } from "@honeybbq/teamspeak/client"; +import { createOpusCodec, FRAME_BYTES } from "./opus.js"; +import { passwordMatches, sanitizeNickname } from "./auth.js"; + +const CONNECT_TIMEOUT_MS = 15_000; +const STATE_THROTTLE_MS = 150; +const END_OF_SPEECH_MS = 100; +const OPUS_BITRATE = 40_000; +const MAX_BUFFERED_OPUS_BYTES = 8 * 1024; +const MAX_BUFFERED_PCM_BYTES = 32 * 1024; +const MAX_SPEAKER_DECODERS = 16; +const MAX_CHAT_LENGTH = 1024; +const MAX_WHISPER_TARGETS = 32; +const KIND_PCM = 0xf0; +// Token bucket for microphone frames: 50 frames/s nominal, small burst allowance. +const FRAME_RATE = 55; +const FRAME_BURST = 25; + +const ERROR_TEXT = { + 1028: ["BAD_PASSWORD", "The TeamSpeak server rejected the password."], + 781: ["CHANNEL_PASSWORD", "Wrong channel password."], + 513: ["NICKNAME_IN_USE", "That nickname is already in use."], + 514: ["NICKNAME_INVALID", "That nickname is not allowed."], + 521: ["TOO_MANY_CLONES", "Too many connections from this gateway."], + 524: ["FLOODING", "Slow down, the server thinks you are flooding."], + 777: ["CHANNEL_FULL", "That channel is full."], + 770: ["ALREADY_IN_CHANNEL", "You are already in that channel."], + 2568: ["NO_PERMISSION", "You do not have permission to do that."], + 3329: ["BANNED", "You are banned from this server."], +}; + +function describeError(error) { + for (let e = error, depth = 0; e && depth < 5; e = e.cause, depth++) { + if (typeof e.code === "number" && ERROR_TEXT[e.code]) { + const [code, message] = ERROR_TEXT[e.code]; + return { code, message }; + } + } + const message = error instanceof Error ? error.message : String(error); + if (/abort|timeout|timed out/i.test(message)) return { code: "TIMEOUT", message: "Timed out reaching the TeamSpeak server." }; + return { code: "TEAMSPEAK_ERROR", message: message.slice(0, 200) || "TeamSpeak error" }; +} + +function snapshot(client) { + const view = client.view; + return { + serverName: view.serverName ?? "", + self: { id: Number(client.clientID), channelId: String(client.channelID) }, + channels: [...view.channels.values()].map((c) => ({ + id: String(c.id), + parentId: String(c.parentID), + order: String(c.order), + name: c.name, + hasPassword: Boolean(c.hasPassword), + })), + clients: [...view.clients.values()].map((e) => ({ + id: Number(e.id), + nickname: e.nickname, + channelId: String(e.channelID), + type: e.type, + away: Boolean(e.isAway), + awayMessage: e.awayMessage ?? "", + inputMuted: Boolean(e.isInputMuted), + outputMuted: Boolean(e.isOutputMuted), + serverGroups: (e.serverGroups ?? []).map(String), + })), + }; +} + +const CHANNEL_ID = /^\d{1,20}$/; + +export class VoiceSession { + /** + * @param {WebSocket} ws + * @param {{ip: string, config: ReturnType<import("./config.js").loadConfig>, limiter: import("./auth.js").LoginLimiter, log: Console, onClose: () => void}} ctx + */ + constructor(ws, ctx) { + this.ws = ws; + this.ctx = ctx; + this.client = null; + this.state = "awaiting-join"; // -> connecting -> ready -> closed + this.muted = false; + this.deafened = false; + this.serverDecode = false; + this.encoder = null; + this.decoders = new Map(); + this.whisperTargetIds = new Set(); + this.whisperActive = false; + this.stateTimer = null; + this.eosTimer = null; + this.tokens = FRAME_BURST; + this.tokensAt = Date.now(); + this.badFrames = 0; + this.kickReason = ""; + this.unsubscribers = []; + this.closing = false; + + this.joinTimer = setTimeout(() => this.fail("JOIN_TIMEOUT", "No join request received.", 4408), 20_000); + ws.on("message", (data, isBinary) => { + if (isBinary) this.onPcm(data); + else this.onJson(data); + }); + ws.on("close", () => this.close()); + ws.on("error", () => this.close()); + } + + send(message) { + if (this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(message)); + } + + fail(code, message, closeCode = 4400) { + this.send({ type: "error", code, message, fatal: true }); + try { this.ws.close(closeCode, code.slice(0, 60)); } catch { /* already closed */ } + this.close(); + } + + error(code, message) { + this.send({ type: "error", code, message, fatal: false }); + } + + onJson(data) { + let msg; + try { + msg = JSON.parse(String(data)); + } catch { + return this.fail("BAD_MESSAGE", "Malformed message."); + } + if (!msg || typeof msg !== "object") return this.fail("BAD_MESSAGE", "Malformed message."); + + if (msg.type === "join") return void this.join(msg); + if (this.state !== "ready") return this.error("NOT_READY", "Not connected yet."); + switch (msg.type) { + case "move": return void this.move(msg); + case "mute": return void this.setMuted(Boolean(msg.muted)); + case "deafen": return void this.setDeafened(Boolean(msg.deafened)); + case "chat": return void this.sendChat(msg); + case "whisper": return void this.setWhisper(msg); + case "leave": return void this.close(); + default: return this.error("UNKNOWN_MESSAGE", "Unknown message type."); + } + } + + async join(msg) { + if (this.state !== "awaiting-join") return this.error("ALREADY_JOINED", "Already joined."); + clearTimeout(this.joinTimer); + const { config, limiter, ip, log } = this.ctx; + + if (!config.server.password) return this.fail("VOICE_DISABLED", "Web voice is disabled: no server password is configured.", 4403); + if (!config.server.host) return this.fail("NOT_CONFIGURED", "The TeamSpeak host is not configured.", 4503); + + const lockedMs = limiter.lockedFor(ip); + if (lockedMs > 0) { + return this.fail("LOCKED_OUT", `Too many wrong passwords. Try again in ${Math.ceil(lockedMs / 60_000)} min.`, 4429); + } + // The gateway itself verifies the server password before spending any + // resources on a TeamSpeak connection; the TeamSpeak server checks it again. + if (!passwordMatches(msg.password, config.server.password)) { + limiter.recordFailure(ip); + log.warn(`[voice] wrong password from ${ip}`); + return this.fail("BAD_PASSWORD", "Wrong server password.", 4401); + } + limiter.recordSuccess(ip); + + const nickname = sanitizeNickname(msg.nickname, config.voice.nicknamePrefix); + if (!nickname) return this.fail("NICKNAME_INVALID", "Nickname must be 3-30 characters."); + + let targetChannel = null; + if (msg.channelId !== undefined && msg.channelId !== null && msg.channelId !== "") { + if (!CHANNEL_ID.test(String(msg.channelId))) return this.fail("BAD_CHANNEL", "Invalid channel."); + targetChannel = String(msg.channelId); + } + this.serverDecode = msg.serverDecode === true; + this.state = "connecting"; + + let identity = null; + if (typeof msg.identity === "string" && msg.identity.length > 0 && msg.identity.length < 512) { + try { identity = parseIdentity(msg.identity); } catch { identity = null; } + } + try { + identity ??= await generateIdentity(config.voice.identitySecurityLevel, { signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + } catch { + return this.fail("IDENTITY_FAILED", "Could not create a TeamSpeak identity.", 4500); + } + if (this.closing) return; + + const client = new Client(identity, `${config.server.host}:${config.server.voicePort}`, nickname, { + serverPassword: config.server.password, + defaultChannel: config.voice.defaultChannel || undefined, + logger: { + debug() {}, + info() {}, + warn: (m) => log.warn(`[ts ${nickname}] ${m}`), + error: (m) => log.error(`[ts ${nickname}] ${m}`), + }, + }); + this.client = client; + this.attachClientEvents(client); + + try { + await client.connect({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + await client.view.whenReady({ signal: AbortSignal.timeout(CONNECT_TIMEOUT_MS) }); + } catch (error) { + if (this.closing) return; + const { code, message } = describeError(error); + log.warn(`[voice] ${ip} could not connect as "${nickname}": ${code} ${error?.message ?? ""}`); + return this.fail(code, message, 4502); + } + if (this.closing) return; + + try { + // Without this the server only reports clients of our own channel. + await subscribeAllChannels(client, { timeoutMs: 5000 }); + } catch (error) { + log.warn(`[voice] could not subscribe to all channels: ${error?.message ?? error}`); + } + + this.encoder = createOpusCodec({ bitrate: OPUS_BITRATE }); + this.state = "ready"; + log.info(`[voice] ${ip} joined as "${nickname}" (clid ${client.clientID})`); + this.send({ type: "joined", identity: identity.export(), nickname, ...snapshot(client) }); + + if (targetChannel && targetChannel !== String(client.channelID)) { + await this.move({ channelId: targetChannel, password: msg.channelPassword }); + } + } + + attachClientEvents(client) { + const on = (event, fn) => this.unsubscribers.push(client.on(event, fn)); + on("viewChanged", () => this.scheduleState()); + on("clientMoved", () => this.scheduleState()); + on("clientLeave", (event) => { + const id = Number(event?.clientID); + const decoder = this.decoders.get(id); + if (decoder) { decoder.dispose(); this.decoders.delete(id); } + if (this.whisperTargetIds.delete(id)) { + if (!this.whisperTargetIds.size) this.whisperActive = false; + this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive }); + } + }); + on("textMessage", (event) => { + const scope = event.targetMode === 1 ? "private" : event.targetMode === 2 ? "channel" : "server"; + if (scope === "private" && Number(event.invokerID) === Number(client.clientID)) return; + const targetId = scope === "channel" + ? String(client.channelID) + : event.targetID ? String(event.targetID) : undefined; + this.send({ + type: "chatMessage", + scope, + ...(targetId ? { targetId } : {}), + invokerId: Number(event.invokerID), + invokerName: event.invokerName, + senderUid: String(event.invokerUID ?? ""), + message: event.message, + timestamp: Date.now(), + }); + }); + on("kicked", (event) => { this.kickReason = event?.reasonMessage ?? ""; }); + on("voiceData", (event) => this.onTeamSpeakVoice(event)); + on("disconnected", (event) => { + if (this.closing || this.state !== "ready") return; + const reason = this.kickReason + ? `Kicked: ${this.kickReason}` + : event?.error ? describeError(event.error).message : "Disconnected from TeamSpeak."; + this.send({ type: "disconnected", reason }); + try { this.ws.close(1000, "disconnected"); } catch { /* ignore */ } + this.close(); + }); + } + + scheduleState() { + if (this.stateTimer || this.state !== "ready") return; + this.stateTimer = setTimeout(() => { + this.stateTimer = null; + if (this.state === "ready" && this.client) this.send({ type: "state", ...snapshot(this.client) }); + }, STATE_THROTTLE_MS); + } + + async move(msg) { + const id = String(msg.channelId ?? ""); + if (!CHANNEL_ID.test(id)) return this.error("BAD_CHANNEL", "Invalid channel."); + const password = typeof msg.password === "string" && msg.password ? msg.password.slice(0, 512) : undefined; + try { + await moveClient(this.client, this.client.clientID, asChannelID(BigInt(id)), { password, timeoutMs: 5000 }); + this.scheduleState(); + } catch (error) { + const { code, message } = describeError(error); + this.send({ type: "error", code, message, fatal: false, channelId: id }); + } + } + + async sendChat(msg) { + const text = typeof msg.message === "string" ? msg.message.trim().slice(0, MAX_CHAT_LENGTH) : ""; + if (!text) return; + const scope = msg.scope === "private" ? "private" : msg.scope === "server" ? "server" : "channel"; + try { + if (scope === "private") { + const targetId = Number(msg.targetId); + if (!Number.isInteger(targetId) || targetId <= 0 || targetId > 65535) { + return this.error("BAD_TARGET", "Invalid private message recipient."); + } + await sendPrivateMessage(this.client, asClientID(targetId), text, { timeoutMs: 5000 }); + const selfId = Number(this.client.clientID); + const selfEntry = this.client.view?.clients?.get?.(this.client.clientID); + this.send({ + type: "chatMessage", + scope: "private", + targetId: String(targetId), + invokerId: selfId, + invokerName: selfEntry?.nickname || "you", + senderUid: "", + message: text, + timestamp: Date.now(), + }); + } else if (scope === "server") { + await sendServerMessage(this.client, text, { timeoutMs: 5000 }); + } else { + await sendChannelMessage(this.client, text, { timeoutMs: 5000 }); + } + } catch (error) { + const { code, message } = describeError(error); + this.error(code, message); + } + } + + setWhisper(msg) { + const selfId = Number(this.client?.clientID ?? 0); + if (Array.isArray(msg.targetIds)) { + const clean = []; + for (const raw of msg.targetIds) { + const id = Number(raw); + if (Number.isInteger(id) && id > 0 && id <= 65535 && id !== selfId && !clean.includes(id)) { + clean.push(id); + if (clean.length >= MAX_WHISPER_TARGETS) break; + } + } + this.whisperTargetIds = new Set(clean); + } + if (typeof msg.active === "boolean") { + this.whisperActive = msg.active && this.whisperTargetIds.size > 0; + } else if (!this.whisperTargetIds.size) { + this.whisperActive = false; + } + this.send({ type: "whisperTargets", targetIds: [...this.whisperTargetIds], active: this.whisperActive }); + } + + async setMuted(muted) { + this.muted = muted; + if (muted) this.endOfSpeech(); + try { await updateClient(this.client, { isInputMuted: muted }, { timeoutMs: 5000 }); } + catch (error) { this.error(describeError(error).code, "Could not update mute state."); } + } + + async setDeafened(deafened) { + this.deafened = deafened; + try { await updateClient(this.client, { isOutputMuted: deafened }, { timeoutMs: 5000 }); } + catch (error) { this.error(describeError(error).code, "Could not update deafen state."); } + } + + // ---- audio ---------------------------------------------------------------- + + takeToken() { + const now = Date.now(); + this.tokens = Math.min(FRAME_BURST, this.tokens + ((now - this.tokensAt) / 1000) * FRAME_RATE); + this.tokensAt = now; + if (this.tokens < 1) return false; + this.tokens -= 1; + return true; + } + + sendVoiceOrWhisper(payload) { + if (this.whisperActive && this.whisperTargetIds.size > 0) { + this.client.sendWhisper(payload, { clients: [...this.whisperTargetIds].map(asClientID) }, 4 /* Codec.OpusVoice */); + } else { + this.client.sendVoice(payload, 4 /* Codec.OpusVoice */); + } + } + + onPcm(frame) { + if (this.state !== "ready" || this.muted || !this.encoder) return; + if (!Buffer.isBuffer(frame) || frame.length !== FRAME_BYTES) { + if (++this.badFrames > 100) this.fail("BAD_AUDIO", "Malformed audio frames."); + return; + } + if (!this.takeToken()) return; + try { + const encoded = this.encoder.encode(frame); + this.sendVoiceOrWhisper(encoded); + } catch { + return; + } + clearTimeout(this.eosTimer); + this.eosTimer = setTimeout(() => this.endOfSpeech(), END_OF_SPEECH_MS); + } + + /** An empty voice packet tells TeamSpeak clients the transmission ended. */ + endOfSpeech() { + clearTimeout(this.eosTimer); + this.eosTimer = null; + if (this.state !== "ready") return; + try { this.sendVoiceOrWhisper(new Uint8Array(0)); } catch { /* not connected */ } + } + + speakerDecoder(clientId) { + let decoder = this.decoders.get(clientId); + if (decoder) { + this.decoders.delete(clientId); // refresh LRU position + } else { + decoder = createOpusCodec(); + while (this.decoders.size >= MAX_SPEAKER_DECODERS) { + const [oldestId, oldest] = this.decoders.entries().next().value; + oldest.dispose(); + this.decoders.delete(oldestId); + } + } + this.decoders.set(clientId, decoder); + return decoder; + } + + onTeamSpeakVoice(event) { + if (this.state !== "ready" || this.deafened || this.ws.readyState !== WebSocket.OPEN) return; + const clientId = Number(event.clientID); + if (clientId === Number(this.client.clientID) || clientId < 1 || clientId > 65535) return; + if (event.codec !== 4 && event.codec !== 5) return; // legacy Speex/CELT are not supported + const data = event.data instanceof Uint8Array ? event.data : new Uint8Array(0); + + let kind = event.codec; + let payload = data; + if (this.serverDecode) { + if (this.ws.bufferedAmount > MAX_BUFFERED_PCM_BYTES) return; + kind = KIND_PCM; + if (data.length > 0) { + try { payload = this.speakerDecoder(clientId).decode(Buffer.from(data.buffer, data.byteOffset, data.byteLength)); } + catch { return; } + } + } else if (this.ws.bufferedAmount > MAX_BUFFERED_OPUS_BYTES) { + return; + } + + const packet = Buffer.allocUnsafe(3 + payload.length); + packet[0] = kind; + packet.writeUInt16BE(clientId, 1); + packet.set(payload, 3); + this.ws.send(packet); + } + + // ---- teardown ------------------------------------------------------------- + + close() { + if (this.closing) return; + this.closing = true; + const wasReady = this.state === "ready"; + this.state = "closed"; + clearTimeout(this.joinTimer); + clearTimeout(this.stateTimer); + clearTimeout(this.eosTimer); + for (const off of this.unsubscribers) { try { off(); } catch { /* ignore */ } } + this.unsubscribers = []; + try { this.encoder?.dispose(); } catch { /* ignore */ } + this.encoder = null; + for (const decoder of this.decoders.values()) { try { decoder.dispose(); } catch { /* ignore */ } } + this.decoders.clear(); + this.whisperTargetIds.clear(); + this.whisperActive = false; + const client = this.client; + this.client = null; + if (client) { + client.disconnect({ reasonMessage: "Left from the web", signal: AbortSignal.timeout(2000) }) + .catch(() => undefined); + } + if (wasReady) this.ctx.log.info(`[voice] ${this.ctx.ip} left`); + if (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING) { + try { this.ws.close(1000, "bye"); } catch { /* ignore */ } + } + this.ctx.onClose(); + } +} |
