Skip to content

Commit 0327199

Browse files
authored
Merge branch 'main' into fix-transcribe-empty-source
2 parents f662a57 + af7d435 commit 0327199

6 files changed

Lines changed: 351 additions & 57 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ neither.
6262
- **Real-time streaming**: `assembly stream` transcribes the microphone, a file, or a URL live — on macOS it can capture system audio too.
6363
- **Voice agent**: `assembly agent` runs a full-duplex spoken conversation in your terminal (use headphones).
6464
- **LLM Gateway**: `assembly llm` prompts an LLM over a transcript, stdin, or a live stream (`assembly stream --llm "summarize as I talk"`).
65-
- **Model evaluation**: `assembly eval` transcribes a Hugging Face dataset or a local `.csv`/`.jsonl` manifest and scores WER against its references (plus DER with `--speaker-labels`) — handy for picking a speech model.
65+
- **Model evaluation**: `assembly eval` transcribes a Hugging Face dataset (with built-in aliases for common benchmarks: `assembly eval tedlium`) or a local `.csv`/`.jsonl` manifest and scores WER against its references (plus DER with `--speaker-labels`) — handy for picking a speech model.
6666
- **Starter apps**: `assembly init` scaffolds a self-contained FastAPI + HTML app (`audio-transcription`, `live-captions`, `voice-agent`).
6767
- **Code generation**: add `--show-code` to `transcribe`/`stream`/`agent` to print the equivalent Python SDK script instead of running.
6868
- **Account self-service**: `assembly keys` / `balance` / `usage` / `limits` / `sessions` / `audit` via browser login.

aai_cli/commands/evaluate.py

Lines changed: 98 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from __future__ import annotations
1010

11+
from concurrent.futures import ThreadPoolExecutor, as_completed
1112
from dataclasses import dataclass
1213
from enum import StrEnum
1314

