Skip to content

fix(voice): wait for the latest speech segment's final before ending the turn - #7993

Closed
u9g wants to merge 1 commit into
mainfrom
jason/eot-flush-latest-segment
Closed

u9g wants to merge 1 commit into
mainfrom
jason/eot-flush-latest-segment

Conversation

@u9g

@u9g u9g commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Follows #7285. The live numbers below include #7287, so ElevenLabs Scribe v2 realtime declares manual_flush and finalizes a segment only when it is flushed.

What was wrong

With STT manual flush, VAD end of speech starts end-of-turn (EOT) detection. When the caller pauses mid-turn and resumes, the transcript at the next VAD end of speech holds only the earlier segments, because the newest segment's final transcript arrives only after the flush.

  • Text turn detector (for example MultilingualModel): the model scored that stale transcript (selection). A negative score held the flush until half of max_delay (delayed flush), so the turn took 1.7 to 2 s at default endpointing. A positive score committed the turn without the newest segment, and that segment's final then started a second user turn.
  • Audio turn detector (inference.TurnDetector): the flush waited for the audio prediction, so the STT started finalizing only after the prediction resolved. The commit could also race ahead of the newest segment's final.

What changed

  • VAD end of speech flushes immediately for every detector type, so the STT finalizes while the audio prediction is in flight. The delayed flush after a negative prediction is removed.
  • If the latest segment has no final transcript yet, EOT detection waits for it (wait). The flushed final re-enters detection: the text detector predicts on the full transcript, and the audio detector awaits its in-flight prediction. An empty final ends the wait. max_delay from the end of speech bounds it for segments that never produce words.
  • A segment counts as transcribed when a non-empty final arrived after its VAD start of speech and no interim text followed that final (check). STTs that finalize on their own, such as Deepgram, usually deliver the final before VAD end of speech and send nothing back for the flush, so they don't wait. The interim check covers a final for the previous segment that lands after the caller resumed.
  • The STTCapabilities.manual_flush docstring describes the new behavior.

Measurements

Live calls through lk agent debugger start --audio with scripted caller lines (lk agent debugger say): ElevenLabs scribe_v2_realtime STT with manual commits, openai/gpt-4.1-mini, cartesia/sonic-3. "Before" is #7287's head. "After" is this branch with #7287 merged. Each build ran three takes per line set; a few lines whose caller TTS failed in the harness are excluded. EOT is the caller's last audio in the recording to the committed user turn (about ±100 ms of alignment noise).

A. Default stack: MultilingualModel, Silero min_silence_duration=0.55

Multi-segment lines pause mid-sentence long enough for VAD to end the segment, for example "Yes. [300 ms] But only if it's before noon." or "I want the blue one. Actually... no, wait, the red one." The control lines have short pauses that VAD does not split.

Lines Median EOT p90 EOT Turns over 1.5 s Text predictions on a stale transcript Commits missing the latest segment
Multi-segment, before 40 848 ms 1760 ms 6 11 4
Multi-segment, after 37 841 ms 979 ms 1 0 0
Control, before 12 841 ms 895 ms 0 0 0
Control, after 14 836 ms 938 ms 0 0 0

Before, three of the four stale commits split the line into two user turns. In the fourth, the newest segment ("John Smith.") never reached the conversation. The one slow turn after the change is a single-segment line whose complete transcript scored 0.0108 against the 0.011 threshold, so it waited max_delay as intended. Lines where the detector ended the turn after a complete first phrase, with the full transcript so far, went from 8 to 6.

B. inference.TurnDetector(version="v1"), Silero min_silence_duration=0.25

Five short questions plus the multi-segment lines.

Lines VAD end of speech to STT flush (median) Median EOT, positive predictions p90 EOT, positive predictions Commits missing the latest segment
Before 36 177 ms 680 ms (27 lines) 757 ms 0
After 33 0 ms 531 ms (30 lines) 627 ms 0

Turns with a negative audio prediction wait max_delay (2.5 s) in both builds, so they are left out of the EOT columns. The audio detector does not read the transcript, and how many lines it scored negative varied from take to take.

With this config, every first phrase that the detector scored as complete ("Perfect, thanks.", "Yes.", "I want the blue one.") became its own user turn after the change: 10 of 10, against 5 of 7 before. The slower commit before the change let a caller who resumed within about 120 ms cancel 2 of them. Committing about 150 ms sooner with a 0.25 s VAD silence moves that boundary. The committed transcript was complete in every case.

Known limits

  • A segment with no words, such as a cough or background noise, waits up to max_delay if the STT sends no final for it. ElevenLabs sends no final for an empty commit.
  • Sometimes the previous segment's final arrives after the caller resumes, and the resumed segment ends before any interim transcript for it arrives. That turn behaves as before this change.

@u9g
u9g requested a review from a team as a code owner October 20, 2026 16:54
@u9g

u9g commented Oct 20, 2026

Copy link
Copy Markdown
Contributor Author

Before/after recordings from the live runs in the description. Each video is one caller line from a real call: the top waveform is the caller and the bottom one is the agent. Times are seconds from the start of each video. "Before" is #7287's head, and "after" is this branch with #7287 merged.

1. Default stack (MultilingualModel, Silero 0.55): "My name is John. [300 ms] John Smith."

