Files
gnome-jarvis/daemon/vad.js
T
snxraven a4073b9020
Rolling release / release (push) Successful in 6m36s
Check Point
2026-09-12 09:07:09 -04:00

54 lines
1.8 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; this.recordingMs = 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);
this.recordingMs += durationMs;
if (voiced) { this.speechMs += durationMs; this.silenceMs = 0; }
else { this.silenceMs += durationMs; }
if (this.recordingMs >= this.params.maxSpeechDurationMs || this.silenceMs >= this.params.minSilenceDurationMs) {
if (this.speechMs >= this.params.minSpeechDurationMs) this.end();
else this.reset();
}
}
end() {
if (!this.speaking) return null;
const audio = Buffer.concat(this.buffer);
this.reset();
this.emit('utterance', audio);
return audio;
}
}