Every iOS voice agent hears itself. The usual fix is muting the mic while the agent talks — which removes self-interruption by removing the one thing that makes a voice agent tolerable.
If they cannot cut the agent off mid-sentence with "no, I need someone today", they stop talking to it. But leave the microphone open and the agent hears its own voice coming back and interrupts itself. Both symptoms, one bug.
You already know what your own echo should sound like. You know exactly what you sent to the speaker, and you can measure how much this device attenuates it. So predict it: recent playback level minus measured echo return loss. If the mic reads roughly that, it is your own voice. Meaningfully louder, and something is in the room you did not play.
The render thread allows no allocation, no locks, no ARC. So: one allocation up front and two atomic integers.
Single producer, single consumer, acquire/release ordering. Verified against two million samples on real threads.
A chirp and a cross-correlation, per device. An earpiece and a Bluetooth car kit are not the same acoustic device.
Several consecutive frames above the predicted echo before declaring barge-in. One frame is a cough.
"My number is four one nine…" is not a finished turn. An unfinished digit run buys 900ms.
Run the VAD first and its noise floor climbs to meet the agent's own echo, going deaf to the caller.
All of this runs in CI with no audio hardware — the acoustics are pure functions over sample buffers:
No echo cancellation — it assumes you are running VoiceProcessingIO and solves the gating problem AEC leaves behind. No ASR, no TTS, no networking. And no neural VAD: if you want Silero, run it on the frames that survive the gate, which is the right place for it.
The README goes deeper, and the tests are the honest documentation. Happy to walk through the trade-offs.