hotato 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hotato/__init__.py +33 -0
- hotato/_engine/__init__.py +47 -0
- hotato/_engine/__main__.py +131 -0
- hotato/_engine/audio.py +149 -0
- hotato/_engine/batch.py +160 -0
- hotato/_engine/score.py +358 -0
- hotato/_engine/vad.py +224 -0
- hotato/_stats.py +47 -0
- hotato/aggregate.py +346 -0
- hotato/benchmark.py +627 -0
- hotato/capture.py +615 -0
- hotato/cli.py +897 -0
- hotato/core.py +537 -0
- hotato/data/audio/01-hard-interruption.example.wav +0 -0
- hotato/data/audio/02-backchannel-mhm.example.wav +0 -0
- hotato/data/audio/03-filler-start.example.wav +0 -0
- hotato/data/audio/04-correction.example.wav +0 -0
- hotato/data/audio/05-telephony-8khz.example.wav +0 -0
- hotato/data/audio/06-double-talk.example.wav +0 -0
- hotato/data/audio/07-echo-bleed.example.wav +0 -0
- hotato/data/audio/08-rapid-turn-taking.example.wav +0 -0
- hotato/data/scenarios/01-hard-interruption.json +26 -0
- hotato/data/scenarios/02-backchannel-mhm.json +26 -0
- hotato/data/scenarios/03-filler-start.json +26 -0
- hotato/data/scenarios/04-correction.json +26 -0
- hotato/data/scenarios/05-telephony-8khz.json +26 -0
- hotato/data/scenarios/06-double-talk.json +26 -0
- hotato/data/scenarios/07-echo-bleed.json +29 -0
- hotato/data/scenarios/08-rapid-turn-taking.json +27 -0
- hotato/data/scenarios/manifest.json +76 -0
- hotato/export.py +204 -0
- hotato/fixmap.py +332 -0
- hotato/mcp_server.py +181 -0
- hotato/neural.py +101 -0
- hotato/pytest_plugin.py +127 -0
- hotato/report.py +1214 -0
- hotato/schema/envelope.v1.json +170 -0
- hotato/stackbench.py +534 -0
- hotato-0.1.0.dist-info/METADATA +257 -0
- hotato-0.1.0.dist-info/RECORD +44 -0
- hotato-0.1.0.dist-info/WHEEL +5 -0
- hotato-0.1.0.dist-info/entry_points.txt +6 -0
- hotato-0.1.0.dist-info/licenses/LICENSE +21 -0
- hotato-0.1.0.dist-info/top_level.txt +1 -0
hotato/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""hotato: the open turn-taking eval for voice agents.
|
|
2
|
+
|
|
3
|
+
Does your agent drop the turn, or hog it? Free (MIT), self-hostable,
|
|
4
|
+
zero-install, and offline. Its primary consumer is an AI agent: grab it as a CLI
|
|
5
|
+
(``uvx hotato ...``) or as a one-tool MCP server mid-task. It scores voice-agent
|
|
6
|
+
turn-taking - barge-in, overlap/talk-over, and backchannel handling - from a
|
|
7
|
+
call recording, returns a machine-readable verdict, and is the only such tool
|
|
8
|
+
that points a surfaced failure at the KIND of fix it needs: a learned
|
|
9
|
+
engagement-control / addressee-detection layer (an open research problem, not a
|
|
10
|
+
config knob) when the failure is a discrimination one no threshold can solve.
|
|
11
|
+
The pointer is vendor-neutral and names no product.
|
|
12
|
+
|
|
13
|
+
Honesty is the point, not a footnote: there is no fabricated accuracy anywhere.
|
|
14
|
+
The numbers are reproducible timing measurements with an exposed method and an
|
|
15
|
+
explicit ceiling.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .core import LIMITS, SUITE_ID, run_single, run_suite
|
|
19
|
+
|
|
20
|
+
from ._engine.vad import register_neural_backend as _register_neural_backend
|
|
21
|
+
from .neural import build_silero_backend as _build_silero_backend
|
|
22
|
+
|
|
23
|
+
# Register the OPTIONAL, non-reference neural VAD backend (Silero VAD, MIT). This
|
|
24
|
+
# only stores the factory reference: the model and its extra are imported lazily,
|
|
25
|
+
# and only if backend="neural" is actually requested -- so importing hotato stays
|
|
26
|
+
# zero-dependency and the energy reference path is untouched. With the [neural]
|
|
27
|
+
# extra absent, a neural request raises a clean BackendUnavailable (never a silent
|
|
28
|
+
# fallback to energy that could change a published number).
|
|
29
|
+
_register_neural_backend(_build_silero_backend)
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
__all__ = ["run_single", "run_suite", "LIMITS", "SUITE_ID", "__version__"]
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""voice-agent-barge-in-tests: a dependency-light harness for measuring the three
|
|
2
|
+
objective barge-in signals (did the agent yield, time to yield, talk-over
|
|
3
|
+
seconds) from a voice-agent call recording.
|
|
4
|
+
|
|
5
|
+
See the repository README for the failure mode this tests and how to wire it
|
|
6
|
+
into CI. This package measures timing from audio and makes no accuracy claim
|
|
7
|
+
about any detector.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .audio import Signal, read_wav, write_wav
|
|
11
|
+
from .score import (
|
|
12
|
+
ScoreConfig,
|
|
13
|
+
ScoreResult,
|
|
14
|
+
Verdict,
|
|
15
|
+
evaluate,
|
|
16
|
+
score_channels,
|
|
17
|
+
score_stereo,
|
|
18
|
+
)
|
|
19
|
+
from .vad import (
|
|
20
|
+
BackendUnavailable,
|
|
21
|
+
VADParams,
|
|
22
|
+
clear_neural_backend,
|
|
23
|
+
energy_vad,
|
|
24
|
+
neural_vad,
|
|
25
|
+
register_neural_backend,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"Signal",
|
|
32
|
+
"read_wav",
|
|
33
|
+
"write_wav",
|
|
34
|
+
"ScoreConfig",
|
|
35
|
+
"ScoreResult",
|
|
36
|
+
"Verdict",
|
|
37
|
+
"evaluate",
|
|
38
|
+
"score_channels",
|
|
39
|
+
"score_stereo",
|
|
40
|
+
"VADParams",
|
|
41
|
+
"energy_vad",
|
|
42
|
+
"neural_vad",
|
|
43
|
+
"register_neural_backend",
|
|
44
|
+
"clear_neural_backend",
|
|
45
|
+
"BackendUnavailable",
|
|
46
|
+
"__version__",
|
|
47
|
+
]
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
python -m barge_scoring score --stereo call.wav --caller-channel 0 --agent-channel 1
|
|
4
|
+
python -m barge_scoring score --caller caller.wav --agent agent.wav --onset 2.4
|
|
5
|
+
python -m barge_scoring batch --scenarios scenarios --audio audio
|
|
6
|
+
|
|
7
|
+
The `score` command works on any recording, not just the bundled fixtures:
|
|
8
|
+
point it at a two-channel export of one of your own calls (caller on one
|
|
9
|
+
channel, agent on the other) and it prints the three signals.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from .audio import Signal, read_wav
|
|
19
|
+
from .batch import run as batch_run
|
|
20
|
+
from .batch import to_json as batch_to_json
|
|
21
|
+
from .batch import to_markdown as batch_to_markdown
|
|
22
|
+
from .score import ScoreConfig, score_channels, score_stereo
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _cmd_score(args) -> int:
|
|
26
|
+
cfg = ScoreConfig(
|
|
27
|
+
yield_hangover_sec=args.yield_hangover,
|
|
28
|
+
max_search_sec=args.max_search,
|
|
29
|
+
)
|
|
30
|
+
onset = None
|
|
31
|
+
if args.label:
|
|
32
|
+
with open(args.label, "r", encoding="utf-8") as fh:
|
|
33
|
+
label = json.load(fh)
|
|
34
|
+
onset = label.get("caller_onset_sec")
|
|
35
|
+
if args.onset is not None:
|
|
36
|
+
onset = args.onset
|
|
37
|
+
|
|
38
|
+
if args.stereo:
|
|
39
|
+
signal = read_wav(args.stereo)
|
|
40
|
+
if signal.num_channels < 2:
|
|
41
|
+
print(
|
|
42
|
+
"error: --stereo file has one channel; use --caller and --agent "
|
|
43
|
+
"with two mono files instead",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return 2
|
|
47
|
+
result = score_stereo(
|
|
48
|
+
signal, args.caller_channel, args.agent_channel, caller_onset_sec=onset, cfg=cfg
|
|
49
|
+
)
|
|
50
|
+
elif args.caller and args.agent:
|
|
51
|
+
c = read_wav(args.caller)
|
|
52
|
+
a = read_wav(args.agent)
|
|
53
|
+
if c.sample_rate != a.sample_rate:
|
|
54
|
+
print(
|
|
55
|
+
f"error: sample-rate mismatch (caller {c.sample_rate} Hz, "
|
|
56
|
+
f"agent {a.sample_rate} Hz); resample so both match",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
return 2
|
|
60
|
+
n = min(c.num_samples, a.num_samples)
|
|
61
|
+
result = score_channels(
|
|
62
|
+
c.get(0)[:n], a.get(0)[:n], c.sample_rate, caller_onset_sec=onset, cfg=cfg
|
|
63
|
+
)
|
|
64
|
+
else:
|
|
65
|
+
print("error: provide --stereo FILE, or both --caller FILE and --agent FILE", file=sys.stderr)
|
|
66
|
+
return 2
|
|
67
|
+
|
|
68
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _cmd_batch(args) -> int:
|
|
73
|
+
cfg = ScoreConfig()
|
|
74
|
+
rows = batch_run(
|
|
75
|
+
args.scenarios,
|
|
76
|
+
args.audio,
|
|
77
|
+
suffix=args.suffix,
|
|
78
|
+
caller_channel=args.caller_channel,
|
|
79
|
+
agent_channel=args.agent_channel,
|
|
80
|
+
cfg=cfg,
|
|
81
|
+
)
|
|
82
|
+
md = batch_to_markdown(rows)
|
|
83
|
+
if args.md:
|
|
84
|
+
with open(args.md, "w", encoding="utf-8") as fh:
|
|
85
|
+
fh.write(md)
|
|
86
|
+
if args.json:
|
|
87
|
+
with open(args.json, "w", encoding="utf-8") as fh:
|
|
88
|
+
json.dump(batch_to_json(rows), fh, indent=2)
|
|
89
|
+
print(md)
|
|
90
|
+
failed = sum(1 for r in rows if not r.passed)
|
|
91
|
+
if args.fail_on_regression and failed:
|
|
92
|
+
return 1
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
97
|
+
p = argparse.ArgumentParser(prog="barge_scoring", description=__doc__)
|
|
98
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
99
|
+
|
|
100
|
+
s = sub.add_parser("score", help="score a single recording")
|
|
101
|
+
s.add_argument("--stereo", help="two-channel WAV: one channel caller, one agent")
|
|
102
|
+
s.add_argument("--caller", help="mono WAV of the caller channel")
|
|
103
|
+
s.add_argument("--agent", help="mono WAV of the agent channel")
|
|
104
|
+
s.add_argument("--caller-channel", type=int, default=0)
|
|
105
|
+
s.add_argument("--agent-channel", type=int, default=1)
|
|
106
|
+
s.add_argument("--onset", type=float, default=None, help="caller onset in seconds (overrides auto-detect and label)")
|
|
107
|
+
s.add_argument("--label", help="scenario JSON to read caller_onset_sec from")
|
|
108
|
+
s.add_argument("--yield-hangover", type=float, default=0.20)
|
|
109
|
+
s.add_argument("--max-search", type=float, default=4.0)
|
|
110
|
+
s.set_defaults(func=_cmd_score)
|
|
111
|
+
|
|
112
|
+
b = sub.add_parser("batch", help="score every scenario and print a results table")
|
|
113
|
+
b.add_argument("--scenarios", default="scenarios")
|
|
114
|
+
b.add_argument("--audio", default="audio")
|
|
115
|
+
b.add_argument("--suffix", default=".example.wav")
|
|
116
|
+
b.add_argument("--caller-channel", type=int, default=0)
|
|
117
|
+
b.add_argument("--agent-channel", type=int, default=1)
|
|
118
|
+
b.add_argument("--md", help="write the Markdown table to this path")
|
|
119
|
+
b.add_argument("--json", help="write the JSON summary to this path")
|
|
120
|
+
b.add_argument("--fail-on-regression", action="store_true", help="exit 1 if any scenario fails (for CI)")
|
|
121
|
+
b.set_defaults(func=_cmd_batch)
|
|
122
|
+
return p
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def main(argv=None) -> int:
|
|
126
|
+
args = build_parser().parse_args(argv)
|
|
127
|
+
return args.func(args)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
raise SystemExit(main())
|
hotato/_engine/audio.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Minimal WAV reading and framing. Standard library only.
|
|
2
|
+
|
|
3
|
+
No third-party dependencies are required. PCM WAV at 8, 16, or 32 bit,
|
|
4
|
+
mono or multi-channel, is supported. If numpy happens to be importable it
|
|
5
|
+
is used to speed up the per-frame RMS, but it is never required and the
|
|
6
|
+
results are identical without it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import array
|
|
12
|
+
import math
|
|
13
|
+
import sys
|
|
14
|
+
import wave
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
try: # optional acceleration only
|
|
19
|
+
import numpy as _np
|
|
20
|
+
except Exception: # pragma: no cover - numpy is genuinely optional
|
|
21
|
+
_np = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Signal:
|
|
26
|
+
"""A decoded multi-channel signal with samples in the range [-1, 1]."""
|
|
27
|
+
|
|
28
|
+
sample_rate: int
|
|
29
|
+
channels: List[List[float]]
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def num_channels(self) -> int:
|
|
33
|
+
return len(self.channels)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def num_samples(self) -> int:
|
|
37
|
+
return len(self.channels[0]) if self.channels else 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def duration_sec(self) -> float:
|
|
41
|
+
return self.num_samples / self.sample_rate if self.sample_rate else 0.0
|
|
42
|
+
|
|
43
|
+
def get(self, index: int) -> List[float]:
|
|
44
|
+
if index < 0 or index >= self.num_channels:
|
|
45
|
+
raise IndexError(
|
|
46
|
+
f"channel {index} out of range for {self.num_channels}-channel audio"
|
|
47
|
+
)
|
|
48
|
+
return self.channels[index]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_wav(path: str) -> Signal:
|
|
52
|
+
"""Read a PCM WAV file into a Signal with float samples in [-1, 1]."""
|
|
53
|
+
with wave.open(path, "rb") as wf:
|
|
54
|
+
n_channels = wf.getnchannels()
|
|
55
|
+
sampwidth = wf.getsampwidth()
|
|
56
|
+
sample_rate = wf.getframerate()
|
|
57
|
+
n_frames = wf.getnframes()
|
|
58
|
+
raw = wf.readframes(n_frames)
|
|
59
|
+
|
|
60
|
+
if sampwidth == 1:
|
|
61
|
+
# 8-bit PCM is unsigned with a midpoint of 128.
|
|
62
|
+
a = array.array("B")
|
|
63
|
+
a.frombytes(raw)
|
|
64
|
+
samples = [(x - 128) / 128.0 for x in a]
|
|
65
|
+
elif sampwidth == 2:
|
|
66
|
+
a = array.array("h")
|
|
67
|
+
a.frombytes(raw)
|
|
68
|
+
if sys.byteorder == "big": # WAV is little-endian
|
|
69
|
+
a.byteswap()
|
|
70
|
+
samples = [x / 32768.0 for x in a]
|
|
71
|
+
elif sampwidth == 4:
|
|
72
|
+
a = array.array("i")
|
|
73
|
+
a.frombytes(raw)
|
|
74
|
+
if sys.byteorder == "big":
|
|
75
|
+
a.byteswap()
|
|
76
|
+
samples = [x / 2147483648.0 for x in a]
|
|
77
|
+
else:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"unsupported sample width {sampwidth * 8}-bit; "
|
|
80
|
+
"please convert to 16-bit PCM (for example with ffmpeg -acodec pcm_s16le)"
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
channels = [samples[ch::n_channels] for ch in range(n_channels)]
|
|
84
|
+
return Signal(sample_rate=sample_rate, channels=channels)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_wav(path: str, sample_rate: int, channels: List[List[float]]) -> None:
|
|
88
|
+
"""Write float channels in [-1, 1] to a 16-bit PCM WAV file."""
|
|
89
|
+
n_channels = len(channels)
|
|
90
|
+
n_samples = len(channels[0]) if channels else 0
|
|
91
|
+
interleaved = array.array("h", bytes(2 * n_channels * n_samples))
|
|
92
|
+
for ch in range(n_channels):
|
|
93
|
+
data = channels[ch]
|
|
94
|
+
for i in range(n_samples):
|
|
95
|
+
v = data[i]
|
|
96
|
+
if v > 1.0:
|
|
97
|
+
v = 1.0
|
|
98
|
+
elif v < -1.0:
|
|
99
|
+
v = -1.0
|
|
100
|
+
interleaved[i * n_channels + ch] = int(round(v * 32767.0))
|
|
101
|
+
if sys.byteorder == "big":
|
|
102
|
+
interleaved.byteswap()
|
|
103
|
+
with wave.open(path, "wb") as wf:
|
|
104
|
+
wf.setnchannels(n_channels)
|
|
105
|
+
wf.setsampwidth(2)
|
|
106
|
+
wf.setframerate(sample_rate)
|
|
107
|
+
wf.writeframes(interleaved.tobytes())
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def frame_rms(
|
|
111
|
+
samples: List[float],
|
|
112
|
+
sample_rate: int,
|
|
113
|
+
frame_ms: float = 20.0,
|
|
114
|
+
hop_ms: float = 10.0,
|
|
115
|
+
) -> Tuple[List[float], float]:
|
|
116
|
+
"""Return per-frame linear RMS and the hop length in seconds."""
|
|
117
|
+
frame_len = max(1, int(round(sample_rate * frame_ms / 1000.0)))
|
|
118
|
+
hop = max(1, int(round(sample_rate * hop_ms / 1000.0)))
|
|
119
|
+
hop_sec = hop / sample_rate
|
|
120
|
+
n = len(samples)
|
|
121
|
+
rms: List[float] = []
|
|
122
|
+
i = 0
|
|
123
|
+
if _np is not None:
|
|
124
|
+
arr = _np.asarray(samples, dtype=_np.float64)
|
|
125
|
+
while i < n:
|
|
126
|
+
seg = arr[i : i + frame_len]
|
|
127
|
+
rms.append(float(_np.sqrt(_np.mean(seg * seg))) if seg.size else 0.0)
|
|
128
|
+
i += hop
|
|
129
|
+
else:
|
|
130
|
+
while i < n:
|
|
131
|
+
seg = samples[i : i + frame_len]
|
|
132
|
+
if seg:
|
|
133
|
+
acc = 0.0
|
|
134
|
+
for x in seg:
|
|
135
|
+
acc += x * x
|
|
136
|
+
rms.append((acc / len(seg)) ** 0.5)
|
|
137
|
+
else:
|
|
138
|
+
rms.append(0.0)
|
|
139
|
+
i += hop
|
|
140
|
+
return rms, hop_sec
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def to_dbfs(rms: List[float], floor_db: float = -120.0) -> List[float]:
|
|
144
|
+
"""Convert linear RMS to dBFS with a floor to avoid log(0)."""
|
|
145
|
+
out = []
|
|
146
|
+
lin_floor = 10 ** (floor_db / 20.0)
|
|
147
|
+
for r in rms:
|
|
148
|
+
out.append(20.0 * math.log10(r if r > lin_floor else lin_floor))
|
|
149
|
+
return out
|
hotato/_engine/batch.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Run the scorer over a folder of scenarios and emit a results table.
|
|
2
|
+
|
|
3
|
+
Produces the Markdown table you can paste into a pull request to show a
|
|
4
|
+
change did not regress interruption handling, plus a machine-readable JSON
|
|
5
|
+
summary you can gate CI on.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from statistics import median
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from .audio import read_wav
|
|
17
|
+
from .score import ScoreConfig, evaluate, score_stereo
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Row:
|
|
22
|
+
scenario_id: str
|
|
23
|
+
category: str
|
|
24
|
+
expected_yield: bool
|
|
25
|
+
did_yield: bool
|
|
26
|
+
time_to_yield_sec: Optional[float]
|
|
27
|
+
talk_over_sec: float
|
|
28
|
+
passed: bool
|
|
29
|
+
reasons: List[str]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def load_scenarios(scenarios_dir: str) -> List[dict]:
|
|
33
|
+
scenarios = []
|
|
34
|
+
for name in sorted(os.listdir(scenarios_dir)):
|
|
35
|
+
if not name.endswith(".json"):
|
|
36
|
+
continue
|
|
37
|
+
if name == "manifest.json":
|
|
38
|
+
continue
|
|
39
|
+
with open(os.path.join(scenarios_dir, name), "r", encoding="utf-8") as fh:
|
|
40
|
+
scenarios.append(json.load(fh))
|
|
41
|
+
return scenarios
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run(
|
|
45
|
+
scenarios_dir: str,
|
|
46
|
+
audio_dir: str,
|
|
47
|
+
suffix: str = ".example.wav",
|
|
48
|
+
caller_channel: int = 0,
|
|
49
|
+
agent_channel: int = 1,
|
|
50
|
+
cfg: ScoreConfig = None,
|
|
51
|
+
) -> List[Row]:
|
|
52
|
+
rows: List[Row] = []
|
|
53
|
+
for sc in load_scenarios(scenarios_dir):
|
|
54
|
+
wav_path = os.path.join(audio_dir, sc["id"] + suffix)
|
|
55
|
+
if not os.path.exists(wav_path):
|
|
56
|
+
rows.append(
|
|
57
|
+
Row(
|
|
58
|
+
scenario_id=sc["id"],
|
|
59
|
+
category=sc.get("category", ""),
|
|
60
|
+
expected_yield=bool(sc.get("expected", {}).get("yield", True)),
|
|
61
|
+
did_yield=False,
|
|
62
|
+
time_to_yield_sec=None,
|
|
63
|
+
talk_over_sec=0.0,
|
|
64
|
+
passed=False,
|
|
65
|
+
reasons=[f"missing audio: {wav_path} (run scenarios/generate_fixtures.py)"],
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
continue
|
|
69
|
+
signal = read_wav(wav_path)
|
|
70
|
+
result = score_stereo(
|
|
71
|
+
signal,
|
|
72
|
+
caller_channel,
|
|
73
|
+
agent_channel,
|
|
74
|
+
caller_onset_sec=sc.get("caller_onset_sec"),
|
|
75
|
+
cfg=cfg,
|
|
76
|
+
)
|
|
77
|
+
verdict = evaluate(result, sc.get("expected", {}))
|
|
78
|
+
rows.append(
|
|
79
|
+
Row(
|
|
80
|
+
scenario_id=sc["id"],
|
|
81
|
+
category=sc.get("category", ""),
|
|
82
|
+
expected_yield=bool(sc.get("expected", {}).get("yield", True)),
|
|
83
|
+
did_yield=result.did_yield,
|
|
84
|
+
time_to_yield_sec=result.time_to_yield_sec,
|
|
85
|
+
talk_over_sec=result.talk_over_sec,
|
|
86
|
+
passed=verdict.passed,
|
|
87
|
+
reasons=verdict.reasons,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
return rows
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def summarize(rows: List[Row]) -> dict:
|
|
94
|
+
yield_cases = [r for r in rows if r.expected_yield]
|
|
95
|
+
yielded = [r for r in yield_cases if r.did_yield]
|
|
96
|
+
ttoys = [r.time_to_yield_sec for r in yielded if r.time_to_yield_sec is not None]
|
|
97
|
+
overs = [r.talk_over_sec for r in yield_cases]
|
|
98
|
+
return {
|
|
99
|
+
"scenarios": len(rows),
|
|
100
|
+
"passed": sum(1 for r in rows if r.passed),
|
|
101
|
+
"failed": sum(1 for r in rows if not r.passed),
|
|
102
|
+
"yield_rate": (len(yielded) / len(yield_cases)) if yield_cases else None,
|
|
103
|
+
"median_time_to_yield_sec": round(median(ttoys), 3) if ttoys else None,
|
|
104
|
+
"median_talk_over_sec": round(median(overs), 3) if overs else None,
|
|
105
|
+
"max_talk_over_sec": round(max(overs), 3) if overs else None,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _fmt(v) -> str:
|
|
110
|
+
return "-" if v is None else (f"{v:.2f}" if isinstance(v, float) else str(v))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_markdown(rows: List[Row], title: str = "Barge-in regression results") -> str:
|
|
114
|
+
s = summarize(rows)
|
|
115
|
+
lines = [f"### {title}", ""]
|
|
116
|
+
yr = f"{s['yield_rate'] * 100:.0f}%" if s["yield_rate"] is not None else "-"
|
|
117
|
+
lines.append(
|
|
118
|
+
f"**{s['passed']}/{s['scenarios']} scenarios pass** | "
|
|
119
|
+
f"yield rate on should-yield cases: {yr} | "
|
|
120
|
+
f"median time-to-yield: {_fmt(s['median_time_to_yield_sec'])}s | "
|
|
121
|
+
f"median talk-over: {_fmt(s['median_talk_over_sec'])}s"
|
|
122
|
+
)
|
|
123
|
+
lines.append("")
|
|
124
|
+
lines.append("| scenario | expected | did yield | time-to-yield (s) | talk-over (s) | result |")
|
|
125
|
+
lines.append("|---|---|---|---|---|---|")
|
|
126
|
+
for r in rows:
|
|
127
|
+
exp = "yield" if r.expected_yield else "hold floor"
|
|
128
|
+
did = "yes" if r.did_yield else "no"
|
|
129
|
+
res = "pass" if r.passed else "FAIL"
|
|
130
|
+
lines.append(
|
|
131
|
+
f"| {r.scenario_id} | {exp} | {did} | {_fmt(r.time_to_yield_sec)} | "
|
|
132
|
+
f"{_fmt(r.talk_over_sec)} | {res} |"
|
|
133
|
+
)
|
|
134
|
+
fails = [r for r in rows if not r.passed]
|
|
135
|
+
if fails:
|
|
136
|
+
lines.append("")
|
|
137
|
+
lines.append("**Failures**")
|
|
138
|
+
for r in fails:
|
|
139
|
+
for why in r.reasons:
|
|
140
|
+
lines.append(f"- `{r.scenario_id}`: {why}")
|
|
141
|
+
return "\n".join(lines) + "\n"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def to_json(rows: List[Row]) -> dict:
|
|
145
|
+
return {
|
|
146
|
+
"summary": summarize(rows),
|
|
147
|
+
"rows": [
|
|
148
|
+
{
|
|
149
|
+
"scenario_id": r.scenario_id,
|
|
150
|
+
"category": r.category,
|
|
151
|
+
"expected_yield": r.expected_yield,
|
|
152
|
+
"did_yield": r.did_yield,
|
|
153
|
+
"time_to_yield_sec": r.time_to_yield_sec,
|
|
154
|
+
"talk_over_sec": r.talk_over_sec,
|
|
155
|
+
"passed": r.passed,
|
|
156
|
+
"reasons": r.reasons,
|
|
157
|
+
}
|
|
158
|
+
for r in rows
|
|
159
|
+
],
|
|
160
|
+
}
|