Offline, reference-free evaluation of the audio experience of AI voice calls — how the agent sounds and behaves on the line, not what it said. It scores the audio bytes themselves, so it catches what transcript/tool-call evals can't: robotic synthesis, dropouts, sluggish replies, awkward silences, failed barge-in, and the #1 production failure — the agent going dead when background noise jams its turn detection.
Two ways to use it:
.mp3/.wav in, get per-clip
scores, an HTML report, Parquet/JSON, and pytest regression gates.Design principle: everything is reference-free / unsupervised. No per-clip ground-truth labeling anywhere in the core pipeline. The only human labeling is an optional ~30–50 clip calibration set, used once to validate the judge.
Scope: this repo scores the audio experience only. Information correctness and tool-call success are owned by your text/OTel evals. A scenario's
goalexists to make the simulated caller behave realistically (and get frustrated when mishandled), not to score task completion here.
| Layer | Question | Labels? | Network? | Scorers |
|---|---|---|---|---|
| A — Acoustic / perceptual | Did it sound good? Artifacts, noise, robotic TTS? | No | No | dnsmos nisqa utmos squim |
| B — Conversational dynamics | Slow? Awkward pauses? Did it freeze? Did barge-in work? | No (timestamps) | No | latency turn_taking barge_in stall |
| C — Audio LLM-judge | Natural prosody, tone, handling of an upset caller — as heard? | No to run; small set to trust | Yes (or self-host) | audio_judge (OpenAI / Gemini / local) |
Layers A and B run with zero API keys and zero network. Layer C is additive and
off by default (scorers.judge: []); enable it with a config profile + a key.
The report speaks in concepts a non-technical reader understands; the raw metric names below are what technical users debug with (surfaced only in expanders).
| Concept | What it measures | Raw metrics |
|---|---|---|
| Response speed | How fast the agent starts replying after the caller stops | latency_p50/p95/p99/max_s |
| Dead air / freezing | How often the agent goes silent and looks dead | stall_rate longest_stall_s n_no_response |
| Interruption handling | When the caller talks over it, does it stop and listen? | bargein_success_rate bargein_latency_* bargein_false_alarm_rate |
| Turn-taking rhythm | Natural back-and-forth, no awkward gaps / talk-over | max_gap_s overlap_ratio agent_talk_ratio |
| Voice quality | How clean/clear the voice sounds on the line | dnsmos_* utmos squim_* |
| Overall / Frustration / Sounds human / … | The judge's take, heard | judge_overall judge_frustration_handling … |
Python 3.11+ and ffmpeg on PATH. Base + [acoustic] + [dynamics] run with no
GPU and no API keys.
# with uv (recommended)
uv sync --extra acoustic --extra dynamics # local-only core
uv sync --extra telephony --extra judge-openai # + live calls + OpenAI judge
uv sync --all-extras # everything + dev/test
uv run voice-evals --help
# or with pip
pip install -e ".[acoustic,dynamics]"
pip install -e ".[all]"
Most model weights self-fetch on first use (UTMOS via torch.hub, SQUIM via torchaudio, silero on first run, DNSMOS bundled). NISQA has no clean PyPI package and is vendored once:
python scripts/fetch_models.py nisqa # clones NISQA + weights into .cache/models/
Secrets go in a gitignored .env (see .env.example) — never in YAML:
OPENAI_API_KEY=sk-... # OpenAI judge + the simulated caller (STT/LLM/TTS)
GEMINI_API_KEY=... # (alt judge)
TWILIO_ACCOUNT_SID=AC... # live telephony
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+1... # your Twilio caller-ID number
TWILIO_AGENT_NUMBER=+1... # the agent's number to dial (overridable with --to)
PUBLIC_STREAM_URL=wss://<tunnel>/caller # your ngrok wss → localhost:8080
cp /path/to/*.mp3 corpus/ # + optional <stem>.json sidecars
uv run voice-evals run --config config/example.local.yaml # Layers A+B, no network
open outputs/runs/<run_id>/report.html
example.local.yaml keeps the judge off. To score the same folder with the
native-audio judge — the same judge as the live eval set, just pointed at your
production recordings — enable it with a judge profile and a key (audio is sent to the
API, so this is explicit opt-in):
export OPENAI_API_KEY=sk-... # (or GEMINI_API_KEY) — or keep it in .env
uv run voice-evals run --corpus corpus/ --config config/judge.openai.yaml
open outputs/runs/<run_id>/report.html
Two judge knobs matter for production recordings:
judge.judged_channel — agent_only grades just the agent's voice (needs a
channel_map in the sidecar to isolate it); mono grades the whole call (caller +
agent — flow, frustration handling). judge.openai.yaml uses mono.judge.exclude_if_model_family_matches — if a recording's tts_provider is the
judge's own model family (e.g. an OpenAI-voiced agent graded by the OpenAI judge),
the judge is skipped to avoid self-enhancement bias; set it false to force it.
judge.openai.yaml sets it false.For Gemini instead of OpenAI, use a config with judge.backend: gemini,
judge.model: gemini-2.5-pro, and GEMINI_API_KEY set. --no-judge turns the judge
off on any run. (Dynamics like barge-in/stall need an event log or split channels,
so on bare recordings they stay sparse — see the channel question; the judge and acoustic layers work on any audio.)
# 1. Your agent is reachable at a phone number (deployed, or local dev behind a Twilio number).
# 2. Run ngrok → localhost:8080 and set PUBLIC_STREAM_URL to its wss URL in .env.
# 3. Smoke-test one call per scenario, then the full set:
uv run voice-evals call --scenarios scenarios/eval_set.yaml --config config/judge.openai.yaml \
-k 1 --agent-version openai-rt --preamble 35 # 7 calls
uv run voice-evals call --scenarios scenarios/eval_set.yaml --config config/judge.openai.yaml \
-k 3 --agent-version openai-rt --preamble 35 # 21 calls → one aggregated report
You set up no Twilio infrastructure — voice-evals call places the outbound
call via Twilio's REST API and embeds the media-stream websocket server (binds
127.0.0.1:8080 for the duration of the call). You just run ngrok pointed at 8080.
Other commands:
uv run voice-evals ingest --corpus corpus/ # decode/normalize only; warm the cache
uv run voice-evals simulate --scenarios scenarios/example.yaml --mock --baseline-version main # synthetic A/B, no telephony
uv run voice-evals report --run recordings/<ts>/report # regenerate report.html from run.json (no re-calling)
uv run voice-evals run --baseline previous --strict # exit 1 on any gate violation
uv run voice-evals calibrate --golden calibration/golden_set.csv
uv run voice-evals cache clear [--scorer dnsmos]
uv run voice-evals list-scorers
scenarios/eval_set.yaml is a curated set of hard callers, each a distinct
failure mode mined from real bad calls:
| Scenario | Stresses |
|---|---|
double_charge_impatient_barge | interruption handling |
loud_transit_hub_package_locker | noise → dead air / freezing |
rent_statement_confused_repeats | pacing, clarity, re-grounding |
mid_call_goal_pivot | conversation flow on a topic change |
simple_hours_cooperative_baseline | control (a healthy agent passes clean) |
hostile_cancel_threat_bad_review | frustration handling / emotional alignment |
noisy_cafe_barging_impatient_combo | combined worst case |
call runs -k calls per scenario (calls are stochastic — the caller LLM and the
real agent both vary — so gate on the aggregate, not one flap). Each call is a
distinct clip; scores roll up per scenario and overall into one report.
Tag each run with --agent-version so the baseline: previous regression gate diffs
one build (or vendor) against the last.
A scenario is a goal + persona + optional barge_in / background policy:
- id: noisy_cafe_barging_impatient_combo
goal: "get the front gate access code reset because the caller is locked out"
persona: { name: locked_out, style: "loud, clipped, impatient", patience: low,
emotional_arc: "flares every time the agent goes quiet", adversarial: true, voice: verse }
max_turns: 5
barge_in: { enabled: true, after_agent_s: 1.5, max_barge_ins: 3 } # caller talks OVER the agent
background: { enabled: true, gain_db: -16, voices: [onyx, fable] } # intelligible café chatter on the open mic
background: mixes synthesized intelligible voices continuously onto the caller's
(open) mic. This reproduces the top production failure: babble jams the agent's
endpoint detection so it can't tell the caller stopped, and it freezes — captured by
stall_rate / latency while the agent's own voice quality stays clean (the
babble is on the caller channel). gain_db: -20 ≈ realistic café; -14 is loud.
barge_in: makes the caller talk over the agent once it's spoken for after_agent_s.
The barge_in scorer measures whether the agent yields, how fast (stop-latency
p50/p95), the success rate, and false alarms — real barge-in over real telephony,
which half-duplex simulation can't see.
voice-evals call dials the agent (--to / TWILIO_AGENT_NUMBER) with TwiML
<Connect><Stream url=PUBLIC_STREAM_URL>, runs the persona caller full-duplex over
the μ-law/8kHz audio, records both legs + an event log, and scores it. What you run:
localhost:8080, with PUBLIC_STREAM_URL = its current wss://…/caller.The simulated caller is a cascade: STT of the agent's turns (gpt-4o-transcribe)
→ LLM reply (gpt-4o) → TTS (gpt-4o-mini-tts), streamed as μ-law. It hears and
responds to the agent — answers questions, escalates when stalled. --no-stt makes
it deaf (cheaper, pure barge-in timing). We deliberately kept the caller a controlled
cascade rather than an audio-to-audio model so barge-in timing stays deterministic and
runs stay reproducible; the caller is pluggable (CallerBot) if you want to swap it.
Many deployed agents play an IVR / greeting / recording caveat before the real agent
speaks. Pass --preamble N (or telephony.preamble_s) so the caller ignores the
agent's first N seconds for turn-taking (it's still recorded) and engages only with
the real agent. A deadlock guard opens the call if the greeting is missed.
| Symptom (Twilio console) | Cause | Fix |
|---|---|---|
31920 Stream – WebSocket – Handshake Error | Twilio can't reach PUBLIC_STREAM_URL | Start ngrok; make sure PUBLIC_STREAM_URL matches its current wss:// URL (ngrok-free rotates it each restart) |
Call completed, 0s, no recording | Agent answered then ended instantly (its webhook transfers/<Dial>s out instead of streaming) | Fix the agent to stay on the line and stream |
| Caller talks over hold music / disclaimer | The agent's preamble | --preamble N |
| Run "looks stuck" for minutes | A call whose stream never attaches used to wait out max_call_s (180s) | connect_timeout_s (default 20s) now aborts non-attaching calls fast |
voice-evals call writes each run to its own dated dir under recordings/<timestamp>/
(a durable library — never auto-wiped; delete by hand). One failed call is logged and
skipped, not fatal; if every call fails it exits cleanly instead of scoring nothing.
Written for a non-technical reader, debuggable by a technical one. Regenerate any
run's report with voice-evals report --run <dir>/report (no re-calling).
[Scenario] — [Concept]: scored X (aim Y) with a "Why it matters" line, worst first. No raw-metric dumps.Layer B accuracy depends on channel separation. The dynamics scorers prefer, in order:
source="events").source="channels").estimated=True); barge-in isn't measured on mono.The call/simulate recordings carry both an event log and dual channels, so timing
is exact. latency and stall are event-based, so background noise can't fool them
— unlike audio-derived gap metrics, which noise masks.
<stem>.json next to the recording){
"call_id": "abc123", "agent_version": "leasing-ai-2.14.0", "prompt_version": "p-...",
"channel_map": {"agent_channel": 0, "caller_channel": 1},
"events": [
{"kind": "user_speech_end", "t_s": 3.2}, {"kind": "stt_final", "t_s": 3.2},
{"kind": "tts_first_audio", "t_s": 4.1}, {"kind": "agent_tts_start", "t_s": 4.1},
{"kind": "barge_in_detected","t_s": 6.05},{"kind": "agent_interrupted","t_s": 6.25}
]
}
Latency reads tts_first_audio − stt_final; barge-in reads agent_interrupted − barge_in_detected — both exact. All fields optional; with none, Layer A still works
and Layer B falls back to VAD.
Sends the audio (not a transcript) to a native-audio model with a versioned rubric and scores nine dimensions 1–5 (5 = best), reasoning before each score:
naturalness · prosody · fluency · pace · responsiveness · emotional_alignment · intelligibility · conversational_flow · frustration_handling
plus a holistic overall, notable_timestamps, and a summary → flattened as
judge_naturalness … judge_overall. Set judged_channel: mono to judge the whole
conversation (caller + agent — flow and frustration handling), or agent_only to
judge the agent's audio in isolation. The rubric/system prompt are plain text in
config/; bump RUBRIC_VERSION on any change (it keys the cache + invalidates
calibration).
⚠ The judge is latency-blind. An audio model doesn't experience real-time duration — it can rate
responsiveness5/5 on a call with 20s of dead air. Timing is owned by the deterministic layer (latency,stall); don't gate responsiveness on the judge. This is exactly why both layers exist.
Guards. exclude_if_model_family_matches stops the judge grading audio from its
own model family (self-enhancement bias — critical for a cross-vendor A/B: an OpenAI
judge inflates an OpenAI agent). Degenerate inputs (silence, no agent speech) surface
as an error, never hallucinated numbers.
Cost/PII. ~32 tokens/s of audio ≈ $0.003–0.006 for a 60s clip; caching makes
reruns free. Recordings contain resident data — hosted-judge use is explicit opt-in;
use the local-only profile for CI and backend: local_omni (self-hosted Qwen-Omni)
or Vertex-with-DPA for compliant paths.
The only place humans label — optional, one-time. Fill calibration/golden_set.csv
(clip_id, dimension, human_score) for ~30–50 clips using the rubric's anchors, then:
uv run voice-evals calibrate --golden calibration/golden_set.csv
Reports per-dimension quadratic-weighted Cohen's κ + correlation and recommends
which dimensions to gate in CI (κ ≥ calibration.kappa_bar, default 0.6). Expect
intelligibility/naturalness/fluency to agree well; treat low-κ dims as advisory.
uv run pytest -m "not slow and not judge" # default CI: Layers A + B, no network/keys
uv run pytest -m judge # judge gates (needs a key or local model)
tests/eval_gates/ parametrizes one assertion per (clip, gated metric) plus a
regression gate that fails when a metric degrades beyond max_delta vs the baseline.
Profiles in config/:
example.local.yaml — Layers A+B, judge off (CI-safe).default.yaml — production defaults, judge opt-in.judge.openai.yaml — the full live-eval profile: acoustic + dynamics (incl. stall)
gpt-audio judge on the whole conversation, all metrics gated pass/fail.Gates support absolute floors/ceilings (with soft warn) and regression deltas
grouped by agent_version. Telephony knobs live under telephony: (preamble_s,
connect_timeout_s, max_call_s, vad_rms, end_silence_ms, port). Thresholds are
heuristic starting points — calibrate to your corpus. Full schema in
src/voice_evals/config.py.
voice-evals/
├── config/ local/default/judge.openai YAML, rubric.default.txt, judge_system.txt
├── scenarios/ eval_set.yaml (the hard-caller set) + example/live_barge_in/noisy_cafe
├── corpus/ (gitignored) drop audio + optional .json sidecars here
├── recordings/ (gitignored) live-call library — one dated dir per `call` invocation
├── outputs/ .cache/ (gitignored) run artifacts + decoded-audio/result cache
├── calibration/ golden_set.csv (human labels for judge validation)
├── scripts/ fetch_models.py (vendor NISQA)
├── src/voice_evals/ models, config, ingest, cache, runner, aggregate, report, cli,
│ scorers/{acoustic,dynamics(latency,turn_taking,barge_in,stall),judge},
│ simulate/ (scenario, orchestrator, openai_backends, gating,
│ telephony + telephony_live — Twilio Media Streams caller)
└── tests/ unit tests + eval_gates/ regression suite
Not a live/streaming monitor, not an STT/correctness system, not a labeling UI. Offline batch over recorded files, plus on-demand live calls to build those recordings.
10 commits
Python
100.0%
Offline, reference-free evaluation of the audio experience of AI voice calls — how the agent sounds and behaves on the line, not what it said. It scores the audio bytes themselves, so it catches what transcript/tool-call evals can't: robotic synthesis, dropouts, sluggish replies, awkward silences, failed barge-in, and the #1 production failure — the agent going dead when background noise jams its turn detection.
Two ways to use it:
.mp3/.wav in, get per-clip
scores, an HTML report, Parquet/JSON, and pytest regression gates.Design principle: everything is reference-free / unsupervised. No per-clip ground-truth labeling anywhere in the core pipeline. The only human labeling is an optional ~30–50 clip calibration set, used once to validate the judge.
Scope: this repo scores the audio experience only. Information correctness and tool-call success are owned by your text/OTel evals. A scenario's
goalexists to make the simulated caller behave realistically (and get frustrated when mishandled), not to score task completion here.
| Layer | Question | Labels? | Network? | Scorers |
|---|---|---|---|---|
| A — Acoustic / perceptual | Did it sound good? Artifacts, noise, robotic TTS? | No | No | dnsmos nisqa utmos squim |
| B — Conversational dynamics | Slow? Awkward pauses? Did it freeze? Did barge-in work? | No (timestamps) | No | latency turn_taking barge_in stall |
| C — Audio LLM-judge | Natural prosody, tone, handling of an upset caller — as heard? | No to run; small set to trust | Yes (or self-host) | audio_judge (OpenAI / Gemini / local) |
Layers A and B run with zero API keys and zero network. Layer C is additive and
off by default (scorers.judge: []); enable it with a config profile + a key.
The report speaks in concepts a non-technical reader understands; the raw metric names below are what technical users debug with (surfaced only in expanders).
| Concept | What it measures | Raw metrics |
|---|---|---|
| Response speed | How fast the agent starts replying after the caller stops | latency_p50/p95/p99/max_s |
| Dead air / freezing | How often the agent goes silent and looks dead | stall_rate longest_stall_s n_no_response |
| Interruption handling | When the caller talks over it, does it stop and listen? | bargein_success_rate bargein_latency_* bargein_false_alarm_rate |
| Turn-taking rhythm | Natural back-and-forth, no awkward gaps / talk-over | max_gap_s overlap_ratio agent_talk_ratio |
| Voice quality | How clean/clear the voice sounds on the line | dnsmos_* utmos squim_* |
| Overall / Frustration / Sounds human / … | The judge's take, heard | judge_overall judge_frustration_handling … |
Python 3.11+ and ffmpeg on PATH. Base + [acoustic] + [dynamics] run with no
GPU and no API keys.
# with uv (recommended)
uv sync --extra acoustic --extra dynamics # local-only core
uv sync --extra telephony --extra judge-openai # + live calls + OpenAI judge
uv sync --all-extras # everything + dev/test
uv run voice-evals --help
# or with pip
pip install -e ".[acoustic,dynamics]"
pip install -e ".[all]"
Most model weights self-fetch on first use (UTMOS via torch.hub, SQUIM via torchaudio, silero on first run, DNSMOS bundled). NISQA has no clean PyPI package and is vendored once:
python scripts/fetch_models.py nisqa # clones NISQA + weights into .cache/models/
Secrets go in a gitignored .env (see .env.example) — never in YAML:
OPENAI_API_KEY=sk-... # OpenAI judge + the simulated caller (STT/LLM/TTS)
GEMINI_API_KEY=... # (alt judge)
TWILIO_ACCOUNT_SID=AC... # live telephony
TWILIO_AUTH_TOKEN=...
TWILIO_FROM_NUMBER=+1... # your Twilio caller-ID number
TWILIO_AGENT_NUMBER=+1... # the agent's number to dial (overridable with --to)
PUBLIC_STREAM_URL=wss://<tunnel>/caller # your ngrok wss → localhost:8080
cp /path/to/*.mp3 corpus/ # + optional <stem>.json sidecars
uv run voice-evals run --config config/example.local.yaml # Layers A+B, no network
open outputs/runs/<run_id>/report.html
example.local.yaml keeps the judge off. To score the same folder with the
native-audio judge — the same judge as the live eval set, just pointed at your
production recordings — enable it with a judge profile and a key (audio is sent to the
API, so this is explicit opt-in):
export OPENAI_API_KEY=sk-... # (or GEMINI_API_KEY) — or keep it in .env
uv run voice-evals run --corpus corpus/ --config config/judge.openai.yaml
open outputs/runs/<run_id>/report.html
Two judge knobs matter for production recordings:
judge.judged_channel — agent_only grades just the agent's voice (needs a
channel_map in the sidecar to isolate it); mono grades the whole call (caller +
agent — flow, frustration handling). judge.openai.yaml uses mono.judge.exclude_if_model_family_matches — if a recording's tts_provider is the
judge's own model family (e.g. an OpenAI-voiced agent graded by the OpenAI judge),
the judge is skipped to avoid self-enhancement bias; set it false to force it.
judge.openai.yaml sets it false.For Gemini instead of OpenAI, use a config with judge.backend: gemini,
judge.model: gemini-2.5-pro, and GEMINI_API_KEY set. --no-judge turns the judge
off on any run. (Dynamics like barge-in/stall need an event log or split channels,
so on bare recordings they stay sparse — see the channel question; the judge and acoustic layers work on any audio.)
# 1. Your agent is reachable at a phone number (deployed, or local dev behind a Twilio number).
# 2. Run ngrok → localhost:8080 and set PUBLIC_STREAM_URL to its wss URL in .env.
# 3. Smoke-test one call per scenario, then the full set:
uv run voice-evals call --scenarios scenarios/eval_set.yaml --config config/judge.openai.yaml \
-k 1 --agent-version openai-rt --preamble 35 # 7 calls
uv run voice-evals call --scenarios scenarios/eval_set.yaml --config config/judge.openai.yaml \
-k 3 --agent-version openai-rt --preamble 35 # 21 calls → one aggregated report
You set up no Twilio infrastructure — voice-evals call places the outbound
call via Twilio's REST API and embeds the media-stream websocket server (binds
127.0.0.1:8080 for the duration of the call). You just run ngrok pointed at 8080.
Other commands:
uv run voice-evals ingest --corpus corpus/ # decode/normalize only; warm the cache
uv run voice-evals simulate --scenarios scenarios/example.yaml --mock --baseline-version main # synthetic A/B, no telephony
uv run voice-evals report --run recordings/<ts>/report # regenerate report.html from run.json (no re-calling)
uv run voice-evals run --baseline previous --strict # exit 1 on any gate violation
uv run voice-evals calibrate --golden calibration/golden_set.csv
uv run voice-evals cache clear [--scorer dnsmos]
uv run voice-evals list-scorers
scenarios/eval_set.yaml is a curated set of hard callers, each a distinct
failure mode mined from real bad calls:
| Scenario | Stresses |
|---|---|
double_charge_impatient_barge | interruption handling |
loud_transit_hub_package_locker | noise → dead air / freezing |
rent_statement_confused_repeats | pacing, clarity, re-grounding |
mid_call_goal_pivot | conversation flow on a topic change |
simple_hours_cooperative_baseline | control (a healthy agent passes clean) |
hostile_cancel_threat_bad_review | frustration handling / emotional alignment |
noisy_cafe_barging_impatient_combo | combined worst case |
call runs -k calls per scenario (calls are stochastic — the caller LLM and the
real agent both vary — so gate on the aggregate, not one flap). Each call is a
distinct clip; scores roll up per scenario and overall into one report.
Tag each run with --agent-version so the baseline: previous regression gate diffs
one build (or vendor) against the last.
A scenario is a goal + persona + optional barge_in / background policy:
- id: noisy_cafe_barging_impatient_combo
goal: "get the front gate access code reset because the caller is locked out"
persona: { name: locked_out, style: "loud, clipped, impatient", patience: low,
emotional_arc: "flares every time the agent goes quiet", adversarial: true, voice: verse }
max_turns: 5
barge_in: { enabled: true, after_agent_s: 1.5, max_barge_ins: 3 } # caller talks OVER the agent
background: { enabled: true, gain_db: -16, voices: [onyx, fable] } # intelligible café chatter on the open mic
background: mixes synthesized intelligible voices continuously onto the caller's
(open) mic. This reproduces the top production failure: babble jams the agent's
endpoint detection so it can't tell the caller stopped, and it freezes — captured by
stall_rate / latency while the agent's own voice quality stays clean (the
babble is on the caller channel). gain_db: -20 ≈ realistic café; -14 is loud.
barge_in: makes the caller talk over the agent once it's spoken for after_agent_s.
The barge_in scorer measures whether the agent yields, how fast (stop-latency
p50/p95), the success rate, and false alarms — real barge-in over real telephony,
which half-duplex simulation can't see.
voice-evals call dials the agent (--to / TWILIO_AGENT_NUMBER) with TwiML
<Connect><Stream url=PUBLIC_STREAM_URL>, runs the persona caller full-duplex over
the μ-law/8kHz audio, records both legs + an event log, and scores it. What you run:
localhost:8080, with PUBLIC_STREAM_URL = its current wss://…/caller.The simulated caller is a cascade: STT of the agent's turns (gpt-4o-transcribe)
→ LLM reply (gpt-4o) → TTS (gpt-4o-mini-tts), streamed as μ-law. It hears and
responds to the agent — answers questions, escalates when stalled. --no-stt makes
it deaf (cheaper, pure barge-in timing). We deliberately kept the caller a controlled
cascade rather than an audio-to-audio model so barge-in timing stays deterministic and
runs stay reproducible; the caller is pluggable (CallerBot) if you want to swap it.
Many deployed agents play an IVR / greeting / recording caveat before the real agent
speaks. Pass --preamble N (or telephony.preamble_s) so the caller ignores the
agent's first N seconds for turn-taking (it's still recorded) and engages only with
the real agent. A deadlock guard opens the call if the greeting is missed.
| Symptom (Twilio console) | Cause | Fix |
|---|---|---|
31920 Stream – WebSocket – Handshake Error | Twilio can't reach PUBLIC_STREAM_URL | Start ngrok; make sure PUBLIC_STREAM_URL matches its current wss:// URL (ngrok-free rotates it each restart) |
Call completed, 0s, no recording | Agent answered then ended instantly (its webhook transfers/<Dial>s out instead of streaming) | Fix the agent to stay on the line and stream |
| Caller talks over hold music / disclaimer | The agent's preamble | --preamble N |
| Run "looks stuck" for minutes | A call whose stream never attaches used to wait out max_call_s (180s) | connect_timeout_s (default 20s) now aborts non-attaching calls fast |
voice-evals call writes each run to its own dated dir under recordings/<timestamp>/
(a durable library — never auto-wiped; delete by hand). One failed call is logged and
skipped, not fatal; if every call fails it exits cleanly instead of scoring nothing.
Written for a non-technical reader, debuggable by a technical one. Regenerate any
run's report with voice-evals report --run <dir>/report (no re-calling).
[Scenario] — [Concept]: scored X (aim Y) with a "Why it matters" line, worst first. No raw-metric dumps.Layer B accuracy depends on channel separation. The dynamics scorers prefer, in order:
source="events").source="channels").estimated=True); barge-in isn't measured on mono.The call/simulate recordings carry both an event log and dual channels, so timing
is exact. latency and stall are event-based, so background noise can't fool them
— unlike audio-derived gap metrics, which noise masks.
<stem>.json next to the recording){
"call_id": "abc123", "agent_version": "leasing-ai-2.14.0", "prompt_version": "p-...",
"channel_map": {"agent_channel": 0, "caller_channel": 1},
"events": [
{"kind": "user_speech_end", "t_s": 3.2}, {"kind": "stt_final", "t_s": 3.2},
{"kind": "tts_first_audio", "t_s": 4.1}, {"kind": "agent_tts_start", "t_s": 4.1},
{"kind": "barge_in_detected","t_s": 6.05},{"kind": "agent_interrupted","t_s": 6.25}
]
}
Latency reads tts_first_audio − stt_final; barge-in reads agent_interrupted − barge_in_detected — both exact. All fields optional; with none, Layer A still works
and Layer B falls back to VAD.
Sends the audio (not a transcript) to a native-audio model with a versioned rubric and scores nine dimensions 1–5 (5 = best), reasoning before each score:
naturalness · prosody · fluency · pace · responsiveness · emotional_alignment · intelligibility · conversational_flow · frustration_handling
plus a holistic overall, notable_timestamps, and a summary → flattened as
judge_naturalness … judge_overall. Set judged_channel: mono to judge the whole
conversation (caller + agent — flow and frustration handling), or agent_only to
judge the agent's audio in isolation. The rubric/system prompt are plain text in
config/; bump RUBRIC_VERSION on any change (it keys the cache + invalidates
calibration).
⚠ The judge is latency-blind. An audio model doesn't experience real-time duration — it can rate
responsiveness5/5 on a call with 20s of dead air. Timing is owned by the deterministic layer (latency,stall); don't gate responsiveness on the judge. This is exactly why both layers exist.
Guards. exclude_if_model_family_matches stops the judge grading audio from its
own model family (self-enhancement bias — critical for a cross-vendor A/B: an OpenAI
judge inflates an OpenAI agent). Degenerate inputs (silence, no agent speech) surface
as an error, never hallucinated numbers.
Cost/PII. ~32 tokens/s of audio ≈ $0.003–0.006 for a 60s clip; caching makes
reruns free. Recordings contain resident data — hosted-judge use is explicit opt-in;
use the local-only profile for CI and backend: local_omni (self-hosted Qwen-Omni)
or Vertex-with-DPA for compliant paths.
The only place humans label — optional, one-time. Fill calibration/golden_set.csv
(clip_id, dimension, human_score) for ~30–50 clips using the rubric's anchors, then:
uv run voice-evals calibrate --golden calibration/golden_set.csv
Reports per-dimension quadratic-weighted Cohen's κ + correlation and recommends
which dimensions to gate in CI (κ ≥ calibration.kappa_bar, default 0.6). Expect
intelligibility/naturalness/fluency to agree well; treat low-κ dims as advisory.
uv run pytest -m "not slow and not judge" # default CI: Layers A + B, no network/keys
uv run pytest -m judge # judge gates (needs a key or local model)
tests/eval_gates/ parametrizes one assertion per (clip, gated metric) plus a
regression gate that fails when a metric degrades beyond max_delta vs the baseline.
Profiles in config/:
example.local.yaml — Layers A+B, judge off (CI-safe).default.yaml — production defaults, judge opt-in.judge.openai.yaml — the full live-eval profile: acoustic + dynamics (incl. stall)
gpt-audio judge on the whole conversation, all metrics gated pass/fail.Gates support absolute floors/ceilings (with soft warn) and regression deltas
grouped by agent_version. Telephony knobs live under telephony: (preamble_s,
connect_timeout_s, max_call_s, vad_rms, end_silence_ms, port). Thresholds are
heuristic starting points — calibrate to your corpus. Full schema in
src/voice_evals/config.py.
voice-evals/
├── config/ local/default/judge.openai YAML, rubric.default.txt, judge_system.txt
├── scenarios/ eval_set.yaml (the hard-caller set) + example/live_barge_in/noisy_cafe
├── corpus/ (gitignored) drop audio + optional .json sidecars here
├── recordings/ (gitignored) live-call library — one dated dir per `call` invocation
├── outputs/ .cache/ (gitignored) run artifacts + decoded-audio/result cache
├── calibration/ golden_set.csv (human labels for judge validation)
├── scripts/ fetch_models.py (vendor NISQA)
├── src/voice_evals/ models, config, ingest, cache, runner, aggregate, report, cli,
│ scorers/{acoustic,dynamics(latency,turn_taking,barge_in,stall),judge},
│ simulate/ (scenario, orchestrator, openai_backends, gating,
│ telephony + telephony_live — Twilio Media Streams caller)
└── tests/ unit tests + eval_gates/ regression suite
Not a live/streaming monitor, not an STT/correctness system, not a labeling UI. Offline batch over recorded files, plus on-demand live calls to build those recordings.
10 commits
Python
100.0%