aria_kit/transcription/
sentences.rs1#[derive(Debug, Default, Clone, PartialEq, Eq)]
28pub struct SentenceEnds {
29 deferred: Option<char>,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Punctuated {
36 pub text: String,
38 pub defers_ending: bool,
41}
42
43const ABBREVIATIONS: [&str; 2] = ["etc", "vs"];
45
46impl SentenceEnds {
47 #[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 pub fn finish(&mut self) -> Option<String> {
72 self.deferred.take().map(char::into)
73 }
74}
75
76pub(crate) fn ends_sentence(word: &str) -> bool {
78 closing_mark(word).is_some()
79}
80
81fn 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}