@@ -70,6 +71,52 @@ def _score_item(
7071
return _ItemResult(row=row, words=words, speakers=speakers)
7172

7273

74+
def _transcripts(
75+
api_key: str,
76+
items: list[eval_data.EvalItem],
77+
*,
78+
transcription_config: aai.TranscriptionConfig,
79+
concurrency: int,
80+
json_mode: bool,
81+
quiet: bool,
82+
) -> list[aai.Transcript]:
83+
"""Each item's transcript, in dataset order.
84+
85+
Sequential by default, with a per-item spinner; ``--concurrency`` fans the
86+
API calls out across a thread pool (the transcribe-batch pattern: the first
87+
worker error drops the not-yet-started items and re-raises).
88+
"""
89+
if concurrency == 1:
90+
transcripts: list[aai.Transcript] = []
91+
for index, item in enumerate(items, start=1):
92+
with output.status(
93+
f"[{index}/{len(items)}] Transcribing {item.item_id}…",
94+
json_mode=json_mode,
95+
quiet=quiet,
96+
):
97+
transcripts.append(
98+
client.transcribe(api_key, item.audio, config=transcription_config)
99+
)
100+
return transcripts
101+
with (
102+
output.status(
103+
f"Transcribing {len(items)} items (concurrency {concurrency})…",
104+
json_mode=json_mode,
105+
quiet=quiet,
106+
),
107+
ThreadPoolExecutor(max_workers=concurrency) as pool,
108+
):
109+
futures = [
110+
pool.submit(client.transcribe, api_key, item.audio, config=transcription_config)
111+
for item in items
112+
]
113+
for future in as_completed(futures):
114+
if (exc := future.exception()) is not None:
115+
pool.shutdown(cancel_futures=True)
116+
raise exc
117+
return [future.result() for future in futures]
118+
119+
73120
def _payload(
74121
label: str, speech_model: EvalSpeechModel | None, results: list[_ItemResult]
75122
) -> dict[str, object]:
@@ -85,7 +132,13 @@ def _payload(
85132
payload.update({"words": total.words, "errors": total.errors, "wer": total.wer})
86133
der_scores = [result.speakers for result in results if result.speakers is not None]
87134
if der_scores:
88-
payload["der"] = der.pooled(der_scores).der
135+
pooled = der.pooled(der_scores)
136+
payload["der"] = pooled.der
137+
payload["der_breakdown"] = {
138+
"missed": pooled.missed / pooled.total,
139+
"false_alarm": pooled.false_alarm / pooled.total,
140+
"confusion": pooled.confusion / pooled.total,
141+
}
89142
return payload
90143

91144

@@ -98,7 +151,12 @@ def _summary(payload: dict[str, object]) -> str:
98151
f"WER {_pct(payload.get('wer'))} ({errors} {noun} / {payload.get('words')} words)"
99152
)
100153
if "der" in payload:
101-
parts.append(f"DER {_pct(payload.get('der'))}")
154+
breakdown = jsonshape.as_mapping(payload.get("der_breakdown")) or {}
155+
parts.append(
156+
f"DER {_pct(payload.get('der'))} (missed {_pct(breakdown.get('missed'))} · "
157+
f"false alarm {_pct(breakdown.get('false_alarm'))} · "
158+
f"confusion {_pct(breakdown.get('confusion'))})"
159+
)
102160
return output.heading(" ".join(parts))
103161

104162

@@ -130,8 +188,8 @@ def _render(payload: dict[str, object]) -> RenderableType:
130188
epilog=examples_epilog(
131189
[
132190
(
133-
"Score a model on 10 rows of an HF dataset",
134-
"assembly eval sanchit-gandhi/tedlium-data",
191+
"Score a model on 10 rows of a benchmark",
192+
"assembly eval tedlium",
135193
),
136194
(
137195
"Compare models on your own audio",
@@ -142,16 +200,16 @@ def _render(payload: dict[str, object]) -> RenderableType:
142200
"assembly eval agent-calls.jsonl --speaker-labels",
143201
),
144202
(
145-
"Pick a subset/split and more rows",
146-
"assembly eval openslr/librispeech_asr --subset clean --limit 50",
203+
"More rows, transcribed four at a time",
204+
"assembly eval librispeech --limit 50 --concurrency 4",
147205
),
148206
(
149207
"Evaluate non-English audio",
150-
"assembly eval fixie-ai/common_voice_17_0 --subset fr --language-code fr",
208+
"assembly eval commonvoice --subset fr --language-code fr",
151209
),
152210
(
153-
"DER on a Hugging Face diarization set",
154-
"assembly eval talkbank/callhome --subset eng --speaker-labels",
211+
"DER on a diarization benchmark",
212+
"assembly eval callhome --speaker-labels",
155213
),
156214
]
157215
),
@@ -192,6 +250,12 @@ def evaluate(
192250
min=0.0,
193251
help="DER forgiveness (seconds) around each reference turn boundary.",
194252
),
253+
concurrency: int = typer.Option(
254+
1,
255+
"--concurrency",
256+
min=1,
257+
help="How many items to transcribe at once (sequential by default).",
258+
),
195259
json_out: bool = options.json_option("Output the rows and summary as one JSON object."),
196260
) -> None:
197261
"""Transcribe an evaluation dataset and score WER against its reference texts.
@@ -204,21 +268,15 @@ def evaluate(
204268
against reference speaker turns.
205269
206270
Datasets come from the Hugging Face Hub (any public dataset its viewer
207-
serves with audio + reference columns; gated ones need HF_TOKEN) or a local
208-
.csv/.jsonl manifest with audio + text columns. Hub sets to try:
209-
openslr/librispeech_asr (read English; subsets clean/other),
210-
sanchit-gandhi/tedlium-data (TED talks),
211-
sanchit-gandhi/earnings22_robust_split (earnings calls),
212-
kensho/spgispeech (financial calls; subset test),
213-
edinburghcstr/ami (meetings; subsets ihm/sdm),
214-
fixie-ai/gigaspeech (--subset dev --split dev),
215-
fixie-ai/peoples_speech (real-world US English; subset clean),
216-
fixie-ai/common_voice_17_0 (99 locales; subsets like en/fr),
217-
facebook/voxpopuli (parliament speech; subset en),
218-
hhoangphuoc/switchboard (phone calls; --split validation),
219-
ylacombe/expresso (expressive speech),
220-
speechbrain/LoquaciousSet (--subset small --audio-column wav), and
221-
talkbank/callhome (phone calls with speaker turns; --subset eng, for
271+
serves with audio + reference columns; gated ones need HF_TOKEN), a local
272+
.csv/.jsonl manifest with audio + text columns, or a built-in benchmark
273+
alias that fills in the right hub id, subset, split, and columns:
274+
librispeech / librispeech-other (read English), tedlium (TED talks),
275+
earnings22 (earnings calls), spgispeech (financial calls), ami / ami-sdm
276+
(meetings), gigaspeech, peoples (real-world US English), commonvoice
277+
(English; --subset fr etc. for its 98 other locales), voxpopuli
278+
(parliament speech), switchboard (phone calls), expresso (expressive
279+
speech), loquacious, and callhome (phone calls with speaker turns, for
222280
--speaker-labels).
223281
"""
224282

