Skip to content

fix(voice): stop dropping turns whose playback starts after audio forwarding completes (combines #1910 + #1960)#1966

Open
toubatbrian wants to merge 3 commits into
mainfrom
fix/late-playback-started-dropped-turns
Open

fix(voice): stop dropping turns whose playback starts after audio forwarding completes (combines #1910 + #1960)#1966
toubatbrian wants to merge 3 commits into
mainfrom
fix/late-playback-started-dropped-turns

Conversation

@toubatbrian

Copy link
Copy Markdown
Contributor

Summary

This PR combines #1910 (@enriqueespaillat-gyde) and #1960 (@snowmaker) into a single change. Both PRs fix the same root cause — forwardAudio rejecting firstFrameFut (and detaching its PLAYBACK_STARTED listener) whenever forwarding finished before playback started — but they edit the exact same lines in generation.ts and agent_activity.ts and would conflict whichever lands first. Rather than asking one author to rebase over the other, we consolidated both fixes here to simplify coordination. Full credit to both authors: the commit carries Co-authored-by trailers for each.

The two scenarios fixed

Both hit the same window — no frame has played by the time forwarding finishes — and both ended with the turn silently removed from the chat context (phantom utterance) while the agent never entered the speaking state:

  1. False interruption before the first frame (Agent speech silently dropped when interrupted before the first audio frame (resumeFalseInterruption) — port of Python #5039 #1909, fixed by fix(voice): resume speech interrupted before its first frame (#1909) #1910): a brief user sound in the thinking state pauses the not-yet-playing speech; TTS frames buffer into the paused output and forwarding completes. When the false interruption cleared and the buffered first frame played, nothing was listening.
  2. Deferred avatar playback-started (fix(voice): commit interrupted replies when playback start arrives after forwarding completes (avatar outputs) #1960): DataStream avatar outputs with waitPlaybackStart: true (e.g. LemonSlice) accept frames faster than real time and send the lk.playback_started RPC ~1s after forwarding already finished; the future was rejected before the RPC arrived.

What this PR does

Takes #1910's structure as the base (it covers all three reply paths, including say()), plus #1960's playback-position evidence and avatar-shaped tests:

  • From fix(voice): resume speech interrupted before its first frame (#1909) #1910: the PLAYBACK_STARTED listener moves to performAudioForwarding so it outlives the forwarding task; forwardAudio no longer settles firstFrameFut; a shared AgentActivity.settleFirstFrameFut helper settles a still-pending future once the playout window ends in all three reply paths — forwardSegment (pipeline), processOneMessage (realtime), and ttsTask (say(), in a try/finally so the listener can't leak on the shared output even if playout throws).
  • From fix(voice): commit interrupted replies when playback start arrives after forwarding completes (avatar outputs) #1960: the interrupted-commit gate additionally honors a reported non-zero playback position as evidence of partial playback when the current segment forwarded audio — covering avatars whose playback-started RPC races the interruption itself — along with the avatar-shaped regression tests (DeferredStartOutput in agent_activity_interrupted_commit.test.ts) and the stale-playback-position guard test.
  • New here: the stray-event guard moved onto _AudioOut._hasCapturedOwnFrame (initialized in one place, so no undefined window), and a listener-lifecycle regression test asserting the shared output returns to zero PLAYBACK_STARTED listeners after both the resolve and reject paths settle.

Python parity note: this mirrors livekit/agents (see #5039), where the playback-started listener is owned by the caller and only the reply task settles the first-frame future after playout.

Replaces

We opened this combined PR because the two originals conflict line-for-line and neither is a strict subset of the other; landing them separately would force a painful rebase on whoever goes second. Both of you are credited as co-authors on the commit. Please take a look and flag anything that misrepresents your original intent.

Fixes #1909.

Test plan

  • pnpm build:agents passes
  • vitest run src/voice/generation_interrupt_before_first_frame.test.ts src/voice/generation_tts_timeout.test.ts src/voice/agent_activity_interrupted_commit.test.ts — 14/14 pass
  • Full src/voice suite: only pre-existing amd.test.ts failures (also fail on pristine main)
  • Prettier + ESLint clean on changed files (warnings pre-existing)
  • Manual check with a waitPlaybackStart: true avatar (LemonSlice) — interrupt mid-reply, confirm the partial turn stays in chat context
  • Manual check of the Agent speech silently dropped when interrupted before the first audio frame (resumeFalseInterruption) — port of Python #5039 #1909 repro — brief sound during thinking state, confirm the resumed reply plays and is kept in history

Made with Cursor

…warding completes

Combines #1910 (false-interruption pause before first frame, #1909) and
#1960 (deferred lk.playback_started from DataStream avatar outputs) into a
single change: the PLAYBACK_STARTED listener moves to performAudioForwarding
so it outlives the forwarding task, forwardAudio no longer settles
firstFrameFut, and all reply paths (pipeline, realtime, and say()) settle a
still-pending future via settleFirstFrameFut once the playout window ends. A
reported non-zero playback position on interruption is also honored as
evidence of partial playback.

Co-authored-by: enrique <enrique.espaillat@gydehealth.ai>
Co-authored-by: Jared Friedman <jared@ycombinator.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Jul 6, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 4de626d

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 35 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-baseten Patch
@livekit/agents-plugin-bey Patch
@livekit/agents-plugin-cartesia Patch
@livekit/agents-plugin-cerebras Patch
@livekit/agents-plugin-deepgram Patch
@livekit/agents-plugin-did Patch
@livekit/agents-plugin-elevenlabs Patch
@livekit/agents-plugin-fishaudio Patch
@livekit/agents-plugin-google Patch
@livekit/agents-plugin-hedra Patch
@livekit/agents-plugin-hume Patch
@livekit/agents-plugin-inworld Patch
@livekit/agents-plugin-lemonslice Patch
@livekit/agents-plugin-liveavatar Patch
@livekit/agents-plugin-livekit Patch
@livekit/agents-plugin-minimax Patch
@livekit/agents-plugin-mistral Patch
@livekit/agents-plugin-mistralai Patch
@livekit/agents-plugin-neuphonic Patch
@livekit/agents-plugin-openai Patch
@livekit/agents-plugin-perplexity Patch
@livekit/agents-plugin-phonic Patch
@livekit/agents-plugin-resemble Patch
@livekit/agents-plugin-rime Patch
@livekit/agents-plugin-runway Patch
@livekit/agents-plugin-sarvam Patch
@livekit/agents-plugin-silero Patch
@livekit/agents-plugin-soniox Patch
@livekit/agents-plugin-tavus Patch
@livekit/agents-plugins-test Patch
@livekit/agents-plugin-trugen Patch
@livekit/agents-plugin-xai Patch

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

@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 potential issue.

Open in Devin Review

Comment on lines +2551 to 2558
if (speechHandle.interrupted) {
replyAbortController.abort();
await cancelAndWait(tasks, AgentActivity.REPLY_TASK_CANCEL_TIMEOUT);
if (audioOutput) {
audioOutput.clearBuffer();
await audioOutput.waitForPlayout();
}
}

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.

🔍 The say() path lacks the new partial-playback-position heuristic

The ttsTask method (used by session.say()) at agents/src/voice/agent_activity.ts:2551-2558 handles interruption by aborting and clearing the buffer, but unlike the pipeline (forwardSegment at line 2925-2928) and realtime (processOneMessage at line 3493-3496) paths, it does NOT check startedForwardingAt or playbackPosition > 0 as a fallback for detecting partial playback when firstFrameFut hasn't resolved. This means the say() path still relies solely on firstFrameFut resolving (via the late PLAYBACK_STARTED listener) to enter the 'speaking' state and record metrics. For avatar outputs where the playback-started RPC races with interruption, say() may still miss the speaking-state transition. This is a pre-existing gap that the PR's new heuristic doesn't cover for this path, though the say() path always commits to chat context regardless (line 2560-2583), so the dropped-turn symptom doesn't apply here.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@chenghao-mou chenghao-mou requested a review from a team July 6, 2026 09:11
@@ -2895,7 +2915,18 @@ export class AgentActivity implements RecognitionHooks {
if (audioOutput) {
audioOutput.clearBuffer();
const interruptedPlaybackEv = await audioOutput.waitForPlayout();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

should we use something like

// generation.ts — performAudioForwarding(): record the baseline
const out: _AudioOut = {
  audio: [], firstFrameFut: new Future(), _hasCapturedOwnFrame: false,
  capturedSegmentsBefore: audioOutput.capturedPlayoutSegments,
};

// agent_activity.ts — the interrupted branch (both the segment and say paths)
- const forwardedAudio = output.audioOut?.startedForwardingAt !== undefined;
+ const playedOwnFrame =
+   output.audioOut !== null &&
+   audioOutput.capturedPlayoutSegments > output.audioOut.capturedSegmentsBefore;

if ((firstFrameFut.done && !firstFrameFut.rejected) ||
    (playedOwnFrame && playbackEv.playbackPosition > 0)) {
  output.played = 'partial';   // now only when a frame really played
  ...
}

basically forwarding check is replaced with captured check

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I can do it, but it will no longer have parity match with python code-level, tho I think its fine.

toubatbrian and others added 2 commits July 7, 2026 11:56
…ment count

startedForwardingAt is set before AudioOutput.captureFrame resolves, so a frame
that bails at a pause/interrupt gate (e.g. ParticipantAudioOutput while paused)
still looked like playback evidence, letting a stale playback position from a
previous segment commit a never-heard reply. Record the output's
capturedPlayoutSegments when forwarding is set up and only trust a non-zero
playback position when the count has since increased — the same condition under
which waitForPlayout returns this segment's event rather than a stale one.

Suggested by @chenghao-mou in #1966 review.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian toubatbrian requested a review from chenghao-mou July 7, 2026 09:39
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.

Agent speech silently dropped when interrupted before the first audio frame (resumeFalseInterruption) — port of Python #5039

2 participants