hawktalk@ai — standard chat guide hawktalk.ai/chatstandards

Everything that separates voice AI
that works from voice AI that demos.

Function calling, latency, echo, interruption, cost, and the three levels a voice product can exist at. Most projects fail for the same handful of reasons, and none of them are model quality. This is the reference for avoiding them — written from measured numbers, including the ones that came out badly.

Starting out Building your first voice app. Begin at level 1 — it is a day's work and it is the right answer more often than people think. Mine feels wrong Slow, awkward turns, talks over you, or the bill is a surprise. Start with the smells and work back. Running at scale Already shipped. Go to evaluation and cost, then the graveyard so you skip the experiments that did not pay.
Level 1

Cascaded

The coreRaw audio input works at all.
Level 2

Live

The coreCommunication goes both ways.
Level 3

Superharness

The coreMany channels at once, reconciled.
snippets
The demo used throughout

SpotBot — a workout coach. Same product at all three levels, so you can see what each one actually buys. The code is illustrative; the shapes are real.

Part one

The three tiers at a glance

What each level is, what it costs you to build, and the one thing it cannot do. Ninety seconds, then the levers worth knowing about before you pick.

The three levels, in ninety seconds

If you read nothing else. Deep dives follow.

Level 1

Cascaded

a day to build

Push to talk. The user finishes, you transcribe, you answer. Turns strictly alternate, which makes almost every hard problem disappear.

Core
Raw audio input works at all.
Transport
Plain REST. No socket.
Echo
Duck — subtract the playback, keep the mic open and collecting.
Use it for
Dictation, ordering, forms, note capture, batch transcription.
Ceiling
No interruption. It feels like a walkie-talkie, because it is one.
Level 2

Live

a week to build

A persistent socket, audio streaming both ways, and either party able to cut the other off. This is a different product from level 1, not a faster one.

Core
Communication goes both ways.
Transport
WebSocket to start; WebRTC once users are mobile.
Echo
Ducking, on the client. Muting the mic is not an option once you want barge-in.
Use it for
Tutoring, support, companionship, anything conversational.
Ceiling
One lane. It cannot think privately while speaking, or run a slow tool without stalling.
Level 3

Superharness

a project

Independent lanes running at once — speaking, thinking, acting, logging — spun up for a task and torn down after. The AI stops being a participant and starts running the session.

Core
Many channels at once, reconciled.
Transport
Multiplexed socket, ephemeral sessions.
Echo
Ducking required, plus affect and speaker handling.
Use it for
Coaching under load, ops copilots, supervising a live process.
Ceiling
Reconciliation. Concurrency is easy to start and hard to keep honest.
L1: CASCADED (SEQUENCE) 1. Mic Input 2. VAD Gating 3. Audio Buffer 4. HTTP REST Call 5. Reply / Playback MIC MUTED DURING PLAYBACK Strict Alternation (Walkie-Talkie) L2: LIVE (DUPLEX LOOP) CLIENT Mic Live Input Speaker Live Output SERVER WSS Engine Streaming LLM + TTS Upstream Downstream Barge-in Cut Persistent Socket Loop L3: SUPERHARNESS EPHEMERAL SESSION audio VAD, TTS & Playout text Token Deltas (Captions) tools Function Call Args thinking Private Reasoning async Out-of-band Tasks Reconcile Five Concurrent Lanes
Level 1 is a sequence, level 2 is a loop, level 3 is parallel lanes.
The mistake this page exists to prevent Building level 2 features on a level 1 foundation, or buying level 3 pricing for a level 1 problem. Start one level below where you think you are. Moving up is additive; moving down means admitting you built the wrong thing.

00Identify the core first

Before you write a line, name the one thing that must work. Everything at a given level is in service of that one thing, and a feature from a higher level bolted onto a lower one always breaks in the same place.

LevelThe coreYou have it whenSymptom of skipping it
1 · Cascadedaudio in works you can capture, endpoint and transcribe a sentence reliably, every time it "works on my machine," fails on a phone in a noisy room
2 · Liveturn-taking works either party can interrupt and the other yields cleanly talking over each other; the model finishes a sentence nobody wanted
3 · Superharnesschannels reconcile the model can speak, think, act and log at once without contradicting itself it says one thing and does another; tool results arrive after they mattered

The professionalism levers

Everything that separates a voice product that works from one that demos. Each links to its deep dive. If you are auditing an existing build, this is the checklist.

LEVER 01 Tool calls are a contract Structured tool_calls, grammar-constrained decode, negative-case training. Never a regex over prose. 71% → 93% from format alone. LEVER 02 Thinking stays off the voice chain A reasoning model in the voice path is the amateur signature. Deliberation runs on another clock and is regrounded in. LEVER 03 First audio, not total turn Users feel time-to-first-audio. Cover the gap with a pre-cached opening and stream under it. ~50ms is achievable from cloud. LEVER 04 Endpointing adapts A fixed silence timer is dead air on every turn. Track the user's own pauses; never cut off mid-clause. LEVER 05 Subtract, never silence Duck the playback out of the mic and keep it open. A muted mic collects nothing. Always client-side — it needs a loopback reference. LEVER 06 Flush the local buffer Cancelling server-side is the easy half. Dropping queued audio on the client is what makes interruption feel instant. LEVER 07 Transport matches the network WebSocket to ship. WebRTC when users are moving, where TCP head-of-line blocking turns 2% loss into unusable audio. LEVER 08 Route, don't pin Most turns are acknowledgements. Pinning a large model means paying frontier rates to say "mhm" — usually 40–70% of the bill. LEVER 09 Declare tools once A tool set is 700–1600 tokens. Re-sent every turn, it is often most of your token spend, buying nothing. LEVER 10 Match the rail to the interaction A socket bills for the session; a request bills for the turn. Structural — the wrong rail cannot be optimised away. LEVER 11 Channels reconcile Independent lanes, one account of reality. Without a rule for who wins, a superharness is four race conditions in a trenchcoat. LEVER 12 Measure before you tune Log the trace id first. It reports which tier answered and what each stage cost. Optimising without it is guessing.
Part two

The three tiers in depth

Each level end to end — how it works, what breaks, and the problems that only appear once you are at that level. Colour-coded throughout: cyan, violet, amber.

01Level 1 — Cascaded

Push-to-talk, walkie-talkie turns. The oldest shape and still the correct one for most products. This is where CoachPal and PurlPal started, and it is a perfectly good place to stay. → REST tier reference

mic ──▶ accumulative VAD ──▶ buffer ──▶ commit on silence │ ▼ POST /v1/generate (audio + text, one request) │ ▼ text or audio reply ──▶ play (mic ducked)

Accumulative VAD, which is the whole trick

You do not send audio continuously. You accumulate frames while speech is present, and you commit the buffer when silence has lasted long enough to mean "they're done." Get the silence threshold wrong in either direction and the product feels broken — too short and you cut people off mid-thought, too long and it feels dead.

Use a neural VAD, not an energy-based one. The old GMM detectors (webrtcvad and friends) trigger on typing, coughs, doors and background conversation. Silero is small enough to run on anything, ships as a single file, and is the sane default in 2026. Starting here saves you a week of tuning noise gates.

# spotbot/listen.py — accumulate while speaking, commit on silence
import torch
vad, _ = torch.hub.load('snakers4/silero-vad', 'silero_vad')   # neural, ~1MB

MIN_SPEECH_MS = 300         # reject coughs, door slams, a stray "uh"

def listen_once(stream, hush):
    """hush = how long a pause must mean 'done'. NOT a constant you ship."""
    buf, silent_ms, speech_ms = [], 0, 0
    for frame in stream:                        # 512 samples @ 16kHz = 32ms
        speaking = vad(torch.from_numpy(frame), 16000).item() > 0.5
        if speaking:
            buf.append(frame); speech_ms += 32; silent_ms = 0
        elif buf:
            buf.append(frame); silent_ms += 32  # keep the trailing silence
            if silent_ms >= hush.current():     # adaptive, not hardcoded
                return b"".join(buf) if speech_ms >= MIN_SPEECH_MS else None
    return None
// spotbot/listen.ts — accumulate while speaking, commit on silence
import { SileroVad } from "@ricky0123/vad-web";   // neural, runs in the browser

const MIN_SPEECH_MS = 300;   // reject coughs, door slams, a stray "uh"

