mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
Research-driven voice/video polish pass based on Discord/TeamSpeak comparison. Refactor: - Split livekitSession.ts (1,509 lines) into 4 modules: audioPipeline.ts, audioElements.ts, deviceManager.ts + facade in livekitSession.ts - Facade pattern preserves all existing exports (zero breaking changes) AudioWorklet VAD: - Migrated VAD from setTimeout polling to AudioWorklet (vad-worklet.js) - Runs on audio thread, works when app is backgrounded - Graceful fallback to setTimeout if AudioWorklet unavailable Bug fixes: - Token TTL extended from 4h to 24h (eliminates fragile long sessions) - Ghost voice state: retry with exponential backoff (3 attempts, 100-400ms) - Client token refresh adjusted to 23h (1h before expiry) UX improvements: - Speaker indicator: pulsing green glow animation (speak-pulse keyframes) - Permission recovery: "Grant Microphone" button in VoiceWidget for listen-only mode with listenOnly state in voiceStore - Device hot-swap: devicechange listener with 500ms debounce, auto-fallback to default device, toast notification - Camera/screenshare stop: toast feedback on disable - Connection quality: auto-expand stats pane on poor/bad quality (3s debounce) - Bandwidth display: human-readable Mbps in stats pane (formatBitrate) Observability: - Voice session metrics: voice_sessions counter on /api/v1/metrics endpoint Tests: - 55 new unit tests for audioPipeline + audioElements modules - 22 new Go tests for HTTPS proxy (WebSocket upgrade, origin validation, path blocking) - 11 new voice E2E tests (lifecycle, widget, speaker indicators) - Pre-refactor snapshot tests for livekitSession public API Docs: - DESIGN.md: full design system documentation (tokens, typography, colors, spacing, motion, voice-specific tokens) - VOICE-COMPARISON-MATRIX.md: 25-behavior comparison across Discord, TeamSpeak, Guilded - voice-video-polish.md: CEO plan with scope decisions
98 lines
3.4 KiB
JavaScript
98 lines
3.4 KiB
JavaScript
// =============================================================================
|
|
// VAD (Voice Activity Detection) AudioWorklet Processor
|
|
//
|
|
// Runs on the audio rendering thread. Computes RMS energy per audio frame and
|
|
// sends gating decisions to the main thread via MessagePort. This replaces
|
|
// setTimeout-based polling which pauses when the app is backgrounded.
|
|
//
|
|
// Protocol:
|
|
// Main → Worklet: { type: "config", threshold: number, gateOnFrames: number, gateOffFrames: number }
|
|
// Main → Worklet: { type: "stop" }
|
|
// Worklet → Main: { type: "gate", gated: boolean }
|
|
// Worklet → Main: { type: "rms", value: number } (optional, for VAD indicator)
|
|
// =============================================================================
|
|
|
|
class VadProcessor extends AudioWorkletProcessor {
|
|
constructor() {
|
|
super();
|
|
this._threshold = 0.05;
|
|
this._gateOnFrames = 12; // ~200ms of silence before gating
|
|
this._gateOffFrames = 2; // ~33ms of speech before ungating
|
|
this._silentFrames = 0;
|
|
this._speechFrames = 0;
|
|
this._gated = false;
|
|
this._active = true;
|
|
this._startupFrames = 0;
|
|
this._startupGrace = 30; // ~500ms grace period
|
|
this._frameCounter = 0; // for throttled RMS updates
|
|
|
|
this.port.onmessage = (event) => {
|
|
if (event.data.type === "config") {
|
|
this._threshold = event.data.threshold;
|
|
if (event.data.gateOnFrames !== undefined) this._gateOnFrames = event.data.gateOnFrames;
|
|
if (event.data.gateOffFrames !== undefined) this._gateOffFrames = event.data.gateOffFrames;
|
|
// Reset state on config change
|
|
this._silentFrames = 0;
|
|
this._speechFrames = 0;
|
|
this._startupFrames = 0;
|
|
if (this._gated) {
|
|
this._gated = false;
|
|
this.port.postMessage({ type: "gate", gated: false });
|
|
}
|
|
} else if (event.data.type === "stop") {
|
|
this._active = false;
|
|
}
|
|
};
|
|
}
|
|
|
|
process(inputs) {
|
|
if (!this._active) return false; // Returning false stops the processor
|
|
|
|
const input = inputs[0];
|
|
if (input === undefined || input.length === 0 || input[0] === undefined) return true;
|
|
|
|
const samples = input[0];
|
|
let sum = 0;
|
|
for (let i = 0; i < samples.length; i++) {
|
|
const v = samples[i];
|
|
sum += v * v;
|
|
}
|
|
const rms = Math.sqrt(sum / samples.length);
|
|
|
|
// Grace period: don't gate for the first ~500ms to let audio settle
|
|
if (this._startupFrames < this._startupGrace) {
|
|
this._startupFrames++;
|
|
return true;
|
|
}
|
|
|
|
// Send RMS value to main thread every ~6 frames (~50ms at 128 samples/frame @ 48kHz)
|
|
// This is used for the VAD indicator bar in the UI
|
|
this._frameCounter++;
|
|
if (this._frameCounter >= 6) {
|
|
this._frameCounter = 0;
|
|
this.port.postMessage({ type: "rms", value: rms });
|
|
}
|
|
|
|
// Gate logic (identical to the setTimeout version)
|
|
if (rms < this._threshold) {
|
|
this._speechFrames = 0;
|
|
this._silentFrames++;
|
|
if (!this._gated && this._silentFrames >= this._gateOnFrames) {
|
|
this._gated = true;
|
|
this.port.postMessage({ type: "gate", gated: true });
|
|
}
|
|
} else {
|
|
this._silentFrames = 0;
|
|
this._speechFrames++;
|
|
if (this._gated && this._speechFrames >= this._gateOffFrames) {
|
|
this._gated = false;
|
|
this.port.postMessage({ type: "gate", gated: false });
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
registerProcessor("vad-processor", VadProcessor);
|