From 4e6d2df988260bc1ebfe2a0be1db0bc81d67fa64 Mon Sep 17 00:00:00 2001
From: John Tennant <jtennant@squareup.com>
Date: Wed, 29 Jul 2026 13:54:25 -0400
Subject: [PATCH 1/2] Simplify Pocket TTS segmentation

Signed-off-by: John Tennant <jtennant@squareup.com>
---
 crates/buzz-voice/src/pocket.rs               |  11 +-
 crates/buzz-voice/src/pocket_april.rs         | 331 ++++++++++++++----
 desktop/src-tauri/Cargo.lock                  |   1 -
 desktop/src-tauri/Cargo.toml                  |   1 -
 desktop/src-tauri/src/huddle/preprocessing.rs | 124 -------
 desktop/src-tauri/src/huddle/tts.rs           |  71 ++--
 desktop/src-tauri/src/huddle/tts_audio.rs     |  41 ---
 desktop/src-tauri/src/huddle/tts_streaming.rs |  12 +-
 .../src/huddle/tts_streaming_tests.rs         |  14 +-
 desktop/src-tauri/src/huddle/tts_tests.rs     |  76 ----
 .../src/huddle/tts_tests/token_split.rs       |   4 +-
 11 files changed, 319 insertions(+), 367 deletions(-)

diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs
index c087bfde9d..6bca5594d6 100644
--- a/crates/buzz-voice/src/pocket.rs
+++ b/crates/buzz-voice/src/pocket.rs
@@ -99,6 +99,15 @@ pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
 impl PocketTts {
     /// Split text into synthesis units that satisfy the bundle's exact
     /// 50-token input limit.
+    ///
+    /// The first sentence remains its own unit when it fits. Oversized
+    /// sentences fall back to clause, word, and UTF-8 scalar boundaries, while
+    /// later sentences pack into the largest natural unit that fits.
+    ///
+    /// Chunks are contiguous substrings of the prepared model prompt and may
+    /// retain boundary whitespace. Concatenating them with `chunks.concat()`
+    /// reconstructs that prompt exactly, and each chunk's prepared token count
+    /// is at most 50.
     pub fn split_text_into_chunks(&self, text: &str) -> Result<Vec<String>, String> {
         let Some(prepared) = prepare_april_prompt(text) else {
             return Ok(Vec::new());
@@ -106,7 +115,7 @@ impl PocketTts {
         self.inner
             .lock()
             .map_err(|_| "Pocket TTS engine lock poisoned".to_string())?
-            .split_prompt(&prepared)
+            .split_playback_prompt(&prepared)
     }
 
     /// Synthesize text with the supplied reference voice.
diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs
index 3d85a3bdcf..11344072df 100644
--- a/crates/buzz-voice/src/pocket_april.rs
+++ b/crates/buzz-voice/src/pocket_april.rs
@@ -36,6 +36,13 @@ const DECODER_CHUNK_FRAMES: usize = 12;
 const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0;
 const GENERATION_SECONDS_PADDING: f32 = 2.0;
 
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum TextBoundary {
+    Sentence,
+    Clause,
+    Word,
+}
+
 #[derive(Debug, Deserialize)]
 struct Bundle {
     schema_version: u32,
@@ -251,62 +258,27 @@ impl AprilPocketTts {
         &self,
         prepared: &AprilPreparedPrompt,
     ) -> Result<Vec<String>, String> {
-        if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk {
+        if self.prepared_token_count(&prepared.text)? <= self.bundle.max_token_per_chunk {
             return Ok(vec![prepared.text.clone()]);
         }
+        split_at_natural_boundaries(
+            &prepared.text,
+            self.bundle.max_token_per_chunk,
+            false,
+            |text| self.prepared_token_count(text),
+        )
+    }
 
-        let mut chunks = Vec::new();
-        let mut current = String::new();
-        for word in prepared.text.split_whitespace() {
-            let candidate = if current.is_empty() {
-                word.to_string()
-            } else {
-                format!("{current} {word}")
-            };
-            if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk {
-                current = candidate;
-                continue;
-            }
-            if !current.is_empty() {
-                chunks.push(std::mem::take(&mut current));
-            }
-
-            if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk {
-                current = word.to_string();
-                continue;
-            }
-
-            let mut fragment = String::new();
-            for ch in word.chars() {
-                let candidate = format!("{fragment}{ch}");
-                if !fragment.is_empty()
-                    && self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk
-                {
-                    chunks.push(std::mem::take(&mut fragment));
-                }
-                fragment.push(ch);
-            }
-            current = fragment;
-        }
-        if !current.is_empty() {
-            chunks.push(current);
-        }
-
-        chunks
-            .into_iter()
-            .map(|text| {
-                let chunk = prepare_april_prompt(&text)
-                    .ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
-                let token_count = self.token_count(&chunk.text)?;
-                if token_count > self.bundle.max_token_per_chunk {
-                    return Err(format!(
-                        "Pocket TTS prompt chunk has {token_count} tokens; maximum is {}",
-                        self.bundle.max_token_per_chunk
-                    ));
-                }
-                Ok(chunk.text)
-            })
-            .collect()
+    pub(crate) fn split_playback_prompt(
+        &self,
+        prepared: &AprilPreparedPrompt,
+    ) -> Result<Vec<String>, String> {
+        split_at_natural_boundaries(
+            &prepared.text,
+            self.bundle.max_token_per_chunk,
+            true,
+            |text| self.prepared_token_count(text),
+        )
     }
 
     pub(crate) fn synth_chunk_streaming<F>(
@@ -656,6 +628,150 @@ impl AprilPocketTts {
     }
 }
 
+fn split_at_natural_boundaries<F>(
+    text: &str,
+    max_tokens: usize,
+    isolate_first_sentence: bool,
+    mut token_count: F,
+) -> Result<Vec<String>, String>
+where
+    F: FnMut(&str) -> Result<usize, String>,
+{
+    if text.is_empty() {
+        return Ok(Vec::new());
+    }
+
+    let mut chunks = Vec::new();
+    let mut start = 0;
+    while start < text.len() {
+        while text[start..]
+            .chars()
+            .next()
+            .is_some_and(char::is_whitespace)
+        {
+            start += text[start..]
+                .chars()
+                .next()
+                .expect("checked above")
+                .len_utf8();
+        }
+        if start == text.len() {
+            break;
+        }
+
+        let mut first_sentence_end = None;
+        let mut sentence_end = None;
+        let mut clause_end = None;
+        let mut word_end = None;
+        for (offset, ch) in text[start..].char_indices() {
+            let end = start + offset + ch.len_utf8();
+            let at_word_end =
+                end == text.len() || text[end..].chars().next().is_some_and(char::is_whitespace);
+            let at_clause_end = matches!(ch, '—' | '–')
+                && !text[end..]
+                    .chars()
+                    .next()
+                    .is_some_and(is_closing_punctuation);
+            if (!at_word_end && !at_clause_end) || token_count(&text[start..end])? > max_tokens {
+                continue;
+            }
+
+            word_end = Some(end);
+            match natural_boundary(&text[start..end], end == text.len()) {
+                TextBoundary::Sentence => {
+                    first_sentence_end.get_or_insert(end);
+                    sentence_end = Some(end);
+                }
+                TextBoundary::Clause => clause_end = Some(end),
+                TextBoundary::Word => {}
+            }
+        }
+
+        let preferred_end = if isolate_first_sentence && chunks.is_empty() {
+            first_sentence_end.or(clause_end).or(word_end)
+        } else {
+            sentence_end.or(clause_end).or(word_end)
+        };
+        let end = if let Some(end) = preferred_end {
+            end
+        } else {
+            // A single word can itself exceed the model limit. Preserve a
+            // scalar boundary as the final safety case without losing UTF-8.
+            let mut scalar_end = None;
+            for (offset, ch) in text[start..].char_indices() {
+                if ch.is_whitespace() {
+                    break;
+                }
+                let end = start + offset + ch.len_utf8();
+                if token_count(&text[start..end])? <= max_tokens {
+                    scalar_end = Some(end);
+                }
+            }
+            scalar_end.ok_or_else(|| {
+                format!(
+                    "Pocket TTS prompt cannot fit one character within the {max_tokens}-token limit"
+                )
+            })?
+        };
+
+        let mut next_start = end;
+        while text[next_start..]
+            .chars()
+            .next()
+            .is_some_and(char::is_whitespace)
+        {
+            next_start += text[next_start..]
+                .chars()
+                .next()
+                .expect("checked above")
+                .len_utf8();
+        }
+        chunks.push(text[start..next_start].to_string());
+        start = next_start;
+    }
+
+    debug_assert_eq!(chunks.concat(), text);
+    Ok(chunks)
+}
+
+fn natural_boundary(candidate: &str, is_end_of_text: bool) -> TextBoundary {
+    if is_end_of_text {
+        return TextBoundary::Sentence;
+    }
+
+    let mut chars = candidate.chars().rev();
+    let mut last = chars.next();
+    while last.is_some_and(is_closing_punctuation) {
+        last = chars.next();
+    }
+    match last {
+        Some('.' | '!' | '?') if !looks_like_abbreviation(candidate) => TextBoundary::Sentence,
+        Some(',' | ';' | ':' | '—' | '–') => TextBoundary::Clause,
+        _ => TextBoundary::Word,
+    }
+}
+
+fn is_closing_punctuation(ch: char) -> bool {
+    matches!(ch, '"' | '\'' | '”' | '’' | ')' | ']' | '}')
+}
+
+fn looks_like_abbreviation(candidate: &str) -> bool {
+    const ABBREVIATIONS: &[&str] = &[
+        "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.",
+        "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.",
+    ];
+
+    let candidate = candidate.trim_end_matches(is_closing_punctuation);
+    let last_word = candidate
+        .rsplit_once(char::is_whitespace)
+        .map_or(candidate, |(_, word)| word);
+    ABBREVIATIONS.contains(&last_word)
+        || (last_word.ends_with('.')
+            && last_word[..last_word.len() - 1]
+                .chars()
+                .all(|ch| ch.is_ascii_digit()))
+}
+
 fn load_session(path: PathBuf, num_threads: usize) -> Result<Session, String> {
     if !path.is_file() {
         return Err(format!("missing Pocket TTS file: {}", path.display()));
@@ -879,6 +995,89 @@ mod tests {
         assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000);
     }
 
+    fn whitespace_token_count(text: &str) -> Result<usize, String> {
+        Ok(text.split_whitespace().count())
+    }
+
+    #[test]
+    fn natural_split_keeps_first_sentence_separate_then_packs_the_remainder() {
+        let text = "One two. Three four. Five six.";
+        let chunks = split_at_natural_boundaries(text, 4, true, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["One two. ", "Three four. Five six."]);
+        assert_eq!(chunks.concat(), text);
+    }
+
+    #[test]
+    fn model_split_packs_multiple_sentences_within_limit() {
+        let text = "One two. Three four. Five six.";
+        let chunks = split_at_natural_boundaries(text, 4, false, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["One two. Three four. ", "Five six."]);
+        assert_eq!(chunks.concat(), text);
+    }
+
+    #[test]
+    fn natural_split_prefers_preceding_sentence_boundary() {
+        let text = "One two. Three four five six.";
+        let chunks = split_at_natural_boundaries(text, 5, true, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["One two. ", "Three four five six."]);
+        assert_eq!(chunks.concat(), text);
+    }
+
+    #[test]
+    fn oversized_sentence_uses_clause_then_word_fallback() {
+        let clause_text = "One two three, four five six seven.";
+        let clause_chunks =
+            split_at_natural_boundaries(clause_text, 5, true, whitespace_token_count).unwrap();
+        assert_eq!(clause_chunks, ["One two three, ", "four five six seven."]);
+        assert_eq!(clause_chunks.concat(), clause_text);
+
+        let word_text = "One two three four five six.";
+        let word_chunks =
+            split_at_natural_boundaries(word_text, 4, true, whitespace_token_count).unwrap();
+        assert_eq!(word_chunks, ["One two three four ", "five six."]);
+        assert_eq!(word_chunks.concat(), word_text);
+    }
+
+    #[test]
+    fn natural_split_preserves_unicode_punctuation_and_abbreviations() {
+        let text = "“Café naïve?” Maybe—yes, definitely; 東京 speaks.";
+        let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap();
+        assert_eq!(
+            chunks,
+            ["“Café naïve?” ", "Maybe—yes, definitely; ", "東京 speaks."]
+        );
+        assert_eq!(chunks.concat(), text);
+
+        let abbreviation = "Dr. Smith waits. Then leaves.";
+        let chunks =
+            split_at_natural_boundaries(abbreviation, 3, true, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["Dr. Smith waits. ", "Then leaves."]);
+        assert_eq!(chunks.concat(), abbreviation);
+
+        let unspaced_clause = "alpha beta—gamma delta";
+        let chunks =
+            split_at_natural_boundaries(unspaced_clause, 2, true, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["alpha beta—", "gamma delta"]);
+        assert_eq!(chunks.concat(), unspaced_clause);
+    }
+
+    #[test]
+    fn natural_split_does_not_treat_numeric_punctuation_as_unspaced_clauses() {
+        let text = "Meet at 12:30 with 1,000 guests onward.";
+        let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap();
+        assert_eq!(chunks, ["Meet at 12:30 ", "with 1,000 guests ", "onward."]);
+        assert_eq!(chunks.concat(), text);
+    }
+
+    #[test]
+    fn oversized_word_uses_utf8_scalar_boundary_without_loss() {
+        let text = "éééé";
+        let chunks =
+            split_at_natural_boundaries(text, 3, true, |chunk| Ok(chunk.chars().count())).unwrap();
+        assert_eq!(chunks, ["ééé", "é"]);
+        assert_eq!(chunks.concat(), text);
+    }
+
     #[test]
     fn normal_noise_has_requested_length() {
         let mut rng = rand::rng();
@@ -928,8 +1127,10 @@ mod tests {
 
         assert!(chunks.len() > 1);
         assert!(chunks.iter().all(|chunk| {
-            engine.token_count(chunk).expect("tokenize chunk") <= engine.bundle.max_token_per_chunk
+            engine.prepared_token_count(chunk).expect("tokenize chunk")
+                <= engine.bundle.max_token_per_chunk
         }));
+        assert_eq!(chunks.concat(), prepared.text);
     }
 
     #[test]
@@ -943,16 +1144,20 @@ mod tests {
         let chunks = engine.split_prompt(&prepared).expect("split long sentence");
         let token_counts: Vec<_> = chunks
             .iter()
-            .map(|chunk| engine.token_count(chunk).expect("count tokens"))
+            .map(|chunk| engine.prepared_token_count(chunk).expect("count tokens"))
             .collect();
 
-        assert_eq!(
-            chunks,
-            [
-                "And sometimes, when I am certain the reader is rested, I will engage him with a sentence of considerable length, a sentence that burns with energy and builds with all the.",
-                "Impetus of a crescendo, the roll of the drums, the crash of the cymbals–sounds that say listen to this, it is important.",
-            ]
-        );
-        assert_eq!(token_counts, [48, 44]);
+        assert!(token_counts
+            .iter()
+            .all(|&count| count <= engine.bundle.max_token_per_chunk));
+        assert_eq!(chunks.concat(), prepared.text);
+        assert!(chunks.len() > 1);
+        assert!(chunks[..chunks.len() - 1].iter().all(|chunk| {
+            chunk
+                .trim_end()
+                .chars()
+                .last()
+                .is_some_and(|ch| ['.', '!', '?', ',', ';', ':', '—', '–'].contains(&ch))
+        }));
     }
 }
diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock
index 46a16cbd89..505c4d2360 100644
--- a/desktop/src-tauri/Cargo.lock
+++ b/desktop/src-tauri/Cargo.lock
@@ -1078,7 +1078,6 @@ dependencies = [
  "opus",
  "plist",
  "png 0.18.1",
- "regex",
  "reqwest 0.13.4",
  "rodio",
  "rubato",
diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml
index aed6ab7bf6..03145125c4 100644
--- a/desktop/src-tauri/Cargo.toml
+++ b/desktop/src-tauri/Cargo.toml
@@ -117,7 +117,6 @@ image = { version = "0.25", default-features = false, features = ["jpeg", "png",
 zip = "8"
 flate2 = "1"
 sherpa-onnx = "1.12"
-regex = "1"
 rusqlite = { version = "0.37", features = ["bundled"] }
 axum = "0.8"
 rodio = "0.22"
diff --git a/desktop/src-tauri/src/huddle/preprocessing.rs b/desktop/src-tauri/src/huddle/preprocessing.rs
index ce85e3145e..8eeddc2bea 100644
--- a/desktop/src-tauri/src/huddle/preprocessing.rs
+++ b/desktop/src-tauri/src/huddle/preprocessing.rs
@@ -12,87 +12,6 @@
 //!   → numbers → words           → "forty two"
 //!   → collapse whitespace       → clean string
 //! ```
-//!
-//! Also provides `split_sentences` — the single sentence-boundary splitter used
-//! by both the TTS batching pipeline and the Supertonic text chunker.
-
-use regex::Regex;
-use std::sync::LazyLock;
-
-// ── Sentence splitting ────────────────────────────────────────────────────────
-
-/// Regex: a sentence-ending punctuation mark followed by whitespace.
-static RE_SENTENCE_BOUNDARY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([.!?])\s+").unwrap());
-
-/// Common abbreviations that end with a period but are NOT sentence boundaries.
-const ABBREVIATIONS: &[&str] = &[
-    "Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.",
-    "Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.",
-];
-
-/// Split text into sentence-sized chunks.
-///
-/// Combines regex-based boundary detection with:
-/// - Abbreviation awareness (`Dr.`, `Mr.`, etc. don't split)
-/// - Digit-before-period check (avoids splitting `1.` `2.` numbered lists)
-/// - `\n` and `—` treated as sentence breaks
-///
-/// Returns non-empty, trimmed strings.
-pub fn split_sentences(text: &str) -> Vec<String> {
-    // First, split on newlines and em-dashes to get coarse segments.
-    let coarse: Vec<&str> = text.split(['\n', '—']).collect();
-
-    let mut sentences = Vec::new();
-
-    for segment in coarse {
-        let segment = segment.trim();
-        if segment.is_empty() {
-            continue;
-        }
-        // Within each segment, split on sentence-ending punctuation.
-        let matches: Vec<_> = RE_SENTENCE_BOUNDARY.find_iter(segment).collect();
-        if matches.is_empty() {
-            sentences.push(segment.to_string());
-            continue;
-        }
-
-        let mut last_end = 0usize;
-        for m in &matches {
-            let before = &segment[last_end..m.start()];
-            let punc_char = &segment[m.start()..m.start() + 1];
-
-            // Skip if this looks like an abbreviation.
-            let combined = format!("{}{}", before.trim(), punc_char);
-            let is_abbrev = ABBREVIATIONS.iter().any(|a| combined.ends_with(a));
-
-            // Skip if the character before the period is a digit (numbered list).
-            let is_digit_period = punc_char == "."
-                && !before.is_empty()
-                && before.ends_with(|c: char| c.is_ascii_digit());
-
-            if !is_abbrev && !is_digit_period {
-                let piece = segment[last_end..m.end()].trim();
-                if !piece.is_empty() {
-                    sentences.push(piece.to_string());
-                }
-                last_end = m.end();
-            }
-        }
-
-        if last_end < segment.len() {
-            let tail = segment[last_end..].trim();
-            if !tail.is_empty() {
-                sentences.push(tail.to_string());
-            }
-        }
-    }
-
-    if sentences.is_empty() {
-        vec![text.to_string()]
-    } else {
-        sentences
-    }
-}
 
 // ── Public API ────────────────────────────────────────────────────────────────
 
@@ -602,49 +521,6 @@ mod tests {
         assert_eq!(out, "hello world");
     }
 
-    #[test]
-    fn split_sentences_basic() {
-        let result = split_sentences("Hello world. How are you? I'm fine!");
-        assert_eq!(result, vec!["Hello world.", "How are you?", "I'm fine!"]);
-    }
-
-    #[test]
-    fn split_sentences_newline_break() {
-        let result = split_sentences("First line.\nSecond line.");
-        assert_eq!(result, vec!["First line.", "Second line."]);
-    }
-
-    #[test]
-    fn split_sentences_em_dash_break() {
-        let result = split_sentences("Start here—then continue.");
-        assert_eq!(result, vec!["Start here", "then continue."]);
-    }
-
-    #[test]
-    fn split_sentences_abbreviations() {
-        let result = split_sentences("Dr. Smith went home. He was tired.");
-        assert_eq!(result, vec!["Dr. Smith went home.", "He was tired."]);
-    }
-
-    #[test]
-    fn split_sentences_numbered_list() {
-        let result = split_sentences("1. First item. 2. Second item.");
-        // "1." and "2." should NOT cause a split (digit before period).
-        assert_eq!(result, vec!["1. First item.", "2. Second item."]);
-    }
-
-    #[test]
-    fn split_sentences_single() {
-        let result = split_sentences("Just one sentence");
-        assert_eq!(result, vec!["Just one sentence"]);
-    }
-
-    #[test]
-    fn split_sentences_empty() {
-        let result = split_sentences("");
-        assert_eq!(result, vec![""]);
-    }
-
     #[test]
     fn filters_trivial_responses() {
         assert_eq!(preprocess_for_tts("."), "");
diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs
index 466cd9bd75..87942a6ea1 100644
--- a/desktop/src-tauri/src/huddle/tts.rs
+++ b/desktop/src-tauri/src/huddle/tts.rs
@@ -7,8 +7,8 @@
 //!   → bounded sync_channel (TEXT_QUEUE_DEPTH = 8)
 //!   → tts_worker thread (owns 1 Pocket TTS engine + 1 persistent Player)
 //!       1. Preprocess text
-//!       2. Split into sentences
-//!       3. Synthesize each sentence while decoder blocks become available
+//!       2. Split into natural model-valid chunks
+//!       3. Synthesize each chunk while decoder blocks become available
 //!       4. Queue monotonic PCM deltas while retaining the final fade suffix
 //!       5. Append each buffer to the persistent rodio Player (gapless)
 //!       6. While audio is draining, keep pulling queued text items and
@@ -51,7 +51,7 @@ use super::pocket::{
     load_text_to_speech, load_voice_style, SynthesisOutcome, DEFAULT_VOICE, SAMPLE_RATE,
     VOICE_FILE_EXT,
 };
-use super::preprocessing::{preprocess_for_tts, split_sentences};
+use super::preprocessing::preprocess_for_tts;
 #[path = "tts_streaming.rs"]
 mod streaming;
 #[cfg(test)]
@@ -111,37 +111,16 @@ const SYNTH_STEPS: usize = 1;
 /// the leading waveform is important.
 const FADE_OUT_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.008) as usize;
 
-/// Length of the zero-sample cushion prepended before each synthesized
-/// sentence chunk, so the OS audio device / rodio mixer has a fully-quiet
-/// ramp-up window before the real onset hits.
+/// Zero-sample cushion prepended to every synthesis chunk (20 ms at 24 kHz).
 ///
-/// This used to be applied only before the first sentence of a whole response.
-/// That still left later sentence chunks vulnerable to first-syllable clipping
-/// when their first phoneme was soft (notably `I'm` / `I've`) and rodio crossed
-/// from an explicit silence buffer straight into non-zero speech. 20 ms ≈ 480
-/// samples is enough to cover a CoreAudio buffer turnover without being audible
-/// as latency. At sentence boundaries this lead-in is budgeted out of the
-/// existing inter-sentence pause, so it does not lengthen multi-sentence gaps.
-const SENTENCE_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize;
-
-/// Approximate character budget for one synthesis chunk.
-///
-/// Upstream pocket-tts groups sentences into chunks of up to
-/// `MAX_TOKEN_PER_CHUNK = 50` tokenizer tokens (`default_parameters.py`) —
-/// typically multi-sentence chunks — because every `generate()` call is an
-/// independent generation with a cold FlowLM start, and each chunk boundary
-/// is an exposed prosody seam (kyutai-labs/pocket-tts #151; the Kyutai team
-/// names chunk stitching as the reliability lever). Our previous
-/// sentence-per-call path created ~2–4× more seams than upstream.
-///
-/// This character budget performs only coarse sentence packing. The April
-/// engine applies its SentencePiece tokenizer afterward and refines every
-/// result at the bundle's exact 50-token boundary.
-const MAX_CHUNK_CHARS: usize = 200;
+/// Gives CoreAudio and the rodio mixer a quiet ramp before speech begins. At
+/// chunk boundaries, the cushion is budgeted from the existing inter-chunk
+/// pause so it does not lengthen multi-chunk gaps.
+const CHUNK_LEAD_IN_SAMPLES: usize = (SAMPLE_RATE as f64 * 0.020) as usize;
 
-/// Silence inserted between sentences by the TTS pipeline (seconds).
-/// Injected as a silent buffer between each synthesized sentence chunk.
-const INTER_SENTENCE_SILENCE: f32 = 0.1;
+/// Silence inserted between synthesis chunks by the TTS pipeline (seconds).
+/// Injected as a silent buffer between each synthesized chunk.
+const INTER_CHUNK_SILENCE: f32 = 0.1;
 
 type WorkerControlState = (
     Arc<AtomicBool>,
@@ -522,7 +501,7 @@ fn tts_worker(
             })
     };
     if let Err(ref e) = monitor {
-        // Degraded but functional: barge-in still works between sentences
+        // Degraded but functional: barge-in still works between chunks
         // via the worker's own checks, just not mid-synthesis.
         eprintln!("buzz-desktop: TTS barge-in monitor failed to spawn: {e}");
     }
@@ -535,7 +514,7 @@ fn tts_worker(
     // `tts_active` lifecycle: set on the first append while idle, cleared
     // whenever the player has fully drained — either in the idle timeout
     // arm or on item receipt before synthesis begins.
-    let silence_buf_len = (INTER_SENTENCE_SILENCE * SAMPLE_RATE as f32) as usize;
+    let silence_buf_len = (INTER_CHUNK_SILENCE * SAMPLE_RATE as f32) as usize;
     // `first_append` = "no audio queued since the player last went idle".
     // Flipped after the first streamed append; the idle branch below uses it
     // to decide when to drop `tts_active` and arm a fresh lead-in cushion for
@@ -661,17 +640,19 @@ fn tts_worker(
             continue;
         }
 
-        // Split into sentences, then group into synthesis chunks: the first
-        // sentence stays alone (fast time-to-first-audio), the rest pack
-        // greedily up to MAX_CHUNK_CHARS. Playback of each model unit overlaps
-        // synthesis of the next one. The Pocket engine applies its exact
-        // 50-token split; keeping those units within one playback chunk avoids
-        // adding fades and pauses at token-only boundaries.
-        let sentences: Vec<String> = split_sentences(&text)
-            .into_iter()
-            .filter(|s| !s.trim().is_empty())
-            .collect();
-        let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS);
+        // The shared Pocket engine keeps a fitting first sentence separate for
+        // low time-to-first-audio, then packs later text at natural boundaries
+        // using the April tokenizer's exact 50-token limit. Decoder streaming
+        // starts playback within each resulting unit.
+        let chunks = match engine.split_text_into_chunks(&text) {
+            Ok(chunks) => chunks,
+            Err(error) => {
+                eprintln!(
+                    "buzz-desktop: tts stage=synthesis status=failed reason=split route_id={route_id} error={error}"
+                );
+                continue;
+            }
+        };
         if chunks.is_empty() {
             eprintln!(
                 "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}"
diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs
index 84f854d86c..8a157b5368 100644
--- a/desktop/src-tauri/src/huddle/tts_audio.rs
+++ b/desktop/src-tauri/src/huddle/tts_audio.rs
@@ -13,44 +13,3 @@ pub(super) fn apply_fade_out(samples: &mut [f32]) {
         samples[len - 1 - i] *= i as f32 / fade as f32;
     }
 }
-
-pub(super) fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec<String> {
-    let mut chunks: Vec<String> = Vec::new();
-    for (index, sentence) in sentences.iter().enumerate() {
-        let sentence = sentence.trim();
-        if sentence.is_empty() {
-            continue;
-        }
-        if index == 0 || chunks.is_empty() {
-            chunks.push(sentence.to_string());
-            continue;
-        }
-        let can_merge = chunks.len() > 1
-            && chunks
-                .last()
-                .is_some_and(|chunk| chunk.len() + 1 + sentence.len() <= max_chars);
-        if can_merge {
-            if let Some(last) = chunks.last_mut() {
-                last.push(' ');
-                last.push_str(sentence);
-            }
-        } else {
-            chunks.push(sentence.to_string());
-        }
-    }
-    chunks
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn grouped_sentences_keep_existing_latency_policy() {
-        let sentences = vec!["First.".to_string(), "Second.".to_string()];
-        assert_eq!(
-            group_sentences_into_chunks(&sentences, 200),
-            ["First.", "Second."]
-        );
-    }
-}
diff --git a/desktop/src-tauri/src/huddle/tts_streaming.rs b/desktop/src-tauri/src/huddle/tts_streaming.rs
index 99bf01e830..5fb180db10 100644
--- a/desktop/src-tauri/src/huddle/tts_streaming.rs
+++ b/desktop/src-tauri/src/huddle/tts_streaming.rs
@@ -1,7 +1,7 @@
 //! Pocket callback-to-playback PCM assembly.
 
 use super::{
-    apply_fade_out, clamp_to_full_scale, FADE_OUT_SAMPLES, SAMPLE_RATE, SENTENCE_LEAD_IN_SAMPLES,
+    apply_fade_out, clamp_to_full_scale, CHUNK_LEAD_IN_SAMPLES, FADE_OUT_SAMPLES, SAMPLE_RATE,
 };
 
 /// Number of samples retained until the next Pocket callback.
@@ -88,11 +88,11 @@ impl PocketStreamAssembler {
 
         let needs_lead_in = self.queued_samples == 0 || playback_idle;
         let mut buffer = Vec::with_capacity(
-            usize::from(needs_lead_in) * SENTENCE_LEAD_IN_SAMPLES
+            usize::from(needs_lead_in) * CHUNK_LEAD_IN_SAMPLES
                 + emit_end.saturating_sub(emit_start),
         );
         if needs_lead_in {
-            buffer.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES));
+            buffer.extend(std::iter::repeat_n(0.0_f32, CHUNK_LEAD_IN_SAMPLES));
         }
         buffer.extend(
             self.pending[emit_start..emit_end]
@@ -137,15 +137,15 @@ impl PocketStreamAssembler {
         apply_fade_out(&mut audio);
 
         if !audio.is_empty() {
-            let trailing_silence_len = silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES);
+            let trailing_silence_len = silence_buf_len.saturating_sub(CHUNK_LEAD_IN_SAMPLES);
             let needs_lead_in = self.queued_samples == 0 || playback_idle;
             let mut buffer = Vec::with_capacity(
-                usize::from(needs_lead_in) * SENTENCE_LEAD_IN_SAMPLES
+                usize::from(needs_lead_in) * CHUNK_LEAD_IN_SAMPLES
                     + audio.len()
                     + trailing_silence_len,
             );
             if needs_lead_in {
-                buffer.extend(std::iter::repeat_n(0.0_f32, SENTENCE_LEAD_IN_SAMPLES));
+                buffer.extend(std::iter::repeat_n(0.0_f32, CHUNK_LEAD_IN_SAMPLES));
             }
             buffer.extend(audio);
             buffer.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len));
diff --git a/desktop/src-tauri/src/huddle/tts_streaming_tests.rs b/desktop/src-tauri/src/huddle/tts_streaming_tests.rs
index 9f66bf7649..700ecccbdd 100644
--- a/desktop/src-tauri/src/huddle/tts_streaming_tests.rs
+++ b/desktop/src-tauri/src/huddle/tts_streaming_tests.rs
@@ -72,7 +72,7 @@ fn cumulative_multi_chunk_callbacks_reconstruct_final_pcm_once() {
     assert_eq!(assembler.callback_count, 2);
     assert_eq!(assembler.queued_samples, complete.len());
     let output = queued.concat();
-    let speech = &output[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + complete.len()];
+    let speech = &output[CHUNK_LEAD_IN_SAMPLES..CHUNK_LEAD_IN_SAMPLES + complete.len()];
     assert!(speech[..1000].iter().all(|sample| *sample == 0.25));
     assert!(speech[1000..1000 + (1000 - FADE_OUT_SAMPLES)]
         .iter()
@@ -97,7 +97,7 @@ fn pocket_streaming_queues_before_generation_finishes() {
         .expect("playback queue receives PCM before finish");
     assert_eq!(
         queued.len(),
-        SENTENCE_LEAD_IN_SAMPLES + 1000 - STREAM_TAIL_SAMPLES
+        CHUNK_LEAD_IN_SAMPLES + 1000 - STREAM_TAIL_SAMPLES
     );
 }
 
@@ -114,10 +114,10 @@ fn pocket_streaming_preserves_chunk_lead_in_while_playback_is_active() {
         .expect("stream first callback behind active playback");
 
     assert_eq!(queued.len(), 1);
-    assert!(queued[0][..SENTENCE_LEAD_IN_SAMPLES]
+    assert!(queued[0][..CHUNK_LEAD_IN_SAMPLES]
         .iter()
         .all(|sample| *sample == 0.0));
-    assert_eq!(queued[0][SENTENCE_LEAD_IN_SAMPLES], 0.25);
+    assert_eq!(queued[0][CHUNK_LEAD_IN_SAMPLES], 0.25);
 }
 
 #[test]
@@ -142,7 +142,7 @@ fn pocket_streaming_preserves_quiet_speech_after_leading_silence() {
         .expect("finish stream");
 
     let output = queued.concat();
-    let speech = &output[SENTENCE_LEAD_IN_SAMPLES..];
+    let speech = &output[CHUNK_LEAD_IN_SAMPLES..];
     let quiet_start = speech
         .iter()
         .position(|sample| *sample == 0.005)
@@ -216,8 +216,8 @@ fn pocket_streaming_rearms_lead_in_after_playback_underrun() {
         .expect("queue decoder block after simulated drain");
 
     assert_eq!(queued.len(), 2);
-    assert!(queued[1][..SENTENCE_LEAD_IN_SAMPLES]
+    assert!(queued[1][..CHUNK_LEAD_IN_SAMPLES]
         .iter()
         .all(|sample| *sample == 0.0));
-    assert_eq!(queued[1][SENTENCE_LEAD_IN_SAMPLES], 0.25);
+    assert_eq!(queued[1][CHUNK_LEAD_IN_SAMPLES], 0.25);
 }
diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs
index e77a372910..3245d02a71 100644
--- a/desktop/src-tauri/src/huddle/tts_tests.rs
+++ b/desktop/src-tauri/src/huddle/tts_tests.rs
@@ -869,79 +869,3 @@ fn clamp_to_full_scale_empty_buffer() {
     let out = clamp_to_full_scale(Vec::new());
     assert!(out.is_empty());
 }
-
-// ── group_sentences_into_chunks tests ─────────────────────────────────────
-
-fn s(v: &[&str]) -> Vec<String> {
-    v.iter().map(|x| x.to_string()).collect()
-}
-
-/// The first sentence always stands alone — it bounds time-to-first-audio.
-/// Even when the whole message would fit in one chunk, sentence one must
-/// not wait on synthesis of the rest.
-#[test]
-fn chunk_grouping_first_sentence_is_always_alone() {
-    let chunks = group_sentences_into_chunks(&s(&["Hi there.", "Short.", "Tiny."]), 200);
-    assert_eq!(chunks[0], "Hi there.");
-    assert_eq!(chunks.len(), 2);
-    assert_eq!(chunks[1], "Short. Tiny.");
-}
-
-/// Sentences after the first pack greedily up to the char budget, then
-/// spill into a new chunk. Fewer generate() calls = fewer prosody seams.
-#[test]
-fn chunk_grouping_packs_up_to_budget_then_spills() {
-    let a = "A".repeat(50) + ".";
-    let b = "B".repeat(50) + ".";
-    let c = "C".repeat(50) + ".";
-    let d = "D".repeat(50) + ".";
-    // Budget of 110: b+c fits (51+1+51 = 103), adding d (103+1+51) does not.
-    let chunks = group_sentences_into_chunks(&s(&[&a, &b, &c, &d]), 110);
-    assert_eq!(chunks.len(), 3, "chunks: {chunks:?}");
-    assert_eq!(chunks[0], a);
-    assert_eq!(chunks[1], format!("{b} {c}"));
-    assert_eq!(chunks[2], d);
-}
-
-/// A single sentence longer than the coarse budget is passed through here;
-/// the loaded April engine subsequently enforces its exact 50-token limit.
-#[test]
-fn chunk_grouping_oversized_sentence_passes_through() {
-    let long = "word ".repeat(60).trim_end().to_string() + ".";
-    assert!(long.len() > 200);
-    let chunks = group_sentences_into_chunks(&s(&["First.", &long]), 200);
-    assert_eq!(chunks, vec!["First.".to_string(), long]);
-}
-
-/// Single-sentence messages — the common huddle case, since agents are
-/// prompted to send one sentence per message — are unaffected by grouping.
-#[test]
-fn chunk_grouping_single_sentence_unchanged() {
-    let chunks = group_sentences_into_chunks(&s(&["Just one sentence here."]), 200);
-    assert_eq!(chunks, vec!["Just one sentence here.".to_string()]);
-}
-
-/// Empty and whitespace-only entries are dropped, and never produce
-/// empty chunks (which would synthesize as garbage).
-#[test]
-fn chunk_grouping_skips_blank_sentences() {
-    let chunks = group_sentences_into_chunks(&s(&["", "  ", "Real sentence.", "   ", "Two."]), 200);
-    assert_eq!(chunks[0], "Real sentence.");
-    assert_eq!(chunks.len(), 2);
-    assert_eq!(chunks[1], "Two.");
-}
-
-/// Empty input produces no chunks (the worker loop then synthesizes nothing).
-#[test]
-fn chunk_grouping_empty_input() {
-    assert!(group_sentences_into_chunks(&[], 200).is_empty());
-}
-
-/// Chunks joined with a single space preserve each sentence's terminal
-/// punctuation — the model sees natural multi-sentence prose, matching the
-/// shape upstream's ~50-token chunker produces.
-#[test]
-fn chunk_grouping_preserves_punctuation_at_joins() {
-    let chunks = group_sentences_into_chunks(&s(&["Lead.", "Really?", "Yes!", "Good."]), 200);
-    assert_eq!(chunks[1], "Really? Yes! Good.");
-}
diff --git a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
index 8fac2b1d5c..e5e90ba253 100644
--- a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
+++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs
@@ -2,6 +2,6 @@ use super::*;
 
 /// The onset cushion covers 20 ms at the production sample rate.
 #[test]
-fn sentence_lead_in_is_sane() {
-    assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz");
+fn chunk_lead_in_is_sane() {
+    assert_eq!(CHUNK_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz");
 }

From 15667debbc82ef6a36afcc7b9f10c9b1604b8960 Mon Sep 17 00:00:00 2001
From: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm
 <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz>
Date: Thu, 30 Jul 2026 20:43:27 -0400
Subject: [PATCH 2/2] perf(voice): stop token counting at the first oversized
 boundary

Each boundary scan in split_at_natural_boundaries tokenized every
word and clause boundary through end-of-text, skipping candidates
that already exceeded the limit instead of stopping at them. Because
token_count re-encodes the prefix text[start..end], tokenizer input
grew superlinearly in prompt length, and that work is paid before the
first chunk reaches synthesis -- taxing the time-to-first-audio this
segmentation is meant to improve. A 1.9 KB prompt at the 2000-char
MAX_TTS_TEXT_LEN cap tokenized 2.36 MB, and doubling a prompt
multiplied tokenizer input by ~5.5x.

Prepared token counts are monotonic in prefix length, so the first
candidate that overflows proves no longer candidate can fit. Break
out of the scan there.

Chunk boundaries are unchanged: verified output-identical against the
previous behavior across 5040 cases (21 corpora x max_tokens 1..=60 x
both split policies x two tokenizer shapes), while saving 1,073,280
tokenizer calls and 568 MB of tokenized input.

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
---
 crates/buzz-voice/src/pocket_april.rs | 43 ++++++++++++++++++++++++++-
 1 file changed, 42 insertions(+), 1 deletion(-)

diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs
index 11344072df..37ef88e085 100644
--- a/crates/buzz-voice/src/pocket_april.rs
+++ b/crates/buzz-voice/src/pocket_april.rs
@@ -672,9 +672,17 @@ where
                     .chars()
                     .next()
                     .is_some_and(is_closing_punctuation);
-            if (!at_word_end && !at_clause_end) || token_count(&text[start..end])? > max_tokens {
+            if !at_word_end && !at_clause_end {
                 continue;
             }
+            // Prepared token counts are monotonic in prefix length, so once a
+            // candidate overflows the limit no longer candidate can fit. Stop
+            // scanning instead of tokenizing every remaining boundary: that
+            // kept this loop superlinear in prompt length, and the cost landed
+            // before the first chunk reached synthesis.
+            if token_count(&text[start..end])? > max_tokens {
+                break;
+            }
 
             word_end = Some(end);
             match natural_boundary(&text[start..end], end == text.len()) {
@@ -1078,6 +1086,39 @@ mod tests {
         assert_eq!(chunks.concat(), text);
     }
 
+    #[test]
+    fn natural_split_stops_counting_tokens_past_the_limit() {
+        // Each boundary scan must stop at the first overflowing candidate
+        // rather than tokenizing every remaining boundary. Scanning to
+        // end-of-text makes tokenizer input grow superlinearly in prompt
+        // length, and that cost is paid before the first chunk reaches
+        // synthesis, taxing time-to-first-audio on long prompts.
+        let sentence = "The relay finished its migration and the channel list refreshed. ";
+        let tokenized_bytes = |repeats: usize| -> usize {
+            let text = sentence.repeat(repeats).trim_end().to_string();
+            let total = std::cell::Cell::new(0_usize);
+            let chunks = split_at_natural_boundaries(&text, 50, true, |chunk| {
+                total.set(total.get() + chunk.len());
+                whitespace_token_count(chunk)
+            })
+            .expect("split repeated sentences");
+            assert_eq!(chunks.concat(), text);
+            assert!(chunks.len() > 1);
+            total.get()
+        };
+
+        // Doubling the prompt must not multiply tokenizer work superlinearly.
+        // Bounded scans grow ~2x here; scanning to end-of-text grows ~5.5x.
+        let single = tokenized_bytes(12);
+        let double = tokenized_bytes(24);
+        assert!(
+            double < single * 3,
+            "doubling the prompt grew tokenizer input from {single} to {double} bytes \
+             ({:.1}x); bounded scans stay near 2x",
+            double as f64 / single as f64,
+        );
+    }
+
     #[test]
     fn normal_noise_has_requested_length() {
         let mut rng = rand::rng();