export async function listenOnce(stream: AsyncIterable<Float32Array>, hush: Hush) {
  const buf: Float32Array[] = [];
  let silentMs = 0, speechMs = 0;
  for await (const frame of stream) {            // 512 samples @ 16kHz = 32ms
    const speaking = (await vad.process(frame)).isSpeech > 0.5;
    if (speaking) { buf.push(frame); speechMs += 32; silentMs = 0; }
    else if (buf.length) {
      buf.push(frame); silentMs += 32;           // keep the trailing silence
      if (silentMs >= hush.current())            // adaptive, not hardcoded
        return speechMs >= MIN_SPEECH_MS ? concat(buf) : null;
    }
  }
  return null;
}
// spotbot/listen.dart — accumulate while speaking, commit on silence
import 'package:onnxruntime/onnxruntime.dart';

const minSpeechMs = 300;    // reject coughs, door slams, a stray "uh"

Future<List<int>?> listenOnce(Stream<Float32List> stream, Hush hush) async {
  final buf = <Float32List>[];
  var silentMs = 0, speechMs = 0;
  await for (final frame in stream) {            // 512 samples @ 16kHz = 32ms
    final speaking = (await vad.run(frame)) > 0.5;
    if (speaking) { buf.add(frame); speechMs += 32; silentMs = 0; }
    else if (buf.isNotEmpty) {
      buf.add(frame); silentMs += 32;            // keep the trailing silence
      if (silentMs >= hush.current) {            // adaptive, not hardcoded
        return speechMs >= minSpeechMs ? pcm16(buf) : null;
      }
    }
  }
  return null;
}
// spotbot/listen.rs — accumulate while speaking, commit on silence
use voice_activity_detector::VoiceActivityDetector;   // silero, ort-backed

const MIN_SPEECH_MS: u32 = 300;   // reject coughs, door slams, a stray "uh"

pub fn listen_once(
    stream: &mut impl Iterator<Item = Vec<f32>>,
    vad: &mut VoiceActivityDetector,
    hush: &Hush,
) -> Option<Vec<i16>> {
    let (mut buf, mut silent_ms, mut speech_ms) = (Vec::new(), 0u32, 0u32);
    for frame in stream {                         // 512 samples @ 16kHz = 32ms
        let speaking = vad.predict(frame.clone()) > 0.5;
        if speaking {
            buf.push(frame); speech_ms += 32; silent_ms = 0;
        } else if !buf.is_empty() {
            buf.push(frame); silent_ms += 32;     // keep the trailing silence
            if silent_ms >= hush.current() {      // adaptive, not hardcoded
                return (speech_ms >= MIN_SPEECH_MS).then(|| to_pcm16(&buf));
            }
        }
    }
    None
}
// spotbot/listen.go — accumulate while speaking, commit on silence
package spotbot

const minSpeechMs = 300   // reject coughs, door slams, a stray "uh"

func ListenOnce(stream <-chan []float32, vad *Silero, hush *Hush) []int16 {
    var buf [][]float32
    silentMs, speechMs := 0, 0
    for frame := range stream {                   // 512 samples @ 16kHz = 32ms
        speaking := vad.Predict(frame) > 0.5
        if speaking {
            buf = append(buf, frame); speechMs += 32; silentMs = 0
        } else if len(buf) > 0 {
            buf = append(buf, frame); silentMs += 32  // keep trailing silence
            if silentMs >= hush.Current() {           // adaptive, not hardcoded
                if speechMs >= minSpeechMs { return toPCM16(buf) }
                return nil
            }
        }
    }
    return nil
}
Do not ship a fixed silence threshold A constant here is where you start, not where you land — and shipping one is the amateur smell from the previous section. Fast talkers get cut off at 700ms; people who think out loud get cut off at 1200ms; both find it unusable. Make it adapt. The cheap version that gets you most of the way:
· Track each user's own pause distribution and set the threshold above their 90th percentile.
· Shorten it after a question, lengthen it after they say "um" or trail off on a rising tone.
· Never cut off mid-clause — if the transcript so far ends on "and", "but", "because", wait regardless of the timer.
The last one is a single line of code and it removes most of the complaints.

One REST call, multimodal — no socket needed

The payoff for cascading is that you never open a WebSocket. Audio goes in as part of an ordinary request, alongside text and images. Any REST client can do it, it is trivially debuggable with curl, and it retries cleanly.

# spotbot/turn.py — audio in, coaching out, one round trip
import base64, requests

def coach_turn(audio_bytes, set_log):
    r = requests.post(
        "https://api.hawktalk.ai/v1/chat/completions",
        headers={"Authorization": f"Bearer {KEY}"},
        json={
            "model": "auto",                       # route per utterance
            "messages": [{"role": "user", "content": [
                {"type": "input_audio", "input_audio": {
                    "format": "pcm16", "data": base64.b64encode(audio_bytes).decode()}},
                {"type": "text", "text": f"Sets so far: {set_log}. Coach the next one."},
            ]}],
            "modalities": ["text", "audio"],       # speech back in the same call
            "audio": {"voice": "am_puck"},
        }, timeout=30)
    d = r.json()["choices"][0]["message"]
    return d["content"], base64.b64decode(d["audio"]["data"])
// spotbot/turn.ts — audio in, coaching out, one round trip
export async function coachTurn(audio: Uint8Array, setLog: string) {
  const r = await fetch("https://api.hawktalk.ai/v1/chat/completions", {
    method: "POST",
    headers: { Authorization: `Bearer ${KEY}`, "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "auto",                             // route per utterance
      messages: [{ role: "user", content: [
        { type: "input_audio", input_audio: { format: "pcm16", data: b64(audio) } },
        { type: "text", text: `Sets so far: ${setLog}. Coach the next one.` },
      ]}],
      modalities: ["text", "audio"],             // speech back in the same call
      audio: { voice: "am_puck" },
    }),
  });
  const m = (await r.json()).choices[0].message;
  return { text: m.content, audio: b64d(m.audio.data) };
}
// spotbot/turn.dart — audio in, coaching out, one round trip
import 'dart:convert';
import 'package:http/http.dart' as http;

Future<(String, List<int>)> coachTurn(List<int> audio, String setLog) async {
  final r = await http.post(
    Uri.parse('https://api.hawktalk.ai/v1/chat/completions'),
    headers: {'Authorization': 'Bearer $KEY', 'Content-Type': 'application/json'},
    body: jsonEncode({
      'model': 'auto',                           // route per utterance
      'messages': [{'role': 'user', 'content': [
        {'type': 'input_audio',
         'input_audio': {'format': 'pcm16', 'data': base64Encode(audio)}},
        {'type': 'text', 'text': 'Sets so far: $setLog. Coach the next one.'},
      ]}],
      'modalities': ['text', 'audio'],           // speech back in the same call
      'audio': {'voice': 'am_puck'},
    }),
  );
  final m = jsonDecode(r.body)['choices'][0]['message'];
  return (m['content'] as String, base64Decode(m['audio']['data']));
}
// spotbot/turn.rs — audio in, coaching out, one round trip
use base64::{Engine, engine::general_purpose::STANDARD as B64};
use serde_json::json;

pub async fn coach_turn(audio: &[u8], set_log: &str)
    -> anyhow::Result<(String, Vec<u8>)>
{
    let body = json!({
        "model": "auto",                          // route per utterance
        "messages": [{ "role": "user", "content": [
            { "type": "input_audio",
              "input_audio": { "format": "pcm16", "data": B64.encode(audio) } },
            { "type": "text", "text": format!("Sets so far: {set_log}. Coach the next one.") },
        ]}],
        "modalities": ["text", "audio"],          // speech back in the same call
        "audio": { "voice": "am_puck" },
    });
    let v: serde_json::Value = reqwest::Client::new()
        .post("https://api.hawktalk.ai/v1/chat/completions")
        .bearer_auth(std::env::var("HAWKTALK_API_KEY")?)
        .json(&body).send().await?.json().await?;
    let m = &v["choices"][0]["message"];
    Ok((m["content"].as_str().unwrap_or_default().to_string(),
        B64.decode(m["audio"]["data"].as_str().unwrap_or_default())?))
}
// spotbot/turn.go — audio in, coaching out, one round trip
package spotbot

import ("bytes"; "encoding/base64"; "encoding/json"; "net/http")

