88
99from __future__ import annotations
1010
11+ from concurrent .futures import ThreadPoolExecutor , as_completed
1112from dataclasses import dataclass
1213from 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+
73120def _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 )
0 commit comments