Files
buzz/desktop/src-tauri/src/huddle/preprocessing.rs
Tyler 068a83b097 feat(huddle): cut voice-turn time-to-first-audio from ~1.0 s to ~0.35 s (env-gated latency levers) (#5671)
## Overview

**Category:** feat (env-gated experiment + one exact always-on
optimization)
**Problem:** Speech-end -> first TTS audio through the desktop huddle
pipeline measures **924–1087 ms** on an M4 Max with a 0 ms LLM leg.
Voice turns feel sluggish no matter how fast the agent replies. Baseline
breakdown: ~300 ms hardcoded VAD silence flush + ~150–250 ms Parakeet
decode + ~380–550 ms TTS synthesis before the first player append.
**Outcome:** With all levers enabled, e2e time-to-first-audio measures
**347–384 ms** (307–357 ms on a longer utterance) on the same hardware,
harness, and production pipelines. Defaults preserve production behavior
everywhere except one deterministic, bit-exact cache win.

## What's in here

### Levers (all default-off, env-gated)

| Lever | Env | Effect (measured medians, short utterance) |
|---|---|---|
| Speculative Parakeet decode | `BUZZ_STT_SPECULATIVE=1` | STT leg ->
~max(flush, decode) |
| Streaming TTS synthesis | `BUZZ_TTS_STREAMING=1`,
`BUZZ_TTS_EMIT_FRAMES` | first audio 380–550 -> 211–320 ms (emit=12,
bit-exact) |
| ONNX intra-op threads | `BUZZ_STT_THREADS`, `BUZZ_TTS_THREADS` | TTS
first audio 211–320 -> 129–180 ms (4 threads) |

- **Speculative decode** starts the Parakeet decode at the *first*
silent VAD frame, overlapping it with the flush window. Resumed speech
invalidates the result (voiced-frame-count check); held silence emits it
instantly at the flush boundary.
- **Streaming TTS**: new `synth_chunk_streaming` (buzz-voice)
interleaves the Flow LM frame loop with incremental *stateful* Mimi
decoding, emitting PCM deltas to the player via the existing
`PlaybackChunkAudio` decoration. At `emit_frames=12` (the decoder's
native chunk) streamed audio is **bit-identical** to the batch path —
verified by the ignored test
`incremental_stateful_decode_matches_batch_decode` (max|diff|=0).
Smaller deltas are faster but diverge (~23 dB SNR; decoder intra-chunk
lookahead), hence the default of 12.

> **Removed after live testing:** the `BUZZ_STT_FLUSH_MS` flush-window
override. Lowering the silence window below natural mid-sentence pauses
(the fast-path recipe said 150 ms) split single spoken sentences into
multiple messages and confused the listening agents. The window is a
turn-taking quality knob, not a latency lever — it is now fixed at the
production 300 ms value.

### Push-to-talk grouping fix (always-on)

A held push-to-talk shortcut is an explicit "I am not done talking"
signal, so silence never ends the utterance while it is held — even when
the microphone is also manually open. The utterance flushes on shortcut
release (existing transmit-edge flush); a manually open mic with the
shortcut up keeps normal VAD pause flushing. Gate is the pure
`vad_flush_allowed` function with a unit-test truth table.

### Always-on (exact): voice-conditioning cache

Phase profiling (`BUZZ_TTS_PHASE_LOG=1`) showed a fixed ~160 ms
`condition_voice` Flow-LM pass on *every* chunk, re-deriving the same
post-conditioning state for the same reference voice. The state is now
snapshotted after first computation and restored per chunk (dtype-tagged
tensor copies, keyed identically to the existing `cached_voice`).
Deterministic — same tensors in, same tensors out. The default path's
TTS leg drops from 380–550 ms to 225–355 ms with no configuration.

### Bench harness

`huddle::latency_bench` (`#[cfg(test)]` + `#[ignore]`) drives the real
`SttPipeline` and `TtsPipeline`, feeding a 48 kHz WAV in real-time 100
ms batches (AudioWorklet cadence) with a configurable fake LLM in place
of the relay leg, timing speech-end -> transcript -> speak() -> first
accepted player append.

```
BUZZ_STT_SPECULATIVE=1 BUZZ_TTS_STREAMING=1 \
BUZZ_TTS_THREADS=4 BUZZ_STT_THREADS=2 \
BUZZ_BENCH_WAV=<48k f32 mono wav> \
cargo test --release -p buzz-desktop --lib huddle::latency_bench -- --ignored --nocapture
```

## Tradeoffs to weigh before promoting any lever to a default

- **Speculative decode**: the speculative buffer has ~1 silent tail
frame vs ~19; observed one CTC wobble ("fail" vs "failed") in 24 turns.
Mitigation if productionized: zero-pad the speculative buffer to match
the flush-path shape.
- **Threads**: defaults stay 1 pending the min-spec (4-core Intel) A/B
flagged in the existing `STT_NUM_THREADS` comment.
- **Streaming at emit<12** is NOT the same waveform — don't ship below
12 without an ear pass.

## Validation

- Full desktop lib suite: **2408 passed / 0 failed** at this head
(`18fab2e1c`).
- buzz-voice suite green; bit-exactness test passes against the
production batch decode.
- Defaults-only bench rerun stays in the baseline family everywhere
except the exact conditioning-cache win (stt 525–532, tts 225–355).
- `cargo clippy --workspace --all-targets -- -D warnings` + fmt clean
(pre-push hook battery green).

Measurement notes with per-lever logs: Eva's workspace,
`RESEARCH/HUDDLE_E2E_LATENCY_OPTIMIZATION_2026_08_12.md` +
`RESEARCH/HUDDLE_E2E_STT_FAKELLM_TTS_BASELINE_2026_08_12.md`.

## Suggested promotion order

1. Conditioning cache (in this PR, always-on, exact).
2. Streaming TTS at emit=12: bit-exact audio, biggest UX win — needs the
env-gate removed + barge-in soak + an ear pass on a real huddle.
3. Speculative decode with silence padding: near-free ~100–150 ms.
4. Threads: after min-spec A/B.

---------

Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Signed-off-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Signed-off-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Signed-off-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Signed-off-by: tlongwell-block <tlongwell@block.xyz>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
Co-authored-by: Wren <5217c5c2f7bfb4333e46d17c98a9255a52dadee18dcd43a43536b95e6776dfa0@buzz.block.builderlab.xyz>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@buzz.block.builderlab.xyz>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@buzz.block.builderlab.xyz>
Co-authored-by: tlongwell-block <tlongwell@block.xyz>
2026-08-13 19:42:54 -04:00

544 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Text preprocessing for TTS output.
//!
//! Mental model:
//!
//! ```text
//! raw agent text
//! → strip fenced code blocks → "code block omitted"
//! → strip inline code → bare text
//! → strip URLs → "link omitted"
//! → strip markdown markers → plain text
//! → strip emoji → (removed)
//! → numbers → words → "forty two"
//! → collapse whitespace → clean string
//! ```
// ── Public API ────────────────────────────────────────────────────────────────
/// Prepare `text` for TTS synthesis.
///
/// Applies in order:
/// 1. Fenced code blocks → "code block omitted"
/// 2. Inline code → bare content (backticks stripped)
/// 3. URLs → "link omitted"
/// 4. Markdown bold/italic/underline markers stripped
/// 5. Emoji stripped
/// 6. Numbers → words (integers 0–999, times HH:MM)
/// 7. Excess whitespace collapsed
pub fn preprocess_for_tts(text: &str) -> String {
let s = strip_fenced_code_blocks(text);
let s = strip_inline_code(&s);
let s = strip_urls(&s);
let s = strip_markdown_markers(&s);
let s = strip_emoji(&s);
let s = expand_numbers(&s);
let s = collapse_whitespace(&s);
// Filter trivially short results — ".", ",", etc. would be spoken as
// "period", "comma" by TTS. Agents that have nothing relevant to say
// should not respond at all, but defense-in-depth catches edge cases.
if s.len() <= 1 {
return String::new();
}
s
}
// ── Step implementations ──────────────────────────────────────────────────────
/// Replace fenced code blocks with "code block omitted".
///
/// Handles both ` ``` ` and `~~~` fences. Multi-line aware.
fn strip_fenced_code_blocks(text: &str) -> String {
let s = replace_fenced(text, "```");
replace_fenced(&s, "~~~")
}
fn replace_fenced(text: &str, fence: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
loop {
match rest.find(fence) {
None => {
out.push_str(rest);
break;
}
Some(start) => {
// Everything before the opening fence.
out.push_str(&rest[..start]);
rest = &rest[start + fence.len()..];
// Skip optional language tag on the same line.
if let Some(nl) = rest.find('\n') {
rest = &rest[nl + 1..];
}
// Find the closing fence.
match rest.find(fence) {
None => {
// Unclosed fence — treat rest as omitted.
out.push_str(" code block omitted ");
break;
}
Some(end) => {
out.push_str(" code block omitted ");
rest = &rest[end + fence.len()..];
// Skip trailing newline after closing fence.
if rest.starts_with('\n') {
rest = &rest[1..];
}
}
}
}
}
}
out
}
/// Strip backtick-delimited inline code, leaving the inner text.
///
/// Single-backtick only — triple backtick already handled above.
fn strip_inline_code(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
loop {
match rest.find('`') {
None => {
out.push_str(rest);
break;
}
Some(start) => {
out.push_str(&rest[..start]);
rest = &rest[start + 1..];
match rest.find('`') {
None => {
// Unclosed — emit as-is.
out.push_str(rest);
break;
}
Some(end) => {
out.push_str(&rest[..end]);
rest = &rest[end + 1..];
}
}
}
}
}
out
}
/// Replace http/https URLs with "link omitted".
///
/// Trailing sentence-ending punctuation (`.`, `!`, `?`) that immediately follows
/// a URL and is at end-of-string or followed by whitespace is preserved so that
/// sentence splitting and TTS prosody are not degraded.
///
/// Example: `"See https://x.y/z."` → `"See link omitted."`
fn strip_urls(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
loop {
// Find the earliest URL prefix.
let http = rest.find("http://");
let https = rest.find("https://");
let url_start = match (http, https) {
(None, None) => {
out.push_str(rest);
break;
}
(Some(a), None) => a,
(None, Some(b)) => b,
(Some(a), Some(b)) => a.min(b),
};
out.push_str(&rest[..url_start]);
rest = &rest[url_start..];
// Consume until whitespace or structural delimiter.
let url_end = rest
.find(|c: char| c.is_whitespace() || c == ')' || c == ']' || c == '"' || c == '\'')
.unwrap_or(rest.len());
let url_token = &rest[..url_end];
rest = &rest[url_end..];
// Check if the URL token ends with sentence-ending punctuation that
// belongs to the surrounding sentence rather than the URL itself.
// A trailing `.`, `!`, or `?` is preserved when it is at end-of-string
// or followed by whitespace (i.e. it is a sentence boundary).
let trailing_punct = if url_token.ends_with(['.', '!', '?']) {
let after = rest; // rest is already past url_end
if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) {
// Preserve the trailing punctuation.
&url_token[url_token.len() - 1..]
} else {
""
}
} else {
""
};
out.push_str("link omitted");
out.push_str(trailing_punct);
}
out
}
/// Strip `**`, `*`, `__`, `_emphasis_`, `~~` markdown markers.
///
/// Underscores are only stripped when they wrap a word (`_text_`).
/// Standalone underscores (e.g. `snake_case` identifiers) are preserved.
fn strip_markdown_markers(text: &str) -> String {
// Order matters: strip multi-char markers before single-char.
let s = text.replace("**", "");
let s = s.replace("__", "");
let s = s.replace("~~", "");
let s = s.replace('*', "");
strip_underscore_emphasis(&s)
}
/// Strip `_text_` emphasis markers while preserving underscores in identifiers.
///
/// A `_` is treated as an emphasis delimiter only when it is preceded by
/// whitespace or the start of the string AND followed by a non-whitespace char,
/// or vice-versa for the closing delimiter.
fn strip_underscore_emphasis(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let chars: Vec<char> = text.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '_' {
// Opening delimiter: preceded by whitespace/start, followed by non-whitespace.
let prev_is_boundary = i == 0 || chars[i - 1].is_whitespace();
let next_is_nonspace = i + 1 < len && !chars[i + 1].is_whitespace();
if prev_is_boundary && next_is_nonspace {
// Look for a matching closing `_`.
if let Some(close) = (i + 1..len).find(|&j| {
chars[j] == '_'
&& !chars[j - 1].is_whitespace()
&& (j + 1 >= len
|| chars[j + 1].is_whitespace()
|| chars[j + 1].is_ascii_punctuation())
}) {
// Emit the inner text without the delimiters.
for &ch in &chars[i + 1..close] {
out.push(ch);
}
i = close + 1;
continue;
}
}
// Not an emphasis delimiter — emit as-is.
out.push('_');
} else {
out.push(chars[i]);
}
i += 1;
}
out
}
/// Strip Unicode emoji (characters in common emoji ranges).
///
/// Covers the main Emoji block (U+1F300–U+1FAFF) and supplemental ranges.
/// ASCII emoticons like `:)` are left as-is.
fn strip_emoji(text: &str) -> String {
text.chars().filter(|&c| !is_emoji(c)).collect()
}
#[inline]
fn is_emoji(c: char) -> bool {
matches!(c,
'\u{1F300}'..='\u{1FAFF}' // Misc symbols, emoticons, transport, etc.
| '\u{2600}'..='\u{27BF}' // Misc symbols, dingbats
| '\u{FE00}'..='\u{FE0F}' // Variation selectors
| '\u{1F000}'..='\u{1F02F}'// Mahjong/domino tiles
| '\u{1F0A0}'..='\u{1F0FF}'// Playing cards
| '\u{200D}' // Zero-width joiner (used in emoji sequences)
| '\u{20E3}' // Combining enclosing keycap
)
}
/// Expand numbers to spoken words.
///
/// Handles:
/// - Times: `HH:MM` → "eleven thirty"
/// - Integers 0–999,999
/// - Leaves other numeric strings (e.g. "3.14", "1000000+") as-is.
fn expand_numbers(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut chars = text.char_indices().peekable();
while let Some((i, c)) = chars.next() {
if c.is_ascii_digit() {
// Collect the full token (digits, colon, dots).
let start = i;
let mut end = i + c.len_utf8();
while let Some(&(j, nc)) = chars.peek() {
if nc.is_ascii_digit() || nc == ':' || nc == '.' {
end = j + nc.len_utf8();
chars.next();
} else {
break;
}
}
let token = &text[start..end];
out.push_str(&expand_numeric_token(token));
} else {
out.push(c);
}
}
out
}
fn expand_numeric_token(token: &str) -> String {
// Strip trailing punctuation that the token collector may have included
// (e.g. "11:30." from "at 11:30.") before attempting to parse.
let token = token.trim_end_matches(|c: char| !c.is_ascii_digit());
// Time: HH:MM
if let Some(colon) = token.find(':') {
let h = &token[..colon];
let m = &token[colon + 1..];
if let (Ok(hh), Ok(mm)) = (h.parse::<u32>(), m.parse::<u32>()) {
if hh < 24 && mm < 60 {
let hour_word = int_to_words(hh);
let min_word = if mm == 0 {
String::new()
} else if mm < 10 {
// "9:05" → "nine oh five" (not "nine five")
format!(" oh {}", int_to_words(mm))
} else {
format!(" {}", int_to_words(mm))
};
return format!("{}{}", hour_word, min_word);
}
}
// Not a valid time — return as-is.
return token.to_string();
}
// Plain integer 0–999,999.
if token.chars().all(|c| c.is_ascii_digit()) {
if let Ok(n) = token.parse::<u32>() {
if n <= 999_999 {
return int_to_words(n);
}
}
}
// Anything else (decimals, millions+) — leave as-is.
token.to_string()
}
/// Convert an integer 0–999,999 to English words.
fn int_to_words(n: u32) -> String {
const ONES: &[&str] = &[
"zero",
"one",
"two",
"three",
"four",
"five",
"six",
"seven",
"eight",
"nine",
"ten",
"eleven",
"twelve",
"thirteen",
"fourteen",
"fifteen",
"sixteen",
"seventeen",
"eighteen",
"nineteen",
];
const TENS: &[&str] = &[
"", "", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety",
];
if n < 20 {
return ONES[n as usize].to_string();
}
if n < 100 {
let ten = TENS[(n / 10) as usize];
let one = n % 10;
return if one == 0 {
ten.to_string()
} else {
format!("{} {}", ten, ONES[one as usize])
};
}
if n < 1000 {
let hundreds = n / 100;
let remainder = n % 100;
let hundred_word = format!("{} hundred", ONES[hundreds as usize]);
return if remainder == 0 {
hundred_word
} else {
format!("{} {}", hundred_word, int_to_words(remainder))
};
}
// 1,000–999,999
let thousands = n / 1000;
let remainder = n % 1000;
let thousand_word = format!("{} thousand", int_to_words(thousands));
if remainder == 0 {
thousand_word
} else {
format!("{} {}", thousand_word, int_to_words(remainder))
}
}
/// Collapse runs of whitespace (spaces, tabs, newlines) to a single space.
/// Trims leading/trailing whitespace.
fn collapse_whitespace(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut prev_space = true; // Start true to trim leading whitespace.
for c in text.chars() {
if c.is_whitespace() {
if !prev_space {
out.push(' ');
prev_space = true;
}
} else {
out.push(c);
prev_space = false;
}
}
// Trim trailing space.
if out.ends_with(' ') {
out.pop();
}
out
}
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_fenced_code_block() {
let input = "Here is some code:\n```rust\nfn main() {}\n```\nDone.";
let out = preprocess_for_tts(input);
assert!(out.contains("code block omitted"), "got: {out}");
assert!(!out.contains("fn main"), "got: {out}");
}
#[test]
fn strips_inline_code() {
let out = preprocess_for_tts("Call `foo()` now.");
assert_eq!(out, "Call foo() now.");
}
#[test]
fn strips_urls() {
let out = preprocess_for_tts("See https://example.com for details.");
assert!(out.contains("link omitted"), "got: {out}");
assert!(!out.contains("example.com"), "got: {out}");
}
#[test]
fn strips_url_preserves_trailing_period() {
// Trailing `.` at end of sentence must be preserved for sentence splitting.
let out = strip_urls("See https://x.y/z.");
assert_eq!(out, "See link omitted.", "got: {out}");
}
#[test]
fn strips_url_preserves_trailing_exclamation() {
let out = strip_urls("Visit https://example.com!");
assert_eq!(out, "Visit link omitted!", "got: {out}");
}
#[test]
fn strips_url_preserves_trailing_question() {
let out = strip_urls("Did you see https://example.com?");
assert_eq!(out, "Did you see link omitted?", "got: {out}");
}
#[test]
fn strips_url_mid_sentence_no_punct_preserved() {
// URL in the middle of a sentence — no trailing punct to preserve.
let out = strip_urls("Check https://example.com for more info.");
assert_eq!(out, "Check link omitted for more info.", "got: {out}");
}
#[test]
fn strips_bold_italic() {
let out = preprocess_for_tts("**bold** and *italic* and _under_");
assert_eq!(out, "bold and italic and under");
}
#[test]
fn preserves_standalone_underscores() {
// snake_case identifiers should not be mangled.
let out = preprocess_for_tts("call foo_bar() or baz_qux");
assert!(out.contains("foo_bar"), "got: {out}");
assert!(out.contains("baz_qux"), "got: {out}");
}
#[test]
fn strips_tilde_fenced_block() {
let input = "Here:\n~~~python\nprint('hi')\n~~~\nDone.";
let out = preprocess_for_tts(input);
assert!(out.contains("code block omitted"), "got: {out}");
assert!(!out.contains("print"), "got: {out}");
}
#[test]
fn expands_integers() {
assert_eq!(preprocess_for_tts("42"), "forty two");
assert_eq!(preprocess_for_tts("0"), "zero");
assert_eq!(preprocess_for_tts("11"), "eleven");
assert_eq!(preprocess_for_tts("100"), "one hundred");
}
#[test]
fn expands_thousands() {
assert_eq!(preprocess_for_tts("1000"), "one thousand");
assert_eq!(
preprocess_for_tts("1234"),
"one thousand two hundred thirty four"
);
assert_eq!(preprocess_for_tts("10000"), "ten thousand");
assert_eq!(preprocess_for_tts("100000"), "one hundred thousand");
assert_eq!(
preprocess_for_tts("999999"),
"nine hundred ninety nine thousand nine hundred ninety nine"
);
}
#[test]
fn expands_times() {
assert_eq!(preprocess_for_tts("11:30"), "eleven thirty");
assert_eq!(preprocess_for_tts("9:00"), "nine");
assert_eq!(preprocess_for_tts("9:05"), "nine oh five");
assert_eq!(preprocess_for_tts("10:09"), "ten oh nine");
}
#[test]
fn collapses_whitespace() {
let out = preprocess_for_tts(" hello world ");
assert_eq!(out, "hello world");
}
#[test]
fn filters_trivial_responses() {
assert_eq!(preprocess_for_tts("."), "");
assert_eq!(preprocess_for_tts(","), "");
assert_eq!(preprocess_for_tts("!"), "");
assert_eq!(preprocess_for_tts(" "), "");
assert_eq!(preprocess_for_tts("ok"), "ok");
}
#[test]
fn full_pipeline() {
let input =
"**Agent says:** check https://relay.example.com at 11:30.\n```\nsome code\n```";
let out = preprocess_for_tts(input);
assert!(!out.contains("**"), "got: {out}");
assert!(!out.contains("https://"), "got: {out}");
assert!(out.contains("eleven thirty"), "got: {out}");
assert!(out.contains("code block omitted"), "got: {out}");
}
}