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.
Files changed (44) hide show
  1. hotato/__init__.py +33 -0
  2. hotato/_engine/__init__.py +47 -0
  3. hotato/_engine/__main__.py +131 -0
  4. hotato/_engine/audio.py +149 -0
  5. hotato/_engine/batch.py +160 -0
  6. hotato/_engine/score.py +358 -0
  7. hotato/_engine/vad.py +224 -0
  8. hotato/_stats.py +47 -0
  9. hotato/aggregate.py +346 -0
  10. hotato/benchmark.py +627 -0
  11. hotato/capture.py +615 -0
  12. hotato/cli.py +897 -0
  13. hotato/core.py +537 -0
  14. hotato/data/audio/01-hard-interruption.example.wav +0 -0
  15. hotato/data/audio/02-backchannel-mhm.example.wav +0 -0
  16. hotato/data/audio/03-filler-start.example.wav +0 -0
  17. hotato/data/audio/04-correction.example.wav +0 -0
  18. hotato/data/audio/05-telephony-8khz.example.wav +0 -0
  19. hotato/data/audio/06-double-talk.example.wav +0 -0
  20. hotato/data/audio/07-echo-bleed.example.wav +0 -0
  21. hotato/data/audio/08-rapid-turn-taking.example.wav +0 -0
  22. hotato/data/scenarios/01-hard-interruption.json +26 -0
  23. hotato/data/scenarios/02-backchannel-mhm.json +26 -0
  24. hotato/data/scenarios/03-filler-start.json +26 -0
  25. hotato/data/scenarios/04-correction.json +26 -0
  26. hotato/data/scenarios/05-telephony-8khz.json +26 -0
  27. hotato/data/scenarios/06-double-talk.json +26 -0
  28. hotato/data/scenarios/07-echo-bleed.json +29 -0
  29. hotato/data/scenarios/08-rapid-turn-taking.json +27 -0
  30. hotato/data/scenarios/manifest.json +76 -0
  31. hotato/export.py +204 -0
  32. hotato/fixmap.py +332 -0
  33. hotato/mcp_server.py +181 -0
  34. hotato/neural.py +101 -0
  35. hotato/pytest_plugin.py +127 -0
  36. hotato/report.py +1214 -0
  37. hotato/schema/envelope.v1.json +170 -0
  38. hotato/stackbench.py +534 -0
  39. hotato-0.1.0.dist-info/METADATA +257 -0
  40. hotato-0.1.0.dist-info/RECORD +44 -0
  41. hotato-0.1.0.dist-info/WHEEL +5 -0
  42. hotato-0.1.0.dist-info/entry_points.txt +6 -0
  43. hotato-0.1.0.dist-info/licenses/LICENSE +21 -0
  44. 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())
@@ -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
@@ -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
+ }