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 +5 -0
- baari/cli.py +242 -0
- baari/clips.py +91 -0
- baari/detector.py +62 -0
- baari/evaluate.py +152 -0
- baari/features.py +189 -0
- baari/indicvoices.py +87 -0
- baari/labels.py +71 -0
- baari/settings.py +30 -0
- baari/spotcheck.html +69 -0
- baari/train.py +359 -0
- baari-0.1.0.dist-info/METADATA +166 -0
- baari-0.1.0.dist-info/RECORD +18 -0
- baari-0.1.0.dist-info/WHEEL +4 -0
- baari-0.1.0.dist-info/entry_points.txt +2 -0
- baari-0.1.0.dist-info/licenses/LICENSE +24 -0
- baari-0.1.0.dist-info/licenses/LICENSE.indic-smart-turn +27 -0
- baari-0.1.0.dist-info/licenses/LICENSE.pipecat +24 -0
baari/__init__.py
ADDED
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"
|