2026-03-29 00:51:54 +01:00
|
|
|
// =============================================================================
|
|
|
|
|
// 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;
|
2026-08-20 20:45:30 +02:00
|
|
|
// process() runs once per 128-sample render quantum (2.667ms @ 48kHz —
|
|
|
|
|
// see audioPipeline.ts's `new AudioContext({ sampleRate: 48000 })`), NOT
|
|
|
|
|
// once per ~16ms poll like the setTimeout fallback. These frame counts
|
|
|
|
|
// are therefore ~6x the fallback's, so both paths gate on the same
|
|
|
|
|
// wall-clock timing.
|
2026-08-28 06:54:32 +02:00
|
|
|
this._gateOnFrames = 75; // ~200ms of silence before gating
|
|
|
|
|
this._gateOffFrames = 12; // ~32ms of speech before ungating
|
2026-03-29 00:51:54 +01:00
|
|
|
this._silentFrames = 0;
|
|
|
|
|
this._speechFrames = 0;
|
|
|
|
|
this._gated = false;
|
|
|
|
|
this._active = true;
|
|
|
|
|
this._startupFrames = 0;
|
2026-08-28 06:54:32 +02:00
|
|
|
this._startupGrace = 188; // ~500ms grace period
|
|
|
|
|
this._frameCounter = 0; // for throttled RMS updates
|
2026-03-29 00:51:54 +01:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-20 20:45:30 +02:00
|
|
|
// Send RMS value to main thread every ~19 frames (~50ms at 128 samples/frame @ 48kHz)
|
2026-03-29 00:51:54 +01:00
|
|
|
// This is used for the VAD indicator bar in the UI
|
|
|
|
|
this._frameCounter++;
|
2026-08-20 20:45:30 +02:00
|
|
|
if (this._frameCounter >= 19) {
|
2026-03-29 00:51:54 +01:00
|
|
|
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);
|