AI EngineeringZero to ProductionHome·About·Contact
Frontier Agent Capabilities · Part 1

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.

⏱️ ~2 hours🧪 4 labs🎯 Beginner→Tech-lead

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

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.

Mic captures STT speech→text Agent (LLM) decides TTS text→speech Speaker plays
🗺️ How to read this diagram

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 → SpeakerTTS (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

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.

Pipeline (STT→LLM→TTS)Speech-to-speech
Latencysum of 3 stages (higher)single model (lower)
Debuggabilitytext at every hopaudio in/out, opaque
Tone / emotionlost at the text hoppreserved
Swap a componenteasy (mix vendors)all-or-nothing
Guardrails / loggingon the transcriptneeds separate transcription
Most teams start with the pipelineThe pipeline is easier to reason about, log, and evaluate, and lets you pick the best STT and best TTS independently. Speech-to-speech wins when latency and natural prosody matter most (e.g. a companion or a fast phone line).

3 · Streaming transport: why not request/response intermediate

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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Python · stream a reply in chunks & measure time-to-first-audio (runs)
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
▶ How this works

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.

  1. stream_reply hands back one small chunk at a time, adding per_chunk_ms (120 ms) of time for each — like audio arriving frame by frame.
  2. The loop records first_audio_at (the moment the very first chunk lands) and keeps updating last_audio_at until the reply is finished.
  3. Because the reply has 5 chunks of 120 ms, the first sound arrives at 120 ms but the whole thing finishes at 600 ms.
  4. 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.

Stream everything you canSTT emits partial transcripts as the user talks, the LLM streams tokens, and TTS streams audio out. Overlapping these three streams is what turns a 2-second sequential pipeline into a sub-second-feeling conversation.

4 · Turn detection: whose turn is it? intermediate

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.

Python · turn-detection state machine (runs)
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)]
▶ How this works

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.

  1. SILENCE_HANGOVER_MS = 500 is the rule: silence must last at least half a second before we decide the turn is over. Short pauses (thinking) don't count.
  2. It walks a list of 100 ms frames that are either speech or silence. Any speech resets the silence counter back to zero.
  3. When silence starts, the state moves to MAYBE_DONE; once the silence has piled up to 500 ms it flips to AGENT_TURN and records a turn_end event.
  4. 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.

The hangover threshold is a UX tradeoffShort threshold = snappy, but you interrupt people who pause to think. Long threshold = polite, but laggy. Production systems tune this per language and often use a smarter (model-based) endpointer, not just fixed silence.

5 · Barge-in: letting the human interrupt advanced

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.

Python · barge-in handler simulation (runs)
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
▶ How this works

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.

  1. agent_speaks queues up the reply chunks and sets the state to SPEAKING — the agent is now playing audio.
  2. on_user_speech is 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.
  3. If it's real user speech while the agent is SPEAKING, that's a barge-in: it clears the play queue (stops playback), switches to LISTENING, and logs it.
  4. 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.

Barge-in is a distributed-systems problemThe reply may be buffered in the model, on the network, and in the audio device. A clean barge-in has to flush all three — and quickly — or the user hears a second of agent voice after they've already started talking.

6 · The latency budget: why ~800 ms matters advanced

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.

Python · latency-budget calculator (runs)
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
▶ How this works

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.

  1. stages is a dictionary of each step (capture, STT, the LLM's first token, TTS, network+play) and how many milliseconds it takes.
  2. latency_budget sums them into total_ms, compares against the target_ms (800), and computes the leftover headroom_ms.
  3. max(stages, key=stages.get) finds the single biggest stage — the one to attack first if you're over budget.
  4. Here the total is 760 ms, so within_budget is True with 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.

Optimize time-to-first-token, not total tokensFor voice, what matters is when the FIRST word comes back, because you start speaking it immediately while the rest streams. A smaller/faster model, prompt caching, and streaming TTS attack the biggest stage — usually the LLM's first token — rather than shaving the whole response.

7 · Production concerns: VAD, echo, fallback, cost professional

A demo that works at your desk breaks on a real phone line. The production checklist:

Production voice hardening

  1. VAD / endpointing tuned per language and noise floor — noisy environments false-trigger on background sound.
  2. Echo cancellation (AEC) so the mic doesn't hear the speaker; without it, barge-in fires on the agent's own voice.
  3. 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').
  4. Interruptibility everywhere: any long agent action (a tool call, a lookup) must remain interruptible, or barge-in dies mid-task.
  5. 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:

Python · realtime voice cost estimate (runs)
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}
▶ How this works

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.

  1. voice_cost multiplies calls per day × average call length × 30 days to get total minutes of audio per month.
  2. Multiplying minutes by cost_per_min gives the monthly dollar cost; a single call's cost is just its length × the per-minute rate.
  3. With 2000 calls/day at 3 minutes each, that's 180,000 minutes a month.
  4. 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.

Open connections are a capacity limit, not just a costEach live call holds a websocket and model session for its whole duration. 500 concurrent callers = 500 open realtime sessions. Plan for peak concurrency and a graceful queue/fallback when you hit the ceiling — an outage here means the phone line goes dead, not a slow web page.

8 · Tech-lead — owning a voice-agent system tech-lead

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.

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.

Python · realtime voice SDK shape — NEEDS THE SDK / NETWORK (does not run offline)
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
The offline labs teach the ideas; the SDK is just wiringStreaming, turn events, and cancel-on-interrupt are exactly the concepts you built in 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_budget flips to True
  • 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.

Exercise 1 · Add up the latency budgetBeginner

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.

Exercise 2 · Pipeline vs speech-to-speechIntermediate

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.

Exercise 3 · A voice-activity gate (energy VAD)Advanced

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.

Exercise 4 · Barge-in state machineExpert

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.

Exercise 5 · Fallback when a stage failsProfessional

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.

Exercise 6 · Wire a real streaming turn (SDK)Industry scenario

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

✓ Knowledge check

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
A pipeline chains three swappable, inspectable models but adds latency at each stage and loses prosody. A native speech-to-speech model takes audio in and emits audio out with lower latency and preserved prosody, but is harder to log and guardrail because there's no text bottleneck.
✓ Knowledge check

Why do voice agents target roughly an 800 ms end-to-end latency budget, and what is barge-in?

Show answer
Natural conversational gaps are about 200 ms; past ~800 ms the agent feels sluggish and people start talking over it, so total time from user-stops to first-audio must stay under that budget. Barge-in is letting the human interrupt mid-reply: the agent stops its playback and starts listening again.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in