Files
buzz/desktop/src-tauri/src/huddle/latency_bench.rs
Matt Toohey 8216db1c71 chore(toolchain): bump Rust to 1.98.1 for macOS 27 proc-macro loading (#8598)
## Summary

Bump the pinned Rust toolchain from 1.95.0 to 1.98.1 so release builds
work on macOS 27 hosts.

Rust ≤ 1.97 strips Apple-target artifacts with its bundled
`llvm-objcopy`, which leaves the Mach-O `LC_SYMTAB` string pool
(`__LINKEDIT`) only 4-byte aligned. macOS 27's dyld refuses to `dlopen`
such images (`mis-aligned LINKEDIT string pool`). Because cargo applies
the release profile's default `strip = "debuginfo"` to proc-macro dylibs
too, `cargo build --release` of any crate that uses sqlx macros
(`buzz-db`, `buzz-relay`, `buzz-admin`, …) fails on macOS 27: rustc
cannot load `libsqlx_macros-*.dylib`. Dev builds pass because the dev
profile does not strip, which is why this only shows up as a
release/packaging failure.

The fix (llvm/llvm-project#203680, pulled into rustc by
rust-lang/rust#158410 for rust-lang/rust#157750) first shipped in Rust
1.98.0 and was never backported to 1.97.x. `block/buzz-app` pinned
1.98.1 for the same failure in block/buzz-app#529. With this bump the
`CARGO_PROFILE_RELEASE_BUILD_OVERRIDE_STRIP=none` / custom-linker
workarounds people have been using locally are no longer needed.

### What changes

Two commits: the toolchain bump (with the desktop clippy fixes), then
the workspace clippy fixes.

| Location | Change |
|---|---|
| `rust-toolchain.toml` | `channel = "1.95.0"` → `"1.98.1"` (profile
unchanged) |
| `Dockerfile`, `Dockerfile.push-gateway` | `ARG RUST_VERSION=1.95` →
`1.98` |
| `Dockerfile.sprig` | `rust:1.98-alpine3.22` with the manifest-list
digest verified on Docker Hub (shared by the `1.98` and `1.98.1` tags) |
| `.github/workflows/release.yml` | `dtolnay/rust-toolchain` pinned to
the tip of its `1.98.1` branch (`18f04874`) for the Windows desktop
release job |
| `benchmarks/harbor-buzz-orchestra` | `RUST_IMAGE = rust:1.98-alpine` |
| `_ci-rust.yml`, `windows-canary.yml`, `app_state_tests.rs` | stale
"1.95" comments reworded; the `unknown_lints` allow stays with a
version-agnostic rationale |
| `desktop/src-tauri` | lints clippy 1.98 newly raises under `-D
warnings`: `chunks_exact_to_as_chunks` at five fixed-width PCM/UTF-16
decoders, `needless_borrows_for_generic_args` on the `Copy` `norm`
closure in `config_bridge/reader.rs`, and a redundant `Engine as _`
import in `hpke_key_backup_tests.rs` that `use super::*` already
provides |
| Workspace crates (9) | the 30 lints clippy 1.98 newly raises in `cargo
clippy --workspace --all-targets -- -D warnings`, listed below |

Workspace lint fixes:

| Lint | Sites | Fix |
|---|---|---|
| `result_large_err` (20) | `buzz-relay` `api/git/transport.rs` (8),
`api/git/settings.rs` (6), `api/gifs.rs`, `api/workflows.rs`,
`api/profiles.rs`; `buzz-push-gateway` `verify_installation_assertion` |
Clippy 1.98 now checks `async fn`s, so every `async fn … -> Result<_,
axum::response::Response>` trips it. These follow the existing allow
pattern in `api/media.rs` and `nip_fi_http.rs` ("Response is the natural
error type for axum handlers"): module-level in the two git files,
per-function elsewhere. Boxing `Response` would touch every handler for
no runtime benefit. |
| `chunks_exact_to_as_chunks` (3) | `buzz-media` MP4 brand scan;
`buzz-voice` npy f32 reader and stereo test fixture | `chunks_exact(N)`
→ `as_chunks::<N>().0` |
| `question_mark` (3) | `buzz-acp` queue flush fallback and
slash-command mention skip; `buzz-agent` `find_git_root` | `match … {
None => return None }` → `?` |
| `useless_format` (2) | `buzz-auth` nip_fi startup test policies |
`format!("…")` → `"…".to_string()` |
| `useless_borrows_in_formatting` (1) | `buzz-pairing-cli` secret print
| drop the borrow |
| `manual_noop_waker` (1) | `buzz-sdk` broker test `block_on` |
`Waker::noop()` (stable since 1.85) |

All `as_chunks` rewrites keep `chunks_exact`'s remainder-dropping
semantics, and `as_chunks` is stable since 1.88, which is the MSRV.

One change in `api/git/transport.rs` is not a lint fix. Staging that
file trips the pre-commit secret scanner on its existing test-only ES256
fixture (`GIT_TEST_EC_PEM`), and the scanner only honours a suppression
marker on the flagged line. So the `\`-continued string literal is
rewritten as a byte-identical `concat!` whose `BEGIN` line carries
`sadscan:disable kingfisher.privkey.2`.

Left as is on purpose: `Cargo.toml` `rust-version = "1.88.0"` (MSRV
floor, a separate concern), the README/CONTRIBUTING "1.88+" text, Hermit
(no Rust packages there), the lockfiles, and the `(1.95.0)` comment in
`scripts/start-isolated-test-relay.sh` (the pre-commit secret scanner
flags that file's pre-existing dev-only `DATABASE_URL` whenever it is
staged).

### Related issue
Fixes #8074

### Testing
All under rustc/clippy 1.98.1 on a macOS 27 host (Darwin 27.0.0,
aarch64), with no `RUSTFLAGS` / `SDKROOT` / strip overrides in the
environment:

- `cargo build --release -p buzz-db` (the #8074 repro: the release
profile strips the proc-macro dylib): **builds**. The resulting
`libsqlx_macros-*.dylib` (14 MB) has `LC_SYMTAB stroff % 8 == 0` per
`otool -l`. The same build on 1.95.0 died with `mis-aligned LINKEDIT
string pool`.
- `just clippy` (workspace, `-D warnings`) and `cargo fmt --check`
(workspace and `desktop/src-tauri`): pass
- `just desktop-tauri-clippy` (default and `mesh-llm` feature sets):
pass
- `just test-unit`: every crate passes. The `buzz-acp` binary failed
only under pre-push lane contention; rerun solo it is 1060 passed, 0
failed.
- `just desktop-tauri-test`: 3422 passed, 0 failed
- After the lint fixes: lib/bin unit tests for `buzz-acp`, `buzz-agent`,
`buzz-auth`, `buzz-media`, `buzz-voice`, `buzz-sdk`, `buzz-pairing-cli`,
and `buzz-relay`'s `api::git::transport` tests, including the
Postgres-backed
`enforce_mode_git_pack_routes_missing_assertion_exact_bytes`, which
parses the rewritten PEM: pass

`cfg(windows)` code was not linted locally (no Windows target
installed). CI's Windows Rust job runs workspace clippy for
`x86_64-pc-windows-msvc`, and that passes on this branch. CI also builds
the relay, push-gateway and sprig images on the new `rust:1.98` bases
for amd64 and arm64.

No runtime behavior changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Signed-off-by: Matt Toohey <contact@matttoohey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-27 08:13:38 +11:00

335 lines
13 KiB
Rust

//! Ad-hoc baseline latency bench for the STT -> fake LLM -> TTS pipeline.
//!
//! Drives the REAL production machinery:
//! - `SttPipeline::new` (rubato 48k->16k, earshot VAD, 300 ms silence flush,
//! Parakeet TDT-CTC 110M int8 via sherpa-onnx, 1 thread)
//! - `TtsPipeline::new_with_voice` (warmup synth, chunker, synth_chunk,
//! rodio persistent Player, 20 ms lead-in)
//!
//! with a fake LLM in place of the relay/agent leg.
//!
//! Audio is fed in real-time 100 ms batches (mirroring the AudioWorklet
//! cadence) so VAD endpointing behaves exactly like production.
//!
//! Timestamps captured per turn:
//! t_speech_end last voiced sample delivered to push_audio (wall clock,
//! derived from the WAV's last voiced sample + feed pacing)
//! t_transcript text_rx yields the transcript
//! t_speak fake-LLM reply handed to TtsPipeline::speak
//! t_first_audio tts_active rising edge = first player.append accepted
//!
//! Run:
//! BUZZ_BENCH_WAV=<48k f32 mono wav> cargo test --release -p buzz-desktop \
//! --lib huddle::latency_bench -- --ignored --nocapture
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc,
};
use std::time::{Duration, Instant};
use super::stt::SttPipeline;
use super::tts::TtsPipeline;
/// Read a mono 32-bit-float WAV (as produced by `afconvert -d LEF32@48000`).
/// Minimal parser: walks RIFF chunks, asserts fmt = IEEE float mono 48 kHz.
fn read_wav_f32_48k(path: &str) -> Vec<f32> {
let bytes = std::fs::read(path).expect("read wav");
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WAVE");
let mut pos = 12usize;
let mut fmt_ok = false;
let mut data: Option<(usize, usize)> = None;
while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let len = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize;
let body = pos + 8;
match id {
b"fmt " => {
let format = u16::from_le_bytes(bytes[body..body + 2].try_into().unwrap());
let channels = u16::from_le_bytes(bytes[body + 2..body + 4].try_into().unwrap());
let rate = u32::from_le_bytes(bytes[body + 4..body + 8].try_into().unwrap());
let bits = u16::from_le_bytes(bytes[body + 14..body + 16].try_into().unwrap());
assert_eq!(format, 3, "expected IEEE float wav");
assert_eq!(channels, 1, "expected mono");
assert_eq!(rate, 48_000, "expected 48 kHz");
assert_eq!(bits, 32);
fmt_ok = true;
}
b"data" => data = Some((body, len)),
_ => {}
}
pos = body + len + (len & 1);
}
assert!(fmt_ok, "fmt chunk missing");
let (off, len) = data.expect("data chunk missing");
bytes[off..off + len]
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect()
}
/// Index (in samples) one past the last sample whose |amplitude| exceeds the
/// threshold — "when the user stopped speaking" on the feed timeline.
fn last_voiced_sample(samples: &[f32], threshold: f32) -> usize {
samples
.iter()
.rposition(|s| s.abs() > threshold)
.map(|i| i + 1)
.unwrap_or(0)
}
/// Poll a tokio mpsc receiver from sync context for up to `timeout`.
/// 1 ms poll keeps timestamp error negligible against ~100 ms scales.
fn tokio_recv_with_timeout(
rx: &mut tokio::sync::mpsc::Receiver<String>,
timeout: Duration,
) -> Option<String> {
let deadline = Instant::now() + timeout;
loop {
if let Ok(t) = rx.try_recv() {
return Some(t);
}
if Instant::now() >= deadline {
return None;
}
std::thread::sleep(Duration::from_millis(1));
}
}
struct TurnResult {
label: &'static str,
transcript: String,
stt_ms: f64,
llm_ms: f64,
tts_ms: f64,
e2e_ms: f64,
}
#[test]
#[ignore = "ad-hoc latency baseline; needs models in ~/.buzz/models and an audio output device"]
fn baseline_stt_fake_llm_tts_first_audio() {
let home = dirs::home_dir().expect("home");
let stt_dir = home.join(".buzz/models/parakeet-tdt-ctc-110m-en");
let tts_dir = home.join(".buzz/models/pocket-tts");
assert!(
stt_dir.join("model.int8.onnx").exists(),
"parakeet model missing"
);
assert!(tts_dir.join("bundle.json").exists(), "pocket model missing");
let wav_path = std::env::var("BUZZ_BENCH_WAV").expect("set BUZZ_BENCH_WAV");
let samples_48k = read_wav_f32_48k(&wav_path);
let speech_end_sample = last_voiced_sample(&samples_48k, 0.015);
let audio_dur_s = samples_48k.len() as f64 / 48_000.0;
let speech_end_s = speech_end_sample as f64 / 48_000.0;
eprintln!(
"bench: utterance {wav_path}: {audio_dur_s:.2} s total, speech ends at {speech_end_s:.2} s"
);
let llm_delay_ms: u64 = std::env::var("BUZZ_BENCH_LLM_MS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0);
// ── Bring up the real pipelines, exactly as maybe_start_* do ────────────
let tts_active = Arc::new(AtomicBool::new(false));
let tts_cancel = Arc::new(AtomicBool::new(false));
let t = Instant::now();
let tts = TtsPipeline::new_with_voice(
tts_dir,
Arc::clone(&tts_active),
Arc::clone(&tts_cancel),
super::human_floor::HumanFloor::new(),
"eve",
None, // default output device
None, // no Tauri app handle
)
.expect("tts pipeline");
eprintln!(
"bench: TTS pipeline ready (engine load + warmup + audio prime) in {:.0} ms",
t.elapsed().as_secs_f64() * 1e3
);
let t = Instant::now();
let (stt, mut text_rx) = SttPipeline::new(
stt_dir,
None,
None,
super::human_floor::HumanFloor::new(),
None,
)
.expect("stt pipeline");
// Recognizer loads inside the worker thread; give it time, then verify
// liveness via a first throwaway feed below.
std::thread::sleep(Duration::from_secs(2));
assert!(!stt.is_finished(), "stt worker died during init");
eprintln!(
"bench: STT pipeline spawned ({:.0} ms incl. settle sleep)",
t.elapsed().as_secs_f64() * 1e3
);
// Fake LLM replies: short / medium / long, cycled across turns.
let replies: [(&'static str, &'static str); 3] = [
("reply_short", "Let me check."),
("reply_medium", "Got it. The relay deploy finished about two minutes ago and all checks passed."),
("reply_long", "Here's where things stand. The relay deploy finished cleanly and every health check is green. Two pods restarted during rollout, which is expected, and message latency is back to normal."),
];
let turns: usize = std::env::var("BUZZ_BENCH_TURNS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(6);
// 100 ms batches at 48 kHz, matching the AudioWorklet push cadence.
const BATCH: usize = 4_800;
let mut results: Vec<TurnResult> = Vec::new();
let stt = Arc::new(stt);
for turn in 0..turns {
let (label, reply) = replies[turn % replies.len()];
// Feed the utterance in real time from a separate thread (the
// AudioWorklet role), then trailing silence so the 300 ms VAD flush
// fires. The main thread meanwhile timestamps transcript arrival —
// recv must NOT be serialized behind the silence feed, or the
// measurement floor becomes the feed loop instead of the STT path.
let feeder_stt = Arc::clone(&stt);
let feeder_samples = samples_48k.clone();
let feed_start = Instant::now();
let feeder = std::thread::spawn(move || {
let mut cursor = 0usize;
while cursor < feeder_samples.len() {
let end = (cursor + BATCH).min(feeder_samples.len());
let bytes: Vec<u8> = feeder_samples[cursor..end]
.iter()
.flat_map(|s| s.to_le_bytes())
.collect();
feeder_stt.push_audio(bytes).expect("push");
cursor = end;
// Pace to real time.
let target = feed_start + Duration::from_millis((cursor / 48) as u64);
let now = Instant::now();
if target > now {
std::thread::sleep(target - now);
}
}
// Trailing silence: 1 s guarantees the 300 ms flush window closes.
let silence = vec![0u8; BATCH * 4];
for _ in 0..10 {
feeder_stt
.push_audio(silence.clone())
.expect("push silence");
std::thread::sleep(Duration::from_millis(100));
}
});
let t_speech_end = feed_start + Duration::from_secs_f64(speech_end_s);
// Transcript arrival. An utterance with an intra-sentence pause can
// VAD-split into multiple segments; keep the LAST one delivered so the
// turn aligns with the true end of speech. The extra "is another
// segment coming?" wait below is a HARNESS artifact (prod forwards
// every segment immediately) and is excluded from all timings.
let mut transcript = text_rx
.blocking_recv()
.expect("stt channel closed before transcript");
let mut t_transcript = Instant::now();
let mut segments = 1usize;
loop {
match text_rx.try_recv() {
Ok(t) => {
transcript = t;
t_transcript = Instant::now();
segments += 1;
}
Err(_) => {
if feeder.is_finished() {
// Feed done (incl. 1 s trailing silence): any final
// segment has already flushed and decoded. One short
// grace poll covers a decode still in flight.
match tokio_recv_with_timeout(&mut text_rx, Duration::from_millis(500)) {
Some(t) => {
transcript = t;
t_transcript = Instant::now();
segments += 1;
}
None => break,
}
} else {
// Feeder still delivering audio — a later segment may
// arrive any time until the feed (plus flush window)
// completes. Keep waiting; do NOT break early or the
// tail segment leaks into the next turn.
if let Some(t) =
tokio_recv_with_timeout(&mut text_rx, Duration::from_millis(100))
{
transcript = t;
t_transcript = Instant::now();
segments += 1;
}
}
}
}
}
feeder.join().expect("feeder");
// Fake LLM. Applied AFTER the harness-only segment wait; llm_ms is the
// configured delay, so the harness wait never leaks into any timing.
if llm_delay_ms > 0 {
std::thread::sleep(Duration::from_millis(llm_delay_ms));
}
let t_speak = Instant::now();
tts.speak(reply.to_string()).expect("speak");
// First audio: tts_active rising edge == first accepted player append.
let deadline = Instant::now() + Duration::from_secs(30);
while !tts_active.load(Ordering::Acquire) {
assert!(Instant::now() < deadline, "no first audio within 30 s");
std::thread::sleep(Duration::from_micros(500));
}
let t_first_audio = Instant::now();
let stt_ms = (t_transcript - t_speech_end).as_secs_f64() * 1e3;
// llm_ms is exactly the configured fake-LLM delay; tts is measured
// from speak() to first accepted append. e2e composes the three real
// legs so the harness-only segment wait (between t_transcript and the
// fake-LLM sleep) never inflates the pipeline number.
let llm_ms = llm_delay_ms as f64;
let tts_ms = (t_first_audio - t_speak).as_secs_f64() * 1e3;
let e2e_ms = stt_ms + llm_ms + tts_ms;
eprintln!(
"bench turn {turn} [{label}]: stt={stt_ms:.0}ms llm={llm_ms:.0}ms tts_first_audio={tts_ms:.0}ms e2e={e2e_ms:.0}ms segments={segments} transcript={transcript:?}"
);
results.push(TurnResult {
label,
transcript,
stt_ms,
llm_ms,
tts_ms,
e2e_ms,
});
// Wait for playback to drain + prod cooldown before the next turn.
while tts_active.load(Ordering::Acquire) {
std::thread::sleep(Duration::from_millis(20));
}
std::thread::sleep(Duration::from_millis(500));
}
// Summary JSON for the write-up.
println!("[");
for (i, r) in results.iter().enumerate() {
let comma = if i + 1 < results.len() { "," } else { "" };
println!(
" {{\"turn\":{i},\"label\":\"{}\",\"stt_ms\":{:.1},\"llm_ms\":{:.1},\"tts_first_audio_ms\":{:.1},\"e2e_ms\":{:.1},\"transcript\":{:?}}}{comma}",
r.label, r.stt_ms, r.llm_ms, r.tts_ms, r.e2e_ms, r.transcript
);
}
println!("]");
stt.shutdown();
tts.shutdown();
}