fix(voice): don't strand playback-segment counter on interrupted-while-paused frame - #1662
Conversation
🦋 Changeset detectedLatest commit: abf1c8c The changes in this PR will be included in the next version bump. This PR includes changesets to release 33 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 |
…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>
f31481b to
abf1c8c
Compare
|
@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. |
@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. |
…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).
…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).
…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).
…#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.
Summary
ParticipantAudioOutput.captureFramecan permanently desync its playback-segment counter when a frame arrives while the output is paused (interruption detection) and is then interrupted. Afterwards everywaitForPlayout()on that output blocks forever, which can hang the agent.Root cause
captureFramecallssuper.captureFrame(frame)— which bumps the baseAudioOutput'splaybackSegmentsCount— before the pause/interrupt gate:When a frame bails at that early
return(interrupted while paused), the segment was already counted, but:pushedDurationis never incremented, soflush()short-circuits onif (!this.pushedDuration) returnand never startswaitForPlayoutTask, soonPlaybackFinishedis never called andplaybackFinishedCountnever catches up.playbackSegmentsCountis now permanently ahead ofplaybackFinishedCount, so the next segment'swaitForPlayout()(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'sawait 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 themainTaskissues #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 accountingsuite to_output.test.ts:waitForPlayout()resolves (this fails before the fix —playbackSegmentsCountis left at 1 andwaitForPlayout()hangs);