Files
rustytorch/crates/models/rtx-csm/src/stt.rs
T
osobhandClaude Opus 4.7 adc9784646 rtx-csm: Stt::finish() — drain asr_delay buffer at end-of-stream
Phase 8.1.1 quality fix from the perf plan. Tokens emitted at LM step
`t` correspond to audio frame `t - ASR_DELAY_FRAMES` (6 frames /
0.48 s), so when a caller stops feeding audio without trailing
silence the last few words trail off — they're still inside the
delay pipeline.

finish() now steps ASR_DELAY_FRAMES additional silent frames after
handling any partial sub-frame buffer, giving the LM the chance to
emit those buffered tokens. Cost: 7 extra step_pcm calls per turn.

Verified end-to-end via stt_demo on a mid-utterance trim of the
LibriSpeech reference clip:
  pre-flush:  11 words ("...turnips and carrots and bruised")
  post-flush: 13 words ("...turnips and carrots and bruised potatoes and")

Also drops the now-redundant 2s silence suffix in stt_demo — the
flush replaces it. Affects converse_server's real-time end-of-turn
path where suffix padding wasn't possible.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-29 04:02:19 -07:00

550 lines
21 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.
//! Streaming Speech-to-Text via Kyutai's delayed-streams architecture.
//!
//! This module is a thin wrapper around the `moshi` crate's `asr` + `lm` +
//! `mimi` modules. Kyutai's STT shares architecture with their Moshi
//! conversational model: a single decoder-only transformer consumes 32
//! streams of Mimi audio codebook embeddings + 1 text stream and emits
//! one text token per 80 ms frame. The "delayed streams" idea: text is
//! shifted forward in time relative to audio, so token at frame `t`
//! corresponds to audio at frame `t - asr_delay`.
//!
//! ## Why depend on moshi?
//!
//! Kyutai already implements the streaming inference loop, the per-batch
//! state machine, and the word-segmentation logic in pure Rust on
//! candle 0.9.1 (the same candle version we use for CSM). Re-porting the
//! ~600 LOC of `moshi::asr` would be wasted effort. Total new code in
//! rtx-csm is ~200 LOC of API wrapping + audio I/O glue + sentencepiece
//! detok.
//!
//! ## Pipeline
//!
//! ```text
//! PCM 24 kHz
//! │
//! ▼ chunked at 1920 samples (= 80 ms = 1 Mimi frame)
//! moshi::mimi::Mimi::encode_step → 32 codebook tokens per frame
//! │
//! ▼
//! moshi::lm::LmModel.forward (1 step)
//! │
//! ▼
//! moshi::asr::State.step_pcm → Vec<AsrMsg::{Step, Word, EndWord}>
//! │
//! ▼ (sentencepiece detok)
//! transcribed words with start/stop times
//! ```
//!
//! ## Status: WORKING
//!
//! Verified end-to-end: 23 words correctly transcribed from a 10s real
//! speech sample (LibriSpeech-style FLAC, "He hoped there would be stew
//! for dinner, turnips and carrots and bruised potatoes and fat mutton
//! pieces to be ladled out in thick peppered flower"). The previous
//! "all-pad" debugging session was misled by feeding CSM-generated audio
//! that even the Python reference can't transcribe.
//!
//! What this module ships:
//! - [`Stt`] struct with `load_default()` (1B en/fr Kyutai checkpoint,
//! ~3 GB), `load(...)` (custom paths), `step_pcm(...)`, `reset()`,
//! `decode_word_text(...)` (sentencepiece detok)
//! - [`AsrEvent`] enum mirroring `moshi::asr::AsrMsg`
//! - [`config_stt_1b_en_fr`] config matching the released checkpoint
//!
//! Known polish items (~1 hour to chase down if you care about parity):
//! - Last word or two in long utterances may be cut off — Python's
//! reference uses `audio_delay_seconds=0.5` directly as a chunk count,
//! while we use `asr_delay_in_tokens=6` (= 6/12.5 Hz = 0.48s) which
//! loses the last ~0.08s. Bump to 7 to match.
//! - SentencePiece tokens are emitted per-word; consecutive same-word
//! tokens may merge in `Word::tokens` events vs Python which splits
//! more aggressively. Cosmetic only.
use crate::error::{CsmError, Result};
use candle_core::{DType, Device, Tensor};
use moshi::StreamMask;
use moshi::asr::AsrMsg;
use moshi::lm;
use moshi::transformer;
use sentencepiece::SentencePieceProcessor;
use std::path::{Path, PathBuf};
/// Token id 3 marks the word boundary in Kyutai STT output. Skip during
/// detok so we don't emit "▁" placeholders. Token id 0 is end-of-padding.
const PADDING_TOKEN_ID: u32 = 3;
/// Build the LM config for the VAD-enabled `kyutai/stt-1b-en_fr-candle`
/// variant. Same as the standard config but with `extra_heads = Some(...)`
/// so the LM exposes 4 extra prediction heads (each 6-dim categorical)
/// for semantic end-of-turn detection.
pub fn config_stt_1b_en_fr_vad() -> lm::Config {
let mut cfg = config_stt_1b_en_fr();
cfg.extra_heads = Some(lm::ExtraHeadsConfig {
num_heads: VAD_EXTRA_HEADS,
dim: VAD_HEAD_DIM,
});
cfg
}
/// Build the LM config for `kyutai/stt-1b-en_fr`. Mirrors the upstream
/// config.json: 16 layers / 2048 dim / 16 heads, hidden_scale 4.125 →
/// dim_feedforward = 2048 * 4 (round); 32 audio codebooks; text vocab
/// 8000 (+1 padding for in-vocab); no depformer; no extra heads.
pub fn config_stt_1b_en_fr() -> lm::Config {
let lm_cfg = transformer::Config {
d_model: 2048,
num_heads: 16,
num_layers: 16,
// moshi's transformer SwiGLU formula:
// if dim_feedforward == 4 * d_model: hidden = 11 * d_model / 4
// else: hidden = 2 * dim_feedforward / 3
// For this checkpoint d_model=2048, hidden=5632, so set
// dim_feedforward = 4 * 2048 = 8192 to trigger the right branch.
dim_feedforward: 2048 * 4,
causal: true,
norm_first: true,
bias_ff: false,
bias_attn: false,
layer_scale: None,
context: 750,
max_period: 100_000,
use_conv_block: false,
use_conv_bias: true,
cross_attention: None,
gating: Some(candle_nn::Activation::Silu),
norm: moshi::NormType::RmsNorm,
positional_embedding: transformer::PositionalEmbedding::Rope,
conv_layout: false,
conv_kernel_size: 3,
kv_repeat: 1,
max_seq_len: 4096,
shared_cross_attn: false,
};
lm::Config {
transformer: lm_cfg,
depformer: None,
audio_vocab_size: 2049,
text_in_vocab_size: 8001,
text_out_vocab_size: 8000,
audio_codebooks: 32,
conditioners: Default::default(),
extra_heads: None,
}
}
/// Kyutai 1B en/fr STT — the standard 1B en/fr model (no VAD heads).
pub const REPO_KYUTAI_STT_1B: &str = "kyutai/stt-1b-en_fr";
/// Kyutai 1B en/fr STT — VAD-enabled variant. Same backbone weights but
/// with 4 extra heads (each 6-dim categorical, e.g. pause-duration buckets)
/// trained for semantic end-of-turn detection.
pub const REPO_KYUTAI_STT_1B_VAD: &str = "kyutai/stt-1b-en_fr-candle";
pub const FILE_STT_MODEL: &str = "model.safetensors";
pub const FILE_STT_MIMI: &str = "[email protected]";
pub const FILE_STT_TOKENIZER: &str = "tokenizer_en_fr_audio_8000.model";
/// Number of extra heads on the VAD-enabled checkpoint.
pub const VAD_EXTRA_HEADS: usize = 4;
/// Per-head output dim (categorical buckets, e.g. pause durations).
pub const VAD_HEAD_DIM: usize = 6;
/// Index of the "end-of-turn" head (per the Kyutai delayed-streams
/// reference Python script: vad_heads[2] is the EOT head).
pub const VAD_EOT_HEAD_IDX: usize = 2;
/// Mimi audio frame rate (12.5 Hz = 80 ms per frame).
pub const FRAME_RATE_HZ: f64 = 12.5;
/// Per-step input samples for `Mimi::encode_step`. The Kyutai STT
/// inference scripts feed 1920 samples per call (one 12.5 Hz output
/// frame at 24 kHz). The internal stride-2 downsample emits one output
/// per call when fed 1920 input samples.
pub const SAMPLES_PER_FRAME: usize = 1920;
pub const SAMPLE_RATE: u32 = 24_000;
/// ASR delay for the 1B en/fr checkpoint. Tokens emitted at frame `t`
/// correspond to audio at frame `t - ASR_DELAY_FRAMES`.
pub const ASR_DELAY_FRAMES: usize = 6;
/// Cleaner enum mirror of `moshi::asr::AsrMsg`. We reshape it slightly so
/// the sentencepiece detok step can be added without disturbing callers.
#[derive(Debug, Clone)]
pub enum AsrEvent {
/// Per-step probabilities from the `extra_heads` output (semantic VAD,
/// turn-taking, etc.). Each inner `Vec<f32>` is one head's output.
Step { step_idx: usize, prs: Vec<Vec<f32>> },
/// A complete word (sequence of subword tokens) with timing.
/// `text` is `None` until sentencepiece detok is wired (Phase 6a polish).
Word {
tokens: Vec<u32>,
text: Option<String>,
start_time: f64,
batch_idx: usize,
},
/// End-of-word marker with stop time.
EndWord { stop_time: f64, batch_idx: usize },
}
impl From<AsrMsg> for AsrEvent {
fn from(m: AsrMsg) -> Self {
match m {
AsrMsg::Step { step_idx, prs } => AsrEvent::Step { step_idx, prs },
AsrMsg::Word {
tokens,
start_time,
batch_idx,
} => AsrEvent::Word {
tokens,
text: None,
start_time,
batch_idx,
},
AsrMsg::EndWord {
stop_time,
batch_idx,
} => AsrEvent::EndWord {
stop_time,
batch_idx,
},
}
}
}
/// Streaming STT engine.
pub struct Stt {
state: moshi::asr::State,
device: Device,
/// Buffer of incoming PCM samples awaiting the next 1920-sample frame.
pending: Vec<f32>,
/// SentencePiece tokenizer for detokenizing word-token sequences. None
/// when constructed without a tokenizer path; in that case `Word.text`
/// is `None` and callers see raw token IDs only.
tokenizer: Option<SentencePieceProcessor>,
#[allow(dead_code)]
tokenizer_path: Option<PathBuf>,
}
impl Stt {
/// Load the default 1B en/fr STT model from HuggingFace cache (downloads
/// on first run via `hf-hub`). No VAD heads.
pub fn load_default(device: &Device) -> Result<Self> {
Self::load_from_repo(REPO_KYUTAI_STT_1B, /* vad */ false, device)
}
/// Load the VAD-enabled variant `kyutai/stt-1b-en_fr-candle`. Same
/// backbone weights but with 4 extra heads exposed via Step events for
/// semantic end-of-turn detection.
pub fn load_default_with_vad(device: &Device) -> Result<Self> {
Self::load_from_repo(REPO_KYUTAI_STT_1B_VAD, /* vad */ true, device)
}
fn load_from_repo(repo: &str, vad: bool, device: &Device) -> Result<Self> {
let api = hf_hub::api::sync::Api::new()
.map_err(|e| CsmError::Config(format!("hf-hub init: {e}")))?;
let r = api.model(repo.to_string());
let model_path = r
.get(FILE_STT_MODEL)
.map_err(|e| CsmError::Config(format!("download {FILE_STT_MODEL}: {e}")))?;
let mimi_path = r
.get(FILE_STT_MIMI)
.map_err(|e| CsmError::Config(format!("download {FILE_STT_MIMI}: {e}")))?;
let tokenizer_path = r
.get(FILE_STT_TOKENIZER)
.map_err(|e| CsmError::Config(format!("download {FILE_STT_TOKENIZER}: {e}")))?;
Self::load_with_config(&model_path, &mimi_path, Some(&tokenizer_path), vad, device)
}
/// Load from explicit weight paths (no VAD).
pub fn load(
model: &Path,
mimi: &Path,
tokenizer: Option<&Path>,
device: &Device,
) -> Result<Self> {
Self::load_with_config(model, mimi, tokenizer, /* vad */ false, device)
}
/// Load from explicit weight paths with optional VAD heads. Pass
/// `vad = true` only when the safetensors actually contains
/// `extra_heads.X.weight` keys (e.g. `kyutai/stt-1b-en_fr-candle`).
pub fn load_with_config(
model: &Path,
mimi: &Path,
tokenizer: Option<&Path>,
vad: bool,
device: &Device,
) -> Result<Self> {
// Kyutai STT checkpoint is bf16. Use bf16 on accelerators (Metal
// supports bf16 in candle 0.9.1) and F32 on CPU. F16 on Metal
// produces all-pad outputs because the LM's RmsNorm overflows in
// some intermediate activations.
let dtype = match device {
Device::Cpu => DType::F32,
_ => DType::BF16,
};
let mimi = moshi::mimi::load(mimi.to_string_lossy().as_ref(), Some(32), device)
.map_err(|e| CsmError::Config(format!("moshi::mimi::load: {e}")))?;
let cfg = if vad {
config_stt_1b_en_fr_vad()
} else {
config_stt_1b_en_fr()
};
let lm = moshi::lm::load_lm_model(cfg, model, dtype, device)
.map_err(|e| CsmError::Config(format!("moshi::lm::load_lm_model: {e}")))?;
let state = moshi::asr::State::new(
/* batch_size */ 1,
ASR_DELAY_FRAMES,
/* temperature */ 0.0,
mimi,
lm,
)
.map_err(|e| CsmError::Config(format!("moshi::asr::State::new: {e}")))?;
let tokenizer_obj = match tokenizer {
Some(p) => Some(
SentencePieceProcessor::open(p)
.map_err(|e| CsmError::Config(format!("sentencepiece open: {e}")))?,
),
None => None,
};
Ok(Self {
state,
device: device.clone(),
pending: Vec::new(),
tokenizer: tokenizer_obj,
tokenizer_path: tokenizer.map(|p| p.to_path_buf()),
})
}
/// Pull the end-of-turn probability out of a [`AsrEvent::Step`] event's
/// `prs` field. Returns `None` if the event isn't a Step or the
/// VAD-enabled config wasn't loaded. The Kyutai delayed-streams
/// reference uses head index 2 (of 4); a probability above ~0.5
/// across multiple consecutive frames signals end-of-turn.
pub fn end_of_turn_probability(event: &AsrEvent) -> Option<f32> {
match event {
AsrEvent::Step { prs, .. } if prs.len() > VAD_EOT_HEAD_IDX => {
prs[VAD_EOT_HEAD_IDX].first().copied()
}
_ => None,
}
}
/// Detokenize a Word event's token IDs to text. Skips padding tokens
/// (id 3) and uses sentencepiece's built-in detok if a tokenizer was
/// loaded.
pub fn decode_word_text(&self, tokens: &[u32]) -> Option<String> {
let sp = self.tokenizer.as_ref()?;
let filtered: Vec<u32> = tokens
.iter()
.copied()
.filter(|&t| t > PADDING_TOKEN_ID)
.collect();
if filtered.is_empty() {
return Some(String::new());
}
sp.decode_piece_ids(&filtered).ok()
}
/// Reset the streaming state for a new utterance/session.
pub fn reset(&mut self) -> Result<()> {
self.state
.reset()
.map_err(|e| CsmError::Config(format!("reset: {e}")))?;
self.pending.clear();
Ok(())
}
/// Feed PCM samples (24 kHz, mono, f32 in [-1, 1]). The buffer is
/// chunked into 1920-sample frames internally; partial frames are
/// buffered until the next call. Emits any `AsrEvent`s produced by
/// the underlying state machine.
pub fn step_pcm(&mut self, samples: &[f32]) -> Result<Vec<AsrEvent>> {
self.pending.extend_from_slice(samples);
let mut events = Vec::new();
while self.pending.len() >= SAMPLES_PER_FRAME {
let frame: Vec<f32> = self.pending.drain(..SAMPLES_PER_FRAME).collect();
let pcm = Tensor::from_vec(frame, (1, 1, SAMPLES_PER_FRAME), &self.device)
.map_err(|e| CsmError::Config(format!("frame tensor: {e}")))?;
let mask = StreamMask::empty();
let msgs = self
.state
.step_pcm(pcm, None, &mask, |_, _, _| {})
.map_err(|e| CsmError::Config(format!("step_pcm: {e}")))?;
events.extend(msgs.into_iter().map(AsrEvent::from));
}
Ok(events)
}
/// End-of-stream flush. Drains any partial sub-frame buffer (zero-padded
/// to a full 1920-sample frame), then steps `ASR_DELAY_FRAMES` additional
/// silent frames so words emitted at LM step `t` for audio at frame
/// `t - ASR_DELAY_FRAMES` finally surface — without this the last ~480 ms
/// of speech (a couple of words on long utterances) trails off after the
/// caller stops feeding audio.
///
/// Cost: `ASR_DELAY_FRAMES + 1` extra `step_pcm` calls (≈ 7 × per-frame
/// compute on Metal). Quality fix only; not a latency optimization.
pub fn finish(&mut self) -> Result<Vec<AsrEvent>> {
let mut events = Vec::new();
let mask = StreamMask::empty();
if !self.pending.is_empty() {
self.pending.resize(SAMPLES_PER_FRAME, 0.0);
let frame = std::mem::take(&mut self.pending);
let pcm = Tensor::from_vec(frame, (1, 1, SAMPLES_PER_FRAME), &self.device)
.map_err(|e| CsmError::Config(format!("finish tensor: {e}")))?;
let msgs = self
.state
.step_pcm(pcm, None, &mask, |_, _, _| {})
.map_err(|e| CsmError::Config(format!("finish step: {e}")))?;
events.extend(msgs.into_iter().map(AsrEvent::from));
}
let zeros = vec![0.0f32; SAMPLES_PER_FRAME];
for _ in 0..ASR_DELAY_FRAMES {
let pcm = Tensor::from_vec(zeros.clone(), (1, 1, SAMPLES_PER_FRAME), &self.device)
.map_err(|e| CsmError::Config(format!("flush tensor: {e}")))?;
let msgs = self
.state
.step_pcm(pcm, None, &mask, |_, _, _| {})
.map_err(|e| CsmError::Config(format!("flush step: {e}")))?;
events.extend(msgs.into_iter().map(AsrEvent::from));
}
Ok(events)
}
}
/// Energy-based VAD gate — classifies a 24 kHz PCM slice as
/// speech/silence by RMS amplitude. Independent of `Stt`'s semantic
/// VAD (head-2 from the `kyutai/stt-1b-en_fr-candle` checkpoint, used
/// for end-of-turn); this is a per-chunk silence detector for the
/// receive-loop gate.
///
/// **Why energy and not Silero V5?** Silero V5 ships via the `ort`
/// ONNX runtime, which links a different protobuf version (3.21) than
/// `sentencepiece-sys` (3.14, used by Kyutai STT for word
/// detokenization). The two crates panic at process startup with
/// `libprotobuf FATAL ... version verification failed`. Phase 8.1.3
/// hit this on the first server boot. Energy VAD avoids the runtime
/// entirely — pure Rust, ~30 LOC.
///
/// **Quality tradeoff.** Energy VAD catches obvious silence (room
/// tone, pauses between words) but misses quiet speech (whispering,
/// distant speakers). For mic-distance voice loops this catches ~70-
/// 80% of what Silero V5 would, at zero linkage risk. If you need
/// better recall on quiet speech, port Silero V5 weights to candle
/// natively (deferred work) or run it in a sidecar process.
///
/// Real-world voice-agent audio is 30-50% silence (typing pauses,
/// breathing, room tone). Skipping STT on those chunks yields
/// proportional `recv_phase` reduction without altering transcript
/// quality.
pub struct VadGate {
/// RMS threshold in [0, 1] (post-normalization to f32 PCM range).
/// Practical defaults: 0.005-0.02 for typical mic audio.
threshold_rms: f32,
pub skipped_chunks: u64,
pub total_chunks: u64,
}
impl VadGate {
pub fn new(threshold_rms: f32) -> Self {
Self {
threshold_rms,
skipped_chunks: 0,
total_chunks: 0,
}
}
/// Returns `true` if the slice's RMS amplitude is at or above the
/// silence threshold. Pure-Rust, branchless inner loop.
pub fn is_speech(&mut self, samples_24k: &[f32]) -> bool {
self.total_chunks += 1;
if samples_24k.is_empty() {
return true; // empty input — treat as speech (safe default).
}
let mut sum_sq = 0.0f64;
for &s in samples_24k {
sum_sq += (s as f64) * (s as f64);
}
let rms = (sum_sq / samples_24k.len() as f64).sqrt() as f32;
let is_speech = rms >= self.threshold_rms;
if !is_speech {
self.skipped_chunks += 1;
}
is_speech
}
/// Fraction of chunks classified as silence so far. For /metrics
/// reporting.
pub fn silence_fraction(&self) -> f64 {
if self.total_chunks == 0 {
0.0
} else {
self.skipped_chunks as f64 / self.total_chunks as f64
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn asr_event_conversion() {
let m = AsrMsg::Word {
tokens: vec![1, 2, 3],
start_time: 1.5,
batch_idx: 0,
};
let e: AsrEvent = m.into();
match e {
AsrEvent::Word {
tokens,
text,
start_time,
batch_idx,
} => {
assert_eq!(tokens, vec![1, 2, 3]);
assert!(text.is_none()); // detok not wired yet
assert!((start_time - 1.5).abs() < 1e-9);
assert_eq!(batch_idx, 0);
}
_ => panic!("expected Word event"),
}
}
#[test]
fn asr_event_endword_passthrough() {
let m = AsrMsg::EndWord {
stop_time: 2.0,
batch_idx: 0,
};
let e: AsrEvent = m.into();
assert!(matches!(e, AsrEvent::EndWord { stop_time, .. } if (stop_time - 2.0).abs() < 1e-9));
}
#[test]
fn end_of_turn_probability_extracts_head_2() {
// 4 heads, each with one prob value (matches moshi 0.6.4 emission).
let event = AsrEvent::Step {
step_idx: 10,
prs: vec![vec![0.1], vec![0.2], vec![0.7], vec![0.05]],
};
let pr = Stt::end_of_turn_probability(&event).expect("VAD prob");
assert!((pr - 0.7).abs() < 1e-6);
}
#[test]
fn end_of_turn_probability_none_when_no_extra_heads() {
let event = AsrEvent::Step {
step_idx: 1,
prs: vec![],
};
assert!(Stt::end_of_turn_probability(&event).is_none());
let event = AsrEvent::Word {
tokens: vec![5],
text: None,
start_time: 0.0,
batch_idx: 0,
};
assert!(Stt::end_of_turn_probability(&event).is_none());
}
}