@@ -238,15 +296,22 @@ def body(state: AppState, json_mode: bool) -> None:
238296
language_code=language_code,
239297
speaker_labels=speaker_labels or None,
240298
)
241-
results: list[_ItemResult] = []
242-
for index, item in enumerate(data.items, start=1):
243-
with output.status(
244-
f"[{index}/{len(data.items)}] Transcribing {item.item_id}…",
245-
json_mode=json_mode,
246-
quiet=state.quiet,
247-
):
248-
transcript = client.transcribe(api_key, item.audio, config=transcription_config)
249-
results.append(_score_item(item, transcript, collar=collar))
299+
transcripts = _transcripts(
300+
api_key,
301+
data.items,
302+
transcription_config=transcription_config,
303+
concurrency=concurrency,
304+
json_mode=json_mode,
305+
quiet=state.quiet,
306+
)
307+
results = [
308+
_score_item(item, transcript, collar=collar)
309+
for item, transcript in zip(
310+
data.items,
311+
transcripts,
312+
strict=True, # pragma: no mutate (defensive invariant; _transcripts returns one transcript per item)
313+
)
314+
]
250315
output.emit(_payload(data.label, speech_model, results), _render, json_mode=json_mode)
251316

252317
run_command(ctx, body, json=json_out)

aai_cli/eval_data.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
fetched through the hub's datasets-server REST API — no heavyweight
1010
``datasets`` dependency, and the audio arrives as hosted URLs the AssemblyAI
1111
API ingests directly. Gated/private datasets authenticate via ``HF_TOKEN``.
12+
The common benchmarks also have short **aliases** (``ALIASES``) that fill in
13+
the hub id plus the subset/split/audio-column defaults each set needs.
1214
1315
With ``with_speakers`` (the ``--speaker-labels`` flag), rows must also carry
1416
diarization references as the parallel ``speakers`` / ``timestamps_start`` /
@@ -47,6 +49,39 @@
4749
_SPEAKER_COLUMNS = ("speakers", "timestamps_start", "timestamps_end")
4850

4951

