fix(voice): signal the interruption before un-gating the audio sink - #2134
Conversation
🦋 Changeset detectedLatest commit: 5887cb6 The changes in this PR will be included in the next version bump. This PR includes changesets to release 39 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 |
Live verification with cue-cliThe ordering this PR is about is directly observable in a live barge-in, and it turns out to be the change that makes #2133 do anything at all. Same scripted scenario throughout (long story, 6s, then "Sorry, stop, stop. I changed my mind. What is two plus two?"), three runs per cell, driven against LiveKit Cloud with real STT/TTS and adaptive interruption. The marks below are the order in which the sink observed
The top row is the finding. Without this change, Consequence for the stack: #2133 sits below this PR and is inert until this lands — both cells in the top row are 0 drops regardless of whether its hoisted check is present. The two are jointly necessary, and this is the half that arms the other. Control: the follow-up reply delivered every frame in all 6 runs of the bottom row (104/104, 24/24, 101/101 with #2133; 24/24, 101/101, 102/102 without). The frames dropped belong to the interrupted reply. Still unchanged: what the revert costsNone of this touches the no-transcript path. |
dc8e1da to
5887cb6
Compare
Live verification: reproduced, reproducibly, and now with a measured durationThis is the one change in the interruption half of the stack whose effect I could see in a live session as a frame-level difference, and it reproduces on every run. Method. A throwaway probe in
The marker column is the fix itself showing up in the trace: without this commit there is no The duration is measured, not inferred. The probe now records What this establishes, and what it does not. It confirms the ordering defect is real on the live path and that the fix stops the audio the user has already barged in over — and it confirms the scale. Around 100–120 ms, not seconds. That is consistent with the unit test in the description measuring a 1-frame leak on Coupling with #2133, worth flagging before merge. The middle row is the interesting one. With this commit but without #2133's hoisted check, one frame is still dropped — caught by the pre-existing check inside the closed-gate branch, which this commit's I noticed the branches now appear reordered so that this one sits directly on The probe is throwaway instrumentation, not committed and not pushed; all five branch tips still match |
|
This PR was closed as merged by mistake. During an attempted reorder of the stack, a force-push moved this PR's base branch ( Nothing was merged into Merged PRs cannot be reopened, so the work continues at #2136, which carries the same commit ( |
Stack 5/5 — split out of #2126. Base: #2133.
The defect
cancelSpeechPause()reopens the sink's pause gate to admit the next speech. But the frames parked at that gate belong to the speech it has just interrupted, and they are released first — so audio the user has already barged in over goes back on the wire.The fix clears the sink before reopening the gate, and only when that same call is what interrupted the paused speech; an
interrupt: falsecancel still resumes without clearing.Python parity
This is parity, not a divergence.
_cancel_speech_pause(agent_activity.py:4230) awaits_wait_for_generation()beforeresume(), so by the time Python reopens the gate the interrupted reply task has already run its ownclear_buffer(). JS wraps the same await inwaitIfNotInterrupted, which races it against the interrupt futurehandle.interrupt()just resolved — so it returns immediately, deliberately, per #1124. JS therefore has to signal the sink explicitly to land in the same state.What this PR does not do
An earlier version of this change also had
onInterruptioncallcancelSpeechPause(), making the model's verdict commit the turn outright. @chenghao-mou pointed out that leaving the speech parked is by design, and Python agrees —on_interruption(agent_activity.py:2066-2076) does restore → interrupt-by-audio-activity →_on_end_of_agent_speechand stops. That half has been dropped, so JS matches Python line for line.The cost of dropping it, measured.
confirmed_interruption_pause_and_commit.test.tsdrives a realAgentSessionagainst a realParticipantAudioOutputand counts frames at theAudioSourceboundary; only the model verdict is synthesized. The reply is 40 frames and the barge-in lands after 3, so 38 is "the entire rest of the reply":origin/maininterrupted=truedeliveredAfterVerdict=1interrupted=truedeliveredAfterVerdict=0interrupted=falsedeliveredAfterVerdict=38falseInterruption=[true]interrupted=falsedeliveredAfterVerdict=38falseInterruption=[true]Read the second row loudly: on the no-transcript path this stack is identical to
main. The whole tail of the reply goes back on the wire and the session reportsagent_false_interruption(resumed: true). So the "agent pauses, then resumes mid-sentence" symptom is only prevented by an STT final transcript landing insidefalseInterruptionTimeout(default 2s) — which is precisely Python's contract, and precisely the dead-air trade-off being defended. Merging this stack means accepting it.The first row is what this PR buys: on the path a real barge-in takes, the turn commits and not one frame of the interrupted reply reaches the wire, where
mainleaks 20ms. Removing theclearBuffer()call turns that 0 back into a 1.Closes #2122.
Made with Cursor