func CoachTurn(audio []byte, setLog string) (string, []byte, error) {
    body, _ := json.Marshal(map[string]any{
        "model": "auto",                          // route per utterance
        "messages": []any{map[string]any{"role": "user", "content": []any{
            map[string]any{"type": "input_audio", "input_audio": map[string]any{
                "format": "pcm16", "data": base64.StdEncoding.EncodeToString(audio)}},
            map[string]any{"type": "text", "text": "Sets so far: " + setLog + ". Coach the next one."},
        }}},
        "modalities": []string{"text", "audio"},  // speech back in the same call
        "audio":      map[string]any{"voice": "am_puck"},
    })
    req, _ := http.NewRequest("POST",
        "https://api.hawktalk.ai/v1/chat/completions", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+key)
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil { return "", nil, err }
    defer res.Body.Close()

    var out struct{ Choices []struct{ Message struct {
        Content string `json:"content"`
        Audio   struct{ Data string `json:"data"` } `json:"audio"`
    } `json:"message"` } `json:"choices"` }
    json.NewDecoder(res.Body).Decode(&out)
    m := out.Choices[0].Message
    raw, err := base64.StdEncoding.DecodeString(m.Audio.Data)
    return m.Content, raw, err
}
Why stay at level 1 It is cheap, it is debuggable, it works offline-ish, it survives bad networks, and for any product where the user expects to take a turn — dictation, ordering, form filling, logging a set — it is not a downgrade. It is the right answer. Do not buy a realtime socket to build a walkie-talkie.

02Duck, don't AEC

The single most common way level 1 projects waste a month.

When your AI speaks through a speaker, the microphone hears it. Left alone, the model transcribes its own voice and answers itself. There are two ways out, and the industry default is the worse one.

Muting the mic during playback is what most products do. It works, it is three lines, and it is a trap: a muted mic collects nothing. You lose the barge-in, you lose the affect signal, you lose the utterance the user started before you finished talking, and you lose every second of audio you would otherwise have had to learn from. You have solved echo by going deaf.

Ducking is the right answer: subtract the speaker's output from the mic signal and leave the mic open. You know exactly what you sent to the speaker, so you know exactly what to remove. The mic never stops collecting, the user can interrupt at any moment, and the audio you keep is clean. Our own implementation measures 11.7 dB ERLE with 0.93 correlation preserved on the user's voice during double-talk — the user survives the subtraction, which is the whole point.

The principle underneath it The microphone should always be collecting. Audio you did not capture is audio you cannot transcribe, cannot learn from, and cannot go back for. Any technique whose mechanism is "stop listening" is trading a permanent loss for a temporary convenience. Subtract, do not silence.

The three lines below are what the lazy option looks like, so you can recognise it in a codebase — not a recommendation:

# the lazy option: mute the mic. three lines, zero DSP, total data loss
def say(audio):
    mic.pause()          # you are now deaf for the duration
    speaker.play(audio)  # blocking
    mic.resume()         # small guard delay for the room reverb tail
// the lazy option: mute the mic. three lines, zero DSP, total data loss
async function say(audio: AudioBuffer) {
  mic.pause();                           // you are now deaf for the duration
  await speaker.play(audio);
  setTimeout(() => mic.resume(), 120);   // guard delay for the reverb tail
}
// the lazy option: mute the mic. three lines, zero DSP, total data loss
Future<void> say(Uint8List audio) async {
  await mic.pause();          // you are now deaf for the duration
  await speaker.play(audio);
  await Future.delayed(const Duration(milliseconds: 120));  // reverb tail
  await mic.resume();
}
// the lazy option: mute the mic. three lines, zero DSP, total data loss
async fn say(audio: &[u8]) {
    mic.pause();                // you are now deaf for the duration
    speaker.play(audio).await;
    tokio::time::sleep(Duration::from_millis(120)).await;  // reverb tail
    mic.resume();
}
// the lazy option: mute the mic. three lines, zero DSP, total data loss
func Say(audio []byte) {
    mic.Pause()                          // you are now deaf for the duration
    speaker.Play(audio)                  // blocking
    time.Sleep(120 * time.Millisecond)   // guard delay for the reverb tail
    mic.Resume()
}
THE LAZY WAY: MUTE THE MIC Mic switched off during playback. Simple, and you lose every byte. SPEAKER PLAYING AUDIO MICROPHONE MUTED / OFF Switch Open No echo — and no data either. The mic hears nothing at all. DUCKING: SUBTRACT THE PLAYBACK Mic stays open. Known speaker output is subtracted out of it. SPEAKER Ref Channel MICROPHONE Live & Open Acoustic Echo Path DUCKING PROCESSOR G(t) Filter Subtraction Mic Signal Loopback Ref CLEAN VOICE
Left: mute the mic and lose the data. Right: subtract the playback, keep listening.

"I already have a VAD — why do I need to subtract anything?"

The most common question at this point, and the answer is worth understanding rather than memorising.

A VAD answers "is this speech?" It cannot answer "is this speech from the user?" Your bot's TTS coming back through the microphone is speech — clean, well-formed, exactly what a VAD is built to detect. The VAD is working perfectly and giving you the wrong answer.

So the instinct is right: gate the VAD on what you know you are playing. That works, up to a point, and here is the ladder from cheapest to most correct:

ApproachCostWhere it breaks
Mute the mic while speakingthree lines no barge-in, and no data. The wall you hit first.
Playback-gated VAD — raise the threshold while audio is out an hour a loud room or a loud speaker still trips it; and a quiet user gets ignored
Speaker verification — accept only the enrolled voice a small model rejects your own TTS and the TV cleanly, but the audio is still mixed
Ducking — subtract the known playback real DSP work, or one platform flag needs a sample-aligned loopback reference, so it lives on the client
The one-sentence version VAD is a decision. Ducking is a repair. A gate can tell you whether to listen. Only subtraction gives you something worth listening to. Even with a perfect gate that fires at exactly the right moment, the audio you hand to your transcriber still has your own voice mixed into it — and it will faithfully transcribe both of you.

The case that kills every gate-only approach is double-talk — the user speaking while the bot is speaking, which is the entire point of barge-in. Gating trades a false positive for a false negative: instead of the bot hearing itself, the bot ignores the user. That is the worse failure, because the user knows they were ignored.

And the subtraction has to run on the client. It works by removing a reference signal — the exact audio you sent to the speaker — from the microphone input, sample-aligned. That alignment only exists on the device that owns both the speaker and the mic. Ship the mic audio to a server first and network jitter has already destroyed the timing relationship you needed. If you take one thing from this section: ducking is a client concern, always.

A note on the word, so you can find the API Platform vendors ship this subtraction under the name "acoustic echo cancellation" — that is what the flag is called in WebRTC, iOS and Android. We call the technique ducking because that is what it does to the speaker signal, and because the name AEC has come to be used loosely for "make the echo problem go away," which is how people end up muting the mic and calling it solved. Same mechanism, clearer name. When you go looking for the switch, look for echo cancellation.
Practical route for a first build Do not write the subtraction yourself. Use the one you already have — WebRTC's audioprocessing module ships AEC, noise suppression and auto gain, and browsers give it to you for free via getUserMedia({ echoCancellation: true }). Native apps get the platform one (VoiceProcessingIO on iOS, AcousticEchoCanceler on Android). Writing your own is a project; the platform's is a flag. Reach for a custom one only when you have measured the platform's and found it wanting.

03Level 2 — Live

A persistent socket, audio streaming both directions, server-side endpointing, and interruption. Gemini Live, GPT Realtime, and wss /v1/realtime all live here. → WebSocket tier reference

The core is communication back and forth. Not speed — turn-taking. The model can begin answering before you have finished forming your thought, and you can cut it off without the session falling apart. That is a different product from level 1, not a faster one.

# spotbot/live.py — streaming both ways, with barge-in
import json, websockets

async def live(mic, speaker):
    async with websockets.connect(
        "wss://api.hawktalk.ai/v1/realtime?model=auto",
        extra_headers={"Authorization": f"Bearer {KEY}"}) as ws:

        async def send_audio():
            async for frame in mic:              # continuous — server endpoints
                await ws.send(json.dumps({
                    "type": "input_audio_buffer.append", "audio": b64(frame)}))

        asyncio.create_task(send_audio())
        async for raw in ws:
            m = json.loads(raw)
            if m["type"] == "response.audio.delta":
                speaker.enqueue(b64d(m["delta"]))
            elif m["type"] == "input_audio_buffer.speech_started":
                speaker.flush()                  # THE line. drop queued audio now.
                await ws.send(json.dumps({"type": "response.cancel"}))
            elif m["type"] == "response.text.delta":
                caption(m["delta"])
