// Browser voice client for the TeamSpeak dashboard. // Talks to the Node gateway over /ws/voice (see gateway/src/voice-session.js // for the wire protocol). No WebRTC: mic PCM goes up, Opus frames come down. // // UI layout: // - Top bar: shows current identity on the left and a [ browser connect ] button // on the right that opens a modal for [ nickname ] and [ server password ] // (both saved in localStorage). // - Each channel card has a [ join ] button; while connected, non-self users // show a per-user volume slider and a [ msg ] button for direct messages, // and the joined channel expands an inline chat box (with tabs for #channel // and @user direct messages) directly underneath its user list. // - Fixed bottom bar: mute / deafen / settings / leave controls. (() => { "use strict"; const cfgEl = document.getElementById("voice-config"); const topbar = document.getElementById("voice-topbar"); const dialog = document.getElementById("voice-dialog"); if (!cfgEl || !topbar || !dialog) return; const cfg = JSON.parse(cfgEl.textContent || "{}"); if (!cfg.enabled) return; const $ = (id) => document.getElementById(id); const ui = { topbar, topbarIdentity: $("voice-topbar-identity"), loginBtn: $("voice-login-btn"), dialog, dialogTarget: $("voice-dialog-target"), dialogMessage: $("voice-dialog-message"), form: $("voice-join-form"), nickname: $("voice-nickname"), password: $("voice-password"), submit: $("voice-submit"), cancel: $("voice-cancel"), message: $("voice-message"), notice: $("voice-notice"), bar: $("voice-bar"), barMessage: $("voice-bar-message"), channel: $("voice-channel"), self: $("voice-self"), meter: $("voice-meter"), threshold: $("voice-threshold"), mute: $("voice-mute"), deafen: $("voice-deafen"), settingsToggle: $("voice-settings-toggle"), settings: $("voice-settings"), mode: $("voice-mode"), ptt: $("voice-ptt"), leave: $("voice-leave"), sensitivity: $("voice-sensitivity"), volume: $("voice-volume"), container: $("ajax-container"), }; const STORE = { nickname: "ts6voice.nickname", password: "ts6voice.password", identity: "ts6voice.identity", sensitivity: "ts6voice.sensitivity", volume: "ts6voice.volume", mode: "ts6voice.mode", userVolumes: "ts6voice.userVolumes", }; const store = { get: (k) => { try { return localStorage.getItem(k); } catch { return null; } }, set: (k, v) => { try { localStorage.setItem(k, v); } catch { /* private mode */ } }, }; const KIND_PCM = 0xf0; const PTT_KEY = "KeyV"; const VAD_HANG_MS = 350; const TALK_TIMEOUT_MS = 300; const START_LATENCY_S = 0.06; const MAX_AHEAD_S = 0.25; const MAX_SEND_BUFFER = 16 * 1024; const MAX_CHAT_HISTORY = 100; let session = null; // active session, see startJoin() let pendingJoin = null; // undefined = save only; null = join default channel; {cid,name} = join specific channel // ---- per-user volume storage ----------------------------------------------- let savedUserVolumes = {}; try { const raw = store.get(STORE.userVolumes); if (raw) { const parsed = JSON.parse(raw); if (parsed && typeof parsed === "object") savedUserVolumes = parsed; } } catch { savedUserVolumes = {}; } function clampUserVolume(val) { const num = Number(val); if (!Number.isFinite(num)) return 100; return Math.max(0, Math.min(200, Math.round(num))); } function getUserVolumePercent(clientId, nickname) { if (session && session.userVolumes.has(clientId)) { return session.userVolumes.get(clientId); } if (nickname && Object.prototype.hasOwnProperty.call(savedUserVolumes, nickname)) { return clampUserVolume(savedUserVolumes[nickname]); } return 100; } function setUserVolumePercent(clientId, nickname, percent) { const clamped = clampUserVolume(percent); if (session) { session.userVolumes.set(clientId, clamped); if (session.playback) session.playback.setUserVolume(clientId, clamped / 100); } if (nickname) { savedUserVolumes[nickname] = clamped; store.set(STORE.userVolumes, JSON.stringify(savedUserVolumes)); } return clamped; } // ---- messages & topbar summary --------------------------------------------- function setText(node, text, kind = "info") { if (!node) return; node.textContent = text || ""; node.dataset.kind = kind; node.hidden = !text; } /** Route a message to wherever the user is looking right now. */ function say(text, kind = "info") { if (session && session.ready) setText(ui.barMessage, text, kind); else if (ui.dialog.open) setText(ui.dialogMessage, text, kind); else setText(ui.message, text, kind); } function updateTopbarSummary() { const name = (ui.nickname.value || "").trim(); const pass = ui.password.value || ""; if (session && session.ready) { ui.topbarIdentity.textContent = `[ ${session.nickname} // connected ]`; ui.topbarIdentity.dataset.ready = "1"; } else if (name.length >= 3 && pass) { ui.topbarIdentity.textContent = `[ ${name} // password saved ]`; ui.topbarIdentity.dataset.ready = "1"; } else if (name.length >= 3) { ui.topbarIdentity.textContent = `[ ${name} // no password ]`; ui.topbarIdentity.dataset.ready = ""; } else { ui.topbarIdentity.textContent = "[ not logged in ]"; ui.topbarIdentity.dataset.ready = ""; } } // ---- browser connect modal -------------------------------------------------- function openLoginModal(joinIntent = null, errorMsg = "") { pendingJoin = joinIntent; if (joinIntent === undefined) { ui.dialogTarget.textContent = "credentials"; ui.submit.textContent = "save"; } else if (joinIntent && joinIntent.name) { ui.dialogTarget.textContent = joinIntent.name; ui.submit.textContent = "connect"; } else { ui.dialogTarget.textContent = "default channel"; ui.submit.textContent = "connect"; } setText(ui.dialogMessage, errorMsg, errorMsg ? "error" : "info"); if (!ui.dialog.open) ui.dialog.showModal(); (ui.nickname.value.trim().length >= 3 ? ui.password : ui.nickname).focus(); } function closeLoginModal() { pendingJoin = null; if (ui.dialog.open) ui.dialog.close(); } ui.loginBtn.addEventListener("click", () => { if (session && session.ready) openLoginModal(undefined); else openLoginModal(null); }); ui.cancel.addEventListener("click", () => { if (session && !session.ready) teardown("", "info"); closeLoginModal(); }); ui.dialog.addEventListener("cancel", (e) => { if (session && !session.ready) { e.preventDefault(); teardown("", "info"); closeLoginModal(); } }); ui.dialog.addEventListener("click", (e) => { if (e.target === ui.dialog && (!session || session.ready)) closeLoginModal(); }); // ---- preferences -------------------------------------------------------------- ui.nickname.value = store.get(STORE.nickname) || ""; ui.password.value = store.get(STORE.password) || ""; if (store.get(STORE.sensitivity) !== null) ui.sensitivity.value = store.get(STORE.sensitivity); if (store.get(STORE.volume) !== null) ui.volume.value = store.get(STORE.volume); let mode = store.get(STORE.mode) === "ptt" ? "ptt" : "vad"; updateTopbarSummary(); ui.nickname.addEventListener("input", () => { store.set(STORE.nickname, ui.nickname.value.trim()); updateTopbarSummary(); setText(ui.dialogMessage, ""); }); ui.password.addEventListener("input", () => { store.set(STORE.password, ui.password.value); updateTopbarSummary(); setText(ui.dialogMessage, ""); }); function levelFromRms(rms) { const db = 20 * Math.log10(Math.max(rms, 1e-6)); return Math.min(1, Math.max(0, (db + 60) / 60)); } const threshold = () => 1 - Number(ui.sensitivity.value) / 100; function updateThresholdMark() { ui.threshold.style.left = `${threshold() * 100}%`; } function applyModeUi() { ui.mode.textContent = mode === "ptt" ? "push to talk" : "voice activity"; ui.ptt.hidden = mode !== "ptt"; ui.threshold.hidden = mode === "ptt"; } updateThresholdMark(); applyModeUi(); // ---- live channel list & chat (channel + individual DMs) -------------------- function sortChannels(channels, pid = "0", depth = 0) { const byOrder = new Map(); for (const c of channels) if (c.parentId === pid) byOrder.set(c.order, c); const ordered = []; let current = "0"; let guard = 0; while (byOrder.has(current) && guard++ <= channels.length && depth < 32) { const channel = byOrder.get(current); ordered.push(channel); ordered.push(...sortChannels(channels, channel.id, depth + 1)); current = channel.id; } return ordered; } function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; } function safeUrl(path) { return encodeURI(String(path || "")).replace(/["'()\\]/g, (c) => "%" + c.charCodeAt(0).toString(16)); } function statItem(label, value) { const item = el("div", "stat-item"); item.append(el("span", "stat-label", label), el("span", "stat-value", String(value))); return item; } const displayName = (name) => name.replace("[Cspacer]", ""); function formatTime(ts) { try { const d = new Date(ts || Date.now()); return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`; } catch { return ""; } } function findClientName(clientId, fallback = "") { if (session && session.snapshot) { const found = session.snapshot.clients.find((c) => c.id === clientId); if (found && found.nickname) return found.nickname; } const existing = session && session.dmByUser.get(clientId); if (existing && existing.peerName) return existing.peerName; return fallback || `user #${clientId}`; } function ensureDmEntry(peerId, peerName) { let dm = session.dmByUser.get(peerId); const resolvedName = findClientName(peerId, peerName); if (!dm) { dm = { peerId, peerName: resolvedName, messages: [], unread: 0 }; session.dmByUser.set(peerId, dm); } else if (resolvedName && dm.peerName !== resolvedName) { dm.peerName = resolvedName; } return dm; } function openDmConversation(peerId, peerName) { if (!session || !session.ready || !peerId || peerId === session.selfId) return; const dm = ensureDmEntry(peerId, peerName); dm.unread = 0; session.activeChatTab = { type: "dm", peerId, peerName: dm.peerName }; render(); const input = ui.container.querySelector(".channel-chat-input"); if (input) { input.focus(); input.scrollIntoView({ behavior: "smooth", block: "nearest" }); } } function buildChatMsgRow(m, selfId) { const isSelf = m.invokerId === selfId; const row = el( "div", "channel-chat-msg" + (isSelf ? " is-self" : "") + (m.scope === "private" ? " is-private" : m.scope === "server" ? " is-server" : "") ); row.append(el("span", "channel-chat-time", formatTime(m.timestamp))); const authorLabel = m.scope === "server" ? `[server] ${m.invokerName}` : m.invokerName; row.append(el("span", "channel-chat-author", `${authorLabel}:`)); row.append(el("span", "channel-chat-text", m.message)); return row; } function buildChannelChat(channelId, channelTitle) { const wrap = el("section", "channel-chat"); wrap.dataset.chatCid = channelId; const activeTab = session ? session.activeChatTab : { type: "channel" }; const isDmTab = activeTab && activeTab.type === "dm"; const activeDm = isDmTab && session ? session.dmByUser.get(activeTab.peerId) : null; if (isDmTab && !activeDm && session) { session.activeChatTab = { type: "channel" }; } const showingDm = Boolean(isDmTab && activeDm); const head = el("header", "channel-chat-header"); const tabs = el("div", "channel-chat-tabs"); const chTab = el( "button", "channel-chat-tab" + (!showingDm ? " is-active" : ""), `# ${channelTitle}` ); chTab.type = "button"; chTab.dataset.chatTab = "channel"; tabs.append(chTab); if (session) { for (const dm of session.dmByUser.values()) { const isCurrentDm = showingDm && activeDm.peerId === dm.peerId; const dmBtn = el( "button", "channel-chat-tab is-dm" + (isCurrentDm ? " is-active" : "") + (dm.unread > 0 ? " has-unread" : ""), dm.unread > 0 ? `@ ${dm.peerName} (${dm.unread})` : `@ ${dm.peerName}` ); dmBtn.type = "button"; dmBtn.dataset.chatTab = "dm"; dmBtn.dataset.dmPeer = String(dm.peerId); tabs.append(dmBtn); } } head.append(tabs); const log = el("div", "channel-chat-messages"); log.id = "voice-chat-log"; if (showingDm) { const msgs = activeDm.messages; if (msgs.length === 0) { log.append(el("div", "channel-chat-empty", `No private messages with ${activeDm.peerName} yet.`)); } else { for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId)); } } else { const msgs = (session && session.chatByChannel.get(channelId)) || []; if (msgs.length === 0) { log.append(el("div", "channel-chat-empty", "No messages in this channel yet.")); } else { for (const m of msgs) log.append(buildChatMsgRow(m, session.selfId)); } } const draftKey = showingDm ? `dm:${activeDm.peerId}` : `channel:${channelId}`; const form = el("form", "channel-chat-form"); form.dataset.chatScope = showingDm ? "private" : "channel"; form.dataset.chatCid = channelId; if (showingDm) form.dataset.chatTargetId = String(activeDm.peerId); const input = el("input", "channel-chat-input"); input.type = "text"; input.name = "message"; input.maxLength = 1024; input.autocomplete = "off"; input.placeholder = showingDm ? `message @${activeDm.peerName}...` : `message #${channelTitle}...`; input.value = (session && session.chatDrafts.get(draftKey)) || ""; input.addEventListener("input", () => { if (session) session.chatDrafts.set(draftKey, input.value); }); const sendBtn = el("button", "channel-chat-send", "send"); sendBtn.type = "submit"; form.append(input, sendBtn); wrap.append(head, log, form); queueMicrotask(() => { log.scrollTop = log.scrollHeight; }); return wrap; } function appendChatMessage(msg) { if (!session || !session.snapshot) return; if (msg.scope === "private") { const isFromSelf = msg.invokerId === session.selfId; const peerId = isFromSelf ? Number(msg.targetId) : Number(msg.invokerId); if (!peerId) return; const peerName = isFromSelf ? findClientName(peerId) : (msg.invokerName || findClientName(peerId)); const dm = ensureDmEntry(peerId, peerName); dm.messages.push(msg); if (dm.messages.length > MAX_CHAT_HISTORY) dm.messages.shift(); const viewingThisDm = session.activeChatTab && session.activeChatTab.type === "dm" && session.activeChatTab.peerId === peerId; if (viewingThisDm) { dm.unread = 0; const log = document.getElementById("voice-chat-log"); if (log) { const empty = log.querySelector(".channel-chat-empty"); if (empty) empty.remove(); log.append(buildChatMsgRow(msg, session.selfId)); log.scrollTop = log.scrollHeight; return; } } else if (!isFromSelf) { dm.unread += 1; } render(); return; } const cid = msg.scope === "channel" && msg.targetId ? String(msg.targetId) : String(session.snapshot.self.channelId); let list = session.chatByChannel.get(cid); if (!list) { list = []; session.chatByChannel.set(cid, list); } list.push(msg); if (list.length > MAX_CHAT_HISTORY) list.shift(); const viewingChannel = !session.activeChatTab || session.activeChatTab.type === "channel"; if (cid !== String(session.snapshot.self.channelId) || !viewingChannel) return; const log = document.getElementById("voice-chat-log"); if (!log) return; const empty = log.querySelector(".channel-chat-empty"); if (empty) empty.remove(); log.append(buildChatMsgRow(msg, session.selfId)); log.scrollTop = log.scrollHeight; } function render() { if (!session || !session.snapshot) return; const activeEl = document.activeElement; const restoreChatFocus = Boolean(activeEl && activeEl.classList && activeEl.classList.contains("channel-chat-input")); const selectionStart = restoreChatFocus ? activeEl.selectionStart : null; const selectionEnd = restoreChatFocus ? activeEl.selectionEnd : null; const activeVolClid = activeEl && activeEl.classList && activeEl.classList.contains("client-volume-slider") ? activeEl.dataset.volClid : null; const snap = session.snapshot; const channels = sortChannels(snap.channels); const realClients = snap.clients.filter((c) => c.type !== 1); const byChannel = new Map(); for (const c of realClients) { if (!byChannel.has(c.channelId)) byChannel.set(c.channelId, []); byChannel.get(c.channelId).push(c); if (c.id !== snap.self.id && !session.userVolumes.has(c.id)) { const vol = getUserVolumePercent(c.id, c.nickname); session.userVolumes.set(c.id, vol); if (session.playback) session.playback.setUserVolume(c.id, vol / 100); } if (session.dmByUser.has(c.id)) { session.dmByUser.get(c.id).peerName = c.nickname; } } // Stats mirror build_context() in app.py. let afk = 0; let afkChannel = null; for (const ch of snap.channels) if (ch.name.includes("AFK")) afkChannel = ch; if (afkChannel) afk = Math.max(0, snap.clients.filter((c) => c.channelId === afkChannel.id).length - 1); afk += realClients.filter((c) => c.away).length; const stats = el("div", "server-stats"); stats.append(statItem("Users Connected", realClients.length), statItem("AFK Users", afk)); const list = el("div", "channel-list"); for (const ch of channels) { if (ch.name.includes("[Cspacer]") && !ch.name.includes("AFK")) continue; // spacer const name = displayName(ch.name); const members = byChannel.get(ch.id) || []; const isCurrent = ch.id === snap.self.channelId; const article = el("article", "channel" + (isCurrent ? " is-current" : "")); article.dataset.cid = ch.id; const header = el("header", "channel-header"); const bg = (cfg.channelBanners || {})[name] || (cfg.genericImages || {}).background || ""; if (bg) { header.style.backgroundImage = `linear-gradient(90deg, rgba(9, 18, 32, 0.96) 0%, rgba(9, 18, 32, 0.78) 45%, rgba(9, 18, 32, 0.94) 100%), url("${safeUrl(bg)}")`; } const title = el("div", "channel-title-group"); const icon = (cfg.channelIcons || {})[name] || (cfg.genericImages || {}).icon || ""; if (icon) { const img = el("img", "channel-icon"); img.src = safeUrl(icon); img.alt = ""; img.setAttribute("aria-hidden", "true"); title.append(img); } title.append(el("h2", null, name)); if (ch.hasPassword) title.append(el("span", "channel-lock", "pw")); const actions = el("div", "channel-actions"); actions.append(el("span", "channel-meta", String(members.length))); if (isCurrent) { actions.append(el("span", "channel-here", "joined")); } else { const joinBtn = el("button", "channel-join", "join"); joinBtn.type = "button"; joinBtn.dataset.cid = ch.id; joinBtn.dataset.name = name; joinBtn.dataset.locked = ch.hasPassword ? "1" : ""; joinBtn.setAttribute("aria-label", `Move to ${name}`); actions.append(joinBtn); } header.append(title, actions); const ul = el("ul", "client-list"); for (const c of members) { const li = el("li", "client"); li.dataset.clid = String(c.id); const isSelf = c.id === snap.self.id; if (isSelf) li.classList.add("is-self"); if (session.talking.has(c.id)) li.classList.add("is-talking"); let roleIcon = ""; for (const g of c.serverGroups) if (!roleIcon && cfg.roleIcons && cfg.roleIcons[g]) roleIcon = cfg.roleIcons[g]; if (roleIcon) { const img = el("img", "role-icon"); img.src = safeUrl(roleIcon); img.alt = "Role"; li.append(img); } else { li.append(el("span", "client-no-role-icon")); } li.append(el("span", "client-name", c.nickname)); if (isSelf) li.append(el("span", "client-you", "(you)")); if (c.away) li.append(el("span", "client-afk", c.awayMessage ? `(AFK: ${c.awayMessage})` : "(AFK)")); if (c.outputMuted) li.append(el("span", "client-muted", "(deafened)")); else if (c.inputMuted) li.append(el("span", "client-muted", "(muted)")); if (!isSelf) { const controls = el("div", "client-controls"); const volPct = getUserVolumePercent(c.id, c.nickname); const volWrap = el("label", "client-volume"); volWrap.title = `Volume for ${c.nickname}`; const slider = el("input", "client-volume-slider"); slider.type = "range"; slider.min = "0"; slider.max = "200"; slider.step = "5"; slider.value = String(volPct); slider.dataset.volClid = String(c.id); slider.dataset.volName = c.nickname; slider.setAttribute("aria-label", `Volume for ${c.nickname}`); const volVal = el("span", "client-volume-value", `${volPct}%`); volWrap.append(slider, volVal); const dmEntry = session.dmByUser.get(c.id); const unread = dmEntry ? dmEntry.unread : 0; const isDmOpen = session.activeChatTab && session.activeChatTab.type === "dm" && session.activeChatTab.peerId === c.id; const msgBtn = el( "button", "client-msg-btn" + (isDmOpen ? " is-active" : "") + (unread > 0 ? " has-unread" : ""), unread > 0 ? `msg (${unread})` : "msg" ); msgBtn.type = "button"; msgBtn.dataset.msgClid = String(c.id); msgBtn.dataset.msgName = c.nickname; msgBtn.title = `Message ${c.nickname}`; controls.append(volWrap, msgBtn); li.append(controls); } ul.append(li); } article.append(header, ul); if (isCurrent) { article.append(buildChannelChat(ch.id, name)); } list.append(article); } ui.container.replaceChildren(stats, list); if (restoreChatFocus) { const nextInput = ui.container.querySelector(".channel-chat-input"); if (nextInput) { nextInput.focus(); try { if (selectionStart !== null && selectionEnd !== null) nextInput.setSelectionRange(selectionStart, selectionEnd); } catch { /* ignore */ } } } else if (activeVolClid) { const nextSlider = ui.container.querySelector(`.client-volume-slider[data-vol-clid="${activeVolClid}"]`); if (nextSlider) nextSlider.focus(); } const current = snap.channels.find((c) => c.id === snap.self.channelId); ui.channel.textContent = current ? displayName(current.name) : "-"; const me = snap.clients.find((c) => c.id === snap.self.id); ui.self.textContent = me ? me.nickname : session.nickname; updateTopbarSummary(); } // ---- talking indicators ------------------------------------------------------- function setTalking(clientId, talking) { if (!session) return; const timers = session.talkTimers; clearTimeout(timers.get(clientId)); if (talking) { timers.set(clientId, setTimeout(() => setTalking(clientId, false), TALK_TIMEOUT_MS)); if (session.talking.has(clientId)) return; session.talking.add(clientId); } else { timers.delete(clientId); if (!session.talking.delete(clientId)) return; } for (const li of ui.container.querySelectorAll(`.client[data-clid="${clientId}"]`)) { li.classList.toggle("is-talking", talking); } } // ---- playback (with per-user GainNode) ---------------------------------------- function createPlayback(ctx, output, getInitialGain) { const speakers = new Map(); function stream(clientId) { let s = speakers.get(clientId); if (!s) { const gain = ctx.createGain(); gain.gain.value = getInitialGain ? getInitialGain(clientId) : 1; gain.connect(output); s = { playTime: 0, timestamp: 0, decoder: null, gain }; speakers.set(clientId, s); } return s; } function schedule(s, buffer) { const now = ctx.currentTime; if (s.playTime < now) s.playTime = now + START_LATENCY_S; if (s.playTime > now + MAX_AHEAD_S) return; const src = ctx.createBufferSource(); src.buffer = buffer; src.connect(s.gain); src.onended = () => src.disconnect(); src.start(s.playTime); s.playTime += buffer.duration; } function playPcm(clientId, bytes) { const samples = new Int16Array(bytes.buffer, bytes.byteOffset, bytes.byteLength >> 1); const buffer = ctx.createBuffer(1, samples.length, 48000); const data = buffer.getChannelData(0); for (let i = 0; i < samples.length; i++) data[i] = samples[i] / 32768; schedule(stream(clientId), buffer); } function playOpus(clientId, bytes) { const s = stream(clientId); if (!s.decoder || s.decoder.state === "closed") { s.decoder = new AudioDecoder({ output: (audio) => { try { const n = audio.numberOfFrames; const buffer = ctx.createBuffer(1, n, audio.sampleRate); const tmp = new Float32Array(n); audio.copyTo(tmp, { planeIndex: 0, format: "f32-planar" }); buffer.copyToChannel(tmp, 0); schedule(s, buffer); } catch { /* drop frame */ } finally { audio.close(); } }, error: () => { s.decoder = null; }, }); s.decoder.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 }); s.timestamp = 0; } if (s.decoder.decodeQueueSize > 5) return; s.decoder.decode(new EncodedAudioChunk({ type: "key", timestamp: s.timestamp, duration: 20000, data: bytes })); s.timestamp += 20000; } return { play(kind, clientId, bytes) { try { if (kind === KIND_PCM) playPcm(clientId, bytes); else playOpus(clientId, bytes); } catch { /* ignore broken frames */ } }, setUserVolume(clientId, gainValue) { const s = speakers.get(clientId); if (s && s.gain) s.gain.gain.value = gainValue; }, close() { for (const s of speakers.values()) { try { s.decoder && s.decoder.close(); } catch { /* closed */ } try { s.gain && s.gain.disconnect(); } catch { /* disconnected */ } } speakers.clear(); }, }; } async function browserDecodesOpus() { if (typeof AudioDecoder === "undefined" || typeof EncodedAudioChunk === "undefined") return false; try { const res = await AudioDecoder.isConfigSupported({ codec: "opus", sampleRate: 48000, numberOfChannels: 1 }); return Boolean(res.supported); } catch { return false; } } // ---- microphone --------------------------------------------------------------- function gateOpen(s, rms) { if (s.muted) return false; if (mode === "ptt") return s.pttDown; const now = performance.now(); if (levelFromRms(rms) >= threshold()) s.vadUntil = now + VAD_HANG_MS; return now < s.vadUntil; } function onMicFrame(s, { samples, rms }) { if (session !== s) return; ui.meter.style.width = `${levelFromRms(rms) * 100}%`; const open = s.ready && gateOpen(s, rms); ui.meter.classList.toggle("is-open", open); if (!open) { if (s.wasTalking) { s.wasTalking = false; setTalking(s.selfId, false); } return; } if (s.ws.readyState !== WebSocket.OPEN || s.ws.bufferedAmount > MAX_SEND_BUFFER) return; const pcm = new Int16Array(samples.length); for (let i = 0; i < samples.length; i++) { const v = Math.max(-1, Math.min(1, samples[i])); pcm[i] = v < 0 ? v * 0x8000 : v * 0x7fff; } s.ws.send(pcm.buffer); s.wasTalking = true; setTalking(s.selfId, true); } // ---- session lifecycle -------------------------------------------------------- function setPhase(phase) { document.body.classList.toggle("voice-connecting", phase === "connecting"); document.body.classList.toggle("voice-live", phase === "live"); ui.bar.hidden = phase !== "live"; ui.submit.disabled = phase === "connecting"; } async function startJoin(targetChannel = null) { if (session) return; const nickname = ui.nickname.value.trim(); const password = ui.password.value; if (nickname.length < 3 || !password) { openLoginModal(targetChannel); return; } if (!window.isSecureContext || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { return say("Browser voice needs HTTPS (or localhost) for microphone access.", "error"); } store.set(STORE.nickname, nickname); store.set(STORE.password, password); updateTopbarSummary(); setText(ui.notice, ""); const s = { nickname, target: targetChannel, ws: null, ctx: null, stream: null, worklet: null, playback: null, output: null, ready: false, muted: false, deafened: false, pttDown: false, vadUntil: 0, wasTalking: false, selfId: 0, snapshot: null, talking: new Set(), talkTimers: new Map(), userVolumes: new Map(), chatByChannel: new Map(), dmByUser: new Map(), activeChatTab: { type: "channel" }, chatDrafts: new Map(), closed: false, }; session = s; setPhase("connecting"); say(`Requesting microphone to join ${targetChannel && targetChannel.name ? targetChannel.name : "voice"}…`); try { // The AudioContext must be created inside the click handler's turn. s.ctx = new AudioContext({ latencyHint: "interactive" }); const resume = s.ctx.resume(); s.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, }); await resume; await s.ctx.audioWorklet.addModule("/static/mic-worklet.js"); if (s.closed) return; const source = s.ctx.createMediaStreamSource(s.stream); s.worklet = new AudioWorkletNode(s.ctx, "ts6-mic-capture", { numberOfInputs: 1, numberOfOutputs: 1, outputChannelCount: [1] }); const sink = s.ctx.createGain(); sink.gain.value = 0; source.connect(s.worklet).connect(sink).connect(s.ctx.destination); s.worklet.port.onmessage = (e) => onMicFrame(s, e.data); s.output = s.ctx.createGain(); s.output.gain.value = Number(ui.volume.value) / 100; s.output.connect(s.ctx.destination); s.playback = createPlayback(s.ctx, s.output, (clientId) => { const nick = findClientName(clientId, ""); return getUserVolumePercent(clientId, nick) / 100; }); } catch (error) { if (s.closed) return; const denied = error && (error.name === "NotAllowedError" || error.name === "SecurityError"); teardown(denied ? "Microphone access was denied." : `Could not start audio: ${error && error.message ? error.message : error}`, "error"); return; } const serverDecode = !(await browserDecodesOpus()); if (s.closed) return; say("Connecting to TeamSpeak…"); const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}${cfg.wsPath || "/ws/voice"}`); ws.binaryType = "arraybuffer"; s.ws = ws; let fatalMessage = ""; let fatalCode = ""; ws.onopen = () => { ws.send(JSON.stringify({ type: "join", nickname, password, channelId: s.target && s.target.cid ? s.target.cid : undefined, identity: store.get(STORE.identity) || undefined, serverDecode, })); }; ws.onmessage = (e) => { if (session !== s) return; if (typeof e.data !== "string") { const bytes = new Uint8Array(e.data); if (bytes.length < 3) return; const kind = bytes[0]; const clientId = (bytes[1] << 8) | bytes[2]; const payload = bytes.subarray(3); if (payload.length === 0) return setTalking(clientId, false); setTalking(clientId, true); s.playback.play(kind, clientId, payload); return; } let msg; try { msg = JSON.parse(e.data); } catch { return; } switch (msg.type) { case "joined": s.ready = true; s.selfId = msg.self.id; s.snapshot = msg; if (msg.identity) store.set(STORE.identity, msg.identity); store.set(STORE.nickname, nickname); store.set(STORE.password, password); updateTopbarSummary(); closeLoginModal(); setText(ui.message, ""); setText(ui.dialogMessage, ""); setPhase("live"); setText(ui.barMessage, serverDecode ? "Compatibility audio mode (your browser lacks WebCodecs)." : ""); render(); break; case "state": s.snapshot = msg; s.selfId = msg.self.id; render(); break; case "chatMessage": appendChatMessage(msg); break; case "error": if (msg.fatal) { fatalMessage = msg.message; fatalCode = msg.code || ""; break; } if (msg.code === "CHANNEL_PASSWORD" && msg.channelId) { promptChannelPassword(msg.channelId); break; } say(msg.message, "error"); break; case "disconnected": fatalMessage = msg.reason || "Disconnected."; break; } }; ws.onclose = (e) => { if (session !== s) return; let reason = fatalMessage; if (!reason) { if (s.ready) reason = e.code === 1006 ? "Connection to the voice gateway was lost." : "Disconnected."; else reason = "Voice gateway unreachable, full, or this origin is not allowed."; } const targetBeforeClose = s.target; teardown(reason, s.ready && !fatalMessage ? "info" : "error"); if (fatalCode === "BAD_PASSWORD" || fatalCode === "NICKNAME_INVALID" || fatalCode === "NICKNAME_IN_USE") { openLoginModal(targetBeforeClose, reason); } }; } function teardown(message, kind = "info") { const s = session; if (!s) return; s.closed = true; const wasReady = s.ready; session = null; for (const t of s.talkTimers.values()) clearTimeout(t); if (s.ws) { s.ws.onclose = null; try { if (s.ws.readyState === WebSocket.OPEN) s.ws.send(JSON.stringify({ type: "leave" })); s.ws.close(1000); } catch { /* closed */ } } if (s.stream) for (const track of s.stream.getTracks()) track.stop(); if (s.worklet) s.worklet.port.onmessage = null; if (s.playback) s.playback.close(); if (s.ctx) s.ctx.close().catch(() => undefined); setPhase("idle"); updateTopbarSummary(); ui.meter.style.width = "0%"; ui.settings.hidden = true; ui.settingsToggle.setAttribute("aria-expanded", "false"); setMuteUi(false); setDeafenUi(false); setText(ui.barMessage, ""); if (!wasReady && ui.dialog.open) setText(ui.dialogMessage, message, kind); else if (!wasReady) setText(ui.message, message, kind); else setText(ui.notice, message, kind); if (wasReady && typeof window.refreshDashboard === "function") window.refreshDashboard(); } // ---- channel moves, per-user volume & chat ------------------------------------ function sendJson(message) { if (session && session.ready && session.ws.readyState === WebSocket.OPEN) session.ws.send(JSON.stringify(message)); } function channelName(cid) { const ch = session && session.snapshot && session.snapshot.channels.find((c) => c.id === cid); return ch ? displayName(ch.name) : "this channel"; } function promptChannelPassword(cid) { const password = window.prompt(`Password for ${channelName(cid)}`); if (password === null) return; sendJson({ type: "move", channelId: cid, password }); } ui.container.addEventListener("input", (e) => { const slider = e.target.closest(".client-volume-slider"); if (!slider) return; const clid = Number(slider.dataset.volClid); const name = slider.dataset.volName || ""; const pct = setUserVolumePercent(clid, name, slider.value); const label = slider.parentElement && slider.parentElement.querySelector(".client-volume-value"); if (label) label.textContent = `${pct}%`; }); ui.container.addEventListener("click", (e) => { const msgBtn = e.target.closest(".client-msg-btn"); if (msgBtn) { const clid = Number(msgBtn.dataset.msgClid); const name = msgBtn.dataset.msgName || ""; if (clid) openDmConversation(clid, name); return; } const tabBtn = e.target.closest(".channel-chat-tab"); if (tabBtn && session && session.ready) { if (tabBtn.dataset.chatTab === "dm") { const peerId = Number(tabBtn.dataset.dmPeer); if (peerId) openDmConversation(peerId); } else { session.activeChatTab = { type: "channel" }; render(); const input = ui.container.querySelector(".channel-chat-input"); if (input) input.focus(); } return; } const button = e.target.closest(".channel-join"); if (!button) return; const cid = button.dataset.cid; if (!session) return void startJoin({ cid, name: button.dataset.name || "" }); if (!session.ready) return; setText(ui.barMessage, ""); if (button.dataset.locked) return promptChannelPassword(cid); sendJson({ type: "move", channelId: cid }); }); ui.container.addEventListener("submit", (e) => { const form = e.target.closest(".channel-chat-form"); if (!form || !session || !session.ready) return; e.preventDefault(); const input = form.querySelector(".channel-chat-input"); if (!input) return; const text = input.value.trim(); if (!text) return; const scope = form.dataset.chatScope || "channel"; if (scope === "private") { const targetId = Number(form.dataset.chatTargetId); if (!targetId) return; input.value = ""; session.chatDrafts.set(`dm:${targetId}`, ""); sendJson({ type: "chat", scope: "private", targetId, message: text }); } else { const cid = form.dataset.chatCid; input.value = ""; session.chatDrafts.set(`channel:${cid}`, ""); sendJson({ type: "chat", scope: "channel", message: text }); } }); // ---- controls ----------------------------------------------------------------- function setMuteUi(on) { ui.mute.setAttribute("aria-pressed", String(on)); ui.mute.textContent = on ? "unmute" : "mute"; } function setDeafenUi(on) { ui.deafen.setAttribute("aria-pressed", String(on)); ui.deafen.textContent = on ? "undeafen" : "deafen"; } ui.form.addEventListener("submit", (e) => { e.preventDefault(); const nickname = ui.nickname.value.trim(); const password = ui.password.value; if (nickname.length < 3) return setText(ui.dialogMessage, "Nickname must be 3-30 characters.", "error"); if (!password) return setText(ui.dialogMessage, "The server password is required.", "error"); store.set(STORE.nickname, nickname); store.set(STORE.password, password); updateTopbarSummary(); const targetToJoin = pendingJoin; closeLoginModal(); if (targetToJoin !== undefined && !session) { void startJoin(targetToJoin); } }); ui.leave.addEventListener("click", () => teardown("You left voice.")); ui.mute.addEventListener("click", () => { if (!session) return; session.muted = !session.muted; setMuteUi(session.muted); sendJson({ type: "mute", muted: session.muted }); }); ui.deafen.addEventListener("click", () => { if (!session) return; session.deafened = !session.deafened; setDeafenUi(session.deafened); if (session.output) session.output.gain.value = session.deafened ? 0 : Number(ui.volume.value) / 100; sendJson({ type: "deafen", deafened: session.deafened }); }); ui.settingsToggle.addEventListener("click", () => { const open = ui.settings.hidden; ui.settings.hidden = !open; ui.settingsToggle.setAttribute("aria-expanded", String(open)); }); ui.mode.addEventListener("click", () => { mode = mode === "ptt" ? "vad" : "ptt"; store.set(STORE.mode, mode); applyModeUi(); }); ui.sensitivity.addEventListener("input", () => { store.set(STORE.sensitivity, ui.sensitivity.value); updateThresholdMark(); }); ui.volume.addEventListener("input", () => { store.set(STORE.volume, ui.volume.value); if (session && session.output && !session.deafened) session.output.gain.value = Number(ui.volume.value) / 100; }); const setPtt = (down) => { if (!session) return; session.pttDown = down; ui.ptt.classList.toggle("is-active", down); }; ui.ptt.addEventListener("pointerdown", (e) => { e.preventDefault(); setPtt(true); }); for (const ev of ["pointerup", "pointerleave", "pointercancel"]) ui.ptt.addEventListener(ev, () => setPtt(false)); const typingTarget = (t) => t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)); document.addEventListener("keydown", (e) => { if (e.code === PTT_KEY && mode === "ptt" && session && !e.repeat && !typingTarget(e.target)) setPtt(true); }); document.addEventListener("keyup", (e) => { if (e.code === PTT_KEY) setPtt(false); }); window.addEventListener("blur", () => setPtt(false)); window.addEventListener("pagehide", () => { if (session) teardown(""); }); })();