import { EventEmitter } from 'node:events'; import { pcmRms } from './audio-pipewire.js'; export const DEFAULT_VAD = Object.freeze({ threshold: 0.6, minSpeechDurationMs: 300, minSilenceDurationMs: 700, maxSpeechDurationMs: 15_000, speechPadMs: 200, }); /** A conservative local gate around QVAC's stream. It bounds audio sent to * ASR and gives the loop deterministic utterance boundaries in tests. */ export class VadSegmenter extends EventEmitter { constructor({ sampleRate = 16_000, frameMs = 20, params = {} } = {}) { super(); this.sampleRate = sampleRate; this.frameMs = frameMs; this.params = { ...DEFAULT_VAD, ...params }; this.reset(); } reset() { this.speaking = false; this.buffer = []; this.speechMs = 0; this.silenceMs = 0; } push(frame) { const chunk = Buffer.from(frame || ''); const rms = pcmRms(chunk); const voiced = rms >= this.params.threshold / 10; // PCM RMS is 0..1; QVAC threshold is posterior-like. this.emit('level', rms); if (!this.speaking && voiced) { this.speaking = true; this.speechMs = 0; this.silenceMs = 0; this.buffer = []; this.emit('speechStart'); } if (!this.speaking) return; this.buffer.push(chunk); if (voiced) { this.speechMs += this.frameMs; this.silenceMs = 0; } else { this.silenceMs += this.frameMs; } if (this.speechMs >= this.params.maxSpeechDurationMs || (this.speechMs >= this.params.minSpeechDurationMs && this.silenceMs >= this.params.minSilenceDurationMs)) { this.end(); } } end() { if (!this.speaking) return null; const audio = Buffer.concat(this.buffer); this.reset(); this.emit('utterance', audio); return audio; } }