// spotbot/live.ts — streaming both ways, with barge-in
const ws = new WebSocket("wss://api.hawktalk.ai/v1/realtime?model=auto",
                         ["bearer", KEY]);

mic.onframe = f => ws.send(JSON.stringify({    // continuous — server endpoints
  type: "input_audio_buffer.append", audio: b64(f),
}));

ws.onmessage = e => {
  const m = JSON.parse(e.data);
  switch (m.type) {
    case "response.audio.delta":
      speaker.enqueue(b64d(m.delta)); break;
    case "input_audio_buffer.speech_started":
      speaker.flush();                          // THE line. drop queued audio now.
      ws.send(JSON.stringify({ type: "response.cancel" }));
      break;
    case "response.text.delta":
      caption(m.delta); break;
  }
};
// spotbot/live.dart — streaming both ways, with barge-in
final ws = WebSocketChannel.connect(
  Uri.parse('wss://api.hawktalk.ai/v1/realtime?model=auto'),
  protocols: ['bearer', key],
);

mic.frames.listen((f) => ws.sink.add(jsonEncode({   // continuous
  'type': 'input_audio_buffer.append', 'audio': base64Encode(f),
})));

ws.stream.listen((raw) {
  final m = jsonDecode(raw);
  switch (m['type']) {
    case 'response.audio.delta':
      speaker.enqueue(base64Decode(m['delta']));
    case 'input_audio_buffer.speech_started':
      speaker.flush();                      // THE line. drop queued audio now.
      ws.sink.add(jsonEncode({'type': 'response.cancel'}));
    case 'response.text.delta':
      caption(m['delta']);
  }
});
// spotbot/live.rs — streaming both ways, with barge-in
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;

let (mut ws, _) = connect_async(req).await?;         // ?model=auto
let (mut tx, mut rx) = ws.split();

tokio::spawn(async move {                            // continuous mic pump
    while let Some(f) = mic.next().await {
        let msg = json!({"type": "input_audio_buffer.append", "audio": b64(&f)});
        tx.send(Message::Text(msg.to_string())).await.ok();
    }
});

while let Some(Ok(Message::Text(raw))) = rx.next().await {
    let m: serde_json::Value = serde_json::from_str(&raw)?;
    match m["type"].as_str() {
        Some("response.audio.delta") => speaker.enqueue(b64d(&m["delta"])?),
        Some("input_audio_buffer.speech_started") => {
            speaker.flush();                         // THE line. drop queued audio.
            cancel_tx.send(json!({"type": "response.cancel"})).await?;
        }
        Some("response.text.delta") => caption(&m["delta"]),
        _ => {}
    }
}
// spotbot/live.go — streaming both ways, with barge-in
conn, _, err := websocket.DefaultDialer.Dial(
    "wss://api.hawktalk.ai/v1/realtime?model=auto", hdr)
if err != nil { return err }
defer conn.Close()

go func() {                                       // continuous mic pump
    for f := range mic {
        conn.WriteJSON(map[string]any{
            "type": "input_audio_buffer.append", "audio": b64(f)})
    }
}()

for {
    var m map[string]any
    if err := conn.ReadJSON(&m); err != nil { return err }
    switch m["type"] {
    case "response.audio.delta":
        speaker.Enqueue(b64d(m["delta"].(string)))
    case "input_audio_buffer.speech_started":
        speaker.Flush()                           // THE line. drop queued audio now.
        conn.WriteJSON(map[string]any{"type": "response.cancel"})
    case "response.text.delta":
        Caption(m["delta"].(string))
    }
}
The line that is the whole level speaker.flush() on speech_started. Cancelling generation server-side is easy; throwing away audio you already buffered locally is what makes interruption feel instant. If you only cancel and let the buffer drain, the AI keeps talking for a second after being interrupted and the illusion dies. Our barge-in measures a ~0ms server decision with 12/12 hermetic checks green — and the client-side flush is the other half of that number.

Now ducking is mandatory, because you are listening while speaking by definition — and per the previous section, the subtraction runs on the client or not at all.

WebSocket or WebRTC?

The transport question everyone hits here and almost nobody explains. Both are used in production by major providers, so this is a real choice, not a right answer.

WebSocketWebRTC
Under the hoodTCPUDP
Packet loss Head-of-line blocking — one lost packet stalls every packet behind it, so a 2% loss rate becomes audible stutter Loss is concealed and playback continues
Audio formatwhatever you encode yourself Opus, negotiated, with packet-loss concealment built in
Ducking / echoyou arrange it Free — AEC, noise suppression and gain control come with the stack
Jitter bufferyou write itincluded
Effort to first working callan afternoona week, plus signalling and TURN servers
What to actually do Start on WebSockets. They are dramatically simpler, they are what most realtime APIs expose, and on office wifi or a good cellular connection they are fine. Ship, learn, get users.

Move to WebRTC when your users are moving. Cars, trains, lifts, bad venue wifi, anything mobile — that is where TCP's head-of-line blocking turns a 2% packet loss into unusable audio, and where WebRTC's loss concealment is the difference between a product and a demo. You also inherit AEC, Opus and a jitter buffer for free, which quietly solves three other problems on this page.
DIAGRAM 4: TCP HEAD-OF-LINE BLOCKING VS UDP TCP forces ordered delivery. One lost packet stalls all subsequent data in the socket buffers. TCP / WEBSOCKET: ORDERED QUEUE (HEAD-OF-LINE BLOCKING) 1 2 3 LOST 5 6 7 8 Play everything behind it waits for the retransmit audible stutter UDP / WEBRTC: UNORDERED FLOW (IMMEDIATE DELIVERY) 1 2 3 LOST 5 6 7 8 Play PLC loss concealment continuous audio On a good network these are identical. On a train they are not.
On a good network these are identical. On a train they are not.

Bandwidth — the mistake in every tutorial's code, including this page's

Raw 16kHz PCM16 mono is 256 kbps. That is fine on wifi and rude on a mobile data plan. Every code sample here streams raw PCM because it is legible, not because it is right.

Encode to Opus before it leaves the device — 16–32 kbps for speech, roughly a tenth the bytes, with no meaningful quality cost for this purpose and packet-loss concealment as a bonus. It is one library call and it is the difference between an app people use on the train and one they only use at home.

What level 2 still cannot do

One model, one stream of consciousness, one thing at a time. It cannot think privately while speaking. It cannot run a slow tool without either stalling the conversation or lying about what it knows. It cannot revise a plan in the background. Everything it does must fit in the single lane it talks through — and the moment your product needs two things happening at once, you have hit the ceiling.

04Interruption is four different things

"Barge-in" is one word covering four distinct events that need four distinct responses. Products that treat them as one are the ones that either talk over you or stop every time a door closes.

EventExampleCorrect responseGetting it wrong
Backchannel "mhm", "right", "yeah", a laugh Keep talking. This is the listener signalling attention, not requesting the floor. Stopping makes the AI seem skittish and breaks its own sentence for no reason.
True interruption "wait—", "no, I meant", a question Yield immediately. Flush the local buffer, cancel generation, listen. Talking over the user is the single most disliked behaviour in voice AI.
Environmental the TV, a bystander, a colleague across the room Ignore entirely. Never yields, never enters the transcript. The agent answers the television. Common and very obvious in a demo.
Self-echo the AI's own voice via the speaker Never reaches the decision. Cancelled in the signal, not judged. A permanent self-interruption loop — it hears itself, stops, hears itself stop.
THE INTERRUPTION LADDER: 4-STAGE BARGE-IN GATE Filters cheap signals first. Only verified user speech exceeding duration and semantic checks yields the floor. INCOMING AUDIO SIGNAL 1. DUCKING (SUBTRACTION) Hardware Reference Subtraction DISCARDS: AI's own voice, subtracted out 2. SPEAKER VERIFICATION Enrolled Speaker Embedding Match DISCARDS: Bystanders, TV, pets 3. DURATION THRESHOLD Continuous Speech Gate (>400ms) DISCARDS: Backchannels ("mhm", cough) 4. SEMANTIC CONTENT FILTER Token Meaning Check (e.g. "wait...") DISCARDS: Muted talk / non-interruption YIELD FLOOR (HALT AI REPLY)
Four filters in order. Each removes a class of error before the next must reason about it.

