From 4e6d2df988260bc1ebfe2a0be1db0bc81d67fa64 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Wed, 29 Jul 2026 13:54:25 -0400 Subject: [PATCH 1/2] Simplify Pocket TTS segmentation Signed-off-by: John Tennant --- 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 { 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, 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, 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, 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( @@ -656,6 +628,150 @@ impl AprilPocketTts { } } +fn split_at_natural_boundaries( + text: &str, + max_tokens: usize, + isolate_first_sentence: bool, + mut token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + 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 { 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 { + 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 = 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 { - // 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, @@ -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 = 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 { - let mut chunks: Vec = 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 { - 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 Signed-off-by: Tyler Longwell --- 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();