Skip to content

fix(voice): don't strand playback-segment counter on interrupted-while-paused frame - #1662

Closed
enriqueespaillat-gyde wants to merge 1 commit into
livekit:mainfrom
enriqueespaillat-gyde:enrique/fix-audiooutput-segment-counter-hang
Closed

fix(voice): don't strand playback-segment counter on interrupted-while-paused frame#1662
enriqueespaillat-gyde wants to merge 1 commit into
livekit:mainfrom
enriqueespaillat-gyde:enrique/fix-audiooutput-segment-counter-hang

Conversation

@enriqueespaillat-gyde

Copy link
Copy Markdown
Contributor

Summary

ParticipantAudioOutput.captureFrame can permanently desync its playback-segment counter when a frame arrives while the output is paused (interruption detection) and is then interrupted. Afterwards every waitForPlayout() on that output blocks forever, which can hang the agent.

Root cause

captureFrame calls super.captureFrame(frame) — which bumps the base AudioOutput's playbackSegmentsCountbefore the pause/interrupt gate:

super.captureFrame(frame);            // playbackSegmentsCount++

if (!this.playbackEnabledFuture.done) {
  this.audioSource.clearQueue();
  await Promise.race([this.playbackEnabledFuture.await, this.interruptedFuture.await]);
  if (this.interruptedFuture.done) {
    return;                           // pushedDuration is never incremented
  }
}

When a frame bails at that early return (interrupted while paused), the segment was already counted, but:

  • pushedDuration is never incremented, so
  • flush() short-circuits on if (!this.pushedDuration) return and never starts waitForPlayoutTask, so
  • onPlaybackFinished is never called and playbackFinishedCount never catches up.

playbackSegmentsCount is now permanently ahead of playbackFinishedCount, so the next segment's waitForPlayout() (while (playbackFinishedCount < playbackSegmentsCount) await ...) never resolves.

Why it can hang the agent

