Skip to main content

aria_kit/transcription/
sentences.rs

1//! Sentence endings across streamed segments.
2
3/// Holds back the mark that ends a streamed segment until the next one
4/// shows whether the sentence went on.
5///
6/// A segment decoded alone ends as a sentence, so a pause mid-sentence
7/// types "should. While it". The next decode hears the words before that
8/// pause, and [`TranscriptSegment::sentence_ended_before`] carries its
9/// reading. Type [`SentenceEnds::punctuate`] per segment, then
10/// [`SentenceEnds::finish`] when nothing follows.
11///
12/// ```
13/// use aria_kit::transcription::SentenceEnds;
14///
15/// let mut sentences = SentenceEnds::default();
16/// let first = sentences.punctuate("I think we should.", None);
17/// assert_eq!(first.text, "I think we should");
18/// assert!(first.defers_ending);
19/// let goes_on = sentences.punctuate("ship it.", Some(false));
20/// assert_eq!(goes_on.text, "ship it");
21/// let new_sentence = sentences.punctuate("Then rest.", Some(true));
22/// assert_eq!(new_sentence.text, ". Then rest");
23/// assert_eq!(sentences.finish().unwrap(), ".");
24/// ```
25///
26/// [`TranscriptSegment::sentence_ended_before`]: super::TranscriptSegment::sentence_ended_before
27#[derive(Debug, Default, Clone, PartialEq, Eq)]
28pub struct SentenceEnds {
29    /// The last segment's closing mark, not yet typed.
30    deferred: Option<char>,
31}
32
33/// One segment's text, ready to type after the text before it.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Punctuated {
36    /// Opens with the mark the segment before held back.
37    pub text: String,
38    /// `text`'s own closing mark is held back. Type `text` with nothing
39    /// after it, so the mark can still attach.
40    pub defers_ending: bool,
41}
42
43/// Abbreviations whose period ends no sentence, as in "etc. and more".
44const ABBREVIATIONS: [&str; 2] = ["etc", "vs"];
45
46impl SentenceEnds {
47    /// Returns `text` to type after the segment before, its own closing
48    /// mark held back. `Some(false)` drops the mark that segment owes;
49    /// anything else opens `text` with it.
50    #[must_use = "the deferred ending is owed to the previous segment"]
51    pub fn punctuate(&mut self, text: &str, sentence_ended_before: Option<bool>) -> Punctuated {
52        let mut text = match (self.deferred.take(), sentence_ended_before) {
53            (Some(_), Some(false)) | (None, _) => text.to_owned(),
54            (Some(mark), _) => format!("{mark} {text}"),
55        };
56        let defers_ending = match closing_mark(&text) {
57            Some(mark) => {
58                text.pop();
59                self.deferred = Some(mark);
60                true
61            }
62            None => false,
63        };
64        Punctuated {
65            text,
66            defers_ending,
67        }
68    }
69
70    /// The mark the last segment owes, once nothing follows it.
71    pub fn finish(&mut self) -> Option<String> {
72        self.deferred.take().map(char::into)
73    }
74}
75
76/// Whether `word` ends a sentence: a lone mark, not "U.S." or "etc.".
77pub(crate) fn ends_sentence(word: &str) -> bool {
78    closing_mark(word).is_some()
79}
80
81/// The one mark that ends `text` as a sentence. `None` for a mark inside
82/// the last word, as in "U.S." or "...", and for anything else.
83fn closing_mark(text: &str) -> Option<char> {
84    let mark = text
85        .chars()
86        .last()
87        .filter(|mark| matches!(mark, '.' | '?' | '!'))?;
88    let kept = &text[..text.len() - mark.len_utf8()];
89    let last_word = kept.rsplit(char::is_whitespace).next().unwrap_or_default();
90    let closes = !last_word.is_empty()
91        && !last_word.ends_with(['.', '?', '!'])
92        && !(mark == '.' && (last_word.contains('.') || ABBREVIATIONS.contains(&last_word)));
93    closes.then_some(mark)
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn after(previous: &str, ended: Option<bool>, next: &str) -> Punctuated {
101        let mut sentences = SentenceEnds::default();
102        let _ = sentences.punctuate(previous, None);
103        sentences.punctuate(next, ended)
104    }
105
106    fn typed(text: &str, defers_ending: bool) -> Punctuated {
107        Punctuated {
108            text: text.to_owned(),
109            defers_ending,
110        }
111    }
112
113    #[test]
114    fn a_sentence_the_recognizer_heard_go_on_drops_the_mark() {
115        assert_eq!(
116            after("I think we should.", Some(false), "ship it tomorrow."),
117            typed("ship it tomorrow", true)
118        );
119        assert_eq!(
120            after("Ready?", Some(false), "or not"),
121            typed("or not", false)
122        );
123    }
124
125    #[test]
126    fn a_sentence_the_recognizer_heard_end_keeps_the_mark() {
127        assert_eq!(
128            after("I think we should.", Some(true), "Ship it."),
129            typed(". Ship it", true)
130        );
131        assert_eq!(after("Really?", Some(true), "Yes!"), typed("? Yes", true));
132    }
133
134    #[test]
135    fn a_pause_at_every_word_keeps_one_sentence() {
136        let mut sentences = SentenceEnds::default();
137        let mut typed_text: Vec<String> = [
138            ("don't need vocabulary.", None),
139            ("for those.", Some(false)),
140            ("anymore.", Some(false)),
141        ]
142        .into_iter()
143        .map(|(text, ended)| sentences.punctuate(text, ended).text)
144        .collect();
145        typed_text.extend(sentences.finish());
146
147        assert_eq!(
148            typed_text,
149            ["don't need vocabulary", "for those", "anymore", "."]
150        );
151    }
152
153    #[test]
154    fn an_unknown_boundary_types_the_mark() {
155        assert_eq!(
156            after("We should.", None, "while it"),
157            typed(". while it", false)
158        );
159    }
160
161    #[test]
162    fn only_a_lone_closing_mark_is_deferred() {
163        for text in ["Ship it.", "Ready?", "Stop!"] {
164            assert!(
165                SentenceEnds::default().punctuate(text, None).defers_ending,
166                "{text:?}"
167            );
168        }
169        for text in [
170            "Wait...",
171            "the U.S.",
172            "salsa, etc.",
173            "us vs.",
174            "done",
175            ".",
176            "?!",
177        ] {
178            assert!(
179                !SentenceEnds::default().punctuate(text, None).defers_ending,
180                "{text:?}"
181            );
182        }
183    }
184
185    #[test]
186    fn finish_types_the_deferred_mark_once() {
187        let mut sentences = SentenceEnds::default();
188        let _ = sentences.punctuate("Ready?", None);
189        assert_eq!(sentences.finish().as_deref(), Some("?"));
190        assert_eq!(sentences.finish(), None);
191    }
192}