Skip to main content

studio_worker/engine/
parakeet.rs

1//! Streaming speech-to-text over parakeet-rs, kept loaded by the model
2//! host.  Two model families: Nemotron streaming (560 ms chunks,
3//! multilingual, punctuated) and Parakeet EOU (160 ms chunks, English,
4//! end-of-utterance aware).  The weights load once; every session opens
5//! a fresh utterance state over them.
6
7use crate::catalog::CatalogModel;
8use crate::engine::onnx_provision::{self, OrtFlavour};
9use crate::host::{LoadedModel, StreamingModel};
10use crate::stt_stream::session::StreamingTranscriber;
11use anyhow::{anyhow, Context, Result};
12use parakeet_rs::{ExecutionConfig, Nemotron, NemotronHandle, ParakeetEOU, ParakeetEOUHandle};
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16const TRACE_TARGET: &str = "studio_worker::engine::parakeet";
17
18/// Samples per Nemotron streaming step (560 ms at 16 kHz).
19pub const NEMOTRON_CHUNK: usize = 8960;
20/// Samples per Parakeet EOU step (160 ms at 16 kHz).
21pub const EOU_CHUNK: usize = 2560;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum StreamKind {
25    Nemotron,
26    Eou,
27}
28
29/// Which family a model's files are: EOU ships `tokenizer.json`, Nemotron
30/// a SentencePiece `tokenizer.model`.
31pub fn stream_kind<'a>(filenames: impl IntoIterator<Item = &'a str>) -> Option<StreamKind> {
32    let names: Vec<&str> = filenames.into_iter().collect();
33    if names.contains(&"tokenizer.model") {
34        Some(StreamKind::Nemotron)
35    } else if names.contains(&"tokenizer.json") {
36        Some(StreamKind::Eou)
37    } else {
38        None
39    }
40}
41
42/// Where a streaming model's files live: one directory per model, as the
43/// loaders read the whole directory.
44pub fn model_dir(models_root: &Path, model_id: &str) -> PathBuf {
45    models_root.join("stt").join(model_id)
46}
47
48enum Handle {
49    Nemotron(NemotronHandle),
50    Eou(ParakeetEOUHandle),
51}
52
53/// A streaming speech model held in memory by the model host.
54pub struct LoadedStream {
55    handle: Handle,
56}
57
58impl LoadedModel for LoadedStream {
59    fn as_any(&self) -> &dyn std::any::Any {
60        self
61    }
62
63    fn as_stream(&self) -> Option<&dyn StreamingModel> {
64        Some(self)
65    }
66}
67
68impl StreamingModel for LoadedStream {
69    fn open(&self) -> Result<Box<dyn StreamingTranscriber + '_>> {
70        Ok(match &self.handle {
71            Handle::Nemotron(h) => Box::new(NemotronStream(Nemotron::from_shared(h))),
72            Handle::Eou(h) => Box::new(EouStream(ParakeetEOU::from_shared(h))),
73        })
74    }
75}
76
77struct NemotronStream(Nemotron);
78
79impl StreamingTranscriber for NemotronStream {
80    fn chunk_samples(&self) -> usize {
81        NEMOTRON_CHUNK
82    }
83    fn step(&mut self, chunk: &[f32]) -> Result<String> {
84        Ok(self.0.transcribe_chunk(chunk)?)
85    }
86    fn reset(&mut self) {
87        self.0.reset();
88    }
89}
90
91struct EouStream(ParakeetEOU);
92
93impl StreamingTranscriber for EouStream {
94    fn chunk_samples(&self) -> usize {
95        EOU_CHUNK
96    }
97    fn step(&mut self, chunk: &[f32]) -> Result<String> {
98        Ok(self.0.transcribe(chunk, false)?)
99    }
100    /// Every session opens a fresh EOU state (`from_shared`), so there is
101    /// nothing to reset.
102    fn reset(&mut self) {}
103}
104
105/// Session options for the process's ONNX Runtime flavour.  On CUDA the
106/// provider is registered strictly: if it cannot load, the model fails
107/// to load rather than running on the CPU unnoticed.
108fn exec_config(flavour: OrtFlavour) -> ExecutionConfig {
109    let config = ExecutionConfig::new();
110    if flavour.is_cuda() {
111        config.with_custom_configure(|builder| {
112            Ok(builder
113                .with_execution_providers([ort::ep::CUDA::default().build().error_on_failure()])?)
114        })
115    } else {
116        config
117    }
118}
119
120/// Download (if needed) and load `model` for the host to keep resident.
121#[cfg_attr(coverage_nightly, coverage(off))]
122pub fn load_resident(models_root: &Path, model: &CatalogModel) -> Result<LoadedStream> {
123    let runtime = onnx_provision::ensure_runtime(models_root)?;
124    let dir = model_dir(models_root, &model.id);
125    for file in &model.source.files {
126        crate::engine::download::ensure_file(&dir, file)
127            .with_context(|| format!("downloading {} for {}", file.filename, model.id))?;
128    }
129    let kind =
130        stream_kind(model.source.files.iter().map(|f| f.filename.as_str())).ok_or_else(|| {
131            anyhow!(
132            "{}: a streaming speech model needs tokenizer.model (Nemotron) or tokenizer.json (EOU)",
133            model.id
134        )
135        })?;
136    if !runtime.flavour.is_cuda() {
137        tracing::warn!(
138            target: TRACE_TARGET,
139            op = "load",
140            model = %model.id,
141            flavour = runtime.flavour.name(),
142            "no CUDA runtime on this host; the speech model runs on the CPU"
143        );
144    }
145    let started = Instant::now();
146    let config = Some(exec_config(runtime.flavour));
147    let handle = match kind {
148        StreamKind::Nemotron => Handle::Nemotron(NemotronHandle::from_pretrained(&dir, config)?),
149        StreamKind::Eou => Handle::Eou(ParakeetEOUHandle::from_pretrained(&dir, config)?),
150    };
151    tracing::info!(
152        target: TRACE_TARGET,
153        op = "load",
154        model = %model.id,
155        kind = ?kind,
156        flavour = runtime.flavour.name(),
157        elapsed_ms = started.elapsed().as_millis() as u64,
158        "streaming speech model loaded"
159    );
160    Ok(LoadedStream { handle })
161}
162
163#[cfg(test)]
164mod tests {
165    use super::*;
166
167    #[test]
168    fn the_tokenizer_file_names_the_family() {
169        assert_eq!(
170            stream_kind(["encoder.onnx", "decoder_joint.onnx", "tokenizer.model"]),
171            Some(StreamKind::Nemotron)
172        );
173        assert_eq!(
174            stream_kind(["encoder.onnx", "decoder_joint.onnx", "tokenizer.json"]),
175            Some(StreamKind::Eou)
176        );
177        assert_eq!(stream_kind(["encoder.onnx"]), None);
178    }
179
180    #[test]
181    fn each_model_gets_its_own_directory() {
182        assert_eq!(
183            model_dir(Path::new("/m"), "nemotron-3.5"),
184            Path::new("/m/stt/nemotron-3.5")
185        );
186    }
187}