ffai_bench/reference.rs
1//! World-standard reference adapters — the "oracle" seat from Prometheus's
2//! trial stage (`prom-trial::oracle`), generalized to external AI tools.
3//!
4//! # Why batch mode is the primary contract
5//!
6//! The naive design — invoke the reference once per clip and time it — is
7//! *wrong* for AI tooling, and wrong in the direction that flatters us. A
8//! Python reference spends seconds on interpreter startup and model load;
9//! transcribing a 5-second clip takes a fraction of that. Timing
10//! per-invocation would measure Python's startup, report our Rust as
11//! spectacularly faster, and the claim would be indefensible.
12//!
13//! So an ASR reference is invoked **once for the whole corpus** with a file
14//! list, and reports per-clip transcription time itself. That yields two
15//! honest numbers, both recorded:
16//!
17//! - **warm RTF** — steady-state throughput, model already loaded. This is
18//! what implementations publish, and what a server-side user experiences.
19//! - **end-to-end RTF** — total wall clock for the batch, including the one
20//! model load, amortized over the corpus. This is what a CLI user
21//! experiences.
22//!
23//! Neither alone is the truth; quoting only the flattering one is how
24//! benchmarks lie. Single-file mode (`command`) remains for simple tools
25//! (tesseract) where startup is negligible.
26
27use ffai_core::error::{Error, Result};
28use serde::{Deserialize, Serialize};
29use std::path::{Path, PathBuf};
30use std::process::Command;
31
32/// One external reference implementation.
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
34pub struct ReferenceSpec {
35 /// Display name, e.g. "faster-whisper-tiny".
36 pub name: String,
37 /// Task this reference applies to: "asr", "tts", "ocr", "vlm".
38 pub task: String,
39 /// **Preferred.** Argv where `{filelist}` is replaced by a temp file
40 /// holding one audio path per line. The adapter must emit JSONL on
41 /// stdout: `{"path": ..., "text": ..., "transcribe_secs": ...}` per clip,
42 /// plus an optional `{"load_secs": ...}` line.
43 pub batch_command: Option<Vec<String>>,
44 /// Fallback: argv where `{input}` is replaced by one media path; stdout
45 /// is taken as the output text. Startup cost lands in the timing.
46 pub command: Option<Vec<String>>,
47 /// Optional argv printing a version string (recorded in the ledger).
48 pub version_command: Option<Vec<String>>,
49 /// What this reference is *configured as* — e.g. `"tiny.en/greedy"`.
50 ///
51 /// **Declared, never inferred.** The quality gate needs to know which
52 /// references are comparable to the engine, and deriving that by looking
53 /// for "tiny" or "greedy" in `name` would make a benchmark's meaning
54 /// depend on a naming convention nobody is enforcing.
55 ///
56 /// Without it the gate answers only "is our output as good as the best
57 /// ASR available?", by picking the lowest WER of everything that ran —
58 /// which for a 39M greedy engine meant being failed against
59 /// `openai-whisper-base`, a 74M beam-search model. That is a real
60 /// question, but it is not "is our implementation good", and the two were
61 /// being reported under one label. References that leave this unset are
62 /// scored in the open comparison only.
63 #[serde(default)]
64 pub config: Option<String>,
65}
66
67/// One clip's result from a batch run.
68#[derive(Debug, Clone, PartialEq)]
69pub struct ClipResult {
70 pub path: String,
71 pub text: String,
72 /// Transcription-only seconds as reported by the adapter (excludes model
73 /// load). `None` if the adapter didn't report it.
74 pub transcribe_secs: Option<f64>,
75}
76
77/// The parsed output of one batch invocation.
78#[derive(Debug, Clone, Default, PartialEq)]
79pub struct BatchResult {
80 pub clips: Vec<ClipResult>,
81 /// Model load seconds as reported by the adapter.
82 pub load_secs: Option<f64>,
83 /// Total processing seconds for the whole batch, for tools that report
84 /// only an aggregate (whisper.cpp prints one timing block per *run*, not
85 /// per file). Used when per-clip timings are absent — reporting the
86 /// aggregate honestly beats splitting it into per-clip numbers the tool
87 /// never measured.
88 pub batch_transcribe_secs: Option<f64>,
89 /// Median resident memory across the run — what the tree sits at while
90 /// working, as opposed to its worst instant.
91 pub steady_bytes: Option<u64>,
92 /// Peak working set of the reference's own process, in bytes.
93 ///
94 /// Measured after `wait()` while the `Child` still owns its handle — see
95 /// [`crate::footprint`]. `None` where the platform has no implementation,
96 /// so the footprint gate can skip honestly rather than invent a number.
97 pub peak_bytes: Option<u64>,
98}
99
100impl BatchResult {
101 /// Adapter-reported processing time for the batch: the sum of per-clip
102 /// timings when available, otherwise the reported aggregate.
103 #[must_use]
104 pub fn transcribe_secs(&self) -> Option<f64> {
105 let sum: f64 = self.clips.iter().filter_map(|c| c.transcribe_secs).sum();
106 if sum > 0.0 {
107 Some(sum)
108 } else {
109 self.batch_transcribe_secs
110 }
111 }
112
113 /// Look up a clip's text by the path the adapter echoed back.
114 #[must_use]
115 pub fn text_for(&self, path: &Path) -> Option<&str> {
116 let want = path.to_string_lossy().replace('\\', "/");
117 self.clips
118 .iter()
119 .find(|c| c.path.replace('\\', "/") == want)
120 .map(|c| c.text.as_str())
121 }
122}
123
124/// A named, executable scorer — declared HERE and not in a corpus.
125///
126/// # Why this lives in the references file
127///
128/// `references.toml` has always been executable input: every entry names an
129/// argv this crate spawns, and it is read and reviewed as such. A
130/// `corpora/*.toml` was pure data.
131///
132/// When VLM scoring landed, the corpus grew a `[scorer]` block carrying an
133/// argv, and that moved the trust boundary: a file shaped like data could
134/// suddenly run code. The risk was never that execution is *possible* — it is
135/// that execution became **invisible**, buried on line 20 of a thousand lines
136/// of hashes and prompts that a reviewer will skim.
137///
138/// So the argv moved back here, and the corpus now merely *selects* a scorer
139/// by name. **Data selects from a set; code defines the set.** The corpus
140/// keeps `metric` and `scale`, which are facts about the benchmark's numbers
141/// rather than instructions to execute anything.
142#[derive(Debug, Clone, Deserialize)]
143pub struct NamedScorer {
144 /// The name a corpus refers to.
145 pub name: String,
146 /// Argv; `{predictions}` is replaced with the predictions JSONL path.
147 pub command: Vec<String>,
148 /// Optional argv printing a version string, recorded in the ledger.
149 #[serde(default)]
150 pub version_command: Option<Vec<String>>,
151}
152
153impl NamedScorer {
154 /// The argv as a single line, for the ledger and for printing before the
155 /// spawn — an executed command should be visible, not merely permitted.
156 #[must_use]
157 pub fn command_line(&self) -> String {
158 self.command.join(" ")
159 }
160}
161
162/// The declaration file: references, and the scorers they may be paired with.
163#[derive(Debug, Clone, Default, Deserialize)]
164pub struct ReferenceFile {
165 #[serde(default, rename = "reference")]
166 pub references: Vec<ReferenceSpec>,
167 /// Named scorers a VLM corpus may select. See [`NamedScorer`].
168 #[serde(default, rename = "scorer")]
169 pub scorers: Vec<NamedScorer>,
170}
171
172impl ReferenceFile {
173 pub fn load(path: &Path) -> Result<Self> {
174 let text = std::fs::read_to_string(path)?;
175 toml::from_str(&text).map_err(|e| Error::Other(format!("bad references file: {e}")))
176 }
177
178 pub fn for_task<'a>(&'a self, task: &str) -> impl Iterator<Item = &'a ReferenceSpec> {
179 self.references.iter().filter(move |r| r.task == task)
180 }
181
182 /// Look up a scorer a corpus asked for by name.
183 #[must_use]
184 pub fn scorer(&self, name: &str) -> Option<&NamedScorer> {
185 self.scorers.iter().find(|s| s.name == name)
186 }
187}
188
189impl ReferenceSpec {
190 #[must_use]
191 pub fn supports_batch(&self) -> bool {
192 self.batch_command.is_some()
193 }
194
195 /// Run the whole corpus in one invocation (see module docs).
196 pub fn run_batch(&self, inputs: &[PathBuf]) -> Result<BatchResult> {
197 self.run_batch_subst(inputs, &[])
198 }
199
200 /// `run_batch` with extra `{placeholder}` -> value substitutions.
201 ///
202 /// Exists for VLM references, which need the corpus manifest as well as
203 /// the file list: a VLM item is an (image, question) pair, and the
204 /// question lives in the manifest — pinned there so it falls inside the
205 /// corpus fingerprint. Without `{corpus}` every VLM reference would have
206 /// to be re-declared per dataset just to vary one argument.
207 ///
208 /// Substitution is literal and applied to each argv element, the same way
209 /// `{filelist}` already is.
210 pub fn run_batch_subst(
211 &self,
212 inputs: &[PathBuf],
213 extra: &[(&str, &str)],
214 ) -> Result<BatchResult> {
215 let argv = self.batch_command.as_ref().ok_or_else(|| {
216 Error::Other(format!("reference `{}` has no batch_command", self.name))
217 })?;
218
219 let listing: String = inputs
220 .iter()
221 .map(|p| p.to_string_lossy().into_owned())
222 .collect::<Vec<_>>()
223 .join("\n");
224 let list_path = std::env::temp_dir().join(format!("ffai-bench-{}.filelist", self.name));
225 std::fs::write(&list_path, listing)?;
226
227 let argv: Vec<String> = argv
228 .iter()
229 .map(|a| {
230 let mut s = a.replace("{filelist}", &list_path.to_string_lossy());
231 for (k, v) in extra {
232 s = s.replace(k, v);
233 }
234 s
235 })
236 .collect();
237 let result = self.exec_measured(&argv);
238 std::fs::remove_file(&list_path).ok();
239 let (stdout, peak, steady) = result?;
240 let mut parsed = parse_batch_output(&stdout, &self.name)?;
241 parsed.peak_bytes = peak;
242 parsed.steady_bytes = steady;
243 Ok(parsed)
244 }
245
246 /// Run on one input file, returning stdout as text.
247 pub fn run(&self, input: &Path) -> Result<String> {
248 let argv = self
249 .command
250 .as_ref()
251 .ok_or_else(|| Error::Other(format!("reference `{}` has no command", self.name)))?;
252 let argv: Vec<String> = argv
253 .iter()
254 .map(|a| a.replace("{input}", &input.to_string_lossy()))
255 .collect();
256 self.exec(&argv)
257 }
258
259 fn exec(&self, argv: &[String]) -> Result<String> {
260 self.exec_measured(argv).map(|(stdout, _, _)| stdout)
261 }
262
263 /// Run the reference and also report its peak working set.
264 ///
265 /// `Command::output()` cannot be used here: it consumes the `Child` and
266 /// drops the process handle, and the handle is exactly what the memory
267 /// counters are read through. So this spawns, drains both pipes on their
268 /// own threads (a child that fills a pipe buffer while nobody reads it
269 /// deadlocks), waits, and only then queries — while the `Child` is still
270 /// alive and owns the handle.
271 fn exec_measured(&self, argv: &[String]) -> Result<(String, Option<u64>, Option<u64>)> {
272 use std::io::Read;
273 use std::process::Stdio;
274
275 let (prog, args) = argv.split_first().ok_or_else(|| {
276 Error::Other(format!("reference `{}` has an empty command", self.name))
277 })?;
278 let mut child = Command::new(prog)
279 .args(args)
280 .stdout(Stdio::piped())
281 .stderr(Stdio::piped())
282 .spawn()
283 .map_err(|e| {
284 Error::Other(format!(
285 "reference `{}` failed to launch (`{prog}`): {e} — is it installed and on PATH?",
286 self.name
287 ))
288 })?;
289
290 // Scope the measurement to the whole TREE, assigned immediately so the
291 // launcher has not yet forked the process that does the work.
292 //
293 // Most references are two processes deep — this one runs
294 // `python.exe adapter.py --bin whisper-cli.exe`, so measuring the
295 // direct child measures the Python launcher. That reported **5 MiB for
296 // a reference that loads a 77.7 MB model**, and a 127x ratio against
297 // us. Both impossible, both plausible-looking in a table.
298 let job = std::sync::Arc::new(crate::footprint::Job::create());
299 if let Some(j) = job.as_ref() {
300 // The return value MATTERS and was being dropped. This job object is
301 // the whole reason the measurement is trustworthy: without it we
302 // measured the Python launcher instead of the process doing the
303 // work, and reported 5 MiB for a reference that loads a 77.7 MB
304 // model. If the assignment fails and nobody says so, the harness
305 // silently reverts to producing exactly that number - impossible,
306 // and plausible-looking in a table.
307 //
308 // `create()` returns None on non-Windows, so reaching here means a
309 // real job object exists and the failure is worth shouting about.
310 if !j.assign(&child) {
311 eprintln!(
312 "WARNING: could not assign the reference process to its job object; footprint numbers from this run measure the direct child only and must not be compared against ours"
313 );
314 }
315 }
316 // Sample the tree's resident memory while it runs and keep the maximum.
317 // Sampling is not optional here: a process's counters die with it, and
318 // the process that matters (the grandchild doing the inference) exits
319 // before we could look.
320 let done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
321 let peak_seen = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0));
322 // Samples are kept, not just the maximum: peak is set by the worst
323 // instant (usually model load), while the MEDIAN is what the process
324 // actually sits at while working. Reporting only the peak would
325 // compare our load spike against their load spike and call it
326 // footprint.
327 let samples = std::sync::Arc::new(std::sync::Mutex::new(Vec::<u64>::new()));
328 let sampler = {
329 let (job, done, peak_seen, samples) = (
330 job.clone(),
331 done.clone(),
332 peak_seen.clone(),
333 samples.clone(),
334 );
335 std::thread::spawn(move || {
336 use std::sync::atomic::Ordering;
337 while !done.load(Ordering::Relaxed) {
338 if let Some(ws) = job.as_ref().as_ref().and_then(|j| j.working_set_now()) {
339 peak_seen.fetch_max(ws, Ordering::Relaxed);
340 if ws > 0
341 && let Ok(mut v) = samples.lock()
342 {
343 v.push(ws);
344 }
345 }
346 std::thread::sleep(std::time::Duration::from_millis(20));
347 }
348 })
349 };
350
351 let mut out_pipe = child.stdout.take().expect("stdout piped above");
352 let mut err_pipe = child.stderr.take().expect("stderr piped above");
353 let out_thread = std::thread::spawn(move || {
354 let mut buf = Vec::new();
355 out_pipe.read_to_end(&mut buf).ok();
356 buf
357 });
358 let err_thread = std::thread::spawn(move || {
359 let mut buf = Vec::new();
360 err_pipe.read_to_end(&mut buf).ok();
361 buf
362 });
363
364 let status = child.wait().map_err(|e| {
365 Error::Other(format!(
366 "reference `{}` could not be waited on: {e}",
367 self.name
368 ))
369 })?;
370 done.store(true, std::sync::atomic::Ordering::Relaxed);
371 sampler.join().ok();
372 // The sampled tree maximum; the direct child's own peak is the
373 // fallback when no job could be created, and is explicitly weaker
374 // because it misses whatever the launcher spawned.
375 let peak = Some(peak_seen.load(std::sync::atomic::Ordering::Relaxed))
376 .filter(|b| *b > 0)
377 .or_else(|| crate::footprint::peak_child(&child).map(|p| p.0));
378 let steady = samples.lock().ok().and_then(|mut v| {
379 if v.is_empty() {
380 return None;
381 }
382 v.sort_unstable();
383 Some(v[v.len() / 2])
384 });
385
386 let stdout = out_thread.join().unwrap_or_default();
387 let stderr = err_thread.join().unwrap_or_default();
388 if !status.success() {
389 return Err(Error::Other(format!(
390 "reference `{}` exited with {}: {}",
391 self.name,
392 status,
393 String::from_utf8_lossy(&stderr).trim()
394 )));
395 }
396 Ok((String::from_utf8_lossy(&stdout).into_owned(), peak, steady))
397 }
398
399 /// The argv this reference invokes, with placeholders left intact — the
400 /// decode configuration as recorded in the ledger.
401 #[must_use]
402 pub fn command_line(&self) -> String {
403 self.batch_command
404 .as_ref()
405 .or(self.command.as_ref())
406 .map(|argv| argv.join(" "))
407 .unwrap_or_default()
408 }
409
410 /// Best-effort version string (first line of `version_command` output).
411 #[must_use]
412 pub fn version(&self) -> Option<String> {
413 let argv = self.version_command.as_ref()?;
414 let (prog, args) = argv.split_first()?;
415 let out = Command::new(prog).args(args).output().ok()?;
416 let text = if out.stdout.trim_ascii().is_empty() {
417 out.stderr
418 } else {
419 out.stdout
420 };
421 String::from_utf8_lossy(&text)
422 .lines()
423 .next()
424 .map(|s| s.trim().to_string())
425 }
426}
427
428/// Parse an adapter's JSONL stdout. Lines that aren't JSON objects are
429/// ignored (adapters sometimes leak a progress line); a line with `text` is
430/// a clip, a line with `load_secs` is metadata.
431fn parse_batch_output(stdout: &str, name: &str) -> Result<BatchResult> {
432 let mut out = BatchResult::default();
433 for line in stdout.lines() {
434 let line = line.trim();
435 if !line.starts_with('{') {
436 continue;
437 }
438 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
439 continue;
440 };
441 if let Some(load) = value.get("load_secs").and_then(|v| v.as_f64()) {
442 out.load_secs = Some(load);
443 }
444 if let Some(total) = value.get("batch_transcribe_secs").and_then(|v| v.as_f64()) {
445 out.batch_transcribe_secs = Some(total);
446 }
447 if let Some(text) = value.get("text").and_then(|v| v.as_str()) {
448 out.clips.push(ClipResult {
449 path: value
450 .get("path")
451 .and_then(|v| v.as_str())
452 .unwrap_or_default()
453 .to_string(),
454 text: text.to_string(),
455 transcribe_secs: value.get("transcribe_secs").and_then(|v| v.as_f64()),
456 });
457 }
458 }
459 if out.clips.is_empty() {
460 return Err(Error::Other(format!(
461 "reference `{name}` produced no parseable JSONL clip results — check the adapter \
462 contract in crates/ffai-bench/src/reference.rs"
463 )));
464 }
465 Ok(out)
466}
467
468// ---------------------------------------------------------------------------
469// TTS batch mode
470// ---------------------------------------------------------------------------
471
472/// One utterance's result from a TTS batch run: the adapter read a text file
473/// and wrote a WAV.
474#[derive(Debug, Clone, PartialEq)]
475pub struct TtsClipResult {
476 /// The input text path the adapter echoed back.
477 pub path: String,
478 /// The generated WAV's path.
479 pub wav: PathBuf,
480 /// Synthesis-only seconds (model loaded, WAV writing excluded).
481 pub synth_secs: Option<f64>,
482 /// Time-to-first-audio: `synthesize()` call to first chunk. For a
483 /// single-sentence input a non-streaming engine reports ttfa == synth.
484 pub ttfa_secs: Option<f64>,
485}
486
487/// The parsed output of one TTS batch invocation.
488#[derive(Debug, Clone, Default, PartialEq)]
489pub struct TtsBatchResult {
490 pub clips: Vec<TtsClipResult>,
491 pub load_secs: Option<f64>,
492 /// Adapter-reported metadata worth carrying into the ledger notes —
493 /// voice name + sha256 and the effective synthesis knobs. The voice file
494 /// is not corpus-pinned, so its hash rides in the record instead.
495 pub meta: Vec<String>,
496 pub steady_bytes: Option<u64>,
497 pub peak_bytes: Option<u64>,
498}
499
500impl TtsBatchResult {
501 /// Look up an utterance's result by input text path.
502 #[must_use]
503 pub fn clip_for(&self, path: &Path) -> Option<&TtsClipResult> {
504 let want = path.to_string_lossy().replace('\\', "/");
505 self.clips
506 .iter()
507 .find(|c| c.path.replace('\\', "/") == want)
508 }
509
510 /// Adapter-reported synthesis time for the whole batch.
511 #[must_use]
512 pub fn synth_secs(&self) -> Option<f64> {
513 let sum: f64 = self.clips.iter().filter_map(|c| c.synth_secs).sum();
514 if sum > 0.0 { Some(sum) } else { None }
515 }
516}
517
518impl ReferenceSpec {
519 /// Run a whole TTS corpus in one invocation. `{filelist}` is replaced by
520 /// a temp file of text paths, `{outdir}` by the directory the adapter
521 /// must write WAVs into. JSONL contract: a `{"load_secs": ...}` line,
522 /// optional metadata lines (any object with a `"voice"` key), and one
523 /// `{"path": ..., "wav": ..., "synth_secs": ..., "ttfa_secs": ...}` per
524 /// utterance. See `corpora/refs/piper_ref.py` for the working example.
525 pub fn run_batch_tts(&self, inputs: &[PathBuf], outdir: &Path) -> Result<TtsBatchResult> {
526 let argv = self.batch_command.as_ref().ok_or_else(|| {
527 Error::Other(format!("reference `{}` has no batch_command", self.name))
528 })?;
529
530 std::fs::create_dir_all(outdir)?;
531 let listing: String = inputs
532 .iter()
533 .map(|p| p.to_string_lossy().into_owned())
534 .collect::<Vec<_>>()
535 .join("\n");
536 let list_path = std::env::temp_dir().join(format!("ffai-bench-{}.filelist", self.name));
537 std::fs::write(&list_path, listing)?;
538
539 let argv: Vec<String> = argv
540 .iter()
541 .map(|a| {
542 a.replace("{filelist}", &list_path.to_string_lossy())
543 .replace("{outdir}", &outdir.to_string_lossy())
544 })
545 .collect();
546 let result = self.exec_measured(&argv);
547 std::fs::remove_file(&list_path).ok();
548 let (stdout, peak, steady) = result?;
549 let mut parsed = parse_tts_batch_output(&stdout, &self.name)?;
550 parsed.peak_bytes = peak;
551 parsed.steady_bytes = steady;
552 Ok(parsed)
553 }
554}
555
556/// Parse a TTS adapter's JSONL stdout. Same tolerance rules as the ASR
557/// parser: non-JSON lines are ignored, a line with `wav` is an utterance, a
558/// line with `load_secs` is timing metadata, a line with `voice` is carried
559/// into the ledger notes verbatim.
560fn parse_tts_batch_output(stdout: &str, name: &str) -> Result<TtsBatchResult> {
561 let mut out = TtsBatchResult::default();
562 for line in stdout.lines() {
563 let line = line.trim();
564 if !line.starts_with('{') {
565 continue;
566 }
567 let Ok(value) = serde_json::from_str::<serde_json::Value>(line) else {
568 continue;
569 };
570 if let Some(load) = value.get("load_secs").and_then(|v| v.as_f64()) {
571 out.load_secs = Some(load);
572 }
573 if value.get("voice").is_some() {
574 out.meta.push(line.to_string());
575 }
576 if let Some(wav) = value.get("wav").and_then(|v| v.as_str()) {
577 out.clips.push(TtsClipResult {
578 path: value
579 .get("path")
580 .and_then(|v| v.as_str())
581 .unwrap_or_default()
582 .to_string(),
583 wav: PathBuf::from(wav),
584 synth_secs: value.get("synth_secs").and_then(|v| v.as_f64()),
585 ttfa_secs: value.get("ttfa_secs").and_then(|v| v.as_f64()),
586 });
587 }
588 }
589 if out.clips.is_empty() {
590 return Err(Error::Other(format!(
591 "reference `{name}` produced no parseable JSONL utterance results — check the TTS \
592 adapter contract in crates/ffai-bench/src/reference.rs"
593 )));
594 }
595 Ok(out)
596}
597
598#[cfg(test)]
599mod tts_tests {
600 use super::*;
601
602 #[test]
603 fn parses_tts_jsonl_and_carries_voice_metadata() {
604 let stdout = concat!(
605 "some progress noise\n",
606 "{\"load_secs\": 2.1}\n",
607 "{\"voice\": \"en_US-lessac-medium\", \"voice_sha256\": \"abc\"}\n",
608 "{\"path\": \"a.txt\", \"wav\": \"out/a.wav\", \"synth_secs\": 0.07, \"ttfa_secs\": 0.07}\n",
609 "{\"path\": \"b.txt\", \"wav\": \"out/b.wav\", \"synth_secs\": 0.05}\n",
610 );
611 let r = parse_tts_batch_output(stdout, "piper").unwrap();
612 assert_eq!(r.clips.len(), 2);
613 assert_eq!(r.load_secs, Some(2.1));
614 assert_eq!(r.meta.len(), 1);
615 assert!(r.meta[0].contains("voice_sha256"));
616 assert!((r.synth_secs().unwrap() - 0.12).abs() < 1e-9);
617 assert_eq!(
618 r.clip_for(Path::new("b.txt")).unwrap().wav,
619 PathBuf::from("out/b.wav")
620 );
621 // Windows paths from the adapter still match POSIX-style queries.
622 let win = parse_tts_batch_output(
623 "{\"path\": \"corpora\\\\texts\\\\a.txt\", \"wav\": \"o.wav\"}\n",
624 "p",
625 )
626 .unwrap();
627 assert!(win.clip_for(Path::new("corpora/texts/a.txt")).is_some());
628 }
629
630 #[test]
631 fn tts_empty_output_is_an_error_not_a_silent_zero() {
632 assert!(parse_tts_batch_output("nothing\n", "piper").is_err());
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639
640 #[test]
641 fn parses_reference_file_with_both_modes() {
642 let f: ReferenceFile = toml::from_str(
643 r#"
644 [[reference]]
645 name = "faster-whisper-tiny"
646 task = "asr"
647 batch_command = ["python", "ref.py", "--batch", "{filelist}"]
648 version_command = ["python", "-c", "print(1)"]
649
650 [[reference]]
651 name = "tesseract"
652 task = "ocr"
653 command = ["tesseract", "{input}", "stdout"]
654 "#,
655 )
656 .unwrap();
657 assert_eq!(f.references.len(), 2);
658 assert!(f.for_task("asr").next().unwrap().supports_batch());
659 assert!(!f.for_task("ocr").next().unwrap().supports_batch());
660 }
661
662 #[test]
663 fn parses_jsonl_batch_output_and_ignores_noise() {
664 let stdout = concat!(
665 "loading model...\n",
666 "{\"load_secs\": 1.5}\n",
667 "{\"path\": \"a.wav\", \"text\": \"hello\", \"transcribe_secs\": 0.25}\n",
668 "{\"path\": \"b.wav\", \"text\": \"world\", \"transcribe_secs\": 0.75}\n",
669 );
670 let r = parse_batch_output(stdout, "test").unwrap();
671 assert_eq!(r.clips.len(), 2);
672 assert_eq!(r.load_secs, Some(1.5));
673 assert_eq!(r.transcribe_secs(), Some(1.0));
674
675 // Aggregate-only adapters (whisper.cpp) are supported too.
676 let agg = parse_batch_output(
677 "{\"batch_transcribe_secs\": 4.0}
678{\"path\": \"a.wav\", \"text\": \"hi\"}
679",
680 "agg",
681 )
682 .unwrap();
683 assert_eq!(agg.transcribe_secs(), Some(4.0));
684 assert_eq!(r.text_for(Path::new("b.wav")), Some("world"));
685 }
686
687 #[test]
688 fn empty_output_is_an_error_not_a_silent_zero() {
689 assert!(parse_batch_output("nothing here\n", "test").is_err());
690 }
691}