How to tell them apart

SignalSeparatesCost
Durationbackchannel from interruption — under ~400ms with no continuation is almost always a backchannelfree, and gets you a long way
Speaker verificationthe enrolled user from the TV, a bystander, and the AI's own voicea small model, and the highest-value one here
Echo cancellationself-echo from everything else, in the signal before any decision is madeplatform AEC, one flag
Semantic content"mhm" from "wait, no" — needs the words a transcription hop, so it arrives late; use it to correct, not to gate
The order that works Cancel echo in the signal, verify the speaker before deciding, gate backchannels on duration, and use semantics only to correct a decision already made. Each layer removes a class of error before the next one has to reason about it — and the layers are cheap in that order, expensive in reverse.
Test that actually finds this Run your agent on a speakerphone in a hard-walled room with a television on in the background, and have a second person occasionally say something to you rather than to the agent. Every one of the four events happens within a minute. Headphones in a quiet office test none of them.

05Level 3 — The superharness

HawkTalkLive. The AI stops being a participant in a conversation and starts being the thing running the session. → HawkTalkLive reference

The core is many channels at once, reconciled. Not one stream carrying everything in sequence — independent lanes that run concurrently and are made consistent with each other. The harness is ephemeral: spun up for a task, given its channels, torn down when done.

ChannelCarriesSpotBot uses it for
audiospeech in and out, visemes, VADcoaching you through the rep
texttoken deltasthe on-screen caption and set counter
toolsfunction-call argumentslogging the set, adjusting tomorrow's plan
thinkingprivate reasoning, never spokendeciding your form is degrading before it says so
asyncslow work that lands latea 40s deep analysis of the whole session
# spotbot/harness.py — one session, five lanes, running at once
harness = hawktalk.live.session(
    model="auto",
    channels=["audio", "text", "tools", "thinking", "async"],
    ephemeral=True,
)

@harness.on("thinking")                # private. the user never hears this.
def _(ev):
    if ev.confidence < 0.6:
        harness.audio.hedge()          # soften delivery, don't stop talking

@harness.on("tools")                   # fires WHILE it is still speaking
def _(call):
    if call.name == "log_set":
        db.write(call.args)
        harness.tools.result(call.id, {"ok": True})

@harness.on("async")                   # lands 40s later, mid-conversation
def _(report):
    harness.audio.interject(f"Your left side is lagging — {report.detail}")

harness.start()   # audio flows immediately; the rest arrive when they arrive
// spotbot/harness.ts — one session, five lanes, running at once
const harness = hawktalk.live.session({
  model: "auto",
  channels: ["audio", "text", "tools", "thinking", "async"],
  ephemeral: true,
});

harness.on("thinking", ev => {          // private. the user never hears this.
  if (ev.confidence < 0.6) harness.audio.hedge();
});

harness.on("tools", async call => {     // fires WHILE it is still speaking
  if (call.name === "log_set") {
    await db.write(call.args);
    harness.tools.result(call.id, { ok: true });
  }
});

harness.on("async", report => {         // lands 40s later, mid-conversation
  harness.audio.interject(`Your left side is lagging — ${report.detail}`);
});

harness.start();  // audio flows immediately; the rest arrive when they arrive
// spotbot/harness.dart — one session, five lanes, running at once
final harness = HawkTalkLive.session(
  model: 'auto',
  channels: const ['audio', 'text', 'tools', 'thinking', 'async'],
  ephemeral: true,
);

harness.on('thinking', (ev) {           // private. the user never hears this.
  if (ev.confidence < 0.6) harness.audio.hedge();
});

harness.on('tools', (call) async {      // fires WHILE it is still speaking
  if (call.name == 'log_set') {
    await db.write(call.args);
    harness.tools.result(call.id, {'ok': true});
  }
});

harness.on('async', (report) {          // lands 40s later, mid-conversation
  harness.audio.interject('Your left side is lagging — ${report.detail}');
});

await harness.start();  // audio flows immediately; the rest arrive later
// spotbot/harness.rs — one session, five lanes, running at once
let harness = hawktalk::live::session(SessionCfg {
    model: "auto",
    channels: &["audio", "text", "tools", "thinking", "async"],
    ephemeral: true,
}).await?;

harness.on_thinking(|ev| {              // private. the user never hears this.
    if ev.confidence < 0.6 { harness.audio().hedge(); }
});

harness.on_tools(|call| async move {    // fires WHILE it is still speaking
    if call.name == "log_set" {
        db.write(&call.args).await?;
        harness.tools().result(&call.id, json!({"ok": true})).await?;
    }
    Ok(())
});

harness.on_async(|report| {             // lands 40s later, mid-conversation
    harness.audio().interject(&format!("Your left side is lagging — {}", report.detail));
});

harness.start().await?;  // audio flows immediately; the rest arrive later
// spotbot/harness.go — one session, five lanes, running at once
harness, err := live.Session(live.Config{
    Model:     "auto",
    Channels:  []string{"audio", "text", "tools", "thinking", "async"},
    Ephemeral: true,
})
if err != nil { return err }

harness.OnThinking(func(ev live.Thinking) {   // private. user never hears this.
    if ev.Confidence < 0.6 { harness.Audio().Hedge() }
})

harness.OnTools(func(call live.ToolCall) {    // fires WHILE it is still speaking
    if call.Name == "log_set" {
        db.Write(call.Args)
        harness.Tools().Result(call.ID, map[string]any{"ok": true})
    }
})

harness.OnAsync(func(r live.Report) {         // lands 40s later, mid-conversation
    harness.Audio().Interject("Your left side is lagging — " + r.Detail)
})

harness.Start()  // audio flows immediately; the rest arrive when they arrive
DIAGRAM 2: LEVEL 3: SIXTY SECONDS OF ONE SESSION Five concurrent context lanes enable fluid background actions and tool usage during active playout. audio text tools thinking async 0s 10s 20s 30s 40s 50s 60s form degrading - hedge delivery log_set(bench, 8, 80kg) deep session analysis interjects speaking, deciding and acting at once At level 2 every one of these is a pause in the conversation.
Sixty seconds of one session. At 22s the audio, tools and thinking lanes are all live.
What this buys that level 2 cannot SpotBot is talking you through rep seven while — at the same time — deciding privately that your form is going, writing set six to the database, and waiting on a 40-second analysis that will interrupt with something useful when it lands. None of those block the others. At level 2 every one of those is a pause in the conversation.
Reconciliation is the hard part Concurrency is easy to start and hard to keep honest. If the async report contradicts what the audio channel already said, you must have a rule for who wins — decided up front, not improvised. The channels are independent; the session's account of reality is not. This is the discipline that separates a superharness from four race conditions in a trenchcoat.
Part three

Everything else that decides whether it works

Function calling, the latency budget, cost, evaluation, and the graveyard of things that were measured and did not pay. Cross-cutting — these bite at every level.

06Function calling is the most important thing your AI does

Everything else on this page — the voice, the latency, the channels — is delivery. Function calling is the part where the model stops describing the world and starts changing it. It is the building block of agentic work, and every agent you will ever ship is a stack of tool calls that either held or did not.

Which is why it is first. A voice agent that sounds wonderful and calls the wrong function is worse than one that sounds robotic and calls the right one — the first will be trusted with something that matters.

The line that decides everything downstream If you are scraping tool calls out of model prose with a regex, you do not have function calling. You have a parser that happens to work on the outputs you have seen so far. Everything you build on top of it inherits that.

The pattern is everywhere: prompt the model to emit something like <tool>get_weather(city="Denver")</tool>, then pull it back out with a pattern match. It demos beautifully. It fails in production for reasons that have nothing to do with model quality:

FailureWhy it happens
A stray quote or newline eats the call The model was never constrained to your format. It was asked. Asking is not a contract.
Markdown fences appear from nowhere Instruction-tuned models like formatting. Your regex does not.
Parallel calls collapse into one Free text has no structure for "two of these." You end up inventing a delimiter and then defending it.
It gets worse with unusual phrasing Failure correlates with the user being interesting, which is exactly when the call mattered.
You cannot test it You are testing your pattern against outputs you have already seen — not against the space of outputs that exist.
The tell you have already lost If you have written a "salvage" or "repair" function for malformed tool calls, stop. That function is an admission that no contract exists. Every hour spent hardening it is an hour spent making a guess more elaborate.
SpotBot, at every level

