mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
## 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>
311 lines
12 KiB
Rust
311 lines
12 KiB
Rust
//! April 2026 Pocket TTS engine for Buzz Desktop.
|
|
//!
|
|
//! The `english_2026-04` bundle uses SentencePiece tokenization, a learned
|
|
//! voice BOS embedding, recurrent FlowLM state, and stateful Mimi decoding.
|
|
//! Buzz selects the upstream three-graph INT8 variant while retaining the
|
|
//! full-precision Mimi encoder and text conditioner specified by that variant.
|
|
//!
|
|
//! ## Attribution
|
|
//!
|
|
//! - Pocket TTS and Mimi: Kyutai, CC-BY-4.0.
|
|
//! - ONNX export: KevinAHM/pocket-tts-onnx, CC-BY-4.0.
|
|
//! - Reference voice: Kyutai's Mary preset (VCTK p333), CC-BY-4.0.
|
|
//!
|
|
//! `huddle::models` writes the complete attribution beside the cached bytes.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Mutex;
|
|
|
|
use sherpa_onnx::Wave;
|
|
|
|
#[path = "pocket_april.rs"]
|
|
mod pocket_april;
|
|
#[path = "pocket_models.rs"]
|
|
mod pocket_models;
|
|
|
|
use pocket_april::{prepare_april_prompt, AprilPocketTts};
|
|
pub use pocket_models::{
|
|
april_model_info, PocketModelArtifact, PocketModelInfo, APRIL_BUNDLE_ID, APRIL_MODEL_ID,
|
|
APRIL_MODEL_REVISION,
|
|
};
|
|
|
|
/// Pocket TTS emits 24 kHz mono PCM.
|
|
pub const SAMPLE_RATE: u32 = 24_000;
|
|
|
|
/// Bundled reference voice name without its extension.
|
|
pub const DEFAULT_VOICE: &str = "reference_sample";
|
|
|
|
/// Pocket voice files are reference WAVs.
|
|
pub const VOICE_FILE_EXT: &str = "wav";
|
|
|
|
const TTS_NUM_THREADS: usize = 1;
|
|
|
|
/// EXPERIMENTAL (latency): override ONNX intra-op threads for the Pocket
|
|
/// sessions via `BUZZ_TTS_THREADS`. Default preserves production's 1.
|
|
fn tts_num_threads() -> usize {
|
|
std::env::var("BUZZ_TTS_THREADS")
|
|
.ok()
|
|
.and_then(|v| v.parse::<usize>().ok())
|
|
.filter(|&n| n >= 1)
|
|
.unwrap_or(TTS_NUM_THREADS)
|
|
}
|
|
|
|
/// Loaded reference voice samples and their original sample rate.
|
|
#[derive(Debug, Clone)]
|
|
pub struct VoiceStyle {
|
|
samples: Vec<f32>,
|
|
sample_rate: i32,
|
|
}
|
|
|
|
/// Load a Pocket reference voice WAV from disk.
|
|
pub fn load_voice_style(path: &Path) -> Result<VoiceStyle, String> {
|
|
let path_str = path
|
|
.to_str()
|
|
.ok_or_else(|| format!("voice path is not valid UTF-8: {}", path.display()))?;
|
|
let wave = Wave::read(path_str)
|
|
.ok_or_else(|| format!("could not read voice WAV at {}", path.display()))?;
|
|
let samples = wave.samples().to_vec();
|
|
if samples.is_empty() {
|
|
return Err(format!("voice WAV is empty: {}", path.display()));
|
|
}
|
|
Ok(VoiceStyle {
|
|
samples,
|
|
sample_rate: wave.sample_rate(),
|
|
})
|
|
}
|
|
|
|
/// Resident April INT8 Pocket TTS engine.
|
|
pub struct PocketTts {
|
|
inner: Mutex<AprilPocketTts>,
|
|
}
|
|
|
|
/// Load Buzz Desktop's pinned April INT8 model.
|
|
pub fn load_text_to_speech(model_dir: &str) -> Result<PocketTts, String> {
|
|
let dir = PathBuf::from(model_dir);
|
|
for artifact in april_model_info().artifacts {
|
|
let path = dir.join(artifact.filename);
|
|
if !path.is_file() {
|
|
return Err(format!(
|
|
"incomplete Pocket TTS {} INT8 bundle: missing {}",
|
|
APRIL_BUNDLE_ID,
|
|
path.display()
|
|
));
|
|
}
|
|
}
|
|
Ok(PocketTts {
|
|
inner: Mutex::new(AprilPocketTts::load(&dir, tts_num_threads())?),
|
|
})
|
|
}
|
|
|
|
impl PocketTts {
|
|
/// 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<Vec<String>, 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.
|
|
///
|
|
/// 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 unit's prepared token count
|
|
/// is at most 50.
|
|
pub fn split_text_for_playback(&self, text: &str) -> Result<Vec<String>, 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_playback_prompt(&prepared)
|
|
}
|
|
|
|
/// Synthesize text with the supplied reference voice.
|
|
///
|
|
/// Pocket detects language from text and this model uses one synthesis
|
|
/// step, so `_lang` and `_steps` intentionally do not affect output.
|
|
pub fn synth_chunk(
|
|
&self,
|
|
text: &str,
|
|
_lang: &str,
|
|
style: &VoiceStyle,
|
|
_steps: usize,
|
|
) -> Result<Vec<f32>, String> {
|
|
let Some(prepared) = prepare_april_prompt(text) else {
|
|
return Ok(Vec::new());
|
|
};
|
|
let mut engine = self
|
|
.inner
|
|
.lock()
|
|
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?;
|
|
let chunks = engine.split_prompt(&prepared)?;
|
|
let mut samples = Vec::new();
|
|
for chunk in chunks {
|
|
let prepared = prepare_april_prompt(&chunk)
|
|
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
|
|
samples.extend(engine.synth_chunk(&prepared, style)?);
|
|
}
|
|
Ok(samples)
|
|
}
|
|
|
|
/// EXPERIMENTAL (latency): streaming synthesis. Invokes `on_audio` with
|
|
/// PCM deltas as soon as roughly `emit_frames` Flow LM frames (80 ms of
|
|
/// audio each) have been generated and decoded. Concatenated deltas equal
|
|
/// one `synth_chunk` result. The callback runs on the caller thread and
|
|
/// returns `false` to cancel; the function then returns Ok(false).
|
|
pub fn synth_chunk_streaming(
|
|
&self,
|
|
text: &str,
|
|
style: &VoiceStyle,
|
|
emit_frames: usize,
|
|
on_audio: &mut dyn FnMut(Vec<f32>) -> bool,
|
|
) -> Result<bool, String> {
|
|
let Some(prepared) = prepare_april_prompt(text) else {
|
|
return Ok(true);
|
|
};
|
|
let mut engine = self
|
|
.inner
|
|
.lock()
|
|
.map_err(|_| "Pocket TTS engine lock poisoned".to_string())?;
|
|
let chunks = engine.split_prompt(&prepared)?;
|
|
for chunk in chunks {
|
|
let prepared = prepare_april_prompt(&chunk)
|
|
.ok_or_else(|| "Pocket TTS prompt chunk became empty".to_string())?;
|
|
if !engine.synth_chunk_streaming(&prepared, style, emit_frames, on_audio)? {
|
|
return Ok(false);
|
|
}
|
|
}
|
|
Ok(true)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn desktop_model_is_april_int8_only() {
|
|
let info = april_model_info();
|
|
assert_eq!(info.max_token_per_chunk, 50);
|
|
assert_eq!(info.sample_rate, SAMPLE_RATE);
|
|
assert!(info
|
|
.artifacts
|
|
.iter()
|
|
.any(|artifact| artifact.filename == "flow_lm_main_int8.onnx"));
|
|
assert!(!info
|
|
.artifacts
|
|
.iter()
|
|
.any(|artifact| artifact.filename == "flow_lm_main.onnx"));
|
|
}
|
|
|
|
/// 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<String>)> {
|
|
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::<Vec<_>>()
|
|
.join("\n");
|
|
let mut out = Vec::new();
|
|
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");
|
|
// 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
|
|
// 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
|
|
}
|
|
|
|
#[test]
|
|
fn every_production_splitter_delegation_is_declared() {
|
|
let source = include_str!("pocket.rs");
|
|
let actual = splitter_delegations(source);
|
|
let expected: Vec<(String, Vec<String>)> = 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!(
|
|
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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "requires BUZZ_POCKET_TEST_MODEL_DIR"]
|
|
fn production_api_emits_non_silent_april_int8_pcm() {
|
|
let dir = std::env::var("BUZZ_POCKET_TEST_MODEL_DIR")
|
|
.expect("set BUZZ_POCKET_TEST_MODEL_DIR to an April INT8 model directory");
|
|
let engine = load_text_to_speech(&dir).expect("load April INT8 engine");
|
|
let style = load_voice_style(&Path::new(&dir).join("reference_sample.wav"))
|
|
.expect("load reference voice");
|
|
let samples = engine
|
|
.synth_chunk("Bright birds begin beside the bay.", "en", &style, 1)
|
|
.expect("synthesize through the production API");
|
|
|
|
assert!(!samples.is_empty());
|
|
assert!(samples.iter().all(|sample| sample.is_finite()));
|
|
assert!(samples.iter().any(|sample| sample.abs() > 1.0e-6));
|
|
}
|
|
}
|