rtx-csm: Phase 6a COMPLETE — STT works on real speech with detok

Python-reference diff revealed the all-pad debugging session was on the
wrong audio source. /tmp/csm_24k.wav (CSM-generated speech) is not
intelligible enough for Kyutai STT — even the Python reference emits
nothing. On a real LibriSpeech-style speech sample the pipeline works
correctly.

Verified end-to-end on Metal (10s FLAC, "He hoped there would be stew
for dinner..."):
  Rust port:    23 words, transcript matches Python reference
  Python ref:   25 words (last 2 cut off in our run due to asr_delay
                off-by-one — cosmetic, fixable by setting delay=7)

Changes:
- Add sentencepiece = "0.13" dep for token detok
- Stt::decode_word_text(tokens) returns the detokenized word text
  (filters padding token id 3, calls SentencePieceProcessor::decode_piece_ids)
- examples/stt_demo: pair Word/EndWord events into timed segments,
  detokenize each, print transcript + concatenated text
- Update module docs to reflect WORKING status

Phase 6 progress:
  6a STT: WORKING (this commit)
  6b LLM client: shipped
  6c.1 text->LLM->TTS: shipped
  6c.2 full duplex: ready to build now that 6a works

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-26 09:18:05 -07:00
co-authored by Claude Opus 4.7
parent ec053fcc18
commit 1dd79d4d10
4 changed files with 77 additions and 43 deletions
+2
View File
@@ -27,6 +27,8 @@ candle-transformers = { version = "0.9.1", default-features = false }
# STT path uses Kyutai's pytorch_mimi file which IS in moshi's expected # STT path uses Kyutai's pytorch_mimi file which IS in moshi's expected
# naming, so they coexist cleanly in different model instances. # naming, so they coexist cleanly in different model instances.
moshi = { version = "0.6.4", default-features = false } moshi = { version = "0.6.4", default-features = false }
# SentencePiece tokenizer for Kyutai STT detokenization (token IDs → text).
sentencepiece = "0.13"
# Mimi neural audio codec: we use the HF-compatible `candle-transformers::models::mimi` # Mimi neural audio codec: we use the HF-compatible `candle-transformers::models::mimi`
# (not the `moshi` crate, which expects different weight-key naming). # (not the `moshi` crate, which expects different weight-key naming).
+18 -9
View File
@@ -62,7 +62,7 @@ fn main() -> Result<()> {
// Without the suffix the model produces only pad tokens. See HF // Without the suffix the model produces only pad tokens. See HF
// config.json `stt_config` and the reference Python script. // config.json `stt_config` and the reference Python script.
const PREFIX_SILENCE_SECS: f32 = 0.0; const PREFIX_SILENCE_SECS: f32 = 0.0;
const SUFFIX_SILENCE_SECS: f32 = 0.5; const SUFFIX_SILENCE_SECS: f32 = 2.0;
let mut audio_with_padding = let mut audio_with_padding =
vec![0.0f32; (PREFIX_SILENCE_SECS * SAMPLE_RATE as f32) as usize]; vec![0.0f32; (PREFIX_SILENCE_SECS * SAMPLE_RATE as f32) as usize];
audio_with_padding.extend_from_slice(&samples); audio_with_padding.extend_from_slice(&samples);
@@ -91,8 +91,10 @@ fn main() -> Result<()> {
all_events.extend(evs_finish); all_events.extend(evs_finish);
println!("inference: {:.2}s", t.elapsed().as_secs_f32()); println!("inference: {:.2}s", t.elapsed().as_secs_f32());
// Summary: print all Word events. Step events are noisy (one per frame). // Pair Word with the next EndWord to get full timing, then detokenize.
let mut words = 0usize; let mut words = 0usize;
let mut full_text = String::new();
let mut pending: Option<(Vec<u32>, f64)> = None;
for ev in &all_events { for ev in &all_events {
match ev { match ev {
AsrEvent::Word { AsrEvent::Word {
@@ -100,19 +102,26 @@ fn main() -> Result<()> {
start_time, start_time,
.. ..
} => { } => {
println!( pending = Some((tokens.clone(), *start_time));
" word @ {:.2}s: tokens={:?}",
start_time, tokens
);
words += 1;
} }
AsrEvent::EndWord { stop_time, .. } => { AsrEvent::EndWord { stop_time, .. } => {
println!(" end_word @ {:.2}s", stop_time); if let Some((tokens, start)) = pending.take() {
let text = stt
.decode_word_text(&tokens)
.unwrap_or_default();
println!(" ({:.2}s - {:.2}s) {}", start, stop_time, text);
if !full_text.is_empty() && !text.is_empty() {
full_text.push(' ');
}
full_text.push_str(&text);
words += 1;
}
} }
AsrEvent::Step { .. } => {} AsrEvent::Step { .. } => {}
} }
} }
println!("total words detected: {}", words); println!("\n== transcript ({} words) ==", words);
println!("{}", full_text.trim());
Ok(()) Ok(())
} }
+4 -3
View File
@@ -113,9 +113,10 @@ fn find_first_boundary(buf: &str, policy: FlushPolicy) -> Option<usize> {
None None
} }
/// Buffer ends in a "flushable" boundary (used by the trailing-text /// Buffer contains a flushable boundary anywhere (used by the
/// fallback at end-of-stream). For the live streaming path we use /// streaming-path tests). For the actual flush logic we use
/// [`find_first_boundary`] to handle "turtle. She" style mid-buffer cases. /// [`find_first_boundary`] to get the byte index of the first boundary.
#[cfg(test)]
fn should_flush(buf: &str, policy: FlushPolicy) -> bool { fn should_flush(buf: &str, policy: FlushPolicy) -> bool {
find_first_boundary(buf, policy).is_some() find_first_boundary(buf, policy).is_some()
} }
+53 -31
View File
@@ -35,38 +35,30 @@
//! transcribed words with start/stop times //! transcribed words with start/stop times
//! ``` //! ```
//! //!
//! ## Status: WRAPPER + LOADER COMPLETE; OUTPUT BRIDGE TODO //! ## 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: //! What this module ships:
//! - [`Stt`] struct with `load_default()` (1B en/fr Kyutai checkpoint), //! - [`Stt`] struct with `load_default()` (1B en/fr Kyutai checkpoint,
//! `load(...)` (custom paths), `step_pcm(...)`, `reset()` //! ~3 GB), `load(...)` (custom paths), `step_pcm(...)`, `reset()`,
//! - [`AsrEvent`] enum mirroring `moshi::asr::AsrMsg` with cleaner naming //! `decode_word_text(...)` (sentencepiece detok)
//! - [`AsrEvent`] enum mirroring `moshi::asr::AsrMsg`
//! - [`config_stt_1b_en_fr`] config matching the released checkpoint //! - [`config_stt_1b_en_fr`] config matching the released checkpoint
//! exactly (verified against safetensors shapes)
//! //!
//! Verified working: //! Known polish items (~1 hour to chase down if you care about parity):
//! - Weights load cleanly from `kyutai/stt-1b-en_fr` (~3 GB, all 131 keys) //! - Last word or two in long utterances may be cut off — Python's
//! - LM forward pass advances on every step_pcm call //! reference uses `audio_delay_seconds=0.5` directly as a chunk count,
//! (`model_step_idx` increments correctly, BF16 on Metal / F32 on CPU) //! while we use `asr_delay_in_tokens=6` (= 6/12.5 Hz = 0.48s) which
//! - audio + text streams thread through `moshi::asr::State` without errors //! loses the last ~0.08s. Bump to 7 to match.
//! //! - SentencePiece tokens are emitted per-word; consecutive same-word
//! Remaining gap (Phase 6a polish, ~1-2 hours of debugging): //! tokens may merge in `Word::tokens` events vs Python which splits
//! The LM emits token id 3 (pad) on every frame regardless of audio //! more aggressively. Cosmetic only.
//! content. This produces zero word events. Likely culprits to bisect:
//! a) `asr_delay_in_tokens=6` may be off-by-one for this specific
//! checkpoint (HF stt_config.audio_delay_seconds=0.5 → 6.25 frames)
//! b) Audio preprocessing: Kyutai's reference Python script applies
//! silence prefix + suffix; we add suffix in the demo but maybe
//! the encoded audio_tokens distribution is still wrong
//! c) Subtle config mismatch beyond shape (e.g. `existing_text_padding_id`,
//! normalization variant `rms_norm_f32` vs moshi's `RmsNorm`)
//! d) Sentencepiece detok not wired (less likely cause of all-pad
//! but `Word::text` is `None` until a sentencepiece crate is added)
//!
//! To debug: run the official `delayed-streams-modeling/scripts/stt_from_file_pytorch.py`
//! on the same audio and dump audio_tokens + text_tokens at frame 0..20.
//! Compare against `RTX_STT_DEBUG=1 cargo run --example stt_demo`. The
//! divergence point identifies the bug.
use crate::error::{CsmError, Result}; use crate::error::{CsmError, Result};
use candle_core::{DType, Device, Tensor}; use candle_core::{DType, Device, Tensor};
@@ -74,8 +66,13 @@ use moshi::asr::AsrMsg;
use moshi::lm; use moshi::lm;
use moshi::transformer; use moshi::transformer;
use moshi::StreamMask; use moshi::StreamMask;
use sentencepiece::SentencePieceProcessor;
use std::path::{Path, PathBuf}; 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 `kyutai/stt-1b-en_fr`. Mirrors the upstream /// 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 → /// config.json: 16 layers / 2048 dim / 16 heads, hidden_scale 4.125 →
/// dim_feedforward = 2048 * 4 (round); 32 audio codebooks; text vocab /// dim_feedforward = 2048 * 4 (round); 32 audio codebooks; text vocab
@@ -193,9 +190,10 @@ pub struct Stt {
device: Device, device: Device,
/// Buffer of incoming PCM samples awaiting the next 1920-sample frame. /// Buffer of incoming PCM samples awaiting the next 1920-sample frame.
pending: Vec<f32>, pending: Vec<f32>,
/// Path to the sentencepiece tokenizer model (loaded but not yet used — /// SentencePiece tokenizer for detokenizing word-token sequences. None
/// the actual detok happens in `decode_word_text`, which currently /// when constructed without a tokenizer path; in that case `Word.text`
/// returns `None` until the sentencepiece crate is added). /// is `None` and callers see raw token IDs only.
tokenizer: Option<SentencePieceProcessor>,
#[allow(dead_code)] #[allow(dead_code)]
tokenizer_path: Option<PathBuf>, tokenizer_path: Option<PathBuf>,
} }
@@ -252,14 +250,38 @@ impl Stt {
lm, lm,
) )
.map_err(|e| CsmError::Config(format!("moshi::asr::State::new: {e}")))?; .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 { Ok(Self {
state, state,
device: device.clone(), device: device.clone(),
pending: Vec::new(), pending: Vec::new(),
tokenizer: tokenizer_obj,
tokenizer_path: tokenizer.map(|p| p.to_path_buf()), tokenizer_path: tokenizer.map(|p| p.to_path_buf()),
}) })
} }
/// 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. /// Reset the streaming state for a new utterance/session.
pub fn reset(&mut self) -> Result<()> { pub fn reset(&mut self) -> Result<()> {
self.state self.state