Build1 publisher3 min readPublished
On iPhone Safari, a finger over the speaker cut the avatar's own turn five milliseconds later
When the acoustic path changes mid-playback, the browser's echo canceller stops cancelling and the leftover sound reaches the voice detector as speech. The fix was 300 extra milliseconds, applied only during playback.
The Engineer · Build desk

What happened
- A voice avatar that behaved on desktop started reacting to its own speech on iPhone Safari, and also reacted when speaker volume changed as the user handled the phone.
- The change raises the continuous time required to declare speech from 0.2 to 0.5 seconds during playback only, leaving the normal threshold and therefore turn-taking latency untouched.
- Misinterrupts during playback fell from five to zero in the follow-up session, with one self-echo dropped 7 milliseconds after playback ended.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint Teams shipping one anti-echo mechanism are covering half the timeline: a gate that fires after playback ends can never cover this class of failure, because it deliberately passes audio during playback so users can interrupt.
- decision Any voice product with a responsiveness target has to make the choice before it picks a framework: a less sensitive interrupt window, or a transcript-gated one that costs about 1.6 seconds.
- cost The 300 milliseconds is charged only to users who genuinely talk over the bot, and it buys silence for everyone whose hand covers the phone speaker.
- contradiction LiveKit publishes measured interrupt-rejection numbers while Deepgram's documentation hands echo handling to the browser default without addressing mobile, so following the vendor doc leaves this failure undiagnosed.
Five milliseconds is too little time to decide what a sound is. The judgment log recorded a VAD speech-start at 13:39:52.843 and the bot's speech stopping at 13:39:52.848 [2]. All five stops followed the same pattern, and none produced a transcription [3]. The audio was loud enough to trip the detector and never became a word, so no turn was taken and the avatar was interrupted again roughly two seconds later [4].
The browser's echo canceller learns the relationship between the sound sent to the speaker and the sound that returns through the microphone. Put a finger over the speaker and the loudness and resonance change, so the learned relationship no longer holds. The residual leaks into the mic, where the VAD treats it as human speech [5]. According to the writeup, the desktop build was quiet because the browser AEC was cancelling the echo during playback, and not because the countermeasure already in the code was working [6].
That countermeasure was a gate that drops speech immediately after playback ends. It passes audio during playback by design, so that a user who talks over the avatar can still interrupt it [7]. The self-echo arrives after the audio stops; the misinterrupt arrives during playback, and the two need different handling [8].
LiveKit's published numbers describe the more expensive approach: listen to 216 ms of audio on average before honoring an interrupt, which rejects 51% of VAD-based interrupts, with model inference under 30 ms [9]. For that 51% to mean anything on another stack you would need LiveKit's classifier, LiveKit's VAD sensitivity, and a similar mix of real interruptions to false ones. Deepgram's documentation defers echo handling to the browser default and does not address mobile or speakerphones [10]. Every misfire here was short and untranscribed, so duration alone was enough to separate them [11].
The change: during playback, the continuous time required to declare speech goes from 0.2 seconds to 0.5 seconds. The normal threshold stays put, so turn-taking latency for a user who speaks into silence is unchanged [12]. That is 300 ms added, a 2.5x threshold during playback only [1], and it sits about 2.3 times LiveKit's average listen window [2]. The framework's own interrupt strategy would have waited for a transcript, at a cost of about 1.6 seconds, 1.2 seconds of silence detection plus recognition [13]. That is more than five times the 300 ms actually spent [3].
The VAD parameter API reinitializes internal state, so calling it while a user is mid-interruption cuts that user off. The moment you want to call it is exactly when the avatar's speech stops [14]. The threshold frame count is overwritten directly instead [14]. The attribute also does not exist on a freshly created object; it appears when the sampling rate is fixed at pipeline start. A fake object in the tests hid that until a real-object test crashed. Until then the exception was being swallowed and the countermeasure did nothing [15].
Misinterrupts during playback went from five to zero, and one self-echo was dropped 7 ms after playback ended [16]. Three real interrupts, at 5.0, 10.5 and 17.2 seconds, all passed [17]. The counts come from one session's logs.
What to watch
- Whether the 0.5 second playback threshold starts dropping genuine interruptions from users who open a turn with a single short word.
- Whether the framework ships a VAD threshold setter that does not reinitialize internal state, removing the need to overwrite the frame count directly.
- Whether a waveform classifier of LiveKit's kind reproduces anything near a 51% rejection rate on other traffic and other VAD settings.