The stalled turn's reply pipeline awaits audioOutput.waitForPlayout() and so never reaches _markGenerationDone(). Because the speech handle is also never interrupted, AgentActivity.mainTask's await speechHandle.waitIfNotInterrupted([speechHandle._waitForGeneration()]) has neither of its two exit conditions and parks forever — the agent goes silent until the participant disconnects. This is easiest to hit when a tool-call-only turn (no spoken text) immediately follows a barge-in. (Same hang surface as the mainTask issues #836 / #1089, but a distinct root cause in the audio output.)

Fix

Register the playback segment (super.captureFrame) only after the pause/interrupt gate is cleared, so a frame that bails never counts a segment.

Test

Adds a ParticipantAudioOutput captureFrame segment accounting suite to _output.test.ts:

  • a frame interrupted while paused must not register a segment, and a following waitForPlayout() resolves (this fails before the fix — playbackSegmentsCount is left at 1 and waitForPlayout() hangs);
  • the normal (non-paused) path still registers and pushes the frame.

@changeset-bot

changeset-bot Bot commented Jun 1, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: abf1c8c

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

This PR includes changesets to release 33 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-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-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 potential bugs to report.

View in Devin Review to see 3 additional findings.

Open in Devin Review

…e-paused frame

ParticipantAudioOutput.captureFrame registered the playback segment
(super.captureFrame, which bumps playbackSegmentsCount) before the
pause/interrupt gate. A frame that bailed at the gate — interrupted while
the output was paused — left playbackSegmentsCount one ahead of
playbackFinishedCount permanently: the early return skips pushedDuration,
flush() then short-circuits on `if (!this.pushedDuration) return` and never
starts waitForPlayoutTask, so onPlaybackFinished is never called.

Every subsequent waitForPlayout() then blocks forever. Because the stalled
turn's speech handle is never interrupted, AgentActivity.mainTask parks on
_waitForGeneration() with no escape and the agent goes silent until the
participant disconnects.

Count the segment only after the pause/interrupt gate is cleared.

Signed-off-by: enrique <enrique.espaillat@gydehealth.ai>
@enriqueespaillat-gyde
enriqueespaillat-gyde force-pushed the enrique/fix-audiooutput-segment-counter-hang branch from f31481b to abf1c8c Compare June 1, 2026 02:29
@enriqueespaillat-gyde

Copy link
Copy Markdown
Contributor Author

@toubatbrian this is an issue we run into often, im not entirely confident on the fix. Essentially causes the agent to hang if interrupted mid tool call.

@toubatbrian

toubatbrian commented Jun 8, 2026

Copy link
Copy Markdown
Contributor

Essentially causes the agent to hang if interrupted mid tool call.

@enriqueespaillat-gyde Just to clarify, is this before or after this fix? From my local testing on your PR, I think this is the right fix.

@enriqueespaillat-gyde

enriqueespaillat-gyde commented Jun 9, 2026

Copy link
Copy Markdown
Contributor Author

Essentially causes the agent to hang if interrupted mid tool call.

@enriqueespaillat-gyde Just to clarify, is this before or after this fix? From my local testing on your PR, I think this is the right fix.

I think the only problem for us - is that it doesn't fully fix the issue. I think I got closer to the root cause here - #1732

I'm less confident on the right fix though.

enriqueespaillat-gyde added a commit to enriqueespaillat-gyde/agents-js that referenced this pull request Jun 9, 2026
…ivekit#1662 + synced-output drift reconcile)

Two related playback-segment counter imbalances leave the agent silent after a
barge-in (the welcome-call silent-hang):

1. ParticipantAudioOutput.captureFrame bumped playbackSegmentsCount via
   super.captureFrame BEFORE its pause/interrupt gate. A frame captured while
   paused and then interrupted bailed at the gate after that bump, stranding the
   segment count ahead of playbackFinishedCount so the next waitForPlayout()
   blocked forever. Count the segment only after the gate. (livekit#1662)

2. SyncedAudioOutput (TranscriptionSynchronizer wrapper) counts a segment in its
   own captureFrame, then forwards to the downstream sink — which can drop the
   frame at its interrupt gate without counting or ever finishing it. The
   wrapper's finish count is driven by the downstream's playback-finished events,
   so its segment count drifts permanently ahead and SyncedAudioOutput.waitForPlayout()
   (awaited by the reply pipeline before it marks generation done) strands forever,
   freezing the turn pump. waitForPlayout() now reconciles the drift before waiting;
   the downstream's legitimate in-flight segments are still awaited. A public
   pendingPlayoutSegments accessor on AudioOutput exposes the outstanding count for
   the cross-output comparison.

Adds a deterministic regression test: a downstream sink that drops a captured frame
makes SyncedAudioOutput.waitForPlayout() resolve (reconciled) rather than hang
(verified: times out without the reconcile).
enriqueespaillat-gyde added a commit to enriqueespaillat-gyde/agents-js that referenced this pull request Jun 9, 2026
…ate livekit#1662 + synced-output drift reconcile)

Two related playback-segment counter imbalances leave the agent silent after a
barge-in:

1. ParticipantAudioOutput.captureFrame bumped playbackSegmentsCount via
   super.captureFrame BEFORE its pause/interrupt gate. A frame captured while
   paused and then interrupted bailed at the gate after that bump, stranding the
   segment count ahead of playbackFinishedCount so the next waitForPlayout()
   blocked forever. Count the segment only after the gate. (livekit#1662)

2. SyncedAudioOutput (TranscriptionSynchronizer wrapper) counts a segment in its
   own captureFrame, then forwards to the downstream sink — which can drop the
   frame at its interrupt gate without counting or ever finishing it. The
   wrapper's finish count is driven by the downstream's playback-finished events,
   so its segment count drifts permanently ahead and SyncedAudioOutput.waitForPlayout()
   (awaited by the reply pipeline before it marks generation done) strands forever,
   freezing the turn pump. waitForPlayout() now reconciles the drift before waiting;
   the downstream's legitimate in-flight segments are still awaited. A public
   pendingPlayoutSegments accessor on AudioOutput exposes the outstanding count for
   the cross-output comparison.

Adds a deterministic regression test: a downstream sink that drops a captured frame
makes SyncedAudioOutput.waitForPlayout() resolve (reconciled) rather than hang
(verified: times out without the reconcile).
enriqueespaillat-gyde added a commit to enriqueespaillat-gyde/agents-js that referenced this pull request Jun 9, 2026
…ate livekit#1662 + synced-output drift reconcile)

Two related playback-segment counter imbalances leave the agent silent after a
barge-in:

1. ParticipantAudioOutput.captureFrame bumped playbackSegmentsCount via
   super.captureFrame BEFORE its pause/interrupt gate. A frame captured while
   paused and then interrupted bailed at the gate after that bump, stranding the
   segment count ahead of playbackFinishedCount so the next waitForPlayout()
   blocked forever. Count the segment only after the gate. (livekit#1662)

2. SyncedAudioOutput (TranscriptionSynchronizer wrapper) counts a segment in its
   own captureFrame, then forwards to the downstream sink — which can drop the
   frame at its interrupt gate without counting or ever finishing it. The
   wrapper's finish count is driven by the downstream's playback-finished events,
   so its segment count drifts permanently ahead and SyncedAudioOutput.waitForPlayout()
   (awaited by the reply pipeline before it marks generation done) strands forever,
   freezing the turn pump. waitForPlayout() now reconciles the drift before waiting;
   the downstream's legitimate in-flight segments are still awaited. A public
   pendingPlayoutSegments accessor on AudioOutput exposes the outstanding count for
   the cross-output comparison.

Adds a deterministic regression test: a downstream sink that drops a captured frame
makes SyncedAudioOutput.waitForPlayout() resolve (reconciled) rather than hang
(verified: times out without the reconcile).
enriqueespaillat-gyde added a commit to enriqueespaillat-gyde/agents-js that referenced this pull request Jun 29, 2026
…#1909)

Use the bare livekit#1909 short form referenced once at the core fix points,
matching existing comments (e.g. livekit#1662, livekit#1430, livekit#1124), instead of the
verbose cross-repo 'livekit#1909 (port of livekit/agents#5039)'
form and the per-call-site repetition. The port context lives in the
commit/PR/changeset.
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.

2 participants