Files
rustytorch/crates/models/rtx-csm/examples/stt_demo.rs
T
osobhandClaude Opus 4.7 0f9cc122e9 rtx-csm: Phase 6a partial — Kyutai STT scaffold via moshi crate
Integrates the moshi crate (0.6.4, candle 0.9.1) for streaming STT.
Module + demo + custom config for kyutai/stt-1b-en_fr. Model loads
cleanly, LM forward pass advances (model_step_idx increments correctly),
but word events don't yet emit on a 10s CSM speech sample.

What works:
- moshi 0.6.4 added as dependency (candle 0.9.1, version-compatible)
- src/stt.rs wraps moshi::asr::State + moshi::lm + moshi::mimi
- Stt::load_default downloads kyutai/stt-1b-en_fr (~3 GB) from HF
- Custom config_stt_1b_en_fr() matching the released checkpoint:
  d_model=2048, num_layers=16, dim_feedforward=8192 (moshi's SwiGLU
  hidden = 11/4 * d_model = 5632 — verified vs safetensors), text vocab
  8001/8000, audio vocab 2049, 32 codebooks, no depformer
- AsrEvent enum + From<moshi::asr::AsrMsg> conversion
- examples/stt_demo.rs streams a WAV through the pipeline
- 2 unit tests for AsrEvent conversion

What needs more work:
- Word emission: 0 words detected on 10s of clean CSM speech, even
  though LM forward advances every frame. Likely culprits:
  a) asr_delay_in_tokens 6 vs HF stt_config.audio_delay_seconds=0.5
     (6.25 frames). Off-by-one possible.
  b) Sentencepiece detok not yet wired (tokens emitted but text=None).
  c) Subtle weight-key remap differences between moshi's expected
     layout and the released checkpoint that don't trip a shape check.
  d) renormalize/audio preprocessing mismatch.

Next step (Phase 6a polish): compare against the official
delayed-streams-modeling/scripts/stt_from_file_pytorch.py reference to
identify the missing piece. The integration framework is sound; only
the final LM-output-to-text-event step needs work.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-26 04:33:40 -07:00

100 lines
3.2 KiB
Rust

//! Streaming STT demo: transcribe a WAV via Kyutai's 1B en/fr model.
//!
//! First run downloads ~3 GB from `kyutai/stt-1b-en_fr` to the HF cache.
//!
//! Usage:
//! ```
//! cargo run -p rtx-csm --release --features metal --example stt_demo -- \
//! --in /tmp/csm_24k.wav
//! ```
//!
//! Note: until sentencepiece detok is wired (Phase 6a polish), the output
//! is raw token IDs per word. The first version is intentionally minimal —
//! demonstrates that the streaming pipeline is connected end to end.
use anyhow::Result;
use clap::Parser;
use rtx_csm::{audio_io, stt::{AsrEvent, Stt, SAMPLE_RATE}};
use std::path::PathBuf;
#[derive(Debug, Parser)]
#[command(name = "stt_demo")]
struct Cli {
/// Input WAV (any rate / channels — resampled to 24 kHz mono).
#[arg(long = "in")]
input: PathBuf,
/// Force CPU device.
#[arg(long)]
cpu: bool,
}
fn main() -> Result<()> {
tracing_subscriber::fmt().init();
let cli = Cli::parse();
let device = if cli.cpu {
candle_core::Device::Cpu
} else if candle_core::utils::metal_is_available() {
candle_core::Device::new_metal(0)?
} else {
candle_core::Device::Cpu
};
println!("device: {device:?}");
let t = std::time::Instant::now();
let mut stt = Stt::load_default(&device)?;
println!("loaded Kyutai STT 1B en/fr in {:.2}s", t.elapsed().as_secs_f32());
// Load WAV at 24 kHz mono (Mimi's expected input rate).
let samples = audio_io::load_mono_at_rate(&cli.input, SAMPLE_RATE)?;
println!(
"loaded {}: {} samples ({:.2}s @ {} Hz)",
cli.input.display(),
samples.len(),
samples.len() as f32 / SAMPLE_RATE as f32,
SAMPLE_RATE
);
// Stream the audio in 1-second chunks so we can observe streaming
// behavior (events arriving as the model processes).
let chunk_size = SAMPLE_RATE as usize;
let mut all_events: Vec<AsrEvent> = Vec::new();
let t = std::time::Instant::now();
for (i, chunk) in samples.chunks(chunk_size).enumerate() {
let evs = stt.step_pcm(chunk)?;
let n_step = evs.iter().filter(|e| matches!(e, AsrEvent::Step { .. })).count();
let n_word = evs.iter().filter(|e| matches!(e, AsrEvent::Word { .. })).count();
let n_end = evs.iter().filter(|e| matches!(e, AsrEvent::EndWord { .. })).count();
println!("[chunk {i}] events: step={n_step} word={n_word} endword={n_end}");
all_events.extend(evs);
}
let evs_finish = stt.finish()?;
all_events.extend(evs_finish);
println!("inference: {:.2}s", t.elapsed().as_secs_f32());
// Summary: print all Word events. Step events are noisy (one per frame).
let mut words = 0usize;
for ev in &all_events {
match ev {
AsrEvent::Word {
tokens,
start_time,
..
} => {
println!(
" word @ {:.2}s: tokens={:?}",
start_time, tokens
);
words += 1;
}
AsrEvent::EndWord { stop_time, .. } => {
println!(" end_word @ {:.2}s", stop_time);
}
AsrEvent::Step { .. } => {}
}
}
println!("total words detected: {}", words);
Ok(())
}