Skip to content

[barge-in] fix(room_io): drop an interrupted reply's TTS backlog instead of playing it - #2133

Closed
toubatbrian wants to merge 2 commits into
brian/interruption-stale-pause-gatefrom
brian/interrupted-reply-backlog
Closed

[barge-in] fix(room_io): drop an interrupted reply's TTS backlog instead of playing it#2133
toubatbrian wants to merge 2 commits into
brian/interruption-stale-pause-gatefrom
brian/interrupted-reply-backlog

Conversation

@toubatbrian

@toubatbrian toubatbrian commented Jul 27, 2026

Copy link
Copy Markdown
Contributor

Part of the barge-in stack. Base: #2132 — review only the top commit; the diff against main includes #2131 and #2132.

Important

Depends on #2136 and is inert without it. Merged in stack order the end state is correct, but this should not be the stack's last landed change — on its own the hoisted check is unreachable on the live barge-in path.

Problem

captureFrame compared a segment's interrupt snapshot only inside the branch that handles a closed pause gate, so the check was skipped entirely once the gate reopened.

cancelSpeechPause reopens the sink to admit the next reply as soon as the handle is interrupted, but the interrupted reply's forwardAudio loop keeps running until its abort signal fires an event loop turn later — and real TTS hands it seconds of audio ahead of realtime to drain in the meantime. Those frames find the gate already open, take the unchecked path, and reach the wire while the next reply's transcript is already streaming.

Fix

Hoist the interrupt check so it runs on every frame, not only the ones that parked at the gate.

Testing

_output_interrupted_segment.test.ts fails on main and passes here, with a control case proving the next reply still plays normally after the interrupted one flushes.

Scope — please don't over-credit this

Measured live with cue-cli as a 2×2 against #2136, three runs per cell, counting frames dropped at the sink during a real barge-in: this fix's own marginal contribution is one additional ~20ms tail frame on top of what #2136 already stops. Without #2136 nothing is dropped at all.

It is a genuine defect and worth fixing, but it does not explain the multi-second audio/transcript desync report it was written while chasing. That investigation is still open, and I would not want this merged under that banner.

Note on recorder_io.test.ts

The hoisted check made one recorder test fail; the test was wrong, not the fix. It called clearBuffer() without a flush(), leaving segmentOpen true and the snapshot stale. forwardAudio always flushes in its finally, so the test now does the same.

@toubatbrian
toubatbrian requested a review from a team as a code owner July 27, 2026 16:22
@changeset-bot

changeset-bot Bot commented Jul 27, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: dfa4855

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

This PR includes changesets to release 39 packages
Name Type
@livekit/agents Patch
@livekit/agents-plugin-anam Patch
@livekit/agents-plugin-anthropic Patch
@livekit/agents-plugin-assemblyai Patch
@livekit/agents-plugin-azure 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-krisp 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-protoface 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: No Issues Found

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

Open in Devin Review

toubatbrian and others added 2 commits July 27, 2026 18:54
ParticipantAudioOutput.clearBuffer() resolves an interruptedFuture that frames parked at the
pause gate consult to decide whether to bail. Nothing reset that signal until the *next*
segment's flush(), which only runs after all of that segment's frames have been captured — so
for the whole of the following reply the signal still described an interruption that was over.
Pause the output mid-reply in that window (an ordinary false-interruption pause, on by default)
and every remaining frame bails at the gate and never reaches the wire, while the session still
reports the reply as fully spoken.

Measured with a real AgentSession and a real ParticipantAudioOutput: after a barge-in, the next
reply lost 18 of 20 frames (360ms of 400ms) and was committed to chat context as complete.

The gate is now scoped to the segment being captured: a frame bails only for an interruption
raised at or after its own segment began. Parked frames are woken through a per-frame signal so
a concurrent flush() that replaces interruptedFuture can no longer strand one at the gate.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ing it

captureFrame only compared the segment's interrupt snapshot while the pause
gate was closed, so the check was skipped once the gate reopened.
cancelSpeechPause un-gates the sink to admit the next reply as soon as the
handle is interrupted, but the interrupted reply's forwardAudio loop keeps
running until its abort signal fires a turn later, draining the seconds of
audio real TTS providers deliver ahead of realtime. Those frames reached the
wire while the next reply's transcript was already streaming.

The recorder's "never flushed" case now flushes before the follow-on turn:
forwardAudio always flushes in its finally, and that flush is what tells the
sink where the interrupted segment ends.

Co-authored-by: Cursor <cursoragent@cursor.com>
@toubatbrian
toubatbrian force-pushed the brian/interrupted-reply-backlog branch from 8b3a0d7 to dfa4855 Compare July 28, 2026 01:59
@toubatbrian toubatbrian changed the title fix(room_io): drop an interrupted reply's TTS backlog instead of playing it [barge-in 3/5] fix(room_io): drop an interrupted reply's TTS backlog instead of playing it Jul 28, 2026
@toubatbrian toubatbrian changed the title [barge-in 3/5] fix(room_io): drop an interrupted reply's TTS backlog instead of playing it [barge-in] fix(room_io): drop an interrupted reply's TTS backlog instead of playing it Jul 28, 2026
@toubatbrian

Copy link
Copy Markdown
Contributor Author

Folded into #2132. The hoisted interruptCount check and segmentInterruptCount snapshot are two halves of the same mechanism, and the hoist is inert without the snapshot — keeping them apart made a 3-line logic change look like a comment-only PR. Same code and tests, now in one commit there.

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