52+
@dataclass(frozen=True)
53+
class Alias:
54+
"""A built-in benchmark alias: the hub dataset id plus the subset/split/
55+
audio-column defaults its layout needs (explicit flags still win)."""
56+
57+
dataset: str
58+
subset: str | None = None
59+
split: str | None = None
60+
audio_column: str | None = None
61+
62+
63+
# The benchmarks the `assembly eval` help recommends, under short memorable names
64+
# — each pins the hub id and the fiddly defaults its layout needs, so
65+
# `assembly eval tedlium` just works.
66+
ALIASES: dict[str, Alias] = {
67+
"librispeech": Alias("openslr/librispeech_asr", subset="clean"),
68+
"librispeech-other": Alias("openslr/librispeech_asr", subset="other"),
69+
"tedlium": Alias("sanchit-gandhi/tedlium-data"),
70+
"earnings22": Alias("sanchit-gandhi/earnings22_robust_split"),
71+
"spgispeech": Alias("kensho/spgispeech", subset="test"),
72+
"ami": Alias("edinburghcstr/ami", subset="ihm"),
73+
"ami-sdm": Alias("edinburghcstr/ami", subset="sdm"),
74+
"gigaspeech": Alias("fixie-ai/gigaspeech", subset="dev", split="dev"),
75+
"peoples": Alias("fixie-ai/peoples_speech", subset="clean"),
76+
"commonvoice": Alias("fixie-ai/common_voice_17_0", subset="en"),
77+
"voxpopuli": Alias("facebook/voxpopuli", subset="en"),
78+
"switchboard": Alias("hhoangphuoc/switchboard", split="validation"),
79+
"expresso": Alias("ylacombe/expresso"),
80+
"loquacious": Alias("speechbrain/LoquaciousSet", subset="small", audio_column="wav"),
81+
"callhome": Alias("talkbank/callhome", subset="eng"),
82+
}
83+
84+
5085
@dataclass(frozen=True)
5186
class EvalItem:
5287
"""One evaluation row: an audio source (path or URL) plus its references —
@@ -91,6 +126,12 @@ def load(
91126
limit=limit,
92127
with_speakers=with_speakers,
93128
)
129+
alias = ALIASES.get(dataset)
130+
if alias is not None:
131+
dataset = alias.dataset
132+
subset = subset or alias.subset
133+
split = split or alias.split
134+
audio_column = audio_column or alias.audio_column
94135
return _load_hf(
95136
dataset,
96137
split=split,

tests/__snapshots__/test_cli_output_snapshots.ambr

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -250,21 +250,15 @@
250250
against reference speaker turns.
251251

252252
Datasets come from the Hugging Face Hub (any public dataset its viewer
253-
serves with audio + reference columns; gated ones need HF_TOKEN) or a local
254-
.csv/.jsonl manifest with audio + text columns. Hub sets to try:
255-
openslr/librispeech_asr (read English; subsets clean/other),
256-
sanchit-gandhi/tedlium-data (TED talks),
257-
sanchit-gandhi/earnings22_robust_split (earnings calls),
258-
kensho/spgispeech (financial calls; subset test),
259-
edinburghcstr/ami (meetings; subsets ihm/sdm),
260-
fixie-ai/gigaspeech (--subset dev --split dev),
261-
fixie-ai/peoples_speech (real-world US English; subset clean),
262-
fixie-ai/common_voice_17_0 (99 locales; subsets like en/fr),
263-
facebook/voxpopuli (parliament speech; subset en),
264-
hhoangphuoc/switchboard (phone calls; --split validation),
265-
ylacombe/expresso (expressive speech),
266-
speechbrain/LoquaciousSet (--subset small --audio-column wav), and
267-
talkbank/callhome (phone calls with speaker turns; --subset eng, for
253+
serves with audio + reference columns; gated ones need HF_TOKEN), a local
254+
.csv/.jsonl manifest with audio + text columns, or a built-in benchmark
255+
alias that fills in the right hub id, subset, split, and columns:
256+
librispeech / librispeech-other (read English), tedlium (TED talks),
257+
earnings22 (earnings calls), spgispeech (financial calls), ami / ami-sdm
258+
(meetings), gigaspeech, peoples (real-world US English), commonvoice
259+
(English; --subset fr etc. for its 98 other locales), voxpopuli
260+
(parliament speech), switchboard (phone calls), expresso (expressive
261+
speech), loquacious, and callhome (phone calls with speaker turns, for
268262
--speaker-labels).
269263

270264
╭─ Arguments ──────────────────────────────────────────────────────────────────╮
@@ -298,6 +292,10 @@
298292
│ around each reference │
299293
│ turn boundary. │
300294
│ [default: 1.0] │
295+
│ --concurrency INTEGER RANGE [x>=1] How many items to │
296+
│ transcribe at once │
297+
│ (sequential by default). │
298+
│ [default: 1] │
301299
│ --json -j Output the rows and │
302300
│ summary as one JSON │
303301
│ object. │
@@ -306,18 +304,18 @@
306304
╰──────────────────────────────────────────────────────────────────────────────╯
307305

308306
Examples
309-
Score a model on 10 rows of an HF dataset
310-
$ assembly eval sanchit-gandhi/tedlium-data
307+
Score a model on 10 rows of a benchmark
308+
$ assembly eval tedlium
311309
Compare models on your own audio
312310
$ assembly eval calls.csv --speech-model universal-3-pro
313311
Score diarization too (WER + DER)
314312
$ assembly eval agent-calls.jsonl --speaker-labels
315-
Pick a subset/split and more rows
316-
$ assembly eval openslr/librispeech_asr --subset clean --limit 50
313+
More rows, transcribed four at a time
314+
$ assembly eval librispeech --limit 50 --concurrency 4
317315
Evaluate non-English audio
318-
$ assembly eval fixie-ai/common_voice_17_0 --subset fr --language-code fr
319-
DER on a Hugging Face diarization set
320-
$ assembly eval talkbank/callhome --subset eng --speaker-labels
316+
$ assembly eval commonvoice --subset fr --language-code fr
317+
DER on a diarization benchmark
318+
$ assembly eval callhome --speaker-labels
321319

322320

323321

0 commit comments

Comments
 (0)