Skip to main content

sayd_core/
chunk.rs

1//! Split text into synthesis units.
2//!
3//! Two-phase, because the binding constraint is not characters. Kokoro accepts
4//! at most 509 phoneme tokens per call, and the phoneme count of a string is
5//! unknowable until after G2P. So `chunk` splits on sentence boundaries to a
6//! character target, and `refit` re-splits anything that turns out to overrun
7//! once phonemized. Skipping the second phase truncates audio mid-word on long
8//! sentences.
9
10#[derive(Clone, Debug, PartialEq, Eq)]
11pub struct Chunk {
12    pub text: String,
13    /// True when this chunk begins a paragraph, so playback can insert a pause.
14    pub starts_paragraph: bool,
15}
16
17/// Sentence-boundary split, merged up to `target_chars`, paragraph-aware.
18pub fn chunk(text: &str, target_chars: usize) -> Vec<Chunk> {
19    let target = target_chars.max(1);
20    let mut out: Vec<Chunk> = Vec::new();
21
22    for para in text.split("\n\n") {
23        let para = para.trim();
24        if para.is_empty() {
25            continue;
26        }
27        let mut first_of_para = true;
28        let mut buf = String::new();
29
30        for sentence in sentences(para) {
31            for piece in split_oversized(&sentence, target) {
32                if !buf.is_empty() && buf.chars().count() + 1 + piece.chars().count() > target {
33                    out.push(Chunk {
34                        text: std::mem::take(&mut buf),
35                        starts_paragraph: first_of_para,
36                    });
37                    first_of_para = false;
38                }
39                if !buf.is_empty() {
40                    buf.push(' ');
41                }
42                buf.push_str(&piece);
43            }
44        }
45        if !buf.is_empty() {
46            out.push(Chunk { text: buf, starts_paragraph: first_of_para });
47        }
48    }
49    out
50}
51
52/// Split on sentence-final punctuation, keeping the punctuation attached.
53///
54/// A run of consecutive terminal punctuation (`?!`, `!!!`, `...`) stays in
55/// the sentence it ends, rather than each character starting a new
56/// (degenerate, one-character) sentence -- the merge step in `chunk` would
57/// otherwise glue those back together with spaces that were never in the
58/// source. `\n` is excluded from the run so newline-triggered breaks are
59/// unaffected.
60fn sentences(text: &str) -> Vec<String> {
61    let mut out = Vec::new();
62    let mut cur = String::new();
63    let mut chars = text.chars().peekable();
64    while let Some(ch) = chars.next() {
65        cur.push(ch);
66        if matches!(ch, '.' | '!' | '?' | ';' | ':' | '\n') {
67            if ch != '\n' {
68                while let Some(&next) = chars.peek() {
69                    if matches!(next, '.' | '!' | '?' | ';' | ':') {
70                        cur.push(next);
71                        chars.next();
72                    } else {
73                        break;
74                    }
75                }
76            }
77            let t = cur.trim().to_string();
78            if !t.is_empty() {
79                out.push(t);
80            }
81            cur.clear();
82        }
83    }
84    let t = cur.trim().to_string();
85    if !t.is_empty() {
86        out.push(t);
87    }
88    out
89}
90
91/// Break a single over-long sentence, preferring comma boundaries, then spaces.
92fn split_oversized(sentence: &str, target: usize) -> Vec<String> {
93    if sentence.chars().count() <= target {
94        return vec![sentence.to_string()];
95    }
96    let mut out = Vec::new();
97    let mut rest = sentence.to_string();
98    while rest.chars().count() > target {
99        let limit = match rest.char_indices().nth(target) {
100            Some((i, _)) => i,
101            None => break,
102        };
103        let head = &rest[..limit];
104        // Prefer a comma, then a space, inside the target window. If neither
105        // exists the window falls inside a single long word: rather than
106        // slicing mid-word, extend forward to that word's end (the next
107        // space) so the whole word survives intact -- the chunk may then
108        // exceed `target`, which is fine, since `target` is approximate and
109        // `refit` enforces the hard limit. Mirrors `halve_until`, which
110        // keeps an unsplittable word whole rather than truncating it.
111        let cut = head
112            .rfind(", ")
113            .map(|i| i + 1)
114            .or_else(|| head.rfind(' '))
115            .or_else(|| rest[limit..].find(' ').map(|off| limit + off))
116            .unwrap_or(rest.len());
117        let (a, b) = rest.split_at(cut);
118        let a = a.trim().to_string();
119        if a.is_empty() {
120            break; // no progress possible; emit the remainder below
121        }
122        out.push(a);
123        rest = b.trim().to_string();
124    }
125    if !rest.is_empty() {
126        out.push(rest);
127    }
128    out
129}
130
131/// Re-split chunks that overrun the phoneme-token budget.
132///
133/// `fits` is called with a chunk's text and answers whether its phonemized
134/// form is within budget. A chunk that cannot be split further is passed
135/// through unchanged rather than looped on -- synthesis will truncate it,
136/// which is audible but finite.
137pub fn refit(chunks: Vec<Chunk>, fits: impl Fn(&str) -> bool) -> Vec<Chunk> {
138    let mut out = Vec::with_capacity(chunks.len());
139    for c in chunks {
140        if fits(&c.text) {
141            out.push(c);
142            continue;
143        }
144        let mut first = true;
145        for piece in halve_until(&c.text, &fits) {
146            out.push(Chunk {
147                text: piece,
148                starts_paragraph: first && c.starts_paragraph,
149            });
150            first = false;
151        }
152    }
153    out
154}
155
156/// Repeatedly halve on word boundaries until each piece fits or is a single
157/// word. Terminates because every recursion strictly reduces the word count.
158fn halve_until(text: &str, fits: &impl Fn(&str) -> bool) -> Vec<String> {
159    if fits(text) {
160        return vec![text.to_string()];
161    }
162    let words: Vec<&str> = text.split_whitespace().collect();
163    if words.len() < 2 {
164        return vec![text.to_string()]; // unsplittable; caller accepts truncation
165    }
166    let mid = words.len() / 2;
167    let left = words[..mid].join(" ");
168    let right = words[mid..].join(" ");
169    let mut out = halve_until(&left, fits);
170    out.extend(halve_until(&right, fits));
171    out
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    fn splits_on_sentence_boundaries() {
180        let cs = chunk("One. Two. Three.", 6);
181        assert_eq!(cs.len(), 3);
182        assert_eq!(cs[0].text, "One.");
183        assert_eq!(cs[2].text, "Three.");
184    }
185
186    #[test]
187    fn merges_short_sentences_up_to_the_target() {
188        let cs = chunk("One. Two. Three.", 100);
189        assert_eq!(cs.len(), 1);
190        assert_eq!(cs[0].text, "One. Two. Three.");
191    }
192
193    #[test]
194    fn marks_paragraph_starts() {
195        let cs = chunk("First para.\n\nSecond para.", 100);
196        assert_eq!(cs.len(), 2, "a blank line must force a chunk break");
197        assert!(cs[0].starts_paragraph);
198        assert!(cs[1].starts_paragraph);
199    }
200
201    #[test]
202    fn splits_an_oversized_sentence_on_commas_then_spaces() {
203        let long = "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet";
204        let cs = chunk(long, 20);
205        assert!(cs.len() > 1);
206        for c in &cs {
207            assert!(c.text.chars().count() <= 25, "chunk too long: {:?}", c.text);
208        }
209    }
210
211    #[test]
212    fn never_produces_an_empty_chunk() {
213        for input in ["", "   ", "\n\n\n", ".", "a"] {
214            for c in chunk(input, 50) {
215                assert!(!c.text.trim().is_empty(), "empty chunk from {input:?}");
216            }
217        }
218    }
219
220    #[test]
221    fn preserves_all_words() {
222        let input = "The quick brown fox. Jumps over the lazy dog, twice.";
223        let rejoined: String = chunk(input, 15)
224            .iter()
225            .map(|c| c.text.clone())
226            .collect::<Vec<_>>()
227            .join(" ");
228        for word in ["quick", "brown", "jumps", "lazy", "twice"] {
229            assert!(
230                rejoined.to_lowercase().contains(word),
231                "lost {word:?} in {rejoined:?}"
232            );
233        }
234    }
235
236    #[test]
237    fn refit_splits_chunks_that_overrun_the_token_budget() {
238        // A chunk that fits the character target but not the token budget.
239        let cs = vec![Chunk { text: "aaa bbb ccc ddd".into(), starts_paragraph: true }];
240        // Pretend anything over 7 characters overruns.
241        let out = refit(cs, |s| s.chars().count() <= 7);
242        assert!(out.len() > 1, "expected a split, got {out:?}");
243        for c in &out {
244            assert!(c.text.chars().count() <= 7, "still too long: {:?}", c.text);
245        }
246    }
247
248    #[test]
249    fn refit_keeps_chunks_that_already_fit() {
250        let cs = vec![Chunk { text: "short".into(), starts_paragraph: false }];
251        let out = refit(cs.clone(), |_| true);
252        assert_eq!(out, cs);
253    }
254
255    #[test]
256    fn refit_only_the_first_piece_keeps_the_paragraph_flag() {
257        let cs = vec![Chunk { text: "aaa bbb ccc".into(), starts_paragraph: true }];
258        let out = refit(cs, |s| s.chars().count() <= 3);
259        assert!(out[0].starts_paragraph);
260        assert!(out[1..].iter().all(|c| !c.starts_paragraph));
261    }
262
263    #[test]
264    fn refit_gives_up_on_an_unsplittable_chunk_rather_than_looping() {
265        // A single word that can never fit. Must terminate and return it.
266        let cs = vec![Chunk { text: "supercalifragilistic".into(), starts_paragraph: false }];
267        let out = refit(cs, |s| s.chars().count() <= 3);
268        assert_eq!(out.len(), 1, "unsplittable input must be passed through, not looped on");
269    }
270
271    /// Every whitespace-separated piece in the chunk output must equal some
272    /// whitespace-separated word from the input (punctuation aside) -- i.e.
273    /// `chunk` never slices a word in two.
274    fn assert_no_word_is_shredded(input: &str, target: usize) {
275        let cs = chunk(input, target);
276        let input_words: std::collections::HashSet<String> = input
277            .split_whitespace()
278            .map(|w| w.trim_matches(|c: char| ",.;:!?".contains(c)).to_string())
279            .collect();
280        for c in &cs {
281            for piece in c.text.split_whitespace() {
282                let stripped = piece.trim_matches(|c: char| ",.;:!?".contains(c));
283                assert!(
284                    input_words.contains(stripped),
285                    "chunk {:?} contains fragment {:?} not present as a whole word in {:?}",
286                    c.text,
287                    stripped,
288                    input
289                );
290            }
291        }
292    }
293
294    #[test]
295    fn split_oversized_keeps_a_long_word_whole_when_alone() {
296        let word = "supercalifragilisticexpialidocious";
297        let cs = chunk(word, 10);
298        assert_eq!(cs.len(), 1);
299        assert_eq!(cs[0].text, word, "a lone unsplittable word must come back whole");
300    }
301
302    #[test]
303    fn split_oversized_keeps_a_long_word_whole_when_embedded() {
304        // The exact fixture from the bug report: without the fix, "charlie"
305        // was sliced into "charli" + "e".
306        assert_no_word_is_shredded(
307            "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet",
308            6,
309        );
310    }
311
312    #[test]
313    fn supercalifragilisticexpialidocious_is_long_at_a_small_target() {
314        let input = "supercalifragilisticexpialidocious is long";
315        assert_no_word_is_shredded(input, 20);
316        let cs = chunk(input, 20);
317        let rejoined: String = cs.iter().map(|c| c.text.as_str()).collect::<Vec<_>>().join(" ");
318        assert!(
319            rejoined.split_whitespace().any(|w| w == "supercalifragilisticexpialidocious"),
320            "the long word must survive whole: {rejoined:?}"
321        );
322    }
323
324    #[test]
325    fn word_preservation_property() {
326        let inputs = [
327            "The quick brown fox jumps over the lazy dog.",
328            "alpha bravo, charlie delta, echo foxtrot golf hotel india juliet",
329            "supercalifragilisticexpialidocious is a very long word indeed.",
330            "One. Two. Three. Four. Five. Six. Seven.",
331            "Short sentence here, followed by another, and yet another one for good measure.",
332            "Yes!!! What?! Wait... okay.",
333        ];
334        let targets = [1usize, 3, 6, 10, 20, 50, 100];
335        let strip = |w: &str| w.trim_matches(|c: char| ",.;:!?".contains(c)).to_string();
336
337        for input in inputs {
338            let expected: Vec<String> =
339                input.split_whitespace().map(strip).filter(|w| !w.is_empty()).collect();
340
341            for &target in &targets {
342                let cs = chunk(input, target);
343                let rejoined: String =
344                    cs.iter().map(|c| c.text.as_str()).collect::<Vec<_>>().join(" ");
345                let actual: Vec<String> =
346                    rejoined.split_whitespace().map(strip).filter(|w| !w.is_empty()).collect();
347                assert_eq!(
348                    actual, expected,
349                    "word sequence mismatch for {input:?} at target {target}: got chunks {cs:?}"
350                );
351            }
352        }
353    }
354
355    #[test]
356    fn sentences_keeps_runs_of_terminal_punctuation_together() {
357        assert_eq!(sentences("Yes!!!"), vec!["Yes!!!".to_string()]);
358        assert_eq!(sentences("What?!"), vec!["What?!".to_string()]);
359        assert_eq!(sentences("Wait..."), vec!["Wait...".to_string()]);
360    }
361
362    #[test]
363    fn chunk_does_not_insert_spaces_into_a_run_of_terminal_punctuation() {
364        // The exact fixture from the bug report: without the fix this
365        // produced the chunk text "... Yes! ! !".
366        let cs = chunk("... Yes!!!", 100);
367        assert_eq!(cs.len(), 1);
368        assert_eq!(cs[0].text, "... Yes!!!");
369    }
370
371    #[test]
372    fn sentences_still_breaks_at_a_newline_after_terminal_punctuation() {
373        let got = sentences("Wait...\nNext line.");
374        assert_eq!(got, vec!["Wait...".to_string(), "Next line.".to_string()]);
375    }
376}