mirror of
https://github.com/block/buzz.git
synced 2026-10-31 06:21:34 +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>
105 lines
3.8 KiB
Rust
105 lines
3.8 KiB
Rust
//! EXPERIMENTAL (latency bench): streaming synthesis path for the TTS worker.
|
|
//!
|
|
//! `BUZZ_TTS_STREAMING=1` streams PCM deltas out of Pocket as they are
|
|
//! generated instead of waiting for the full first-chunk synthesis.
|
|
//! `BUZZ_TTS_EMIT_FRAMES` tunes the delta size in Flow LM frames (80 ms of
|
|
//! audio each). Default 12 = the Mimi decoder's native chunk, which keeps
|
|
//! streamed audio bit-identical to the batch path; smaller deltas are faster
|
|
//! to first audio but diverge (~23 dB SNR vs batch — decoder intra-chunk
|
|
//! lookahead).
|
|
|
|
use super::*;
|
|
|
|
use crate::huddle::pocket::{PocketTts, VoiceStyle};
|
|
|
|
/// Read the streaming env overrides once per worker: `Some(emit_frames)`
|
|
/// when `BUZZ_TTS_STREAMING=1`, `None` for the production batch path.
|
|
pub(super) fn streaming_emit_frames() -> Option<usize> {
|
|
std::env::var("BUZZ_TTS_STREAMING")
|
|
.is_ok_and(|v| v == "1")
|
|
.then(|| {
|
|
std::env::var("BUZZ_TTS_EMIT_FRAMES")
|
|
.ok()
|
|
.and_then(|v| v.parse().ok())
|
|
.unwrap_or(12)
|
|
})
|
|
}
|
|
|
|
/// Playback context threaded through one streamed chunk.
|
|
pub(super) struct StreamingPlayback<'a> {
|
|
pub(super) player: &'a rodio::Player,
|
|
pub(super) first_append: &'a mut bool,
|
|
pub(super) route_id: u64,
|
|
}
|
|
|
|
/// Synthesize one text chunk through `synth_chunk_streaming`, appending PCM
|
|
/// deltas to the player as they are generated so first audio lands after
|
|
/// ~`emit_frames` of generation instead of after the whole first-chunk
|
|
/// synthesis. Delta boundary decoration reuses `PlaybackChunkAudio`: lead-in
|
|
/// on the first delta, fade-out only on the final one.
|
|
///
|
|
/// `signals` = (cancel, voice_cancel, shutdown); `append_audio` returns
|
|
/// `false` to abort (its own cancellation checks and logging apply). Returns
|
|
/// `None` on success or `Some(outcome)` — the worker's `synthesis_outcome`
|
|
/// label — when the chunk was cancelled or failed.
|
|
pub(super) fn synthesize_streaming(
|
|
engine: &PocketTts,
|
|
text: &str,
|
|
style: &VoiceStyle,
|
|
emit_frames: usize,
|
|
signals: (&AtomicBool, &AtomicBool, &AtomicBool),
|
|
playback: StreamingPlayback<'_>,
|
|
append_audio: &mut dyn FnMut(PreparedModelAudio) -> bool,
|
|
) -> Option<&'static str> {
|
|
let (cancel, voice_cancel, shutdown) = signals;
|
|
let StreamingPlayback {
|
|
player,
|
|
first_append,
|
|
route_id,
|
|
} = playback;
|
|
let mut playback_audio = PlaybackChunkAudio::new();
|
|
let mut delta_index = 0usize;
|
|
let stream_result = engine.synth_chunk_streaming(text, style, emit_frames, &mut |samples| {
|
|
if cancel.load(Ordering::Acquire)
|
|
|| voice_cancel.load(Ordering::Acquire)
|
|
|| shutdown.load(Ordering::Acquire)
|
|
{
|
|
return false;
|
|
}
|
|
let chunk_index = delta_index;
|
|
delta_index += 1;
|
|
if let Some(prepared) =
|
|
playback_audio.push(samples, chunk_index, first_append, player.empty())
|
|
{
|
|
if !append_audio(prepared) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
});
|
|
match stream_result {
|
|
Ok(true) => {
|
|
if let Some(prepared) = playback_audio.finish(first_append, player.empty()) {
|
|
if !append_audio(prepared) {
|
|
*first_append = true;
|
|
return Some("cancelled");
|
|
}
|
|
}
|
|
None
|
|
}
|
|
Ok(false) => {
|
|
eprintln!(
|
|
"buzz-desktop: tts stage=synthesis status=cancelled reason=stream_callback route_id={route_id}"
|
|
);
|
|
*first_append = true;
|
|
Some("cancelled")
|
|
}
|
|
Err(_) => {
|
|
eprintln!(
|
|
"buzz-desktop: tts stage=synthesis status=failed reason=inference route_id={route_id}"
|
|
);
|
|
Some("failed")
|
|
}
|
|
}
|
|
}
|