Files
OwnCord/Client/tauri-client/public/vad-worklet.js
T
jevb 9f381f54e9 feat: voice/video polish — refactor, AudioWorklet VAD, bug fixes, UX improvements
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
2026-03-29 00:51:54 +01:00

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);