mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
feat(voice): use natural Pocket TTS segmentation (#3588)
## Context Pocket TTS decoder streaming begins playback while each model-valid chunk is still being decoded. Segmentation should preserve a fast first-sentence boundary, enforce the model's exact 50-token safety limit, and use natural prosody boundaries for oversized sentences and the remainder. ## Summary Move Pocket TTS segmentation into the shared tokenizer-aware layer. A fitting first sentence stays separate for low time-to-first-audio. Oversized sentences use clause, word, then UTF-8 scalar fallback, while later sentences pack into the largest natural unit of at most 50 prepared model tokens. Returned chunks are contiguous and reconstruct the prepared prompt exactly. ## Changes - Replace the shared word-only splitter with distinct playback and model-safety policies using actual SentencePiece counts. - Keep a fitting first sentence as the initial playback unit, then pack later sentences into the largest natural model-valid units. - Split oversized sentences at clause, word, then UTF-8 scalar boundaries. - Preserve punctuation, whitespace, Unicode, text order, and exact reconstruction across chunks. - Remove the Desktop 200-character splitter while preserving its low-latency first-sentence behavior in the shared layer. - Route Desktop playback directly through the shared splitter without changing streaming, cancellation, queue, fade, or voice-switch behavior. - Remove the unused Desktop sentence splitter and its tests. ## Related issue None found. ## Testing Parent versus child medians after one warmup and three measured runs: | Response | Parent TTFA | Child TTFA | Delta | Parent RTF | Child RTF | | --- | ---: | ---: | ---: | ---: | ---: | | Short | 335.328 ms | 317.890 ms | -17.439 ms | 0.231865 | 0.220702 | | Medium | 472.118 ms | 383.934 ms | -88.184 ms | 0.214413 | 0.188397 | | Long | 922.557 ms | 682.583 ms | -239.974 ms | 0.186114 | 0.185482 | In this measured run, preserving the first-sentence boundary improved median TTFA for all three response lengths while the natural remainder policy kept median RTF lower for all three. Cancellation after callback 2 returned `Interrupted` in 0.0195 ms. Boundary cancellation prevented chunk 2 from starting. Every final callback matched returned PCM byte-for-byte, callback lengths were monotonic, splitting was deterministic, and text reconstruction was exact. ## Reviewer-reproducible examples Run model-independent boundary properties: ```sh cargo test --manifest-path crates/buzz-voice/Cargo.toml --lib natural_split cargo test --manifest-path crates/buzz-voice/Cargo.toml --lib oversized ``` Run the cached-model cases: ```sh BUZZ_POCKET_TEST_MODEL_DIR=/path/to/april-int8-bundle \ cargo test --manifest-path crates/buzz-voice/Cargo.toml --lib -- --ignored --nocapture ``` The 90-token Gary sentence reconstructs exactly as three natural chunks of 32, 42, and 15 prepared tokens. Automated transcription found complete expected endings in all six clips, with no clipping, late onset, or meaningful DC offset. Manual listening in the combined daily-driver build also completed successfully. ## Screenshots N/A, nonvisual audio behavior. --------- Signed-off-by: John Tennant <jtennant@squareup.com> Signed-off-by: Tyler Longwell <tlongwell@squareup.com> Co-authored-by: npub1jmc9dt2lyvzu3h0kxlwxt5zg4fxp9476awyxw6gwxn72g6cw7exqs64whm <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
This commit is contained in:
committed by
John Tennant
parent
b2385ee3bc
commit
efbc63daa2
@@ -100,6 +100,15 @@ pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
|
||||
impl PocketTts {
|
||||
/// Split text into synthesis units that satisfy the bundle's exact
|
||||
/// 50-token input limit.
|
||||
///
|
||||
/// The first sentence remains its own unit when it fits. Oversized
|
||||
/// sentences fall back to clause, word, and UTF-8 scalar boundaries, while
|
||||
/// later sentences pack into the largest natural unit that fits.
|
||||
///
|
||||
/// Chunks are contiguous substrings of the prepared model prompt and may
|
||||
/// retain boundary whitespace. Concatenating them with `chunks.concat()`
|
||||
/// reconstructs that prompt exactly, and each chunk's prepared token count
|
||||
/// is at most 50.
|
||||
pub fn split_text_into_chunks(&self, text: &str) -> Result<Vec<String>, String> {
|
||||
let Some(prepared) = prepare_april_prompt(text) else {
|
||||
return Ok(Vec::new());
|
||||
@@ -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.
|
||||
|
||||
@@ -36,6 +36,13 @@ const DECODER_CHUNK_FRAMES: usize = 12;
|
||||
const TOKENS_PER_SECOND_ESTIMATE: f32 = 3.0;
|
||||
const GENERATION_SECONDS_PADDING: f32 = 2.0;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum TextBoundary {
|
||||
Sentence,
|
||||
Clause,
|
||||
Word,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Bundle {
|
||||
schema_version: u32,
|
||||
@@ -251,62 +258,27 @@ impl AprilPocketTts {
|
||||
&self,
|
||||
prepared: &AprilPreparedPrompt,
|
||||
) -> Result<Vec<String>, String> {
|
||||
if self.token_count(&prepared.text)? <= self.bundle.max_token_per_chunk {
|
||||
if self.prepared_token_count(&prepared.text)? <= self.bundle.max_token_per_chunk {
|
||||
return Ok(vec![prepared.text.clone()]);
|
||||
}
|
||||
split_at_natural_boundaries(
|
||||
&prepared.text,
|
||||
self.bundle.max_token_per_chunk,
|
||||
false,
|
||||
|text| self.prepared_token_count(text),
|
||||
)
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut current = String::new();
|
||||
for word in prepared.text.split_whitespace() {
|
||||
let candidate = if current.is_empty() {
|
||||
word.to_string()
|
||||
} else {
|
||||
format!("{current} {word}")
|
||||
};
|
||||
if self.prepared_token_count(&candidate)? <= self.bundle.max_token_per_chunk {
|
||||
current = candidate;
|
||||
continue;
|
||||
}
|
||||
if !current.is_empty() {
|
||||
chunks.push(std::mem::take(&mut current));
|
||||
}
|
||||
|
||||
if self.prepared_token_count(word)? <= self.bundle.max_token_per_chunk {
|
||||
current = word.to_string();
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut fragment = String::new();
|
||||
for ch in word.chars() {
|
||||
let candidate = format!("{fragment}{ch}");
|
||||
if !fragment.is_empty()
|
||||
&& self.prepared_token_count(&candidate)? > self.bundle.max_token_per_chunk
|
||||
{
|
||||
chunks.push(std::mem::take(&mut fragment));
|
||||
}
|
||||
fragment.push(ch);
|
||||
}
|
||||
current = fragment;
|
||||
}
|
||||
if !current.is_empty() {
|
||||
chunks.push(current);
|
||||
}
|
||||
|
||||
chunks
|
||||
.into_iter()
|
||||
.map(|text| {
|
||||
let chunk = prepare_april_prompt(&text)
|
||||
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
|
||||
let token_count = self.token_count(&chunk.text)?;
|
||||
if token_count > self.bundle.max_token_per_chunk {
|
||||
return Err(format!(
|
||||
"Pocket TTS prompt chunk has {token_count} tokens; maximum is {}",
|
||||
self.bundle.max_token_per_chunk
|
||||
));
|
||||
}
|
||||
Ok(chunk.text)
|
||||
})
|
||||
.collect()
|
||||
pub(crate) fn split_playback_prompt(
|
||||
&self,
|
||||
prepared: &AprilPreparedPrompt,
|
||||
) -> Result<Vec<String>, String> {
|
||||
split_at_natural_boundaries(
|
||||
&prepared.text,
|
||||
self.bundle.max_token_per_chunk,
|
||||
true,
|
||||
|text| self.prepared_token_count(text),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn synth_chunk_streaming<F>(
|
||||
@@ -656,6 +628,158 @@ impl AprilPocketTts {
|
||||
}
|
||||
}
|
||||
|
||||
fn split_at_natural_boundaries<F>(
|
||||
text: &str,
|
||||
max_tokens: usize,
|
||||
isolate_first_sentence: bool,
|
||||
mut token_count: F,
|
||||
) -> Result<Vec<String>, String>
|
||||
where
|
||||
F: FnMut(&str) -> Result<usize, String>,
|
||||
{
|
||||
if text.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut chunks = Vec::new();
|
||||
let mut start = 0;
|
||||
while start < text.len() {
|
||||
while text[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(char::is_whitespace)
|
||||
{
|
||||
start += text[start..]
|
||||
.chars()
|
||||
.next()
|
||||
.expect("checked above")
|
||||
.len_utf8();
|
||||
}
|
||||
if start == text.len() {
|
||||
break;
|
||||
}
|
||||
|
||||
let mut first_sentence_end = None;
|
||||
let mut sentence_end = None;
|
||||
let mut clause_end = None;
|
||||
let mut word_end = None;
|
||||
for (offset, ch) in text[start..].char_indices() {
|
||||
let end = start + offset + ch.len_utf8();
|
||||
let at_word_end =
|
||||
end == text.len() || text[end..].chars().next().is_some_and(char::is_whitespace);
|
||||
let at_clause_end = matches!(ch, '—' | '–')
|
||||
&& !text[end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(is_closing_punctuation);
|
||||
if !at_word_end && !at_clause_end {
|
||||
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<Session, String> {
|
||||
if !path.is_file() {
|
||||
return Err(format!("missing Pocket TTS file: {}", path.display()));
|
||||
@@ -879,6 +1003,122 @@ mod tests {
|
||||
assert_eq!(shape_len(&[2, 1, 8, 1000, 64]).expect("shape"), 1_024_000);
|
||||
}
|
||||
|
||||
fn whitespace_token_count(text: &str) -> Result<usize, String> {
|
||||
Ok(text.split_whitespace().count())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_split_keeps_first_sentence_separate_then_packs_the_remainder() {
|
||||
let text = "One two. Three four. Five six.";
|
||||
let chunks = split_at_natural_boundaries(text, 4, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["One two. ", "Three four. Five six."]);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_split_packs_multiple_sentences_within_limit() {
|
||||
let text = "One two. Three four. Five six.";
|
||||
let chunks = split_at_natural_boundaries(text, 4, false, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["One two. Three four. ", "Five six."]);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_split_prefers_preceding_sentence_boundary() {
|
||||
let text = "One two. Three four five six.";
|
||||
let chunks = split_at_natural_boundaries(text, 5, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["One two. ", "Three four five six."]);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_sentence_uses_clause_then_word_fallback() {
|
||||
let clause_text = "One two three, four five six seven.";
|
||||
let clause_chunks =
|
||||
split_at_natural_boundaries(clause_text, 5, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(clause_chunks, ["One two three, ", "four five six seven."]);
|
||||
assert_eq!(clause_chunks.concat(), clause_text);
|
||||
|
||||
let word_text = "One two three four five six.";
|
||||
let word_chunks =
|
||||
split_at_natural_boundaries(word_text, 4, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(word_chunks, ["One two three four ", "five six."]);
|
||||
assert_eq!(word_chunks.concat(), word_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_split_preserves_unicode_punctuation_and_abbreviations() {
|
||||
let text = "“Café naïve?” Maybe—yes, definitely; 東京 speaks.";
|
||||
let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(
|
||||
chunks,
|
||||
["“Café naïve?” ", "Maybe—yes, definitely; ", "東京 speaks."]
|
||||
);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
|
||||
let abbreviation = "Dr. Smith waits. Then leaves.";
|
||||
let chunks =
|
||||
split_at_natural_boundaries(abbreviation, 3, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["Dr. Smith waits. ", "Then leaves."]);
|
||||
assert_eq!(chunks.concat(), abbreviation);
|
||||
|
||||
let unspaced_clause = "alpha beta—gamma delta";
|
||||
let chunks =
|
||||
split_at_natural_boundaries(unspaced_clause, 2, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["alpha beta—", "gamma delta"]);
|
||||
assert_eq!(chunks.concat(), unspaced_clause);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn natural_split_does_not_treat_numeric_punctuation_as_unspaced_clauses() {
|
||||
let text = "Meet at 12:30 with 1,000 guests onward.";
|
||||
let chunks = split_at_natural_boundaries(text, 3, true, whitespace_token_count).unwrap();
|
||||
assert_eq!(chunks, ["Meet at 12:30 ", "with 1,000 guests ", "onward."]);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_word_uses_utf8_scalar_boundary_without_loss() {
|
||||
let text = "éééé";
|
||||
let chunks =
|
||||
split_at_natural_boundaries(text, 3, true, |chunk| Ok(chunk.chars().count())).unwrap();
|
||||
assert_eq!(chunks, ["ééé", "é"]);
|
||||
assert_eq!(chunks.concat(), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn 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();
|
||||
@@ -928,8 +1168,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 +1185,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))
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
1
desktop/src-tauri/Cargo.lock
generated
1
desktop/src-tauri/Cargo.lock
generated
@@ -1081,7 +1081,6 @@ dependencies = [
|
||||
"opus",
|
||||
"plist",
|
||||
"png 0.18.1",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"rodio",
|
||||
"rubato",
|
||||
|
||||
@@ -126,7 +126,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"
|
||||
|
||||
@@ -12,87 +12,6 @@
|
||||
//! → numbers → words → "forty two"
|
||||
//! → collapse whitespace → clean string
|
||||
//! ```
|
||||
//!
|
||||
//! Also provides `split_sentences` — the single sentence-boundary splitter used
|
||||
//! by both the TTS batching pipeline and the Supertonic text chunker.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
// ── Sentence splitting ────────────────────────────────────────────────────────
|
||||
|
||||
/// Regex: a sentence-ending punctuation mark followed by whitespace.
|
||||
static RE_SENTENCE_BOUNDARY: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"([.!?])\s+").unwrap());
|
||||
|
||||
/// Common abbreviations that end with a period but are NOT sentence boundaries.
|
||||
const ABBREVIATIONS: &[&str] = &[
|
||||
"Dr.", "Mr.", "Mrs.", "Ms.", "Prof.", "Sr.", "Jr.", "St.", "Ave.", "Rd.", "Blvd.", "Dept.",
|
||||
"Inc.", "Ltd.", "Co.", "Corp.", "etc.", "vs.", "i.e.", "e.g.", "Ph.D.",
|
||||
];
|
||||
|
||||
/// Split text into sentence-sized chunks.
|
||||
///
|
||||
/// Combines regex-based boundary detection with:
|
||||
/// - Abbreviation awareness (`Dr.`, `Mr.`, etc. don't split)
|
||||
/// - Digit-before-period check (avoids splitting `1.` `2.` numbered lists)
|
||||
/// - `\n` and `—` treated as sentence breaks
|
||||
///
|
||||
/// Returns non-empty, trimmed strings.
|
||||
pub fn split_sentences(text: &str) -> Vec<String> {
|
||||
// First, split on newlines and em-dashes to get coarse segments.
|
||||
let coarse: Vec<&str> = text.split(['\n', '—']).collect();
|
||||
|
||||
let mut sentences = Vec::new();
|
||||
|
||||
for segment in coarse {
|
||||
let segment = segment.trim();
|
||||
if segment.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// Within each segment, split on sentence-ending punctuation.
|
||||
let matches: Vec<_> = RE_SENTENCE_BOUNDARY.find_iter(segment).collect();
|
||||
if matches.is_empty() {
|
||||
sentences.push(segment.to_string());
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut last_end = 0usize;
|
||||
for m in &matches {
|
||||
let before = &segment[last_end..m.start()];
|
||||
let punc_char = &segment[m.start()..m.start() + 1];
|
||||
|
||||
// Skip if this looks like an abbreviation.
|
||||
let combined = format!("{}{}", before.trim(), punc_char);
|
||||
let is_abbrev = ABBREVIATIONS.iter().any(|a| combined.ends_with(a));
|
||||
|
||||
// Skip if the character before the period is a digit (numbered list).
|
||||
let is_digit_period = punc_char == "."
|
||||
&& !before.is_empty()
|
||||
&& before.ends_with(|c: char| c.is_ascii_digit());
|
||||
|
||||
if !is_abbrev && !is_digit_period {
|
||||
let piece = segment[last_end..m.end()].trim();
|
||||
if !piece.is_empty() {
|
||||
sentences.push(piece.to_string());
|
||||
}
|
||||
last_end = m.end();
|
||||
}
|
||||
}
|
||||
|
||||
if last_end < segment.len() {
|
||||
let tail = segment[last_end..].trim();
|
||||
if !tail.is_empty() {
|
||||
sentences.push(tail.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if sentences.is_empty() {
|
||||
vec![text.to_string()]
|
||||
} else {
|
||||
sentences
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -602,49 +521,6 @@ mod tests {
|
||||
assert_eq!(out, "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_basic() {
|
||||
let result = split_sentences("Hello world. How are you? I'm fine!");
|
||||
assert_eq!(result, vec!["Hello world.", "How are you?", "I'm fine!"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_newline_break() {
|
||||
let result = split_sentences("First line.\nSecond line.");
|
||||
assert_eq!(result, vec!["First line.", "Second line."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_em_dash_break() {
|
||||
let result = split_sentences("Start here—then continue.");
|
||||
assert_eq!(result, vec!["Start here", "then continue."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_abbreviations() {
|
||||
let result = split_sentences("Dr. Smith went home. He was tired.");
|
||||
assert_eq!(result, vec!["Dr. Smith went home.", "He was tired."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_numbered_list() {
|
||||
let result = split_sentences("1. First item. 2. Second item.");
|
||||
// "1." and "2." should NOT cause a split (digit before period).
|
||||
assert_eq!(result, vec!["1. First item.", "2. Second item."]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_single() {
|
||||
let result = split_sentences("Just one sentence");
|
||||
assert_eq!(result, vec!["Just one sentence"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_sentences_empty() {
|
||||
let result = split_sentences("");
|
||||
assert_eq!(result, vec![""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filters_trivial_responses() {
|
||||
assert_eq!(preprocess_for_tts("."), "");
|
||||
|
||||
@@ -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;
|
||||
/// 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;
|
||||
|
||||
/// 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;
|
||||
/// Silence inserted between synthesis chunks by the TTS pipeline (seconds).
|
||||
/// Injected as a silent buffer between each synthesized chunk.
|
||||
const INTER_CHUNK_SILENCE: f32 = 0.1;
|
||||
|
||||
type WorkerControlState = (
|
||||
Arc<AtomicBool>,
|
||||
@@ -522,7 +501,7 @@ fn tts_worker(
|
||||
})
|
||||
};
|
||||
if let Err(ref e) = monitor {
|
||||
// Degraded but functional: barge-in still works between sentences
|
||||
// Degraded but functional: barge-in still works between chunks
|
||||
// via the worker's own checks, just not mid-synthesis.
|
||||
eprintln!("buzz-desktop: TTS barge-in monitor failed to spawn: {e}");
|
||||
}
|
||||
@@ -535,7 +514,7 @@ fn tts_worker(
|
||||
// `tts_active` lifecycle: set on the first append while idle, cleared
|
||||
// whenever the player has fully drained — either in the idle timeout
|
||||
// arm or on item receipt before synthesis begins.
|
||||
let silence_buf_len = (INTER_SENTENCE_SILENCE * SAMPLE_RATE as f32) as usize;
|
||||
let silence_buf_len = (INTER_CHUNK_SILENCE * SAMPLE_RATE as f32) as usize;
|
||||
// `first_append` = "no audio queued since the player last went idle".
|
||||
// Flipped after the first streamed append; the idle branch below uses it
|
||||
// to decide when to drop `tts_active` and arm a fresh lead-in cushion for
|
||||
@@ -661,17 +640,19 @@ fn tts_worker(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Split into sentences, then group into synthesis chunks: the first
|
||||
// sentence stays alone (fast time-to-first-audio), the rest pack
|
||||
// greedily up to MAX_CHUNK_CHARS. Playback of each model unit overlaps
|
||||
// synthesis of the next one. The Pocket engine applies its exact
|
||||
// 50-token split; keeping those units within one playback chunk avoids
|
||||
// adding fades and pauses at token-only boundaries.
|
||||
let sentences: Vec<String> = split_sentences(&text)
|
||||
.into_iter()
|
||||
.filter(|s| !s.trim().is_empty())
|
||||
.collect();
|
||||
let chunks = group_sentences_into_chunks(&sentences, MAX_CHUNK_CHARS);
|
||||
// The shared Pocket engine keeps a fitting first sentence separate for
|
||||
// low time-to-first-audio, then packs later text at natural boundaries
|
||||
// using the April tokenizer's exact 50-token limit. Decoder streaming
|
||||
// starts playback within each resulting unit.
|
||||
let chunks = match engine.split_text_into_chunks(&text) {
|
||||
Ok(chunks) => chunks,
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"buzz-desktop: tts stage=synthesis status=failed reason=split route_id={route_id} error={error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if chunks.is_empty() {
|
||||
eprintln!(
|
||||
"buzz-desktop: tts stage=synthesis status=empty reason=no_chunks route_id={route_id}"
|
||||
|
||||
@@ -13,44 +13,3 @@ pub(super) fn apply_fade_out(samples: &mut [f32]) {
|
||||
samples[len - 1 - i] *= i as f32 / fade as f32;
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn group_sentences_into_chunks(sentences: &[String], max_chars: usize) -> Vec<String> {
|
||||
let mut chunks: Vec<String> = Vec::new();
|
||||
for (index, sentence) in sentences.iter().enumerate() {
|
||||
let sentence = sentence.trim();
|
||||
if sentence.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if index == 0 || chunks.is_empty() {
|
||||
chunks.push(sentence.to_string());
|
||||
continue;
|
||||
}
|
||||
let can_merge = chunks.len() > 1
|
||||
&& chunks
|
||||
.last()
|
||||
.is_some_and(|chunk| chunk.len() + 1 + sentence.len() <= max_chars);
|
||||
if can_merge {
|
||||
if let Some(last) = chunks.last_mut() {
|
||||
last.push(' ');
|
||||
last.push_str(sentence);
|
||||
}
|
||||
} else {
|
||||
chunks.push(sentence.to_string());
|
||||
}
|
||||
}
|
||||
chunks
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn grouped_sentences_keep_existing_latency_policy() {
|
||||
let sentences = vec!["First.".to_string(), "Second.".to_string()];
|
||||
assert_eq!(
|
||||
group_sentences_into_chunks(&sentences, 200),
|
||||
["First.", "Second."]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -869,79 +869,3 @@ fn clamp_to_full_scale_empty_buffer() {
|
||||
let out = clamp_to_full_scale(Vec::new());
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
// ── group_sentences_into_chunks tests ─────────────────────────────────────
|
||||
|
||||
fn s(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|x| x.to_string()).collect()
|
||||
}
|
||||
|
||||
/// The first sentence always stands alone — it bounds time-to-first-audio.
|
||||
/// Even when the whole message would fit in one chunk, sentence one must
|
||||
/// not wait on synthesis of the rest.
|
||||
#[test]
|
||||
fn chunk_grouping_first_sentence_is_always_alone() {
|
||||
let chunks = group_sentences_into_chunks(&s(&["Hi there.", "Short.", "Tiny."]), 200);
|
||||
assert_eq!(chunks[0], "Hi there.");
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[1], "Short. Tiny.");
|
||||
}
|
||||
|
||||
/// Sentences after the first pack greedily up to the char budget, then
|
||||
/// spill into a new chunk. Fewer generate() calls = fewer prosody seams.
|
||||
#[test]
|
||||
fn chunk_grouping_packs_up_to_budget_then_spills() {
|
||||
let a = "A".repeat(50) + ".";
|
||||
let b = "B".repeat(50) + ".";
|
||||
let c = "C".repeat(50) + ".";
|
||||
let d = "D".repeat(50) + ".";
|
||||
// Budget of 110: b+c fits (51+1+51 = 103), adding d (103+1+51) does not.
|
||||
let chunks = group_sentences_into_chunks(&s(&[&a, &b, &c, &d]), 110);
|
||||
assert_eq!(chunks.len(), 3, "chunks: {chunks:?}");
|
||||
assert_eq!(chunks[0], a);
|
||||
assert_eq!(chunks[1], format!("{b} {c}"));
|
||||
assert_eq!(chunks[2], d);
|
||||
}
|
||||
|
||||
/// A single sentence longer than the coarse budget is passed through here;
|
||||
/// the loaded April engine subsequently enforces its exact 50-token limit.
|
||||
#[test]
|
||||
fn chunk_grouping_oversized_sentence_passes_through() {
|
||||
let long = "word ".repeat(60).trim_end().to_string() + ".";
|
||||
assert!(long.len() > 200);
|
||||
let chunks = group_sentences_into_chunks(&s(&["First.", &long]), 200);
|
||||
assert_eq!(chunks, vec!["First.".to_string(), long]);
|
||||
}
|
||||
|
||||
/// Single-sentence messages — the common huddle case, since agents are
|
||||
/// prompted to send one sentence per message — are unaffected by grouping.
|
||||
#[test]
|
||||
fn chunk_grouping_single_sentence_unchanged() {
|
||||
let chunks = group_sentences_into_chunks(&s(&["Just one sentence here."]), 200);
|
||||
assert_eq!(chunks, vec!["Just one sentence here.".to_string()]);
|
||||
}
|
||||
|
||||
/// Empty and whitespace-only entries are dropped, and never produce
|
||||
/// empty chunks (which would synthesize as garbage).
|
||||
#[test]
|
||||
fn chunk_grouping_skips_blank_sentences() {
|
||||
let chunks = group_sentences_into_chunks(&s(&["", " ", "Real sentence.", " ", "Two."]), 200);
|
||||
assert_eq!(chunks[0], "Real sentence.");
|
||||
assert_eq!(chunks.len(), 2);
|
||||
assert_eq!(chunks[1], "Two.");
|
||||
}
|
||||
|
||||
/// Empty input produces no chunks (the worker loop then synthesizes nothing).
|
||||
#[test]
|
||||
fn chunk_grouping_empty_input() {
|
||||
assert!(group_sentences_into_chunks(&[], 200).is_empty());
|
||||
}
|
||||
|
||||
/// Chunks joined with a single space preserve each sentence's terminal
|
||||
/// punctuation — the model sees natural multi-sentence prose, matching the
|
||||
/// shape upstream's ~50-token chunker produces.
|
||||
#[test]
|
||||
fn chunk_grouping_preserves_punctuation_at_joins() {
|
||||
let chunks = group_sentences_into_chunks(&s(&["Lead.", "Really?", "Yes!", "Good."]), 200);
|
||||
assert_eq!(chunks[1], "Really? Yes! Good.");
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user