fix(voice): drop the recognition turn a false interruption resumed over - #7066
Conversation
`_on_false_interruption` resumes the paused agent speech without closing the `AudioRecognition` turn that raised the interruption. That turn stays open, and `_on_vad_event` only takes a fresh anchor while `_vad_speech_started` is False, so the next real utterance inherits the abandoned turn's `_speech_start_time` and `ChatMessage.metrics.started_speaking_at` can predate agent speech that actually came first. The drop path in `_bounce_eou_task` deliberately leaves the turn open β a dropped turn usually means the user is not finished. A confirmed false interruption is the case where it means the opposite: the agent is resuming over that turn, so it has been decided to be noise. Clear it there, before the next agent-speech interval opens. Fixes livekit#7063
The previous commit cleared the abandoned turn unconditionally, which also tears down the stt pipeline. That is safe only on the `_on_turn_settled` entry path, where a decision ran and dropped the turn. `_on_timeout` reaches `_on_false_interruption` with no decision ever open: the eou bounce only starts on VAD END_OF_SPEECH when `_vad_base_turn_detection`, so with `turn_detection="stt"` it waits for the stt final instead. There the speech may have been real with a slow final still in flight, and closing the pipeline loses it β trading out-of-order metrics for lost audio. At timeout, noise and a slow stt are indistinguishable, which is why the framework resumes in both cases. So the discriminator is whether a decision was made, not whether the speech was noise: pass the entry path in and release only the inherited anchors when it wasn't.
|
The review finding is correct, and it caught a real regression in the first version of this PR. Fixed in the push above. Details, since the reasoning is the interesting part of this change.
In the second path the speech may have been real, with a slow stt final still on its way, and What makes this awkward is that at timeout time you can't distinguish noise from slow stt β both look like "VAD fired, silence, no transcript". That's why the framework resumes the speech in both cases. So the discriminator can't be "is this noise?", it has to be "was a decision made?". So the reset is now two levels, with the entry path passed in as
Both paths still fix the reported bug, which matters because the issue's own reproduction drives New test: |
β¦callback The previous commit read "was this turn decided?" off which callback invoked the resume, but `_on_timeout` also falls through to the direct path when a decision has already completed β `eot_task.done()`. With the shipped 2s `false_interruption_timeout` against a backchannel dropped in a few hundred ms, that is the common case, so a dropped backchannel kept its transcript and `_audio_transcript` prepended it to the next real utterance. `eot_task.done()` cannot stand in for the verdict either: `_end_of_turn_task` is never reset to None, so a completed task from an earlier turn would look like a decision about this one and bring back the discarded-final bug. So record it where it is known. `_bounce_eou_task` sets `_user_turn_dropped` once per logical turn, a new VAD speech start supersedes it, and both resets clear it. The resume now reads the verdict rather than deducing it, which also lets the entry-path parameter go.
|
Also correct, and it's the common case rather than an edge: the shipped
Both of my attempts failed the same way: inferring turn-decision state from an indirect signal. So the fix stops inferring and records it where it is known.
New test: 12 tests in the file now (9 before this PR), each red without its own fix; |
The verdict recorded in the previous commit is only meaningful for the turn it describes, and I invalidated it on a new VAD speech start alone. The resume timer is also armed from the stt hooks, so a session can anchor its next turn through `_on_stt_event` instead: there the stale verdict survived and the resume erased a real transcript β the first review finding, by another door. Three paths take a fresh `_speech_start_time` (stt START_OF_SPEECH, VAD START_OF_SPEECH, VAD INFERENCE_DONE). Rather than remembering to invalidate at each, they now go through `_open_user_turn`, which takes the anchor and drops the previous verdict together.
|
Right again, and this one is squarely on me: I had spotted this exact staleness risk while writing the previous commit and then closed it for the VAD path only, assuming a new turn always begins at a VAD Three paths take a fresh New test: 13 tests in the file (9 before this PR), each red without its own fix; Happy to squash the four commits if you'd rather review this as one change β the history is only useful as a record of the review round trips. |
Summary
Fixes #7063.
When a false interruption is confirmed and the paused agent speech resumes,
_on_false_interruptionnever closes theAudioRecognitionturn that raised the interruption. The turn stays open, and_on_vad_eventonly takes a fresh anchor while_vad_speech_startedis stillFalse:So the next real utterance inherits the abandoned turn's
_speech_start_time, andChatMessage.metrics.started_speaking_atcan predate agent speech that actually came first. The guard from #6093 / #6098 doesn't catch it because the resulting metrics stay internally consistent β the anchor is stale, not impossible.This clears the turn in the
resumedbranch of_on_false_interruption, in the order the issue asks for: clear β_on_start_of_agent_speechβaudio_output.resume().Why here and not in the drop path
_bounce_eou_taskresets the anchors only when the turn commits. On a drop it resets just_turn_backchannel_over_agent,_overlap_in_current_turnand_user_turn_committedβ which is right in general, because a dropped turn usually means the user isn't finished and the turn should keep accumulating. A confirmed false interruption is the one case where a drop means the opposite: the agent is resuming over that turn, so it has been decided to be noise.Notes for review
resumedbranch._pause_enabled()gatesresume_false_interruption/can_pause/audio_enabledbefore_paused_speechis set, and the timer is only armed when_paused_speechis set β so with resume disabled this code is never reached._on_turn_settledruns after a decision dropped the turn, so nothing is in flight and the full_clear_user_turn()applies β it is also needed there, since a confirmed backchannel's transcript would otherwise prepend itself to the next utterance._on_timeoutcan run with no decision ever open (turn_detection="stt"starts no bounce on VADEND_OF_SPEECH), where a slow stt final for real speech may still be on its way; there only the inherited anchors are released, via_release_user_turn_anchors(). The first version of this PR cleared unconditionally and lost that final β see the discussion below.on_start_of_speechcalls_cancel_false_interruption_timer(), closing both entry paths. In the sub-tick window where the timer callback wins, the clear is harmless:_vad_speech_startedgoesFalse, so the VADSTART_OF_SPEECHprocessed right after takes its own anchor. This is also why the fix doesn't need the_last_speaking_time == last_speaking_timeguard the commit path uses._clear_user_turn()swaps the STT pipeline,_release_user_turn_anchors()doesn't. The full clear stays as-is because the issue requires the buffered transcript not to survive a confirmed drop, and it's the existing "discard this turn" primitive that the publicclear_user_turn()already uses. If you'd rather express the narrow reset as a keyword on_clear_user_turnthan as a second method, say so β it's a shared primitive, so the shape seemed like your call.Test
tests/test_false_interruption_resume.py::test_resume_drops_the_turn_it_resumed_over, built on the helpers already in that file.It drives the harder entry path: with
FALSE_INTERRUPTION_TIMEOUT = 0.3andMAX_DELAY = 0.5the timer fires while the end-of-turn decision is still open, so the resume goes through_false_interruption_pendingβ_on_turn_settledβ_on_false_interruption. The assertion is on the observable anchor rather than an internal flag: after the resume a real VADSTART_OF_SPEECHis delivered, and_speech_start_timemust equal that onset. Without the fix it comes back ~12.7 s stale β the same order of magnitude as the 12.151 s reported in the issue.test_resume_without_a_turn_decision_keeps_a_late_transcript_alivecovers the other entry path: no bounce is started, so the timer fires with no decision open, and the test asserts the anchor is released while the transcript and the stt pipeline β everything a late final needs to commit β survive. Against the first commit it fails onassert '' == 'what the caller actually said'.Testing
uv run pytest tests/test_false_interruption_resume.pyβ 11 passed (was 9); each new test fails without its fix and passes with ituv run pytest --unitβ 2307 passed, 5 skippeduv run ruff format --checkanduv run ruff checkβ cleanuv run python scripts/check_types.py(mypy strict) β no issues in 644 source files