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#[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 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}