1use crate::providers::{BackendKind, Segment, TranscriptionResult};
8use serde::{Deserialize, Serialize};
9
10const SPECIAL_MARKERS: &[&str] = &[
12 "[BLANK_AUDIO]",
13 "[blank_audio]",
14 "[MUSIC]",
15 "[Music]",
16 "[music]",
17 "[SILENCE]",
18 "[Silence]",
19 "[silence]",
20 "[NOISE]",
21 "[Noise]",
22 "[noise]",
23 "[INAUDIBLE]",
24 "[Inaudible]",
25 "[CLICK]",
26 "[Click]",
27 "[APPLAUSE]",
28 "[Applause]",
29 "[LAUGHTER]",
30 "[Laughter]",
31 "[COUGH]",
32 "[Cough]",
33 "♪",
34 "♫",
35];
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct NormalizationEvent {
40 pub code: String,
41 pub detail: String,
42}
43
44#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
46pub struct NormalizationReport {
47 pub events: Vec<NormalizationEvent>,
48 pub dropped_segments: usize,
49 pub repaired_timestamps: usize,
50 pub markers_stripped: bool,
51}
52
53impl NormalizationReport {
54 pub fn warnings(&self) -> Vec<String> {
55 self.events
56 .iter()
57 .map(|e| format!("{}: {}", e.code, e.detail))
58 .collect()
59 }
60
61 pub fn is_clean(&self) -> bool {
62 self.events.is_empty()
63 }
64}
65
66pub fn normalize_result(result: TranscriptionResult) -> TranscriptionResult {
68 normalize_result_with_report(result).0
69}
70
71pub fn detect_repetition_degeneration(text: &str) -> Option<String> {
79 let words: Vec<&str> = text.split_whitespace().filter(|w| !w.is_empty()).collect();
80 if words.len() < 40 {
81 return None;
82 }
83 const N: usize = 5;
85 const MAX_SAME_NGRAM: usize = 12; if words.len() < N + MAX_SAME_NGRAM {
87 return None;
88 }
89 let mut best = 0usize;
90 let mut best_phrase = String::new();
91 let mut i = 0usize;
92 while i + N <= words.len() {
93 let phrase = &words[i..i + N];
94 let mut count = 1usize;
95 let mut j = i + N;
96 while j + N <= words.len() && &words[j..j + N] == phrase {
97 count += 1;
98 j += N;
99 }
100 if count > best {
101 best = count;
102 best_phrase = phrase.join(" ");
103 }
104 if count >= MAX_SAME_NGRAM {
105 return Some(format!(
106 "repeated 5-gram {count}× (\"{best_phrase}\") — possible model degeneration"
107 ));
108 }
109 i += 1;
110 }
111 use std::collections::HashMap;
113 let mut freq: HashMap<String, usize> = HashMap::new();
114 for w in words.windows(N) {
115 *freq.entry(w.join(" ")).or_default() += 1;
116 }
117 if let Some((phrase, c)) = freq.into_iter().max_by_key(|(_, c)| *c) {
118 if c >= MAX_SAME_NGRAM && (c * N * 100 / words.len()) >= 40 {
119 return Some(format!(
120 "5-gram occupies large fraction of transcript ({c}× \"{phrase}\") — possible model degeneration"
121 ));
122 }
123 }
124 let _ = best_phrase;
125 let _ = best;
126 None
127}
128
129pub fn normalize_result_with_report(
131 mut result: TranscriptionResult,
132) -> (TranscriptionResult, NormalizationReport) {
133 let mut report = NormalizationReport::default();
134
135 if let Some(detail) = detect_repetition_degeneration(&result.text) {
136 report.events.push(NormalizationEvent {
137 code: "degeneration_repetition".into(),
138 detail,
139 });
140 }
141
142 if matches!(result.backend_kind, BackendKind::LlmAssisted) && result.timestamps_reliable {
144 result.timestamps_reliable = false;
145 report.events.push(NormalizationEvent {
146 code: "backend_reliability".into(),
147 detail: "LLM-assisted backend cannot claim reliable timestamps".into(),
148 });
149 }
150
151 if !result.duration_secs.is_finite() || result.duration_secs < 0.0 {
152 report.events.push(NormalizationEvent {
153 code: "duration".into(),
154 detail: format!("non-finite or negative duration {}", result.duration_secs),
155 });
156 result.duration_secs = 0.0;
157 }
158
159 let mut cleaned_segments = Vec::with_capacity(result.segments.len());
160 for mut seg in result.segments.drain(..) {
161 let before = seg.text.clone();
162 seg.text = strip_markers(&seg.text);
163 if seg.text != before {
164 report.markers_stripped = true;
165 }
166 let trimmed = seg.text.trim();
167 if trimmed.is_empty() || is_only_marker(trimmed) {
168 report.dropped_segments += 1;
169 report.events.push(NormalizationEvent {
170 code: "drop_segment".into(),
171 detail: "empty or marker-only segment".into(),
172 });
173 continue;
174 }
175 seg.text = trimmed.to_string();
176
177 if !seg.start.is_finite() || !seg.end.is_finite() {
178 report.dropped_segments += 1;
179 report.events.push(NormalizationEvent {
180 code: "drop_segment".into(),
181 detail: "non-finite timestamps".into(),
182 });
183 continue;
184 }
185
186 let mut repaired = false;
187 if result.duration_secs > 0.0 {
188 let ns = seg.start.clamp(0.0, result.duration_secs);
189 let ne = seg.end.clamp(0.0, result.duration_secs);
190 if ns != seg.start || ne != seg.end {
191 repaired = true;
192 }
193 seg.start = ns;
194 seg.end = ne;
195 } else {
196 if seg.start < 0.0 {
197 seg.start = 0.0;
198 repaired = true;
199 }
200 if seg.end < 0.0 {
201 seg.end = 0.0;
202 repaired = true;
203 }
204 }
205 if seg.end < seg.start {
206 std::mem::swap(&mut seg.start, &mut seg.end);
207 repaired = true;
208 report.events.push(NormalizationEvent {
209 code: "swap_timestamps".into(),
210 detail: "inverted segment span swapped".into(),
211 });
212 }
213 if repaired {
214 report.repaired_timestamps += 1;
215 }
216 cleaned_segments.push(seg);
217 }
218 result.segments = cleaned_segments;
219
220 if !result.segments.is_empty() {
221 result.text = join_segment_text(&result.segments);
222 } else {
223 let before = result.text.clone();
224 result.text = strip_markers(&result.text);
225 if result.text != before {
226 report.markers_stripped = true;
227 }
228 result.text = result.text.trim().to_string();
229 if is_only_marker(&result.text) {
230 result.text.clear();
231 }
232 }
233
234 (result, report)
235}
236
237fn join_segment_text(segments: &[Segment]) -> String {
238 let mut out = String::new();
239 for seg in segments {
240 let t = seg.text.trim();
241 if t.is_empty() {
242 continue;
243 }
244 if !out.is_empty() {
245 out.push(' ');
246 }
247 out.push_str(t);
248 }
249 out
250}
251
252fn strip_markers(text: &str) -> String {
253 let mut out = text.to_string();
254 for marker in SPECIAL_MARKERS {
255 out = out.replace(marker, " ");
256 }
257 out.split_whitespace().collect::<Vec<_>>().join(" ")
258}
259
260fn is_only_marker(text: &str) -> bool {
261 let t = text.trim();
262 if t.is_empty() {
263 return false;
264 }
265 SPECIAL_MARKERS
266 .iter()
267 .any(|m| t.eq_ignore_ascii_case(m.trim_matches(|c| c == '[' || c == ']')))
268 || SPECIAL_MARKERS.iter().any(|m| t.eq_ignore_ascii_case(m))
269}
270
271pub fn truncate_chars(s: &str, max_chars: usize) -> String {
273 if s.chars().count() <= max_chars {
274 return s.to_string();
275 }
276 let truncated: String = s.chars().take(max_chars).collect();
277 format!("{truncated}…")
278}
279
280pub fn validated_segment(start: f64, end: f64, text: impl Into<String>) -> Option<Segment> {
282 if !start.is_finite() || !end.is_finite() || end < start {
283 return None;
284 }
285 let text = text.into();
286 if text.trim().is_empty() {
287 return None;
288 }
289 Some(Segment {
290 start,
291 end,
292 text: text.trim().to_string(),
293 })
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299
300 #[test]
301 fn detects_extreme_ngram_loop() {
302 let phrase = "feature and i think it's ";
303 let text = phrase.repeat(40);
304 let detail = detect_repetition_degeneration(&text).expect("should flag loop");
305 assert!(detail.contains("repeated") || detail.contains("fraction"));
306 }
307
308 #[test]
309 fn ordinary_text_not_flagged() {
310 let text = "There's something that a lot of people do when they're working with AI \
311 to write code that totally drives me crazy and it's sort of something \
312 that I've been railing against for a while now. The thing that I've \
313 noticed is people tend to think I need to create a spec for AI.";
314 assert!(detect_repetition_degeneration(text).is_none());
315 }
316
317 #[test]
318 fn short_legitimate_repetition_ok() {
319 let text = "no no no I said yes yes yes please please please thank you thank you";
320 assert!(detect_repetition_degeneration(text).is_none());
321 }
322
323 #[test]
324 fn drops_nan_segments() {
325 let r = TranscriptionResult::local(
326 "x".into(),
327 vec![
328 Segment {
329 start: f64::NAN,
330 end: 1.0,
331 text: "bad".into(),
332 },
333 Segment {
334 start: 0.0,
335 end: 1.0,
336 text: "good".into(),
337 },
338 ],
339 None,
340 "m".into(),
341 2.0,
342 );
343 let (out, report) = normalize_result_with_report(r);
344 assert_eq!(out.segments.len(), 1);
345 assert_eq!(out.segments[0].text, "good");
346 assert!(report.dropped_segments >= 1);
347 }
348
349 #[test]
350 fn llm_backend_clears_reliable_flag() {
351 let mut r =
352 TranscriptionResult::openrouter("hi".into(), vec![], None, "m".into(), 1.0, true);
353 r.timestamps_reliable = true;
354 let (out, report) = normalize_result_with_report(r);
355 assert!(!out.timestamps_reliable);
356 assert!(!report.is_clean());
357 }
358
359 #[test]
360 fn validated_segment_rejects_inverted() {
361 assert!(validated_segment(2.0, 1.0, "x").is_none());
362 assert!(validated_segment(0.0, 1.0, "x").is_some());
363 }
364}