Voice agents and interruptions
A voice agent streams audio both ways over one socket: the microphone up, the answer down. The hard part isn't the streaming, it's the interruption. The user starts talking while the agent is still answering, and four things must happen at once:
- Stop playback. The answer is usually generated faster than it's played, so seconds of it may be queued in the speaker.
- Throw away what's queued, on both sides.
- Cancel generation, so the model stops producing, and paying for, audio nobody will hear.
- Report what was actually heard. The server's transcript has the whole answer it generated. Unless it's cut back to where playback stopped, the model believes the user heard words they never did, and the conversation goes wrong from there.
The library does the transport part. Audio capture and playback stay in your app, and this page shows both.
The connection
Audio frames are binary, while control messages are JSON, so use a serialize and parse that let both through:
Microphone frames
Send each frame from your AudioWorklet as soon as it's captured:
volatile: while disconnected, a frame is dropped instead of queued. Half a second of speech replayed after a reconnect is worse than none.maxBuffered: while connected on a slow link, a frame is dropped when 64 KB are already waiting to go out, instead of building up lag. Speech stays current, and the server's voice activity detection keeps up.
In Chrome, 50 frames sent while disconnected sent nothing, and 20 half-megabyte frames sent at once with maxBuffered at 1 MB sent 3 and dropped 17.
Playing the answer
The server streams the answer in chunks, faster than real time. Schedule them back to back on a Web Audio clock, and remember when the current item started, so you can tell later how much of it has been played:
The interruption
When the user starts talking, whether the server's voice activity detection says so or a push-to-talk button does, stop playback, then tell the server to stop and how much was heard:
priorityputs both messages ahead of anything queued. If the connection happens to be down at that moment, they go out first on the next one, before microphone frames or anything else.heardMscomes from the audio clock, not from what arrived, so it's what the speaker played. The server cuts its copy of the answer back to it: OpenAI's Realtime API calls thisconversation.item.truncatewithaudio_end_ms. Other servers have their own message; the shape above is an example.
In Chrome, against a server streaming audio about two and a half times faster than real time, interrupting 705 ms after the first chunk arrived found 1.7 s of audio received and 1 s of it still scheduled. Playback stopped, no audio arrived after cancel, and the server got cancel, then truncate with heardMs: 688: the 705 ms minus Chrome's 16 ms output latency.
Reconnects
When the connection drops mid-answer, the server usually gives up on that response. Stop playback then too, so the speaker doesn't keep talking while the app reconnects:
For a voice session that must survive a reconnect, such as a long dictation, keep the session id on your side and pass it in the url function, so the server can find the session again.
See also
- Reconnecting and heartbeats:
priorityandmaxBuffered - AI agents: tools, approvals and streamed text answers
- Performance
- Build a voice assistant and text to speech: all of this in a whole app, with a server that relays to OpenAI