Skip to main content

forge_tts/
bench.rs

1//! The speaking bench: synthesize a corpus's transcripts, transcribe them
2//! back through a caller-supplied recognizer, and score the round trip with
3//! its first-audio time, realtime factor and the resident set it needed.
4//!
5//! The recognizer is a parameter because this crate is a leaf and owns no
6//! model of the other direction.
7
8use std::sync::Arc;
9use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
10use std::time::{Duration, Instant};
11
12use sysinfo::{Pid, ProcessesToUpdate, System};
13
14use crate::{Engine, Error, SpeakRequest};
15
16/// One clip to score: the words to say.
17pub struct BenchClip {
18    pub transcript: String,
19}
20
21/// What one run measured.
22#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct TtsBenchResult {
24    pub clips: usize,
25    /// Word error rate of what was heard against what was said, pooled over
26    /// every clip.
27    pub round_trip_wer: f64,
28    /// Mean time from a call to its first audio.
29    pub first_audio_ms: u64,
30    /// Rendered audio seconds per wall second.
31    pub realtime_factor: f64,
32    /// The process's peak resident set while the run rendered, above what it
33    /// held before the first clip.
34    #[serde(default)]
35    pub peak_rss_bytes: u64,
36}
37
38/// A resident-set sampler that runs beside the bench for its duration.
39struct PeakRss {
40    stop: Arc<AtomicBool>,
41    peak: Arc<AtomicU64>,
42    baseline: u64,
43    thread: Option<std::thread::JoinHandle<()>>,
44}
45
46impl PeakRss {
47    fn start() -> Self {
48        let baseline = resident_bytes();
49        let stop = Arc::new(AtomicBool::new(false));
50        let peak = Arc::new(AtomicU64::new(baseline));
51        let thread_stop = Arc::clone(&stop);
52        let thread_peak = Arc::clone(&peak);
53        let thread = std::thread::Builder::new()
54            .name("forge-tts-rss".to_owned())
55            .spawn(move || {
56                let pid = Pid::from_u32(std::process::id());
57                let mut system = System::new();
58                while !thread_stop.load(Ordering::Relaxed) {
59                    system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
60                    let resident = system.process(pid).map_or(0, sysinfo::Process::memory);
61                    thread_peak.fetch_max(resident, Ordering::Relaxed);
62                    std::thread::sleep(Duration::from_millis(50));
63                }
64            })
65            .ok();
66        Self { stop, peak, baseline, thread }
67    }
68
69    fn finish(mut self) -> u64 {
70        self.stop_and_join();
71        self.peak.load(Ordering::Relaxed).saturating_sub(self.baseline)
72    }
73
74    fn stop_and_join(&mut self) {
75        self.stop.store(true, Ordering::Relaxed);
76        if let Some(thread) = self.thread.take() {
77            let _ = thread.join();
78        }
79    }
80}
81
82impl Drop for PeakRss {
83    fn drop(&mut self) {
84        // A run that stops early still stops its sampler: the guard may be
85        // dropped by a `?` on a failed clip.
86        self.stop_and_join();
87    }
88}
89
90/// This process's resident set, in bytes; zero where the platform will not
91/// answer.
92fn resident_bytes() -> u64 {
93    let pid = Pid::from_u32(std::process::id());
94    let mut system = System::new();
95    system.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
96    system.process(pid).map_or(0, sysinfo::Process::memory)
97}
98
99/// Synthesize every transcript, transcribe the audio back, and score it.
100///
101/// `on_clip` is told each clip's 1-based position as it lands; a `Break`
102/// stops the run with [`Error::Cancelled`], because what ran is not a
103/// result.
104pub async fn run(
105    engine: &dyn Engine,
106    corpus: &[BenchClip],
107    transcribe: impl Fn(&[f32], u32) -> String,
108    mut on_clip: impl FnMut(usize) -> std::ops::ControlFlow<()>,
109) -> Result<TtsBenchResult, Error> {
110    if corpus.is_empty() {
111        return Err(Error::Render("the corpus has no clips to run".to_owned()));
112    }
113    let started_at = Instant::now();
114    let rss = PeakRss::start();
115    let mut first_audio = Duration::ZERO;
116    let mut audio_seconds = 0.0_f64;
117    let mut edits_total = 0_usize;
118    let mut words_total = 0_usize;
119    let mut scored = 0_usize;
120
121    for (index, clip) in corpus.iter().enumerate() {
122        let started = Instant::now();
123        let mut chunks = engine.speak(SpeakRequest { text: clip.transcript.clone(), voice: None });
124        let mut samples: Vec<f32> = Vec::new();
125        let mut rate = 0_u32;
126        let mut first: Option<Duration> = None;
127        while let Some(chunk) = chunks.recv().await {
128            let chunk = chunk?;
129            if first.is_none() {
130                first = Some(started.elapsed());
131                rate = chunk.sample_rate;
132            }
133            samples.extend(chunk.pcm.iter().map(|sample| f32::from(*sample) / f32::from(i16::MAX)));
134        }
135        let Some(first) = first else {
136            // Nothing rendered is nothing to score, and the mean latency it
137            // would add is a number about an empty clip.
138            continue;
139        };
140        scored += 1;
141        first_audio += first;
142        if rate > 0 {
143            let count = u32::try_from(samples.len()).unwrap_or(u32::MAX);
144            audio_seconds += f64::from(count) / f64::from(rate);
145        }
146        let heard = transcribe(&samples, rate);
147        let truth = words(&clip.transcript);
148        edits_total += edits(&words(&heard), &truth);
149        words_total += truth.len();
150        if on_clip(index + 1) == std::ops::ControlFlow::Break(()) {
151            return Err(Error::Cancelled);
152        }
153    }
154
155    let peak_rss_bytes = rss.finish();
156    let wall = started_at.elapsed().as_secs_f64();
157    Ok(TtsBenchResult {
158        clips: scored,
159        round_trip_wer: ratio(edits_total, words_total),
160        first_audio_ms: u64::try_from(first_audio.as_millis()).unwrap_or(u64::MAX)
161            / u64::try_from(scored).unwrap_or(1),
162        realtime_factor: if wall > 0.0 { audio_seconds / wall } else { 0.0 },
163        peak_rss_bytes,
164    })
165}
166
167/// A string's words: lowercased, split on anything that is not a letter or a
168/// digit, blanks dropped.
169fn words(text: &str) -> Vec<String> {
170    text.split(|c: char| !c.is_alphanumeric())
171        .filter(|word| !word.is_empty())
172        .map(str::to_lowercase)
173        .collect()
174}
175
176/// Edits over the truth's own words; a truth with no words is no error
177/// rather than a division by zero.
178fn ratio(part: usize, whole: usize) -> f64 {
179    if whole == 0 {
180        return 0.0;
181    }
182    f64::from(u32::try_from(part).unwrap_or(u32::MAX))
183        / f64::from(u32::try_from(whole).unwrap_or(u32::MAX))
184}
185
186/// Word-level Levenshtein distance: the edits between two word sequences.
187fn edits(text: &[String], truth: &[String]) -> usize {
188    let mut previous: Vec<usize> = (0..=truth.len()).collect();
189    let mut current = vec![0_usize; truth.len() + 1];
190    for (row, word) in text.iter().enumerate() {
191        current[0] = row + 1;
192        for (column, against) in truth.iter().enumerate() {
193            let substitute = previous[column] + usize::from(word != against);
194            current[column + 1] = substitute.min(previous[column + 1] + 1).min(current[column] + 1);
195        }
196        std::mem::swap(&mut previous, &mut current);
197    }
198    previous[truth.len()]
199}
200
201#[cfg(test)]
202mod tests {
203    use super::{BenchClip, run};
204    use crate::{Chunk, Engine, SpeakRequest};
205    use tokio::sync::mpsc;
206
207    /// A render that answers one clip, so the bench's figures are its own
208    /// rather than a model's.
209    struct StubEngine;
210
211    impl Engine for StubEngine {
212        fn speak(&self, _request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, crate::Error>> {
213            let (sender, receiver) = mpsc::channel(2);
214            tokio::spawn(async move {
215                let _ = sender
216                    .send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm: vec![1; 2_400] }))
217                    .await;
218            });
219            receiver
220        }
221    }
222
223    fn clips() -> Vec<BenchClip> {
224        vec![BenchClip { transcript: "the quick brown fox".to_owned() }]
225    }
226
227    #[tokio::test]
228    async fn the_tts_bench_reports_wer_and_latency_off_a_stub_engine() {
229        let result = run(
230            &StubEngine,
231            &clips(),
232            |_, _| "the quick brown fox".to_owned(),
233            |_| std::ops::ControlFlow::Continue(()),
234        )
235        .await
236        .expect("the run lands");
237        assert_eq!(result.clips, 1);
238        assert_eq!(result.round_trip_wer, 0.0, "the exact words came back");
239        assert!(result.realtime_factor > 0.0, "a render with audio has a pace");
240        assert!(
241            result.first_audio_ms <= 1_000,
242            "the stub answers at once, got {}",
243            result.first_audio_ms
244        );
245    }
246
247    #[tokio::test]
248    async fn a_round_trip_that_changes_a_word_scores_an_error() {
249        let result = run(
250            &StubEngine,
251            &clips(),
252            |_, _| "the quick brown fix".to_owned(),
253            |_| std::ops::ControlFlow::Continue(()),
254        )
255        .await
256        .expect("the run lands");
257        assert_eq!(
258            result.round_trip_wer, 0.25,
259            "one changed word in four is a quarter, not the clip count",
260        );
261    }
262
263    /// A clip whose channel closes with no chunk is not scored, and the mean
264    /// latency divides by the clips that rendered rather than the corpus.
265    #[tokio::test]
266    async fn a_clip_that_renders_nothing_is_not_counted() {
267        struct Selective;
268        impl Engine for Selective {
269            fn speak(&self, request: SpeakRequest) -> mpsc::Receiver<Result<Chunk, crate::Error>> {
270                let (sender, receiver) = mpsc::channel(2);
271                let silent = request.text == "silent";
272                tokio::spawn(async move {
273                    if !silent {
274                        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
275                        let _ = sender
276                            .send(Ok(Chunk { seq: 0, sample_rate: 24_000, pcm: vec![1; 2_400] }))
277                            .await;
278                    }
279                });
280                receiver
281            }
282        }
283        let corpus = vec![
284            BenchClip { transcript: "silent".to_owned() },
285            BenchClip { transcript: "heard".to_owned() },
286        ];
287        let result = run(
288            &Selective,
289            &corpus,
290            |_, _| "heard".to_owned(),
291            |_| std::ops::ControlFlow::Continue(()),
292        )
293        .await
294        .expect("the run lands");
295        assert_eq!(result.clips, 1, "only the clip that rendered is scored");
296        assert_eq!(result.round_trip_wer, 0.0);
297        assert!(
298            result.first_audio_ms >= 150,
299            "the mean divides by the clip that rendered, not the corpus: got {}",
300            result.first_audio_ms,
301        );
302    }
303
304    #[tokio::test]
305    async fn an_empty_corpus_is_refused_by_name() {
306        let Err(error) =
307            run(&StubEngine, &[], |_, _| String::new(), |_| std::ops::ControlFlow::Continue(()))
308                .await
309        else {
310            panic!("an empty corpus is no run");
311        };
312        assert!(error.to_string().contains("no clips"));
313    }
314
315    #[tokio::test]
316    async fn each_scored_clip_is_reported_as_it_lands() {
317        let corpus = vec![
318            BenchClip { transcript: "one".to_owned() },
319            BenchClip { transcript: "two".to_owned() },
320        ];
321        let mut landed = Vec::new();
322        run(
323            &StubEngine,
324            &corpus,
325            |_, _| "one".to_owned(),
326            |index| {
327                landed.push(index);
328                std::ops::ControlFlow::Continue(())
329            },
330        )
331        .await
332        .expect("the run lands");
333
334        assert_eq!(landed, vec![1, 2], "the caller hears every clip, in order");
335    }
336
337    #[tokio::test]
338    async fn a_stopped_run_is_cancelled_and_scores_nothing() {
339        let corpus = vec![
340            BenchClip { transcript: "one".to_owned() },
341            BenchClip { transcript: "two".to_owned() },
342        ];
343        let Err(error) = run(
344            &StubEngine,
345            &corpus,
346            |_, _| "one".to_owned(),
347            |_| std::ops::ControlFlow::Break(()),
348        )
349        .await
350        else {
351            panic!("a stopped run is not a result");
352        };
353        assert!(matches!(error, crate::Error::Cancelled));
354    }
355}