fix(voice): enforce interruption min_duration on STT transcript triggers (#3515) - #6417
fix(voice): enforce interruption min_duration on STT transcript triggers (#3515)#6417dorukdumlu wants to merge 6 commits into
Conversation
|
Friendly ping β CI is green on this one too. The change is intentionally small: gate STT-triggered interruptions on the same |
Keep interim_interval (STT min_duration regression harness) alongside main's FakeUserSpeech.final flag.
|
Addressed Devin's finding on Prefer VAD-measured Keeps short coughs/noise from clearing |
current_speech_duration previously returned wall-clock elapsed while speaking, so a short utterance whose STT transcript arrived after trailing silence could still exceed interruption.min_duration. Prefer VAD speech_duration (same metric as on_vad_inference_done) and keep wall-clock only as a no-VAD fallback.
Silero resets pub_speech_duration to 0 after END_OF_SPEECH; ignoring those INFERENCE_DONE updates so late STT finals still see the segment duration for interruption.min_duration.
|
Addressed the post-EOS Silero overwrite: after Now we only update from |
Stale _vad_speech_duration after EOT made the next STT-failsafe interruption reuse the previous segment length for min_duration. Clear it with the other speech anchors in bounce_eou cleanup.
|
Addressed Devin's stale-duration finding: _bounce_eou_task now clears _vad_speech_duration with the other speech anchors on turn commit, so the next STT-failsafe path sees unknown duration (None) instead of the previous segment. Regression in ests/test_speech_start_time_persistence.py. |
on_vad_inference_done reads current_speech_duration for min_duration. Writing the frame duration after the hook delayed barge-in by one VAD window (~32ms). Keep the post-EOS zero guard.
|
Addressed Devin's remaining yellow: _vad_speech_duration is now written before on_vad_inference_done, so _interrupt_by_audio_activity sees the same frame the VAD pre-check used. Post-EOS zero guard is unchanged. Regression in ests/test_speech_start_time_persistence.py. |
| if self._speaking or ev.speech_duration > 0.0: | ||
| self._vad_speech_duration = ev.speech_duration |
There was a problem hiding this comment.
π‘ Agent can't be interrupted when the microphone-based speech detector misses the user's speech
A zero speech length is recorded (self._vad_speech_duration = ev.speech_duration at livekit-agents/livekit/agents/voice/audio_recognition.py:1435-1436) while the user is already known to be talking, so the safety net that lets a finished transcript interrupt the agent is turned off and the agent keeps talking over the user.
Impact: In sessions where speech-to-text drives turn taking and the local speech detector fails to register the user's voice, barge-in stops working for that turn.
Mechanism: `_speaking` set by STT while VAD reports speech_duration == 0 turns "unknown duration" into "0.0 β too short"
The new min_duration gate in livekit-agents/livekit/agents/voice/agent_activity.py:2029-2036 deliberately lets an unknown duration (None) through so the VAD-failure failsafe in on_final_transcript (livekit-agents/livekit/agents/voice/agent_activity.py:2255) still works.
However, in turn_detection="stt" mode _speaking is set to True by the STT START_OF_SPEECH branch (livekit-agents/livekit/agents/voice/audio_recognition.py:1356-1365), independently of VAD. Silero only accumulates pub_speech_duration once pub_speaking is true, so every INFERENCE_DONE frame emitted while VAD has not (or never) detected onset carries speech_duration == 0.0. Because the guard is self._speaking or ev.speech_duration > 0.0, those frames write 0.0 into _vad_speech_duration, and current_speech_duration then returns 0.0 instead of None β the gate blocks the interruption permanently for that segment (0.0 < min_duration).
The self._speaking or disjunct is not needed for the stated purposes: post-EOS Silero zeros are already excluded by ev.speech_duration > 0.0, and any real voiced frame has speech_duration > 0.
| if self._speaking or ev.speech_duration > 0.0: | |
| self._vad_speech_duration = ev.speech_duration | |
| if ev.speech_duration > 0.0: | |
| self._vad_speech_duration = ev.speech_duration |
Was this helpful? React with π or π to provide feedback.
Closes #3515
Problem
interruption.min_duration(formerlymin_interruption_duration) is only enforced on the VAD path:on_vad_inference_donechecksev.speech_duration >= min_durationbefore calling_interrupt_by_audio_activity(). The STT paths bypass it entirely βon_interim_transcriptandon_final_transcriptinterrupt as soon as any non-empty transcript arrives, and_interrupt_by_audio_activity()itself only gates onmin_words.With providers that stream continuous interim results (Amazon Transcribe, etc.), the first interim lands well before
min_durationis reached, so the option effectively does nothing β which is what was reported in #3515 (details in my comment there). This implements Option A from that comment: all entry paths share the samemin_durationsemantics.Fix
AudioRecognitiongains acurrent_speech_durationproperty derived from the speech timestamps it already tracks (_speech_start_time/_last_speaking_time): wall-clock elapsed while the user is speaking, actual segment length once they've stopped,Nonewhen no speech start was ever tracked._interrupt_by_audio_activity()enforcesmin_durationagainst that value, next to the existingmin_wordsgate, so the interim/final transcript paths can no longer bypass it.Two deliberate exemptions:
None) is allowed through. This preserves the existing failsafe inon_final_transcript("agent speech might not be interrupted if VAD failed and a final transcript is received"), and keeps VAD-less sessions behaving as before rather than becoming un-interruptible.on_interruption) skips the check viaenforce_min_duration=Falseβ that model already made an explicit "this is a real interruption" decision.The VAD path keeps its existing pre-check; the new gate can't disagree with it (wall-clock elapsed β₯ VAD-measured speech duration), so VAD-triggered interruptions are unaffected.
Testing
test_min_interruption_duration_applies_to_stt_transcripts: user speaks for 4s with interim transcripts streaming every 0.3s andmin_duration=2.0. Before the fix the first interim interrupts at ~5.3s (playback position ~1.8s); after the fix the interruption fires at 7.0s (position 3.5s). Verified it fails without the fix and passes with it.FakeSTT/FakeActionsgained an opt-ininterim_intervalto simulate continuous-interim providers; default behavior of existing fixtures is unchanged.--unitsuite passes;ruffclean; no new mypy errors.