// 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"), wasmOpenBtn: $("voice-wasm-open-btn"), wasmDialog: $("voice-wasm-dialog"), wasmClose: $("voice-wasm-close"), wasmMessage: $("voice-wasm-message"), rnnoiseToggle: $("voice-rnnoise-toggle"), wasmRedownload: $("voice-wasm-redownload"), wasmDelete: $("voice-wasm-delete"), wasmStatus: $("voice-wasm-status"), wasmActive: $("voice-wasm-active"), 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", rnnoise: "ts6voice.rnnoise", 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; const MIC_WORKLET_VERSION = "rnnoise-2"; 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(); }); if (ui.wasmOpenBtn && ui.wasmDialog) { ui.wasmOpenBtn.addEventListener("click", () => { setText(ui.wasmMessage, ""); updateRnnoiseActiveUi(session); if (!ui.wasmDialog.open) ui.wasmDialog.showModal(); }); if (ui.wasmClose) { ui.wasmClose.addEventListener("click", () => { if (ui.wasmDialog.open) ui.wasmDialog.close(); }); } ui.wasmDialog.addEventListener("click", (e) => { if (e.target === ui.wasmDialog) ui.wasmDialog.close(); }); } // ---- 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"; let rnnoiseEnabled = store.get(STORE.rnnoise) !== "0"; 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"; } function applyRnnoiseUi() { const label = rnnoiseEnabled ? "rnnoise: on" : "rnnoise: off"; for (const btn of [ui.wasmOpenBtn, ui.rnnoiseToggle]) { if (!btn) continue; btn.setAttribute("aria-pressed", String(rnnoiseEnabled)); btn.textContent = label; } } updateThresholdMark(); applyModeUi(); applyRnnoiseUi(); // ---- 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 & RNNoise (WASM) ---------------------------------------------- const RNNOISE_WASM_URLS = [ "https://cdn.jsdelivr.net/npm/@jitsi/rnnoise-wasm@0.2.1/dist/rnnoise.wasm", "https://unpkg.com/@jitsi/rnnoise-wasm@0.2.1/dist/rnnoise.wasm", ]; const WASM_CACHE_NAME = "ts6voice-rnnoise-v1"; const WASM_CACHE_KEY = "https://ts6voice.local/rnnoise.wasm"; const RNNOISE_FRAME_SAMPLES = 480; const PCM16_SCALE = 32768; let rnnoiseWasmBuffer = null; let rnnoiseWasmPromise = null; function setWasmStatus(text, state = "idle") { if (!ui.wasmStatus) return; ui.wasmStatus.textContent = text; ui.wasmStatus.dataset.state = state; } function setWasmButtonsBusy(busy) { for (const btn of [ui.wasmRedownload, ui.wasmDelete]) { if (btn) btn.disabled = busy; } } function sayWasm(text, kind = "info") { if (ui.wasmDialog && ui.wasmDialog.open) setText(ui.wasmMessage, text, kind); else say(text, kind); } function formatKb(byteLength) { return `${Math.max(1, Math.round(byteLength / 1024))} KB`; } async function readWasmFromCache() { if (typeof caches === "undefined") return null; try { const cache = await caches.open(WASM_CACHE_NAME); const match = await cache.match(WASM_CACHE_KEY); if (!match) return null; const buf = await match.arrayBuffer(); return buf && buf.byteLength > 0 ? buf : null; } catch { return null; } } async function writeWasmToCache(buf) { if (typeof caches === "undefined" || !buf) return; try { const cache = await caches.open(WASM_CACHE_NAME); await cache.put( WASM_CACHE_KEY, new Response(buf.slice(0), { headers: { "content-type": "application/wasm" } }) ); } catch { /* ignore quota or private mode errors */ } } async function clearWasmCache() { if (typeof caches === "undefined") return; try { await caches.delete(WASM_CACHE_NAME); } catch { /* ignore */ } } function fetchRnnoiseWasm({ forceReload = false } = {}) { if (!forceReload && rnnoiseWasmBuffer) { return Promise.resolve(rnnoiseWasmBuffer); } if (!forceReload && rnnoiseWasmPromise) { return rnnoiseWasmPromise; } setWasmButtonsBusy(true); setWasmStatus("downloading...", "loading"); rnnoiseWasmPromise = (async () => { if (!forceReload) { const cached = await readWasmFromCache(); if (cached) { rnnoiseWasmBuffer = cached; setWasmStatus(`cached (${formatKb(cached.byteLength)})`, "cached"); return cached; } } else { await clearWasmCache(); } let lastError = null; for (const url of RNNOISE_WASM_URLS) { try { const res = await fetch(url, { mode: "cors", credentials: "omit", cache: forceReload ? "reload" : "default", }); if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`); const buf = await res.arrayBuffer(); if (!buf || buf.byteLength === 0) throw new Error("Empty WASM response"); rnnoiseWasmBuffer = buf; await writeWasmToCache(buf); setWasmStatus(`cached (${formatKb(buf.byteLength)})`, "cached"); return buf; } catch (err) { lastError = err; } } throw lastError || new Error("Failed to download rnnoise.wasm"); })() .catch((err) => { rnnoiseWasmBuffer = null; rnnoiseWasmPromise = null; setWasmStatus("download failed", "error"); throw err; }) .finally(() => { setWasmButtonsBusy(false); }); return rnnoiseWasmPromise; } async function deleteRnnoiseWasm() { setWasmButtonsBusy(true); try { await clearWasmCache(); rnnoiseWasmBuffer = null; rnnoiseWasmPromise = null; // Keep the user's on/off preference; with no model cached, RNNoise simply // stays inactive until the model is downloaded again. if (session) { session.rnnoiseLoaded = false; session.rnnoiseWorkletReady = false; if (session.rnnoiseFallback) { session.rnnoiseFallback.destroy(); session.rnnoiseFallback = null; } if (session.worklet) { try { session.worklet.port.postMessage({ type: "rnnoise-clear" }); } catch { /* ignore */ } } applyBrowserNoiseSuppression(session); updateRnnoiseActiveUi(session); } setWasmStatus("deleted (not cached)", "idle"); sayWasm("RNNoise WASM model deleted from browser storage.", "info"); } finally { setWasmButtonsBusy(false); } } async function redownloadRnnoiseWasm() { try { setText(ui.wasmMessage, ""); rnnoiseWasmBuffer = null; rnnoiseWasmPromise = null; const wasmBinary = await fetchRnnoiseWasm({ forceReload: true }); if (session && !session.closed) { session.rnnoiseLoaded = true; if (session.worklet) { session.worklet.port.postMessage({ type: "rnnoise-init", wasmBinary: wasmBinary.slice(0), enabled: rnnoiseEnabled, }); } const nextFallback = await createMainThreadRnnoise(wasmBinary); if (session && !session.closed) { if (session.rnnoiseFallback) session.rnnoiseFallback.destroy(); session.rnnoiseFallback = nextFallback; applyBrowserNoiseSuppression(session); } else { nextFallback.destroy(); } } sayWasm("RNNoise WASM model downloaded and ready.", "info"); } catch (err) { sayWasm(`Failed to re-download RNNoise WASM: ${err && err.message ? err.message : err}`, "error"); } } void readWasmFromCache().then((cached) => { if (cached) { rnnoiseWasmBuffer = cached; setWasmStatus(`cached (${formatKb(cached.byteLength)})`, "cached"); } else { setWasmStatus("not cached", "idle"); } }); async function createMainThreadRnnoise(wasmBinary) { let memory = null; const resizeHeap = (requestedSize) => { if (!memory) return 0; const pages = ((requestedSize >>> 0) - memory.buffer.byteLength + 65535) >>> 16; try { memory.grow(pages); return 1; } catch { return 0; } }; const memcpyBig = (dest, src, num) => { if (!memory) return; new Uint8Array(memory.buffer).copyWithin(dest >>> 0, src >>> 0, (src + num) >>> 0); }; const res = await WebAssembly.instantiate(wasmBinary, { a: { a: resizeHeap, b: memcpyBig }, env: { emscripten_resize_heap: resizeHeap, emscripten_memcpy_big: memcpyBig, __assert_fail: () => { throw new Error("RNNoise WASM assertion failed"); }, }, }); const inst = res.instance || res; const exp = inst.exports; memory = exp.memory || exp.c; if (typeof exp.emscripten_stack_init === "function") exp.emscripten_stack_init(); const initRuntime = exp.__wasm_call_ctors || exp.d; if (typeof initRuntime === "function") initRuntime(); const rnnoiseInit = exp.rnnoise_init || exp._rnnoise_init || exp.e; const rnnoiseCreate = exp.rnnoise_create || exp._rnnoise_create || exp.f; const malloc = exp.malloc || exp._malloc || exp.g; const rnnoiseDestroy = exp.rnnoise_destroy || exp._rnnoise_destroy || exp.h; const free = exp.free || exp._free || exp.i; const rnnoiseProcessFrame = exp.rnnoise_process_frame || exp._rnnoise_process_frame || exp.j; const state = rnnoiseCreate(0); const ptrIn = malloc(RNNOISE_FRAME_SAMPLES * 4); const ptrOut = malloc(RNNOISE_FRAME_SAMPLES * 4); if (!state || !ptrIn || !ptrOut) { if (state) rnnoiseDestroy(state); if (ptrIn) free(ptrIn); if (ptrOut) free(ptrOut); throw new Error("Failed to allocate RNNoise WASM buffers"); } let heapF32 = new Float32Array(memory.buffer); let destroyed = false; const processSubFrame = (samples, offset) => { if (heapF32.buffer !== memory.buffer) heapF32 = new Float32Array(memory.buffer); const inBase = ptrIn >> 2; const outBase = ptrOut >> 2; for (let i = 0; i < RNNOISE_FRAME_SAMPLES; i++) { heapF32[inBase + i] = samples[offset + i] * PCM16_SCALE; } rnnoiseProcessFrame(state, ptrOut, ptrIn); if (heapF32.buffer !== memory.buffer) heapF32 = new Float32Array(memory.buffer); for (let i = 0; i < RNNOISE_FRAME_SAMPLES; i++) { const v = heapF32[outBase + i] / PCM16_SCALE; samples[offset + i] = v < -1 ? -1 : v > 1 ? 1 : v; } }; return { processFrame960(samples) { if (destroyed || samples.length < RNNOISE_FRAME_SAMPLES * 2) return; processSubFrame(samples, 0); processSubFrame(samples, RNNOISE_FRAME_SAMPLES); }, reset() { if (!destroyed && typeof rnnoiseInit === "function") rnnoiseInit(state, 0); }, destroy() { if (destroyed) return; destroyed = true; try { rnnoiseDestroy(state); } catch { /* ignore */ } try { free(ptrIn); } catch { /* ignore */ } try { free(ptrOut); } catch { /* ignore */ } }, }; } async function ensureSessionRnnoise(s) { if (!s || s.closed || s.rnnoiseLoading || s.rnnoiseLoaded) return; s.rnnoiseLoading = true; try { const wasmBinary = await fetchRnnoiseWasm(); if (s.closed || session !== s) return; s.rnnoiseLoaded = true; if (s.worklet) { s.worklet.port.postMessage({ type: "rnnoise-init", wasmBinary: wasmBinary.slice(0), enabled: rnnoiseEnabled, }); } s.rnnoiseFallback = await createMainThreadRnnoise(wasmBinary); if (s.closed || session !== s) { s.rnnoiseFallback.destroy(); s.rnnoiseFallback = null; } else { applyBrowserNoiseSuppression(s); } } catch (err) { if (session === s && rnnoiseEnabled) { say(`RNNoise WASM failed to load: ${err && err.message ? err.message : err}`, "error"); } } finally { s.rnnoiseLoading = false; } } 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; } /** True once a working RNNoise instance exists (worklet or main thread). */ function rnnoiseAvailable(s) { return Boolean(s && (s.rnnoiseWorkletReady || s.rnnoiseFallback)); } /** * The browser's built-in noise suppression runs before our pipeline and * already removes most steady background noise, which makes RNNoise look * like it does nothing. Turn it off while RNNoise is actually processing, * and back on otherwise so users are never left without any suppression. */ function applyBrowserNoiseSuppression(s) { if (!s || !s.stream) return; const wantBrowserNs = !(rnnoiseEnabled && rnnoiseAvailable(s)); if (s.browserNs === wantBrowserNs) return; s.browserNs = wantBrowserNs; for (const track of s.stream.getAudioTracks()) { track.applyConstraints({ noiseSuppression: wantBrowserNs }).catch(() => undefined); } } function updateRnnoiseActiveUi(s) { if (!ui.wasmActive) return; let text = "not in a call"; let state = "idle"; if (s && !s.closed) { if (!rnnoiseEnabled) { text = "off (browser noise suppression)"; } else if (s.rnnoiseVia) { text = `active (${s.rnnoiseVia})`; state = "cached"; } else if (s.rnnoiseError) { text = `failed: ${s.rnnoiseError}`; state = "error"; } else { text = "waiting for model..."; state = "loading"; } } if (ui.wasmActive.textContent !== text) ui.wasmActive.textContent = text; ui.wasmActive.dataset.state = state; } function onMicMessage(s, data) { if (session !== s || !data) return; if (data.type === "rnnoise-status") { s.rnnoiseWorkletReady = Boolean(data.ready); if (data.error) { s.rnnoiseError = data.error; console.warn("[rnnoise] worklet init failed, using main-thread fallback:", data.error); } applyBrowserNoiseSuppression(s); updateRnnoiseActiveUi(s); return; } if (!data.samples) return; const samples = data.samples; let rms = data.rms; let via = data.denoised ? "audio worklet" : ""; // If RNNoise is enabled and the worklet did not denoise this frame yet, // run the main-thread WASM denoiser before metering, VAD gating, and gateway send. if (rnnoiseEnabled && !data.denoised && s.rnnoiseFallback) { s.rnnoiseFallback.processFrame960(samples); let sum = 0; for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i]; rms = Math.sqrt(sum / samples.length); via = "main thread"; } if (s.rnnoiseVia !== via) { s.rnnoiseVia = via; updateRnnoiseActiveUi(s); } 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, rnnoiseLoaded: false, rnnoiseLoading: false, rnnoiseWorkletReady: false, rnnoiseFallback: null, 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(); const wasmPreload = rnnoiseEnabled ? fetchRnnoiseWasm().catch(() => null) : Promise.resolve(null); // Browser NS starts on; applyBrowserNoiseSuppression() turns it off once // RNNoise is confirmed to be running. s.stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }, }); s.browserNs = true; await resume; // Versioned URL so browsers never run a stale cached worklet without RNNoise support. await s.ctx.audioWorklet.addModule(`/static/mic-worklet.js?v=${MIC_WORKLET_VERSION}`); const wasmBinary = await wasmPreload; 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], processorOptions: { rnnoiseEnabled, wasmBinary: wasmBinary ? wasmBinary.slice(0) : null, }, }); if (wasmBinary) { s.rnnoiseLoaded = true; createMainThreadRnnoise(wasmBinary) .then((fallback) => { if (s.closed || session !== s) fallback.destroy(); else { s.rnnoiseFallback = fallback; applyBrowserNoiseSuppression(s); updateRnnoiseActiveUi(s); } }) .catch((err) => { s.rnnoiseError = err && err.message ? err.message : String(err); updateRnnoiseActiveUi(s); }); } else if (rnnoiseEnabled) { void ensureSessionRnnoise(s); } const sink = s.ctx.createGain(); sink.gain.value = 0; source.connect(s.worklet).connect(sink).connect(s.ctx.destination); s.worklet.port.onmessage = (e) => onMicMessage(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) { try { s.worklet.port.postMessage({ type: "dispose" }); } catch { /* ignore */ } s.worklet.port.onmessage = null; } if (s.rnnoiseFallback) { s.rnnoiseFallback.destroy(); s.rnnoiseFallback = null; } updateRnnoiseActiveUi(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(); }); function toggleRnnoise() { rnnoiseEnabled = !rnnoiseEnabled; store.set(STORE.rnnoise, rnnoiseEnabled ? "1" : "0"); applyRnnoiseUi(); if (session) { if (rnnoiseEnabled && !session.rnnoiseLoaded) { void ensureSessionRnnoise(session); } if (rnnoiseEnabled && session.rnnoiseFallback) { session.rnnoiseFallback.reset(); } if (session.worklet) { try { session.worklet.port.postMessage({ type: "rnnoise-enable", enabled: rnnoiseEnabled }); } catch { /* ignore */ } } applyBrowserNoiseSuppression(session); updateRnnoiseActiveUi(session); } } if (ui.rnnoiseToggle) ui.rnnoiseToggle.addEventListener("click", toggleRnnoise); if (ui.wasmRedownload) ui.wasmRedownload.addEventListener("click", () => void redownloadRnnoiseWasm()); if (ui.wasmDelete) ui.wasmDelete.addEventListener("click", () => void deleteRnnoiseWasm()); 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(""); }); })();