1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
10pub struct SttFixture {
11 pub id: String,
12 pub language: String,
13 pub reference: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub audio: Option<String>,
18 #[serde(default)]
20 pub tags: Vec<String>,
21 #[serde(default = "default_true")]
23 pub timestamps_expected_reliable: bool,
24 #[serde(default)]
26 pub license: String,
27}
28
29fn default_true() -> bool {
30 true
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35pub struct TtsFixture {
36 pub id: String,
37 pub text: String,
38 #[serde(default)]
39 pub tags: Vec<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub duration_ms_min: Option<u64>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub duration_ms_max: Option<u64>,
45 #[serde(default)]
46 pub license: String,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct EvalCorpus {
52 pub version: u32,
53 pub name: String,
54 #[serde(default)]
55 pub stt: Vec<SttFixture>,
56 #[serde(default)]
57 pub tts: Vec<TtsFixture>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
62pub struct SttScore {
63 pub fixture_id: String,
64 pub wer: f64,
65 pub cer: f64,
66 pub empty_hypothesis: bool,
67 pub ref_words: usize,
68 pub hyp_words: usize,
69 pub timestamps_reliable: bool,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct EvalReport {
76 pub corpus_version: u32,
77 pub corpus_name: String,
78 pub model: String,
79 pub backend_kind: String,
80 pub stt_scores: Vec<SttScore>,
81 pub mean_wer: f64,
82 pub mean_cer: f64,
83}
84
85pub fn normalize_transcript(s: &str) -> String {
87 let mut out = String::with_capacity(s.len());
88 let mut last_space = true;
89 for ch in s.chars() {
90 if ch.is_alphanumeric() {
91 for c in ch.to_lowercase() {
92 out.push(c);
93 }
94 last_space = false;
95 } else if ch.is_whitespace() && !last_space {
96 out.push(' ');
97 last_space = true;
98 }
99 }
101 out.trim().to_string()
102}
103
104fn words(s: &str) -> Vec<&str> {
105 s.split_whitespace().filter(|w| !w.is_empty()).collect()
106}
107
108pub fn word_error_rate(reference: &str, hypothesis: &str) -> f64 {
110 let r = normalize_transcript(reference);
111 let h = normalize_transcript(hypothesis);
112 let rw = words(&r);
113 let hw = words(&h);
114 if rw.is_empty() {
115 return if hw.is_empty() { 0.0 } else { 1.0 };
116 }
117 let dist = levenshtein(&rw, &hw);
118 dist as f64 / rw.len() as f64
119}
120
121pub fn char_error_rate(reference: &str, hypothesis: &str) -> f64 {
123 let r: Vec<char> = normalize_transcript(reference).chars().collect();
124 let h: Vec<char> = normalize_transcript(hypothesis).chars().collect();
125 if r.is_empty() {
126 return if h.is_empty() { 0.0 } else { 1.0 };
127 }
128 let dist = levenshtein(&r, &h);
129 dist as f64 / r.len() as f64
130}
131
132fn levenshtein<T: PartialEq>(a: &[T], b: &[T]) -> usize {
133 let n = a.len();
134 let m = b.len();
135 if n == 0 {
136 return m;
137 }
138 if m == 0 {
139 return n;
140 }
141 let mut prev: Vec<usize> = (0..=m).collect();
142 let mut curr = vec![0; m + 1];
143 for i in 1..=n {
144 curr[0] = i;
145 for j in 1..=m {
146 let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
147 curr[j] = (prev[j] + 1) .min(curr[j - 1] + 1) .min(prev[j - 1] + cost); }
151 std::mem::swap(&mut prev, &mut curr);
152 }
153 prev[m]
154}
155
156pub fn score_stt(fixture: &SttFixture, hypothesis: &str, timestamps_reliable: bool) -> SttScore {
158 let wer = word_error_rate(&fixture.reference, hypothesis);
159 let cer = char_error_rate(&fixture.reference, hypothesis);
160 let hyp_n = words(&normalize_transcript(hypothesis)).len();
161 let ref_n = words(&normalize_transcript(&fixture.reference)).len();
162 SttScore {
163 fixture_id: fixture.id.clone(),
164 wer,
165 cer,
166 empty_hypothesis: hypothesis.trim().is_empty(),
167 ref_words: ref_n,
168 hyp_words: hyp_n,
169 timestamps_reliable,
170 }
171}
172
173pub fn build_report(
175 corpus: &EvalCorpus,
176 model: &str,
177 backend_kind: &str,
178 scores: Vec<SttScore>,
179) -> EvalReport {
180 let n = scores.len().max(1) as f64;
181 let mean_wer = scores.iter().map(|s| s.wer).sum::<f64>() / n;
182 let mean_cer = scores.iter().map(|s| s.cer).sum::<f64>() / n;
183 EvalReport {
184 corpus_version: corpus.version,
185 corpus_name: corpus.name.clone(),
186 model: model.into(),
187 backend_kind: backend_kind.into(),
188 stt_scores: scores,
189 mean_wer,
190 mean_cer,
191 }
192}
193
194pub fn smoke_corpus() -> EvalCorpus {
196 EvalCorpus {
197 version: 1,
198 name: "aurum-smoke-v1".into(),
199 stt: vec![
200 SttFixture {
201 id: "clean_short_en".into(),
202 language: "en".into(),
203 reference: "hello world".into(),
204 audio: None,
205 tags: vec!["clean".into(), "short".into()],
206 timestamps_expected_reliable: true,
207 license: "synthetic CC0".into(),
208 },
209 SttFixture {
210 id: "numbers_en".into(),
211 language: "en".into(),
212 reference: "the meeting is at 3 30 pm".into(),
213 audio: None,
214 tags: vec!["numbers".into()],
215 timestamps_expected_reliable: true,
216 license: "synthetic CC0".into(),
217 },
218 SttFixture {
219 id: "silence_empty".into(),
220 language: "en".into(),
221 reference: "".into(),
222 audio: None,
223 tags: vec!["silence".into()],
224 timestamps_expected_reliable: true,
225 license: "synthetic CC0".into(),
226 },
227 ],
228 tts: vec![
229 TtsFixture {
230 id: "tts_short".into(),
231 text: "Hello from Aurum.".into(),
232 tags: vec!["short".into()],
233 duration_ms_min: Some(200),
234 duration_ms_max: Some(5_000),
235 license: "synthetic CC0".into(),
236 },
237 TtsFixture {
238 id: "tts_numbers".into(),
239 text: "Call me at extension 42.".into(),
240 tags: vec!["numbers".into()],
241 duration_ms_min: Some(300),
242 duration_ms_max: Some(8_000),
243 license: "synthetic CC0".into(),
244 },
245 ],
246 }
247}
248
249pub fn tts_duration_in_range(fixture: &TtsFixture, duration_ms: u64) -> bool {
251 if let Some(min) = fixture.duration_ms_min {
252 if duration_ms < min {
253 return false;
254 }
255 }
256 if let Some(max) = fixture.duration_ms_max {
257 if duration_ms > max {
258 return false;
259 }
260 }
261 true
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267
268 #[test]
269 fn perfect_match_zero_wer() {
270 assert_eq!(word_error_rate("Hello, world!", "hello world"), 0.0);
271 }
272
273 #[test]
274 fn one_sub_wer() {
275 let wer = word_error_rate("a b c", "a x c");
276 assert!((wer - 1.0 / 3.0).abs() < 1e-9);
277 }
278
279 #[test]
280 fn cer_basic() {
281 let cer = char_error_rate("abc", "axc");
282 assert!((cer - 1.0 / 3.0).abs() < 1e-9);
283 }
284
285 #[test]
286 fn smoke_corpus_scores() {
287 let c = smoke_corpus();
288 let scores: Vec<_> = c
289 .stt
290 .iter()
291 .map(|f| score_stt(f, &f.reference, true))
292 .collect();
293 let report = build_report(&c, "tiny-q5_1", "asr", scores);
294 assert_eq!(report.mean_wer, 0.0);
295 assert_eq!(report.corpus_version, 1);
296 let json = serde_json::to_string_pretty(&report).unwrap();
297 assert!(json.contains("mean_wer"));
298 }
299
300 #[test]
301 fn empty_ref_empty_hyp() {
302 assert_eq!(word_error_rate("", ""), 0.0);
303 assert_eq!(word_error_rate("", "hi"), 1.0);
304 }
305}