Skip to content

Commit 4a94843

Browse files
toubatbrianclaude
andauthored
fix(voice): reset VAD on premature STT EOT & guard empty recorder frames (#1181)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6ae3203 commit 4a94843

3 files changed

Lines changed: 41 additions & 3 deletions

File tree

.changeset/tender-baboons-judge.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@livekit/agents": patch
3+
---
4+
5+
fix(voice): reset VAD on premature STT EOT & guard empty recorder frames

agents/src/voice/audio_recognition.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export class AudioRecognition {
165165
private isInterruptionEnabled: boolean;
166166
private isAgentSpeaking: boolean;
167167
private interruptionStreamChannel?: StreamChannel<InterruptionSentinel | AudioFrame>;
168+
private closed = false;
168169

169170
constructor(opts: AudioRecognitionOptions) {
170171
this.hooks = opts.recognitionHooks;
@@ -696,6 +697,15 @@ export class AudioRecognition {
696697
});
697698
});
698699
}
700+
// STT EOT changes user state from speaking to listening without updating VAD internal states.
701+
// VAD EOS will also skip updating user state from listening (STT enforced) to listening (VAD detected)
702+
// and user state won't be updated until a new VAD SOS is received.
703+
// Reset VAD so that incorrect end of turn from STT can be corrected by VAD interruption.
704+
// If user is still speaking (an immediate VAD SOS will interrupt the agent).
705+
if (this.vad && this.speaking) {
706+
this.logger.warn('stt end of speech received while user is speaking, resetting vad');
707+
this.resetVad();
708+
}
699709
this.speaking = false;
700710
this.userTurnCommitted = true;
701711
this.lastSpeakingTime = Date.now();
@@ -1159,6 +1169,23 @@ export class AudioRecognition {
11591169
});
11601170
}
11611171

1172+
/**
1173+
* Reset the VAD by restarting its task. This is needed when STT sends a premature
1174+
* end-of-turn signal while the user is still speaking, so VAD can detect new speech
1175+
* and trigger interruptions correctly.
1176+
*/
1177+
private resetVad() {
1178+
if (!this.vad) return;
1179+
1180+
this.vadTask?.cancelAndWait().finally(() => {
1181+
if (this.closed) return;
1182+
this.vadTask = Task.from(({ signal }) => this.createVadTask(this.vad, signal));
1183+
this.vadTask.result.catch((err) => {
1184+
this.logger.error(`Error running VAD task: ${err}`);
1185+
});
1186+
});
1187+
}
1188+
11621189
commitUserTurn(audioDetached: boolean) {
11631190
const commitUserTurnTask =
11641191
(delayDuration: number = 500) =>
@@ -1206,6 +1233,7 @@ export class AudioRecognition {
12061233
}
12071234

12081235
async close() {
1236+
this.closed = true;
12091237
this.detachInputAudioStream();
12101238
this.silenceAudioWriter.releaseLock();
12111239
await this.commitUserTurnTask?.cancelAndWait();

agents/src/voice/recorder_io/recorder_io.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -679,11 +679,16 @@ class RecorderAudioOutput extends AudioOutput {
679679
pauseIdx++;
680680
}
681681

682-
if (buf.length > 0) {
682+
// Filter out empty frames from split operations to avoid spurious buffer writes
683+
const filteredBuf = buf.filter((f) => f.samplesPerChannel > 0);
684+
685+
if (filteredBuf.length > 0) {
683686
if (trailingSilenceDuration > 0) {
684-
buf.push(createSilenceFrame(trailingSilenceDuration / 1000, sampleRate, numChannels));
687+
filteredBuf.push(
688+
createSilenceFrame(trailingSilenceDuration / 1000, sampleRate, numChannels),
689+
);
685690
}
686-
this.writeFn(buf);
691+
this.writeFn(filteredBuf);
687692
}
688693

689694
this.accFrames = [];

0 commit comments

Comments
 (0)