Skip to content

fix(voice): don't start reply playout while the user is speaking - #6733

Open
ajayarora1235 wants to merge 2 commits into
livekit:mainfrom
ajayarora1235:fix/hold-playout-while-user-speaking
Open

fix(voice): don't start reply playout while the user is speaking#6733
ajayarora1235 wants to merge 2 commits into
livekit:mainfrom
ajayarora1235:fix/hold-playout-while-user-speaking

Conversation

@ajayarora1235

Copy link
Copy Markdown
Contributor

The bug

A reply can start playing into the user's speech when the user resumes talking inside the reply's generation window β€” after their turn commits, before the first TTS frame. Sequence (from a production call, all four occurrences of this class reproduced offline):

  1. User pauses mid-thought β†’ turn commits, reply generation starts.
  2. User resumes 100-300ms later. on_start_of_speech's pre-playout hold fires and pauses the output (or no-ops, if the reply's SpeechHandle wasn't scheduled yet at onset).
  3. Reply's TTS starts streaming β†’ _audio_forwarding_task's first act is an unconditional audio_output.resume() β€” releasing the hold while the user is still mid-sentence.
  4. The reply is audible over the user until an interruption path cuts it (the user hears the agent barge into their sentence and get chopped off).

Both existing defenses are edge-triggered, so neither can catch this: the playout-authorization _user_silence_event gather is sampled once at authorization (β‰ˆ the commit, before the generation window), and the on_start_of_speech hold both requires the handle to already exist and gets undone by the forwarding resume.

The fix

Make the check level-triggered at the true launch moment β€” the start of audio forwarding:

  • perform_audio_forwarding / _audio_forwarding_task accept an optional hold_playout callback; when it returns True the initial resume() (which exists to clear a stale pause from an earlier speech) is skipped.
  • AgentActivity._hold_playout_if_user_speaking(speech_handle) implements the check: user currently speaking + agent not audibly speaking + pause enabled + speech interruptible β†’ apply the same hold as on_start_of_speech (_update_paused_speech(timeout=0) + pause()).
  • Wired at the two pipeline call sites in _tts_task_impl. The realtime/shared-core path is untouched (hold_playout=None β†’ previous behavior).

No new release machinery. Once held, resolution is the existing paths in both directions: user stops without a turn commit β†’ their end-of-speech arms the false-interruption timer (timeout 0 β†’ immediate resume); user's continuation commits a turn β†’ the held speech is interrupted and the superseding reply (generated from the full transcript) plays instead β€” the stale reply is never audible.

Verification

  • 7 unit tests (tests/test_playout_launch_hold.py) covering the level-check conditions and the forwarding head honoring the hold.
  • End-to-end against a live STT session (our downstream harness, synthesized caller audio, scripted LLM at incident latency):
    • before: hold placed at user onset β†’ forwarding resume 0.18s later β†’ 0.56s of reply audible over the user β†’ cut.
    • after: first TTS frame captured while paused; a continuation supersedes the held reply with 0.00s played; a backchannel releases at end-of-speech with the reply then delivered in full.
  • tests/test_false_interruption_resume.py still passes; ruff check/format clean.

Trade-off

A short utterance landing exactly in the generation window now delays the reply until the user's end-of-speech (typically a few hundred ms) instead of playing over them β€” silence instead of overlap.

Related: #6714 (same family β€” pause/resume vs. late transcripts β€” different window).

πŸ€– Generated with Claude Code

