Skip to content

fix(voice): drop the recognition turn a false interruption resumed over - #7066

Open
salvatorebottiglieri wants to merge 4 commits into
livekit:mainfrom
salvatorebottiglieri:fix/false-interruption-clears-abandoned-turn
Open

fix(voice): drop the recognition turn a false interruption resumed over#7066
salvatorebottiglieri wants to merge 4 commits into
livekit:mainfrom
salvatorebottiglieri:fix/false-interruption-clears-abandoned-turn

Conversation

@salvatorebottiglieri

@salvatorebottiglieri salvatorebottiglieri commented Aug 31, 2026

Copy link
Copy Markdown

Summary

Fixes #7063.

When a false interruption is confirmed and the paused agent speech resumes, _on_false_interruption never closes the AudioRecognition turn that raised the interruption. The turn stays open, and _on_vad_event only takes a fresh anchor while _vad_speech_started is still False:

# audio_recognition.py, _on_vad_event, START_OF_SPEECH
if not self._vad_speech_started:
    self._speech_start_time = speech_start_time
    self._vad_speech_started = True

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 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 resumed branch 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_task resets the anchors only when the turn commits. On a drop it resets just _turn_backchannel_over_agent, _overlap_in_current_turn and _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

  • Only the resumed branch. _pause_enabled() gates resume_false_interruption / can_pause / audio_enabled before _paused_speech is set, and the timer is only armed when _paused_speech is set β€” so with resume disabled this code is never reached.
  • Two entry paths, two levels of reset. _on_turn_settled runs 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_timeout can run with no decision ever open (turn_detection="stt" starts no bounce on VAD END_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.
  • No live turn can be wiped. on_start_of_speech calls _cancel_false_interruption_timer(), closing both entry paths. In the sub-tick window where the timer callback wins, the clear is harmless: _vad_speech_started goes False, so the VAD START_OF_SPEECH processed right after takes its own anchor. This is also why the fix doesn't need the _last_speaking_time == last_speaking_time guard 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 public clear_user_turn() already uses. If you'd rather express the narrow reset as a keyword on _clear_user_turn than 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.3 and MAX_DELAY = 0.5 the 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 VAD START_OF_SPEECH is delivered, and _speech_start_time must 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_alive covers 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 on assert '' == '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 it
  • uv run pytest --unit β†’ 2307 passed, 5 skipped
  • uv run ruff format --check and uv run ruff check β†’ clean
  • uv run python scripts/check_types.py (mypy strict) β†’ no issues in 644 source files

`_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
@salvatorebottiglieri
salvatorebottiglieri requested a review from a team as a code owner August 31, 2026 16:41
@CLAassistant

CLAassistant commented Aug 31, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@salvatorebottiglieri

salvatorebottiglieri commented Aug 31, 2026

Copy link
Copy Markdown
Author

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.

_on_false_interruption has two entry paths, and I had wrongly treated them as equivalent:

  • _on_turn_settled : a turn decision ran and did not commit. If it had committed, on_end_of_turn would have interrupted the paused speech β†’ _cancel_speech_pause β†’ _cancel_false_interruption_timer clears _false_interruption_pending, and the callback returns early. So reaching _on_false_interruption here means the turn was decided and dropped: nothing is in flight for it.
  • _on_timeout direct : no decision was ever open. Reachable because the eou bounce only starts on VAD END_OF_SPEECH when _vad_base_turn_detection, which is self._turn_detection_mode in ("vad", None). With turn_detection="stt" the bounce waits for the stt final instead, so _end_of_turn_task is None and the timer fires straight through. _pause_enabled() doesn't exclude that mode.

In the second path the speech may have been real, with a slow stt final still on its way, and _clear_user_turn() runs _update_stt(None) β€” closing the pipeline and cancelling the stt consumer. The final is lost. Before this PR that final would have arrived and committed the turn: out-of-order metrics (the bug this PR is about) but not lost audio. Trading a metrics defect for lost speech is strictly worse.

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 _on_false_interruption(*, turn_dropped: bool):

  • turn_dropped=True β†’ _clear_user_turn() as before. The full clear is also needed here: a confirmed backchannel's transcript sits in _audio_transcript (the drop path in _bounce_eou_task only clears it on commit) and would otherwise prepend itself to the next real utterance.
  • turn_dropped=False β†’ a new AudioRecognition._release_user_turn_anchors(), which releases only what would be inherited β€” _speech_start_time, _vad_speech_started, _last_emitted_prediction, _turn_tracker and the open user_turn span β€” and leaves everything that could still commit: the transcript, the accumulated confidence, the turn detector's buffer, the stt pipeline.

Both paths still fix the reported bug, which matters because the issue's own reproduction drives _start_false_interruption_timer(0) with no decision open β€” i.e. the direct path. Narrowing the fix to the settled path alone would have left the reported case unfixed.

New test: test_resume_without_a_turn_decision_keeps_a_late_transcript_alive. Against the previous commit it fails on assert '' == 'what the caller actually said'.

devin-ai-integration[bot]

This comment was marked as resolved.

…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.
@salvatorebottiglieri

Copy link
Copy Markdown
Author

Also correct, and it's the common case rather than an edge: the shipped false_interruption_timeout is 2s (voice/turn.py), while a confirmed backchannel drops in a few hundred ms, so the decision normally lands well before the resume timer. _on_timeout then falls through to the direct path with eot_task.done() β€” and the previous commit read the verdict off which callback invoked the resume, so it treated that as "no decision" and kept the transcript. _audio_transcript accumulates, so the dropped words prepend themselves to the next real utterance.

eot_task.done() can't stand in for the verdict either: _end_of_turn_task is never reset to None after completion, so a task completed for an earlier turn is indistinguishable from a decision about this one β€” using it would bring back the discarded-final case from the first finding.

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.

AudioRecognition._user_turn_dropped is set by _bounce_eou_task as not committed, once per logical turn, in the section that already resets the turn-scoped barge-in state. A new VAD START_OF_SPEECH supersedes it (the branch that takes a fresh _speech_start_time), and both _clear_user_turn and _release_user_turn_anchors clear it. _on_false_interruption now reads the verdict instead of deducing it, which also lets the entry-path parameter from the previous commit go β€” the resume no longer cares how it was reached.

New test: test_a_backchannel_dropped_before_the_timeout_does_not_leak_forward arms a resume timer that outlives the decision and asserts the verdict survives the gap and the transcript ends up empty. Without the recorded verdict it fails on assert False is True.

12 tests in the file now (9 before this PR), each red without its own fix; uv run pytest --unit β†’ 2308 passed, 5 skipped; ruff and mypy strict clean.

devin-ai-integration[bot]

This comment was marked as resolved.

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.
@salvatorebottiglieri

Copy link
Copy Markdown
Author

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 START_OF_SPEECH. It doesn't β€” the resume timer is armed from the stt hooks too (on_interim_transcript / on_final_transcript when speaking is False), so a session can anchor its next turn through _on_stt_event, where the verdict from the turn that just ended survived and the resume erased a real transcript. The first finding, by another door.

Three paths take a fresh _speech_start_time: _on_stt_event START_OF_SPEECH, _on_vad_event START_OF_SPEECH, and _on_vad_event INFERENCE_DONE. I had covered one. Rather than adding the invalidation to the other two and relying on the next person remembering, they now all go through _open_user_turn(speech_start_time), which takes the anchor and drops the previous verdict together β€” the invariant is structural instead of remembered, which is precisely why I missed it.

New test: test_an_stt_anchored_turn_supersedes_the_previous_verdict anchors the next turn through the stt stream with a dropped verdict standing, and asserts the verdict is superseded and a slow final for the new turn survives the resume. Without the reset on that path it fails on assert True is False.

13 tests in the file (9 before this PR), each red without its own fix; uv run pytest --unit β†’ 2309 passed, 5 skipped; ruff and mypy strict clean.

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.

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.

False interruption keeps stale started_speaking_at for next user turn

2 participants