Reset Silero VAD between voice turns to prevent LSTM drift
The Silero VAD session ran for the entire voice mode lifetime without resetting its LSTM hidden states. Over time the internal state drifted, causing phantom speech detection on silence and getting permanently stuck in the "speaking" phase. Add reset() to TurnDetectionSession and call it after each completed utterance to clear the LSTM hidden states between turns.
This commit is contained in:
@@ -8,10 +8,17 @@ import { loadSherpaOnnxNode } from "./sherpa-onnx-node-loader.js";
|
|||||||
const DEFAULT_SAMPLE_RATE = 16000;
|
const DEFAULT_SAMPLE_RATE = 16000;
|
||||||
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
const DEFAULT_BUFFER_SIZE_SECONDS = 60;
|
||||||
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
const DEFAULT_SILERO_THRESHOLD = 0.5;
|
||||||
const DEFAULT_MIN_SILENCE_DURATION = 1.2;
|
|
||||||
const DEFAULT_MIN_SPEECH_DURATION = 0.2;
|
|
||||||
const DEFAULT_WINDOW_SIZE = 512;
|
const DEFAULT_WINDOW_SIZE = 512;
|
||||||
|
|
||||||
|
// Silero internal durations — kept low so isDetected() tracks actual sound.
|
||||||
|
// Our own state machine handles speech confirmation and end-of-speech detection.
|
||||||
|
const SILERO_MIN_SILENCE_DURATION = 0.2;
|
||||||
|
const SILERO_MIN_SPEECH_DURATION = 0.1;
|
||||||
|
|
||||||
|
// Our boundary detection thresholds (in milliseconds).
|
||||||
|
const DEFAULT_CONFIRM_MS = 800;
|
||||||
|
const DEFAULT_SILENCE_MS = 1000;
|
||||||
|
|
||||||
type SherpaVadHandle = {
|
type SherpaVadHandle = {
|
||||||
acceptWaveform(samples: Float32Array): void;
|
acceptWaveform(samples: Float32Array): void;
|
||||||
isDetected(): boolean;
|
isDetected(): boolean;
|
||||||
@@ -42,20 +49,30 @@ export interface SherpaSileroVadSessionConfig {
|
|||||||
modelPath?: string;
|
modelPath?: string;
|
||||||
sampleRate?: number;
|
sampleRate?: number;
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
minSilenceDuration?: number;
|
|
||||||
minSpeechDuration?: number;
|
|
||||||
windowSize?: number;
|
windowSize?: number;
|
||||||
bufferSizeInSeconds?: number;
|
bufferSizeInSeconds?: number;
|
||||||
|
confirmMs?: number;
|
||||||
|
silenceMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VadPhase =
|
||||||
|
| { state: "idle" }
|
||||||
|
| { state: "confirming"; startedAt: number }
|
||||||
|
| { state: "speaking" }
|
||||||
|
| { state: "ending"; startedAt: number };
|
||||||
|
|
||||||
export class SherpaSileroVadSession extends EventEmitter implements TurnDetectionSession {
|
export class SherpaSileroVadSession extends EventEmitter implements TurnDetectionSession {
|
||||||
public readonly requiredSampleRate: number;
|
public readonly requiredSampleRate: number;
|
||||||
|
|
||||||
private readonly vad: SherpaVadHandle;
|
private readonly vad: SherpaVadHandle;
|
||||||
private readonly inputBuffer: SherpaCircularBufferHandle;
|
private readonly inputBuffer: SherpaCircularBufferHandle;
|
||||||
private readonly windowSize: number;
|
private readonly windowSize: number;
|
||||||
|
private readonly msPerWindow: number;
|
||||||
|
private readonly confirmMs: number;
|
||||||
|
private readonly silenceMs: number;
|
||||||
private connected = false;
|
private connected = false;
|
||||||
private inSpeech = false;
|
private phase: VadPhase = { state: "idle" };
|
||||||
|
private windowTimestamp = 0;
|
||||||
private readonly logger;
|
private readonly logger;
|
||||||
|
|
||||||
constructor(params: {
|
constructor(params: {
|
||||||
@@ -67,16 +84,35 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
|||||||
const config = params.config ?? {};
|
const config = params.config ?? {};
|
||||||
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
this.requiredSampleRate = config.sampleRate ?? DEFAULT_SAMPLE_RATE;
|
||||||
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
|
this.windowSize = config.windowSize ?? DEFAULT_WINDOW_SIZE;
|
||||||
|
this.msPerWindow = (this.windowSize / this.requiredSampleRate) * 1000;
|
||||||
|
this.confirmMs = config.confirmMs ?? DEFAULT_CONFIRM_MS;
|
||||||
|
this.silenceMs = config.silenceMs ?? DEFAULT_SILENCE_MS;
|
||||||
|
|
||||||
|
const threshold = config.threshold ?? DEFAULT_SILERO_THRESHOLD;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
{
|
||||||
|
threshold,
|
||||||
|
sileroMinSilenceDuration: SILERO_MIN_SILENCE_DURATION,
|
||||||
|
sileroMinSpeechDuration: SILERO_MIN_SPEECH_DURATION,
|
||||||
|
confirmMs: this.confirmMs,
|
||||||
|
silenceMs: this.silenceMs,
|
||||||
|
windowSize: this.windowSize,
|
||||||
|
msPerWindow: this.msPerWindow,
|
||||||
|
sampleRate: this.requiredSampleRate,
|
||||||
|
},
|
||||||
|
"[VAD] Initializing Silero VAD session",
|
||||||
|
);
|
||||||
|
|
||||||
const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule;
|
const sherpa = loadSherpaOnnxNode() as unknown as SherpaVadModule;
|
||||||
this.vad = new sherpa.Vad(
|
this.vad = new sherpa.Vad(
|
||||||
{
|
{
|
||||||
sileroVad: {
|
sileroVad: {
|
||||||
model: config.modelPath ?? resolveBundledSileroVadModelPath(),
|
model: config.modelPath ?? resolveBundledSileroVadModelPath(),
|
||||||
threshold: config.threshold ?? DEFAULT_SILERO_THRESHOLD,
|
threshold,
|
||||||
minSilenceDuration: config.minSilenceDuration ?? DEFAULT_MIN_SILENCE_DURATION,
|
minSilenceDuration: SILERO_MIN_SILENCE_DURATION,
|
||||||
minSpeechDuration: config.minSpeechDuration ?? DEFAULT_MIN_SPEECH_DURATION,
|
minSpeechDuration: SILERO_MIN_SPEECH_DURATION,
|
||||||
windowSize: config.windowSize ?? DEFAULT_WINDOW_SIZE,
|
windowSize: this.windowSize,
|
||||||
},
|
},
|
||||||
sampleRate: this.requiredSampleRate,
|
sampleRate: this.requiredSampleRate,
|
||||||
numThreads: 1,
|
numThreads: 1,
|
||||||
@@ -106,11 +142,14 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
|||||||
try {
|
try {
|
||||||
const samples = pcm16leToFloat32(pcm16le, 1);
|
const samples = pcm16leToFloat32(pcm16le, 1);
|
||||||
this.inputBuffer.push(samples);
|
this.inputBuffer.push(samples);
|
||||||
|
let windowsProcessed = 0;
|
||||||
while (this.inputBuffer.size() > this.windowSize) {
|
while (this.inputBuffer.size() > this.windowSize) {
|
||||||
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
|
const window = this.inputBuffer.get(this.inputBuffer.head(), this.windowSize, false);
|
||||||
this.inputBuffer.pop(this.windowSize);
|
this.inputBuffer.pop(this.windowSize);
|
||||||
this.vad.acceptWaveform(window);
|
this.vad.acceptWaveform(window);
|
||||||
this.syncDetectionState();
|
this.windowTimestamp += this.msPerWindow;
|
||||||
|
windowsProcessed++;
|
||||||
|
this.stepStateMachine();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||||
@@ -123,41 +162,111 @@ export class SherpaSileroVadSession extends EventEmitter implements TurnDetectio
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this.logger.debug(
|
||||||
|
{ phase: this.phase.state },
|
||||||
|
"[VAD] Flushing remaining audio",
|
||||||
|
);
|
||||||
this.vad.flush();
|
this.vad.flush();
|
||||||
this.syncDetectionState();
|
this.stepStateMachine();
|
||||||
if (this.inSpeech) {
|
if (this.phase.state === "speaking" || this.phase.state === "ending") {
|
||||||
this.inSpeech = false;
|
this.logger.debug("[VAD] Forcing speech_stopped after flush");
|
||||||
|
this.phase = { state: "idle" };
|
||||||
this.emit("speech_stopped");
|
this.emit("speech_stopped");
|
||||||
|
} else if (this.phase.state === "confirming") {
|
||||||
|
this.logger.debug("[VAD] Discarding unconfirmed speech on flush");
|
||||||
|
this.phase = { state: "idle" };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
this.emit("error", error instanceof Error ? error : new Error(String(error)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
reset(): void {
|
||||||
try {
|
try {
|
||||||
this.vad.reset();
|
this.vad.reset();
|
||||||
this.inputBuffer.reset();
|
this.inputBuffer.reset();
|
||||||
} catch {
|
} catch {
|
||||||
// ignore native cleanup failures
|
// ignore native cleanup failures
|
||||||
} finally {
|
} finally {
|
||||||
this.connected = false;
|
this.phase = { state: "idle" };
|
||||||
this.inSpeech = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncDetectionState(): void {
|
close(): void {
|
||||||
const detected = this.vad.isDetected();
|
this.reset();
|
||||||
if (detected && !this.inSpeech) {
|
this.connected = false;
|
||||||
this.inSpeech = true;
|
this.windowTimestamp = 0;
|
||||||
this.emit("speech_started");
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!detected && this.inSpeech && !this.vad.isEmpty()) {
|
private stepStateMachine(): void {
|
||||||
this.logger.debug("Silero VAD marked end of speech");
|
const detected = this.vad.isDetected();
|
||||||
this.inSpeech = false;
|
const now = this.windowTimestamp;
|
||||||
this.emit("speech_stopped");
|
|
||||||
|
switch (this.phase.state) {
|
||||||
|
case "idle": {
|
||||||
|
if (detected) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ now },
|
||||||
|
"[VAD] idle → confirming (detection started)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "confirming", startedAt: now };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "confirming": {
|
||||||
|
if (!detected) {
|
||||||
|
const elapsed = now - this.phase.startedAt;
|
||||||
|
this.logger.debug(
|
||||||
|
{ elapsed, confirmMs: this.confirmMs },
|
||||||
|
"[VAD] confirming → idle (detection dropped before confirmation)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "idle" };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const elapsed = now - this.phase.startedAt;
|
||||||
|
if (elapsed >= this.confirmMs) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ elapsed, confirmMs: this.confirmMs },
|
||||||
|
"[VAD] confirming → speaking (speech confirmed)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "speaking" };
|
||||||
|
this.emit("speech_started");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "speaking": {
|
||||||
|
if (!detected) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ now },
|
||||||
|
"[VAD] speaking → ending (silence started)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "ending", startedAt: now };
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "ending": {
|
||||||
|
if (detected) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ elapsed: now - this.phase.startedAt },
|
||||||
|
"[VAD] ending → speaking (speech resumed)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "speaking" };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const elapsed = now - this.phase.startedAt;
|
||||||
|
if (elapsed >= this.silenceMs) {
|
||||||
|
this.logger.debug(
|
||||||
|
{ elapsed, silenceMs: this.silenceMs },
|
||||||
|
"[VAD] ending → idle (speech stopped)",
|
||||||
|
);
|
||||||
|
this.phase = { state: "idle" };
|
||||||
|
this.emit("speech_stopped");
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type TurnDetectionSession = {
|
|||||||
connect(): Promise<void>;
|
connect(): Promise<void>;
|
||||||
appendPcm16(pcm16le: Buffer): void;
|
appendPcm16(pcm16le: Buffer): void;
|
||||||
flush(): void;
|
flush(): void;
|
||||||
|
reset(): void;
|
||||||
close(): void;
|
close(): void;
|
||||||
|
|
||||||
on(event: "speech_started", handler: () => void): unknown;
|
on(event: "speech_started", handler: () => void): unknown;
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ class FakeTurnDetectionSession extends EventEmitter implements TurnDetectionSess
|
|||||||
}
|
}
|
||||||
|
|
||||||
flush(): void {}
|
flush(): void {}
|
||||||
|
reset(): void {}
|
||||||
close(): void {}
|
close(): void {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { FixedDurationPcmRingBuffer } from "./fixed-duration-pcm-ring-buffer.js"
|
|||||||
|
|
||||||
const PCM_CHANNELS = 1;
|
const PCM_CHANNELS = 1;
|
||||||
const PCM_BITS_PER_SAMPLE = 16;
|
const PCM_BITS_PER_SAMPLE = 16;
|
||||||
const DEFAULT_PREFIX_DURATION_MS = 400;
|
const DEFAULT_PREFIX_DURATION_MS = 1000;
|
||||||
|
|
||||||
type VoiceInputState =
|
type VoiceInputState =
|
||||||
| { status: "idle" }
|
| { status: "idle" }
|
||||||
@@ -134,6 +134,8 @@ export function createVoiceTurnController(params: {
|
|||||||
utteranceChunks = [];
|
utteranceChunks = [];
|
||||||
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
|
state = { status: "listening", rollingPrefixBytes: prefixBuffer.byteLength };
|
||||||
|
|
||||||
|
detector.reset();
|
||||||
|
|
||||||
await params.callbacks.onSpeechStopped();
|
await params.callbacks.onSpeechStopped();
|
||||||
|
|
||||||
params.logger.info(
|
params.logger.info(
|
||||||
|
|||||||
Reference in New Issue
Block a user