Skip to content

fix(voice): clear the stale interim on an empty final transcript - #7933

Open
BlueX888 wants to merge 1 commit into
livekit:mainfrom
BlueX888:fix/multi-speaker-interim-clearing
Open

BlueX888 wants to merge 1 commit into
livekit:mainfrom
BlueX888:fix/multi-speaker-interim-clearing

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

fix(voice): clear the stale interim on an empty final transcript

What breaks

MultiSpeakerAdapter signals "clear the interim" by emitting an empty FINAL_TRANSCRIPT (documented in multi_speaker_adapter.py) when a background speaker's attributed final is suppressed. AudioRecognition._process_stt_event sets _final_transcript_received and returns on an empty final β€” before clearing _audio_interim_transcript and before anything reaches the hooks.

With a diarizing STT that sends speaker-less interims (Deepgram sets speaker=None on every interim), the background speaker's interim is stored before its attributed final is suppressed, and the clearing event is dropped:

  • the stale interim text stays in _audio_interim_transcript until the next non-empty transcript,
  • it feeds _current_transcript's min_words interruption gate,
  • and a manual/API commit_user_turn commits the background words into the user turn β€” defeating suppress_background_speaker=True.

Fix

On an empty FINAL_TRANSCRIPT, clear _audio_interim_transcript and _audio_preflight_transcript, then return. _final_transcript_received is still set; every non-empty path (accumulation, interrupt handling, turn limits) is untouched.

Scope note: the client-visible interim display is not fixed by this change. The transcript transport carries text as chunked streams, and an empty final is a zero-chunk stream that all reference clients ignore (no wire-level "clear" exists today); a client clear needs protocol/client support and is tracked as a follow-up. This fix restores the internal state (interruption gate + commit_user_turn), which is where suppress_background_speaker was actually defeated.

Tests

tests/test_multi_speaker_interim_clear.py feeds the adapter's documented sequence (final hello S0 β†’ speaker-less interim background chatter β†’ empty final):

  • On base: assert ar._audio_interim_transcript == '' fails ('background chatter' != '') and commit_user_turn returns 'hello background chatter' instead of 'hello'.
  • With the fix: stale interim cleared, _current_transcript == 'hello', commit returns 'hello'; a gate-free variant covers an empty final as the first event.

3 passed; multi-speaker+delay group 42, audio-recognition 504 (identical at base), test_agent_session 110, test_room_io 60; ruff clean. The fix went through two independent review rounds β€” the first rejected a client-clear attempt (a stream sink change that would have finalized the suppressed background text, and then a zero-chunk clear that reference clients ignore), which is why this revision intentionally stops at the internal state and makes no client-side claim.

MultiSpeakerAdapter signals a suppressed background-speaker segment with a
FINAL_TRANSCRIPT whose text is empty. Diarizing providers report interims
without a speaker id, so a background interim bypasses suppression and is
buffered by AudioRecognition, while the attributed final is suppressed.

AudioRecognition returned on the empty final before clearing
_audio_interim_transcript. The stale background words stayed in
_current_transcript, where they counted toward the min_words interruption gate,
and commit_user_turn appended them to the user turn.

Drop the buffered interim and preflight text when the final is empty. The event
is not forwarded: the transcript transport cannot carry a wire-level clear (an
empty text stream has zero data chunks, which clients ignore), so a
client-visible clear needs protocol support and is out of scope here.
@BlueX888
BlueX888 requested a review from a team as a code owner October 18, 2026 19:40

@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 found 2 potential issues.

Devin Review

Comment on lines +1246 to +1247
self._audio_interim_transcript = ""
self._audio_preflight_transcript = ""

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.

πŸ”΄ Background final erases primary speaker words

When a background final follows a primary speaker interim, _audio_interim_transcript loses the primary speaker's words. The adapter sends the empty final without identifying which interim it clears. A manual commit then omits those words.

Learn more

The adapter forwards unattributed interim results but replaces a suppressed background final with an empty final that has no speaker or segment identifier. Recognition holds only one interim and one preflight buffer. If the latest interim belongs to the primary speaker while a background final arrives, clearing those buffers drops legitimate unfinalized speech. The same loss occurs for a primary speaker preflight transcript. The adapter cannot tell recognition which transcript was suppressed.

Example: The primary speaker says β€œbook a ticket,” producing an interim. A background speaker's attributed final arrives next and is suppressed. The empty final deletes β€œbook a ticket,” so an immediate manual commit returns no ticket request instead of preserving those words.

Recommended fix: Carry a segment identity or ordering information through the adapter and clear only interim/preflight state belonging to the suppressed segment. Validate interleaved speaker events as well as the sequential events already tested.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

Comment on lines +1246 to 1248
self._audio_interim_transcript = ""
self._audio_preflight_transcript = ""
return

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.

🟑 Manual commit waits after empty final

When the previous nonempty final is over 0.5 seconds old, _last_final_transcript_time remains stale after an empty final. Manual commit then discards its completion signal and waits until timeout without a later final.

Learn more

Manual commit uses the last-final timestamp to decide whether it must wait for a new STT result. A suppressed segment produces a real final event, but the empty-final path does not update that timestamp. After a quiet interval, the commit clears the completion event that the empty final just set and waits for an additional final or for the timeout. The regression test overwrites the timestamp and skips this path.

Example: A primary final arrives at t=0; a background interim and its empty final arrive at t=1. A manual commit at t=1.1 clears _final_transcript_received and waits for the configured transcript timeout, although the suppressed segment has already finalized.

Recommended fix: Decide whether an empty final can satisfy the manual-commit freshness check, and update the relevant timestamp when it does. Exercise commit without overwriting the timestamp in the test.

Devin Review


Was this helpful? React with πŸ‘ or πŸ‘Ž to provide feedback.

This branch has not been deployed

No deployments
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.

1 participant