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().to_string();
162 seg.set_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.set_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.set_start(ns);
194 seg.set_end(ne);
195 } else {
196 if seg.start() < 0.0 {
197 seg.set_start(0.0);
198 repaired = true;
199 }
200 if seg.end() < 0.0 {
201 seg.set_end(0.0);
202 repaired = true;
203 }
204 }
205 if seg.end() < seg.start() {
206 let (a, b) = (seg.start(), seg.end());
207 seg.set_start(b);
208 seg.set_end(a);
209 repaired = true;
210 report.events.push(NormalizationEvent {
211 code: "swap_timestamps".into(),
212 detail: "inverted segment span swapped".into(),
213 });
214 }
215 if repaired {
216 report.repaired_timestamps += 1;
217 }
218 cleaned_segments.push(seg);
219 }
220 result.segments = cleaned_segments;
221
222 if !result.segments.is_empty() {
223 result.text = join_segment_text(&result.segments);
224 } else {
225 let before = result.text.clone();
226 result.text = strip_markers(&result.text);
227 if result.text != before {
228 report.markers_stripped = true;
229 }
230 result.text = result.text.trim().to_string();
231 if is_only_marker(&result.text) {
232 result.text.clear();
233 }
234 }
235
236 (result, report)
237}
238
239fn join_segment_text(segments: &[Segment]) -> String {
240 let mut out = String::new();
241 for seg in segments {
242 let t = seg.text().trim();
243 if t.is_empty() {
244 continue;
245 }
246 if !out.is_empty() {
247 out.push(' ');
248 }
249 out.push_str(t);
250 }
251 out
252}
253
254fn strip_markers(text: &str) -> String {
255 let mut out = text.to_string();
256 for marker in SPECIAL_MARKERS {
257 out = out.replace(marker, " ");
258 }
259 out.split_whitespace().collect::<Vec<_>>().join(" ")
260}
261
262fn is_only_marker(text: &str) -> bool {
263 let t = text.trim();
264 if t.is_empty() {
265 return false;
266 }
267 SPECIAL_MARKERS
268 .iter()
269 .any(|m| t.eq_ignore_ascii_case(m.trim_matches(|c| c == '[' || c == ']')))
270 || SPECIAL_MARKERS.iter().any(|m| t.eq_ignore_ascii_case(m))
271}
272
273pub fn truncate_chars(s: &str, max_chars: usize) -> String {
275 if s.chars().count() <= max_chars {
276 return s.to_string();
277 }
278 let truncated: String = s.chars().take(max_chars).collect();
279 format!("{truncated}…")
280}
281
282pub fn validated_segment(start: f64, end: f64, text: impl Into<String>) -> Option<Segment> {
284 if !start.is_finite() || !end.is_finite() || end < start {
285 return None;
286 }
287 let text = text.into();
288 if text.trim().is_empty() {
289 return None;
290 }
291 Some(Segment::from_parts_unchecked(
292 start,
293 end,
294 text.trim().to_string(),
295 ))
296}
297
298#[cfg(test)]
299mod tests {
300 use super::*;
301
302 #[test]
303 fn detects_extreme_ngram_loop() {
304 let phrase = "feature and i think it's ";
305 let text = phrase.repeat(40);
306 let detail = detect_repetition_degeneration(&text).expect("should flag loop");
307 assert!(detail.contains("repeated") || detail.contains("fraction"));
308 }
309
310 #[test]
311 fn ordinary_text_not_flagged() {
312 let text = "There's something that a lot of people do when they're working with AI \
313 to write code that totally drives me crazy and it's sort of something \
314 that I've been railing against for a while now. The thing that I've \
315 noticed is people tend to think I need to create a spec for AI.";
316 assert!(detect_repetition_degeneration(text).is_none());
317 }
318
319 #[test]
320 fn short_legitimate_repetition_ok() {
321 let text = "no no no I said yes yes yes please please please thank you thank you";
322 assert!(detect_repetition_degeneration(text).is_none());
323 }
324
325 #[test]
326 fn drops_nan_segments() {
327 let r = TranscriptionResult::local(
328 "x".into(),
329 vec![
330 Segment::from_parts_unchecked(f64::NAN, 1.0, "bad".to_string()),
331 Segment::from_parts_unchecked(0.0, 1.0, "good".to_string()),
332 ],
333 None,
334 "m".into(),
335 2.0,
336 );
337 let (out, report) = normalize_result_with_report(r);
338 assert_eq!(out.segments.len(), 1);
339 assert_eq!(out.segments[0].text(), "good");
340 assert!(report.dropped_segments >= 1);
341 }
342
343 #[test]
344 fn llm_backend_clears_reliable_flag() {
345 let mut r =
346 TranscriptionResult::openrouter("hi".into(), vec![], None, "m".into(), 1.0, true);
347 r.timestamps_reliable = true;
348 let (out, report) = normalize_result_with_report(r);
349 assert!(!out.timestamps_reliable);
350 assert!(!report.is_clean());
351 }
352
353 #[test]
354 fn validated_segment_rejects_inverted() {
355 assert!(validated_segment(2.0, 1.0, "x").is_none());
356 assert!(validated_segment(0.0, 1.0, "x").is_some());
357 }
358}