The coach's whole job is log_set(exercise, reps, weight). Get the weight wrong and the training plan is wrong — silently, for weeks. That call is the product; the voice around it is how the product is delivered. Which is why this section comes before the levels: SpotBot with perfect audio and a broken log_set is worthless, and SpotBot with robotic audio and a correct one is a training log.

Why voice makes this fatal instead of annoying

In text, a broken tool call is survivable. The user sees something odd, scrolls back, retries. You log the raw output and fix it tomorrow. The interaction has a record and the record is repairable.

Voice has none of that. It is ephemeral by construction, and that changes the cost of every failure:

In textIn voice
The user sees the malformed output and knows something broke The user hears silence, or a confident wrong answer, with no indication anything failed
Scrollback is the log — you can both point at it The user's memory is the only record, and it is not a log
A silent retry costs nothing A retry is a 600ms hole in a conversation with no latency budget left to spend
"Try rephrasing that" is a mild ask "Try rephrasing that" is the product telling the user it is broken
The failure is visible and therefore fixable The failure is invisible and therefore permanent

A voice turn happens once and is gone. There is no scrollback to inspect, no message to edit, no place to surface an error that does not also destroy the conversation. The contract has to hold on the first attempt, every attempt, because there is no second one that the user will not feel.

The three layers that actually work

# WRONG — a wish and a pattern match
m = re.search(r'<tool>(\w+)\((.*?)\)</tool>', reply)
if m:
    name, args = m.group(1), parse_args_somehow(m.group(2))   # good luck

# RIGHT — a typed object the API is contractually obliged to return
for call in resp.choices[0].message.tool_calls:   # no parsing. no salvage path.
    result = TOOLS[call.function.name](**json.loads(call.function.arguments))
    session.tools.result(call.id, result)
// WRONG — a wish and a pattern match
const m = /<tool>(\w+)\((.*?)\)<\/tool>/.exec(reply);
if (m) { const [, name, raw] = m; parseArgsSomehow(raw); }   // good luck

// RIGHT — a typed object the API is contractually obliged to return
for (const call of resp.choices[0].message.tool_calls ?? []) {
  const result = await TOOLS[call.function.name](JSON.parse(call.function.arguments));
  session.tools.result(call.id, result);
}
// WRONG — a wish and a pattern match
final m = RegExp(r'<tool>(\w+)\((.*?)\)</tool>').firstMatch(reply);
if (m != null) parseArgsSomehow(m.group(2)!);                // good luck

// RIGHT — a typed object the API is contractually obliged to return
for (final call in resp.choices.first.message.toolCalls ?? []) {
  final result = await tools[call.function.name]!(jsonDecode(call.function.arguments));
  session.tools.result(call.id, result);
}
// WRONG — a wish and a pattern match
let re = Regex::new(r"<tool>(\w+)\((.*?)\)</tool>")?;
if let Some(c) = re.captures(&reply) { parse_args_somehow(&c[2]); }  // good luck

// RIGHT — a typed object the API is contractually obliged to return
for call in resp.choices[0].message.tool_calls.iter().flatten() {
    let args: serde_json::Value = serde_json::from_str(&call.function.arguments)?;
    let result = dispatch(&call.function.name, args).await?;
    session.tools_result(&call.id, result).await?;
}
// WRONG — a wish and a pattern match
m := regexp.MustCompile(`<tool>(\w+)\((.*?)\)</tool>`).FindStringSubmatch(reply)
if m != nil { parseArgsSomehow(m[2]) }                       // good luck

// RIGHT — a typed object the API is contractually obliged to return
for _, call := range resp.Choices[0].Message.ToolCalls {
    var args map[string]any
    json.Unmarshal([]byte(call.Function.Arguments), &args)
    result, err := Tools[call.Function.Name](args)
    if err != nil { return err }
    session.ToolResult(call.ID, result)
}
LayerWhat it guarantees
1 · Native tool_calls The transport returns a typed object with a name, typed arguments and an id. There is nothing to parse, so there is nothing to parse wrong.
2 · Grammar-constrained decode A GBNF grammar makes an invalid token unreachable at each slot. The model cannot emit malformed JSON because malformed JSON is not in the sampling space.
3 · Negative-case training The model must decline to call anything when no tool applies. This is the failure that costs real money, and it is the one prompting never fixes.
What each layer is worth, measured On a 42-case suite across 7 categories, greedy, against a frontier ceiling: raw 71.4% → grammar-constrained 78.6% → tuned + constrained 92.9% with the cloud ceiling at 100%. Constraining the decode alone moved parallel calls from 50% to 83% — no retraining, just removing the ability to be malformed. And negative cases score 100%: it does not invent tool calls when no tool applies, which in production is the failure that actually bills you.

The gap to frontier here is a format gap, not a brains gap. That is worth internalising, because it means reliability is an engineering choice rather than a model-budget problem. Constrain the decode, take the typed object, and the whole class of failure disappears — on a small model, on-device, at 40ms.

07Amateur latency smells

You can usually diagnose a voice product in thirty seconds of using it, without seeing a line of its code. These are the tells, what each one actually means, and what the fix is.

The smellWhat it actually isThe fix
Long silence, then a whole paragraph at once A thinking model sitting on the voice chain. The user is waiting through chain-of-thought that was never meant to be heard. Deliberation comes off the main chain entirely — a separate call, or the async channel. The voice path stays on the fast tier.
Longer answers take longer to start Not streaming. The whole response is generated, then synthesized, then played. Stream tokens into TTS. Time-to-first-audio must be independent of answer length.
The pause before every reply is identical A hardcoded VAD silence timeout doing the job of endpointing. Adaptive endpointing. A fixed 800ms wall is 800ms of dead air on every single turn.
Fine alone, sluggish with users on it Batch-1 serving with a queue behind it. Continuous batching. Concurrency should cost throughput, not first-audio.
Turn one is snappy, turn nine is not Re-prefilling the whole conversation every turn. KV reuse and prefix caching. Turn latency should be flat across a session.
You interrupt and it keeps talking for a beat Server-side cancel with no client-side buffer flush. Drop queued audio locally the instant speech is detected. Cancelling is the easy half.
Tool calls create dead air The conversation halts while a function runs. Speak through it, or speculate through it. Silence is the one thing a voice product cannot afford.
Everything routes to the biggest model No router. You are paying frontier latency to say "mhm." Route per utterance. Most turns do not need the expensive rung.
The vendor quotes total turn time They have not optimised first-audio, so they are benchmarking the number that flatters them. Ask for TTFA p50 and p95 under load. That is the number the user feels.
The one that matters most A reasoning model in the voice path is the amateur signature. It is the most common architectural mistake in the category and it is completely avoidable. Voice has a hard budget; thinking does not fit in it. Deliberation belongs on a different call, on a different channel, on a different clock — and the answer gets regrounded against it rather than waiting on it.
SpotBot, when latency goes wrong

You finish rep eight and ask "was that too fast?". At the floor you get an answer as you are racking the bar — useless, the moment passed. Coaching has a deadline the way a conversation does not. The latency budget is not about feeling snappy; it is about whether the answer arrives while it can still change what you do.

The three standards

TargetWhat it takes
The floor 1.5–3s felt reply What most shipped products do. Cascaded, unoptimised, thinking on-chain.
Silver — local ~40ms TTFT, sub-200ms felt reply OuroLive on-device. Model resident on the NPU, no network in the loop, no prefill to pay. This is the bar, and it is measured, not projected.
Gold — cloud that feels local ~50ms to first audio Pre-cached openings, speculative drafting before end-of-turn, and regrounding at commit. See below — this is the interesting one.

Silver is the honest bar because it removes every excuse. A local model on an NPU answers in 40ms because nothing is in the way — no network, no queue, no cold prefill. Once you have felt that, a 1.2-second cloud reply stops being "the cost of doing business" and starts being what it is: unoptimised.

Gold — how a cloud reply hits 50ms

Three mechanisms, and the third is the one that keeps it honest.

user still speaking ──┬──▶ partial transcript ──▶ speculative draft (continuous) │ └──▶ retrieval / tools warm in parallel │ end of turn ────────────────────┤ ├──▶ precached opening plays at ~50ms └──▶ reground: verify draft vs final transcript + truth │ accept · patch · discard │ ▼ real reply streams in under the opening

