fix(voice): commit interrupted replies when playback start arrives after forwarding completes (avatar outputs)#1960
Conversation
…ter forwarding completes With DataStream avatar outputs (waitPlaybackStart: true), audio frames are forwarded faster than real time and the remote worker's lk.playback_started RPC typically arrives ~1s later — after forwardAudio has already completed. forwardAudio's finally block then rejected firstFrameFut and detached the playback-started listener, so the deferred notification was dropped: - the agent never entered the 'speaking' state for avatar sessions (no onStartOfAgentSpeech, no e2e latency metrics), and - an interrupted reply was classified played='skipped' and silently removed from the chat context, even though the user audibly heard part of it (phantom utterance — the same symptom livekit#1916 fixed, via a different mechanism). Mirror the python implementation instead: reject firstFrameFut only when forwarding is aborted before playback started, keep the listener attached until the future settles, and settle any still-pending future when the segment's playout window ends. Additionally, honor a non-zero playback position reported on interruption as evidence of partial playback, covering the case where the interruption races ahead of the playback-started RPC. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 32074f2 The changes in this PR will be included in the next version bump. This PR includes changesets to release 35 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Jared Friedman seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
…ce when the segment forwarded audio waitForPlayout() returns the output's lastPlaybackEvent, which is stale (from a previous segment/message) when the current one captured no frames. Gate the position-based partial classification on output.audioOut?.startedForwardingAt so an interrupted no-audio segment can't be committed as heard speech with a stale position. Applies to both the pipeline and realtime paths; adds a regression test seeding a stale playback event under a zero-frame segment.
There was a problem hiding this comment.
🟡 Event listener on the shared audio output leaks when using the "say" path with deferred playback notifications
The playback-started listener is never detached from the shared audio output (performAudioForwarding at agents/src/voice/agent_activity.ts:2519) because the say() code path has no finally-block cleanup to settle the pending firstFrameFut, so each call accumulates a stale listener.
Impact: Repeated say() calls with avatar outputs that defer playback-started notifications leak event listeners, causing gradual memory growth.
Mechanism: the say() path was not updated with the new cleanup pattern
The PR changed forwardAudio (agents/src/voice/generation.ts:952) to only reject firstFrameFut when signal?.aborted, keeping the listener attached until the future settles. It then added matching finally-block cleanup in forwardSegment (agents/src/voice/agent_activity.ts:2931-2937) and processOneMessage (agents/src/voice/agent_activity.ts:3503-3509) to settle any still-pending firstFrameFut.
However, the say() path at agents/src/voice/agent_activity.ts:2519-2557 also calls performAudioForwarding but was not updated with the same cleanup. When forwarding completes normally (stream closes, signal not aborted), firstFrameFut stays pending and the onPlaybackStarted listener at agents/src/voice/generation.ts:894 remains attached to audioOutput indefinitely. Before this PR, forwardAudio always rejected firstFrameFut on exit, so the listener was always cleaned up.
(Refers to lines 2550-2557)
Prompt for agents
The say() code path at agents/src/voice/agent_activity.ts:2519-2557 calls performAudioForwarding but does not settle the returned audioOut.firstFrameFut when the playout window ends. The forwardSegment (line 2931-2937) and processOneMessage (line 3503-3509) paths both have a finally block that rejects firstFrameFut if still pending, which triggers the .finally() cleanup in forwardAudio to detach the onPlaybackStarted listener. The say() path needs the same pattern: after the interrupted-handling block (around line 2557), add cleanup that rejects audioOut.firstFrameFut if it is not yet done, similar to the pattern used in forwardSegment's finally block. This could be done by wrapping the relevant section in a try/finally or adding the check after the interrupted block.
Was this helpful? React with 👍 or 👎 to provide feedback.
|
The core mechanism looks right and matches the Python semantics ( One gap though: https://github.com/livekit/agents/blob/main/livekit-agents/livekit/agents/voice/agent_activity.py — if audio_out is not None and not audio_out.first_frame_fut.done():
audio_out.first_frame_fut.cancel()Without the equivalent in Suggested fix: the same three-line settle used in Minor, non-blocking: the position-based evidence branch ( |
|
Hi @snowmaker — following up on the review: community PR #1910 fixes the same To simplify coordination we've opened #1966 combining both fixes: your playback-position evidence gate and the avatar-shaped regression tests ( |
Description
Interrupted agent replies are silently dropped from the chat context (and
conversation_item_addednever fires) when using a DataStream avatar output — the same "phantom utterance" symptom that #1916 fixed, reintroduced through a different mechanism. The agent also never enters thespeakingstate in avatar sessions.Root cause. With avatar outputs configured with
waitPlaybackStart: true(e.g. the LemonSlice plugin since #1406),AudioOutput.EVENT_PLAYBACK_STARTEDis only emitted when the remote avatar worker sends thelk.playback_startedRPC — which arrives once the worker actually starts playing, typically ~1s after the first audio push (we measured LemonSlicetime_to_first_push≈ 1.0–1.1s). ButforwardAudio()pushes frames into the DataStream faster than real time, so for essentially every utterance it completes before that RPC arrives. Itsfinallyblock then:firstFrameFut(audio forwarding cancelled before playback started) — even when forwarding completed normally.The deferred
lk.playback_startednotification is dropped on the floor. Consequences, for every utterance of every avatar session:onFirstFramenever runs → the agent never transitions to thespeakingstate →audioRecognition.onStartOfAgentSpeech()is never called (degraded interruption gating / adaptive endpointing), andstartedSpeakingAt/e2eLatencymetrics are missing.firstFrameFut.done && !rejected) fails → the segment is classifiedplayed: 'skipped'→forwardedTextFor()returns''→ the audibly-heard reply is never inserted into the chat context. The LLM doesn't know what it already said, and downstreamconversation_item_addedconsumers (transcripts) lose the turn.The python implementation does not have this bug:
perform_audio_forwardingattaches the playback-started listener outside the forwarding task, removes it only when the future settles, and lets the caller settle the future after the playout window (generation.py, "out.first_frame_fut should be cancelled in the caller after the playout is finished or interrupted"). This PR mirrors that.Real-world reproduction (agents-js 1.5.0 + real LemonSlice avatar session,
AgentSession+FakeLLM+ a tonettsNode, interrupt at t+2.5s mid-audible-playback):With this fix, the same scenario commits the reply with
interrupted: trueand the agent state correctly transitionsthinking → speaking → listening.We hit this in production: in one 3-minute session, 4 of 15 spoken utterances were interrupted and all 4 were missing from the conversation history, visibly confusing the LLM (it could not see its own prior answers).
Changes Made
generation.tsforwardAudio: rejectfirstFrameFutonly when forwarding was aborted before playback started; otherwise keep the playback-started listener attached until the future settles (python parity). The stray-event guard (hasCapturedOwnFrame, fix(voice): scope forwardAudio playback-started listener to its own segment #1786) is preserved.agent_activity.ts(forwardSegmentin both the pipeline and realtime paths): settle a still-pendingfirstFrameFutonce the segment's playout window ends, bounding the listener's lifetime (leak guard, and preserves the "never started" reject semantics for non-avatar outputs).agent_activity.tsinterrupted branches: also honor a non-zeroplaybackPositionreported bywaitForPlayout()as evidence of partial playback — covers an interruption racing ahead of thelk.playback_startedRPC.agent_activity_interrupted_commit.test.ts(deferred playback-started notification; position-only evidence) — both fail onmainand pass with this fix. Updated the teardown assertion ingeneration_tts_timeout.test.tsto the new lifecycle (future stays pending after zero-frame forwarding; stray events still ignored).Pre-Review Checklist
Testing
agents: 88 files, 1180 passed; lint + prettier + tsc clean)speaking; post-fix it is committed withinterrupted: trueand full state transitions.