53 lines
1.7 KiB
JavaScript
53 lines
1.7 KiB
JavaScript
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 durationMs = chunk.length / 2 / this.sampleRate * 1000;
|
|
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 += durationMs; this.silenceMs = 0; }
|
|
else { this.silenceMs += durationMs; }
|
|
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;
|
|
}
|
|
}
|