Pre-cached beginnings. Openings are predictable and prosodically neutral — "Right—", "So on that,", "Let me check." Render a library of them ahead of time and start playing one immediately at end-of-turn. Audio begins in ~50ms because nothing is being generated; you are buying 300–600ms of cover for the real answer forming behind it. Keep enough variety that it never sounds canned, and keep them neutral enough to lead into any continuation.

Speculative drafting before the reply is called for. Do not wait for the endpoint to start working. Draft the likely reply continuously against the partial transcript while the user is still talking, and warm retrieval and tools alongside it. Most turns are predictable enough that by the time they stop, the answer is largely formed. You are spending idle compute during a window that was previously dead.

Regrounding at commit — the part that stops this being a liar. Drafting early risks answering the question you predicted instead of the one that was asked. So the draft is never authoritative. At end-of-turn you verify it against the completed transcript and against retrieved truth, then accept, patch, or discard it. Speculation buys latency; regrounding buys correctness. Ship one without the other and you have built something fast and confidently wrong, which is worse than slow.

DIAGRAM 1: TWO CLOCKS: HOW A CLOUD REPLY STARTS IN 50ms Speculative parallel computation hides the round-trip regrounding delay from the user. 0ms 200ms 400ms 600ms 800ms 1000ms 1200ms 1400ms END OF TURN WHAT USER HEARS Silence (User Speaking & System Speculating) pre-cached opening (~50ms in) real reply streams... Gap covered by pre-cache WHAT SYSTEM DOES speculative draft against partial transcript retrieval & tools warming REGROUND: verify draft vs final transcript & truth accept patch discard Speculation buys latency. Regrounding buys correctness. You need both.
Two clocks. The bottom track works during the silence on the top track.
The rule underneath all of it Latency is not something you optimise at the end. It is a consequence of where you put the thinking. Keep the voice chain on the fast tier, move deliberation onto another clock, cover the gap with audio you already have, and reground before you commit. Every product that feels fast does some version of these four things; every product that feels slow skipped them and is hoping a better model will save it.

08The latency budget, decomposed

Where the milliseconds actually go, and which ones you can get back. All figures measured on one serving configuration; treat the shape as transferable and the absolute numbers as ours, not yours.

The distinction almost nobody makes Users perceive time-to-first-audio, not turn completion. A three-second answer that starts speaking at 500ms feels fast. A 1.2-second answer that starts at 1.2s feels slow. Optimise TTFA and the total barely matters; optimise total and you will work hard for something nobody notices.
StageMeasuredNotes
STT436ms warm TTFT · 375ms p95 audio tower folded into the model, resident on the accelerator. Flat from 1 to 16 concurrent seats — that flatness matters more than the number.
LLM536ms TTFT · 29–70 tok/s fast enough that it is not the bottleneck, which surprises people.
TTS — fast tierRTF 0.175 Matcha on CPU. Comfortably realtime with headroom.
TTS — balancedRTF 1.31 Kokoro. Real-time-ish, better voices, visemes for lip-sync.
TTS — expressiveRTF 0.52× realtime Orpheus-class. Not shippable at this speed; see the graveyard.
Felt first audio ~590ms p50 at a 12-seat load, inside the 450–600ms cascade floor the good products sit in.
On-device, whole turn~40ms TTFT · sub-200ms felt no network, no queue, no cold prefill. The bar that removes every excuse.
THE LATENCY BUDGET: TIME-TO-FIRST-AUDIO (TTFA) Users perceive the start of voice output (TTFA), not the time to complete generation. 0ms 500ms 1000ms 1500ms 2000ms (Turn Limit) STT: 436ms LLM TTFT: +100ms (536ms) TTS First Audio: +40ms (576ms) Streaming Audio Playback (continues to ~1800ms) FELT FIRST AUDIO: ~590ms (p50) Turn Complete (1800ms) AI speaks early while LLM synthesizes remainder of the turn
One turn, stage by stage. Felt first audio lands well before turn completion.

What the old serial cascade cost, and what changed

An earlier configuration measured 669.9ms STT + 656.2ms chat + 1449.5ms TTS = 2775.6ms per turn, with TTS at 52% of the budget. Two changes moved it: folding STT into the model on the accelerator removed the separate transcription hop entirely (42.6× faster than host-CPU decode), and picking the right TTS tier rather than the best-sounding one took synthesis off the critical path.

Read this before you optimise anything The bottleneck was never the language model. It was the CPU-bound voice stages and the hops between them. Every hour spent on a faster LLM before moving STT and TTS off the host CPU is an hour spent on the 20%.

Where you can actually get time back

LeverTypical gainCost to you
Fold STT into the modelremoves a whole hop needs a model with an audio tower
Pick the right TTS tiercan be most of the budget the expressive voice is usually not worth the seconds
Stream tokens into TTSmakes TTFA independent of length a day of plumbing
Pre-cached openingaudio starts at ~50ms needs regrounding or it lies — see section on standards
Route small turns to a small modelTTFT scales with model size a router
A bigger, better LLMusually nothing it was not the bottleneck

09Amateur cost smells

The same diagnostic as section 02, on the other axis. These are the tells that a voice product is paying several times what it needs to — and unlike latency, nobody notices until the invoice arrives.

The smellWhat it actually isThe fix
One model id hardcoded everywhere You are paying frontier rates to say "mhm." Most turns in a real conversation are acknowledgements and short answers. Route per utterance. Let a small model take the turns that do not need a big one.
Tool schemas in every request A realistic tool set is 700–1600 tokens. At forty turns a session, re-sent every turn, that is most of your token spend buying nothing. Declare tools once per session. This is usually the single biggest line item.
A realtime socket for a walkie-talkie A session bills for its duration; a request bills for the turn. Dictation and ordering do not need interruption. Use the request API. This one is structural — you cannot optimise the wrong rail.
The system prompt has the user's name in it A per-user prefix cannot be cache-shared, so every request pays full prefill. Static prefix, user specifics retrieved into the turn.
Overnight jobs on the interactive tier You are paying for latency nobody is waiting on. Mark it batch. Interruptible work belongs on interruptible capacity.
The whole document in every prompt Context is billed per turn, forever, whether or not it was relevant. Embed once, retrieve the relevant few hundred tokens per turn.
Sessions provisioned at the highest tier they might need Paying peak for the mean. One hard question in a ten-minute call should not price the whole call. Start cheap, escalate for the turns that need it, drop back down.
A reasoning model answering the user directly Both smells at once — the user waits, and you pay for tokens they never hear. Fast tier answers; deliberation runs on another clock and is folded in.
Nobody can say which tier answered You have no per-turn cost attribution, so every optimisation is a guess. Log the trace id on every request before changing anything else.
SpotBot, on the invoice

A session is forty turns and most of them are "good", "next", "one more", "how many left". Pin a large model and you pay frontier rates for every one of those. Re-send an eight-tool schema each turn and you have billed 56,000 tokens of definitions the model already had. Neither changes a single word SpotBot says.

DIAGRAM 3: WHERE THE TOKENS ACTUALLY GO Standard API design resends identical tool definitions on every single turn, bloating request sizes. STANDARD TURN (Tools declared per-turn): 2470 tokens Prefix Tool Schemas (API Definitions): 1400 tokens Context User Output: 420 220 300 130 identical on every turn — billed on every turn AFTER DECLARING TOOLS ONCE AT SESSION LEVEL: 1070 tokens (Red segment removed) Prefix Context User Output: 420 220 300 130 420 40,000 turns/day = 1.68 billion tokens of definitions the model already had. Eliminated by persistent session-level caching. Fixing this changes nothing about what the product says.
One turn, drawn to scale. The red block is identical on every turn and billed every turn.
The two that are usually most of the bill Tool schemas re-sent every turn, and a large model pinned for every turn. In a typical tool-using voice agent those two together are commonly the majority of spend, and neither changes a single thing about what the product does. Fix them before you negotiate a rate with anyone.
Do it in this order Measure, then fix the arithmetic, then fix the judgement. Start by logging trace ids so you know where the money goes. Then fix the things that are pure arithmetic — schemas re-sent, context re-pasted, batch work on the interactive tier — because those savings are certain. Only then tune routing, which depends on your traffic mix and needs the measurements you just started collecting.

10How to evaluate a voice agent

