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