baari 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.
baari/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """End-of-turn detection for Urdu voice agents."""
2
+
3
+ from baari.detector import TurnDetector, model_path
4
+
5
+ __all__ = ["TurnDetector", "model_path"]
baari/cli.py ADDED
@@ -0,0 +1,242 @@
1
+ import json
2
+ import random
3
+ import zlib
4
+ from collections import defaultdict
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ try:
9
+ import pyarrow as pa
10
+ import pyarrow.parquet as pq
11
+ import typer
12
+
13
+ from baari import clips, indicvoices, labels
14
+ from baari.settings import Settings
15
+ except ImportError as e:
16
+ raise SystemExit(
17
+ f'The baari command needs the pipeline extras: pip install "baari[pipeline]" ({e})'
18
+ ) from e
19
+
20
+ app = typer.Typer(no_args_is_help=True)
21
+
22
+
23
+ def load_index(s: Settings, split: str) -> list[dict]:
24
+ rows = pq.read_table(s.index_dir / f"{split}.parquet").to_pylist()
25
+ if split == "valid":
26
+ return rows
27
+ return indicvoices.without_test(rows, pq.read_table(s.index_dir / "valid.parquet").to_pylist())
28
+
29
+
30
+ @app.command()
31
+ def scan(split: str = "valid") -> None:
32
+ """Index the conversation segments of one IndicVoices Urdu split (text only, no audio)."""
33
+ s = Settings()
34
+ rows = [
35
+ r
36
+ for shard in indicvoices.shards(split, s.hf_token)
37
+ for r in indicvoices.index_shard(shard, s.hf_token)
38
+ ]
39
+ s.index_dir.mkdir(parents=True, exist_ok=True)
40
+ pq.write_table(pa.Table.from_pylist(rows), s.index_dir / f"{split}.parquet")
41
+ hours = sum(r["duration"] for r in rows) / 3600
42
+ typer.echo(
43
+ f"{split}: {len(rows)} segments, {len({r['session'] for r in rows})} sessions, "
44
+ f"{len({r['speaker_id'] for r in rows})} speakers, {hours:.1f} h"
45
+ )
46
+
47
+
48
+ @app.command()
49
+ def batches(split: str = "valid", max_segments: int = 350) -> None:
50
+ """Write label batches for the LLM agents."""
51
+ s = Settings()
52
+ paths = labels.write_batches(load_index(s, split), s.labels_dir / split / "batches", max_segments)
53
+ typer.echo(f"wrote {len(paths)} batches to {paths[0].parent}")
54
+
55
+
56
+ def split_of(split: str, speaker_id: str, agree: bool) -> str:
57
+ """valid becomes the test set; any other split is cut by speaker into train and a 5% dev set."""
58
+ if split == "valid":
59
+ return "test" if agree else "test_ambiguous"
60
+ return "dev" if zlib.crc32(speaker_id.encode()) % 20 == 0 else "train"
61
+
62
+
63
+ @app.command()
64
+ def build(split: str = "valid", labelers: str = "a,b", seed: int = 0, delete_shards: bool = False) -> None:
65
+ """Cut 8 s clips for every labelled segment, one shard at a time, and write them to data/clips/<split>.parquet.
66
+
67
+ For valid, agreeing labels go to 'test' and the rest to 'test_ambiguous'. Labeler order matters: the
68
+ first labeler's label is the clip's label and sets the context gaps.
69
+ """
70
+ s = Settings()
71
+ index = load_index(s, split)
72
+ names = labelers.split(",")
73
+ results = {n: labels.load_results(s.labels_dir / split / "results" / n, index) for n in names}
74
+ sessions = labels.sessions(index)
75
+ rows_of, need, in_shard = defaultdict(set), defaultdict(set), defaultdict(set)
76
+ for r in index:
77
+ rows_of[r["shard"]].add(r["row"])
78
+ need[r["session"]].add(r["shard"])
79
+ in_shard[r["shard"]].add(r["session"])
80
+ rng, audio, counts, writer = random.Random(seed), {}, defaultdict(int), None
81
+ path = s.clips_dir / f"{split}.parquet"
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ for n, shard in enumerate(sorted(rows_of), 1):
84
+ got = indicvoices.shard_audio(shard, rows_of[shard], s.hf_token, s.hf_cache, delete=delete_shards)
85
+ audio |= {(shard, i): x for i, x in got.items()}
86
+ out = []
87
+ # A few sessions span two shards; cut them once all their audio is in.
88
+ for sid in [
89
+ sid for sid in sessions if sid in in_shard[shard]
90
+ ]: # index order keeps the gaps reproducible
91
+ need[sid].discard(shard)
92
+ if need[sid]:
93
+ continue
94
+ chunks = sessions[sid]
95
+ wavs = {
96
+ c["chunk"]: audio.pop((c["shard"], c["row"]))
97
+ for c in chunks
98
+ if (c["shard"], c["row"]) in audio
99
+ }
100
+ for c, clip in zip(chunks, clips.build(chunks, wavs, results[names[0]], rng), strict=True):
101
+ if clip is None:
102
+ continue
103
+ votes = {n: results[n][(sid, c["chunk"])] for n in names}
104
+ row = {
105
+ "audio": clip,
106
+ "endpoint_bool": votes[names[0]].complete,
107
+ "split": split_of(split, c["speaker_id"], len({v.complete for v in votes.values()}) == 1),
108
+ "session": sid,
109
+ "chunk": c["chunk"],
110
+ "speaker_id": c["speaker_id"],
111
+ "spoken_text": c["text"],
112
+ **{f"label_{n}": v.complete for n, v in votes.items()},
113
+ **{f"confidence_{n}": v.confidence for n, v in votes.items()},
114
+ }
115
+ counts[row["split"], row["endpoint_bool"]] += 1
116
+ out.append(row)
117
+ if out:
118
+ table = clips.table(out)
119
+ writer = writer or pq.ParquetWriter(path, table.schema)
120
+ writer.write_table(table, row_group_size=500)
121
+ typer.echo(f"[{n}/{len(rows_of)}] {shard}: {len(out)} clips", err=True)
122
+ if writer:
123
+ writer.close()
124
+ typer.echo(
125
+ f"{sum(counts.values())} clips: "
126
+ + json.dumps({f"{k[0]}/{'finished' if k[1] else 'unfinished'}": v for k, v in sorted(counts.items())})
127
+ )
128
+
129
+
130
+ @app.command()
131
+ def spotcheck(
132
+ split: str = "valid",
133
+ per_class: int = 25,
134
+ unfinished_only: bool = False,
135
+ skip: Path | None = None,
136
+ name: str = "spotcheck",
137
+ seed: int = 0,
138
+ ) -> None:
139
+ """Write a local listening page of clean test clips: a random sample of each label, or every
140
+ unfinished clip. Clips already answered in the `skip` file are left out."""
141
+ from importlib.resources import files
142
+
143
+ s = Settings()
144
+ done = set(json.loads(skip.read_text())) if skip else set()
145
+ rows = [
146
+ r
147
+ for r in pq.read_table(s.clips_dir / f"{split}.parquet").to_pylist()
148
+ if r["split"] == "test" and f"{r['session']}|{r['chunk']}" not in done
149
+ ]
150
+ rng = random.Random(seed)
151
+ if unfinished_only:
152
+ picked = [r for r in rows if not r["endpoint_bool"]]
153
+ else:
154
+ picked = [
155
+ r
156
+ for label in (False, True)
157
+ for r in rng.sample([r for r in rows if r["endpoint_bool"] is label], per_class)
158
+ ]
159
+ rng.shuffle(picked)
160
+ out = s.data_dir / name
161
+ out.mkdir(parents=True, exist_ok=True)
162
+ clips_meta = []
163
+ for r in picked:
164
+ wav = f"{r['session']}_{r['chunk']}.wav"
165
+ (out / wav).write_bytes(r["audio"]["bytes"])
166
+ clips_meta.append(
167
+ {
168
+ "id": f"{r['session']}|{r['chunk']}",
169
+ "file": wav,
170
+ "finished": r["endpoint_bool"],
171
+ "text": r["spoken_text"],
172
+ }
173
+ )
174
+ page = (
175
+ files("baari")
176
+ .joinpath("spotcheck.html")
177
+ .read_text()
178
+ .replace("__CLIPS__", json.dumps(clips_meta, ensure_ascii=False))
179
+ .replace("__NAME__", name)
180
+ )
181
+ (out / "index.html").write_text(page)
182
+ typer.echo(f"open {out / 'index.html'}")
183
+
184
+
185
+ @app.command()
186
+ def evaluate(split: str = "valid", subset: str = "test") -> None:
187
+ """Score the existing turn detectors on the clips."""
188
+ from baari import evaluate as ev
189
+
190
+ s = Settings()
191
+ report = ev.run(s.clips_dir / f"{split}.parquet", subset, s.hf_cache)
192
+ s.reports_dir.mkdir(parents=True, exist_ok=True)
193
+ (s.reports_dir / f"{split}-{subset}.md").write_text(report)
194
+ typer.echo(report)
195
+
196
+
197
+ @app.command()
198
+ def train(
199
+ clips: Path | None = None,
200
+ test: Path | None = None,
201
+ extra: Annotated[
202
+ list[Path] | None, typer.Option(help="More clip files; their 'train' rows are mixed in.")
203
+ ] = None,
204
+ init: Annotated[str, typer.Option(help="fp32 ONNX to start from: a path or repo:file")] = (
205
+ "adimyth/indic-smart-turn:indic-smart-turn-tiny-fp32.onnx"
206
+ ),
207
+ out: Path = Path("data/models/run"),
208
+ epochs: float = 3,
209
+ lr: float = 2e-5,
210
+ batch: int = 64,
211
+ workers: int = 4,
212
+ max_steps: int = 0,
213
+ seed: int = 0,
214
+ ) -> None:
215
+ """Fine-tune on Urdu clips (picked on the dev split), export fp32 and int8 ONNX, score on the test clips."""
216
+ from huggingface_hub import hf_hub_download
217
+
218
+ from baari import train as tr
219
+
220
+ s = Settings()
221
+ start = (
222
+ Path(init)
223
+ if Path(init).exists()
224
+ else Path(hf_hub_download(*init.split(":", 1), cache_dir=s.hf_cache))
225
+ )
226
+ log = tr.run(
227
+ clips or s.clips_dir / "train.parquet",
228
+ test or s.clips_dir / "valid.parquet",
229
+ start,
230
+ out,
231
+ extra or [],
232
+ epochs=epochs,
233
+ lr=lr,
234
+ batch=batch,
235
+ seed=seed,
236
+ workers=workers,
237
+ cache=s.hf_cache,
238
+ max_steps=max_steps,
239
+ )
240
+ typer.echo(
241
+ json.dumps({k: log[k] for k in ("sizes", "start", "onnx_fp32_test", "onnx_int8_test")}, indent=1)
242
+ )
baari/clips.py ADDED
@@ -0,0 +1,91 @@
1
+ """Turn labelled segments into Smart Turn style clips: the last 8 s of 16 kHz audio.
2
+
3
+ Adapted from indic_turn/build.py in Indic Smart Turn (https://github.com/adimyth/indic-smart-turn).
4
+ Copyright (c) 2026, adimyth. BSD-2-Clause; see LICENSE.indic-smart-turn.
5
+
6
+ A segment's clip is the segment plus the preceding consecutive segments of the same speaker,
7
+ joined with a short gap after an unfinished segment and a long one after a finished segment
8
+ (the other person would have spoken in between), with trailing silence trimmed to 0.2 s.
9
+ """
10
+
11
+ import io
12
+ import random
13
+
14
+ import numpy as np
15
+ import pyarrow as pa
16
+ import soundfile as sf
17
+
18
+ from baari.indicvoices import SR
19
+ from baari.labels import Key, SegmentLabel
20
+
21
+ WINDOW = 8 * SR
22
+ TRAIL = int(0.2 * SR)
23
+
24
+
25
+ def frame_energy(x: np.ndarray, win: int = 400, hop: int = 160) -> np.ndarray:
26
+ n = max(0, (len(x) - win) // hop + 1)
27
+ if n == 0:
28
+ return np.zeros(0)
29
+ idx = np.arange(win)[None, :] + hop * np.arange(n)[:, None]
30
+ return np.sqrt((x[idx] ** 2).mean(axis=1) + 1e-12)
31
+
32
+
33
+ def trim_trailing_silence(
34
+ x: np.ndarray, rel: float = 0.03, floor: float = 1e-3, hop: int = 160
35
+ ) -> np.ndarray:
36
+ e = frame_energy(x, hop=hop)
37
+ if len(e) == 0:
38
+ return x
39
+ voiced = np.flatnonzero(e > max(floor, rel * e.max()))
40
+ if len(voiced) == 0:
41
+ return x
42
+ return x[: min(len(x), (voiced[-1] + 1) * hop + 400)]
43
+
44
+
45
+ def fit_window(x: np.ndarray) -> np.ndarray:
46
+ x = np.concatenate([trim_trailing_silence(x), np.zeros(TRAIL, np.float32)])
47
+ return x[-WINDOW:]
48
+
49
+
50
+ def build(
51
+ chunks: list[dict], audio: dict[int, np.ndarray], labels: dict[Key, SegmentLabel], rng: random.Random
52
+ ) -> list[np.ndarray | None]:
53
+ """One clip per chunk of one session, in order; None where the chunk's audio is missing."""
54
+ clips = []
55
+ for i, c in enumerate(chunks):
56
+ if c["chunk"] not in audio:
57
+ clips.append(None)
58
+ continue
59
+ parts, total, j = [audio[c["chunk"]]], len(audio[c["chunk"]]), i - 1
60
+ while (
61
+ total < WINDOW
62
+ and j >= 0
63
+ and chunks[j]["chunk"] == chunks[j + 1]["chunk"] - 1
64
+ and chunks[j]["chunk"] in audio
65
+ ):
66
+ prev = chunks[j]
67
+ finished = labels[(prev["session"], prev["chunk"])].complete
68
+ gap = np.zeros(
69
+ int((rng.uniform(1.5, 4.0) if finished else rng.uniform(0.15, 0.6)) * SR), np.float32
70
+ )
71
+ parts = [audio[prev["chunk"]], gap, *parts]
72
+ total += len(gap) + len(audio[prev["chunk"]])
73
+ j -= 1
74
+ clips.append(fit_window(np.concatenate(parts)))
75
+ return clips
76
+
77
+
78
+ def wav_bytes(x: np.ndarray) -> bytes:
79
+ buf = io.BytesIO()
80
+ sf.write(buf, x, SR, format="WAV", subtype="PCM_16")
81
+ return buf.getvalue()
82
+
83
+
84
+ def table(rows: list[dict]) -> pa.Table:
85
+ """rows carry 'audio' as float32 arrays; stored as 16-bit WAV bytes, as Hugging Face Audio expects."""
86
+ return pa.Table.from_pylist(
87
+ [
88
+ {**r, "audio": {"bytes": wav_bytes(r["audio"]), "path": f"{r['session']}_{r['chunk']}.wav"}}
89
+ for r in rows
90
+ ]
91
+ )
baari/detector.py ADDED
@@ -0,0 +1,62 @@
1
+ """Run baari on audio. Needs only numpy and onnxruntime."""
2
+
3
+ import os
4
+ import urllib.request
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import onnxruntime as ort
9
+
10
+ from baari.features import compute_whisper_log_mel_features
11
+
12
+ SAMPLE_RATE = 16000
13
+ MODEL_URL = "https://huggingface.co/samadpls/baari/resolve/main/baari-int8.onnx"
14
+
15
+
16
+ def model_path() -> str:
17
+ """Path to baari's int8 ONNX file, downloaded once into ~/.cache/baari (or $BAARI_CACHE)."""
18
+ path = Path(os.environ.get("BAARI_CACHE", Path.home() / ".cache" / "baari")) / "baari-int8.onnx"
19
+ if not path.exists():
20
+ path.parent.mkdir(parents=True, exist_ok=True)
21
+ part = path.with_suffix(".part")
22
+ try:
23
+ urllib.request.urlretrieve(MODEL_URL, part)
24
+ except OSError as e:
25
+ raise RuntimeError(
26
+ f"Could not download the baari model from {MODEL_URL} ({e}). "
27
+ "Pass TurnDetector(path=...) to use a local Smart Turn style ONNX file."
28
+ ) from e
29
+ part.replace(path)
30
+ return str(path)
31
+
32
+
33
+ class TurnDetector:
34
+ """Decides, at a pause, whether an Urdu speaker has finished their turn.
35
+
36
+ threads: CPU threads for one call. 1 suits a server handling many calls at once; 4 makes a single
37
+ call about three times faster.
38
+ """
39
+
40
+ def __init__(self, path: str | None = None, threads: int = 1):
41
+ options = ort.SessionOptions()
42
+ options.intra_op_num_threads = threads
43
+ options.inter_op_num_threads = 1
44
+ self.session = ort.InferenceSession(
45
+ path or model_path(), sess_options=options, providers=["CPUExecutionProvider"]
46
+ )
47
+
48
+ def probability(self, audio: np.ndarray) -> float:
49
+ """Probability that the speaker has finished. `audio` is 16 kHz mono, float or int16, ending at
50
+ the pause. Only the last 8 seconds are used."""
51
+ x = np.asarray(audio)
52
+ if x.ndim != 1:
53
+ raise ValueError(f"expected mono audio as a 1-D array, got shape {x.shape}")
54
+ x = x / 32768.0 if x.dtype == np.int16 else x
55
+ n = 8 * SAMPLE_RATE
56
+ x = x[-n:] if len(x) > n else np.pad(x, (n - len(x), 0))
57
+ features = compute_whisper_log_mel_features(x.astype(np.float32))[None]
58
+ return float(np.asarray(self.session.run(None, {"input_features": features})[0]).ravel()[0])
59
+
60
+ def is_finished(self, audio: np.ndarray, threshold: float = 0.5) -> bool:
61
+ """True when the speaker has finished and the agent should reply."""
62
+ return self.probability(audio) > threshold
baari/evaluate.py ADDED
@@ -0,0 +1,152 @@
1
+ """Score existing end-of-turn models on the Urdu clips.
2
+
3
+ Smart Turn style models take an 80 x 800 log-mel of the last 8 s and return P(finished).
4
+ PuristanLabs1/urdu-turn-v2 is a PyTorch Whisper-tiny encoder with attention pooling, trained on
5
+ 3 s clips; its documented package is not on PyPI, so it is rebuilt here from its model.py.
6
+ """
7
+
8
+ import io
9
+ from pathlib import Path
10
+
11
+ import numpy as np
12
+ import onnxruntime as ort
13
+ import pyarrow.parquet as pq
14
+ import soundfile as sf
15
+ from huggingface_hub import hf_hub_download
16
+ from sklearn.metrics import roc_auc_score
17
+ from transformers import WhisperFeatureExtractor
18
+
19
+ from baari.features import compute_whisper_log_mel_features
20
+ from baari.indicvoices import SR
21
+
22
+ ONNX_MODELS = {
23
+ "Smart Turn v3.2 (CPU, int8)": ("pipecat-ai/smart-turn-v3", "smart-turn-v3.2-cpu.onnx"),
24
+ "Smart Turn v3.2 (GPU, fp32)": ("pipecat-ai/smart-turn-v3", "smart-turn-v3.2-gpu.onnx"),
25
+ "Indic Smart Turn tiny (int8)": ("adimyth/indic-smart-turn", "indic-smart-turn-tiny-int8.onnx"),
26
+ "Indic Smart Turn tiny (fp32)": ("adimyth/indic-smart-turn", "indic-smart-turn-tiny-fp32.onnx"),
27
+ "Indic Smart Turn base (fp32)": ("adimyth/indic-smart-turn", "indic-smart-turn-base-fp32.onnx"),
28
+ }
29
+ PURISTAN = "PuristanLabs1/urdu-turn-v2 (Urdu)"
30
+
31
+
32
+ def last(x: np.ndarray, seconds: int) -> np.ndarray:
33
+ n = seconds * SR
34
+ return x[-n:] if len(x) > n else np.pad(x, (n - len(x), 0))
35
+
36
+
37
+ class OnnxModel:
38
+ """Runs a Smart Turn style ONNX file the way Pipecat does: its numpy log-mel features, not
39
+ transformers'. int8 models can change their answer on about 1 clip in 100 between the two, which
40
+ differ by about 1e-5, so scores must use the features people will run."""
41
+
42
+ def __init__(self, path: str):
43
+ self.sess = ort.InferenceSession(path, providers=["CPUExecutionProvider"])
44
+ self.input = self.sess.get_inputs()[0].name
45
+
46
+ def __call__(self, wavs: list[np.ndarray]) -> np.ndarray:
47
+ feats = np.stack([compute_whisper_log_mel_features(last(w, 8).astype(np.float32)) for w in wavs])
48
+ p = np.asarray(self.sess.run(None, {self.input: feats})[0], dtype=np.float64).reshape(len(wavs), -1)[
49
+ :, -1
50
+ ]
51
+ return p if 0 <= p.min() and p.max() <= 1 else 1 / (1 + np.exp(-p))
52
+
53
+
54
+ class PuristanModel:
55
+ def __init__(self, cache: Path):
56
+ import torch
57
+ from torch import nn
58
+ from transformers import WhisperConfig, WhisperModel
59
+
60
+ class WhisperTurnDetector(
61
+ nn.Module
62
+ ): # same layout as the repo's model.py, so the state dict loads strictly
63
+ def __init__(self):
64
+ super().__init__()
65
+ self.whisper = WhisperModel(
66
+ WhisperConfig.from_pretrained("openai/whisper-tiny", cache_dir=cache)
67
+ )
68
+ h = self.whisper.config.d_model
69
+ self.attn = nn.Sequential(nn.Linear(h, 128), nn.Tanh(), nn.Linear(128, 1))
70
+ self.silence_embedding = nn.Parameter(torch.zeros(h))
71
+ self.classifier = nn.Sequential(
72
+ nn.Linear(h, 128), nn.ReLU(), nn.Dropout(0.1), nn.Linear(128, 2)
73
+ )
74
+
75
+ def forward(self, feats, mask):
76
+ hidden = self.whisper.encoder(feats).last_hidden_state
77
+ weights = self.attn(hidden).squeeze(-1).masked_fill(mask[:, ::2] == 0, -1e9)
78
+ pooled = torch.bmm(torch.softmax(weights, dim=1).unsqueeze(1), hidden).squeeze(1)
79
+ return self.classifier(pooled)
80
+
81
+ self.torch = torch
82
+ self.model = WhisperTurnDetector().eval()
83
+ weights = hf_hub_download("PuristanLabs1/urdu-turn-v2", "whisper_turn_model.pth", cache_dir=cache)
84
+ self.model.load_state_dict(torch.load(weights, map_location="cpu", weights_only=True))
85
+ self.fe = WhisperFeatureExtractor.from_pretrained("openai/whisper-tiny", cache_dir=cache)
86
+
87
+ def __call__(self, wavs: list[np.ndarray], seconds: int = 3) -> np.ndarray:
88
+ f = self.fe(
89
+ [w[-seconds * SR :] for w in wavs],
90
+ sampling_rate=SR,
91
+ return_tensors="pt",
92
+ return_attention_mask=True,
93
+ )
94
+ with self.torch.no_grad():
95
+ logits = self.model(f.input_features, f.attention_mask)
96
+ return self.torch.softmax(logits, dim=1)[:, 1].numpy().astype(np.float64)
97
+
98
+
99
+ def load_clips(path: Path, subset: str) -> tuple[list[np.ndarray], np.ndarray]:
100
+ t = pq.read_table(path).to_pydict()
101
+ keep = [i for i, s in enumerate(t["split"]) if subset == "all" or s == subset]
102
+ wavs = [sf.read(io.BytesIO(t["audio"][i]["bytes"]), dtype="float32")[0] for i in keep]
103
+ return wavs, np.array([int(t["endpoint_bool"][i]) for i in keep])
104
+
105
+
106
+ def score(model, wavs: list[np.ndarray], batch: int = 32) -> np.ndarray:
107
+ return np.concatenate([model(wavs[i : i + batch]) for i in range(0, len(wavs), batch)])
108
+
109
+
110
+ def balanced(pred: np.ndarray, y: np.ndarray) -> float:
111
+ return ((pred[y == 0] == 0).mean() + (pred[y == 1] == 1).mean()) / 2
112
+
113
+
114
+ def metrics(p: np.ndarray, y: np.ndarray, resamples: int = 1000, seed: int = 0) -> dict:
115
+ """Balanced accuracy is the headline: most clips are finished, so plain accuracy rewards always saying 'finished'."""
116
+ pred = (p > 0.5).astype(int)
117
+ rng = np.random.default_rng(seed)
118
+ boot = [balanced(pred[i], y[i]) for i in (rng.integers(0, len(y), len(y)) for _ in range(resamples))]
119
+ return {
120
+ "balanced": balanced(pred, y),
121
+ "ci": np.quantile(boot, [0.025, 0.975]),
122
+ "acc": (pred == y).mean(),
123
+ "waits": (pred[y == 0] == 0).mean(),
124
+ "replies": (pred[y == 1] == 1).mean(),
125
+ "auc": roc_auc_score(y, p) if p.std() > 0 else 0.5,
126
+ }
127
+
128
+
129
+ def run(clips: Path, subset: str, cache: Path) -> str:
130
+ wavs, y = load_clips(clips, subset)
131
+ models = {
132
+ name: OnnxModel(hf_hub_download(repo, f, cache_dir=cache)) for name, (repo, f) in ONNX_MODELS.items()
133
+ }
134
+ models[PURISTAN] = PuristanModel(cache)
135
+ lines = [
136
+ f"# Existing models on Urdu clips ({clips.stem}, {subset})",
137
+ "",
138
+ f"{len(y)} clips: {int(y.sum())} finished, {int(len(y) - y.sum())} unfinished. Threshold 0.5.",
139
+ "Balanced accuracy is the mean of the two middle columns.",
140
+ "",
141
+ "| Model | Balanced accuracy (95% CI) | Waits when unfinished | Replies when finished | Plain accuracy | AUC |",
142
+ "|---|---|---|---|---|---|",
143
+ ]
144
+ scores = {"Always says finished (reference)": np.ones(len(y))}
145
+ scores |= {name: score(model, wavs) for name, model in models.items()}
146
+ for name, p in scores.items():
147
+ m = metrics(p, y)
148
+ lines.append(
149
+ f"| {name} | {100 * m['balanced']:.1f}% ({100 * m['ci'][0]:.1f}–{100 * m['ci'][1]:.1f}) | "
150
+ f"{100 * m['waits']:.1f}% | {100 * m['replies']:.1f}% | {100 * m['acc']:.1f}% | {m['auc']:.3f} |"
151
+ )
152
+ return "\n".join(lines) + "\n"