Skip to main content

aria_kit/transcription/layer/
backtrack.rs

1use std::ops::Range;
2
3use crate::cancel::CancelToken;
4
5use super::{Layer, TranscriptionError};
6
7const ONE_WORD_CUE: &str = "actually";
8const TWO_WORD_CUES: &[(&str, &str)] = &[("scratch", "that"), ("i", "mean"), ("no", "wait")];
9const PREPOSITIONS: &[&str] = &["to", "at", "on", "in", "for", "from", "with"];
10
11/// Applies a spoken self-correction in English: "meet at 3, actually 4"
12/// becomes "meet at 4". Only a lone integer, or the word after a repeated
13/// preposition, is replaced. Quoted or bracketed text is left alone.
14#[derive(Debug, Clone, Copy, Default)]
15pub struct Backtrack;
16
17impl Layer for Backtrack {
18    fn apply(&self, text: &mut String, _cancel: &CancelToken) -> Result<(), TranscriptionError> {
19        if let Some(corrected) = correct(text) {
20            *text = corrected;
21        }
22        Ok(())
23    }
24}
25
26fn correct(text: &str) -> Option<String> {
27    let mut tokens = tokens(text);
28    for index in 0..tokens.len() {
29        let width = cue_width(&tokens[index..]);
30        if width == 0 {
31            continue;
32        }
33        let mut before = index;
34        while before > 0 && separator(tokens[before - 1].text) {
35            before -= 1;
36        }
37        let mut after = index + width;
38        while after < tokens.len()
39            && separator(tokens[after].text)
40            && !tokens.get(after + 1).is_some_and(|next| {
41                (tokens[after].text == "-"
42                    || tokens[after].start + tokens[after].text.len() == next.start)
43                    && next.text.starts_with(|c: char| c.is_ascii_digit())
44            })
45        {
46            after += 1;
47        }
48        for token in &mut tokens[before..index] {
49            token.cue_separator = true;
50        }
51        for token in &mut tokens[index + width..after] {
52            token.cue_separator = true;
53        }
54    }
55
56    let mut protected = Vec::new();
57    let mut unmatched_quote = false;
58    let mut blocked = false;
59    let mut clause_start = 0;
60    let mut copied = 0;
61    let mut output = String::new();
62    for index in 0..=tokens.len() {
63        if let Some(token) = tokens.get(index) {
64            unmatched_quote |= matches!(token.text, "”" | "’")
65                && protected
66                    .last()
67                    .is_none_or(|closing| *closing != token.text);
68            blocked |= protection(token.text, &mut protected);
69            if !boundary(token.text) || token.cue_separator {
70                continue;
71            }
72        }
73        if !blocked && let Some(removed) = correction(&tokens[clause_start..index]) {
74            output.push_str(&text[copied..removed.start]);
75            copied = removed.end;
76        }
77        clause_start = index + 1;
78        blocked = unmatched_quote || !protected.is_empty();
79    }
80    if copied == 0 {
81        return None;
82    }
83    output.push_str(&text[copied..]);
84    Some(output)
85}
86
87struct Token<'a> {
88    text: &'a str,
89    start: usize,
90    cue_separator: bool,
91}
92
93fn tokens(text: &str) -> Vec<Token<'_>> {
94    let mut tokens = Vec::new();
95    let mut start = 0;
96    for (index, character) in text.char_indices() {
97        let end = index + character.len_utf8();
98        let previous = text[..index].chars().next_back();
99        let next = text[end..].chars().next();
100        let internal = match character {
101            '.' | '?' | '!' | ';' => next.is_some_and(|c| !c.is_whitespace()),
102            '\'' | '’' => {
103                previous.is_some_and(char::is_alphanumeric)
104                    && next.is_some_and(char::is_alphanumeric)
105            }
106            ',' => next.is_some_and(|c| c.is_ascii_digit()),
107            _ => false,
108        };
109        if !character.is_whitespace()
110            && (internal
111                || !matches!(
112                    character,
113                    '.' | ','
114                        | '!'
115                        | '?'
116                        | ';'
117                        | '—'
118                        | '–'
119                        | '"'
120                        | '“'
121                        | '”'
122                        | '\''
123                        | '‘'
124                        | '’'
125                        | '`'
126                        | '('
127                        | ')'
128                        | '['
129                        | ']'
130                        | '{'
131                        | '}'
132                ))
133        {
134            continue;
135        }
136        if start < index {
137            tokens.push(Token {
138                text: &text[start..index],
139                start,
140                cue_separator: false,
141            });
142        }
143        if !character.is_whitespace()
144            || matches!(character, '\n' | '\r' | '\u{85}' | '\u{2028}' | '\u{2029}')
145        {
146            tokens.push(Token {
147                text: &text[index..end],
148                start: index,
149                cue_separator: false,
150            });
151        }
152        start = end;
153    }
154    if start < text.len() {
155        tokens.push(Token {
156            text: &text[start..],
157            start,
158            cue_separator: false,
159        });
160    }
161    tokens
162}
163
164fn cue_width(tokens: &[Token<'_>]) -> usize {
165    let Some(first) = tokens.first() else {
166        return 0;
167    };
168    if first.text.eq_ignore_ascii_case(ONE_WORD_CUE) {
169        return 1;
170    }
171    let Some(second) = tokens.get(1) else {
172        return 0;
173    };
174    for (begin, end) in TWO_WORD_CUES {
175        if first.text.eq_ignore_ascii_case(begin) && second.text.eq_ignore_ascii_case(end) {
176            return 2;
177        }
178    }
179    0
180}
181
182fn separator(text: &str) -> bool {
183    matches!(text, "," | "—" | "–" | "-")
184}
185
186fn boundary(text: &str) -> bool {
187    matches!(
188        text,
189        "." | "," | "!" | "?" | ";" | "\n" | "\r" | "\u{85}" | "\u{2028}" | "\u{2029}"
190    )
191}
192
193fn protection(text: &str, stack: &mut Vec<&'static str>) -> bool {
194    if stack.last().is_some_and(|closing| *closing == text) {
195        stack.pop();
196        return true;
197    }
198    let closing = match text {
199        "\"" => "\"",
200        "'" => "'",
201        "`" => "`",
202        "“" => "”",
203        "‘" => "’",
204        "(" => ")",
205        "[" => "]",
206        "{" => "}",
207        ")" | "]" | "}" | "”" | "’" => return true,
208        _ => return false,
209    };
210    if !stack
211        .last()
212        .is_some_and(|closing| matches!(*closing, "\"" | "'" | "`" | "”" | "’"))
213    {
214        stack.push(closing);
215    }
216    true
217}
218
219fn correction(tokens: &[Token<'_>]) -> Option<Range<usize>> {
220    let mut cues = tokens.iter().enumerate().filter_map(|(index, _)| {
221        let width = cue_width(&tokens[index..]);
222        (width > 0).then_some((index, width))
223    });
224    let (cue, width) = cues.next()?;
225    if cues.next().is_some() {
226        return None;
227    }
228    let mut before_end = cue;
229    while before_end > 0 && tokens[before_end - 1].cue_separator {
230        before_end -= 1;
231    }
232    let mut after_start = cue + width;
233    while after_start < tokens.len() && tokens[after_start].cue_separator {
234        after_start += 1;
235    }
236    let before = &tokens[..before_end];
237    let after = &tokens[after_start..];
238    let old = before.last()?;
239    let numeric = |word: &str| !word.is_empty() && word.bytes().all(|c| c.is_ascii_digit());
240    // Only adjacent integers and repeated prepositions are corrected. Anything
241    // broader needs semantic cleanup.
242    let replacement = match after {
243        [new] if numeric(old.text) && numeric(new.text) => {
244            if before
245                .iter()
246                .rev()
247                .nth(1)
248                .is_some_and(|token| separator(token.text) || numeric(token.text))
249            {
250                return None;
251            }
252            new
253        }
254        [preposition, new] => {
255            let anchor = before.iter().rev().nth(1)?;
256            if !PREPOSITIONS
257                .iter()
258                .any(|word| anchor.text.eq_ignore_ascii_case(word))
259                || !anchor.text.eq_ignore_ascii_case(preposition.text)
260                || !old.text.chars().all(char::is_alphabetic)
261                || !new.text.chars().all(char::is_alphabetic)
262            {
263                return None;
264            }
265            new
266        }
267        _ => return None,
268    };
269    Some(old.start..replacement.start)
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn cues_replace_integers_and_repeated_prepositions() {
278        for cue in ["actually", "scratch that", "I mean", "no wait"] {
279            for (heard, expected) in [
280                (format!("meet me at 3, {cue} 4"), "meet me at 4"),
281                (format!("meet me at 3 {cue} 4."), "meet me at 4."),
282                (
283                    format!("send it to Ali — {cue} — to Sara"),
284                    "send it to Sara",
285                ),
286                (format!("send it to Ali–{cue}–to Sara!"), "send it to Sara!"),
287                (
288                    format!("Send it TO Ali, {} TO Sára.", cue.to_uppercase()),
289                    "Send it TO Sára.",
290                ),
291            ] {
292                assert_eq!(correct(&heard).as_deref(), Some(expected), "{heard}");
293                assert_eq!(correct(expected), None, "{expected}");
294            }
295        }
296        for preposition in ["to", "at", "on", "in", "for", "from", "with"] {
297            let heard = format!("send it {preposition} Ali, actually {preposition} Sara");
298            assert_eq!(correct(&heard), Some(format!("send it {preposition} Sara")));
299        }
300    }
301
302    #[test]
303    fn splices_preserve_surrounding_text_and_punctuation() {
304        for (heard, expected) in [
305            (
306                "Leave it here; meet me at 3, actually 4.",
307                "Leave it here; meet me at 4.",
308            ),
309            (
310                "At 3, actually 4. To Ali, no wait to Sara!",
311                "At 4. To Sara!",
312            ),
313            (
314                "  meet\tme at 3, actually 4!\nGo home.",
315                "  meet\tme at 4!\nGo home.",
316            ),
317            (
318                "meet at 3, actually, 4, then leave",
319                "meet at 4, then leave",
320            ),
321            ("meet at 3—actually—4", "meet at 3—actually—4"),
322            ("meet at 3—actually 4", "meet at 4"),
323            ("meet at 3,actually 4", "meet at 4"),
324            ("at 3 - no wait 4?", "at 4?"),
325            ("to Али — scratch that — to Сара.", "to Сара."),
326            ("to علی، actually to سارا", "to علی، actually to سارا"),
327            ("meet at 0003, actually 0004", "meet at 0004"),
328        ] {
329            let corrected = correct(heard);
330            assert_eq!(corrected.as_deref().unwrap_or(heard), expected, "{heard}");
331        }
332    }
333
334    #[test]
335    fn ambiguous_or_unsupported_speech_is_unchanged() {
336        for heard in [
337            "",
338            "I mean it",
339            "actually, yes",
340            "I mean 4",
341            "actually 4",
342            "scratch that",
343            "meet at 3 actually",
344            "meet at 3 scratch that",
345            "meet at 3 no wait",
346            "at 3. Actually 4.",
347            "at 3; actually 4",
348            "at 3\nactually 4",
349            "at 3, actually 4 tomorrow",
350            "to Ali actually Sara",
351            "to Ali actually from Sara",
352            "to Ali Reza actually to Sara",
353            "to Ali actually to Sara Khan",
354            "at three actually four",
355            "at 3 actually four",
356            "at three actually 4",
357            "version 1.3, actually 1.4",
358            "at 3.5 actually 4",
359            "at 3 actually 4.5",
360            "at .3 actually 4",
361            "at 3 actually .4",
362            "at 3 actually 4.\u{200b}5",
363            "at 3:30 actually 4:30",
364            "at 3 actually 4:30",
365            "at 3,000 actually 4,000",
366            "at -3 actually 4",
367            "at +3 actually 4",
368            "at 3 actually -4",
369            "at 3 actually - 4",
370            "at –3 actually 4",
371            "at 3 actually –4",
372            "at 3 actually —4",
373            "at 3% actually 4",
374            "at $3 actually 4",
375            "at 3 actually $4",
376            "at v3 actually v4",
377            "at item_3 actually 4",
378            "at 3/4 actually 5",
379            "to Ali-Reza actually to Sara",
380            "to Ali—Reza actually to Sara",
381            "to Ali actually to Sara-Khan",
382            "to Ali actually to Sara—Khan",
383            "at 3 actually 4 no wait 5",
384            "at 3 actually 4, no wait 5",
385            "I mean it, at 3 actually 4 no wait 5",
386            "Actually, meet at 3 actually 4",
387            "at 3 notactually 4",
388            "at 3 I-mean 4",
389            "at 3 no\nwait 4",
390            "write \"at 3 actually 4\"",
391            "write 'at 3 actually 4'",
392            "write `at 3 actually 4`",
393            "write “at 3 actually 4”",
394            "write ‘at 3 actually 4’",
395            "(at 3 actually 4)",
396            "[at 3 actually 4]",
397            "{at 3 actually 4}",
398            "\"hello. meet at 3 actually 4.\"",
399            "\"hello. meet at 3 actually 4",
400            "`code; at 3 actually 4",
401            "(aside [aside]. at 3 actually 4)",
402            "to Ali, I mean it",
403            "go to https://example.com actually to Sara",
404            "send https://example.com?3 actually 4",
405            "send https://example.com;3 actually 4",
406            "send https://example.com!3 actually 4",
407            "send https://example.com/a,3 actually 4",
408            "at 3 actually 4?5",
409            "to Ali actually to Sara?Khan",
410            "at 1\u{202f}000 actually 2",
411            "at 1\u{a0}000 actually 2",
412            "at 1 000 actually 2",
413            "at 3\u{2028}actually 4",
414            "at 3\u{2029}actually 4",
415            "at 3\u{85}actually 4",
416            "”quoted. Meet at 3 actually 4.",
417            "’quoted. Meet at 3 actually 4.",
418        ] {
419            assert_eq!(correct(heard), None, "{heard}");
420        }
421    }
422
423    #[test]
424    fn closing_quotes_restore_eligibility_only_in_the_next_clause() {
425        for quoted in [
426            "\"quoted. text\"",
427            "'quoted. text'",
428            "‘quoted. text’",
429            "`quoted; text`",
430            "(quoted [text]. text)",
431        ] {
432            let heard = format!("{quoted}. Meet at 3 actually 4.");
433            assert_eq!(correct(&heard), Some(format!("{quoted}. Meet at 4.")));
434            let heard = format!("{quoted} meet at 3 actually 4.");
435            assert_eq!(correct(&heard), None);
436        }
437        assert_eq!(
438            correct("I'm at 3, actually 4."),
439            Some("I'm at 4.".to_owned())
440        );
441    }
442
443    #[test]
444    fn many_clauses_preserve_rejected_text() {
445        let heard = "I mean it. Meet at 3 actually 4. At 3 actually 4 no wait 5. ".repeat(4096);
446        let expected = "I mean it. Meet at 4. At 3 actually 4 no wait 5. ".repeat(4096);
447        assert_eq!(correct(&heard), Some(expected));
448    }
449}