The pre-playout hold (on_start_of_speech) pauses a pending reply when the
user starts speaking while the agent is thinking β€” but it is edge-triggered,
and _audio_forwarding_task resume()s the output unconditionally the moment
the reply's TTS starts streaming. A user who resumes speaking inside the
reply's generation window (after their turn commits, before the first TTS
frame) therefore gets the reply launched into their sentence: the hold either
never existed (the SpeechHandle wasn't scheduled yet at their onset) or was
released mid-utterance by the forwarding resume.

Re-check the user state at the moment audio forwarding starts: if the user
is speaking right then, keep the output paused (same bookkeeping as the
on_start_of_speech hold, timeout 0) and skip the forwarding resume. Release
paths are unchanged β€” the user's end of speech arms the false-interruption
timer for an immediate resume, and a committed user turn interrupts the held
speech so the superseding reply is generated instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ajayarora1235
ajayarora1235 requested a review from a team as a code owner August 6, 2026 18:42
devin-ai-integration[bot]

This comment was marked as resolved.

… downgrade an upgraded pause timeout

Review follow-ups: forward_generation now threads hold_playout, and the
pipeline reply call site passes the level check β€” LLM replies (the main
incident path) are covered, not just say(). The realtime call site is left
unwired deliberately: realtime turn handling owns its own interruptions.
The hold also no longer overwrites a timeout _interrupt_by_audio_activity
already upgraded for the same handle (mirrors on_start_of_speech's guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@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 +4304 to +4321
if (
self._session.agent_state != "speaking"
and self._session.user_state == "speaking"
and self._pause_enabled()
and not speech_handle.interrupted
and speech_handle.allow_interruptions
):
assert (audio_output := self._session.output.audio) is not None

# don't downgrade a timeout already recorded for this handle
# (_interrupt_by_audio_activity may have upgraded it to
# false_interruption_timeout); only place a fresh hold
if self._paused_speech is None or self._paused_speech.handle is not speech_handle:
self._update_paused_speech(speech_handle, timeout=0)
audio_output.pause()
return True

return False

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 fall silent when a spoken message is held back during a realtime-model session

The reply's audio is put on hold (_hold_playout_if_user_speaking at livekit-agents/livekit/agents/voice/agent_activity.py:4286-4321) whenever the user is marked as talking, but in sessions where the talking/not-talking signal comes from the speech model's own server-side detection nothing ever releases that hold, so the message stays stuck and unheard.
Impact: In realtime-model sessions the agent can go silent indefinitely β€” the queued message never plays and later replies stay queued behind it β€” until the user happens to start talking again.

Why the release machinery never fires for realtime server-side turn detection

The hold's only release paths are (a) AgentActivity.on_end_of_speech arming the false-interruption timer (livekit-agents/livekit/agents/voice/agent_activity.py:2117-2118) and (b) on_final_transcript β†’ _cancel_speech_pause (livekit-agents/livekit/agents/voice/agent_activity.py:2244-2246). Both hooks are driven exclusively by the local VAD/STT recognition loop (livekit-agents/livekit/agents/voice/audio_recognition.py:1321,1421,1860).

With a RealtimeModel using server-side turn detection and no user VAD, user_state is instead driven by _on_input_speech_started/_on_input_speech_stopped (livekit-agents/livekit/agents/voice/agent_activity.py:1892-1919), which call self._session._update_user_state(...) directly and never invoke on_end_of_speech, and realtime transcripts go through _on_input_audio_transcription_completed, not on_final_transcript.

say() in such a session still routes through _tts_task_impl whenever a TTS is configured (livekit-agents/livekit/agents/voice/agent_activity.py:1466-1468), which is exactly where hold_playout is now wired. So: user starts talking (server VAD) β†’ say() starts forwarding β†’ hold placed, audio_output.pause() β†’ _tts_task_impl blocks on audio_output.wait_for_playout() (the room output's playout wait blocks on _playback_enabled, livekit-agents/livekit/agents/voice/room_io/_output.py:148-158). The scheduler is itself blocked in speech._wait_for_generation() (livekit-agents/livekit/agents/voice/agent_activity.py:1717), so its resume safety net at livekit-agents/livekit/agents/voice/agent_activity.py:1720-1725 cannot run either. Recovery only happens if the user speaks again, since _on_input_speech_started calls self.interrupt().

Prompt for agents
The new pre-playout hold in AgentActivity._hold_playout_if_user_speaking pauses the audio output and records _paused_speech, relying entirely on the existing false-interruption machinery for release. That machinery is only armed by AgentActivity.on_end_of_speech and on_final_transcript, which are driven by the local VAD/STT recognition loop. When a RealtimeModel drives turn detection server-side (no user VAD), user_state transitions come from _on_input_speech_started/_on_input_speech_stopped, which never call those hooks β€” yet say() still routes through _tts_task_impl (where hold_playout is wired) when a TTS is configured. The result is a hold that is never released: _tts_task_impl blocks in wait_for_playout, the scheduling task blocks in _wait_for_generation, and the scheduler's own resume safety net cannot run. Consider either (a) not applying the hold when turn detection is server-side/realtime (e.g. gate on self._rt_turn_detection_enabled or on the recognition hooks being the source of user_state), or (b) giving the hold its own guaranteed release (arm a timer at hold time, and/or release on the realtime input-speech-stopped path).
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.

1 participant