fix(voice): don't start reply playout while the user is speaking - #6733
fix(voice): don't start reply playout while the user is speaking#6733ajayarora1235 wants to merge 2 commits into
Conversation
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>
β¦ 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>
| 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 |
There was a problem hiding this comment.
π΄ 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).
Was this helpful? React with π or π to provide feedback.
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):
on_start_of_speech's pre-playout hold fires and pauses the output (or no-ops, if the reply'sSpeechHandlewasn't scheduled yet at onset)._audio_forwarding_task's first act is an unconditionalaudio_output.resume()β releasing the hold while the user is still mid-sentence.Both existing defenses are edge-triggered, so neither can catch this: the playout-authorization
_user_silence_eventgather is sampled once at authorization (β the commit, before the generation window), and theon_start_of_speechhold 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_taskaccept an optionalhold_playoutcallback; when it returns True the initialresume()(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 ason_start_of_speech(_update_paused_speech(timeout=0)+pause())._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
tests/test_playout_launch_hold.py) covering the level-check conditions and the forwarding head honoring the hold.tests/test_false_interruption_resume.pystill 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