Measuring Latency#
This page defines the latency terms used across the kiosk UI, the
GET /api/v1/pipeline/latest API, and make benchmark, and explains how
to read a benchmark run.
Use these terms as written. Do not introduce synonyms — the same span having two names in two places is what made earlier numbers incomparable.
Terms#
Term |
Definition |
|---|---|
Voice-to-voice latency |
Customer’s last word → first sound ready for the speaker |
Endpointing delay |
Customer’s last word → turn-end decision |
Processing latency |
Turn-end decision → first sound ready for the speaker |
Transcription latency |
Last word → final transcript available |
LLM TTFT |
Time to first token |
TTS time to first sentence |
Time until sentence 1’s synthesized audio WAV is on disk (not the first audio byte) |
The two parts reconcile exactly:
voice_to_voice_ms == endpointing_delay_ms + processing_latency_ms
The clock stops when the first answer audio is ready for the speaker —
the WAV is on disk and queued for playback. make benchmark is headless, so
this is the furthest point it can observe. In the live hands-free UI the
browser additionally reports when playback actually began, recorded
separately as voice_to_voice_speaker_ms.
“Time to first audio” is retired. It named different spans in different places. Use voice-to-voice latency, and state which of its two parts you mean.
The opener#
By default the kiosk plays a short cached acknowledgement (“One moment.”)
as soon as it decides the turn has ended, before the answer is ready. When
it does, the first sound ready for the speaker is the opener, so
voice_to_voice_ms measures the opener — which is a real thing the
customer hears, but it is not a measure of the pipeline.
Every turn therefore reports both, and says which it was:
Field |
Meaning |
|---|---|
|
To the first sound ready for the speaker, whatever it was |
|
To the first sound of the answer |
|
Whether the first sound was the opener |
|
Opener was enabled but could not be rendered, so the customer heard nothing until the answer |
Quote voice_to_voice_answer_ms when comparing pipeline changes. Quote
voice_to_voice_ms when describing what the customer experiences.
Disable the opener with KIOSK_CORE_OPENER_ENABLED=false to measure the
pipeline alone.
Host prerequisites#
Everything in this stack runs in Docker except the measurement harness,
which runs on the host: the orchestrator re-invokes the in-application harness
with sys.executable, so the host interpreter needs its own dependencies.
make benchmark handles this automatically. It builds a small venv
(.venv-benchmark, from tests/benchmarks/requirements.txt) and launches the
orchestrator with it, so the run no longer depends on what the system
python3 happens to have installed. Two things are checked before the stack
is started, by make check-benchmark-env:
Requirement |
Why |
If missing |
|---|---|---|
|
Default |
Installed into |
|
Resamples synthesised prompts to 16 kHz mono PCM, and locates the true end of speech via |
Install it yourself — not a PyPI package: |
ffmpeg is not optional plumbing. Besides resampling, true_end_of_speech_seconds()
runs silencedetect to find where the speaker actually stopped, from the
waveform — the zero point for voice-to-voice latency, deliberately independent
of the pipeline’s own VAD. Without it that function returns None and the
harness silently falls back to detector-based timing, i.e. the pipeline grades
its own endpointing and the numbers stop being comparable with other runs.
Both are verified up front deliberately. A missing host dependency is not a property of the pipeline being measured, and discovering it mid-run used to cost a full stack bring-up and a discarded warmup before surfacing as a report in which every turn carried the same error — which reads like a broken stack rather than a broken host.
Useful targets:
make check-benchmark-env # verify the host, start nothing
make install-host-deps # apt-get ffmpeg + python3-venv (uses sudo)
make benchmark-venv # (re)build .venv-benchmark
install-host-deps is opt-in and is never a prerequisite of another target:
nothing else in this repo installs system packages, so escalating to sudo
stays something you type rather than a side effect of make benchmark.
Service preconditions#
Once the stack is up, the harness also requires text-to-speech and
audio-analyzer to be healthy before the first turn. The orchestrator’s own
gate only covers kiosk-core, rag-service and ovms-llm, so a
crash-looping TTS container still prints All services healthy — and then
every turn fails identically on its first synth call. That check now raises an
environment error instead, naming the service and its health URL.
On a host with no PyPI access, V2V_UPLINK=rest make benchmark replays over
the legacy chunked-WAV transport, which is pure stdlib. It still needs
ffmpeg, and it measures a different uplink — never mix its numbers with
ws-uplink numbers in one comparison.
Reading a benchmark run#
make benchmark writes per-run artifacts to results/:
File |
Contents |
|---|---|
|
Per-turn latency rows |
|
Full per-turn traces and percentiles |
|
What actually ran, and whether the run completed |
|
First and last measured turn |
|
Hardware counters plus aggregate latency |
Report the median and p95, never the mean — the distribution has a long tail and the mean hides it.
The headline voice-to-voice number the benchmark prints is clip-anchored:
the customer’s last word is taken from the test clip itself (where speech ends
in the WAV), not from the kiosk’s own voice activity detector. Endpointing is
part of what is being measured, so the clip anchor is the honest customer-facing
figure. The detector-anchored number (voice_to_voice_ms, started at the
kiosk’s VAD) is printed below it only as a secondary cross-check. Live-mic runs
have no clip ground truth and fall back to the detector number as the headline.
run_manifest.json#
Records what the run actually used, not what was requested. Where the
orchestrator overrides a setting, both the effective value and a
*_requested value appear. status is "started" until the run
completes successfully, so a failed run cannot be mistaken for a good one
on the strength of leftover numbers from the previous run.
early_commit_count — check this first#
In a benchmark run the customer’s last word is taken from the test clip itself (where speech ends in the WAV), not from the kiosk’s own voice activity detector. Endpointing is part of what is being measured, so measuring it against its own decision would always report roughly zero.
Because that anchor is independent of the kiosk, the endpointing delay can come out negative. That means the turn was committed before the customer finished speaking — the transcript is truncated and the order was taken from a partial sentence.
Field |
Meaning |
|---|---|
|
Turns committed before the customer finished |
|
Turns with a usable clip anchor |
early_commit_count must be zero. A non-zero value is a failed run no
matter how good the latency percentiles look — cutting customers off
shortens every measured turn, so the numbers improve as the behaviour gets
worse.
Two settings keep it at zero:
KIOSK_CORE_ENDPOINT_STABLE_SECONDS— the turn is only committed once the transcript has stopped changing. While more speech is arriving the transcript keeps changing and the window resets, which is what distinguishes a mid-sentence pause from the end of a turn. A fixed silence threshold cannot tell those apart.KIOSK_CORE_ADAPTIVE_FLUSH_PAUSE_SECONDS— must not be set below0.50; shorter pauses are normal mid-sentence.
Run size#
A p95 over four turns is just the slowest of the four. The default
benchmark runs order-fastpath: six single-intent ordering turns (one
clean mutation each, ending in a confirm), all synthesised with one TTS
voice, repeated
V2V_RUNS times — 48 measured turns at the default V2V_RUNS=8, enough
for a stable median and a p95 that is not a single outlier. This is the
clean-ordering voice-to-voice number; quote it with the scenario named
next to it (order-fastpath), because it deliberately stays on the fast
path and is faster than a mixed workload.
For the broader workload — single items, multi-item orders, changes,
removals, confirmations, mid-sentence pauses and a price-question + upsell
journey — run make benchmark V2V_SCRIPT=all (88 measured turns per run)
and report that number under its own name (all). Warm-up turns are pruned
before the percentiles are computed in either case.
Hardware counters#
Counters are trimmed to the measured window before consolidation, so utilization reflects the turns rather than being diluted by the health wait, model warm-up and teardown.
kiosk-core runs as uid 1000. If results/ is not writable by that uid,
latency events cannot be recorded and the benchmark reports zero
transactions even though every turn succeeded. A warning is logged at
startup if this is the case.