There is no standard for this and everyone is guessing. Word error rate is necessary and nowhere near sufficient — it measures one stage of a pipeline whose failures mostly happen between stages. Here is a metric set worth arguing with.

MetricWhat it catchesHow to measure
Cut-off rate endpointing firing while the user is still talking — the complaint people phrase as "it doesn't listen" % of turns where the transcript ends mid-clause or the user immediately repeats themselves
Dead-air endpointing firing too late; the silence before every reply median ms from true end-of-speech to first audio out
TTFA p50 / p95 under load the number the user feels, and whether it survives concurrency at your target seat count, never on an idle box. p95 is the honest one.
False barge-in rate stopping for the TV, a cough, or its own echo interruptions triggered per minute in a room with background audio and nobody addressing the agent
Missed barge-in rate ignoring a genuine interruption — worse than a false one, because the user knows % of scripted interruptions where the agent kept speaking past 300ms
Tool: valid formatmalformed calls must be 100%. Anything less means no contract exists.
Tool: right toolchoosing correctly among candidates % correct on an ambiguous-by-design set, not on easy cases
Tool: exact argsthe values that carry the consequences tool and arguments both correct
Negative rate inventing a call when no tool applies The one that costs real money in production. Feed it turns where the answer is "just talk" and count the calls it makes anyway.
Task completionwhether the user got what they came for end-to-end, judged, on scripted scenarios — the only metric that is actually the product
Coherence over N turnsdrift, repetition, echo loops long scripted sessions, judged. Ours ran 32/32 coherent on one tier and 26/32 on another — the difference was invisible in single-turn tests.
WERtranscription only report it, do not lead with it. A perfect transcript with a wrong tool call is a failed turn.
Three rules that decide whether the numbers mean anything Measure under load — an unloaded p95 is marketing. Measure on a speakerphone — headphones hide echo, false barge-in and every room-acoustic failure. Publish your negatives — a suite where everything passes is a suite that is too easy, and you learn nothing from it.
The cheapest useful eval you can build this week Twenty scripted scenarios. Each one run on a speakerphone with a TV on, at your target concurrency, with a scripted interruption at a known timestamp. Record cut-off rate, TTFA p95, false and missed barge-in, and tool exactness. That is an afternoon of setup and it will find more than a month of listening to your own demo.

11The graveyard

Things that sounded right, got built, got measured, and did not work. Every one of these cost real time. Published because a catalogue of only wins is a brochure, and because negatives are the part nobody else will tell you.

The ideaWhat happenedWhat to do instead
Per-token retrieval blending
mix retrieved neighbours into the logits at each step
Fired 40 times, corrected zero. Inert exactly when the model was confidently wrong, which is the case you needed it for. Retrieved-prefix grounding. Same information, measured +40pp, and it works because it changes what the model sees rather than nudging what it says.
Speculative drafters on a static accelerator Two parallel KV caches on a chip with fixed compiled graphs. A memory problem for a gain that was not the bottleneck anyway. Nothing. The LLM was not the constraint — the voice stages were.
Self-speculative early exit Exit-layer token agreement 0.168 against a draft cost fraction of 0.534. It costs more than it saves. Drop it. Published because a measured negative is worth as much as a win.
Layer-pruned "turbo" models Faster, and instruction-following destroyed. Fluent, obedient to nothing. Depth cuts as latency probes, not as shipping models.
Quantising a 270M model to 4-bit Tool matching fell from 25/25 to 4/25. A 270M has no weight redundancy left to give. fp16 and accept the size. Below it, a deterministic rule parser — never a smaller model.
fp8 / int8 to make TTS realtime fp8 would not compile on the stack at all; int8 gave zero decode speedup — which disproved the bandwidth theory it was based on. The bind was all-reduce latency across two devices. Fewer devices, not smaller numbers.
Emotion tags into an expressive TTS The "prosody responds" result had been measured on a mock. The real compiled graph ignores prosody fields and breaks on tags. Ship emotion detection, which works. Emotion voice needs trained latent tokens, not markup.
Vocab-trimming the router Smaller and rejected — trimming remaps token ids and breaks token-space alignment with the tiers it routes to, which is what made handoff free. Trim the whole family to one shared vocabulary, or none of it.
Distilling a mandate router 83.5% in-eval, 47.3% held out. It memorised the templates. Disjoint entities are not enough — held-out rows sharing templates with training rows will flatter you.
Z-normalising audio for emotion parity Falsified. Normalising biased every input toward one class. Raw amplitude with tile-fill. And re-check the "obvious" preprocessing step.
The smallest speech-to-text model Measurably mishears domain-specific phrases — exactly the words your product exists to hear. Pay the extra memory for the next size up. Fallback only, never default.
The pattern in nine of these eleven The mechanism was sound and the measurement was against the wrong baseline, on the wrong hardware, or on a mock. Almost none of these failed because the idea was stupid. They failed because something in the harness was lying, and nobody checked until after the work was done. Build the measurement before you build the thing.

12Picking your level

Level 1Level 2Level 3
TransportRESTWebSocketWebSocket, multiplexed
Endpointingclient, accumulative VADserverserver + affect
Echoduck, or mute if prototypingduckduck + speaker handling
Interruptionnonebarge-inbarge-in + interjection
Concurrencyone turnone lanefive lanes
Build timea daya weeka project
Right fordictation, ordering, logging, forms tutoring, support, companionship coaching under load, ops copilots, anything supervising a live process
Honest status Levels 1 and 2 are served today on POST /v1/generate and wss /v1/realtime. The level 3 thinking and async channels are published API design; server support is in progress. We would rather tell you that than have you find out in production.

13HawkTalkLive is the EC2 for voice AI

Everything above describes one session. The reason HawkTalkLive is a platform and not an endpoint is what happens when you have ten thousand of them.

EC2 was never "a server you rent." It was an API for managing compute as a fleet — launch, resize, monitor, place, reclaim. The machine was the commodity; the control plane was the product. Voice AI is at exactly the stage compute was before that idea existed: everyone is selling you one session at a time, and nobody is selling you the thing that runs ten thousand of them.

HawkTalkLive manages both directions. It carries the user-to-model conversation, and because it lives on the cloud side it also manages the fleet of those conversations — placement, tiering, capacity, spend and lifecycle — through the same API.

EC2 conceptHawkTalkLive equivalentWhat it does
instancevoice sessionthe unit you provision, meter and reclaim
instance typetier — quick · dank · thinkcapability class, priced accordingly
launch / terminatesession.start / ephemeral teardownsessions are cattle, not pets
resize a running instancesession.updatere-point a live call at a different model without dropping it
placement / schedulerthe router, per utterancepicks the cheapest tier that clears the bar
autoscaling groupseats per box, replicated data-parallel12 seats measured per node, ~144 dense
CloudWatchtrace_id on every turnfull audit of tokens, tool calls, channel activity
spot vs on-demandpriority: batchinterruptible work at interruptible prices
security groupsplan entitlement clampa key can never be routed above what it bought
AMIthe model registrysame id resolves to whatever silicon the node has
The mapping that carries the whole pitch Resizing a running instance. Every conglomerate realtime API is one model per session — you choose at connect time and you live with it. session.update re-points a live socket at a different brain mid-conversation without dropping the call. That is the elastic in elastic compute, and applied to voice it means you can start every call on the cheap tier and only pay for the expensive one during the ninety seconds that actually needed it.

What fleet management means when the fleet is conversations

You want toThe platform does
cap what a customer can spendentitlement clamp applied after routing, never a suggestion to the model
survive a traffic spikebias the router down a rung under load — "good enough now" beats "better in four seconds"
know why a call cost what it costper-turn trace: which tier answered, which tool ran, which channel was open
run 10k sessions on fixed siliconcontinuous batching plus KV paging, seats as the unit of capacity planning
degrade instead of failingthe ladder has rungs; a saturated top tier steps down rather than erroring
reclaim a wedged sessionephemeral by construction — sessions have a lifecycle, not just a socket

None of that is available to you if your voice AI is an endpoint you post to. It is only available if the thing running your sessions is designed to run a fleet of them, and exposes that fleet to you. That is the difference between renting a machine and having a control plane.

Honest status The session-level primitives are live — tiering, mid-session model switching, trace_id on every response, entitlement clamping, and measured capacity at 12 seats per node. The fleet-level API surface — enumerate sessions, set autoscaling policy, query capacity programmatically — is in build. The architecture assumes it; the endpoints are not published yet.