Skip to main content

aurum_core/
postprocess.rs

1//! Shared transcript cleanup applied after every provider.
2//!
3//! Whisper (and some remote models) emit control / non-speech tokens that should
4//! not leak into scripted `txt`/`srt`/`json` output.
5
6use crate::providers::{Segment, TranscriptionResult};
7
8/// Known non-speech / control markers frequently emitted by whisper.cpp.
9const SPECIAL_MARKERS: &[&str] = &[
10    "[BLANK_AUDIO]",
11    "[blank_audio]",
12    "[MUSIC]",
13    "[Music]",
14    "[music]",
15    "[SILENCE]",
16    "[Silence]",
17    "[silence]",
18    "[NOISE]",
19    "[Noise]",
20    "[noise]",
21    "[INAUDIBLE]",
22    "[Inaudible]",
23    "[CLICK]",
24    "[Click]",
25    "[APPLAUSE]",
26    "[Applause]",
27    "[LAUGHTER]",
28    "[Laughter]",
29    "[COUGH]",
30    "[Cough]",
31    "♪",
32    "♫",
33];
34
35/// Clean provider output into a stable, scriptable result.
36pub fn normalize_result(mut result: TranscriptionResult) -> TranscriptionResult {
37    // Drop / strip special markers from segments.
38    let mut cleaned_segments = Vec::with_capacity(result.segments.len());
39    for mut seg in result.segments.drain(..) {
40        seg.text = strip_markers(&seg.text);
41        let trimmed = seg.text.trim();
42        if trimmed.is_empty() || is_only_marker(trimmed) {
43            continue;
44        }
45        seg.text = trimmed.to_string();
46        // Drop non-finite / inverted spans (LLM backends can emit NaN).
47        if !seg.start.is_finite() || !seg.end.is_finite() {
48            continue;
49        }
50        // Clamp timestamps into [0, duration].
51        if result.duration_secs > 0.0 {
52            seg.start = seg.start.clamp(0.0, result.duration_secs);
53            seg.end = seg.end.clamp(0.0, result.duration_secs);
54        } else {
55            seg.start = seg.start.max(0.0);
56            seg.end = seg.end.max(0.0);
57        }
58        if seg.end < seg.start {
59            std::mem::swap(&mut seg.start, &mut seg.end);
60        }
61        cleaned_segments.push(seg);
62    }
63    result.segments = cleaned_segments;
64
65    // Rebuild full text from segments when available (keeps txt/srt/json aligned).
66    if !result.segments.is_empty() {
67        result.text = join_segment_text(&result.segments);
68    } else {
69        result.text = strip_markers(&result.text);
70        result.text = result.text.trim().to_string();
71        if is_only_marker(&result.text) {
72            result.text.clear();
73        }
74    }
75
76    result
77}
78
79fn join_segment_text(segments: &[Segment]) -> String {
80    let mut out = String::new();
81    for seg in segments {
82        let t = seg.text.trim();
83        if t.is_empty() {
84            continue;
85        }
86        if !out.is_empty() {
87            out.push(' ');
88        }
89        out.push_str(t);
90    }
91    out
92}
93
94fn strip_markers(text: &str) -> String {
95    let mut out = text.to_string();
96    for marker in SPECIAL_MARKERS {
97        out = out.replace(marker, " ");
98    }
99    // Collapse whitespace.
100    out.split_whitespace().collect::<Vec<_>>().join(" ")
101}
102
103fn is_only_marker(text: &str) -> bool {
104    let t = text.trim();
105    if t.is_empty() {
106        return false;
107    }
108    // Only drop known non-speech markers — not arbitrary bracketed phrases.
109    SPECIAL_MARKERS
110        .iter()
111        .any(|m| t.eq_ignore_ascii_case(m.trim_matches(|c| c == '[' || c == ']')))
112        || SPECIAL_MARKERS.iter().any(|m| t.eq_ignore_ascii_case(m))
113}
114
115/// UTF-8–safe truncation for error messages (never panics on char boundaries).
116pub fn truncate_chars(s: &str, max_chars: usize) -> String {
117    if s.chars().count() <= max_chars {
118        return s.to_string();
119    }
120    let truncated: String = s.chars().take(max_chars).collect();
121    format!("{truncated}…")
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn strips_blank_audio() {
130        let r = normalize_result(TranscriptionResult::local(
131            "[BLANK_AUDIO]".into(),
132            vec![Segment {
133                start: 0.0,
134                end: 10.0,
135                text: "[BLANK_AUDIO]".into(),
136            }],
137            Some("en".into()),
138            "tiny".into(),
139            3.0,
140        ));
141        assert!(r.text.is_empty(), "got {:?}", r.text);
142        assert!(r.segments.is_empty());
143    }
144
145    #[test]
146    fn clamps_timestamps() {
147        let r = normalize_result(TranscriptionResult::local(
148            "Hello".into(),
149            vec![Segment {
150                start: -1.0,
151                end: 99.0,
152                text: "Hello".into(),
153            }],
154            None,
155            "tiny".into(),
156            5.0,
157        ));
158        assert_eq!(r.segments[0].start, 0.0);
159        assert_eq!(r.segments[0].end, 5.0);
160    }
161
162    #[test]
163    fn truncate_multibyte_safe() {
164        let s = "hello 你好世界";
165        let t = truncate_chars(s, 8);
166        assert!(t.ends_with('…'));
167        assert!(t.is_char_boundary(t.len() - '…'.len_utf8()) || t.ends_with('…'));
168        // Must not panic and must be valid UTF-8 (guaranteed by String).
169        let _ = t.len();
170    }
171}