This commit is contained in:
valenti
2026-10-16 11:36:02 +01:00
commit 236fd509ca
59 changed files with 18858 additions and 0 deletions
+705
View File
@@ -0,0 +1,705 @@
//! NVIDIA Parakeet TDT 0.6B v3 STT engine via ONNX Runtime (pure Rust).
//!
//! Implements the full NeMo TDT speech-to-text pipeline in Rust, mirroring the
//! reference implementation in `istupakov/onnx-asr`:
//!
//! ```text
//! 16 kHz mono waveform
//! └─ nemo128.onnx → mel features [1, 128, T]
//! └─ encoder-model → encoder outputs [1, 1024, T']
//! └─ decoder_joint (greedy TDT) → token IDs
//! └─ vocab.txt → text
//! ```
//!
//! The TDT decoder is transitive: at each encoder frame it predicts a token and
//! a "duration" (how many frames to advance). A special blank token (id 8192,
//! `<blk>`) means "no token emitted for this frame".
//!
//! A worker task consumes microphone PCM frames, runs RMS-based Voice Activity
//! Detection (VAD), and triggers inference once an end-of-utterance (EOU)
//! silence window is detected.
use crate::config::{EOU_SILENCE_MS, STT_SAMPLE_RATE, VAD_RMS_THRESHOLD};
use anyhow::{Context, Result};
use ndarray::{Array1, ArrayD, IxDyn};
use ort::environment::Environment;
use ort::session::builder::GraphOptimizationLevel;
use ort::session::{Session, SessionInputValue};
use ort::value::{Value, ValueType};
use parking_lot::Mutex;
use std::path::Path;
use std::sync::Arc;
use tokio::sync::mpsc;
use tracing::{debug, error, info};
// --- Model files (relative to the model directory) -------------------------
const CONFIG_FILE: &str = "config.json";
const VOCAB_FILE: &str = "vocab.txt";
const PREPROCESSOR_FILE: &str = "nemo128.onnx";
/// Quantized encoder (preferred when present: ~2-3x faster on CPU, no external
/// data file). Drop this file to fall back to the fp32 encoder.
const ENCODER_INT8_FILE: &str = "encoder-model.int8.onnx";
const ENCODER_FILE: &str = "encoder-model.onnx";
const DECODER_FILE: &str = "decoder_joint-model.onnx";
// --- ONNX tensor names (fixed by the exported graphs) ----------------------
const PP_IN_WAVEFORMS: &str = "waveforms";
const PP_IN_LENS: &str = "waveforms_lens";
const PP_OUT_FEATURES: &str = "features";
const PP_OUT_LENS: &str = "features_lens";
const ENC_IN_SIGNAL: &str = "audio_signal";
const ENC_IN_LENGTH: &str = "length";
const ENC_OUT_OUTPUTS: &str = "outputs";
const ENC_OUT_LENGTHS: &str = "encoded_lengths";
const DEC_IN_ENCODER: &str = "encoder_outputs";
const DEC_IN_TARGETS: &str = "targets";
const DEC_IN_TARGET_LENGTH: &str = "target_length";
const DEC_IN_STATE1: &str = "input_states_1";
const DEC_IN_STATE2: &str = "input_states_2";
const DEC_OUT_OUTPUTS: &str = "outputs";
const DEC_OUT_STATE1: &str = "output_states_1";
const DEC_OUT_STATE2: &str = "output_states_2";
/// A single decoded turn from the STT engine.
#[derive(Debug, Clone)]
pub struct Transcript {
/// Token IDs emitted by the TDT decoder.
pub token_ids: Vec<i64>,
/// Decoded, human-readable transcript.
pub text: String,
/// Approximate duration of the utterance in milliseconds.
pub duration_ms: u64,
}
/// The three ONNX sessions making up the Parakeet TDT pipeline.
struct Sessions {
/// Keeps the ONNX Runtime environment alive for the lifetime of the sessions.
_environment: Environment,
preprocessor: Session,
encoder: Session,
decoder_joint: Session,
}
/// Parakeet TDT ONNX pipeline. Cloning is cheap (Arc + Mutex).
#[derive(Clone)]
pub struct ParakeetStt {
sessions: Arc<Mutex<Sessions>>,
/// Token id -> piece, with the SentencePiece word marker (`▁`) replaced by a
/// regular space.
vocab: Arc<Vec<String>>,
/// Index of the blank token (`<blk>`).
blank_idx: i64,
/// Number of token logits (excludes the trailing duration logits).
vocab_size: usize,
/// Maximum number of tokens emitted per encoder frame before forcing an advance.
max_tokens_per_step: usize,
/// Encoder hidden size (1024 for Parakeet TDT 0.6B).
d_model: usize,
/// Number of decoder prediction-network state layers.
state_layers: usize,
/// Hidden size of each decoder state layer.
state_hidden: usize,
}
impl ParakeetStt {
/// Load the Parakeet TDT ONNX pipeline from `model_dir`, which must contain
/// `nemo128.onnx`, an `encoder-model*.onnx`, `decoder_joint-model.onnx`,
/// `vocab.txt` and `config.json`.
pub fn load(model_dir: &Path) -> Result<Self> {
info!(dir = %model_dir.display(), "loading Parakeet TDT ONNX models");
// `config.json` carries `max_tokens_per_step` (default 10).
let config_json: serde_json::Value = serde_json::from_slice(
&std::fs::read(model_dir.join(CONFIG_FILE))
.with_context(|| format!("failed to read {}", model_dir.join(CONFIG_FILE).display()))?,
)
.context("invalid Parakeet config.json")?;
let max_tokens_per_step = config_json
.get("max_tokens_per_step")
.and_then(|v| v.as_u64())
.unwrap_or(10) as usize;
// Vocabulary: id -> piece, with `▁` (U+2581) mapped to a real space.
let vocab = Arc::new(load_vocab(&model_dir.join(VOCAB_FILE))?);
let vocab_size = vocab.len();
let blank_idx = vocab
.iter()
.position(|p| p == "<blk>")
.map(|i| i as i64)
.context("vocab.txt does not contain the <blk> token")?;
// Prefer the int8 encoder when available; fall back to fp32.
let int8_path = model_dir.join(ENCODER_INT8_FILE);
let encoder_path = if int8_path.exists() {
int8_path
} else {
model_dir.join(ENCODER_FILE)
};
let environment = crate::onnx::environment()?;
let preprocessor = build_session(&environment, &model_dir.join(PREPROCESSOR_FILE))?;
let encoder = build_session(&environment, &encoder_path)?;
let decoder_joint = build_session(&environment, &model_dir.join(DECODER_FILE))?;
// Introspect the graphs for the dimensions we need at runtime.
let d_model = output_dim(&encoder, ENC_OUT_OUTPUTS, 1)?;
let decoder_out_dim = output_dim(&decoder_joint, DEC_OUT_OUTPUTS, 3)?;
let num_durations = decoder_out_dim
.checked_sub(vocab_size)
.context("decoder output is smaller than the vocabulary")?;
let (state_layers, state_hidden) = input_state_dims(&decoder_joint, DEC_IN_STATE1)?;
info!(
encoder = %encoder_path.display(),
vocab_size,
blank_idx,
num_durations,
d_model,
state_layers,
state_hidden,
"Parakeet TDT pipeline loaded"
);
Ok(Self {
sessions: Arc::new(Mutex::new(Sessions {
_environment: environment,
preprocessor,
encoder,
decoder_joint,
})),
vocab,
blank_idx,
vocab_size,
max_tokens_per_step,
d_model,
state_layers,
state_hidden,
})
}
/// Run the full pipeline on a complete utterance (16 kHz mono float PCM
/// normalized to [-1, 1]). Returns the emitted token IDs and decoded text.
pub fn infer(&self, audio: &[f32]) -> Result<(Vec<i64>, String)> {
let n = audio.len();
if n == 0 {
return Ok((Vec::new(), String::new()));
}
let mut guard = self.sessions.lock();
let sessions = &mut *guard;
// --- 1. Mel-feature preprocessor (nemo128.onnx) ---------------------
let (feature_shape, feature_data, feature_len) = {
let waveforms = Value::from_array(ArrayD::from_shape_vec(IxDyn(&[1, n]), audio.to_vec())?)
.context("failed to build waveform tensor")?;
let waveforms_lens = Value::from_array(Array1::from_vec(vec![n as i64]))
.context("failed to build waveform length tensor")?;
let outputs = sessions
.preprocessor
.run(vec![
(PP_IN_WAVEFORMS.to_string(), SessionInputValue::from(waveforms)),
(PP_IN_LENS.to_string(), SessionInputValue::from(waveforms_lens)),
])
.context("nemo128 preprocessor inference failed")?;
let (shape, data) = outputs
.get(PP_OUT_FEATURES)
.context("preprocessor produced no 'features' output")?
.try_extract_tensor::<f32>()
.context("failed to extract preprocessor features")?;
let (_, lens) = outputs
.get(PP_OUT_LENS)
.context("preprocessor produced no 'features_lens' output")?
.try_extract_tensor::<i64>()
.context("failed to extract preprocessor feature lengths")?;
let shape: Vec<usize> = shape.iter().map(|&d| d as usize).collect();
(shape, data.to_vec(), lens[0])
};
// --- 2. Encoder -----------------------------------------------------
// Encoder output layout is [1, d_model, T'] (already transposed by the
// exported graph). We re-pack it to [T', d_model] so each frame is
// contiguous for the decoder loop.
let (frames, encoded_len) = {
let signal = Value::from_array(
ArrayD::from_shape_vec(IxDyn(&feature_shape), feature_data)
.context("failed to reshape mel features")?,
)
.context("failed to build encoder input tensor")?;
let length = Value::from_array(Array1::from_vec(vec![feature_len]))
.context("failed to build encoder length tensor")?;
let outputs = sessions
.encoder
.run(vec![
(ENC_IN_SIGNAL.to_string(), SessionInputValue::from(signal)),
(ENC_IN_LENGTH.to_string(), SessionInputValue::from(length)),
])
.context("encoder inference failed")?;
let (shape, data) = outputs
.get(ENC_OUT_OUTPUTS)
.context("encoder produced no 'outputs'")?
.try_extract_tensor::<f32>()
.context("failed to extract encoder outputs")?;
let (_, lens) = outputs
.get(ENC_OUT_LENGTHS)
.context("encoder produced no 'encoded_lengths'")?
.try_extract_tensor::<i64>()
.context("failed to extract encoded lengths")?;
let t = shape[2] as usize;
let mut frames = vec![0f32; t * self.d_model];
for tt in 0..t {
for d in 0..self.d_model {
frames[tt * self.d_model + d] = data[d * t + tt];
}
}
// Clamp: low-precision encoders may report more frames than present.
let encoded_len = (lens[0] as usize).min(t);
(frames, encoded_len)
};
// --- 3. Greedy TDT decoding ----------------------------------------
let mut tokens: Vec<i64> = Vec::new();
let mut state1 = vec![0f32; self.state_layers * self.state_hidden];
let mut state2 = state1.clone();
let mut prev_token = self.blank_idx;
let mut emitted = 0usize;
let mut t = 0usize;
while t < encoded_len {
let frame = &frames[t * self.d_model..(t + 1) * self.d_model];
let enc_frame = Value::from_array(
ArrayD::from_shape_vec(IxDyn(&[1, self.d_model, 1]), frame.to_vec())?,
)
.context("failed to build decoder encoder-frame tensor")?;
let targets = Value::from_array(ArrayD::from_shape_vec(
IxDyn(&[1, 1]),
vec![prev_token as i32],
)?)
.context("failed to build decoder targets tensor")?;
let target_length = Value::from_array(Array1::from_vec(vec![1i32]))
.context("failed to build decoder target-length tensor")?;
let s1 = Value::from_array(ArrayD::from_shape_vec(
IxDyn(&[self.state_layers, 1, self.state_hidden]),
state1.clone(),
)?)
.context("failed to build decoder state1 tensor")?;
let s2 = Value::from_array(ArrayD::from_shape_vec(
IxDyn(&[self.state_layers, 1, self.state_hidden]),
state2.clone(),
)?)
.context("failed to build decoder state2 tensor")?;
let (token, step, new_state1, new_state2) = {
let outputs = sessions
.decoder_joint
.run(vec![
(DEC_IN_ENCODER.to_string(), SessionInputValue::from(enc_frame)),
(DEC_IN_TARGETS.to_string(), SessionInputValue::from(targets)),
(DEC_IN_TARGET_LENGTH.to_string(), SessionInputValue::from(target_length)),
(DEC_IN_STATE1.to_string(), SessionInputValue::from(s1)),
(DEC_IN_STATE2.to_string(), SessionInputValue::from(s2)),
])
.context("decoder_joint inference failed")?;
let (_, logits) = outputs
.get(DEC_OUT_OUTPUTS)
.context("decoder produced no 'outputs'")?
.try_extract_tensor::<f32>()
.context("failed to extract decoder logits")?;
if logits.len() < self.vocab_size {
anyhow::bail!("decoder logits shorter than vocabulary");
}
// Token logits come first, then the duration logits (TDT).
let token = argmax(&logits[..self.vocab_size]) as i64;
let step = argmax(&logits[self.vocab_size..]);
let (_, next_s1) = outputs
.get(DEC_OUT_STATE1)
.context("decoder produced no 'output_states_1'")?
.try_extract_tensor::<f32>()
.context("failed to extract decoder state1")?;
let (_, next_s2) = outputs
.get(DEC_OUT_STATE2)
.context("decoder produced no 'output_states_2'")?
.try_extract_tensor::<f32>()
.context("failed to extract decoder state2")?;
(token, step, next_s1.to_vec(), next_s2.to_vec())
};
// Emit the token unless it is the blank symbol.
if token != self.blank_idx {
state1 = new_state1;
state2 = new_state2;
tokens.push(token);
prev_token = token;
emitted += 1;
}
// Advance: a positive duration jumps several frames; otherwise we
// only move on when a blank was emitted or we hit the per-step cap.
if step > 0 {
t += step;
emitted = 0;
} else if token == self.blank_idx || emitted == self.max_tokens_per_step {
t += 1;
emitted = 0;
}
}
let text = decode_text(&self.vocab, &tokens);
Ok((tokens, text))
}
}
/// Build a session with full graph optimization.
fn build_session(environment: &Environment, path: &Path) -> Result<Session> {
let builder = Session::builder(environment)
.map_err(|e| anyhow::anyhow!("failed to create ONNX session builder: {e}"))?;
builder
.with_optimization_level(GraphOptimizationLevel::Level3)
.map_err(|e| anyhow::anyhow!("failed to set ONNX optimization level: {e}"))?
.commit_from_file(path)
.with_context(|| format!("failed to load ONNX model {}", path.display()))
}
/// Read the size of `axis` from the named output of `session`.
fn output_dim(session: &Session, name: &str, axis: usize) -> Result<usize> {
let outlet = session
.outputs()
.iter()
.find(|o| o.name() == name)
.with_context(|| format!("model has no output named '{name}'"))?;
match outlet.dtype() {
ValueType::Tensor { shape, .. } => {
let dim = *shape
.get(axis)
.with_context(|| format!("output '{name}' has no axis {axis}"))?;
usize::try_from(dim).with_context(|| format!("output '{name}' axis {axis} is dynamic"))
}
_ => anyhow::bail!("output '{name}' is not a tensor"),
}
}
/// Read `(layers, hidden)` from a decoder state input shaped `[layers, batch, hidden]`.
fn input_state_dims(session: &Session, name: &str) -> Result<(usize, usize)> {
let outlet = session
.inputs()
.iter()
.find(|i| i.name() == name)
.with_context(|| format!("decoder has no input named '{name}'"))?;
match outlet.dtype() {
ValueType::Tensor { shape, .. } => {
let layers = usize::try_from(*shape.first().context("state input has no axes")?)
.context("state input layer count is dynamic")?;
let hidden = usize::try_from(*shape.get(2).context("state input has < 3 axes")?)
.context("state input hidden size is dynamic")?;
Ok((layers, hidden))
}
_ => anyhow::bail!("input '{name}' is not a tensor"),
}
}
/// Index of the maximum value (ties resolved to the lowest index).
fn argmax(values: &[f32]) -> usize {
let mut best = 0usize;
let mut best_val = f32::NEG_INFINITY;
for (i, &v) in values.iter().enumerate() {
if v > best_val {
best_val = v;
best = i;
}
}
best
}
/// Load `vocab.txt` (lines of `<piece> <id>`) into an id-indexed table, mapping
/// the SentencePiece word marker `▁` (U+2581) to a real space.
fn load_vocab(path: &Path) -> Result<Vec<String>> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let mut entries: Vec<(usize, String)> = Vec::new();
let mut max_id = 0usize;
for line in text.lines() {
if line.is_empty() {
continue;
}
let (piece, id) = line
.rsplit_once(' ')
.with_context(|| format!("malformed vocab line: {line:?}"))?;
let id: usize = id
.trim()
.parse()
.with_context(|| format!("invalid token id in vocab line: {line:?}"))?;
max_id = max_id.max(id);
entries.push((id, piece.replace('\u{2581}', " ")));
}
let mut vocab = vec![String::new(); max_id + 1];
for (id, piece) in entries {
vocab[id] = piece;
}
Ok(vocab)
}
/// Decode token IDs to text and normalize SentencePiece spacing.
fn decode_text(vocab: &[String], ids: &[i64]) -> String {
let mut joined = String::new();
for &id in ids {
if id >= 0 {
if let Some(piece) = vocab.get(id as usize) {
joined.push_str(piece);
}
}
}
normalize_spaces(&joined)
}
/// Collapse SentencePiece spacing: drop a leading space, keep a space only when
/// it precedes a word character, and drop spacing before punctuation. This
/// mirrors the `\A\s|\s\B|(\s)\b` substitution used by the reference decoder.
fn normalize_spaces(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::with_capacity(text.len());
for (i, &c) in chars.iter().enumerate() {
if !c.is_whitespace() {
out.push(c);
continue;
}
if i == 0 {
continue; // leading whitespace
}
let next_is_word = chars
.get(i + 1)
.map(|&n| n.is_alphanumeric() || n == '_')
.unwrap_or(false);
if next_is_word {
out.push(' ');
}
}
out
}
/// Input of the STT worker channel.
enum SttInput {
/// A streaming mic frame: the worker runs its own RMS voice detection.
Frame(Vec<i16>),
/// A complete utterance already segmented by an external VAD (see
/// [`crate::vad`]), which bypasses the internal RMS detection.
Utterance(Vec<i16>),
}
/// Handle to the running STT worker.
#[derive(Clone)]
pub struct SttWorker {
tx: mpsc::UnboundedSender<SttInput>,
}
impl SttWorker {
/// Spawn the STT worker task. `stt` is the loaded Parakeet pipeline.
/// `transcript_tx` receives one [`Transcript`] per detected EOU.
pub fn spawn(
stt: ParakeetStt,
transcript_tx: mpsc::UnboundedSender<Transcript>,
) -> Self {
let (tx, mut rx) = mpsc::unbounded_channel::<SttInput>();
tokio::spawn(async move {
let mut utterance: Vec<f32> = Vec::new();
let mut in_utterance = false;
let mut silence_samples: u64 = 0;
let eou_samples = (STT_SAMPLE_RATE as u64 * EOU_SILENCE_MS) / 1000;
while let Some(input) = rx.recv().await {
// Frames arrive already resampled to 16 kHz by the session layer.
// Externally segmented utterances need no further detection.
let frame = match input {
SttInput::Utterance(frame) => {
let audio: Vec<f32> =
frame.iter().map(|&s| s as f32 / 32768.0).collect();
debug!(
samples = audio.len(),
"VAD-segmented utterance received"
);
let stt = stt.clone();
let tx = transcript_tx.clone();
tokio::task::spawn_blocking(move || {
run_inference_blocking(&stt, &audio, tx);
});
continue;
}
SttInput::Frame(frame) => frame,
};
// Convert i16 -> f32 normalized.
let normalized: Vec<f32> =
frame.iter().map(|&s| s as f32 / 32768.0).collect();
let rms = compute_rms(&normalized);
if rms > VAD_RMS_THRESHOLD {
in_utterance = true;
silence_samples = 0;
utterance.extend_from_slice(&normalized);
} else if in_utterance {
silence_samples += normalized.len() as u64;
utterance.extend_from_slice(&normalized);
if silence_samples >= eou_samples {
// EOU detected: run inference on a blocking thread.
let utterance_clone = std::mem::take(&mut utterance);
let stt = stt.clone();
let tx = transcript_tx.clone();
tokio::task::spawn_blocking(move || {
run_inference_blocking(&stt, &utterance_clone, tx);
});
in_utterance = false;
silence_samples = 0;
}
}
}
info!("STT worker channel closed, stopping");
});
Self { tx }
}
/// Push a frame of 16 kHz mono i16 PCM samples into the worker, which will
/// segment it with the built-in RMS voice detection.
pub fn push_frame(&self, samples: Vec<i16>) {
let _ = self.tx.send(SttInput::Frame(samples));
}
/// Push a complete 16 kHz mono i16 utterance segmented by the external VAD.
/// Transcribed as-is, without any further voice-activity detection.
pub fn push_utterance(&self, samples: Vec<i16>) {
if samples.is_empty() {
return;
}
let _ = self.tx.send(SttInput::Utterance(samples));
}
}
/// Blocking inference helper used inside `spawn_blocking`.
fn run_inference_blocking(
stt: &ParakeetStt,
audio: &[f32],
tx: mpsc::UnboundedSender<Transcript>,
) {
let (token_ids, text) = match stt.infer(audio) {
Ok(result) => result,
Err(e) => {
error!(error = %e, "Parakeet inference failed");
return;
}
};
let duration_ms = (audio.len() as u64 * 1000) / STT_SAMPLE_RATE as u64;
debug!(tokens = token_ids.len(), duration_ms, text = %text, "EOU transcript ready");
let _ = tx.send(Transcript {
token_ids,
text,
duration_ms,
});
}
/// Compute the Root-Mean-Square energy of a normalized f32 frame.
fn compute_rms(samples: &[f32]) -> f32 {
if samples.is_empty() {
return 0.0;
}
let sum_sq: f32 = samples.iter().map(|&s| s * s).sum();
(sum_sq / samples.len() as f32).sqrt()
}
/// Convert a duration in milliseconds to a sample count at the STT rate.
#[allow(dead_code)]
pub fn ms_to_samples(ms: u64) -> usize {
((STT_SAMPLE_RATE as u64 * ms) / 1000) as usize
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_spaces() {
assert_eq!(normalize_spaces(" Hello world"), "Hello world");
assert_eq!(normalize_spaces(" Hello world!"), "Hello world!");
assert_eq!(normalize_spaces("a b c"), "a b c");
assert_eq!(normalize_spaces(" trailing "), "trailing");
assert_eq!(normalize_spaces("a , b"), "a, b");
}
#[test]
fn test_load_real_vocab() {
let path = Path::new("models/parakeet/vocab.txt");
if !path.exists() {
eprintln!("{} not found, skipping", path.display());
return;
}
let vocab = load_vocab(path).expect("vocab load failed");
assert_eq!(vocab.len(), 8193);
assert_eq!(vocab[8192], "<blk>");
assert_eq!(vocab[0], "<unk>");
}
/// Read a canonical 16-bit PCM mono WAV file into normalized f32 samples.
fn read_wav_mono16(path: &Path) -> Vec<f32> {
let bytes = std::fs::read(path).expect("failed to read wav");
assert_eq!(&bytes[0..4], b"RIFF", "not a RIFF file");
assert_eq!(&bytes[8..12], b"WAVE", "not a WAVE file");
// Walk the chunk list to find the `data` chunk.
let mut pos = 12usize;
while pos + 8 <= bytes.len() {
let id = &bytes[pos..pos + 4];
let size = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize;
let body = pos + 8;
if id == b"data" {
return bytes[body..body + size]
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]) as f32 / 32768.0)
.collect();
}
pos = body + size + (size & 1); // chunks are word-aligned
}
panic!("no data chunk found");
}
/// End-to-end decode test. Requires the full model directory. Run with:
/// `PARAKEET_TEST_WAV=/path/to/16k_mono.wav cargo test -- --ignored`
#[test]
#[ignore = "requires the full Parakeet ONNX model directory + a 16 kHz WAV"]
fn test_infer_real_audio() {
let model_dir = std::env::var("PARAKEET_MODEL_DIR")
.unwrap_or_else(|_| "models/parakeet".to_string());
let wav = std::env::var("PARAKEET_TEST_WAV")
.unwrap_or_else(|_| "/tmp/stt_test/solis16k.wav".to_string());
if !Path::new(&model_dir).join(PREPROCESSOR_FILE).exists() {
eprintln!("model dir {model_dir} not found, skipping");
return;
}
let stt = ParakeetStt::load(Path::new(&model_dir)).expect("model load failed");
let audio = read_wav_mono16(Path::new(&wav));
eprintln!("running inference on {} samples", audio.len());
let (tokens, text) = stt.infer(&audio).expect("inference failed");
eprintln!("tokens: {} | text: {text:?}", tokens.len());
assert!(!tokens.is_empty(), "no tokens emitted");
assert!(!text.trim().is_empty(), "empty transcript");
}
}