1use 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
16pub struct BenchClip {
18 pub transcript: String,
19}
20
21#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
23pub struct TtsBenchResult {
24 pub clips: usize,
25 pub round_trip_wer: f64,
28 pub first_audio_ms: u64,
30 pub realtime_factor: f64,
32 #[serde(default)]
35 pub peak_rss_bytes: u64,
36}
37
38struct 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 self.stop_and_join();
87 }
88}
89
90fn 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
99pub 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 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
167fn 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
176fn 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
186fn 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 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 #[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}