From 1f4167b6f3be6a0063b4e8a7b861570d03c2af5c Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 11:22:10 -0400 Subject: [PATCH 1/9] feat(voice): pack natural TTS sentences without fixed silence Prioritize a model-safe first sentence, pack the remainder at natural tokenizer-aware boundaries, and queue generated PCM contiguously while subsequent units synthesize during playback. Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 11 +- crates/buzz-voice/src/pocket_april.rs | 372 +++++++++++++++--- desktop/src-tauri/src/huddle/preprocessing.rs | 124 ------ desktop/src-tauri/src/huddle/tts.rs | 73 ++-- desktop/src-tauri/src/huddle/tts_audio.rs | 115 ++---- desktop/src-tauri/src/huddle/tts_streaming.rs | 16 +- desktop/src-tauri/src/huddle/tts_tests.rs | 170 +------- .../src/huddle/tts_tests/token_split.rs | 13 +- 8 files changed, 390 insertions(+), 504 deletions(-) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 090ab74422a..8232f95368b 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -100,6 +100,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()); @@ -107,7 +116,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 157be700256..690e59e2336 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, @@ -367,62 +374,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( @@ -990,6 +962,158 @@ 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 { + 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()) { + 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())); @@ -1213,6 +1337,122 @@ 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 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(); @@ -1490,8 +1730,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] @@ -1505,16 +1747,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/src/huddle/preprocessing.rs b/desktop/src-tauri/src/huddle/preprocessing.rs index ce85e3145e3..8eeddc2bea0 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 da9b44dbde4..72a52e72bd6 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -7,9 +7,9 @@ //! → 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 individually → f32 PCM -//! 4. Clamp to full scale + fade out each sentence +//! 2. Split into tokenizer-safe natural units, prioritizing sentence one +//! 3. Synthesize each unit → f32 PCM +//! 4. Clamp to full scale + fade out each unit //! 5. Append each buffer to the persistent rodio Player (gapless) //! 6. While audio is draining, keep pulling queued text items and //! synthesizing ahead — playback of item N overlaps synthesis of @@ -50,7 +50,7 @@ use std::{ use super::pocket::{ load_text_to_speech, load_voice_style, DEFAULT_VOICE, SAMPLE_RATE, VOICE_FILE_EXT, }; -use super::preprocessing::{preprocess_for_tts, split_sentences}; +use super::preprocessing::preprocess_for_tts; #[path = "tts_voice_transition.rs"] mod voice_transition; @@ -102,38 +102,11 @@ 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. -/// -/// 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. +/// Length of the zero-sample cushion prepended when playback is idle, so the +/// OS audio device / rodio mixer has a fully-quiet ramp-up window before the +/// real onset hits. Continuously queued chunks receive no synthetic padding. 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; - -/// 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; - type WorkerControlState = ( Arc, Arc, @@ -453,7 +426,6 @@ 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; // EXPERIMENTAL (latency bench): `Some(emit_frames)` = stream PCM deltas // out of Pocket as they are generated (see tts_streaming.rs). let tts_streaming = streaming_emit_frames(); @@ -711,17 +683,20 @@ 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); + // Let Pocket's tokenizer-aware splitter isolate the first sentence for + // minimum time-to-first-audio, then pack later sentences into the + // largest natural units within the model's exact 50-token limit. Once + // each unit is appended, generation of the next proceeds while rodio + // plays the already-queued audio. + let chunks = match engine.split_text_into_chunks(&text) { + Ok(chunks) => chunks, + Err(_) => { + eprintln!( + "buzz-desktop: tts stage=synthesis status=failed reason=chunking route_id={route_id}" + ); + continue; + } + }; if chunks.is_empty() { eprintln!( "buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}" @@ -765,7 +740,6 @@ fn tts_worker( StreamingPlayback { player: &player, first_append: &mut first_append, - silence_buf_len, route_id, }, &mut |prepared| { @@ -853,7 +827,6 @@ fn tts_worker( samples, chunk_index, &mut first_append, - silence_buf_len, player.empty(), ) { if !append_audio( @@ -884,9 +857,7 @@ fn tts_worker( } } } - if let Some(prepared) = - playback_audio.finish(&mut first_append, silence_buf_len, player.empty()) - { + if let Some(prepared) = playback_audio.finish(&mut first_append, player.empty()) { if !append_audio( prepared, route_id, diff --git a/desktop/src-tauri/src/huddle/tts_audio.rs b/desktop/src-tauri/src/huddle/tts_audio.rs index 58300b7497e..80bf0c4661c 100644 --- a/desktop/src-tauri/src/huddle/tts_audio.rs +++ b/desktop/src-tauri/src/huddle/tts_audio.rs @@ -10,15 +10,11 @@ pub(super) struct PreparedModelAudio { /// on the first and last unit that actually produced audio. pub(super) struct PlaybackChunkAudio { pending: Option<(Vec, usize)>, - appended: bool, } impl PlaybackChunkAudio { pub(super) fn new() -> Self { - Self { - pending: None, - appended: false, - } + Self { pending: None } } pub(super) fn push( @@ -26,36 +22,26 @@ impl PlaybackChunkAudio { samples: Vec, chunk_index: usize, first_append: &mut bool, - silence_buf_len: usize, playback_idle: bool, ) -> Option { if samples.is_empty() { return None; } let previous = self.pending.replace((samples, chunk_index))?; - let prepared = prepare_model_audio( - previous, - first_append, - silence_buf_len, - !self.appended || playback_idle, - false, - ); - self.appended = true; + let prepared = prepare_model_audio(previous, first_append, playback_idle, false); Some(prepared) } pub(super) fn finish( &mut self, first_append: &mut bool, - silence_buf_len: usize, playback_idle: bool, ) -> Option { let pending = self.pending.take()?; Some(prepare_model_audio( pending, first_append, - silence_buf_len, - !self.appended || playback_idle, + playback_idle, true, )) } @@ -64,7 +50,6 @@ impl PlaybackChunkAudio { fn prepare_model_audio( (samples, chunk_index): (Vec, usize), first_append: &mut bool, - silence_buf_len: usize, starts_playback_chunk: bool, ends_playback_chunk: bool, ) -> PreparedModelAudio { @@ -74,13 +59,7 @@ fn prepare_model_audio( apply_fade_out(&mut audio); } PreparedModelAudio { - buffer: build_sentence_append_buffer( - first_append, - audio, - silence_buf_len, - starts_playback_chunk, - ends_playback_chunk, - ), + buffer: build_sentence_append_buffer(first_append, audio, starts_playback_chunk), sample_count, chunk_index, } @@ -103,9 +82,7 @@ pub(super) fn apply_fade_out(samples: &mut [f32]) { pub(super) fn build_sentence_append_buffer( first_append: &mut bool, audio: Vec, - silence_buf_len: usize, starts_playback_chunk: bool, - ends_playback_chunk: bool, ) -> Vec { if *first_append { *first_append = false; @@ -116,117 +93,73 @@ pub(super) fn build_sentence_append_buffer( } else { 0 }; - let trailing_silence_len = if ends_playback_chunk { - silence_buf_len.saturating_sub(SENTENCE_LEAD_IN_SAMPLES) - } else { - 0 - }; - let mut buffer = Vec::with_capacity(lead_in_len + audio.len() + trailing_silence_len); + let mut buffer = Vec::with_capacity(lead_in_len + audio.len()); buffer.extend(std::iter::repeat_n(0.0_f32, lead_in_len)); buffer.extend(audio); - buffer.extend(std::iter::repeat_n(0.0_f32, trailing_silence_len)); buffer } -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 multi_unit_audio_decorates_only_outer_playback_boundaries() { + fn model_units_are_queued_contiguously_without_injected_silence() { let mut chunk = PlaybackChunkAudio::new(); let mut first_append = true; - let silence = SENTENCE_LEAD_IN_SAMPLES + 100; assert!(chunk - .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .push(vec![0.4; 16], 0, &mut first_append, false) .is_none()); let first = chunk - .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .push(vec![0.5; 16], 1, &mut first_append, false) .expect("first ready model unit"); - assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); - assert!(first.buffer[..SENTENCE_LEAD_IN_SAMPLES] - .iter() - .all(|sample| *sample == 0.0)); - assert_eq!(first.buffer[SENTENCE_LEAD_IN_SAMPLES], 0.4); + assert_eq!(first.buffer, vec![0.4; 16]); let last = chunk - .finish(&mut first_append, silence, false) + .finish(&mut first_append, false) .expect("last ready model unit"); - assert_eq!(last.buffer.len(), 16 + 100); - assert_eq!(last.buffer.last(), Some(&0.0)); + assert_eq!(last.buffer.len(), 16); + assert_eq!(last.sample_count, 16); } #[test] - fn empty_edge_units_do_not_steal_lead_in_or_trailing_boundary() { + fn empty_edge_units_do_not_steal_audio_boundaries() { let mut chunk = PlaybackChunkAudio::new(); let mut first_append = true; - let silence = SENTENCE_LEAD_IN_SAMPLES + 100; assert!(chunk - .push(Vec::new(), 0, &mut first_append, silence, false) + .push(Vec::new(), 0, &mut first_append, false) .is_none()); assert!(chunk - .push(vec![0.5; 16], 1, &mut first_append, silence, false) + .push(vec![0.5; 16], 1, &mut first_append, false) .is_none()); assert!(chunk - .push(Vec::new(), 2, &mut first_append, silence, false) + .push(Vec::new(), 2, &mut first_append, false) .is_none()); let only = chunk - .finish(&mut first_append, silence, false) + .finish(&mut first_append, false) .expect("only audible model unit"); - assert_eq!(only.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16 + 100); - assert!(only.buffer[..SENTENCE_LEAD_IN_SAMPLES] - .iter() - .all(|sample| *sample == 0.0)); - assert_eq!(only.buffer.last(), Some(&0.0)); + assert_eq!(only.buffer.len(), 16); } #[test] fn playback_underrun_rearms_the_onset_cushion() { let mut chunk = PlaybackChunkAudio::new(); let mut first_append = true; - let silence = SENTENCE_LEAD_IN_SAMPLES + 100; assert!(chunk - .push(vec![0.4; 16], 0, &mut first_append, silence, false) + .push(vec![0.4; 16], 0, &mut first_append, false) .is_none()); let first = chunk - .push(vec![0.5; 16], 1, &mut first_append, silence, false) - .expect("first model unit"); - assert_eq!(first.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); + .push(vec![0.5; 16], 1, &mut first_append, false) + .expect("first ready model unit"); + assert_eq!(first.buffer.len(), 16); let after_underrun = chunk - .push(vec![0.6; 16], 2, &mut first_append, silence, true) - .expect("model unit after underrun"); + .push(vec![0.6; 16], 2, &mut first_append, true) + .expect("second ready model unit"); assert_eq!(after_underrun.buffer.len(), SENTENCE_LEAD_IN_SAMPLES + 16); assert!(after_underrun.buffer[..SENTENCE_LEAD_IN_SAMPLES] .iter() diff --git a/desktop/src-tauri/src/huddle/tts_streaming.rs b/desktop/src-tauri/src/huddle/tts_streaming.rs index de2bfd835f9..2bb401c43f5 100644 --- a/desktop/src-tauri/src/huddle/tts_streaming.rs +++ b/desktop/src-tauri/src/huddle/tts_streaming.rs @@ -29,7 +29,6 @@ pub(super) fn streaming_emit_frames() -> Option { pub(super) struct StreamingPlayback<'a> { pub(super) player: &'a rodio::Player, pub(super) first_append: &'a mut bool, - pub(super) silence_buf_len: usize, pub(super) route_id: u64, } @@ -56,7 +55,6 @@ pub(super) fn synthesize_streaming( let StreamingPlayback { player, first_append, - silence_buf_len, route_id, } = playback; let mut playback_audio = PlaybackChunkAudio::new(); @@ -70,13 +68,9 @@ pub(super) fn synthesize_streaming( } let chunk_index = delta_index; delta_index += 1; - if let Some(prepared) = playback_audio.push( - samples, - chunk_index, - first_append, - silence_buf_len, - player.empty(), - ) { + if let Some(prepared) = + playback_audio.push(samples, chunk_index, first_append, player.empty()) + { if !append_audio(prepared) { return false; } @@ -85,9 +79,7 @@ pub(super) fn synthesize_streaming( }); match stream_result { Ok(true) => { - if let Some(prepared) = - playback_audio.finish(first_append, silence_buf_len, player.empty()) - { + if let Some(prepared) = playback_audio.finish(first_append, player.empty()) { if !append_audio(prepared) { *first_append = true; return Some("cancelled"); diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 1dee4de90cc..184e5803f6d 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -785,99 +785,37 @@ fn apply_fade_out_single_sample() { // ── build_sentence_append_buffer tests ─────────────────────────────────── -/// REGRESSION: every chunk needs an onset cushion; synthesized chunks -/// can start with speech energy within the first millisecond. -#[test] -fn lead_in_pad_is_present_for_every_sentence_chunk() { - const SENTENCE_AUDIO_LEN: usize = 1000; - const SILENCE_BUF_LEN: usize = 2400; // 100 ms at 24 kHz, like production - const N_SENTENCES: usize = 5; - - let mut first = true; - - for _ in 0..N_SENTENCES { - let buf = build_sentence_append_buffer( - &mut first, - vec![0.5_f32; SENTENCE_AUDIO_LEN], - SILENCE_BUF_LEN, - true, - true, - ); - - assert_eq!(buf.len(), SENTENCE_AUDIO_LEN + SILENCE_BUF_LEN); - assert!( - buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0), - "lead-in pad must be pure silence" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN] - .iter() - .all(|&s| s == 0.5), - "sentence audio must immediately follow the lead-in" - ); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES + SENTENCE_AUDIO_LEN..] - .iter() - .all(|&s| s == 0.0), - "trailing gap must be pure silence" - ); - } - - assert!(!first, "first_append flag must be cleared after first call"); -} - -/// `first_append` still flips on the first call for `tts_active` gating. +/// `first_append` still flips on the first append for `tts_active` gating. #[test] fn build_sentence_append_buffer_flips_first_append() { let mut first = true; - let _ = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + assert_eq!(buf, vec![0.5; 100]); assert!(!first, "first call must flip the flag"); - - // Subsequent call: still has a per-sentence lead-in, flag stays false. - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert!(!first); -} - -/// Leading silence is exactly the lead-in; no pre-audio gap is double-counted. -#[test] -fn first_sentence_leading_silence_is_exactly_lead_in() { - let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); - assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } -/// Tail silence plus the next lead-in preserves the 100 ms sentence gap. +/// Playback chunks are contiguous: Pocket's generated pause is not extended +/// with a fixed inter-sentence silence budget. #[test] -fn sentence_gap_budget_is_preserved() { +fn sentence_append_buffer_does_not_inject_silence() { let mut first = true; - let silence_buf_len = 2400; - let first_buf = - build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); - let second_buf = - build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, true); + let first_buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + let second_buf = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); - let first_tail = &first_buf[SENTENCE_LEAD_IN_SAMPLES + 100..]; - let second_lead = &second_buf[..SENTENCE_LEAD_IN_SAMPLES]; - assert_eq!(first_tail.len(), silence_buf_len - SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(second_lead.len(), SENTENCE_LEAD_IN_SAMPLES); - assert_eq!(first_tail.len() + second_lead.len(), silence_buf_len); + assert_eq!(first_buf, vec![0.5; 100]); + assert_eq!(second_buf, vec![0.25; 100]); } -/// Regression guard: one contiguous rodio source per synthesized sentence. +/// If generation falls behind playback, retain the onset cushion that protects +/// the first phoneme while the output path wakes back up. #[test] -fn sentence_append_buffer_is_one_contiguous_source() { +fn idle_playback_gets_an_onset_cushion() { let mut first = true; - let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], 2400, true, true); + let buf = build_sentence_append_buffer(&mut first, vec![0.5; 100], true); - assert_eq!(buf.len(), 2400 + 100); + assert_eq!(buf.len(), SENTENCE_LEAD_IN_SAMPLES + 100); assert!(buf[..SENTENCE_LEAD_IN_SAMPLES].iter().all(|&s| s == 0.0)); - assert!( - buf[SENTENCE_LEAD_IN_SAMPLES..SENTENCE_LEAD_IN_SAMPLES + 100] - .iter() - .all(|&s| s == 0.5) - ); + assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } // ── clamp_to_full_scale tests ───────────────────────────────────────────── @@ -907,79 +845,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 b9249c9afc4..404f8a8153f 100644 --- a/desktop/src-tauri/src/huddle/tts_tests/token_split.rs +++ b/desktop/src-tauri/src/huddle/tts_tests/token_split.rs @@ -2,7 +2,7 @@ use super::*; /// The onset cushion covers 20 ms at the production sample rate. #[test] -fn sentence_lead_in_is_sane() { +fn chunk_lead_in_is_sane() { assert_eq!(SENTENCE_LEAD_IN_SAMPLES, 480, "20 ms × 24 kHz"); } @@ -11,14 +11,11 @@ fn sentence_lead_in_is_sane() { #[test] fn token_split_units_do_not_add_sentence_boundary_padding() { let mut first = true; - let silence_buf_len = 2400; - let first_unit = - build_sentence_append_buffer(&mut first, vec![0.5; 100], silence_buf_len, true, false); - let last_unit = - build_sentence_append_buffer(&mut first, vec![0.25; 100], silence_buf_len, false, true); + let first_unit = build_sentence_append_buffer(&mut first, vec![0.5; 100], false); + let last_unit = build_sentence_append_buffer(&mut first, vec![0.25; 100], false); - assert_eq!(first_unit.len(), SENTENCE_LEAD_IN_SAMPLES + 100); + assert_eq!(first_unit.len(), 100); assert_eq!(first_unit.last(), Some(&0.5)); assert_eq!(last_unit.first(), Some(&0.25)); - assert_eq!(first_unit.len() + last_unit.len(), 200 + silence_buf_len); + assert_eq!(first_unit.len() + last_unit.len(), 200); } From 6ba487ecf69e9504505eb0449c3c929e606461d4 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 12:02:08 -0400 Subject: [PATCH 2/9] fix(voice): preserve packed TTS playback units Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 26 ++++++++----- crates/buzz-voice/src/pocket_april.rs | 56 +++++++++++++++++++++------ desktop/src-tauri/src/huddle/tts.rs | 2 +- 3 files changed, 63 insertions(+), 21 deletions(-) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 8232f95368b..44149c8f4ec 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -98,18 +98,26 @@ 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. + /// Split text into model-safe synthesis units that satisfy the bundle's + /// exact 50-token input limit, packing sentences whenever they fit. + pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + let Some(prepared) = prepare_april_prompt(text) else { + return Ok(Vec::new()); + }; + self.inner + .lock() + .map_err(|_| "Pocket TTS engine lock poisoned".to_string())? + .split_prompt(&prepared) + } + + /// Split text into ordered playback units, keeping the first sentence + /// separate so it reaches synthesis before the remainder is packed. /// - /// Chunks are contiguous substrings of the prepared model prompt and may + /// Units 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 + /// reconstructs that prompt exactly, and each unit's prepared token count /// is at most 50. - pub fn split_text_into_chunks(&self, text: &str) -> Result, String> { + pub fn split_text_for_playback(&self, text: &str) -> Result, String> { let Some(prepared) = prepare_april_prompt(text) else { return Ok(Vec::new()); }; diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index 690e59e2336..beb102c1ba5 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -377,22 +377,18 @@ impl AprilPocketTts { 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), - ) + split_model_at_natural_boundaries(&prepared.text, self.bundle.max_token_per_chunk, |text| { + self.prepared_token_count(text) + }) } pub(crate) fn split_playback_prompt( &self, prepared: &AprilPreparedPrompt, ) -> Result, String> { - split_at_natural_boundaries( + split_playback_at_natural_boundaries( &prepared.text, self.bundle.max_token_per_chunk, - true, |text| self.prepared_token_count(text), ) } @@ -962,6 +958,28 @@ impl AprilPocketTts { } } +fn split_model_at_natural_boundaries( + text: &str, + max_tokens: usize, + token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + split_at_natural_boundaries(text, max_tokens, false, token_count) +} + +fn split_playback_at_natural_boundaries( + text: &str, + max_tokens: usize, + token_count: F, +) -> Result, String> +where + F: FnMut(&str) -> Result, +{ + split_at_natural_boundaries(text, max_tokens, true, token_count) +} + fn split_at_natural_boundaries( text: &str, max_tokens: usize, @@ -1342,9 +1360,9 @@ mod tests { } #[test] - fn natural_split_keeps_first_sentence_separate_then_packs_the_remainder() { + fn playback_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(); + let chunks = split_playback_at_natural_boundaries(text, 4, whitespace_token_count).unwrap(); assert_eq!(chunks, ["One two. ", "Three four. Five six."]); assert_eq!(chunks.concat(), text); } @@ -1352,11 +1370,27 @@ mod tests { #[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(); + let chunks = split_model_at_natural_boundaries(text, 4, whitespace_token_count).unwrap(); assert_eq!(chunks, ["One two. Three four. ", "Five six."]); assert_eq!(chunks.concat(), text); } + #[test] + fn playback_then_model_split_does_not_isolate_later_sentences_again() { + let text = "Alpha one. Beta two. Gamma three."; + let playback = + split_playback_at_natural_boundaries(text, 50, whitespace_token_count).unwrap(); + assert_eq!(playback, ["Alpha one. ", "Beta two. Gamma three."]); + + let model: Vec<_> = playback + .iter() + .flat_map(|chunk| { + split_model_at_natural_boundaries(chunk.trim(), 50, whitespace_token_count).unwrap() + }) + .collect(); + assert_eq!(model, ["Alpha one.", "Beta two. Gamma three."]); + } + #[test] fn natural_split_prefers_preceding_sentence_boundary() { let text = "One two. Three four five six."; diff --git a/desktop/src-tauri/src/huddle/tts.rs b/desktop/src-tauri/src/huddle/tts.rs index 72a52e72bd6..aca2339a3c4 100644 --- a/desktop/src-tauri/src/huddle/tts.rs +++ b/desktop/src-tauri/src/huddle/tts.rs @@ -688,7 +688,7 @@ fn tts_worker( // largest natural units within the model's exact 50-token limit. Once // each unit is appended, generation of the next proceeds while rodio // plays the already-queued audio. - let chunks = match engine.split_text_into_chunks(&text) { + let chunks = match engine.split_text_for_playback(&text) { Ok(chunks) => chunks, Err(_) => { eprintln!( From d0b3ca71c2a80d11a85c4d09a462a8ad24dad002 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 12:30:28 -0400 Subject: [PATCH 3/9] test(voice): pin TTS splitter wiring Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 14 ++++++++++++++ desktop/src-tauri/src/huddle/tts_tests.rs | 13 +++++++++++++ 2 files changed, 27 insertions(+) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 44149c8f4ec..0193b65f338 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -205,6 +205,20 @@ mod tests { .any(|artifact| artifact.filename == "flow_lm_main.onnx")); } + #[test] + fn playback_api_delegates_to_first_sentence_splitter() { + let source = include_str!("pocket.rs"); + let (_, playback_api) = source + .split_once("pub fn split_text_for_playback") + .expect("playback API exists"); + let (playback_api, _) = playback_api + .split_once("pub fn synth_chunk") + .expect("playback API ends before synthesis API"); + + assert_eq!(playback_api.matches(".split_playback_prompt(").count(), 1); + assert_eq!(playback_api.matches(".split_prompt(").count(), 0); + } + #[test] #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] fn production_api_emits_non_silent_april_int8_pcm() { diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 184e5803f6d..5dad117ade1 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -818,6 +818,19 @@ fn idle_playback_gets_an_onset_cushion() { assert_eq!(buf[SENTENCE_LEAD_IN_SAMPLES], 0.5); } +#[test] +fn tts_worker_uses_distinct_playback_and_model_splitters() { + let source = include_str!("tts.rs"); + let playback_calls = source.matches("engine.split_text_for_playback(").count(); + let model_calls = source.matches("engine.split_text_into_chunks(").count(); + + assert_eq!( + (playback_calls, model_calls), + (1, 1), + "the worker must isolate sentence one only in the outer playback split" + ); +} + // ── clamp_to_full_scale tests ───────────────────────────────────────────── /// In-range speech audio passes through bit-exact — no gain is applied. From f5b14071ca345c4d03a4cbe8a9cfae2e579c402d Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 12:54:07 -0400 Subject: [PATCH 4/9] test(voice): pin TTS splitter order Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 25 +++++++++++++++++++++++ desktop/src-tauri/src/huddle/tts_tests.rs | 14 +++++++++++++ 2 files changed, 39 insertions(+) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 0193b65f338..73f26369876 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -219,6 +219,31 @@ mod tests { assert_eq!(playback_api.matches(".split_prompt(").count(), 0); } + /// Mirror of the playback guard: the model API must NOT isolate the first + /// sentence, or every already-packed playback unit gets sentence one peeled + /// off again — the exact double-split this PR removes. + #[test] + fn model_api_delegates_to_non_isolating_splitter() { + let source = include_str!("pocket.rs"); + let (_, model_api) = source + .split_once("pub fn split_text_into_chunks") + .expect("model API exists"); + let (model_api, _) = model_api + .split_once("pub fn split_text_for_playback") + .expect("model API ends before the playback API"); + + assert_eq!( + model_api.matches(".split_prompt(").count(), + 1, + "the model splitter must pack sentences, not isolate the first one" + ); + assert_eq!( + model_api.matches(".split_playback_prompt(").count(), + 0, + "isolating inside the model split reinstates the per-sentence seam" + ); + } + #[test] #[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"] fn production_api_emits_non_silent_april_int8_pcm() { diff --git a/desktop/src-tauri/src/huddle/tts_tests.rs b/desktop/src-tauri/src/huddle/tts_tests.rs index 5dad117ade1..50e4d17ced5 100644 --- a/desktop/src-tauri/src/huddle/tts_tests.rs +++ b/desktop/src-tauri/src/huddle/tts_tests.rs @@ -829,6 +829,20 @@ fn tts_worker_uses_distinct_playback_and_model_splitters() { (1, 1), "the worker must isolate sentence one only in the outer playback split" ); + + // Counts alone are order-blind: swapping the two call sites keeps them at + // (1, 1) while the outer split stops isolating sentence one, which delays + // first audio by a whole generation. Pin the ORDER too. + let playback_at = source + .find("engine.split_text_for_playback(") + .expect("outer playback split exists"); + let model_at = source + .find("engine.split_text_into_chunks(") + .expect("inner model split exists"); + assert!( + playback_at < model_at, + "the playback split must be the OUTER pass; swapping the two delays first audio" + ); } // ── clamp_to_full_scale tests ───────────────────────────────────────────── From 363204152c8471b3b861af33dbcf7c5e4e7d6225 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 13:24:32 -0400 Subject: [PATCH 5/9] test(voice): guard splitter polarity end to end Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 85 +++++++++++++++++---------- crates/buzz-voice/src/pocket_april.rs | 55 +++++++++++++++++ 2 files changed, 109 insertions(+), 31 deletions(-) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index 73f26369876..a4c9d55aa0d 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -205,42 +205,65 @@ mod tests { .any(|artifact| artifact.filename == "flow_lm_main.onnx")); } - #[test] - fn playback_api_delegates_to_first_sentence_splitter() { - let source = include_str!("pocket.rs"); - let (_, playback_api) = source - .split_once("pub fn split_text_for_playback") - .expect("playback API exists"); - let (playback_api, _) = playback_api - .split_once("pub fn synth_chunk") - .expect("playback API ends before synthesis API"); - - assert_eq!(playback_api.matches(".split_playback_prompt(").count(), 1); - assert_eq!(playback_api.matches(".split_prompt(").count(), 0); + /// Which splitter each production function delegates to, across the whole + /// file rather than one hand-picked window. + /// + /// A wrong delegation can reinstate either shipped defect in one token: + /// removing first-sentence priority from playback, or re-isolating sentence + /// one inside units that already fit. Asserting the whole map means a new + /// delegation must be declared here to compile green. + fn splitter_delegations(source: &str) -> Vec<(String, Vec)> { + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(production, _)| production); + let mut out = Vec::new(); + let mut rest = production; + while let Some((_, after)) = rest.split_once(" fn ") { + let (name, body) = after + .split_once('(') + .expect("a function signature has an argument list"); + let body = body.split(" fn ").next().expect("split yields one part"); + let mut calls = Vec::new(); + // Check the isolating spelling first: ".split_prompt(" is a + // substring of neither, but a naive contains() on the shorter name + // would also match the longer one. + for _ in 0..body.matches(".split_playback_prompt(").count() { + calls.push("split_playback_prompt".to_string()); + } + let plain = body.matches(".split_prompt(").count(); + for _ in 0..plain { + calls.push("split_prompt".to_string()); + } + if !calls.is_empty() { + out.push((name.trim().to_string(), calls)); + } + rest = after; + } + out } - /// Mirror of the playback guard: the model API must NOT isolate the first - /// sentence, or every already-packed playback unit gets sentence one peeled - /// off again — the exact double-split this PR removes. #[test] - fn model_api_delegates_to_non_isolating_splitter() { + fn every_production_splitter_delegation_is_declared() { let source = include_str!("pocket.rs"); - let (_, model_api) = source - .split_once("pub fn split_text_into_chunks") - .expect("model API exists"); - let (model_api, _) = model_api - .split_once("pub fn split_text_for_playback") - .expect("model API ends before the playback API"); - - assert_eq!( - model_api.matches(".split_prompt(").count(), - 1, - "the model splitter must pack sentences, not isolate the first one" - ); + let actual = splitter_delegations(source); + let expected: Vec<(String, Vec)> = vec![ + // Model units: pack sentences, never isolate. + ("split_text_into_chunks".into(), vec!["split_prompt".into()]), + // Playback units: isolate sentence one for time-to-first-audio. + ( + "split_text_for_playback".into(), + vec!["split_playback_prompt".into()], + ), + // Synthesis receives an already-packed unit: re-isolating here + // re-adds the per-sentence seam this PR removes. + ("synth_chunk".into(), vec!["split_prompt".into()]), + ("synth_chunk_streaming".into(), vec!["split_prompt".into()]), + ]; assert_eq!( - model_api.matches(".split_playback_prompt(").count(), - 0, - "isolating inside the model split reinstates the per-sentence seam" + actual, expected, + "a production function changed which splitter it calls (or a new \ + one appeared); isolating outside split_text_for_playback delays \ + first audio, packing inside it removes the guarantee" ); } diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index beb102c1ba5..cbeba069746 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -1355,6 +1355,61 @@ mod tests { assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000); } + /// The two engine splitters must keep OPPOSITE isolation polarity. + /// + /// The guards in `pocket.rs` pin which engine method each public API calls, + /// but they cannot see what the method itself does: pointing + /// `split_playback_prompt` at the model wrapper leaves every call site's + /// source text untouched while first-sentence isolation silently stops + /// happening, so the first playback unit becomes the whole utterance and + /// first audio waits on generating all of it. + #[test] + fn engine_splitters_keep_opposite_isolation_polarity() { + let source = include_str!("pocket_april.rs"); + let production = source + .split_once("\n#[cfg(test)]") + .map_or(source, |(production, _)| production); + + let (_, model) = production + .split_once("fn split_prompt") + .expect("model splitter exists"); + let (model, _) = model + .split_once("fn split_playback_prompt") + .expect("model splitter precedes the playback splitter"); + let (_, playback) = production + .split_once("fn split_playback_prompt") + .expect("playback splitter exists"); + let (playback, _) = playback + .split_once("\n pub(crate) fn ") + .expect("playback splitter is followed by another method"); + + assert_eq!( + ( + model.matches("split_model_at_natural_boundaries(").count(), + model + .matches("split_playback_at_natural_boundaries(") + .count(), + ), + (1, 0), + "split_prompt must pack sentences: isolating here peels sentence \ + one off every already-packed unit" + ); + assert_eq!( + ( + playback + .matches("split_playback_at_natural_boundaries(") + .count(), + playback + .matches("split_model_at_natural_boundaries(") + .count(), + ), + (1, 0), + "split_playback_prompt must isolate sentence one: packing here \ + makes the first playback unit the whole utterance and delays \ + first audio by the full generation" + ); + } + fn whitespace_token_count(text: &str) -> Result { Ok(text.split_whitespace().count()) } From 6ca42a56ee905013a094f2c0ef1b5e35408b9326 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 13:48:21 -0400 Subject: [PATCH 6/9] test(voice): guard unconditional playback splitting Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket_april.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index cbeba069746..ed3689b4a87 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -1408,6 +1408,20 @@ mod tests { makes the first playback unit the whole utterance and delays \ first audio by the full generation" ); + + // Calling the isolating splitter is necessary but not sufficient: a + // short circuit before the call can return the whole utterance as one + // unit while leaving the delegated splitter unchanged. Playback must + // delegate unconditionally so sentence one remains the first unit. + for control_flow in ["if ", "match ", "else", "return"] { + assert!( + !playback.contains(control_flow), + "split_playback_prompt must delegate unconditionally, found \ + `{control_flow}`: a branch before the split can return the \ + whole utterance as the first playback unit, delaying first \ + audio by the full generation" + ); + } } fn whitespace_token_count(text: &str) -> Result { From 4b2201855200d56ee7fe1e6bb16efa8852451cd7 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 14:10:24 -0400 Subject: [PATCH 7/9] test(voice): scope playback guard to method body Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket_april.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index ed3689b4a87..2da66858eb5 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -1379,9 +1379,20 @@ mod tests { let (_, playback) = production .split_once("fn split_playback_prompt") .expect("playback splitter exists"); + // End at this method's own closing brace, not at the next `fn`: the + // gap between them holds the NEXT method's doc comment, and prose + // there would otherwise be scanned as this method's control flow. let (playback, _) = playback - .split_once("\n pub(crate) fn ") - .expect("playback splitter is followed by another method"); + .split_once("\n }\n") + .expect("playback splitter has a closing brace"); + // Scan code only. A comment cannot branch, and rejecting one reports + // drift in a method that has not changed. + let playback: String = playback + .lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n"); + let playback = playback.as_str(); assert_eq!( ( From c6156891344bceb3b6f953c8bbdb40ee702d0067 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 14:30:40 -0400 Subject: [PATCH 8/9] test(voice): scope both splitter guards to method bodies Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket_april.rs | 41 +++++++++++++-------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/crates/buzz-voice/src/pocket_april.rs b/crates/buzz-voice/src/pocket_april.rs index 2da66858eb5..9ace5001daa 100644 --- a/crates/buzz-voice/src/pocket_april.rs +++ b/crates/buzz-voice/src/pocket_april.rs @@ -1370,28 +1370,25 @@ mod tests { .split_once("\n#[cfg(test)]") .map_or(source, |(production, _)| production); - let (_, model) = production - .split_once("fn split_prompt") - .expect("model splitter exists"); - let (model, _) = model - .split_once("fn split_playback_prompt") - .expect("model splitter precedes the playback splitter"); - let (_, playback) = production - .split_once("fn split_playback_prompt") - .expect("playback splitter exists"); - // End at this method's own closing brace, not at the next `fn`: the - // gap between them holds the NEXT method's doc comment, and prose - // there would otherwise be scanned as this method's control flow. - let (playback, _) = playback - .split_once("\n }\n") - .expect("playback splitter has a closing brace"); - // Scan code only. A comment cannot branch, and rejecting one reports - // drift in a method that has not changed. - let playback: String = playback - .lines() - .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) - .collect::>() - .join("\n"); + // A method's own code, and nothing else. Ending at the method's own + // closing brace keeps the NEXT method's doc comment out, and stripping + // `//` to end of line keeps prose out: neither can call a splitter, so + // scanning either reports drift in a method that has not changed. + let method_code = |name: &str| -> String { + let (_, body) = production + .split_once(name) + .unwrap_or_else(|| panic!("{name} exists")); + let (body, _) = body + .split_once("\n }\n") + .unwrap_or_else(|| panic!("{name} has a closing brace")); + body.lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n") + }; + let model = method_code("fn split_prompt"); + let model = model.as_str(); + let playback = method_code("fn split_playback_prompt"); let playback = playback.as_str(); assert_eq!( From e5dde781494a021a043fa9813d5cf4448bdf2fa5 Mon Sep 17 00:00:00 2001 From: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Date: Thu, 13 Aug 2026 14:58:26 -0400 Subject: [PATCH 9/9] test(voice): parse complete splitter bodies Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz> --- crates/buzz-voice/src/pocket.rs | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/crates/buzz-voice/src/pocket.rs b/crates/buzz-voice/src/pocket.rs index a4c9d55aa0d..e23bf2a516c 100644 --- a/crates/buzz-voice/src/pocket.rs +++ b/crates/buzz-voice/src/pocket.rs @@ -216,13 +216,37 @@ mod tests { let production = source .split_once("\n#[cfg(test)]") .map_or(source, |(production, _)| production); + // Scan code only. Prose cannot call a splitter, but it can contain + // ` fn `, which would end a body early and hide a call after it, and it + // can name a splitter, which would report a call the code never makes. + let production: String = production + .lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n"); let mut out = Vec::new(); - let mut rest = production; + let mut rest = production.as_str(); while let Some((_, after)) = rest.split_once(" fn ") { let (name, body) = after .split_once('(') .expect("a function signature has an argument list"); - let body = body.split(" fn ").next().expect("split yields one part"); + // End at this function's own closing brace, not at the next ` fn `: + // a body provably stops where its braces balance, so no later + // function's calls are attributed here and none of this one's are + // dropped. + let inner = body.split_once('{').map_or("", |(_, inner)| inner); + let mut depth = 1usize; + let body = inner + .char_indices() + .find(|&(_, ch)| { + depth = match ch { + '{' => depth + 1, + '}' => depth - 1, + _ => depth, + }; + depth == 0 + }) + .map_or(inner, |(end, _)| &inner[..end]); let mut calls = Vec::new(); // Check the isolating spelling first: ".split_prompt(" is a // substring of neither, but a naive contains() on the shorter name