// 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, 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(); } }