Files
buzz/desktop/src-tauri/src/huddle/stt.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

848 lines
32 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.

//! Speech-to-Text pipeline for huddle voice transcription.
//!
//! Mental model:
//!
//! ```text
//! AudioWorklet (48 kHz f32 PCM)
//! → push_audio_pcm (Tauri cmd)
//! → SttPipeline::push_audio [bounded sync_channel]
//! → stt_worker thread
//! rubato: 48 kHz → 16 kHz mono
//! earshot VAD: accumulate speech frames
//! sherpa-onnx Parakeet TDT-CTC 110M: transcribe on silence
//! → text_rx [mpsc channel]
//! → tokio task (start_stt_pipeline)
//! builds kind:9 event → relay
//! ```
//!
//! The worker runs on a dedicated `std::thread` (not async) because
//! sherpa-onnx is CPU-bound and not Send-safe across await points.
use std::{
collections::VecDeque,
path::PathBuf,
sync::{
atomic::{AtomicBool, Ordering},
mpsc::{self, Receiver, SyncSender},
Arc,
},
thread,
time::Duration,
};
use tokio::sync::mpsc as tokio_mpsc;
use super::{human_floor::HumanFloor, local_barge_in};
// ── Public pipeline handle ────────────────────────────────────────────────────
/// Bounded audio queue capacity.
/// 100 ms batches at 48 kHz ≈ 19 KB each → 50 slots ≈ 5 s / ~1 MB max backlog.
const AUDIO_QUEUE_DEPTH: usize = 50;
/// Maximum speech buffer size: 30 seconds at 16 kHz.
/// Prevents OOM if VAD stays in speech mode (noisy environment).
const MAX_SPEECH_SAMPLES: usize = 16_000 * 30;
/// Handle to the running STT pipeline.
///
/// Not Clone — wrap in `Arc` to share across threads.
///
/// The text receiver (`tokio::sync::mpsc::Receiver<String>`) is returned
/// separately from `new()` so the caller can move it directly into an async
/// task without holding a Mutex across await points.
#[derive(Debug)]
pub struct SttPipeline {
/// Send raw PCM bytes (f32 LE, 48 kHz mono) into the pipeline.
audio_tx: SyncSender<SttAudioInput>,
/// Signals the worker thread to stop.
shutdown: Arc<AtomicBool>,
/// Worker thread handle — taken on drop to join cleanly.
thread: Option<thread::JoinHandle<()>>,
}
#[derive(Debug)]
struct SttAudioInput {
pcm_bytes: Vec<u8>,
origin: SttAudioOrigin,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum SttAudioOrigin {
Local,
RemoteHuman,
}
impl SttPipeline {
/// Spawn the pipeline thread.
///
/// Mic input is transcribed even while agent TTS is playing. In open-mic
/// VAD mode, confirmed speech acquires the shared human floor: immediately
/// on an isolated output route, or after the restored 320 ms sustained-
/// speech debounce on an acoustically coupled route. Push-to-talk retains
/// its explicit shortcut cancellation path.
///
/// `ptt_active` and `manual_mic_unmuted` are present when the PTT shortcut
/// is enabled. The pipeline accepts speech while either input path is open;
/// manual unmute uses normal VAD flushing while a shortcut hold is grouped
/// into one utterance.
///
/// Returns `Err` only if the thread cannot be spawned (OS error).
/// If model files are missing, the worker logs and exits cleanly —
/// the pipeline handle is still returned but will never produce text.
///
/// The `tokio::sync::mpsc::Receiver<String>` is returned separately so the
/// caller can move it directly into an async task. This avoids holding a
/// `Mutex<Receiver>` across await points (which would block a Tokio worker
/// thread on every `recv_timeout` call).
pub fn new(
model_dir: PathBuf,
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
human_floor: HumanFloor,
output_device: Option<String>,
) -> Result<(Self, tokio_mpsc::Receiver<String>), String> {
let (audio_tx, audio_rx) = mpsc::sync_channel::<SttAudioInput>(AUDIO_QUEUE_DEPTH);
let (text_tx, text_rx) = tokio_mpsc::channel::<String>(64);
let shutdown = Arc::new(AtomicBool::new(false));
let shutdown_worker = Arc::clone(&shutdown);
let ptt_active_worker = ptt_active.as_ref().map(Arc::clone);
let manual_mic_unmuted_worker = manual_mic_unmuted.as_ref().map(Arc::clone);
let handle = thread::Builder::new()
.name("stt-worker".into())
.spawn(move || {
stt_worker(
model_dir,
audio_rx,
text_tx,
shutdown_worker,
ptt_active_worker,
manual_mic_unmuted_worker,
human_floor,
output_device,
)
})
.map_err(|e| format!("failed to spawn stt-worker thread: {e}"))?;
let pipeline = Self {
audio_tx,
shutdown,
thread: Some(handle),
};
Ok((pipeline, text_rx))
}
/// Signal the worker thread to stop.
pub fn shutdown(&self) {
self.shutdown.store(true, Ordering::Release);
}
/// Returns `true` if the worker thread has exited (init failure, crash, or normal exit).
/// Used by hot-start to detect dead pipelines and clear them for retry.
pub fn is_finished(&self) -> bool {
self.thread.as_ref().is_none_or(|h| h.is_finished())
}
/// Feed raw PCM bytes into the pipeline.
///
/// Non-blocking. Drops audio silently if the pipeline can't keep up —
/// better to lose frames than to stall the UI thread.
pub fn push_audio(&self, pcm_bytes: Vec<u8>) -> Result<(), String> {
self.push_audio_from(pcm_bytes, SttAudioOrigin::Local)
}
/// Feed decoded remote-human PCM into transcription. Unlike the desktop
/// microphone path, this is not gated by the desktop PTT or mute state: the
/// remote participant already made their transmission choice on their own
/// device before the relay delivered these samples.
pub fn push_remote_audio(&self, pcm_bytes: Vec<u8>) -> Result<(), String> {
self.push_audio_from(pcm_bytes, SttAudioOrigin::RemoteHuman)
}
fn push_audio_from(&self, pcm_bytes: Vec<u8>, origin: SttAudioOrigin) -> Result<(), String> {
// Reject non-4-byte-aligned input — would silently truncate in bytes_to_f32.
if !pcm_bytes.len().is_multiple_of(4) {
return Err(format!(
"audio input not 4-byte aligned ({} bytes) — expected f32 LE samples",
pcm_bytes.len()
));
}
// Drop audio if the pipeline can't keep up — better than blocking the UI.
let _ = self.audio_tx.try_send(SttAudioInput { pcm_bytes, origin });
Ok(())
}
}
impl Drop for SttPipeline {
fn drop(&mut self) {
// Signal the worker to stop.
self.shutdown.store(true, Ordering::Release);
// Dropping `audio_tx` (implicitly when self is dropped after this fn)
// unblocks the worker's recv_timeout loop. Join to ensure clean exit.
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
// ── Worker thread ─────────────────────────────────────────────────────────────
/// How many 16 kHz samples of silence before we flush to STT.
/// 500 ms × 16 000 Hz / 256 samples-per-frame ≈ 31 frames.
/// This favors natural conversational pauses over the lower latency of the
/// previous 19-frame / 304 ms window.
///
/// This window is a turn-taking quality knob, not a latency lever: an earlier
/// env override (`BUZZ_STT_FLUSH_MS`) let it be lowered to 150 ms, which split
/// natural mid-sentence pauses into separate messages and confused the
/// listening agents. Reverted — the window is fixed at the production value.
const SILENCE_FLUSH_FRAMES: usize = 31;
/// earshot requires exactly 256 samples per frame at 16 kHz.
const VAD_FRAME_SAMPLES: usize = 256;
/// Earshot 1.1.0 onset operating point. Any Earshot model/version change
/// invalidates this and `VAD_OFFSET_THRESHOLD`; re-run the matched-corpus
/// threshold harness before updating either constant.
const VAD_ONSET_THRESHOLD: f32 = 0.55;
/// Earshot 1.1.0 offset operating point. The lower threshold keeps borderline
/// speech inside the active utterance without changing the onset sensitivity.
const VAD_OFFSET_THRESHOLD: f32 = 0.35;
/// Consecutive onset frames required before an utterance begins.
const VAD_ONSET_FRAMES: usize = 3;
/// Audio retained before confirmed onset so initial phonemes are not clipped.
/// A rolling pre-roll that survived a hard boundary would leak segment N into
/// segment N+1 when the next confirmed onset occurs within
/// `VAD_PRE_ROLL_FRAMES - VAD_ONSET_FRAMES` frames (13 frames, or 208 ms, at
/// the shipped values) of the previous flush. Hangover and the silence flush
/// window do not enter this bound; `reset_segment` keeps them independent by
/// clearing pre-roll.
const VAD_PRE_ROLL_FRAMES: usize = 16;
/// Trailing silence retained in the transcript buffer (about 100 ms).
const VAD_HANGOVER_FRAMES: usize = 6;
/// Minimum voiced audio needed before an utterance may be decoded.
/// One earshot false-positive frame is only 16 ms; requiring 192 ms prevents
/// silence/room-noise blips from reaching Parakeet and becoming hallucinated
/// transcript text while still preserving short replies such as "yes".
const MIN_VOICED_FRAMES: usize = 12;
#[derive(Debug, PartialEq, Eq)]
enum VadFrameAction {
None,
ConfirmedOnset,
Speech,
FirstSilence,
Flush,
}
struct VadEndpoint {
pre_roll: VecDeque<Vec<f32>>,
speech_buf: Vec<f32>,
onset_frames: usize,
silence_frames: usize,
voiced_frames: usize,
in_speech: bool,
}
impl VadEndpoint {
fn new() -> Self {
Self {
pre_roll: VecDeque::with_capacity(VAD_PRE_ROLL_FRAMES),
speech_buf: Vec::new(),
onset_frames: 0,
silence_frames: 0,
voiced_frames: 0,
in_speech: false,
}
}
fn process_frame(
&mut self,
frame: Vec<f32>,
probability: f32,
accepts_audio: bool,
flush_allowed: bool,
flush_frames: usize,
) -> VadFrameAction {
if !accepts_audio {
self.pre_roll.clear();
self.onset_frames = 0;
return VadFrameAction::None;
}
if !self.in_speech {
self.pre_roll.push_back(frame);
if self.pre_roll.len() > VAD_PRE_ROLL_FRAMES {
self.pre_roll.pop_front();
}
if probability > VAD_ONSET_THRESHOLD {
self.onset_frames += 1;
} else {
self.onset_frames = 0;
}
if self.onset_frames < VAD_ONSET_FRAMES {
return VadFrameAction::None;
}
self.in_speech = true;
self.silence_frames = 0;
self.voiced_frames = self.onset_frames;
self.onset_frames = 0;
for buffered in self.pre_roll.drain(..) {
self.speech_buf.extend_from_slice(&buffered);
}
return VadFrameAction::ConfirmedOnset;
}
if probability > VAD_OFFSET_THRESHOLD {
self.silence_frames = 0;
self.voiced_frames += 1;
self.speech_buf.extend_from_slice(&frame);
return VadFrameAction::Speech;
}
self.silence_frames += 1;
self.speech_buf.extend_from_slice(&frame);
if flush_allowed && self.silence_frames >= flush_frames {
let excess_silence = self.silence_frames.saturating_sub(VAD_HANGOVER_FRAMES);
let retained_samples = self
.speech_buf
.len()
.saturating_sub(excess_silence * VAD_FRAME_SAMPLES);
self.speech_buf.truncate(retained_samples);
VadFrameAction::Flush
} else if self.silence_frames == 1 {
VadFrameAction::FirstSilence
} else {
VadFrameAction::None
}
}
fn reset_segment(&mut self) {
self.speech_buf.clear();
// A hard message boundary also clears pre-roll: fast follow-up turns
// may receive less than the full window, but no frame can be decoded
// into both adjacent transcript messages.
self.pre_roll.clear();
self.onset_frames = 0;
self.silence_frames = 0;
self.voiced_frames = 0;
self.in_speech = false;
}
}
/// How long the worker waits on the audio channel before checking the shutdown flag.
const RECV_TIMEOUT: Duration = Duration::from_millis(50);
/// Number of ONNX Runtime intra-op threads used by the offline recognizer.
///
/// Held at 1 (conservative) until we have a local A/B on real huddle audio.
/// Sherpa-onnx's Parakeet example uses 2 and most published RTF numbers are
/// at 2 threads on x86_64 server class hardware, but the encoder runs only
/// on VAD chunk boundaries on a dedicated thread, so the threading knob
/// trades worker latency against potential oversubscription with the audio
/// worklet on small Macs (4-core Intel especially). Bump to 2 once the A/B
/// shows it's safe on the minimum-spec target.
const STT_NUM_THREADS: i32 = 1;
/// EXPERIMENTAL (latency bench): override recognizer intra-op threads via
/// `BUZZ_STT_THREADS`. Default preserves the production single thread.
fn stt_num_threads() -> i32 {
std::env::var("BUZZ_STT_THREADS")
.ok()
.and_then(|v| v.parse::<i32>().ok())
.filter(|&n| n >= 1)
.unwrap_or(STT_NUM_THREADS)
}
/// EXPERIMENTAL (latency bench): `BUZZ_STT_SPECULATIVE=1` starts the Parakeet
/// decode at the FIRST silent VAD frame instead of after the full flush
/// window, overlapping the ~150-250 ms decode with the silence wait. If
/// speech resumes, the speculative result is discarded. When silence holds
/// to the flush threshold the transcript is emitted immediately, so the STT
/// leg collapses to ~max(flush window, decode time).
fn stt_speculative_decode() -> bool {
std::env::var("BUZZ_STT_SPECULATIVE").is_ok_and(|v| v == "1")
}
struct SttStreamState {
resampler: rubato::Fft<f32>,
chunk_in: usize,
input_buf_48k: Vec<f32>,
leftover_16k: Vec<f32>,
vad: earshot::Detector<earshot::DefaultPredictor>,
endpoint: VadEndpoint,
speculative: Option<(String, usize)>,
}
impl SttStreamState {
fn new() -> Result<Self, String> {
use rubato::{FixedSync, Resampler};
let resampler = rubato::Fft::<f32>::new(48_000, 16_000, 1024, 2, 1, FixedSync::Input)
.map_err(|error| format!("STT resampler init failed: {error}"))?;
let chunk_in = resampler.input_frames_next();
Ok(Self {
resampler,
chunk_in,
input_buf_48k: Vec::with_capacity(chunk_in * 2),
leftover_16k: Vec::new(),
vad: earshot::Detector::new(earshot::DefaultPredictor::new()),
endpoint: VadEndpoint::new(),
speculative: None,
})
}
}
#[derive(Debug)]
enum SttLoopInput {
Tick,
Batch(Vec<SttAudioInput>),
}
fn run_stt_receive_loop(
audio_rx: Receiver<SttAudioInput>,
shutdown: &AtomicBool,
human_floor: HumanFloor,
mut process: impl FnMut(SttLoopInput, &mut local_barge_in::LocalBargeIn),
) {
let mut local_barge_in_state = local_barge_in::WorkerLocalBargeIn::new(human_floor);
loop {
// Check shutdown flag before blocking.
if shutdown.load(Ordering::Acquire) {
break;
}
process(SttLoopInput::Tick, &mut local_barge_in_state);
// Use recv_timeout so we can periodically check the shutdown flag.
let input = match audio_rx.recv_timeout(RECV_TIMEOUT) {
Ok(input) => input,
Err(mpsc::RecvTimeoutError::Timeout) => continue,
Err(mpsc::RecvTimeoutError::Disconnected) => break, // Sender dropped.
};
// Drain any additional pending messages to batch-process.
let mut batch = vec![input];
while let Ok(input) = audio_rx.try_recv() {
batch.push(input);
}
process(SttLoopInput::Batch(batch), &mut local_barge_in_state);
}
}
#[allow(clippy::too_many_arguments)]
fn stt_worker(
model_dir: PathBuf,
audio_rx: Receiver<SttAudioInput>,
text_tx: tokio_mpsc::Sender<String>,
shutdown: Arc<AtomicBool>,
ptt_active: Option<Arc<AtomicBool>>,
manual_mic_unmuted: Option<Arc<AtomicBool>>,
human_floor: HumanFloor,
output_device: Option<String>,
) {
// ── 1. Initialise sherpa-onnx recognizer ─────────────────────────────────
//
// Parakeet TDT-CTC 110M ships as a single `model.int8.onnx` (CTC head) plus
// `tokens.txt`. sherpa-onnx infers the model family from which inner config
// has a `model` path set, so we don't need to set `model_type` explicitly.
// (See rust-api-examples/parakeet_tdt_ctc_simulate_streaming_microphone.rs
// in k2-fsa/sherpa-onnx.)
use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig};
let tokens_path = model_dir.join("tokens.txt");
let model_path = model_dir.join("model.int8.onnx");
if !tokens_path.exists() || !model_path.exists() {
eprintln!(
"buzz-desktop: STT model not found at {} — STT disabled",
model_dir.display()
);
drain_until_shutdown(audio_rx, &shutdown);
return;
}
let mut cfg = OfflineRecognizerConfig::default();
cfg.model_config.nemo_ctc.model = Some(model_path.to_string_lossy().into_owned());
cfg.model_config.tokens = Some(tokens_path.to_string_lossy().into_owned());
cfg.model_config.num_threads = stt_num_threads();
// Explicit — defaults are not part of the API contract, and noisy debug
// logging in release builds would be expensive on every VAD chunk.
cfg.model_config.debug = false;
let recognizer = match OfflineRecognizer::create(&cfg) {
Some(r) => r,
None => {
eprintln!("buzz-desktop: OfflineRecognizer::create returned None — STT disabled");
drain_until_shutdown(audio_rx, &shutdown);
return;
}
};
// ── 2. Independent local and remote processing state ─────────────────────
// Separate resampler/VAD state prevents simultaneous desktop and remote
// speech from being serialized into one artificial utterance.
let mut local_stream = match SttStreamState::new() {
Ok(stream) => stream,
Err(error) => {
eprintln!("buzz-desktop: {error}");
return;
}
};
let mut remote_stream = match SttStreamState::new() {
Ok(stream) => stream,
Err(error) => {
eprintln!("buzz-desktop: {error}");
return;
}
};
let speculative_enabled = stt_speculative_decode();
let mut transmit_was_active = ptt_active
.as_ref()
.is_some_and(|ptt| ptt.load(Ordering::Acquire))
|| manual_mic_unmuted
.as_ref()
.is_some_and(|manual| manual.load(Ordering::Acquire));
run_stt_receive_loop(
audio_rx,
&shutdown,
human_floor.clone(),
|input, local_barge_in_state| match input {
SttLoopInput::Tick => {
// The worklet stops sending frames when both local transmit
// paths close, so flush on that edge instead of waiting for
// silence that will never arrive.
if let Some(ref ptt) = ptt_active {
let transmit_now = ptt.load(Ordering::Acquire)
|| manual_mic_unmuted
.as_ref()
.is_some_and(|manual| manual.load(Ordering::Acquire));
if transmit_was_active
&& !transmit_now
&& local_stream.endpoint.in_speech
&& !local_stream.endpoint.speech_buf.is_empty()
{
flush_to_stt(
&local_stream.endpoint.speech_buf,
local_stream.endpoint.voiced_frames,
&recognizer,
&text_tx,
);
local_stream.endpoint.reset_segment();
local_stream.speculative.take();
local_barge_in_state.release(&human_floor);
}
transmit_was_active = transmit_now;
}
}
SttLoopInput::Batch(batch) => {
for input in batch {
let (stream, ptt_gate, manual_gate, track_local_floor) = match input.origin {
SttAudioOrigin::Local => (
&mut local_stream,
ptt_active.as_ref(),
manual_mic_unmuted.as_ref(),
true,
),
SttAudioOrigin::RemoteHuman => (&mut remote_stream, None, None, false),
};
process_stt_input(
stream,
&input.pcm_bytes,
speculative_enabled,
&recognizer,
&text_tx,
ptt_gate,
manual_gate,
&human_floor,
local_barge_in_state,
output_device.as_deref(),
track_local_floor,
);
}
}
},
);
// No final flush — leave_huddle/end_huddle emit lifecycle events before
// the STT worker exits, so a final flush would post a kind:9 message AFTER
// the user has "left." Losing the last partial utterance is acceptable.
}
#[allow(clippy::too_many_arguments)]
fn process_stt_input(
stream: &mut SttStreamState,
pcm_bytes: &[u8],
speculative_enabled: bool,
recognizer: &sherpa_onnx::OfflineRecognizer,
text_tx: &tokio_mpsc::Sender<String>,
ptt_active: Option<&Arc<AtomicBool>>,
manual_mic_unmuted: Option<&Arc<AtomicBool>>,
human_floor: &HumanFloor,
local_barge_in_state: &mut local_barge_in::LocalBargeIn,
output_device: Option<&str>,
track_local_floor: bool,
) {
stream
.input_buf_48k
.extend_from_slice(&bytes_to_f32(pcm_bytes));
while stream.input_buf_48k.len() >= stream.chunk_in {
let chunk: Vec<f32> = stream.input_buf_48k.drain(..stream.chunk_in).collect();
let resampled = resample_chunk(&mut stream.resampler, &chunk);
process_16k_samples(
&resampled,
&mut stream.leftover_16k,
&mut stream.vad,
&mut stream.endpoint,
SILENCE_FLUSH_FRAMES,
(speculative_enabled, &mut stream.speculative),
recognizer,
text_tx,
ptt_active,
manual_mic_unmuted,
human_floor,
local_barge_in_state,
output_device,
track_local_floor,
);
}
}
/// Resample a mono 48 kHz chunk to 16 kHz using rubato.
/// Returns the resampled samples (may be empty on error).
fn resample_chunk(resampler: &mut rubato::Fft<f32>, chunk_48k: &[f32]) -> Vec<f32> {
use audioadapter_buffers::direct::InterleavedSlice;
use rubato::Resampler;
// rubato expects interleaved layout even for mono.
let input = match InterleavedSlice::new(chunk_48k, 1, chunk_48k.len()) {
Ok(a) => a,
Err(e) => {
eprintln!("buzz-desktop: STT resample input error: {e}");
return Vec::new();
}
};
match resampler.process(&input, 0, None) {
Ok(out) => out.take_data(),
Err(e) => {
eprintln!("buzz-desktop: STT resample error: {e}");
Vec::new()
}
}
}
/// Feed 16 kHz samples through the VAD and accumulate speech.
/// Flushes to STT when silence exceeds threshold.
///
/// Mic input keeps flowing while agent TTS plays: the huddle UI instructs
/// users to wear headphones, so overlapping human speech is transcribed
/// instead of discarded.
///
/// When `ptt_active` is `Some`, input is accepted while either the shortcut is
/// held or the microphone is manually unmuted. A held shortcut is an explicit
/// "I am not done talking" signal, so silence NEVER flushes while it is held —
/// even when the microphone is also manually open. The utterance flushes on
/// shortcut release (the transmit-edge flush in the worker loop) or, with a
/// manually open mic, via normal VAD pause flushing once the shortcut is up.
#[allow(clippy::too_many_arguments)]
fn process_16k_samples(
samples: &[f32],
leftover: &mut Vec<f32>,
vad: &mut earshot::Detector<earshot::DefaultPredictor>,
endpoint: &mut VadEndpoint,
flush_frames: usize,
speculative: (bool, &mut Option<(String, usize)>),
recognizer: &sherpa_onnx::OfflineRecognizer,
text_tx: &tokio_mpsc::Sender<String>,
ptt_active: Option<&Arc<AtomicBool>>,
manual_mic_unmuted: Option<&Arc<AtomicBool>>,
human_floor: &HumanFloor,
local_barge_in_state: &mut local_barge_in::LocalBargeIn,
output_device: Option<&str>,
track_local_floor: bool,
) {
let (speculative_enabled, speculative) = speculative;
leftover.extend_from_slice(samples);
while leftover.len() >= VAD_FRAME_SAMPLES {
let frame: Vec<f32> = leftover.drain(..VAD_FRAME_SAMPLES).collect();
let clamped: Vec<f32> = frame.iter().map(|&s| s.clamp(-1.0, 1.0)).collect();
let prob = vad.predict_f32(&clamped);
let manually_open = manual_mic_unmuted.is_some_and(|manual| manual.load(Ordering::Acquire));
let ptt_held = ptt_active.is_some_and(|ptt| ptt.load(Ordering::Acquire));
let accepts_audio = ptt_active.is_none() || ptt_held || manually_open;
// A held shortcut means "I am not done talking": silence never ends
// the utterance while it is held. VAD pause flushing applies in pure
// VAD mode, or with a manually open mic once the shortcut is up.
let flush_allowed = vad_flush_allowed(ptt_active.is_some(), manually_open, ptt_held);
let action =
endpoint.process_frame(frame, prob, accepts_audio, flush_allowed, flush_frames);
// Open-mic VAD semantics also apply when a PTT-mode user manually
// opens the mic. A held shortcut keeps its explicit key-down cancel.
let local_barge_in = track_local_floor
&& local_barge_in::enabled(ptt_active.is_some(), manually_open, ptt_held);
if track_local_floor {
if local_barge_in {
local_barge_in_state.observe(
prob,
action == VadFrameAction::ConfirmedOnset,
human_floor,
output_device,
VAD_ONSET_THRESHOLD,
);
} else {
local_barge_in_state.release(human_floor);
}
}
match action {
VadFrameAction::ConfirmedOnset => {
speculative.take();
}
VadFrameAction::Speech => {
// New voiced audio invalidates any speculative decode.
speculative.take();
}
VadFrameAction::FirstSilence => {
// Start speculative decode at the first silent frame. Any
// resumed speech invalidates this result in the arm above.
if speculative_enabled
&& speculative.is_none()
&& flush_allowed
&& has_enough_voiced_audio(endpoint.voiced_frames)
{
speculative.replace((
decode_speech(recognizer, &endpoint.speech_buf),
endpoint.voiced_frames,
));
}
}
VadFrameAction::Flush => {
match speculative.take() {
Some((text, decoded_at)) if decoded_at == endpoint.voiced_frames => {
send_transcript(text, text_tx);
}
_ => flush_to_stt(
&endpoint.speech_buf,
endpoint.voiced_frames,
recognizer,
text_tx,
),
}
endpoint.reset_segment();
if local_barge_in {
local_barge_in_state.release(human_floor);
}
}
VadFrameAction::None => {}
}
// Preserve the 30 s guard even while PTT suppresses silence flushing.
if endpoint.speech_buf.len() >= MAX_SPEECH_SAMPLES {
flush_to_stt(
&endpoint.speech_buf,
endpoint.voiced_frames,
recognizer,
text_tx,
);
endpoint.reset_segment();
if local_barge_in {
local_barge_in_state.release(human_floor);
}
speculative.take();
}
}
}
/// Run sherpa-onnx on the accumulated speech buffer and send the text.
///
/// Uses `blocking_send` because this runs on a `std::thread` (not async).
/// The tokio channel's `blocking_send` is safe to call from sync contexts.
fn flush_to_stt(
speech_buf: &[f32],
voiced_frames: usize,
recognizer: &sherpa_onnx::OfflineRecognizer,
text_tx: &tokio_mpsc::Sender<String>,
) {
if speech_buf.is_empty() {
return;
}
if !has_enough_voiced_audio(voiced_frames) {
eprintln!(
"buzz-desktop: STT dropped short VAD segment ({voiced_frames}/{MIN_VOICED_FRAMES} voiced frames)"
);
return;
}
send_transcript(decode_speech(recognizer, speech_buf), text_tx);
}
/// Run the Parakeet decode on a speech buffer and return the trimmed text.
fn decode_speech(recognizer: &sherpa_onnx::OfflineRecognizer, speech_buf: &[f32]) -> String {
let stream = recognizer.create_stream();
stream.accept_waveform(16_000, speech_buf);
recognizer.decode(&stream);
stream
.get_result()
.map(|r| r.text.trim().to_string())
.unwrap_or_default()
}
fn send_transcript(text: String, text_tx: &tokio_mpsc::Sender<String>) {
if !text.is_empty() {
if let Err(e) = text_tx.blocking_send(text) {
eprintln!("buzz-desktop: STT text channel closed: {e}");
}
}
}
fn has_enough_voiced_audio(voiced_frames: usize) -> bool {
voiced_frames >= MIN_VOICED_FRAMES
}
/// Whether a silence run may end the current utterance and flush it to STT.
///
/// Pure VAD mode (no shortcut configured) always allows pause flushing. When
/// the push-to-talk shortcut is configured, a held shortcut is an explicit
/// "I am not done talking" signal, so silence never flushes while it is held
/// — even if the microphone is also manually open. A manually open mic with
/// the shortcut up behaves like normal VAD.
fn vad_flush_allowed(ptt_mode: bool, manually_open: bool, ptt_held: bool) -> bool {
!ptt_mode || (manually_open && !ptt_held)
}
/// Convert raw bytes (f32 LE) to f32 samples.
/// Caller should ensure `bytes.len() % 4 == 0`; extra bytes are silently truncated.
///
/// Assumes little-endian — matches all current Tauri targets (macOS ARM64,
/// Windows/Linux x86). The JS AudioWorklet's Float32Array uses platform-native
/// byte order, which is LE on all supported platforms.
fn bytes_to_f32(bytes: &[u8]) -> Vec<f32> {
bytes
.as_chunks::<4>()
.0
.iter()
.map(|b| f32::from_le_bytes(*b))
.collect()
}
// drain_until_shutdown lives in super (huddle/mod.rs) — shared with tts.rs.
use super::drain_until_shutdown;
#[cfg(test)]
#[path = "stt_tests.rs"]
mod tests;