feat: replace client WebRTC with LiveKit SDK (Phase 2)

Client changes:
- Create livekitSession.ts (~400 lines, replaces 1105-line
  voiceSession.ts): Room lifecycle, device switching via
  switchActiveDevice, RNNoise pre-processing for enhanced NS
- types.ts: add VoiceTokenPayload, remove VoiceOffer/Answer/Ice
  payloads, remove ThresholdMode
- dispatcher.ts: replace voice_offer/answer/ice handlers with
  single voice_token handler
- VoiceCallbacks.ts: swap imports to livekitSession
- VoiceAudioTab.ts: remove silence suppression toggle, inline
  threshold math (removed vad.ts dependency)
- Update all files importing from deleted modules

Deleted files (11 source + 5 test):
- webrtc.ts, vad.ts, voiceSession.ts, audio.ts, video.ts,
  Soundboard.ts + their test files

Kept: noise-suppression.ts + @jitsi/rnnoise-wasm (Krisp is
LiveKit Cloud only, not available for self-hosted)

Added: livekit-client@^2.17.3

TypeScript compiles with zero errors (tsc --noEmit).
This commit is contained in:
jevb
2026-03-20 05:46:19 +01:00
parent fed85a0d3a
commit aa162d88c5
31 changed files with 781 additions and 3435 deletions
+136 -3
View File
@@ -1,12 +1,12 @@
{
"name": "owncord-client",
"version": "0.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "owncord-client",
"version": "0.1.0",
"version": "1.2.0",
"dependencies": {
"@jitsi/rnnoise-wasm": "^0.2.1",
"@tauri-apps/api": "^2.10.1",
@@ -18,7 +18,8 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0"
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.17.3"
},
"devDependencies": {
"@playwright/test": "^1",
@@ -178,6 +179,12 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.1.tgz",
"integrity": "sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
@@ -850,6 +857,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@livekit/mutex": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz",
"integrity": "sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==",
"license": "Apache-2.0"
},
"node_modules/@livekit/protocol": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.44.0.tgz",
"integrity": "sha512-/vfhDUGcUKO8Q43r6i+5FrDhl5oZjm/X3U4x2Iciqvgn5C8qbj+57YPcWSJ1kyIZm5Cm6AV2nAPjMm3ETD/iyg==",
"license": "Apache-2.0",
"dependencies": {
"@bufbuild/protobuf": "^1.10.0"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1553,6 +1575,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/dom-mediacapture-record": {
"version": "1.0.22",
"resolved": "https://registry.npmjs.org/@types/dom-mediacapture-record/-/dom-mediacapture-record-1.0.22.tgz",
"integrity": "sha512-mUMZLK3NvwRLcAAT9qmcK+9p7tpU2FHdDsntR3YI4+GY88XrgG4XiE7u1Q2LAN2/FZOz/tdMDC3GQCR4T8nFuw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -2011,6 +2040,15 @@
"@types/estree": "^1.0.0"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
@@ -2250,6 +2288,15 @@
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/js-tokens": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz",
@@ -2308,6 +2355,39 @@
"node": "20 || >=22"
}
},
"node_modules/livekit-client": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.3.tgz",
"integrity": "sha512-htwsAL/BMylY/zwdcT/z00U789csbi9DldSW7DO+5tz7Q15pwu++E1X+ZdtZDfkmlysfQLLibdcqlyg9FY7veQ==",
"license": "Apache-2.0",
"dependencies": {
"@livekit/mutex": "1.1.1",
"@livekit/protocol": "1.44.0",
"events": "^3.3.0",
"jose": "^6.1.0",
"loglevel": "^1.9.2",
"sdp-transform": "^2.15.0",
"tslib": "2.8.1",
"typed-emitter": "^2.1.0",
"webrtc-adapter": "^9.0.1"
},
"peerDependencies": {
"@types/dom-mediacapture-record": "^1"
}
},
"node_modules/loglevel": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz",
"integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
},
"funding": {
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/loglevel"
}
},
"node_modules/loupe": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz",
@@ -2644,6 +2724,16 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rxjs": {
"version": "7.8.2",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz",
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
@@ -2657,6 +2747,21 @@
"node": ">=v12.22.7"
}
},
"node_modules/sdp": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz",
"integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==",
"license": "MIT"
},
"node_modules/sdp-transform": {
"version": "2.15.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.15.0.tgz",
"integrity": "sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==",
"license": "MIT",
"bin": {
"sdp-verify": "checker.js"
}
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
@@ -3003,6 +3108,21 @@
"node": ">=20"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typed-emitter": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz",
"integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==",
"license": "MIT",
"optionalDependencies": {
"rxjs": "*"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3221,6 +3341,19 @@
"node": ">=20"
}
},
"node_modules/webrtc-adapter": {
"version": "9.0.4",
"resolved": "https://registry.npmjs.org/webrtc-adapter/-/webrtc-adapter-9.0.4.tgz",
"integrity": "sha512-5ZZY1+lGq8LEKuDlg9M2RPJHlH3R7OVwyHqMcUsLKCgd9Wvf+QrFTCItkXXYPmrJn8H6gRLXbSgxLLdexiqHxw==",
"license": "BSD-3-Clause",
"dependencies": {
"sdp": "^3.2.0"
},
"engines": {
"node": ">=6.0.0",
"npm": ">=3.10.0"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+2 -1
View File
@@ -38,6 +38,7 @@
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0"
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.17.3"
}
}
@@ -26,7 +26,7 @@ import {
isCategoryCollapsed,
} from "@stores/ui.store";
import { voiceStore, getChannelVoiceUsers } from "@stores/voice.store";
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
// ---------------------------------------------------------------------------
// Per-user volume context menu (right-click on voice user row)
@@ -1,117 +0,0 @@
/**
* Step 6.53 — Soundboard component.
* Grid of sound buttons with cooldown enforcement (1 play per 3s).
*/
import { createElement, appendChildren, setText, clearChildren } from "@lib/dom";
import type { MountableComponent } from "@lib/safe-render";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface SoundItem {
readonly id: number;
readonly name: string;
readonly durationMs: number;
}
export interface SoundboardOptions {
readonly sounds: readonly SoundItem[];
readonly onPlaySound: (soundId: number) => void;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const COOLDOWN_MS = 3_000;
function formatDuration(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export function createSoundboard(options: SoundboardOptions): MountableComponent {
let root: HTMLDivElement | null = null;
let cooldownTimer: ReturnType<typeof setTimeout> | null = null;
const ac = new AbortController();
function mount(container: Element): void {
root = createElement("div", { class: "soundboard" });
if (options.sounds.length === 0) {
const empty = createElement(
"div",
{ class: "soundboard__empty" },
"No sounds available",
);
root.appendChild(empty);
container.appendChild(root);
return;
}
const grid = createElement("div", { class: "soundboard__grid" });
const buttons: HTMLButtonElement[] = [];
for (const sound of options.sounds) {
const btn = createElement("button", { class: "sound-btn", type: "button" });
const nameSpan = createElement("span", { class: "sound-btn__name" }, sound.name);
const durSpan = createElement(
"span",
{ class: "sound-btn__duration" },
formatDuration(sound.durationMs),
);
appendChildren(btn, nameSpan, durSpan);
btn.addEventListener("click", () => {
if (btn.disabled) return;
options.onPlaySound(sound.id);
startCooldown(buttons);
}, { signal: ac.signal });
buttons.push(btn);
grid.appendChild(btn);
}
root.appendChild(grid);
container.appendChild(root);
}
function startCooldown(buttons: readonly HTMLButtonElement[]): void {
for (const btn of buttons) {
btn.disabled = true;
btn.classList.add("sound-btn--cooldown");
}
if (cooldownTimer !== null) {
clearTimeout(cooldownTimer);
}
cooldownTimer = setTimeout(() => {
cooldownTimer = null;
for (const btn of buttons) {
btn.disabled = false;
btn.classList.remove("sound-btn--cooldown");
}
}, COOLDOWN_MS);
}
function destroy(): void {
ac.abort();
if (cooldownTimer !== null) {
clearTimeout(cooldownTimer);
cooldownTimer = null;
}
if (root !== null) {
root.remove();
root = null;
}
}
return { mount, destroy };
}
@@ -8,7 +8,7 @@ import { createElement, appendChildren, clearChildren, setText } from "@lib/dom"
import { voiceStore } from "@stores/voice.store";
import type { VoiceUser } from "@stores/voice.store";
import { membersStore } from "@stores/members.store";
import { setUserVolume, getUserVolume } from "@lib/voiceSession";
import { setUserVolume, getUserVolume } from "@lib/livekitSession";
import { authStore } from "@stores/auth.store";
export interface VoiceChannelOptions {
@@ -6,7 +6,7 @@ import { createElement, appendChildren, clearChildren } from "@lib/dom";
import { getLogBuffer, clearLogBuffer, addLogListener, setLogLevel } from "@lib/logger";
import type { LogEntry, LogLevel } from "@lib/logger";
import type { TabName } from "../SettingsOverlay";
import { getSessionDebugInfo, measureStreamLevel, getRemoteStreams, getLocalProcessedStream } from "@lib/voiceSession";
import { getSessionDebugInfo } from "@lib/livekitSession";
// ---------------------------------------------------------------------------
// Constants
@@ -175,53 +175,7 @@ export function createLogsTab(
function refreshDiag(): void {
const info = getSessionDebugInfo();
const ctx = info.sharedAudioCtx as { state: string; sampleRate: number } | null;
const localTracks = info.localTracks as Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
const remoteEls = info.remoteAudioElements as Array<{
streamId: string; userId: number; audioPaused: boolean; audioMuted: boolean;
audioVolume: number; audioReadyState: number; hasSrcObject: boolean;
gainValue: number | string;
tracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
}>;
const webrtcStreams = info.webrtcRemoteStreams as Array<{
streamId: string; trackCount: number;
audioTracks: Array<{ id: string; enabled: boolean; muted: boolean; readyState: string }>;
}>;
const lines: string[] = [
`=== Session ===`,
`WebRTC: ${info.hasWebrtc} VAD: ${info.hasVad} Suppressor: ${info.hasNoiseSuppressor}`,
`Join in progress: ${info.joinInProgress} Silence suppression: ${info.silenceSuppressionEnabled}`,
`SharedAudioCtx: ${ctx ? `${ctx.state} @ ${ctx.sampleRate}Hz` : "none"}`,
``,
`=== Local Audio ===`,
`Stream: ${info.hasLocalStream} Processed: ${info.hasProcessedStream}`,
];
for (const t of localTracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
lines.push(``, `=== WebRTC Remote Streams ===`);
if (webrtcStreams.length === 0) lines.push(` (none)`);
for (const s of webrtcStreams) {
lines.push(` Stream ${s.streamId}: ${s.trackCount} tracks`);
for (const t of s.audioTracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
}
lines.push(``, `=== Remote Audio Elements ===`);
if (remoteEls.length === 0) lines.push(` (none)`);
for (const el of remoteEls) {
lines.push(` [user ${el.userId}] stream=${el.streamId}`);
lines.push(` <audio> paused=${el.audioPaused} muted=${el.audioMuted} volume=${el.audioVolume} readyState=${el.audioReadyState} srcObject=${el.hasSrcObject}`);
lines.push(` GainNode: ${typeof el.gainValue === "number" ? el.gainValue.toFixed(2) : el.gainValue}`);
for (const t of el.tracks) {
lines.push(` Track ${t.id.slice(0, 8)}: enabled=${t.enabled} muted=${t.muted} state=${t.readyState}`);
}
}
diagPanel.textContent = lines.join("\n");
diagPanel.textContent = JSON.stringify(info, null, 2);
}
refreshDiag();
@@ -236,100 +190,10 @@ export function createLogsTab(
});
}, { signal });
// Live audio level probe — measures actual signal flowing through streams
const levelBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Probe Audio Levels");
const levelResult = createElement("pre", {
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
});
levelBtn.addEventListener("click", () => {
levelBtn.textContent = "Probing...";
levelResult.textContent = "";
const info = getSessionDebugInfo();
const promises: Array<Promise<string>> = [];
// 1. Probe local mic (what we're sending)
const localStream = getLocalProcessedStream();
if (localStream) {
promises.push(
measureStreamLevel(localStream).then((lvl) => `Local mic (outgoing): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
} else {
promises.push(Promise.resolve("Local mic: no stream"));
}
// 2. Probe raw WebRTC remote streams (before GainNode)
const rawRemoteStreams = getRemoteStreams();
rawRemoteStreams.forEach((s, i) => {
promises.push(
measureStreamLevel(s).then((lvl) => `Remote [${i}] (raw WebRTC ${s.id}): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
});
// 3. Probe GainNode output (what <audio> element plays)
const audioContainer = document.getElementById("voice-audio-container");
const audioEls = audioContainer?.querySelectorAll("audio") ?? [];
audioEls.forEach((el, i) => {
const a = el as HTMLAudioElement;
const src = a.srcObject as MediaStream | null;
if (src) {
promises.push(
measureStreamLevel(src).then((lvl) => `Remote [${i}] (GainNode output): level=${lvl} ${lvl > 0 ? "✅ AUDIO FLOWING" : "❌ SILENCE"}`),
);
}
});
if (promises.length === 0) {
levelResult.textContent = "No audio streams to probe";
levelBtn.textContent = "Probe Audio Levels";
return;
}
void Promise.all(promises).then((results) => {
levelResult.textContent = results.join("\n");
levelBtn.textContent = "Probe Audio Levels";
});
}, { signal });
section.appendChild(diagPanel);
const diagBtns = createElement("div", { style: "display: flex; flex-wrap: wrap;" });
appendChildren(diagBtns, diagRefresh, diagCopy, levelBtn);
appendChildren(diagBtns, diagRefresh, diagCopy);
section.appendChild(diagBtns);
section.appendChild(levelResult);
// Direct playback test — bypasses GainNode pipeline entirely
const directBtn = createElement("button", { class: "ac-btn", style: "margin: 6px 0 0 6px;" }, "Test Direct Playback");
const directResult = createElement("pre", {
style: "margin: 6px 0 0 0; color: #ccc; font-family: monospace; font-size: 12px; white-space: pre-wrap;",
});
directBtn.addEventListener("click", () => {
const rawStreams = getRemoteStreams();
if (rawStreams.length === 0) {
directResult.textContent = "No remote streams to test";
return;
}
const lines: string[] = [];
for (const s of rawStreams) {
const testAudio = document.createElement("audio");
testAudio.srcObject = s;
testAudio.autoplay = true;
testAudio.volume = 1.0;
document.body.appendChild(testAudio);
testAudio.play().then(() => {
lines.push(`Stream ${s.id}: play() succeeded, paused=${testAudio.paused}, readyState=${testAudio.readyState}`);
lines.push(` tracks: ${s.getAudioTracks().map((t) => `${t.id.slice(0,8)} enabled=${t.enabled} muted=${t.muted} readyState=${t.readyState}`).join(", ")}`);
directResult.textContent = lines.join("\n") + "\n\nDirect <audio> element added — can you hear audio now? (playing raw WebRTC stream, no GainNode)";
// Clean up after 10 seconds
setTimeout(() => { testAudio.srcObject = null; testAudio.remove(); }, 10000);
}).catch((err) => {
lines.push(`Stream ${s.id}: play() FAILED — ${err instanceof Error ? err.message : String(err)}`);
directResult.textContent = lines.join("\n");
testAudio.remove();
});
}
}, { signal });
diagBtns.appendChild(directBtn);
section.appendChild(directResult);
// Log count
const countEl = createElement("div", {
@@ -4,8 +4,7 @@
import { createElement, appendChildren, setText } from "@lib/dom";
import { loadPref, savePref, createToggle } from "./helpers";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity, updateSilenceSuppressionPref } from "@lib/voiceSession";
import { sensitivityToThreshold } from "@lib/vad";
import { switchInputDevice, switchOutputDevice, setVoiceSensitivity } from "@lib/livekitSession";
export interface VoiceAudioTabHandle {
build(): HTMLDivElement;
@@ -237,7 +236,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
// Position threshold indicator
function updateThresholdIndicator(sensitivity: number): void {
const threshold = sensitivityToThreshold(sensitivity);
const threshold = ((100 - sensitivity) / 100) * 0.15;
// Map threshold (0-0.15) to percentage position (0-100%)
const pct = Math.min((threshold / 0.15) * 100, 100);
meterThreshold.style.left = `${pct}%`;
@@ -287,7 +286,7 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
meterLevel.style.width = `${visual * 100}%`;
// Color: green if above threshold, yellow/red if below
const threshold = sensitivityToThreshold(Number(sensitivitySlider.value));
const threshold = ((100 - Number(sensitivitySlider.value)) / 100) * 0.15;
if (rms >= threshold) {
meterLevel.style.background = "#43b581"; // green — voice detected
} else {
@@ -310,7 +309,6 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
{ key: "noiseSuppression", label: "Noise Suppression", desc: "Filter out background noise from your microphone", fallback: true },
{ key: "autoGainControl", label: "Automatic Gain Control", desc: "Automatically adjust microphone volume", fallback: true },
{ key: "enhancedNoiseSuppression", label: "Enhanced Noise Suppression", desc: "ML-powered noise removal (RNNoise) — filters keyboard, pets, and other non-voice sounds", fallback: false },
{ key: "silenceSuppression", label: "Silence Suppression", desc: "Stop sending audio during silence to save bandwidth", fallback: true },
];
for (const item of audioToggles) {
@@ -325,12 +323,8 @@ function buildVoiceAudioTabInner(signal: AbortSignal, registerMic: MicRegistrar,
signal,
onChange: (nowOn) => {
savePref(item.key, nowOn);
if (item.key === "silenceSuppression") {
updateSilenceSuppressionPref();
} else {
const currentDevice = loadPref<string>("audioInputDevice", "");
void switchInputDevice(currentDevice);
}
const currentDevice = loadPref<string>("audioInputDevice", "");
void switchInputDevice(currentDevice);
},
});
-155
View File
@@ -1,155 +0,0 @@
// =============================================================================
// Audio Device Manager — enumerate devices, acquire streams, set output
// =============================================================================
import { loadPref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
const log = createLogger("audio");
export interface AudioDevice {
readonly deviceId: string;
readonly label: string;
readonly kind: "audioinput" | "audiooutput";
}
export interface AudioManager {
enumerateDevices(): Promise<readonly AudioDevice[]>;
getUserMedia(deviceId?: string): Promise<MediaStream>;
setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void>;
getInputDeviceId(): string | null;
getOutputDeviceId(): string | null;
onDeviceChange(callback: (devices: readonly AudioDevice[]) => void): () => void;
destroy(): void;
}
type DeviceChangeCallback = (devices: readonly AudioDevice[]) => void;
function toAudioDevice(info: MediaDeviceInfo): AudioDevice | null {
if (info.kind !== "audioinput" && info.kind !== "audiooutput") return null;
return {
deviceId: info.deviceId,
label: info.label || `${info.kind === "audioinput" ? "Microphone" : "Speaker"} (${info.deviceId.slice(0, 8)})`,
kind: info.kind,
};
}
export function createAudioManager(): AudioManager {
let currentInputDeviceId: string | null = null;
let currentOutputDeviceId: string | null = null;
let destroyed = false;
const activeStreams = new Set<MediaStream>();
const deviceChangeCallbacks = new Set<DeviceChangeCallback>();
async function listAudioDevices(): Promise<readonly AudioDevice[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
const audioDevices: AudioDevice[] = [];
for (const d of devices) {
const mapped = toAudioDevice(d);
if (mapped !== null) {
audioDevices.push(mapped);
}
}
return audioDevices;
}
function handleDeviceChange(): void {
if (destroyed) return;
void listAudioDevices().then((devices) => {
log.info("Audio device change detected", {
inputs: devices.filter((d) => d.kind === "audioinput").length,
outputs: devices.filter((d) => d.kind === "audiooutput").length,
});
for (const cb of deviceChangeCallbacks) {
cb(devices);
}
});
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return {
async enumerateDevices(): Promise<readonly AudioDevice[]> {
if (destroyed) throw new Error("AudioManager has been destroyed");
return listAudioDevices();
},
async getUserMedia(deviceId?: string): Promise<MediaStream> {
if (destroyed) throw new Error("AudioManager has been destroyed");
const constraints: MediaStreamConstraints = {
audio: {
deviceId: deviceId !== undefined ? { exact: deviceId } : undefined,
echoCancellation: loadPref<boolean>("echoCancellation", true),
noiseSuppression: loadPref<boolean>("noiseSuppression", true),
autoGainControl: loadPref<boolean>("autoGainControl", true),
},
video: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
activeStreams.add(stream);
// Determine actual device ID from the track settings
const audioTrack = stream.getAudioTracks()[0];
if (audioTrack !== undefined) {
const settings = audioTrack.getSettings();
currentInputDeviceId = settings.deviceId ?? deviceId ?? null;
log.info("Microphone acquired", {
deviceId: currentInputDeviceId,
sampleRate: settings.sampleRate,
channelCount: settings.channelCount,
echoCancellation: settings.echoCancellation,
noiseSuppression: settings.noiseSuppression,
autoGainControl: settings.autoGainControl,
});
}
return stream;
},
async setOutputDevice(element: HTMLAudioElement, deviceId: string): Promise<void> {
if (destroyed) throw new Error("AudioManager has been destroyed");
// setSinkId is not available in all browsers; check before calling
if (typeof element.setSinkId !== "function") {
throw new Error("Audio output device selection is not supported in this browser");
}
await element.setSinkId(deviceId);
currentOutputDeviceId = deviceId;
},
getInputDeviceId(): string | null {
return currentInputDeviceId;
},
getOutputDeviceId(): string | null {
return currentOutputDeviceId;
},
onDeviceChange(callback: DeviceChangeCallback): () => void {
deviceChangeCallbacks.add(callback);
return () => { deviceChangeCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
// Stop all tracks on all active streams
log.debug("AudioManager destroying", { activeStreams: activeStreams.size });
for (const stream of activeStreams) {
for (const track of stream.getTracks()) {
track.stop();
}
}
activeStreams.clear();
deviceChangeCallbacks.clear();
currentInputDeviceId = null;
currentOutputDeviceId = null;
},
};
}
+3 -19
View File
@@ -38,11 +38,7 @@ import {
joinVoiceChannel,
leaveVoiceChannel,
} from "@stores/voice.store";
import {
handleServerOffer,
handleServerAnswer,
handleServerIce,
} from "@lib/voiceSession";
import { handleVoiceToken } from "@lib/livekitSession";
import { notifyIncomingMessage } from "./notifications";
import { createLogger } from "./logger";
@@ -269,20 +265,8 @@ export function wireDispatcher(ws: WsClient): DispatcherCleanup {
);
unsubs.push(
ws.on("voice_offer", (payload) => {
handleServerOffer(payload.sdp, payload.channel_id);
}),
);
unsubs.push(
ws.on("voice_answer", (payload) => {
handleServerAnswer(payload.sdp);
}),
);
unsubs.push(
ws.on("voice_ice", (payload) => {
handleServerIce(payload.candidate);
ws.on("voice_token", (payload) => {
void handleVoiceToken(payload.token, payload.url, payload.channel_id);
}),
);
@@ -0,0 +1,605 @@
// =============================================================================
// LiveKit Session — lifecycle orchestrator for voice chat via LiveKit
//
// Replaces the old WebRTC-based voiceSession.ts. Manages LiveKit Room
// connection, mic publishing (with optional RNNoise pre-processing),
// remote track playback, and camera/screenshare.
// =============================================================================
import {
Room,
RoomEvent,
Track,
type RemoteTrack,
type RemoteTrackPublication,
type RemoteParticipant,
type Participant,
DisconnectReason,
} from "livekit-client";
import type { WsClient } from "@lib/ws";
import {
voiceStore,
setLocalMuted,
setLocalDeafened,
setLocalSpeaking,
setLocalCamera,
} from "@stores/voice.store";
import { loadPref, savePref } from "@components/settings/helpers";
import { createLogger } from "@lib/logger";
import { createNoiseSuppressor } from "@lib/noise-suppression";
import type { NoiseSuppressor } from "@lib/noise-suppression";
const log = createLogger("livekitSession");
// ---------------------------------------------------------------------------
// Module-level state (singleton)
// ---------------------------------------------------------------------------
let room: Room | null = null;
let ws: WsClient | null = null;
let noiseSuppressor: NoiseSuppressor | null = null;
let onErrorCallback: ((message: string) => void) | null = null;
let currentChannelId: number | null = null;
/** The raw mic stream acquired for RNNoise processing (must be stopped on cleanup). */
let rawMicStream: MediaStream | null = null;
// Remote audio playback
const audioElements = new Map<string, HTMLAudioElement>();
let audioContainer: HTMLDivElement | null = null;
// Remote video callbacks
type RemoteVideoCallback = (userId: number, stream: MediaStream) => void;
type RemoteVideoRemovedCallback = (userId: number) => void;
let onRemoteVideoCallback: RemoteVideoCallback | null = null;
let onRemoteVideoRemovedCallback: RemoteVideoRemovedCallback | null = null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Parse userId from LiveKit participant identity "user-{id}". Returns 0 if unparseable. */
function parseUserId(identity: string): number {
const match = identity.match(/^user-(\d+)$/);
if (match !== null && match[1] !== undefined) {
return parseInt(match[1], 10);
}
return 0;
}
/** Get or create the hidden container for remote audio elements. */
function getOrCreateAudioContainer(): HTMLDivElement {
if (audioContainer !== null) return audioContainer;
const existing = document.getElementById("voice-audio-container");
if (existing instanceof HTMLDivElement) {
audioContainer = existing;
return audioContainer;
}
const div = document.createElement("div");
div.id = "voice-audio-container";
div.style.display = "none";
document.body.appendChild(div);
audioContainer = div;
return audioContainer;
}
/** Get saved per-user volume (0-200 range, default 100). */
function getSavedUserVolume(userId: number): number {
return loadPref<number>(`userVolume_${userId}`, 100);
}
/** Clean up all remote audio elements. */
function cleanupAudioElements(): void {
for (const el of audioElements.values()) {
el.srcObject = null;
el.remove();
}
audioElements.clear();
}
// ---------------------------------------------------------------------------
// RNNoise integration
// ---------------------------------------------------------------------------
/** Acquire mic, run through RNNoise, and publish the processed track to LiveKit. */
async function publishWithNoiseSuppression(): Promise<void> {
if (room === null) return;
const savedDevice = loadPref<string>("audioInputDevice", "");
const constraints: MediaStreamConstraints = {
audio: {
deviceId: savedDevice ? { exact: savedDevice } : undefined,
echoCancellation: loadPref("echoCancellation", true),
noiseSuppression: loadPref("noiseSuppression", true),
autoGainControl: loadPref("autoGainControl", true),
},
video: false,
};
const rawStream = await navigator.mediaDevices.getUserMedia(constraints);
rawMicStream = rawStream;
noiseSuppressor = createNoiseSuppressor();
const processedStream = await noiseSuppressor.process(rawStream);
const processedTrack = processedStream.getAudioTracks()[0];
if (processedTrack) {
await room.localParticipant.publishTrack(processedTrack, {
source: Track.Source.Microphone,
});
}
}
// ---------------------------------------------------------------------------
// Room event handlers
// ---------------------------------------------------------------------------
function handleTrackSubscribed(
track: RemoteTrack,
publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
// Attach audio track to a hidden <audio> element for playback
const container = getOrCreateAudioContainer();
const audioEl = track.attach();
// Apply saved per-user volume
const savedVolume = userId > 0 ? getSavedUserVolume(userId) : 100;
audioEl.volume = Math.min(savedVolume, 100) / 100;
// Respect current deafen state
if (voiceStore.getState().localDeafened) {
audioEl.muted = true;
}
// Apply saved output device
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput !== "" && typeof audioEl.setSinkId === "function") {
audioEl.setSinkId(savedOutput).catch((err) => {
log.warn("Failed to set output device on remote audio", err);
});
}
container.appendChild(audioEl);
const trackKey = `${participant.identity}-${track.sid}`;
audioElements.set(trackKey, audioEl);
log.debug("Remote audio track subscribed", { userId, trackSid: track.sid });
} else if (track.kind === Track.Kind.Video) {
if (userId > 0 && onRemoteVideoCallback !== null) {
// Create a MediaStream from the video track for the VideoGrid
const mediaTrack = track.mediaStreamTrack;
const stream = new MediaStream([mediaTrack]);
onRemoteVideoCallback(userId, stream);
}
log.debug("Remote video track subscribed", { userId, trackSid: track.sid });
}
}
function handleTrackUnsubscribed(
track: RemoteTrack,
_publication: RemoteTrackPublication,
participant: RemoteParticipant,
): void {
const userId = parseUserId(participant.identity);
if (track.kind === Track.Kind.Audio) {
track.detach().forEach((el) => {
el.remove();
});
const trackKey = `${participant.identity}-${track.sid}`;
audioElements.delete(trackKey);
log.debug("Remote audio track unsubscribed", { userId, trackSid: track.sid });
} else if (track.kind === Track.Kind.Video) {
track.detach();
if (userId > 0) {
onRemoteVideoRemovedCallback?.(userId);
}
log.debug("Remote video track unsubscribed", { userId, trackSid: track.sid });
}
}
function handleActiveSpeakersChanged(speakers: Participant[]): void {
const speakerIds = new Set<number>();
for (const speaker of speakers) {
const userId = parseUserId(speaker.identity);
if (userId > 0) {
speakerIds.add(userId);
}
}
// Update speaking state in voice store for all users in the channel
const state = voiceStore.getState();
const channelId = state.currentChannelId;
if (channelId === null) return;
const channelUsers = state.voiceUsers.get(channelId);
if (!channelUsers) return;
for (const [userId] of channelUsers) {
const isSpeaking = speakerIds.has(userId);
setLocalSpeaking(isSpeaking);
}
}
function handleDisconnected(reason?: DisconnectReason): void {
log.info("LiveKit room disconnected", { reason });
const isUnexpected = reason !== DisconnectReason.CLIENT_INITIATED;
leaveVoice(false);
if (isUnexpected) {
onErrorCallback?.("Voice connection lost — disconnected");
}
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Set the WS client reference used for signaling. */
export function setWsClient(client: WsClient): void {
ws = client;
}
/** Set error callback for UI feedback (e.g. toast on connection failure). */
export function setOnError(cb: (message: string) => void): void {
onErrorCallback = cb;
}
/** Clear the error callback. */
export function clearOnError(): void {
onErrorCallback = null;
}
/** Set callback for when a remote user enables their camera. */
export function setOnRemoteVideo(cb: RemoteVideoCallback): void {
onRemoteVideoCallback = cb;
}
/** Set callback for when a remote user disables their camera. */
export function setOnRemoteVideoRemoved(cb: RemoteVideoRemovedCallback): void {
onRemoteVideoRemovedCallback = cb;
}
/** Clear remote video callbacks. */
export function clearOnRemoteVideo(): void {
onRemoteVideoCallback = null;
onRemoteVideoRemovedCallback = null;
}
/**
* Handle a voice_token message from the server. Creates a LiveKit Room,
* connects to the SFU, and publishes the local microphone track.
*/
export async function handleVoiceToken(
token: string,
url: string,
channelId: number,
): Promise<void> {
// Disconnect existing session first
if (room !== null) {
leaveVoice(false);
}
try {
// Create room with audio processing defaults
room = new Room({
adaptiveStream: true,
dynacast: true,
audioCaptureDefaults: {
echoCancellation: loadPref("echoCancellation", true),
noiseSuppression: loadPref("noiseSuppression", true),
autoGainControl: loadPref("autoGainControl", true),
},
});
// Wire room events
room.on(RoomEvent.TrackSubscribed, handleTrackSubscribed);
room.on(RoomEvent.TrackUnsubscribed, handleTrackUnsubscribed);
room.on(RoomEvent.ActiveSpeakersChanged, handleActiveSpeakersChanged);
room.on(RoomEvent.Disconnected, handleDisconnected);
// Connect to LiveKit server
await room.connect(url, token);
log.info("Connected to LiveKit room", { channelId, url });
// Enable microphone: use RNNoise if Enhanced Noise Suppression is on
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
if (enhancedNS) {
await publishWithNoiseSuppression();
log.info("Published mic with RNNoise noise suppression");
} else {
await room.localParticipant.setMicrophoneEnabled(true);
log.info("Published mic via LiveKit native capture");
}
// Apply saved input device
const savedInput = loadPref<string>("audioInputDevice", "");
if (savedInput) {
await room.switchActiveDevice("audioinput", savedInput);
}
// Apply saved output device
const savedOutput = loadPref<string>("audioOutputDevice", "");
if (savedOutput) {
await room.switchActiveDevice("audiooutput", savedOutput);
}
currentChannelId = channelId;
log.info("Voice session active", { channelId });
} catch (err) {
log.error("Failed to connect to LiveKit", err);
onErrorCallback?.("Failed to join voice — connection error");
leaveVoice(false);
}
}
/**
* Leave the current voice session and clean up all resources.
* If sendWs is true (default), also notifies the server via voice_leave.
*/
export function leaveVoice(sendWs = true): void {
if (sendWs && ws !== null) {
ws.send({ type: "voice_leave", payload: {} });
}
// Stop raw mic stream acquired for RNNoise
if (rawMicStream !== null) {
for (const track of rawMicStream.getTracks()) {
track.stop();
}
rawMicStream = null;
}
// Destroy noise suppressor
if (noiseSuppressor !== null) {
noiseSuppressor.destroy();
noiseSuppressor = null;
}
// Disconnect LiveKit room
if (room !== null) {
room.removeAllListeners();
void room.disconnect();
room = null;
}
// Clean up remote audio elements
cleanupAudioElements();
currentChannelId = null;
log.info("Left voice session");
}
/** Mute or unmute the local microphone. */
export function setMuted(muted: boolean): void {
setLocalMuted(muted);
if (room !== null) {
void room.localParticipant.setMicrophoneEnabled(!muted);
}
}
/** Deafen or undeafen — mutes all remote audio playback. */
export function setDeafened(deafened: boolean): void {
setLocalDeafened(deafened);
if (room !== null) {
// Toggle subscription on remote audio tracks for deafen
for (const participant of room.remoteParticipants.values()) {
for (const pub of participant.audioTrackPublications.values()) {
pub.setSubscribed(!deafened);
}
}
}
// Also mute/unmute audio elements directly for immediate effect
for (const el of audioElements.values()) {
el.muted = deafened;
}
log.debug("Deafen state changed", { deafened });
}
/** Enable camera: publish video track, notify server. */
export async function enableCamera(): Promise<void> {
if (room === null || ws === null) {
log.warn("Cannot enable camera: no active voice session");
onErrorCallback?.("Join a voice channel first");
return;
}
try {
await room.localParticipant.setCameraEnabled(true);
// Apply saved video device
const savedVideoDevice = loadPref<string>("videoInputDevice", "");
if (savedVideoDevice) {
await room.switchActiveDevice("videoinput", savedVideoDevice);
}
setLocalCamera(true);
ws.send({ type: "voice_camera", payload: { enabled: true } });
log.info("Camera enabled");
} catch (err) {
log.error("Failed to enable camera", err);
if (err instanceof DOMException && err.name === "NotAllowedError") {
onErrorCallback?.("Camera permission denied");
} else if (err instanceof DOMException && err.name === "NotFoundError") {
onErrorCallback?.("No camera found");
} else {
onErrorCallback?.("Failed to start camera");
}
}
}
/** Disable camera: unpublish video track, notify server. */
export async function disableCamera(): Promise<void> {
if (room !== null) {
await room.localParticipant.setCameraEnabled(false);
}
setLocalCamera(false);
if (ws !== null) {
ws.send({ type: "voice_camera", payload: { enabled: false } });
}
log.info("Camera disabled");
}
/** Switch the input (microphone) device on an active session. */
export async function switchInputDevice(deviceId: string): Promise<void> {
if (room === null) {
log.debug("Skipping input device switch — no active voice session");
return;
}
try {
const enhancedNS = loadPref<boolean>("enhancedNoiseSuppression", false);
if (enhancedNS && noiseSuppressor !== null) {
// Need to re-acquire mic and re-process through RNNoise
// Stop old raw stream
if (rawMicStream !== null) {
for (const track of rawMicStream.getTracks()) {
track.stop();
}
rawMicStream = null;
}
noiseSuppressor.destroy();
noiseSuppressor = null;
// Unpublish current mic track
for (const pub of room.localParticipant.audioTrackPublications.values()) {
if (pub.source === Track.Source.Microphone && pub.track) {
await room.localParticipant.unpublishTrack(pub.track);
}
}
// Re-publish with new device
await publishWithNoiseSuppression();
} else {
await room.switchActiveDevice("audioinput", deviceId);
}
log.info("Switched input device", { deviceId });
} catch (err) {
log.error("Failed to switch input device", err);
onErrorCallback?.("Failed to switch microphone");
}
}
/** Switch the output (speaker) device on an active session. */
export async function switchOutputDevice(deviceId: string): Promise<void> {
if (room !== null) {
await room.switchActiveDevice("audiooutput", deviceId);
}
// Also update any existing audio elements
for (const el of audioElements.values()) {
if (typeof el.setSinkId === "function") {
try {
await el.setSinkId(deviceId);
} catch (err) {
log.warn("Failed to set output device on audio element", err);
}
}
}
log.info("Switched output device", { deviceId });
}
/**
* Set per-user volume (0-200%). Persisted to localStorage.
* Note: HTMLAudioElement.volume only supports 0.0-1.0, so volumes above
* 100% are clamped.
*/
export function setUserVolume(userId: number, volume: number): void {
const clamped = Math.max(0, Math.min(200, volume));
savePref(`userVolume_${userId}`, clamped);
// Find audio elements for this user's tracks
if (room !== null) {
for (const participant of room.remoteParticipants.values()) {
const pUserId = parseUserId(participant.identity);
if (pUserId === userId) {
for (const pub of participant.audioTrackPublications.values()) {
if (pub.track) {
const els = pub.track.attachedElements;
for (const el of els) {
if (el instanceof HTMLAudioElement) {
el.volume = Math.min(clamped, 100) / 100;
}
}
}
}
}
}
}
}
/** Get the current per-user volume (0-200%, default 100). */
export function getUserVolume(userId: number): number {
return getSavedUserVolume(userId);
}
/** Update the VAD sensitivity — no-op in LiveKit (server handles VAD). */
export function setVoiceSensitivity(_sensitivity: number): void {
// No local VAD in LiveKit mode — server/SFU handles speaker detection
}
/** Get the local camera stream for self-view display. */
export function getLocalCameraStream(): MediaStream | null {
if (room === null) return null;
const cameraPub = room.localParticipant.getTrackPublication(Track.Source.Camera);
if (cameraPub?.track?.mediaStreamTrack) {
return new MediaStream([cameraPub.track.mediaStreamTrack]);
}
return null;
}
/** Snapshot of current voice session state for debugging. */
export function getSessionDebugInfo(): Record<string, unknown> {
if (room === null) {
return {
hasRoom: false,
hasNoiseSuppressor: noiseSuppressor !== null,
currentChannelId,
};
}
const remoteParticipants: Record<string, unknown>[] = [];
for (const p of room.remoteParticipants.values()) {
const tracks: Record<string, unknown>[] = [];
for (const pub of p.trackPublications.values()) {
tracks.push({
sid: pub.trackSid,
source: pub.source,
kind: pub.kind,
subscribed: pub.isSubscribed,
enabled: pub.isEnabled,
});
}
remoteParticipants.push({
identity: p.identity,
userId: parseUserId(p.identity),
tracks,
});
}
const localTracks: Record<string, unknown>[] = [];
for (const pub of room.localParticipant.trackPublications.values()) {
localTracks.push({
sid: pub.trackSid,
source: pub.source,
kind: pub.kind,
isMuted: pub.isMuted,
});
}
return {
hasRoom: true,
roomName: room.name,
roomState: room.state,
hasNoiseSuppressor: noiseSuppressor !== null,
currentChannelId,
localParticipant: room.localParticipant.identity,
localTracks,
remoteParticipants,
audioElements: audioElements.size,
};
}
+1 -1
View File
@@ -6,7 +6,7 @@
import { loadPref, savePref } from "@components/settings/helpers";
import { voiceStore } from "@stores/voice.store";
import { setMuted } from "./voiceSession";
import { setMuted } from "./livekitSession";
import { createLogger } from "./logger";
const log = createLogger("ptt");
+7 -28
View File
@@ -17,9 +17,6 @@ export type ChannelType = "text" | "voice" | "announcement";
/** Voice quality presets. */
export type VoiceQuality = "low" | "medium" | "high";
/** Voice threshold mode. CRITICAL: always "threshold_mode", never "mode". */
export type ThresholdMode = "forwarding" | "selective";
/** Reaction action direction. */
export type ReactionAction = "add" | "remove";
@@ -262,7 +259,7 @@ export interface VoiceConfigPayload {
readonly channel_id: number;
readonly quality: VoiceQuality;
readonly bitrate: number;
readonly threshold_mode: ThresholdMode;
readonly threshold_mode: string;
readonly mixing_threshold: number;
readonly top_speakers: number;
readonly max_users: number;
@@ -272,22 +269,13 @@ export interface VoiceConfigPayload {
export interface VoiceSpeakersPayload {
readonly channel_id: number;
readonly speakers: readonly number[];
readonly threshold_mode: ThresholdMode;
readonly threshold_mode: string;
}
export interface VoiceOfferPayload {
export interface VoiceTokenPayload {
readonly channel_id: number;
readonly sdp: string;
}
export interface VoiceAnswerPayload {
readonly channel_id: number;
readonly sdp: string;
}
export interface VoiceIcePayload {
readonly channel_id: number;
readonly candidate: RTCIceCandidateInit;
readonly token: string;
readonly url: string;
}
export interface MemberJoinPayload {
@@ -390,10 +378,6 @@ export interface SoundboardPlayPayload {
readonly sound_id: string;
}
// Note: VoiceOfferPayload, VoiceAnswerPayload, VoiceIcePayload are
// bidirectional — the same interface is used for both client→server
// and server→client directions. See definitions above.
// -----------------------------------------------------------------------------
// Discriminated Union: Server → Client Messages
// -----------------------------------------------------------------------------
@@ -416,9 +400,7 @@ export type ServerMessage =
| (WsEnvelope<VoiceLeavePayload> & { readonly type: "voice_leave" })
| (WsEnvelope<VoiceConfigPayload> & { readonly type: "voice_config" })
| (WsEnvelope<VoiceSpeakersPayload> & { readonly type: "voice_speakers" })
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" })
| (WsEnvelope<VoiceTokenPayload> & { readonly type: "voice_token" })
| (WsEnvelope<MemberJoinPayload> & { readonly type: "member_join" })
| (WsEnvelope<MemberLeavePayload> & { readonly type: "member_leave" })
| (WsEnvelope<MemberUpdatePayload> & { readonly type: "member_update" })
@@ -446,10 +428,7 @@ export type ClientMessage =
| (WsEnvelope<VoiceDeafenPayload> & { readonly type: "voice_deafen" })
| (WsEnvelope<VoiceCameraPayload> & { readonly type: "voice_camera" })
| (WsEnvelope<VoiceScreensharePayload> & { readonly type: "voice_screenshare" })
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" })
| (WsEnvelope<VoiceOfferPayload> & { readonly type: "voice_offer" })
| (WsEnvelope<VoiceAnswerPayload> & { readonly type: "voice_answer" })
| (WsEnvelope<VoiceIcePayload> & { readonly type: "voice_ice" });
| (WsEnvelope<SoundboardPlayPayload> & { readonly type: "soundboard_play" });
// -----------------------------------------------------------------------------
// REST API Response Types
-183
View File
@@ -1,183 +0,0 @@
// =============================================================================
// Voice Activity Detection — Web Audio API based speech detection
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("vad");
export interface VadOptions {
/** Audio volume threshold (0-1) to detect speech. Default 0.01 */
readonly threshold?: number;
/** How often to check volume in ms. Default 50 */
readonly intervalMs?: number;
/** Minimum consecutive detections before triggering. Default 3 */
readonly minConsecutive?: number;
}
export interface VadDetector {
start(stream: MediaStream): void;
stop(): void;
setThreshold(threshold: number): void;
onSpeakingChange(callback: (speaking: boolean) => void): () => void;
isSpeaking(): boolean;
destroy(): void;
}
type SpeakingCallback = (speaking: boolean) => void;
const DEFAULT_THRESHOLD = 0.01;
const DEFAULT_INTERVAL_MS = 50;
const DEFAULT_MIN_CONSECUTIVE = 3;
/** Max VAD threshold value. Sensitivity 0% maps to this threshold. */
const MAX_THRESHOLD = 0.15;
/** Convert sensitivity slider (0-100) to VAD threshold (0-MAX_THRESHOLD).
* High sensitivity = low threshold (picks up quiet sounds).
* 0% sensitivity = threshold 0.15 (only loud sounds trigger).
* 100% sensitivity = threshold 0.0 (everything triggers). */
export function sensitivityToThreshold(sensitivity: number): number {
return ((100 - sensitivity) / 100) * MAX_THRESHOLD;
}
// Require more silence samples than speech samples to prevent flicker
const SILENCE_MULTIPLIER = 2;
function computeRms(data: Uint8Array): number {
let sum = 0;
for (let i = 0; i < data.length; i++) {
const val = data[i];
if (val === undefined) continue;
// getByteFrequencyData returns 0-255 where 0 = silence, 255 = max.
// Normalize to 0-1 range.
const normalized = val / 255;
sum += normalized * normalized;
}
return Math.sqrt(sum / data.length);
}
export function createVadDetector(options?: VadOptions): VadDetector {
let threshold = options?.threshold ?? DEFAULT_THRESHOLD;
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
const minConsecutive = options?.minConsecutive ?? DEFAULT_MIN_CONSECUTIVE;
const silenceRequired = minConsecutive * SILENCE_MULTIPLIER;
let audioContext: AudioContext | null = null;
let analyser: AnalyserNode | null = null;
let sourceNode: MediaStreamAudioSourceNode | null = null;
let intervalId: ReturnType<typeof setInterval> | null = null;
let destroyed = false;
let speaking = false;
let consecutiveAbove = 0;
let consecutiveBelow = 0;
const callbacks = new Set<SpeakingCallback>();
function emitChange(newState: boolean): void {
if (speaking === newState) return;
speaking = newState;
for (const cb of callbacks) {
cb(speaking);
}
}
function tick(): void {
if (analyser === null) return;
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(data);
const rms = computeRms(data);
if (rms >= threshold) {
consecutiveAbove++;
consecutiveBelow = 0;
if (!speaking && consecutiveAbove >= minConsecutive) {
emitChange(true);
}
} else {
consecutiveBelow++;
consecutiveAbove = 0;
if (speaking && consecutiveBelow >= silenceRequired) {
emitChange(false);
}
}
}
function cleanup(): void {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
if (sourceNode !== null) {
sourceNode.disconnect();
sourceNode = null;
}
if (analyser !== null) {
analyser.disconnect();
analyser = null;
}
if (audioContext !== null) {
void audioContext.close();
audioContext = null;
}
consecutiveAbove = 0;
consecutiveBelow = 0;
if (speaking) {
emitChange(false);
}
}
return {
start(stream: MediaStream): void {
if (destroyed) throw new Error("VadDetector has been destroyed");
// Stop any existing monitoring first
cleanup();
// Force 48kHz so FFT bins cover the voice-frequency range (0-24kHz)
// consistently regardless of the system audio device's native rate.
// At high native rates (e.g. 192kHz), most bins would be above voice
// frequencies, making the RMS calculation artificially low.
audioContext = new AudioContext({ sampleRate: 48000 });
analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
analyser.smoothingTimeConstant = 0.5;
sourceNode = audioContext.createMediaStreamSource(stream);
sourceNode.connect(analyser);
intervalId = setInterval(tick, intervalMs);
log.debug("VAD started", { threshold, intervalMs, minConsecutive, sampleRate: audioContext.sampleRate });
},
stop(): void {
if (destroyed) return;
cleanup();
},
setThreshold(newThreshold: number): void {
if (newThreshold < 0 || newThreshold > 1) {
throw new Error("Threshold must be between 0 and 1");
}
log.debug("VAD threshold changed", { old: threshold, new: newThreshold });
threshold = newThreshold;
},
onSpeakingChange(callback: SpeakingCallback): () => void {
callbacks.add(callback);
return () => { callbacks.delete(callback); };
},
isSpeaking(): boolean {
return speaking;
},
destroy(): void {
if (destroyed) return;
destroyed = true;
cleanup();
callbacks.clear();
log.debug("VAD destroyed");
},
};
}
-144
View File
@@ -1,144 +0,0 @@
// =============================================================================
// Video Device Manager — enumerate cameras, acquire streams, stop capture
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("video");
export interface VideoDevice {
readonly deviceId: string;
readonly label: string;
readonly kind: "videoinput";
}
export interface VideoManager {
enumerateDevices(): Promise<readonly VideoDevice[]>;
getCameraStream(deviceId?: string): Promise<MediaStream>;
stopCameraStream(): void;
getCurrentStream(): MediaStream | null;
onDeviceChange(callback: (devices: readonly VideoDevice[]) => void): () => void;
destroy(): void;
}
type DeviceChangeCallback = (devices: readonly VideoDevice[]) => void;
function toVideoDevice(info: MediaDeviceInfo): VideoDevice | null {
if (info.kind !== "videoinput") return null;
return {
deviceId: info.deviceId,
label: info.label || `Camera (${info.deviceId.slice(0, 8)})`,
kind: "videoinput",
};
}
export function createVideoManager(): VideoManager {
let currentStream: MediaStream | null = null;
let destroyed = false;
const deviceChangeCallbacks = new Set<DeviceChangeCallback>();
async function listVideoDevices(): Promise<readonly VideoDevice[]> {
const devices = await navigator.mediaDevices.enumerateDevices();
const videoDevices: VideoDevice[] = [];
for (const d of devices) {
const mapped = toVideoDevice(d);
if (mapped !== null) {
videoDevices.push(mapped);
}
}
return videoDevices;
}
function handleDeviceChange(): void {
if (destroyed) return;
void listVideoDevices().then((devices) => {
log.info("Video device change detected", {
cameras: devices.length,
});
for (const cb of deviceChangeCallbacks) {
cb(devices);
}
});
}
navigator.mediaDevices.addEventListener("devicechange", handleDeviceChange);
return {
async enumerateDevices(): Promise<readonly VideoDevice[]> {
if (destroyed) throw new Error("VideoManager has been destroyed");
return listVideoDevices();
},
async getCameraStream(deviceId?: string): Promise<MediaStream> {
if (destroyed) throw new Error("VideoManager has been destroyed");
// Stop any existing stream before acquiring a new one
if (currentStream !== null) {
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
}
const constraints: MediaStreamConstraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: deviceId !== undefined ? { exact: deviceId } : undefined,
},
audio: false,
};
const stream = await navigator.mediaDevices.getUserMedia(constraints);
currentStream = stream;
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const settings = videoTrack.getSettings();
log.info("Camera acquired", {
deviceId: settings.deviceId ?? deviceId ?? null,
width: settings.width,
height: settings.height,
frameRate: settings.frameRate,
});
}
return stream;
},
stopCameraStream(): void {
if (currentStream === null) return;
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
log.info("Camera stream stopped");
},
getCurrentStream(): MediaStream | null {
return currentStream;
},
onDeviceChange(callback: DeviceChangeCallback): () => void {
deviceChangeCallbacks.add(callback);
return () => { deviceChangeCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
navigator.mediaDevices.removeEventListener("devicechange", handleDeviceChange);
log.debug("VideoManager destroying", { hasStream: currentStream !== null });
if (currentStream !== null) {
for (const track of currentStream.getTracks()) {
track.stop();
}
currentStream = null;
}
deviceChangeCallbacks.clear();
},
};
}
File diff suppressed because it is too large Load Diff
-394
View File
@@ -1,394 +0,0 @@
// =============================================================================
// WebRTC Service — peer connection management for voice communication
// =============================================================================
import { createLogger } from "@lib/logger";
const log = createLogger("webrtc");
export interface WebRtcConfig {
readonly iceServers: readonly RTCIceServer[];
readonly opusBitrate?: number;
}
export interface WebRtcService {
createConnection(config: WebRtcConfig): void;
handleOffer(sdp: string): Promise<string>;
handleAnswer(sdp: string): Promise<void>;
handleServerOffer(sdp: string): Promise<string>;
createOffer(iceRestart?: boolean): Promise<string>;
handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void>;
/** Add a video track to the PeerConnection. Returns the sender for removal. */
addVideoTrack(stream: MediaStream): RTCRtpSender | null;
/** Remove the video track sender from the PeerConnection. */
removeVideoTrack(sender: RTCRtpSender): void;
setLocalStream(stream: MediaStream): void;
/** Swap the media track on existing senders without SDP renegotiation. */
replaceTrack(stream: MediaStream): Promise<void>;
getRemoteStreams(): readonly MediaStream[];
setMuted(muted: boolean): void;
setSilenced(silenced: boolean): void;
onIceCandidate(callback: (candidate: RTCIceCandidateInit) => void): () => void;
onRemoteTrack(callback: (stream: MediaStream) => void): () => void;
onStateChange(callback: (state: RTCPeerConnectionState) => void): () => void;
onIceStateChange(callback: (state: RTCIceConnectionState) => void): () => void;
destroy(): void;
}
type IceCandidateCallback = (candidate: RTCIceCandidateInit) => void;
type RemoteTrackCallback = (stream: MediaStream) => void;
type StateChangeCallback = (state: RTCPeerConnectionState) => void;
type IceStateCallback = (state: RTCIceConnectionState) => void;
/** Apply Opus bitrate and FEC constraints via SDP munging. */
function applyOpusSettings(sdp: string, bitrate: number | undefined): string {
const lines = sdp.split("\r\n");
const result: string[] = [];
let inAudioSection = false;
let bitrateInserted = false;
for (let i = 0; i < lines.length; i++) {
let line = lines[i];
if (line === undefined) continue;
// Track which media section we're in
if (line.startsWith("m=audio")) {
inAudioSection = true;
bitrateInserted = false;
} else if (line.startsWith("m=")) {
inAudioSection = false;
}
// Enable Opus in-band FEC for packet loss resilience
if (line.startsWith("a=fmtp:111 ")) {
if (!line.includes("useinbandfec=")) {
line += ";useinbandfec=1";
}
}
result.push(line);
// Insert b=AS after m=audio line (always present, unlike c= which may
// only exist at session level)
if (inAudioSection && !bitrateInserted && bitrate !== undefined && line.startsWith("m=audio")) {
result.push(`b=AS:${Math.round(bitrate / 1000)}`);
bitrateInserted = true;
}
}
return result.join("\r\n");
}
export function createWebRtcService(): WebRtcService {
let pc: RTCPeerConnection | null = null;
let localSenders: readonly RTCRtpSender[] = [];
let isMuted = false;
let isSilenced = false;
let remoteStreams: readonly MediaStream[] = [];
let opusBitrate: number | undefined;
let destroyed = false;
/** True once setRemoteDescription has been called (ICE candidates are safe). */
let hasRemoteDescription = false;
/** Queue ICE candidates that arrive before the remote description is set. */
const pendingIceCandidates: RTCIceCandidateInit[] = [];
const iceCandidateCallbacks = new Set<IceCandidateCallback>();
const remoteTrackCallbacks = new Set<RemoteTrackCallback>();
const stateChangeCallbacks = new Set<StateChangeCallback>();
const iceStateCallbacks = new Set<IceStateCallback>();
function assertConnection(): RTCPeerConnection {
if (destroyed) throw new Error("WebRTC service has been destroyed");
if (pc === null) throw new Error("No peer connection created");
return pc;
}
/** Flush queued ICE candidates now that the remote description is set. */
async function flushIceCandidates(conn: RTCPeerConnection): Promise<void> {
hasRemoteDescription = true;
const queued = pendingIceCandidates.splice(0);
if (queued.length > 0) {
log.debug("Flushing queued ICE candidates", { count: queued.length });
}
for (const c of queued) {
await conn.addIceCandidate(c);
}
}
/** Apply track.enabled based on current mute + silence state. */
function applyTrackEnabled(): void {
for (const sender of localSenders) {
const track = sender.track;
if (track !== null) {
track.enabled = !isMuted && !isSilenced;
}
}
}
function handleIceCandidateEvent(event: RTCPeerConnectionIceEvent): void {
if (event.candidate === null) {
log.debug("ICE gathering complete");
return;
}
const c = event.candidate;
log.debug("Local ICE candidate", {
type: c.type,
address: c.address,
port: c.port,
protocol: c.protocol,
candidate: c.candidate,
});
const init: RTCIceCandidateInit = {
candidate: c.candidate,
sdpMid: c.sdpMid,
sdpMLineIndex: c.sdpMLineIndex,
};
for (const cb of iceCandidateCallbacks) {
cb(init);
}
}
function handleTrackEvent(event: RTCTrackEvent): void {
const stream = event.streams[0];
if (stream === undefined) {
log.warn("Remote track event with no stream");
return;
}
const isNew = !remoteStreams.some((s) => s.id === stream.id);
if (isNew) {
remoteStreams = [...remoteStreams, stream];
}
log.info("Remote track received", {
streamId: stream.id,
trackId: event.track.id,
kind: event.track.kind,
isNew,
totalStreams: remoteStreams.length,
});
// Always notify — renegotiation may add new tracks to existing
// streams (e.g. after leave/rejoin with the same stream ID).
for (const cb of remoteTrackCallbacks) {
cb(stream);
}
}
function handleConnectionStateChange(): void {
if (pc === null) return;
const state = pc.connectionState;
for (const cb of stateChangeCallbacks) {
cb(state);
}
}
function handleIceConnectionStateChange(): void {
if (pc === null) return;
const state = pc.iceConnectionState;
for (const cb of iceStateCallbacks) {
cb(state);
}
}
function handleNegotiationNeeded(): void {
// Log canary — if this fires, something triggered SDP renegotiation
// that our explicit offer/answer flow didn't handle. Upgrade to a
// full handler (auto-create offer) if this shows up in production.
log.warn("negotiationneeded fired unexpectedly", { signalingState: pc?.signalingState ?? "none" });
}
function mungeIfNeeded(sdp: string | undefined): string {
if (sdp === undefined) return "";
return applyOpusSettings(sdp, opusBitrate);
}
return {
createConnection(config: WebRtcConfig): void {
if (destroyed) throw new Error("WebRTC service has been destroyed");
if (pc !== null) {
pc.close();
}
opusBitrate = config.opusBitrate;
remoteStreams = [];
localSenders = [];
isMuted = false;
isSilenced = false;
hasRemoteDescription = false;
pendingIceCandidates.length = 0;
pc = new RTCPeerConnection({
iceServers: [...config.iceServers],
});
pc.addEventListener("icecandidate", handleIceCandidateEvent);
pc.addEventListener("track", handleTrackEvent);
pc.addEventListener("connectionstatechange", handleConnectionStateChange);
pc.addEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
pc.addEventListener("negotiationneeded", handleNegotiationNeeded);
log.info("PeerConnection created", {
iceServerCount: config.iceServers.length,
opusBitrate: config.opusBitrate,
});
},
async handleOffer(sdp: string): Promise<string> {
const conn = assertConnection();
await conn.setRemoteDescription({ type: "offer", sdp });
await flushIceCandidates(conn);
const answer = await conn.createAnswer();
const mungedSdp = mungeIfNeeded(answer.sdp);
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
return mungedSdp;
},
async handleAnswer(sdp: string): Promise<void> {
const conn = assertConnection();
await conn.setRemoteDescription({ type: "answer", sdp });
await flushIceCandidates(conn);
},
async handleServerOffer(sdp: string): Promise<string> {
const conn = assertConnection();
if (conn.signalingState === "have-local-offer") {
log.info("Rolling back local offer for server renegotiation (glare)");
await conn.setLocalDescription({ type: "rollback" });
}
await conn.setRemoteDescription({ type: "offer", sdp });
await flushIceCandidates(conn);
const answer = await conn.createAnswer();
const mungedSdp = mungeIfNeeded(answer.sdp);
await conn.setLocalDescription({ type: "answer", sdp: mungedSdp });
return mungedSdp;
},
async createOffer(iceRestart = false): Promise<string> {
const conn = assertConnection();
const offer = await conn.createOffer({ iceRestart });
const mungedSdp = mungeIfNeeded(offer.sdp);
await conn.setLocalDescription({ type: "offer", sdp: mungedSdp });
return mungedSdp;
},
async handleIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
const conn = assertConnection();
if (!hasRemoteDescription) {
pendingIceCandidates.push(candidate);
log.debug("ICE candidate queued (no remote description yet)", { queueDepth: pendingIceCandidates.length });
return;
}
await conn.addIceCandidate(candidate);
},
setLocalStream(stream: MediaStream): void {
const conn = assertConnection();
const removedCount = localSenders.length;
for (const sender of localSenders) {
conn.removeTrack(sender);
}
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
localSenders = newSenders;
// Apply current mute/silence state to new tracks
applyTrackEnabled();
log.debug("Local stream set", { removedSenders: removedCount, addedTracks: newSenders.length });
},
async replaceTrack(stream: MediaStream): Promise<void> {
assertConnection();
const newTracks = stream.getAudioTracks();
if (newTracks.length === 0) {
log.warn("replaceTrack called with no audio tracks");
return;
}
const newTrack = newTracks[0]!;
if (localSenders.length > 0) {
// Swap track on existing sender — no SDP renegotiation needed
for (const sender of localSenders) {
await sender.replaceTrack(newTrack);
}
log.debug("Track replaced on existing senders", { senderCount: localSenders.length, trackId: newTrack.id });
} else {
// No existing senders — fall back to addTrack (initial attach)
log.debug("replaceTrack fallback: no senders, using addTrack");
const conn = assertConnection();
const newSenders = stream.getTracks().map((track) => conn.addTrack(track, stream));
localSenders = newSenders;
}
// Apply current mute/silence state to the new track
applyTrackEnabled();
},
addVideoTrack(stream: MediaStream): RTCRtpSender | null {
const conn = assertConnection();
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack === undefined) {
log.warn("addVideoTrack called with no video tracks");
return null;
}
const sender = conn.addTrack(videoTrack, stream);
log.info("Video track added to PeerConnection", { trackId: videoTrack.id });
return sender;
},
removeVideoTrack(sender: RTCRtpSender): void {
const conn = assertConnection();
conn.removeTrack(sender);
log.info("Video track removed from PeerConnection");
},
getRemoteStreams(): readonly MediaStream[] {
return remoteStreams;
},
setMuted(muted: boolean): void {
isMuted = muted;
applyTrackEnabled();
},
setSilenced(silenced: boolean): void {
isSilenced = silenced;
applyTrackEnabled();
},
onIceCandidate(callback: IceCandidateCallback): () => void {
iceCandidateCallbacks.add(callback);
return () => { iceCandidateCallbacks.delete(callback); };
},
onRemoteTrack(callback: RemoteTrackCallback): () => void {
remoteTrackCallbacks.add(callback);
return () => { remoteTrackCallbacks.delete(callback); };
},
onStateChange(callback: StateChangeCallback): () => void {
stateChangeCallbacks.add(callback);
return () => { stateChangeCallbacks.delete(callback); };
},
onIceStateChange(callback: IceStateCallback): () => void {
iceStateCallbacks.add(callback);
return () => { iceStateCallbacks.delete(callback); };
},
destroy(): void {
if (destroyed) return;
destroyed = true;
log.debug("WebRTC service destroying", { remoteStreams: remoteStreams.length, localSenders: localSenders.length });
if (pc !== null) {
pc.removeEventListener("icecandidate", handleIceCandidateEvent);
pc.removeEventListener("track", handleTrackEvent);
pc.removeEventListener("connectionstatechange", handleConnectionStateChange);
pc.removeEventListener("iceconnectionstatechange", handleIceConnectionStateChange);
pc.removeEventListener("negotiationneeded", handleNegotiationNeeded);
pc.close();
pc = null;
}
localSenders = [];
remoteStreams = [];
pendingIceCandidates.length = 0;
iceCandidateCallbacks.clear();
remoteTrackCallbacks.clear();
stateChangeCallbacks.clear();
iceStateCallbacks.clear();
},
};
}
+1 -1
View File
@@ -12,7 +12,7 @@ import { createWsClient } from "@lib/ws";
import { wireDispatcher } from "@lib/dispatcher";
import { authStore, setAuth, clearAuth } from "@stores/auth.store";
import { voiceStore, leaveVoiceChannel } from "@stores/voice.store";
import { leaveVoice as voiceSessionLeave } from "@lib/voiceSession";
import { leaveVoice as voiceSessionLeave } from "@lib/livekitSession";
import { createConnectPage } from "@pages/ConnectPage";
import { createMainPage } from "@pages/MainPage";
import { applyStoredAppearance } from "@components/SettingsOverlay";
+1 -12
View File
@@ -27,7 +27,6 @@ import { closeSettings, toggleMemberList, uiStore } from "@stores/ui.store";
import { channelsStore, getActiveChannel } from "@stores/channels.store";
import { voiceStore } from "@stores/voice.store";
import {
joinVoice,
leaveVoice as voiceSessionLeave,
setOnRemoteVideo,
setOnRemoteVideoRemoved,
@@ -35,7 +34,7 @@ import {
setWsClient,
setOnError as setVoiceOnError,
clearOnError as clearVoiceOnError,
} from "@lib/voiceSession";
} from "@lib/livekitSession";
import { buildChatHeader } from "./main-page/ChatHeader";
import { setServerHost } from "@components/message-list/renderers";
import {
@@ -177,16 +176,6 @@ export function createMainPage(options: MainPageOptions): MountableComponent {
}),
);
// --- Voice config: trigger WebRTC join flow ---
unsubscribers.push(
ws.on("voice_config", (payload) => {
void joinVoice(payload.channel_id, payload, async () => {
const creds = await api.getVoiceCredentials();
return creds.ice_servers;
});
}),
);
// --- Main .app row ---
const app = createElement("div", { class: "app", "data-testid": "app-layout" });
@@ -4,7 +4,7 @@
*/
import { voiceStore } from "@stores/voice.store";
import { getLocalCameraStream } from "@lib/voiceSession";
import { getLocalCameraStream } from "@lib/livekitSession";
import type { VideoGridComponent } from "@components/VideoGrid";
// ---------------------------------------------------------------------------
@@ -17,7 +17,7 @@ import {
setDeafened as voiceSessionSetDeafened,
enableCamera,
disableCamera,
} from "@lib/voiceSession";
} from "@lib/livekitSession";
const log = createLogger("voice-callbacks");
+1 -1
View File
@@ -6,7 +6,7 @@
import { createStore } from "@lib/store";
import type { UserWithRole } from "@lib/types";
import { resetVoiceStore } from "@stores/voice.store";
import { leaveVoice } from "@lib/voiceSession";
import { leaveVoice } from "@lib/livekitSession";
export interface AuthState {
readonly token: string | null;
+4 -4
View File
@@ -56,7 +56,7 @@ vi.mock("@lib/logger", () => ({
}));
// setMuted is called internally by the ptt-state listener — mock to isolate
vi.mock("../../src/lib/voiceSession", () => ({
vi.mock("../../src/lib/livekitSession", () => ({
setMuted: vi.fn(),
}));
@@ -465,7 +465,7 @@ describe("ptt-state event listener", () => {
});
it("calls setMuted(false) when PTT is pressed (payload true) and in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/voiceSession");
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
@@ -487,7 +487,7 @@ describe("ptt-state event listener", () => {
});
it("calls setMuted(true) when PTT is released (payload false) and in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/voiceSession");
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
@@ -508,7 +508,7 @@ describe("ptt-state event listener", () => {
});
it("does not call setMuted when not in a voice channel", async () => {
const { setMuted } = await import("../../src/lib/voiceSession");
const { setMuted } = await import("../../src/lib/livekitSession");
const mockSetMuted = vi.mocked(setMuted);
mockSetMuted.mockClear();
@@ -25,7 +25,7 @@ vi.mock("@stores/ui.store", () => ({
setTheme: (...args: unknown[]) => mockSetTheme(...args),
}));
vi.mock("@lib/voiceSession", () => ({
vi.mock("@lib/livekitSession", () => ({
switchInputDevice: vi.fn().mockResolvedValue(undefined),
switchOutputDevice: vi.fn().mockResolvedValue(undefined),
setVoiceSensitivity: vi.fn(),
@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSoundboard } from "../../src/components/Soundboard";
import type { SoundItem } from "../../src/components/Soundboard";
const testSounds: SoundItem[] = [
{ id: 1, name: "Airhorn", durationMs: 2500 },
{ id: 2, name: "Rimshot", durationMs: 1200 },
{ id: 3, name: "Sad Trombone", durationMs: 3800 },
];
describe("Soundboard", () => {
let container: HTMLDivElement;
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement("div");
document.body.appendChild(container);
});
afterEach(() => {
vi.useRealTimers();
container.remove();
});
it("renders empty state when no sounds", () => {
const board = createSoundboard({
sounds: [],
onPlaySound: vi.fn(),
});
board.mount(container);
const empty = container.querySelector(".soundboard__empty");
expect(empty).not.toBeNull();
expect(empty!.textContent).toBe("No sounds available");
board.destroy?.();
});
it("renders sound buttons with names and durations", () => {
const board = createSoundboard({
sounds: testSounds,
onPlaySound: vi.fn(),
});
board.mount(container);
const buttons = container.querySelectorAll(".sound-btn");
expect(buttons.length).toBe(3);
const names = Array.from(container.querySelectorAll(".sound-btn__name")).map(
(el) => el.textContent,
);
expect(names).toEqual(["Airhorn", "Rimshot", "Sad Trombone"]);
const durations = Array.from(container.querySelectorAll(".sound-btn__duration")).map(
(el) => el.textContent,
);
expect(durations).toEqual(["2.5s", "1.2s", "3.8s"]);
board.destroy?.();
});
it("calls onPlaySound with correct id when button is clicked", () => {
const onPlaySound = vi.fn();
const board = createSoundboard({
sounds: testSounds,
onPlaySound,
});
board.mount(container);
const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>;
buttons[1]!.click();
expect(onPlaySound).toHaveBeenCalledWith(2);
board.destroy?.();
});
it("disables all buttons during cooldown", () => {
const board = createSoundboard({
sounds: testSounds,
onPlaySound: vi.fn(),
});
board.mount(container);
const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>;
buttons[0]!.click();
// All buttons should be disabled
for (const btn of buttons) {
expect(btn.disabled).toBe(true);
expect(btn.classList.contains("sound-btn--cooldown")).toBe(true);
}
board.destroy?.();
});
it("re-enables buttons after cooldown period", () => {
const board = createSoundboard({
sounds: testSounds,
onPlaySound: vi.fn(),
});
board.mount(container);
const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>;
buttons[0]!.click();
// Advance past cooldown (3000ms)
vi.advanceTimersByTime(3000);
for (const btn of buttons) {
expect(btn.disabled).toBe(false);
expect(btn.classList.contains("sound-btn--cooldown")).toBe(false);
}
board.destroy?.();
});
it("does not fire onPlaySound when button is disabled", () => {
const onPlaySound = vi.fn();
const board = createSoundboard({
sounds: testSounds,
onPlaySound,
});
board.mount(container);
const buttons = container.querySelectorAll(".sound-btn") as NodeListOf<HTMLButtonElement>;
buttons[0]!.click(); // first click triggers cooldown
onPlaySound.mockClear();
buttons[1]!.click(); // should not fire since disabled
expect(onPlaySound).not.toHaveBeenCalled();
board.destroy?.();
});
it("cleans up on destroy", () => {
const board = createSoundboard({
sounds: testSounds,
onPlaySound: vi.fn(),
});
board.mount(container);
expect(container.querySelector(".soundboard")).not.toBeNull();
board.destroy?.();
expect(container.querySelector(".soundboard")).toBeNull();
});
});
+5 -5
View File
@@ -186,12 +186,12 @@ describe("ClientMessage types", () => {
expect(soundboard.type).toBe("soundboard_play");
});
it("includes voice signaling types", () => {
const offer: ClientMessage = {
type: "voice_offer",
payload: { channel_id: 10, sdp: "v=0..." },
it("includes voice mute type", () => {
const mute: ClientMessage = {
type: "voice_mute",
payload: { muted: true },
};
expect(offer.type).toBe("voice_offer");
expect(mute.type).toBe("voice_mute");
});
});
@@ -1,35 +0,0 @@
/**
* Unit tests for VAD pure functions.
*/
import { describe, it, expect } from "vitest";
import { sensitivityToThreshold } from "@lib/vad";
describe("sensitivityToThreshold", () => {
it("maps 0% sensitivity to max threshold (0.15)", () => {
expect(sensitivityToThreshold(0)).toBeCloseTo(0.15);
});
it("maps 100% sensitivity to zero threshold", () => {
expect(sensitivityToThreshold(100)).toBeCloseTo(0);
});
it("maps 50% sensitivity to half max threshold", () => {
expect(sensitivityToThreshold(50)).toBeCloseTo(0.075);
});
it("maps 75% sensitivity to quarter max threshold", () => {
expect(sensitivityToThreshold(75)).toBeCloseTo(0.0375);
});
it("is monotonically decreasing (higher sensitivity = lower threshold)", () => {
const t0 = sensitivityToThreshold(0);
const t25 = sensitivityToThreshold(25);
const t50 = sensitivityToThreshold(50);
const t75 = sensitivityToThreshold(75);
const t100 = sensitivityToThreshold(100);
expect(t0).toBeGreaterThan(t25);
expect(t25).toBeGreaterThan(t50);
expect(t50).toBeGreaterThan(t75);
expect(t75).toBeGreaterThan(t100);
});
});
@@ -13,7 +13,7 @@ vi.mock("@stores/voice.store", () => ({
voiceStore: { getState: mockVoiceStoreGetState },
}));
vi.mock("@lib/voiceSession", () => ({
vi.mock("@lib/livekitSession", () => ({
getLocalCameraStream: mockGetLocalCameraStream,
}));
@@ -1,242 +0,0 @@
/**
* Unit tests for the Video Device Manager.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createVideoManager } from "@lib/video";
import type { VideoManager } from "@lib/video";
// ---------------------------------------------------------------------------
// Mock navigator.mediaDevices
// ---------------------------------------------------------------------------
function createMockTrack(kind: string): MediaStreamTrack {
return {
kind,
stop: vi.fn(),
getSettings: () => ({ deviceId: "cam-1", width: 1280, height: 720, frameRate: 30 }),
} as unknown as MediaStreamTrack;
}
function createMockStream(tracks: MediaStreamTrack[]): MediaStream {
return {
getTracks: () => tracks,
getVideoTracks: () => tracks.filter((t) => t.kind === "video"),
getAudioTracks: () => tracks.filter((t) => t.kind === "audio"),
} as unknown as MediaStream;
}
const mockDevices: MediaDeviceInfo[] = [
{ deviceId: "cam-1", label: "Front Camera", kind: "videoinput", groupId: "g1", toJSON: () => ({}) },
{ deviceId: "cam-2", label: "Back Camera", kind: "videoinput", groupId: "g2", toJSON: () => ({}) },
{ deviceId: "mic-1", label: "Microphone", kind: "audioinput", groupId: "g3", toJSON: () => ({}) },
{ deviceId: "spk-1", label: "Speakers", kind: "audiooutput", groupId: "g4", toJSON: () => ({}) },
];
let deviceChangeListeners: Array<() => void>;
beforeEach(() => {
deviceChangeListeners = [];
Object.defineProperty(navigator, "mediaDevices", {
value: {
enumerateDevices: vi.fn().mockResolvedValue(mockDevices),
getUserMedia: vi.fn().mockResolvedValue(
createMockStream([createMockTrack("video")]),
),
addEventListener: vi.fn((event: string, handler: () => void) => {
if (event === "devicechange") deviceChangeListeners.push(handler);
}),
removeEventListener: vi.fn((event: string, handler: () => void) => {
deviceChangeListeners = deviceChangeListeners.filter((h) => h !== handler);
}),
},
writable: true,
configurable: true,
});
});
afterEach(() => {
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("VideoManager", () => {
let manager: VideoManager;
beforeEach(() => {
manager = createVideoManager();
});
afterEach(() => {
manager.destroy();
});
describe("enumerateDevices", () => {
it("returns only videoinput devices", async () => {
const devices = await manager.enumerateDevices();
expect(devices).toHaveLength(2);
expect(devices.every((d) => d.kind === "videoinput")).toBe(true);
});
it("returns deviceId and label for each device", async () => {
const devices = await manager.enumerateDevices();
expect(devices[0]).toEqual({
deviceId: "cam-1",
label: "Front Camera",
kind: "videoinput",
});
expect(devices[1]).toEqual({
deviceId: "cam-2",
label: "Back Camera",
kind: "videoinput",
});
});
});
describe("getCameraStream", () => {
it("calls getUserMedia with correct video constraints and no audio", async () => {
await manager.getCameraStream("cam-1");
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: { exact: "cam-1" },
},
audio: false,
});
});
it("uses default device when no deviceId is provided", async () => {
await manager.getCameraStream();
expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith({
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
deviceId: undefined,
},
audio: false,
});
});
it("returns the media stream", async () => {
const stream = await manager.getCameraStream();
expect(stream).toBeDefined();
expect(stream.getVideoTracks()).toHaveLength(1);
});
it("stores the stream as current stream", async () => {
expect(manager.getCurrentStream()).toBeNull();
const stream = await manager.getCameraStream();
expect(manager.getCurrentStream()).toBe(stream);
});
it("stops previous stream when acquiring a new one", async () => {
const track1 = createMockTrack("video");
const stream1 = createMockStream([track1]);
vi.mocked(navigator.mediaDevices.getUserMedia)
.mockResolvedValueOnce(stream1)
.mockResolvedValueOnce(createMockStream([createMockTrack("video")]));
await manager.getCameraStream("cam-1");
await manager.getCameraStream("cam-2");
expect(track1.stop).toHaveBeenCalled();
});
});
describe("stopCameraStream", () => {
it("stops all tracks on the current stream", async () => {
const track = createMockTrack("video");
const stream = createMockStream([track]);
vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(stream);
await manager.getCameraStream();
manager.stopCameraStream();
expect(track.stop).toHaveBeenCalled();
expect(manager.getCurrentStream()).toBeNull();
});
it("does nothing when no stream is active", () => {
// Should not throw
manager.stopCameraStream();
expect(manager.getCurrentStream()).toBeNull();
});
});
describe("onDeviceChange", () => {
it("registers a listener for device changes", () => {
expect(navigator.mediaDevices.addEventListener).toHaveBeenCalledWith(
"devicechange",
expect.any(Function),
);
});
it("notifies callbacks with only video devices on change", async () => {
const callback = vi.fn();
manager.onDeviceChange(callback);
// Trigger the device change event
for (const listener of deviceChangeListeners) {
listener();
}
// Wait for async enumeration
await vi.waitFor(() => expect(callback).toHaveBeenCalled());
const devices = callback.mock.calls[0]![0];
expect(devices).toHaveLength(2);
expect(devices.every((d: { kind: string }) => d.kind === "videoinput")).toBe(true);
});
it("returns an unsubscribe function", async () => {
const callback = vi.fn();
const unsub = manager.onDeviceChange(callback);
unsub();
for (const listener of deviceChangeListeners) {
listener();
}
// Give time for any async callback
await new Promise((r) => { setTimeout(r, 50); });
expect(callback).not.toHaveBeenCalled();
});
});
describe("destroy", () => {
it("removes the devicechange event listener", () => {
manager.destroy();
expect(navigator.mediaDevices.removeEventListener).toHaveBeenCalledWith(
"devicechange",
expect.any(Function),
);
});
it("stops the current stream on destroy", async () => {
const track = createMockTrack("video");
const stream = createMockStream([track]);
vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValueOnce(stream);
await manager.getCameraStream();
manager.destroy();
expect(track.stop).toHaveBeenCalled();
});
it("throws on enumerateDevices after destroy", async () => {
manager.destroy();
await expect(manager.enumerateDevices()).rejects.toThrow("VideoManager has been destroyed");
});
it("throws on getCameraStream after destroy", async () => {
manager.destroy();
await expect(manager.getCameraStream()).rejects.toThrow("VideoManager has been destroyed");
});
});
});
@@ -42,7 +42,7 @@ vi.mock("@stores/voice.store", () => ({
setLocalScreenshare: mockSetLocalScreenshare,
}));
vi.mock("@lib/voiceSession", () => ({
vi.mock("@lib/livekitSession", () => ({
leaveVoice: mockVoiceSessionLeave,
setMuted: mockSetMuted,
setDeafened: mockSetDeafened,
@@ -1,435 +0,0 @@
/**
* Unit tests for voice session camera (video) lifecycle:
* enableCamera, disableCamera, setOnRemoteVideo, clearOnRemoteVideo.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Mock helpers
// ---------------------------------------------------------------------------
function createMockTrack(kind: "video" | "audio"): MediaStreamTrack {
return {
kind,
stop: vi.fn(),
enabled: true,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
getSettings: () => ({ deviceId: "cam-1", width: 1280, height: 720, frameRate: 30 }),
} as unknown as MediaStreamTrack;
}
function createMockStream(tracks: MediaStreamTrack[]): MediaStream {
return {
id: "mock-stream-1",
getTracks: () => [...tracks],
getVideoTracks: () => tracks.filter((t) => t.kind === "video"),
getAudioTracks: () => tracks.filter((t) => t.kind === "audio"),
onremovetrack: null,
} as unknown as MediaStream;
}
// ---------------------------------------------------------------------------
// Track mock calls
// ---------------------------------------------------------------------------
const mockWsSend = vi.fn();
const mockAddVideoTrack = vi.fn();
const mockRemoveVideoTrack = vi.fn();
const mockCreateOffer = vi.fn().mockResolvedValue("mock-sdp-offer");
const mockSetLocalStream = vi.fn();
const mockDestroy = vi.fn();
const mockOnIceCandidate = vi.fn().mockReturnValue(() => {});
const mockOnRemoteTrack = vi.fn().mockReturnValue(() => {});
const mockOnStateChange = vi.fn().mockReturnValue(() => {});
const mockOnIceStateChange = vi.fn().mockReturnValue(() => {});
const mockCreateConnection = vi.fn();
const mockGetCameraStream = vi.fn();
const mockVideoManagerDestroy = vi.fn();
const mockSender = { track: null } as unknown as RTCRtpSender;
// Prefs storage for tests
const testPrefs = new Map<string, unknown>();
// ---------------------------------------------------------------------------
// Module mocks
// ---------------------------------------------------------------------------
vi.mock("../../src/lib/webrtc", () => ({
createWebRtcService: () => ({
createConnection: mockCreateConnection,
createOffer: mockCreateOffer,
handleAnswer: vi.fn(),
handleServerOffer: vi.fn(),
handleIceCandidate: vi.fn(),
addVideoTrack: mockAddVideoTrack,
removeVideoTrack: mockRemoveVideoTrack,
setLocalStream: mockSetLocalStream,
replaceTrack: vi.fn(),
getRemoteStreams: () => [],
setMuted: vi.fn(),
setSilenced: vi.fn(),
onIceCandidate: mockOnIceCandidate,
onRemoteTrack: mockOnRemoteTrack,
onStateChange: mockOnStateChange,
onIceStateChange: mockOnIceStateChange,
destroy: mockDestroy,
}),
}));
vi.mock("../../src/lib/audio", () => ({
createAudioManager: () => ({
getUserMedia: vi.fn().mockResolvedValue(
createMockStream([createMockTrack("audio")]),
),
destroy: vi.fn(),
}),
}));
vi.mock("../../src/lib/video", () => ({
createVideoManager: () => ({
getCameraStream: mockGetCameraStream,
stopCameraStream: vi.fn(),
getCurrentStream: () => null,
enumerateDevices: vi.fn().mockResolvedValue([]),
onDeviceChange: vi.fn().mockReturnValue(() => {}),
destroy: mockVideoManagerDestroy,
}),
}));
vi.mock("../../src/lib/vad", () => ({
createVadDetector: () => ({
start: vi.fn(),
destroy: vi.fn(),
onSpeakingChange: vi.fn().mockReturnValue(() => {}),
setThreshold: vi.fn(),
}),
sensitivityToThreshold: (s: number) => s,
}));
vi.mock("../../src/lib/noise-suppression", () => ({
createNoiseSuppressor: () => ({
process: vi.fn().mockImplementation((stream: MediaStream) => Promise.resolve(stream)),
destroy: vi.fn(),
}),
}));
vi.mock("../../src/components/settings/helpers", () => ({
STORAGE_PREFIX: "owncord:settings:",
loadPref: (key: string, fallback: unknown) => testPrefs.get(key) ?? fallback,
savePref: (key: string, value: unknown) => testPrefs.set(key, value),
THEMES: { dark: {}, midnight: {}, light: {} },
applyTheme: vi.fn(),
}));
vi.mock("../../src/lib/logger", () => ({
createLogger: () => ({
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
// ---------------------------------------------------------------------------
// Import the module under test (AFTER mocks are set up)
// ---------------------------------------------------------------------------
import {
setWsClient,
setOnError,
clearOnError,
joinVoice,
leaveVoice,
enableCamera,
disableCamera,
setOnRemoteVideo,
setOnRemoteVideoRemoved,
clearOnRemoteVideo,
} from "../../src/lib/voiceSession";
import type { WsClient } from "../../src/lib/ws";
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
function createMockWs(): WsClient {
return {
send: mockWsSend,
close: vi.fn(),
onMessage: vi.fn().mockReturnValue(() => {}),
onOpen: vi.fn().mockReturnValue(() => {}),
onClose: vi.fn().mockReturnValue(() => {}),
onError: vi.fn().mockReturnValue(() => {}),
isConnected: vi.fn().mockReturnValue(true),
} as unknown as WsClient;
}
const DEFAULT_CONFIG = {
channel_id: 42,
quality: "medium" as const,
bitrate: 64000,
threshold_mode: "forwarding" as const,
mixing_threshold: 3,
top_speakers: 5,
max_users: 25,
};
/** Set up a joined voice session so enableCamera/disableCamera have context. */
async function setupActiveSession(): Promise<void> {
const ws = createMockWs();
setWsClient(ws);
await joinVoice(42, DEFAULT_CONFIG);
// Clear setup-related mock calls so tests only see camera-related calls
mockWsSend.mockClear();
mockCreateOffer.mockClear();
mockAddVideoTrack.mockClear();
mockRemoveVideoTrack.mockClear();
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("Voice session — camera lifecycle", () => {
let errorCb: ReturnType<typeof vi.fn>;
beforeEach(async () => {
testPrefs.clear();
mockWsSend.mockClear();
mockCreateOffer.mockClear();
mockAddVideoTrack.mockClear();
mockRemoveVideoTrack.mockClear();
mockGetCameraStream.mockReset();
mockGetCameraStream.mockResolvedValue(
createMockStream([createMockTrack("video")]),
);
mockAddVideoTrack.mockReturnValue(mockSender);
mockCreateOffer.mockResolvedValue("mock-sdp-offer");
errorCb = vi.fn();
setOnError(errorCb);
});
afterEach(() => {
clearOnError();
clearOnRemoteVideo();
// leaveVoice to reset singleton state
leaveVoice(false);
});
// -----------------------------------------------------------------------
// enableCamera
// -----------------------------------------------------------------------
describe("enableCamera", () => {
it("acquires camera stream and adds video track to WebRTC", async () => {
await setupActiveSession();
await enableCamera();
expect(mockGetCameraStream).toHaveBeenCalled();
expect(mockAddVideoTrack).toHaveBeenCalledTimes(1);
// The stream passed to addVideoTrack should be the camera stream
const passedStream = mockAddVideoTrack.mock.calls[0]![0] as MediaStream;
expect(passedStream.getVideoTracks()).toHaveLength(1);
});
it("sends voice_offer for renegotiation after adding track", async () => {
await setupActiveSession();
await enableCamera();
const offerMessages = mockWsSend.mock.calls.filter(
(c) => (c[0] as { type: string }).type === "voice_offer",
);
expect(offerMessages.length).toBeGreaterThanOrEqual(1);
expect(offerMessages[0]![0]).toEqual({
type: "voice_offer",
payload: { channel_id: 42, sdp: "mock-sdp-offer" },
});
});
it("sends voice_camera enabled=true AFTER successful track addition", async () => {
await setupActiveSession();
await enableCamera();
// voice_camera should be sent after addVideoTrack and voice_offer
const cameraMessages = mockWsSend.mock.calls.filter(
(c) => (c[0] as { type: string }).type === "voice_camera",
);
expect(cameraMessages).toHaveLength(1);
expect(cameraMessages[0]![0]).toEqual({
type: "voice_camera",
payload: { enabled: true },
});
// Verify ordering: voice_offer comes before voice_camera
const allTypes = mockWsSend.mock.calls.map(
(c) => (c[0] as { type: string }).type,
);
const offerIdx = allTypes.indexOf("voice_offer");
const cameraIdx = allTypes.indexOf("voice_camera");
expect(offerIdx).toBeLessThan(cameraIdx);
});
it("handles permission denied error with toast", async () => {
await setupActiveSession();
mockGetCameraStream.mockRejectedValue(
new DOMException("Permission denied", "NotAllowedError"),
);
await enableCamera();
expect(errorCb).toHaveBeenCalledWith("Camera permission denied");
});
it("handles no camera found error with toast", async () => {
await setupActiveSession();
mockGetCameraStream.mockRejectedValue(
new DOMException("No device found", "NotFoundError"),
);
await enableCamera();
expect(errorCb).toHaveBeenCalledWith("No camera found");
});
it("uses saved video device preference", async () => {
testPrefs.set("videoInputDevice", "cam-back");
await setupActiveSession();
await enableCamera();
expect(mockGetCameraStream).toHaveBeenCalledWith("cam-back");
});
it("does nothing when no active voice session", async () => {
// No joinVoice, so no webrtcService
await enableCamera();
expect(mockGetCameraStream).not.toHaveBeenCalled();
expect(errorCb).toHaveBeenCalledWith("Join a voice channel first");
});
it("does nothing when camera is already enabled", async () => {
await setupActiveSession();
await enableCamera();
mockGetCameraStream.mockClear();
mockWsSend.mockClear();
await enableCamera();
expect(mockGetCameraStream).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// disableCamera
// -----------------------------------------------------------------------
describe("disableCamera", () => {
it("stops camera tracks and removes video track from WebRTC", async () => {
const videoTrack = createMockTrack("video");
const cameraStream = createMockStream([videoTrack]);
mockGetCameraStream.mockResolvedValue(cameraStream);
await setupActiveSession();
await enableCamera();
mockWsSend.mockClear();
await disableCamera();
expect(videoTrack.stop).toHaveBeenCalled();
expect(mockRemoveVideoTrack).toHaveBeenCalledWith(mockSender);
});
it("sends voice_camera enabled=false", async () => {
await setupActiveSession();
await enableCamera();
mockWsSend.mockClear();
await disableCamera();
const cameraMessages = mockWsSend.mock.calls.filter(
(c) => (c[0] as { type: string }).type === "voice_camera",
);
expect(cameraMessages).toHaveLength(1);
expect(cameraMessages[0]![0]).toEqual({
type: "voice_camera",
payload: { enabled: false },
});
});
it("sends voice_offer for renegotiation after removing track", async () => {
await setupActiveSession();
await enableCamera();
mockWsSend.mockClear();
await disableCamera();
const offerMessages = mockWsSend.mock.calls.filter(
(c) => (c[0] as { type: string }).type === "voice_offer",
);
expect(offerMessages).toHaveLength(1);
});
it("is safe to call when camera is not enabled", async () => {
await setupActiveSession();
// Should not throw
await disableCamera();
expect(mockRemoveVideoTrack).not.toHaveBeenCalled();
});
});
// -----------------------------------------------------------------------
// Remote video callbacks
// -----------------------------------------------------------------------
describe("setOnRemoteVideo / clearOnRemoteVideo", () => {
it("registers a remote video callback", () => {
const cb = vi.fn();
setOnRemoteVideo(cb);
// The callback is stored internally — we verify by clearing it
// (no public getter). This tests the registration path.
clearOnRemoteVideo();
// If clearOnRemoteVideo didn't throw and completed, registration succeeded.
expect(true).toBe(true);
});
it("registers a remote video removed callback", () => {
const cb = vi.fn();
setOnRemoteVideoRemoved(cb);
clearOnRemoteVideo();
expect(true).toBe(true);
});
it("clearOnRemoteVideo nullifies both callbacks", () => {
const videoCb = vi.fn();
const removedCb = vi.fn();
setOnRemoteVideo(videoCb);
setOnRemoteVideoRemoved(removedCb);
clearOnRemoteVideo();
// After clearing, new registrations should work without error
setOnRemoteVideo(vi.fn());
setOnRemoteVideoRemoved(vi.fn());
clearOnRemoteVideo();
expect(true).toBe(true);
});
it("can overwrite existing callbacks", () => {
const cb1 = vi.fn();
const cb2 = vi.fn();
setOnRemoteVideo(cb1);
setOnRemoteVideo(cb2);
// No throw — second registration overwrites the first
clearOnRemoteVideo();
expect(true).toBe(true);
});
});
});
@@ -1,247 +0,0 @@
/**
* Unit tests for WebRTC SDP munging (applyOpusSettings) and
* replaceTrack logic via mocked RTCPeerConnection.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createWebRtcService } from "../../src/lib/webrtc";
// The applyOpusSettings function is module-private, so we test the
// SDP manipulation patterns it implements as string transformations.
// This validates the core logic without needing a real PeerConnection.
describe("SDP Opus settings", () => {
const baseSdp = [
"v=0",
"o=- 0 0 IN IP4 127.0.0.1",
"s=-",
"t=0 0",
"m=audio 9 UDP/TLS/RTP/SAVPF 111",
"a=rtpmap:111 opus/48000/2",
"a=fmtp:111 minptime=10;usedtx=1",
"a=mid:0",
"",
].join("\r\n");
// Replicate applyOpusSettings logic for testing
function applyOpusSettings(sdp: string, bitrate: number | undefined): string {
const lines = sdp.split("\r\n");
const result: string[] = [];
let inAudioSection = false;
let bitrateInserted = false;
for (const line of lines) {
let out = line;
if (out.startsWith("m=audio")) {
inAudioSection = true;
bitrateInserted = false;
} else if (out.startsWith("m=")) {
inAudioSection = false;
}
if (out.startsWith("a=fmtp:111 ")) {
if (!out.includes("useinbandfec=")) {
out += ";useinbandfec=1";
}
}
result.push(out);
if (inAudioSection && !bitrateInserted && bitrate !== undefined && out.startsWith("m=audio")) {
result.push(`b=AS:${Math.round(bitrate / 1000)}`);
bitrateInserted = true;
}
}
return result.join("\r\n");
}
it("adds useinbandfec to Opus fmtp line", () => {
const result = applyOpusSettings(baseSdp, undefined);
expect(result).toContain("a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1");
});
it("does not duplicate useinbandfec if already present", () => {
const sdpWithFec = baseSdp.replace(
"a=fmtp:111 minptime=10;usedtx=1",
"a=fmtp:111 minptime=10;usedtx=1;useinbandfec=1",
);
const result = applyOpusSettings(sdpWithFec, undefined);
const matches = result.match(/useinbandfec/g);
expect(matches).toHaveLength(1);
});
it("inserts b=AS bandwidth line after m=audio", () => {
const result = applyOpusSettings(baseSdp, 64000);
const lines = result.split("\r\n");
const mAudioIdx = lines.findIndex((l) => l.startsWith("m=audio"));
expect(lines[mAudioIdx + 1]).toBe("b=AS:64");
});
it("calculates b=AS correctly for different bitrates", () => {
expect(applyOpusSettings(baseSdp, 32000)).toContain("b=AS:32");
expect(applyOpusSettings(baseSdp, 128000)).toContain("b=AS:128");
});
it("does not insert b=AS when bitrate is undefined", () => {
const result = applyOpusSettings(baseSdp, undefined);
expect(result).not.toContain("b=AS:");
});
it("handles multi-section SDP (audio + video)", () => {
const multiSdp = [
"v=0",
"o=- 0 0 IN IP4 127.0.0.1",
"s=-",
"t=0 0",
"m=audio 9 UDP/TLS/RTP/SAVPF 111",
"a=fmtp:111 minptime=10",
"m=video 9 UDP/TLS/RTP/SAVPF 96",
"a=rtpmap:96 VP8/90000",
"",
].join("\r\n");
const result = applyOpusSettings(multiSdp, 64000);
// b=AS should appear after m=audio, not after m=video
const lines = result.split("\r\n");
const audioIdx = lines.findIndex((l) => l.startsWith("m=audio"));
const videoIdx = lines.findIndex((l) => l.startsWith("m=video"));
const basIdx = lines.findIndex((l) => l.startsWith("b=AS:"));
expect(basIdx).toBeGreaterThan(audioIdx);
expect(basIdx).toBeLessThan(videoIdx);
});
});
// ---------------------------------------------------------------------------
// replaceTrack tests — uses mocked RTCPeerConnection
// ---------------------------------------------------------------------------
/** Create a minimal mock MediaStreamTrack. */
function mockTrack(id = "track-1"): MediaStreamTrack {
return {
id,
kind: "audio",
enabled: true,
stop: vi.fn(),
readyState: "live",
} as unknown as MediaStreamTrack;
}
/** Create a minimal mock MediaStream. */
function mockStream(tracks: MediaStreamTrack[] = [mockTrack()]): MediaStream {
return {
id: "stream-1",
getTracks: () => [...tracks],
getAudioTracks: () => tracks.filter((t) => t.kind === "audio"),
getVideoTracks: () => [],
addTrack: vi.fn(),
removeTrack: vi.fn(),
clone: vi.fn(),
active: true,
} as unknown as MediaStream;
}
describe("replaceTrack", () => {
let originalRTCPeerConnection: typeof RTCPeerConnection;
let mockReplaceTrack: ReturnType<typeof vi.fn>;
let mockAddTrack: ReturnType<typeof vi.fn>;
let mockRemoveTrack: ReturnType<typeof vi.fn>;
let mockSender: RTCRtpSender;
beforeEach(() => {
originalRTCPeerConnection = globalThis.RTCPeerConnection;
mockReplaceTrack = vi.fn().mockResolvedValue(undefined);
mockSender = {
track: mockTrack(),
replaceTrack: mockReplaceTrack,
getParameters: vi.fn().mockReturnValue({}),
setParameters: vi.fn(),
} as unknown as RTCRtpSender;
mockAddTrack = vi.fn().mockReturnValue(mockSender);
mockRemoveTrack = vi.fn();
const MockPeerConnection = vi.fn().mockImplementation(() => ({
addTrack: mockAddTrack,
removeTrack: mockRemoveTrack,
close: vi.fn(),
signalingState: "stable",
connectionState: "new",
iceConnectionState: "new",
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
createOffer: vi.fn().mockResolvedValue({ type: "offer", sdp: "v=0\r\n" }),
createAnswer: vi.fn().mockResolvedValue({ type: "answer", sdp: "v=0\r\n" }),
setLocalDescription: vi.fn().mockResolvedValue(undefined),
setRemoteDescription: vi.fn().mockResolvedValue(undefined),
addIceCandidate: vi.fn().mockResolvedValue(undefined),
}));
globalThis.RTCPeerConnection = MockPeerConnection as unknown as typeof RTCPeerConnection;
});
afterEach(() => {
globalThis.RTCPeerConnection = originalRTCPeerConnection;
});
it("swaps track on existing sender via sender.replaceTrack", async () => {
const service = createWebRtcService();
const stream1 = mockStream();
service.createConnection({ iceServers: [] });
// Initial attach — sets up senders
service.setLocalStream(stream1);
expect(mockAddTrack).toHaveBeenCalledTimes(1);
// Replace with new stream — should use replaceTrack, NOT removeTrack+addTrack
const newTrack = mockTrack("track-2");
const stream2 = mockStream([newTrack]);
await service.replaceTrack(stream2);
expect(mockReplaceTrack).toHaveBeenCalledWith(newTrack);
expect(mockRemoveTrack).not.toHaveBeenCalled();
// addTrack should still be 1 (from initial setLocalStream, not from replaceTrack)
expect(mockAddTrack).toHaveBeenCalledTimes(1);
service.destroy();
});
it("falls back to addTrack when no senders exist", async () => {
const service = createWebRtcService();
service.createConnection({ iceServers: [] });
// No setLocalStream — no existing senders
const newTrack = mockTrack("track-new");
const stream = mockStream([newTrack]);
await service.replaceTrack(stream);
// Should fall back to addTrack
expect(mockAddTrack).toHaveBeenCalledTimes(1);
expect(mockReplaceTrack).not.toHaveBeenCalled();
service.destroy();
});
it("applies mute+silence state to new track after replaceTrack", async () => {
const service = createWebRtcService();
// Create a track that the mock sender will reference
const senderTrack = mockTrack("track-sender");
Object.defineProperty(mockSender, "track", { value: senderTrack, writable: true, configurable: true });
const stream1 = mockStream([mockTrack("track-1")]);
service.createConnection({ iceServers: [] });
service.setLocalStream(stream1);
// Mute the stream — operates on sender.track
service.setMuted(true);
expect(senderTrack.enabled).toBe(false);
// Replace track — the new track should also get mute state applied
const track2 = mockTrack("track-2");
track2.enabled = true; // starts enabled
const stream2 = mockStream([track2]);
// After replaceTrack, the sender's track reference updates
Object.defineProperty(mockSender, "track", { value: track2, writable: true, configurable: true });
await service.replaceTrack(stream2);
// applyTrackEnabled runs after replaceTrack — should mute the new track
expect(track2.enabled).toBe(false);
service.destroy();
});
});