Skip to main content

forge_tts/
split.rs

1//! Splitting an utterance into render units, and the bound one must fit.
2
3use crate::Error;
4
5/// A rough speaking pace, in characters per second, for a duration estimate.
6const CHARS_PER_SECOND: u64 = 15;
7
8/// The sentences of `text`, in order, with their punctuation.
9///
10/// A unit ends at a `.`, `!` or `?` followed by whitespace or the end, except
11/// a period between two digits, which is a decimal. Abbreviations are not
12/// recognised: a false split costs one extra render, a missed split only
13/// latency.
14pub fn sentences(text: &str) -> Vec<String> {
15    let mut units = Vec::new();
16    let mut start = 0;
17    let mut previous_digit = false;
18    for (index, character) in text.char_indices() {
19        let end = index + character.len_utf8();
20        let next = text[end..].chars().next();
21        let ends = match character {
22            '!' | '?' => next.is_none_or(char::is_whitespace),
23            '.' => {
24                let decimal = previous_digit && next.is_some_and(|next| next.is_ascii_digit());
25                !decimal && next.is_none_or(char::is_whitespace)
26            }
27            _ => false,
28        };
29        if ends {
30            push(&mut units, &text[start..end]);
31            start = end;
32        }
33        previous_digit = character.is_ascii_digit();
34    }
35    push(&mut units, &text[start..]);
36    units
37}
38
39/// Whether one whole utterance fits `max_seconds`, as a character estimate
40/// at fifteen characters per second rather than a wall-clock measure.
41pub fn within_bound(text: &str, max_seconds: u64) -> Result<(), Error> {
42    let bound = max_seconds.saturating_mul(CHARS_PER_SECOND);
43    let characters = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
44    if characters > bound {
45        return Err(Error::Render(format!(
46            "the utterance is longer than [tts] max_utterance_seconds ({max_seconds})"
47        )));
48    }
49    Ok(())
50}
51
52fn push(units: &mut Vec<String>, unit: &str) {
53    let trimmed = unit.trim();
54    if !trimmed.is_empty() {
55        units.push(trimmed.to_owned());
56    }
57}
58
59#[cfg(test)]
60mod tests {
61    use super::{sentences, within_bound};
62
63    #[test]
64    fn three_sentences_split_into_three() {
65        assert_eq!(
66            sentences("One. Two! Three?"),
67            vec!["One.".to_owned(), "Two!".to_owned(), "Three?".to_owned()]
68        );
69    }
70
71    #[test]
72    fn a_decimal_is_not_a_sentence_end() {
73        assert_eq!(sentences("Version 1.2 shipped."), vec!["Version 1.2 shipped.".to_owned()]);
74    }
75
76    #[test]
77    fn text_with_no_terminal_punctuation_is_one_unit() {
78        assert_eq!(sentences("no punctuation here"), vec!["no punctuation here".to_owned()]);
79    }
80
81    #[test]
82    fn an_over_long_utterance_is_refused_by_name() {
83        let long = "word ".repeat(2_000);
84        let error = within_bound(&long, 120).expect_err("refused");
85        assert!(error.to_string().contains("max_utterance_seconds"));
86    }
87}