Skip to content

fix(voice): enforce interruption min_duration on STT transcript triggers (#3515) - #6417

Open
dorukdumlu wants to merge 6 commits into
livekit:mainfrom
dorukdumlu:fix/min-interruption-duration
Open

fix(voice): enforce interruption min_duration on STT transcript triggers (#3515)#6417
dorukdumlu wants to merge 6 commits into
livekit:mainfrom
dorukdumlu:fix/min-interruption-duration

Conversation

@dorukdumlu

Copy link
Copy Markdown

Closes #3515

Problem

interruption.min_duration (formerly min_interruption_duration) is only enforced on the VAD path: on_vad_inference_done checks ev.speech_duration >= min_duration before calling _interrupt_by_audio_activity(). The STT paths bypass it entirely β€” on_interim_transcript and on_final_transcript interrupt as soon as any non-empty transcript arrives, and _interrupt_by_audio_activity() itself only gates on min_words.

With providers that stream continuous interim results (Amazon Transcribe, etc.), the first interim lands well before min_duration is 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 same min_duration semantics.

Fix

  • AudioRecognition gains a current_speech_duration property 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, None when no speech start was ever tracked.
  • _interrupt_by_audio_activity() enforces min_duration against that value, next to the existing min_words gate, so the interim/final transcript paths can no longer bypass it.

Two deliberate exemptions:

  • Unknown duration (None) is allowed through. This preserves the existing failsafe in on_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.
  • The interruption detection model path (on_interruption) skips the check via enforce_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

  • New regression test test_min_interruption_duration_applies_to_stt_transcripts: user speaks for 4s with interim transcripts streaming every 0.3s and min_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/FakeActions gained an opt-in interim_interval to simulate continuous-interim providers; default behavior of existing fixtures is unchanged.
  • Full --unit suite passes; ruff clean; no new mypy errors.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

βœ… Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

@dorukdumlu

Copy link
Copy Markdown
Author

Friendly ping β€” CI is green on this one too. The change is intentionally small: gate STT-triggered interruptions on the same min_duration the VAD path already uses, with an escape hatch for the adaptive interruption model. Happy to tweak if you'd prefer a different call site.

Keep interim_interval (STT min_duration regression harness) alongside
main's FakeUserSpeech.final flag.
devin-ai-integration[bot]

This comment was marked as resolved.

@dorukdumlu

Copy link
Copy Markdown
Author

Addressed Devin's finding on current_speech_duration:

Prefer VAD-measured speech_duration (same metric on_vad_inference_done already uses for min_duration) instead of wall-clock elapsed while _speaking. Wall-clock remains the fallback for STT-only setups with no VAD events.

Keeps short coughs/noise from clearing min_duration just because the interim transcript arrived after trailing silence.

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.
devin-ai-integration[bot]

This comment was marked as resolved.

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.
@dorukdumlu

Copy link
Copy Markdown
Author

Addressed the post-EOS Silero overwrite: after END_OF_SPEECH, Silero resets pub_speech_duration to 0, so later INFERENCE_DONE events were clobbering _vad_speech_duration and making late STT finals look too short for interruption.min_duration.

Now we only update from INFERENCE_DONE while still speaking or when speech_duration > 0, and keep the EOS final otherwise. Added a unit regression in tests/test_speech_start_time_persistence.py.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@dorukdumlu

Copy link
Copy Markdown
Author

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.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@dorukdumlu

Copy link
Copy Markdown
Author

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.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 1 new potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +1435 to +1436
if self._speaking or ev.speech_duration > 0.0:
self._vad_speech_duration = ev.speech_duration

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟑 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.

Suggested change
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
Open in Devin Review

Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

min_interruption_duration seems to be ignored over min_interruption_words

1 participant