Before: VAD ends the second segment at 3.12 s. The text detector scores the stale "My name is John." as complete, and the turn commits at 3.24 s without "John Smith.", which never reaches the conversation. The agent replies "Thank you, John; please share your booking reference…".

1_text_eot_stale_commit_before.mp4

After: VAD ends the second segment at 3.39 s and the STT flushes right away. The turn waits for that segment's final and commits at 3.57 s as "My name is John. John Smith.". The agent replies "Thank you, John Smith; …".

1_text_eot_stale_commit_after.mp4

2. Default stack: "Yes. [300 ms] But only if it's before noon."

Before: the caller stops at 3.15 s. The detector scores the stale "Yes." as incomplete, so the flush waits until 4.75 s and the turn commits at 5.13 s, 1980 ms after the caller's last audio.

2_text_eot_wait_before.mp4

After: the caller stops at 2.80 s. The STT flushes at end of speech (3.43 s), the detector scores the full sentence, and the turn commits at 3.57 s, 767 ms after the caller's last audio.

2_text_eot_wait_after.mp4

3. inference.TurnDetector(version="v1"), Silero 0.25: "Do they shed a lot in the summer?"

Before: VAD ends speech at 2.25 s, but the STT flush waits for the audio prediction (2.42 s). The turn commits at 2.58 s, 680 ms after the caller's last audio at 1.90 s.

3_audio_eot_before.mp4

After: VAD ends speech at 2.07 s and the STT flushes at once, in parallel with the prediction. The turn commits at 2.24 s, 530 ms after the caller's last audio at 1.71 s.

3_audio_eot_after.mp4

@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 +1631 to +1637
waiters = [
asyncio.ensure_future(self._final_transcript_received.wait()),
asyncio.ensure_future(self._closing.wait()),
]
try:
await asyncio.wait(
waiters, timeout=max(timeout, 0.0), return_when=asyncio.FIRST_COMPLETED

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.

🔴 Earlier final releases latest segment wait

If an earlier flush returns an empty final after the latest flush, _final_transcript_received releases the latest segment's wait. EOT can commit the previous transcript before the latest segment's final arrives.

Learn more

A flush sentinel is queued into the STT pipeline, but the final-transcript event carries no identifier linking it to that flush. The new wait listens to a shared event set by every final, including an empty final from an earlier flush. If the earlier response arrives after the next VAD end, it releases the next segment's wait without updating its transcript. The EOT task can then score and commit the earlier words while the next segment is still being transcribed. Final processing sets the event before rejecting empty text.

Example: The first segment finalizes as "hello" before VAD ends; its flush produces a delayed empty final. A second segment begins and ends, sending another flush. The first flush's empty final now arrives, releasing the second wait. The pending "world" final has not arrived when EOT commits "hello".

Recommended fix: Associate flush completion with the segment or flush request before releasing the wait. If the provider does not expose IDs, account for ordered pending flushes and treat an earlier empty response as completion of the earlier flush, not the current one.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

async def _on_vad_event(self, ev: vad.VADEvent) -> None:
if ev.type == vad.VADEventType.START_OF_SPEECH:
self._stt_flush_requested = False
self._segment_transcribed = False

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.

🟡 Early final incurs full endpointing delay

When STT finalizes before VAD starts, _segment_transcribed resets despite the completed transcript. If the flush produces no further final, EOT waits max_delay before committing that turn.

Learn more

STT and VAD events arrive through separate asynchronous consumers, so their event order does not establish which audio a final covers. A provider can send a final before local VAD emits START_OF_SPEECH for the same audio. Final processing marks that segment transcribed, but VAD start resets the mark. At VAD end the subsequent flush may produce no final for a provider that already finalized the words, leaving the new wait active for the entire maximum delay.

Example: An STT emits "hello" at 0.3 seconds, while local VAD emits START_OF_SPEECH at 0.4 seconds and END_OF_SPEECH at 1 second. The STT has no words left to finalize on the flush. The response waits until max_delay rather than the usual minimum delay.

Recommended fix: Track segment coverage using audio timestamps or a reliable segment boundary instead of clearing the final marker solely on VAD event arrival; ensure an already-finalized segment does not wait for a second final.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

Base automatically changed from chenghao/fix/AGT-3514-vad-eos-stt-flush to main October 20, 2026 18:06
@chenghao-mou

Copy link
Copy Markdown
Member

The reason why it flushes at EOT boundary and not VAD boundary is that 11labs often misses short speeches with pauses with VAD EOS flushes, e.g. when user is speaking a long phone/card number.

ElevenLabs explicitly warns: “Committing manually several times in a short sequence can degrade model performance.” in their docs.

…the turn

With STT manual flush, VAD end of speech could run end-of-turn detection on a
transcript that did not include the segment that just ended. The text turn
detector then scored the earlier segments only, and a negative score held the
flush until half of max_delay. The audio turn detector delayed the flush until
its prediction resolved, and the turn could commit before the latest segment's
final transcript arrived.

VAD end of speech now flushes immediately for every detector type. If the
latest segment has no final transcript yet, detection waits until the flushed
final re-enters it, bounded by max_delay from the end of speech. An empty
final, or a segment the STT already finalized, does not wait.
@u9g
u9g force-pushed the jason/eot-flush-latest-segment branch from 3db091b to 6127e85 Compare October 20, 2026 20:53
@u9g u9g closed this Oct 20, 2026
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.

2 participants