mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
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:
Generated
+136
-3
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user