Voice & realtime agents
Agents you talk to. Learn the voice loop — mic, speech-to-text, agent, text-to-speech, speaker — plus streaming transport, turn detection, barge-in, and the sub-second latency budget that makes it feel like a real conversation.
Learning objectives
- Say what a voice/realtime agent is and how it differs from a chat agent.
- Distinguish the STT→LLM→TTS pipeline from native speech-to-speech.
- Explain streaming/websocket transport, turn detection, and barge-in.
- Budget end-to-end latency and know why ~800 ms round-trip matters.
- Design a production voice-agent architecture: VAD, echo, fallback, cost.
1 · What a voice agent is essential
You've almost certainly been on the wrong end of one already. You call a bank, a robot voice reads you five options, you say "representative," and it replies "I'm sorry, I didn't understand — press 1 for billing." You try to interrupt; it keeps talking. Every pause you take, it either barges in over you or leaves three seconds of dead air. That experience — stilted, deaf to interruption, laggy — is exactly what a voice agent has to not be. Understanding why that old phone tree feels robotic is the fastest way to understand what a good voice agent is actually solving.
Here's the mental model. A text chatbot has it easy: you type, you hit Enter, it thinks, it prints. Turn-taking is trivial because Enter is an unambiguous "I'm done." Strip that away — make the whole thing happen through sound, live, with no keyboard — and every one of those clean boundaries turns into a hard, real-time problem. When did the human stop talking? Can they cut you off? How fast must you answer before it feels like lag? A voice agent is a chat agent wrapped in the machinery that answers those questions continuously, dozens of times a second, while audio is flowing in both directions.
So think of it as two things bolted together. In the middle sits the same kind of reasoning agent you already know — it reads words and decides what to say. Around it sits a real-time audio harness: something that turns your voice into words for the agent, turns the agent's words back into voice for you, and — the genuinely hard part — manages the timing of the back-and-forth so it feels like talking to a person. Get the harness right and a plain LLM feels alive on the phone; get it wrong and even the smartest model feels like that bank robot. The rest of this chapter is that harness, one piece at a time. A common beginner mistake is to assume the model is the whole product; in voice, the transport and timing around the model are usually where the experience is won or lost.
A voice agent is an agent you talk to out loud and that talks back — a phone support line, a hands-free assistant, a drive-thru order taker. A realtime agent is one where the interaction is live and continuous: the user speaks, the agent responds within a fraction of a second, and either side can interrupt the other. That live, low-latency, interruptible quality is what makes it feel like a conversation instead of a walkie-talkie.
Under the hood a voice turn is a loop. Sound goes in through a microphone, becomes text or tokens, an agent decides what to say, that reply becomes sound, and it comes out a speaker. The whole loop has to close fast enough that the human doesn't feel a gap.
This is the whole voice agent in one picture. Sound travels left to right, then loops back. Read each box as one job that hands its result to the next.
- Mic → STT — the microphone captures your voice, and STT (speech-to-text) turns those sound waves into words the agent can read.
- Agent (LLM) — the brain. It reads the words, decides what to say (and may call tools), and produces a text reply.
- TTS → Speaker — TTS (text-to-speech) turns the reply back into sound, and the speaker plays it to you.
- Barge-in is the hidden arrow from Speaker back to Mic: while the agent is talking, the mic keeps listening, so if you jump in it stops and listens to you.
In short: a good voice agent runs this whole loop in well under a second, and lets you interrupt it — that's what makes it feel like talking to a person, not a menu.
The dashed idea not drawn as a box: barge-in. While the speaker is playing the agent's reply, the mic is still listening. If the human starts talking, the agent must stop its own playback and listen — the loop jumps from Speaker back to Mic. Without that, talking over the agent does nothing and it feels robotic.
2 · Two architectures: pipeline vs speech-to-speech essential
Imagine two ways to translate for a friend at a foreign dinner. In the first, your friend speaks, a transcriber writes down every word, a translator reads the transcript and works out a reply, and a third person reads that reply aloud in the new language. Three specialists, each doing one clean job — and you can fire and replace any one of them without touching the others. In the second, you find one bilingual person who simply listens and speaks back directly. Faster, warmer, catches the sarcasm in your friend's voice — but if they mistranslate, you can't peek at their notes, because there aren't any. That is exactly the choice you face building a voice agent, and neither option is "the right one."
The first way is the pipeline: three separate models — speech-to-text, then the LLM, then text-to-speech — wired in series, with plain text passing between them. Its great virtue is that text is inspectable. At every hop you have a transcript you can log, run guardrails on, evaluate, and store for a compliance audit. And because the stages are independent, you can shop for the best transcriber from one vendor and the best voice from another. The cost is twofold: latency adds up hop by hop (each stage waits for the one before it), and the moment audio becomes text you throw away everything that wasn't words — the laughter, the hesitation, the rising pitch of a question.
The second way is speech-to-speech: a single model that takes audio in and emits audio out, with no text checkpoint in the middle. Because nothing has to be serialized into words and back, it's lower-latency and it keeps the prosody — the emotional texture of speech survives. The price is exactly the transparency the pipeline gave away: with no automatic transcript, logging, guardrails, and debugging all get harder, and you can't swap out just the voice or just the reasoning — it's one opaque unit. The mistake to avoid is treating this as a purely technical benchmark; the deciding factor is usually a product question — do you need an auditable record more than you need the last 200 ms and a warmer voice?
There are two ways to build the loop. The classic pipeline chains three separate models: STT (speech-to-text) transcribes audio to words, an LLM reasons over the text, and TTS (text-to-speech) speaks the answer. Each stage is swappable and debuggable, but each adds latency, and tone/emotion is lost when audio becomes plain text.
Speech-to-speech (a native realtime/voice model) takes audio in and emits audio out directly — no text bottleneck in the middle. It's lower latency and keeps prosody (laughter, hesitation, emphasis), but it's harder to inspect, log, and guardrail because you don't automatically get a transcript.
Compare the two rows top-to-bottom: both take audio in on the left and emit audio out on the right, but what happens in the middle is the whole story.
- The top row is the pipeline: three separate boxes (STT, LLM, TTS) wired in series, with the blue
textlabels marking the transcript that passes between each hop. - Those blue
textcheckpoints are the payoff of the pipeline — every one is something you can log, run guardrails on, and audit, and each box is a vendor you can swap independently. - The bottom row is speech-to-speech: a single model spans mic to speaker with no text in between — faster and it keeps prosody, but there's nothing to inspect unless you transcribe separately.
In short: the pipeline trades latency for a transcript you can see and control; speech-to-speech trades that transcript for speed and a warmer voice.
| Pipeline (STT→LLM→TTS) | Speech-to-speech | |
|---|---|---|
| Latency | sum of 3 stages (higher) | single model (lower) |
| Debuggability | text at every hop | audio in/out, opaque |
| Tone / emotion | lost at the text hop | preserved |
| Swap a component | easy (mix vendors) | all-or-nothing |
| Guardrails / logging | on the transcript | needs separate transcription |
3 · Streaming transport: why not request/response intermediate
Think about how a person actually answers you. They don't listen to your entire sentence, retreat into silence to compose a complete paragraph, and then deliver it in one breath. They start talking almost as soon as they've caught your drift — "Sure, so what you'll want to do is…" — assembling the rest of the thought while their mouth is already moving. That overlap is the secret to why human conversation feels instant even though nobody is actually faster than a computer. A voice agent that answers like a normal web API — wait for the whole question, compute the whole answer, then play it — will always feel like a walkie-talkie, because it refuses to start speaking until it's completely finished thinking.
The technical reason ordinary APIs can't do this is their shape: request → wait → full response is a single round trip that only completes when the entire answer is ready. Nothing comes back early. For voice that's fatal on both ends of the loop — you can't send audio until the user stops, and you can't play audio until the reply is done — so every stage's delay stacks into one long silence. The fix is to abandon the one-shot round trip for a persistent, two-way connection that stays open for the whole call: a WebSocket or WebRTC channel. Over it, audio flows as a continuous stream of tiny chunks (frames of, say, 20 ms) in both directions at once, rather than one big upload followed by one big download.
Streaming buys you two overlaps that together hide almost all the latency. First, you send the user's speech as they talk, so transcription can begin before they've finished their sentence. Second — and this is the one people underestimate — you begin playing the reply the instant the first audio frame is ready, long before the last word has been generated. The user hears "Sure, I can help with that…" while the model is still working out the details of "that." The runnable model below makes this concrete by printing a timeline: watch how the time-to-first-audio stays tiny even as the total reply grows, because what the listener perceives as responsiveness is when the first sound arrives, not when the last one does. The classic beginner error is optimizing total response time; in streaming voice, first-audio is the number that matters.
A normal API call is request → wait → full response. That's fatal for voice: you'd record the whole sentence, upload it, wait for the whole reply, then play it — seconds of dead air. Realtime voice uses a persistent bidirectional connection (a WebSocket or WebRTC) so audio flows continuously in both directions. You send mic audio in small chunks (e.g. every 20 ms) as you capture it, and you start playing the reply as the first audio frames arrive — before the agent has finished its whole sentence. That overlap is what hides latency.
The runnable model below streams a 'reply' out in chunks and shows that the user hears the first audio long before the last chunk is produced — the core reason streaming feels instant. It is pure stdlib and prints a timeline.
Read both rows left-to-right as time after the user stops talking; each row is the same reply, delivered two different ways.
- The top row (request/response) is one long amber bar of silence — nothing plays until the whole reply is computed — then a single red
playblock right at the end. - The bottom row (streaming) breaks the reply into small green
playchunks that start almost immediately and keep coming; the model is still generating later chunks while the early ones are already sounding. - Compare the two first-sound labels: "late" on top versus "now" on the bottom — that early green block is the entire perceived-speed win, even though both rows finish at about the same time.
In short: streaming doesn't make the reply shorter — it makes the first word arrive early, and first-audio is the number the user's ear actually judges.
streaming.pydef stream_reply(chunks, per_chunk_ms):
"""Yield (elapsed_ms, chunk) as if streaming audio out frame by frame."""
elapsed = 0
for chunk in chunks:
elapsed += per_chunk_ms
yield elapsed, chunk
reply = ["Sure,", " I can", " help", " with", " that."]
first_audio_at = None
last_audio_at = 0
for elapsed, chunk in stream_reply(reply, per_chunk_ms=120):
if first_audio_at is None:
first_audio_at = elapsed
last_audio_at = elapsed
# In a NON-streaming design the user hears nothing until the whole reply is done.
print("time to FIRST audio (streaming): ", first_audio_at, "ms")
print("time to LAST audio (whole reply):", last_audio_at, "ms")
print("non-streaming would wait: ", last_audio_at, "ms before ANY sound")
time to FIRST audio (streaming): 120 ms
time to LAST audio (whole reply): 600 ms
non-streaming would wait: 600 ms before ANY sound
This shows why voice agents stream their reply out piece by piece instead of waiting for the whole answer. The key number is when you hear the first sound.
stream_replyhands back one small chunk at a time, addingper_chunk_ms(120 ms) of time for each — like audio arriving frame by frame.- The loop records
first_audio_at(the moment the very first chunk lands) and keeps updatinglast_audio_atuntil the reply is finished. - Because the reply has 5 chunks of 120 ms, the first sound arrives at 120 ms but the whole thing finishes at 600 ms.
- The last print makes the point: a non-streaming design would play nothing at all until 600 ms — five times longer before you hear anything.
What the output means: You hear the first word at 120 ms while streaming, versus waiting 600 ms for any sound at all without streaming. That gap is why real voice systems stream STT, the LLM tokens, and TTS all at once.
Try this: Add more chunks to reply. Notice first_audio_at stays 120 ms no matter how long the reply gets — streaming hides the length.
4 · Turn detection: whose turn is it? intermediate
Here's a problem so basic that it's easy to miss it's a problem at all. When does the agent get to talk? In a chat window the answer is trivial — you press Enter, and Enter means "I'm done, your move." Voice hands you no Enter key. All the agent has is a stream of sound, and somewhere in that stream the human stops and expects a reply. Guess too early and you chop them off mid-sentence like an over-eager interviewer; guess too late and you leave an awkward, dead silence that makes the whole thing feel broken. Deciding whose turn it is, from sound alone, is one of the quiet make-or-break problems of voice.
The tool for this is called turn detection (or endpointing), and the workhorse underneath it is VAD — voice activity detection, which simply classifies each little slice of audio as "speech" or "silence." Once you can tell speech from silence, a first, surprisingly effective rule falls out: the turn is over when silence has lasted long enough. Not the instant the person stops making noise — because people pause constantly to breathe, to think, to say "ummm" — but once the quiet has held past some threshold. That threshold has a name worth knowing: the hangover (or silence timeout), the grace period you wait through before declaring the turn finished.
The cleanest way to think about the logic is a tiny state machine — a system that sits in one named state and hops to another when something happens. Here there are three states: LISTENING (the human is talking), MAYBE_DONE (they've gone quiet, but not for long enough to be sure), and AGENT_TURN (the silence outlasted the hangover — respond now). Any fresh burst of speech snaps you back to LISTENING and forgets the pause, which is exactly what stops a mid-sentence breath from being mistaken for the end. The lab below runs this over a fake frame stream so you can watch a short pause get correctly ignored while a long one triggers a turn. Note the trap this design avoids and re-introduces at will: a fixed silence threshold is a blunt instrument — the "right" value depends on the language, the speaker, and how noisy the line is, which is why real systems eventually replace it with a smarter, model-based endpointer.
In text chat, pressing Enter marks end-of-turn. In voice there is no Enter — the agent must infer when the human has finished speaking so it can respond, and not cut them off mid-sentence. This is turn detection (a.k.a. endpointing), usually built on VAD (voice activity detection): detect speech vs silence, and when silence lasts longer than a threshold, treat the turn as over.
Model this as a tiny state machine. States: LISTENING (user talking), MAYBE_DONE (a silence gap started), and AGENT_TURN (silence held long enough — respond). This runs offline over a fake frame stream.
Follow the arrows left-to-right; each box is a state the detector sits in, and each arrow is the event that moves it.
- LISTENING (blue) is the normal state while the human is speaking. The first slice of silence moves it right to MAYBE_DONE (amber) — quiet has started, but it's too soon to be sure.
- The amber arrow is the decisive one: only when silence has held for at least the hangover does the machine reach AGENT_TURN (green) and fire the reply.
- The curved arrow underneath is the safeguard: any fresh speech snaps the machine straight back to LISTENING and forgets the pause — which is exactly why a breath or an "ummm" mid-sentence doesn't trigger a premature turn.
In short: silence walks you toward responding, the hangover decides when, and any speech resets the whole thing — that loop-back is what stops the agent cutting people off.
turns.pySILENCE_HANGOVER_MS = 500 # how long silence must hold before the turn ends
def detect_turns(frames, frame_ms=100):
"""frames: list of 'speech'/'silence'. Emit an event when a turn ends."""
state = "LISTENING"
silence_ms = 0
events = []
for i, f in enumerate(frames):
t = i * frame_ms
if f == "speech":
silence_ms = 0
state = "LISTENING"
else: # silence
silence_ms += frame_ms
if state == "LISTENING":
state = "MAYBE_DONE"
if state == "MAYBE_DONE" and silence_ms >= SILENCE_HANGOVER_MS:
state = "AGENT_TURN"
events.append(("turn_end", t))
state = "LISTENING" # reset for the next turn
silence_ms = 0
return events
# user says something, pauses briefly (not enough), then a real long pause
stream = (["speech"] * 6 + ["silence"] * 3 # 300 ms pause: too short
+ ["speech"] * 4 + ["silence"] * 6) # 600 ms pause: turn ends
print(detect_turns(stream))
[('turn_end', 1700)]
In voice there is no Enter key, so the agent must guess when you've finished talking. This little state machine watches for a long-enough silence and calls that the end of your turn.
SILENCE_HANGOVER_MS = 500is the rule: silence must last at least half a second before we decide the turn is over. Short pauses (thinking) don't count.- It walks a list of 100 ms
framesthat are eitherspeechorsilence. Any speech resets the silence counter back to zero. - When silence starts, the state moves to
MAYBE_DONE; once the silence has piled up to 500 ms it flips toAGENT_TURNand records aturn_endevent. - The test stream has a short 300 ms pause (ignored) and then a real 600 ms pause, so only one turn-end fires.
What the output means: [('turn_end', 1700)] — one turn ended, at 1700 ms. The early 300 ms pause was correctly ignored because it was shorter than the 500 ms threshold.
Try this: Lower SILENCE_HANGOVER_MS to 200 and re-run — now the short pause also ends a turn, so you get two events. That's the snappy-vs-polite tradeoff.
5 · Barge-in: letting the human interrupt advanced
Picture the moment that most annoys everyone about automated phone systems. The robot launches into a long-winded menu — "For account balances, say balance; for recent transactions, say…" — and you already know you need a human. You say "agent." Nothing. It keeps reading. You have to sit through the whole speech, blood pressure rising, before it will listen to you. Now picture a real assistant: the second you start speaking, they stop mid-word and turn their attention to you. That instinct to yield the floor the moment you're interrupted is called barge-in, and its presence or absence is the single sharpest line between a voice agent that feels human and an IVR tree that feels like a hostage situation.
Mechanically, a barge-in is a three-step scramble that has to happen almost instantly. The moment you detect the user talking over the agent, you must (1) stop TTS playback right now, (2) throw away the rest of the queued reply — all the audio the agent was about to say is now irrelevant — and (3) switch back to listening so the interruption is captured. Miss any one of these and it breaks: forget to flush the queue and the agent finishes its old sentence a beat after you've started your new one; forget to switch to listening and you drop the very words the user interrupted with.
But there's a genuinely nasty catch hiding here, and it's the reason barge-in is an "advanced" topic rather than an obvious one: echo. The microphone doesn't only hear the human — it also hears the agent's own voice coming out of the speaker. If you naively treat "any sound on the mic while speaking" as a barge-in, the agent will constantly interrupt itself, stopping dead every time it hears its own words. So a real barge-in system needs echo cancellation (AEC) to subtract the agent's known output from the mic input, so only genuine user speech triggers the interrupt. The lab below models exactly this with a session that ignores its own echo but stops for real speech. Keep in mind the deeper reason this is subtle, previewed in the callout after: the reply you're trying to kill may be buffered in the model, in the network, and in the audio device all at once — a clean interrupt has to flush every one of those, fast.
Barge-in is the ability to interrupt the agent while it is speaking — the single biggest thing that separates a natural voice agent from an IVR phone tree. When the user starts talking over the agent's playback, you must (1) immediately stop TTS playback, (2) discard the rest of the queued reply, and (3) switch to listening. The hard part is echo: the mic hears the agent's own voice from the speaker, so you need echo cancellation or you'll 'barge in' on yourself.
Follow the sound left-to-right from the mic, through the filter, to the three actions on the right.
- The Mic hears everything — including the agent's own voice leaking from the speaker — and hands it to Echo cancel.
- The loop-back arrow underneath is the trap being avoided: the agent's own echo is subtracted and dropped, so it never counts as an interruption (otherwise the agent would barge in on itself).
- Only real speech passes through, and it triggers the three stacked boxes on the right — stop playback, flush the queued reply, switch to listening. The red boxes are urgent (must happen instantly); the green box is the state you land in.
In short: echo cancellation decides whether to interrupt; the three steps are how — and all three must fire together or the agent talks over the person it's trying to hear.
bargein.pyclass VoiceSession:
def __init__(self):
self.state = "IDLE"
self.play_queue = []
self.log = []
def agent_speaks(self, chunks):
self.state = "SPEAKING"
self.play_queue = list(chunks)
self.log.append(f"agent speaking, {len(self.play_queue)} chunks queued")
def on_user_speech(self, is_agents_own_echo):
# Echo cancellation: ignore the agent's own voice coming back in.
if is_agents_own_echo:
self.log.append("ignored echo of own voice")
return
if self.state == "SPEAKING":
dropped = len(self.play_queue)
self.play_queue.clear() # stop + flush playback
self.state = "LISTENING"
self.log.append(f"BARGE-IN: stopped playback, dropped {dropped} chunks")
s = VoiceSession()
s.agent_speaks(["chunk1", "chunk2", "chunk3", "chunk4"])
s.on_user_speech(is_agents_own_echo=True) # speaker bleed -> ignored
s.on_user_speech(is_agents_own_echo=False) # real interruption -> barge-in
print("final state:", s.state)
for line in s.log:
print("-", line)
final state: LISTENING
- agent speaking, 4 chunks queued
- ignored echo of own voice
- BARGE-IN: stopped playback, dropped 4 chunks
Barge-in means letting the human interrupt the agent mid-sentence. This class models a call session that stops talking the moment the user really speaks — but ignores its own echo.
agent_speaksqueues up the reply chunks and sets the state toSPEAKING— the agent is now playing audio.on_user_speechis called whenever the mic hears something. If it's the agent's own voice bleeding back from the speaker (is_agents_own_echo), it's ignored — otherwise the agent would interrupt itself.- If it's real user speech while the agent is
SPEAKING, that's a barge-in: it clears the play queue (stops playback), switches toLISTENING, and logs it. - The demo fires an echo first (ignored) then a real interruption (barge-in), so only the second one stops the agent.
What the output means: The final state is LISTENING and the log shows the echo was ignored but the real interruption dropped all 4 queued chunks — exactly what should happen when someone talks over the agent.
Try this: Call s.on_user_speech(is_agents_own_echo=False) a second time. Nothing extra happens — the agent is already listening, so there's no playback left to stop.
6 · The latency budget: why ~800 ms matters advanced
You have a lag detector built into your brain, and it is brutally sensitive. You've felt it on a bad phone call: the tiny delay where you both start talking, both stop, both apologize, both start again. Face-to-face, the gaps between conversational turns are astonishingly short — on the order of 200 milliseconds, faster than you can consciously plan a sentence. We're so tuned to that rhythm that when a reply is even a little slow, we don't think "the computer is processing" — we assume the other person didn't hear us, and we start to repeat ourselves or talk over them. That reflex is why latency isn't a nice-to-have metric for voice; it's the thing that decides whether the conversation holds together at all.
The rough rule of thumb the industry works to is a ceiling around 800 ms from the moment the user stops speaking to the moment they hear the first word back. It's not a magic constant — it varies by context and no one number is universal — but below it the exchange feels conversational, and above it the agent starts to feel sluggish and people begin stepping on it. The crucial reframing is that this 800 ms is a budget, not a single measurement: it's a fixed pot of time you have to divide across every stage in the loop — capturing and uploading the audio, finalizing the transcript, the LLM producing its first token, TTS producing its first audio, and the trip back down plus playback. Every stage spends from the same pot.
Two consequences follow, and both are easy to get wrong. First, because the stages add up, one fat stage can blow the whole budget no matter how lean the others are — so the engineering move is always to find the single biggest contributor and attack that, rather than shaving milliseconds evenly. Second, and this is the one beginners miss: the number that matters is time-to-first-token, not total generation time. Because you stream and start speaking on the first word (§3), a model that takes 350 ms to begin and then rambles for two seconds is fine for voice, while one that thinks silently for a full second before emitting anything is not — even if its total time is shorter. The calculator below sums a per-stage budget and names the worst offender; play with the numbers to feel where a real system's time actually goes.
Humans notice conversational lag. In natural conversation, gaps between turns are roughly 200 ms; once the agent's response gap climbs past about 800 ms it starts to feel sluggish and people begin to talk over it. So end-to-end latency — from the user finishing their sentence to hearing the first word back — is a hard budget you must divide across every stage: capture + network up, STT finalization, LLM time-to-first-token, TTS time-to-first-audio, network down + playback.
The tool below adds up a per-stage budget and tells you whether you fit under a target, and how much headroom is left. Change the numbers to see where the time goes.
Read the bar left-to-right as the clock running from the instant the user stops talking to the instant they hear the first word; the dashed line on the right is the 800 ms ceiling.
- Each coloured segment is one stage of the loop — capture, STT, LLM first-token, TTS, network+play — and its width is its time. They stack because the stages run one after another.
- The wide red segment is the LLM's first token (350 ms). It dwarfs the others, so it's the single stage to attack if you need to save time — shaving the small blue slices barely moves the bar.
- The small amber gap between the end of the bar and the dashed line is the 40 ms of headroom: the bar ends before the ceiling, so you fit. Push any segment wider than that gap and the bar crosses the line — over budget.
In short: the 800 ms target is a fixed-width lane; keep the stacked stages inside it, and spend your optimisation on the widest slice.
latency.pydef latency_budget(stages, target_ms=800):
"""stages: dict of stage -> milliseconds. Report total vs target."""
total = sum(stages.values())
headroom = target_ms - total
worst = max(stages, key=stages.get)
return {
"total_ms": total,
"target_ms": target_ms,
"within_budget": total <= target_ms,
"headroom_ms": headroom,
"biggest_stage": worst,
}
stages = {
"capture+upload": 60,
"stt_finalize": 150,
"llm_first_token": 350,
"tts_first_audio": 120,
"network_down+play": 80,
}
result = latency_budget(stages, target_ms=800)
for k, v in result.items():
print(f"{k}: {v}")
total_ms: 760
target_ms: 800
within_budget: True
headroom_ms: 40
biggest_stage: llm_first_token
Voice feels laggy once the reply gap passes about 800 ms, so every stage of the loop gets a slice of a fixed time budget. This adds up the slices and tells you if you fit.
stagesis a dictionary of each step (capture, STT, the LLM's first token, TTS, network+play) and how many milliseconds it takes.latency_budgetsums them intototal_ms, compares against thetarget_ms(800), and computes the leftoverheadroom_ms.max(stages, key=stages.get)finds the single biggest stage — the one to attack first if you're over budget.- Here the total is 760 ms, so
within_budgetisTruewith 40 ms to spare, and the biggest offender is the LLM's first token.
What the output means: You're under the 800 ms target by 40 ms, and the slowest stage is llm_first_token at 350 ms — that's where you'd optimise first (a faster model, prompt caching, streaming).
Try this: Bump llm_first_token to 500 and re-run — the total exceeds 800, within_budget flips to False, and headroom goes negative.
7 · Production concerns: VAD, echo, fallback, cost professional
Here is the story that repeats on every voice project. The demo is magical. In a quiet room, with a good headset, talking to one engineer, the agent listens, answers, gets interrupted gracefully — everyone's impressed. Then it ships to a real phone line, and it falls apart: it triggers on the hum of an air conditioner, it interrupts itself on speaker echo, it goes silent for four seconds when a backend hiccups, and the finance team asks why the bill is five figures. None of these are model problems. They're the gap between "works at my desk" and "works for a thousand strangers on bad connections," and closing that gap is what this section is about.
The failures cluster into a short, memorable checklist, and it helps to see why each one only shows up in production. VAD and endpointing that were tuned in a silent room false-trigger the moment there's background noise, so the threshold has to be re-tuned for the real noise floor and often per language. Echo cancellation is optional in a demo you drive with a headset (no speaker bleed) but mandatory the instant a real speakerphone lets the mic hear the agent — without it, barge-in (§5) fires on the agent's own voice. A fallback path never matters until the day a stage times out, and then it's the difference between a graceful "Sorry, I didn't catch that" and dead air — the single worst thing a voice agent can do, because silence reads as "the line dropped." Interruptibility has to hold everywhere, including during a slow tool call or database lookup, or barge-in quietly dies exactly when a long action makes the user most likely to want to interrupt.
The last item, cost, deserves a mental reset because voice breaks the billing intuition you built on chat. A text agent costs per token, so a terse reply is cheap. Realtime voice is billed by the minute of audio and holds an open connection for the entire call — which means a long, rambling call is expensive even if the agent barely speaks, and your capacity ceiling is set by concurrent sessions, not requests per second. Five hundred people on the line at once is five hundred live model sessions and sockets held open simultaneously; plan for peak concurrency, not average volume. The estimator below turns call-volume assumptions into a monthly bill so you can feel how directly talk-time drives the number — and why "get the caller to their answer faster" is a cost lever, not just a UX one.
A demo that works at your desk breaks on a real phone line. The production checklist:
Read it as a two-column table: each red box on the left is a failure that only shows up in production, and the green box across the arrow is what fixes it.
- The left red column is the list of things that were invisible in the demo — background noise, speaker echo, a stalled stage, an un-interruptible long action, and a socket held open per call.
- Each arrow pairs a failure with its green fix: tuned VAD, echo cancellation, a fallback holding phrase, interruptibility everywhere, and concurrency planning.
- The payoff is the bottom row — open connections: voice capacity is measured in concurrent sessions, not requests, so this fix is about capacity and cost, not audio quality.
In short: the demo tests the model; production tests the five red rows — harden each one and the agent survives real callers.
Production voice hardening
- VAD / endpointing tuned per language and noise floor — noisy environments false-trigger on background sound.
- Echo cancellation (AEC) so the mic doesn't hear the speaker; without it, barge-in fires on the agent's own voice.
- Fallback path: if speech-to-speech or a stage times out, degrade gracefully (retry, switch to the text pipeline, or 'Sorry, I didn't catch that').
- Interruptibility everywhere: any long agent action (a tool call, a lookup) must remain interruptible, or barge-in dies mid-task.
- Cost & concurrency: realtime audio is billed by the second and holds an open connection per call — capacity planning is about concurrent sessions, not requests.
Cost for realtime voice is dominated by connection-time, not per-request tokens. Model the monthly bill from concurrent-call assumptions:
cost.pydef voice_cost(calls_per_day, avg_call_min, cost_per_min, days=30):
"""Realtime voice is billed by audio minute (in + out)."""
minutes = calls_per_day * avg_call_min * days
monthly = minutes * cost_per_min
return {
"billed_minutes_per_month": minutes,
"monthly_cost_usd": round(monthly, 2),
"cost_per_call_usd": round(avg_call_min * cost_per_min, 4),
}
print(voice_cost(calls_per_day=2000, avg_call_min=3.0, cost_per_min=0.08))
{'billed_minutes_per_month': 180000.0, 'monthly_cost_usd': 14400.0, 'cost_per_call_usd': 0.24}
Realtime voice isn't billed like a chat message — it's billed by the minute of audio, for as long as the call is connected. This estimates the monthly bill from call volume.
voice_costmultiplies calls per day × average call length × 30 days to get totalminutesof audio per month.- Multiplying minutes by
cost_per_mingives the monthly dollar cost; a single call's cost is just its length × the per-minute rate. - With 2000 calls/day at 3 minutes each, that's 180,000 minutes a month.
- This is why voice cost scales with talk time and concurrency, not with the number of requests — a long call is expensive even if the agent says little.
What the output means: 180,000 billed minutes cost about $14,400/month, or $0.24 per 3-minute call. Longer calls or more callers push this up linearly.
Try this: Halve avg_call_min to 1.5 — the bill halves too. Getting callers to their answer faster is a direct cost saving in voice.
8 · Tech-lead — owning a voice-agent system tech-lead
An engineer can make the demo talk; a lead has to answer for it at 2 a.m. when the phone line goes dead. The shift in altitude is the whole point of this section. So far every piece — streaming, turns, barge-in, latency, cost — has been a component you can build and test in isolation. Owning the system means turning those components into commitments you're accountable for in production, where "it usually feels fast" isn't good enough and "it works on my machine" is a liability, not a status update.
Concretely, that means four things a lead signs their name to. A latency SLO — not a vibe but a measured target, e.g. p95 first-audio under 800 ms, tracked in production so you know when you're drifting before your users tell you. An architecture decision — pipeline versus speech-to-speech (§2) — justified out loud by that SLO and by your observability needs, not by which one demoed better. A transcription path for logging and guardrails even when you chose speech-to-speech, because "we can't audit what the agent said" is an answer that ends careers in a regulated industry. And a fallback and concurrency plan so a flaky network or a traffic spike degrades into a holding phrase and a queue, not a wall of dropped calls.
Notice what's not on that list: the specific SDK. The sketch below shows real vendor wiring, and it's worth reading precisely so you can see that it contains no new ideas — stream, turn_end, audio_out, cancel_response are the streaming, turn-detection, and barge-in concepts you already modeled offline, now delivered as events over a socket. That's the lead's most useful realization: the SDK is plumbing, and plumbing gets swapped. The reasoning you can defend — why this latency budget, why this architecture, why this fallback — is the durable part, and it's what the offline labs in this chapter were quietly building all along.
A lead owns the end-to-end contract: a latency SLO (e.g. p95 first-audio < 800 ms) measured in production, an architecture decision (pipeline vs speech-to-speech) justified by that SLO and by observability needs, a transcription path for logging/guardrails even in speech-to-speech, and a fallback + concurrency plan so a bad network or a traffic spike degrades gracefully instead of dropping calls.
Read it top-to-bottom: the durable commitments sit on top, and they all rest on the swappable layer below.
- The four top boxes are what a lead signs their name to — a measured latency SLO, the pipeline-vs-speech-to-speech decision, a transcription path for logging and audit, and a fallback + concurrency plan.
- The arrows all point down to one SDK box — the streaming,
turn_end,audio_out, andcancelevents that deliver those decisions in code. - The payoff is the label on that bottom box: it's plumbing. Swap the vendor and the four commitments above are unchanged — the reasoning is the durable part, the SDK is not.
In short: lead by owning the four commitments up top; treat the SDK below as replaceable wiring that happens to implement them.
The real SDK usage looks like the sketch below. It is illustrative only — it needs the vendor SDK, a network connection, and credentials, so it is NOT part of the runnable labs. Every concept in it (streaming, turn events, barge-in) you already modeled offline above.
sdk_sketch.py# ILLUSTRATIVE — requires a realtime voice SDK, credentials, and a live socket.
# Do NOT expect this to run with `python file.py`; the labs above model the ideas offline.
import realtime_voice_sdk as rv # placeholder for the actual vendor SDK
async def run_call(mic_stream, speaker):
session = await rv.connect(model="voice-realtime", voice="warm")
async for event in session.stream(mic_stream): # bidirectional websocket
if event.type == "partial_transcript":
pass # show live captions
elif event.type == "turn_end": # VAD says user finished
await session.respond()
elif event.type == "audio_out":
speaker.play(event.audio) # stream out as it arrives
elif event.type == "user_started_speaking":
await session.cancel_response() # BARGE-IN: stop + flush
streaming.py, turns.py, and bargein.py. A real SDK gives you these as events over a socket — the reasoning is the same.Exercise FA1.1 — Fit the latency budget
Context: You have a concrete p95 target and a pipeline that is currently over it — the everyday job of a voice-agent engineer is finding the fat stage and trimming it without wrecking quality.
Your task: Starting from latency.py with the given stage numbers (total 760 ms, over a 700 ms p95 first-audio target), identify the biggest stage, cut it, and re-run until within_budget is True.
Requirements:
- Locate the single largest contributor to the total
- Apply a realistic cut (e.g. faster LLM first-token or streaming TTS)
- Re-run until
within_budgetflips toTrue - Write down which stage you cut and the tradeoff you accepted
💡 Hint: There's usually one dominant stage; halving it is more effective than shaving milliseconds off three small ones.
Exercise FA1.2 — Design the turn + barge-in policy
Context: Turn-taking and barge-in policy is where a support line either feels responsive or constantly talks over people. This ties the VAD, barge-in, and fallback labs into one design decision.
Your task: For a customer-support line, pick and justify a silence hangover for turns.py, then use bargein.py to show what happens when a caller interrupts mid-answer.
Requirements:
- Choose a hangover length and justify it for support-call speech patterns
- Demonstrate a mid-answer interruption stopping the agent
- State how you'd stop the agent barging in on its own audio echo
- State the fallback that fires if STT times out
💡 Hint: Echo-cancellation or gating the mic while the agent speaks is what keeps the agent from interrupting itself.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: A voice agent that answers even a beat too slowly feels broken; conversational turn-taking lives inside roughly an 800 ms budget. The first skill is simply adding up where that budget goes.
Your task: Given per-stage latencies for an ASR → LLM → TTS pipeline, compute the total response time and check it against the ~800 ms conversational budget.
Requirements:
- Sum the ASR, LLM, and TTS stage latencies
- Compare the total to the ~800 ms target and report over/under
- Return both the total and a within-budget boolean
- Make the stage numbers easy to change and re-check
💡 Hint: This rung is pure arithmetic over a dict of stage times — the value is seeing which single stage dominates the budget.
Show solution
Pure Python — the arithmetic behind the latency budget:
stages = {"asr": 150, "llm_first_token": 400, "tts_first_audio": 120} # ms
total = sum(stages.values())
BUDGET = 800
print("total:", total, "ms") # 670 ms
print("within budget" if total <= BUDGET else "TOO SLOW",
f"({BUDGET - total} ms headroom)") # within budget (130 ms headroom)
Humans perceive pauses over ~800 ms as awkward. The dominant term is time-to-first-token from the LLM, which is why streaming matters.
Context: The core architectural choice for a voice agent is pipeline (ASR+LLM+TTS) versus a single speech-to-speech model — and it's a genuine tradeoff, not a clear win.
Your task: Contrast the two architectures on a scorecard across latency, control/observability, emotion/prosody, and cost, then print a recommendation for a regulated support line that must log every transcript.
Requirements:
- Score both architectures on all four dimensions
- Note that a pipeline exposes an inspectable transcript at each stage
- Note that speech-to-speech typically wins on latency and prosody
- Recommend the pipeline for the regulated line and justify it by the transcript-logging requirement
💡 Hint: The regulated constraint (a loggable transcript per turn) is what tips the decision, even where speech-to-speech would otherwise feel more natural.
Show solution
archs = {
"pipeline (ASR+LLM+TTS)": {
"latency": "higher (3 hops)", "transcripts": "native (text between hops)",
"prosody": "lost/limited", "control": "high -- swap any stage"},
"speech-to-speech": {
"latency": "lower (1 model)", "transcripts": "must be derived",
"prosody": "preserved", "control": "low -- opaque"},
}
for name, a in archs.items():
print(name); [print(f" {k}: {v}") for k, v in a.items()]
# regulated line needs auditable transcripts + stage control:
print("\nRecommendation: PIPELINE -- transcripts are first-class and each "
"stage is swappable/loggable, which a compliance audit requires.")
Speech-to-speech wins on latency and natural prosody; the pipeline wins when you need transcripts, guardrails between stages, and per-stage control.
Context: Before an agent can take turns it has to know when someone is speaking. A simple energy-based voice-activity detector is the first building block of turn detection.
Your task: Implement an energy-based VAD that marks a frame as speech when its RMS energy exceeds a threshold, and only ends the turn after N consecutive silent frames (a hangover).
Requirements:
- Compute per-frame RMS energy and compare against a threshold
- A frame above threshold counts as speech
- End the turn only after N consecutive silent frames (hangover), not on the first silent frame
- Expose the threshold and hangover length as parameters
💡 Hint: The hangover counter is what stops a brief pause mid-sentence from being mistaken for end-of-turn.
Show solution
Offline model on synthetic frames (no mic needed):
def rms(frame):
return (sum(x * x for x in frame) / len(frame)) ** 0.5
def detect_turns(frames, threshold=0.2, hangover=3):
speaking = False; silence = 0; turns = []; start = None
for i, f in enumerate(frames):
loud = rms(f) > threshold
if loud:
silence = 0
if not speaking: speaking = True; start = i
elif speaking:
silence += 1
if silence >= hangover: # end turn after enough silence
turns.append((start, i - hangover)); speaking = False
if speaking: turns.append((start, len(frames) - 1))
return turns
loud, quiet = [0.5, 0.5], [0.0, 0.0]
frames = [quiet, loud, loud, quiet, quiet, quiet, loud]
print(detect_turns(frames)) # [(1, 2), (6, 6)]
The hangover prevents a brief pause mid-sentence from prematurely ending the user's turn — the core tuning knob of turn detection.
Context: Barge-in — letting the caller interrupt the agent mid-sentence — is what makes a voice agent feel human, and it's cleanest to model as a small state machine.
Your task: Model barge-in as a state machine: while the agent is speaking, if the user starts talking the agent stops its TTS and starts listening. Drive it with an event sequence.
Requirements:
- Define states such as listening, speaking, and interrupted
- A user-speech event while speaking transitions to stop-TTS-and-listen
- Transitions are driven by an event stream, not hard-coded
- Reaching the interrupted path halts synthesis immediately
💡 Hint: Keep the machine tiny — a state variable plus a transition function over events is enough; the interesting transition is user-speech arriving during speaking.
Show solution
class VoiceAgent:
def __init__(self): self.state = "idle"
def on_event(self, event):
if event == "user_speaks":
if self.state == "speaking":
self.state = "listening"; return "BARGE-IN: stop TTS, listen"
self.state = "listening"; return "listening"
if event == "user_stops":
self.state = "thinking"; return "run LLM"
if event == "llm_done":
self.state = "speaking"; return "start TTS"
if event == "tts_done":
self.state = "idle"; return "idle"
return self.state
a = VoiceAgent()
for e in ["user_speaks", "user_stops", "llm_done", "user_speaks", "tts_done"]:
print(e, "->", a.on_event(e))
# the 2nd user_speaks arrives while speaking -> BARGE-IN
Barge-in is why full-duplex streaming is required: the agent must keep listening while it speaks so it can abort playback the instant the user interrupts.
Context: Dead air is the worst failure mode for a voice agent. Production systems degrade gracefully by falling back to a holding phrase rather than silence when a stage stalls.
Your task: Wrap the LLM call so a timeout or error falls back to a canned holding phrase instead of dead air, and count fallbacks so the failure rate is monitorable.
Requirements:
- Catch timeouts and errors from the LLM stage
- On failure, return a fixed holding/acknowledgement phrase instead of nothing
- Increment a fallback counter for monitoring
- Normal responses pass through unchanged
💡 Hint: Treat the fallback as a wrapper around the real call; the counter is what lets ops alert when fallbacks spike.
Show solution
class VoiceService:
def __init__(self): self.fallbacks = 0
def respond(self, transcript, llm):
try:
reply = llm(transcript, timeout=1.5) # must beat the latency budget
if not reply:
raise ValueError("empty reply")
return reply
except Exception as e:
self.fallbacks += 1
return "Sorry, could you repeat that?" # holding phrase, never silence
def flaky_llm(text, timeout):
if "trigger" in text:
raise TimeoutError("model slow")
return f"You said: {text}"
svc = VoiceService()
print(svc.respond("hello", flaky_llm)) # You said: hello
print(svc.respond("trigger", flaky_llm)) # holding phrase
print("fallbacks:", svc.fallbacks) # 1
Dead air is the worst failure in voice; a fast holding phrase buys time and the counter feeds an alert if fallbacks spike.
Context: The "think" stage is where perceived latency is won or lost. Streaming tokens from the model straight into TTS keeps time-to-first-audio low instead of waiting for the whole reply. This uses the Anthropic Python SDK.
Your task: Sketch a real streaming LLM turn for the think stage with the Anthropic SDK so time-to-first-token stays low, and explain why you stream tokens into TTS rather than waiting for the full reply.
Requirements:
- Use the Anthropic SDK's streaming interface for the model call
- Begin handing text to TTS as tokens arrive, not after completion
- Explain that streaming cuts perceived latency by overlapping think and speak
- Keep the sketch focused on the streaming boundary, not a full app
💡 Hint: The win is overlap: the first spoken words can start while the model is still generating the rest of the sentence.
Show solution
Needs the SDK + network (pip install anthropic, ANTHROPIC_API_KEY set). Streaming shape is the documented messages.stream:
from anthropic import Anthropic
client = Anthropic()
def stream_reply(user_text, on_token):
with client.messages.stream(
model="claude-sonnet-4-5",
max_tokens=200,
messages=[{"role": "user", "content": user_text}],
) as stream:
for text in stream.text_stream: # tokens as they arrive
on_token(text) # feed straight into TTS
return stream.get_final_message()
# on_token would push each chunk to the TTS engine so audio starts
# after the FIRST sentence, not the whole reply.
Streaming collapses the LLM stage's contribution to time-to-first-audio: you start speaking after the first clause instead of paying for the entire generation, which is what keeps total latency under budget.
✓ Checkpoint — you can move on when you can…
- Explain a voice/realtime agent and the mic→STT→agent→TTS→speaker loop.
- Choose between the pipeline and speech-to-speech and justify it.
- Explain streaming transport, turn detection, and barge-in.
- Build a latency budget and defend the ~800 ms round-trip target.
- List the production concerns: VAD, echo, fallback, cost, concurrency.
Knowledge check check yourself
What is the difference between a pipeline (STT → LLM → TTS) voice architecture and a speech-to-speech model, and what does each trade off?
Show answer
Why do voice agents target roughly an 800 ms end-to-end latency budget, and what is barge-in?