Skip to main content

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}