Latency Budget to First Audio
Also known as: time to first byte of speech, perceived latency, TTFA
Treat time-to-first-audible-syllable as the product metric, and engineer every stage against an explicit allocation.
The problem
Voice teams optimize total response time, which is the wrong number. What a caller experiences is the silence between when they stop speaking and when they hear something — after that, generation speed barely matters as long as it keeps up with playback. Optimizing the wrong metric leads to work on the LLM's throughput while a 400ms endpointing threshold and a non-streaming TTS call go unexamined.
The pattern
Define time-to-first-audio as the primary SLO, allocate an explicit millisecond budget to every stage in the chain, and instrument each stage separately so a regression is attributable. Then apply the standard levers in the order they pay: stream everything, overlap stages speculatively, warm connections, and — where a stage genuinely cannot be made fast — cover it with speech rather than silence.
Use when
- Any real-time voice product, especially telephony where callers expect phone-call timing
- Perceived responsiveness is a differentiator or a source of complaints
- The pipeline has more than two stages and regressions are hard to attribute
- Some turns require slow tool calls that blow through the budget
Avoid when
- Asynchronous or recorded voice, where nobody is waiting in real time
- Push-to-talk interfaces where users already expect a processing pause
- Very early prototypes — get the conversation working before you optimize its timing
Tradeoffs
GainA clear metric everyone can optimize against, with per-stage attribution
CostInstrumentation at every boundary, and an SLO someone has to own
GainSpeculative execution can remove an entire stage from the critical path
CostWasted compute on speculations that are discarded when the user keeps talking
GainFiller speech makes an unavoidable 3-second tool call tolerable
CostFillers used reflexively become a verbal tic users notice and dislike
The number to hold in mind: in natural conversation, the gap between turns averages around 200ms, and people begin reading a gap as hesitation somewhere past 500ms. That is the bar. A 2-second pause is not slightly slow, it is a different kind of interaction.
Allocate, then instrument
Write the budget down as an object in code, emit a timing event at every boundary, and alert on the stages rather than the total. A p95 regression in TTS connection time looks identical to one in LLM time-to-first-token if all you track is the sum.
BUDGET_MS = {
"endpoint": 350, # adaptive silence detection
"stt_final": 100, # finalize the partial transcript
"llm_ttft": 350, # first token out of the model
"tts_ttfa": 120, # first audio chunk out of TTS
"transport": 100, # network + jitter buffer
} # total target: 1020 ms
class TurnTimer:
def __init__(self, turn_id: str):
self.turn_id, self.t0, self.marks = turn_id, time.perf_counter(), {}
def mark(self, stage: str) -> None:
elapsed = (time.perf_counter() - self.t0) * 1000
self.marks[stage] = elapsed - sum(self.marks.values())
metrics.histogram(f"voice.stage.{stage}", self.marks[stage])
if self.marks[stage] > BUDGET_MS[stage] * 1.5:
log.warning("stage over budget", extra={"stage": stage, "ms": self.marks[stage]})
def first_audio(self) -> None:
metrics.histogram("voice.ttfa", (time.perf_counter() - self.t0) * 1000)Speculative generation
The endpointing wait is dead time — the user has stopped, and you are only waiting to be sure. Use it: when speech pauses and the partial transcript looks complete, start generating against it immediately. If the user resumes, cancel and discard. If they do not, the model has a head start equal to the entire endpointing threshold, which is typically the largest single line in the budget.
async def on_speech_pause(self, partial: str) -> None:
if not looks_complete(partial):
return
self.speculation = asyncio.create_task(self.generate(partial))
async def on_speech_resumed(self) -> None:
if self.speculation:
self.speculation.cancel() # user kept talking; throw the work away
self.speculation = None
metrics.increment("voice.speculation.discarded")
async def on_endpoint(self, final: str) -> AsyncIterator[bytes]:
if self.speculation and normalized(final) == normalized(self.speculative_input):
metrics.increment("voice.speculation.hit")
return await self.speculation # entire endpoint window saved
if self.speculation:
self.speculation.cancel()
return await self.generate(final)Track speculation hit rate against wasted token spend. Above roughly 60% hits it is clearly worth it; below 30% you are burning money for little latency gain, and the endpointing heuristic is what needs fixing.
Covering unavoidable slowness
Some turns cannot be fast: a database lookup takes two seconds, and no pipeline work changes that. Cover it with speech rather than silence — but do so conditionally, only when the wait actually exceeds a threshold, and vary the phrasing. A filler on every turn becomes a tic; a filler on the 5% of turns that are genuinely slow reads as natural.
FILLERS = ["Let me check that.", "One moment.", "Looking that up now."]
async def with_filler(coro, threshold_s: float = 0.9):
task = asyncio.create_task(coro)
done, _ = await asyncio.wait({task}, timeout=threshold_s)
if not done:
await speak(random.choice(FILLERS)) # only when it is really slow
return await task- Time to first audio is the SLO; total response time is not the user's experience
- Instrument every boundary and alert per stage, never on the sum alone
- Speculate during the endpoint window — it is usually the biggest line in the budget
- Warm TTS and LLM connections at call start; cold TLS handshakes cost 100–200ms
- Use fillers conditionally on real slowness, and rotate the phrasing
Measure on the real transport. A pipeline that hits 600ms on localhost can land at 1.4 seconds over a PSTN leg with jitter buffering and codec transcoding. Benchmark end to end from an actual phone, not from your laptop.