This commit is contained in:
2026-09-11 14:09:04 -04:00
parent 68d7a40605
commit 5c6c94713a
16 changed files with 539 additions and 16 deletions
+51
View File
@@ -0,0 +1,51 @@
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;
}
}