Skip to content

fix(voice): spoken audio vs transcript misalignment#6404

Draft
Bobronium wants to merge 1 commit into
mainfrom
arseny/fix-false-interruption-stale-resume
Draft

fix(voice): spoken audio vs transcript misalignment#6404
Bobronium wants to merge 1 commit into
mainfrom
arseny/fix-false-interruption-stale-resume

Conversation

@Bobronium

@Bobronium Bobronium commented Jul 13, 2026

Copy link
Copy Markdown
Member
Generated by Claude
                     barge-in while speaking
                              β”‚
                        β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”
                        β”‚  PAUSED   β”‚  owner: _PausedSpeechInfo
                        β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
              VAD EOS         β”‚
      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
      β”‚ segment ≀ 1.5s        β”‚ segment > 1.5s           β”‚ non-empty interim/
      β”‚ AND no transcript     β”‚ (too long to be noise)   β”‚ final arrives
β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ resume timer   β”‚      β”‚ EVIDENCE:      β”‚        β”‚ EVIDENCE:      β”‚
β”‚ (2s, as today) β”‚      β”‚ wait for turn  │◄───────│ transcript_seenβ”‚
β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
      β”‚ fires, still          β”‚
      β”‚ no transcript         β”œβ”€ final lands β†’ turn commits β†’ interrupt paused
β”Œβ”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”            β”‚  reply (existing _cancel_speech_pause path)
β”‚ RESUME         β”‚            └─ watchdog (endpointing.max_delay from EOS)
β”‚ (true noise)   β”‚               β†’ INTERRUPT + emit resumed=False. Never resume.
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

What this run's timeline becomes

t HEAD fixed
4.9s pause reply A pause reply A
8.4s resume stale A (timer) nothing β€” 2.0s segment > gate, timer never armed
10.4s interim β†’ re-pause, re-arm interim β†’ transcript_seen, keep paused
12.4s resume stale A again still silent
14.4s final β†’ interrupt A, reply B final β†’ interrupt A, reply B

The caller hears ≀1.4s of A (pre-barge-in), silence, then reply B β€” audio matches the transcript. My three repro tests and the sibling stale-resume test go green; the control and the pinned noise-blip test stay green.

The accepted trade-off: a long noise burst (>1.5s of VAD speech that never produces a transcript) now ends in an interrupt after endpointing.max_delay instead of a resume at 2s. That's the right side of the asymmetry β€” a wrongly-silenced agent gets re-prompted; a wrongly-resumed one talks over the caller and corrupts the transcript/audio record for every downstream consumer (LLM context, sims, insights). I'd reject the two cheaper alternatives: raising false_interruption_timeout globally freezes noise recovery for everyone, and an adaptive per-provider timeout adds machinery without removing the race.

tests/test_transcript_audio_divergence.py reproduces the recorded run with amplitude-tagged TTS audio (live sink + recorder channel): its regression tests failed before this fix, and whitebox tests pin the pause-ownership invariant.

…r turn

The false-interruption resume timer raced the turn machinery: when a caller
barged into a reply and their STT final landed later than
false_interruption_timeout (2s default) after VAD end-of-speech, the timer
fired first and resumed the stale reply out loud over the caller's
already-finished turn. Each interim transcript re-paused and re-armed the
timer, so the outdated tail kept bleeding out in bursts until the final
arrived β€” the transcript/audio divergence observed in staging sim runs
SR_ye3sFKLN2ZYz and SR_4UpFo6cNC7wA (chat commits reply B while the audio in
its window is stale reply A).

Resuming is now subordinate to turn evidence:

- VAD segments longer than the new
  interruption.false_interruption_max_speech_duration (default 1.5s) are
  treated as real speech whose transcript is in flight: the resume timer is
  not armed; an interrupt-watchdog (endpointing.max_delay) bounds the pause
  in case the final never arrives.
- Any non-empty interim/final transcript marks the pause as a real turn
  (transcript_seen); from then on the only exits are turn commit or
  interrupt β€” never resume.
- Pause bookkeeping now has a single exit point (_release_paused_speech)
  that maintains the invariant "output paused iff a _PausedSpeechInfo owns
  it": the timer's abandon paths and _cancel_speech_pause no longer leave
  the shared audio output paused without an owner.

Short VAD-only noise blips (no transcript) keep the existing 2s
pause-then-resume behavior.

tests/test_transcript_audio_divergence.py reproduces the recorded run with
amplitude-tagged TTS audio (live sink + recorder channel): its regression
tests failed before this fix, and whitebox tests pin the pause-ownership
invariant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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