use std::path::{Path, PathBuf};
use async_trait::async_trait;
use sherpa_onnx::{OfflineRecognizer, OfflineRecognizerConfig, OfflineTransducerModelConfig};
use crate::config::{ParakeetConfig, ParakeetVariant};
use crate::error::TalkError;
use crate::transcription::{
OneShotTranscriber, RequestTimeoutPolicy, TranscriptionBody, TranscriptionResult,
};
use super::model;
const FILENAMES_INT8: [&str; 4] = [
"encoder.int8.onnx",
"decoder.int8.onnx",
"joiner.int8.onnx",
"tokens.txt",
];
const FILENAMES_FP32: [&str; 4] = ["encoder.onnx", "decoder.onnx", "joiner.onnx", "tokens.txt"];
fn filenames_for(variant: ParakeetVariant) -> [&'static str; 4] {
match variant {
ParakeetVariant::Int8 => FILENAMES_INT8,
ParakeetVariant::Fp32 => FILENAMES_FP32,
}
}
const PARAKEET_SAMPLE_RATE_HZ: i32 = 16_000;
fn pcm_i16_to_f32_normalised(samples: &[i16]) -> Vec<f32> {
samples.iter().map(|&s| s as f32 / 32768.0).collect()
}
pub struct ParakeetOneShotTranscriber {
model_dir: PathBuf,
variant: ParakeetVariant,
num_threads: i32,
}
impl ParakeetOneShotTranscriber {
pub fn with_policy(
cfg: ParakeetConfig,
_policy: RequestTimeoutPolicy,
) -> Result<Self, TalkError> {
let model_dir = cfg.resolved_model_dir()?;
let variant = cfg.resolved_variant();
Ok(Self {
model_dir,
variant,
num_threads: cfg.num_threads,
})
}
fn model_file_paths(&self) -> [PathBuf; 4] {
let names = filenames_for(self.variant);
[
self.model_dir.join(names[0]),
self.model_dir.join(names[1]),
self.model_dir.join(names[2]),
self.model_dir.join(names[3]),
]
}
}
#[async_trait]
impl OneShotTranscriber for ParakeetOneShotTranscriber {
async fn validate(&self) -> Result<(), TalkError> {
model::ensure_present(&self.model_dir, self.variant)
}
async fn fetch_transcription(
&self,
body: TranscriptionBody,
) -> Result<TranscriptionResult, TalkError> {
let pcm_i16 = match body {
TranscriptionBody::File(path) => {
crate::record::audio::read_audio_as_i16(&path)?
}
TranscriptionBody::Pipe {
mut chunks,
file_name,
} => {
let mut bytes = Vec::new();
while let Some(chunk) = chunks.recv().await {
bytes.extend_from_slice(&chunk);
}
log::info!(
"parakeet stream: collected {} bytes ({}) -> decoding via temp file",
bytes.len(),
file_name
);
write_temp_and_decode(&bytes, &file_name)?
}
};
let samples_f32 = pcm_i16_to_f32_normalised(&pcm_i16);
let paths = self.model_file_paths();
let num_threads = self.num_threads;
let variant = self.variant;
let text = tokio::task::spawn_blocking(move || -> Result<String, TalkError> {
run_inference(&paths, num_threads, variant, &samples_f32)
})
.await
.map_err(|e| {
TalkError::Transcription(format!(
"parakeet inference task panicked or was cancelled: {}",
e
))
})??;
Ok(TranscriptionResult {
text,
..Default::default()
})
}
}
fn write_temp_and_decode(bytes: &[u8], file_name: &str) -> Result<Vec<i16>, TalkError> {
let extension = Path::new(file_name)
.extension()
.and_then(|s| s.to_str())
.unwrap_or("ogg");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let path = std::env::temp_dir().join(format!(
"talk-rs-parakeet-{}-{}.{}",
std::process::id(),
nanos,
extension
));
std::fs::write(&path, bytes).map_err(|e| {
TalkError::Transcription(format!(
"parakeet stream: failed to write temp audio {}: {}",
path.display(),
e
))
})?;
let result = crate::record::audio::read_audio_as_i16(&path);
if let Err(e) = std::fs::remove_file(&path) {
log::debug!(
"parakeet stream: failed to remove temp file {}: {}",
path.display(),
e
);
}
result
}
fn run_inference(
paths: &[PathBuf; 4],
num_threads: i32,
variant: ParakeetVariant,
samples_f32: &[f32],
) -> Result<String, TalkError> {
let encoder = path_to_string(&paths[0])?;
let decoder = path_to_string(&paths[1])?;
let joiner = path_to_string(&paths[2])?;
let tokens = path_to_string(&paths[3])?;
let mut cfg = OfflineRecognizerConfig::default();
cfg.model_config.transducer = OfflineTransducerModelConfig {
encoder: Some(encoder),
decoder: Some(decoder),
joiner: Some(joiner),
};
cfg.model_config.tokens = Some(tokens);
cfg.model_config.provider = Some("cpu".into());
cfg.model_config.num_threads = num_threads;
cfg.model_config.debug = false;
cfg.model_config.model_type = Some("nemo_transducer".into());
let recognizer = OfflineRecognizer::create(&cfg).ok_or_else(|| {
TalkError::Transcription(format!(
"parakeet: failed to create recognizer (variant={}, model_dir contains: {:?})",
variant,
paths
.iter()
.map(|p| p.file_name().and_then(|n| n.to_str()).unwrap_or("?"))
.collect::<Vec<_>>()
))
})?;
let stream = recognizer.create_stream();
stream.accept_waveform(PARAKEET_SAMPLE_RATE_HZ, samples_f32);
recognizer.decode(&stream);
let result = stream.get_result().ok_or_else(|| {
TalkError::Transcription(
"parakeet: recognizer returned no result (decode produced null JSON)".to_string(),
)
})?;
Ok(result.text)
}
fn path_to_string(p: &Path) -> Result<String, TalkError> {
p.to_str().map(|s| s.to_string()).ok_or_else(|| {
TalkError::Config(format!(
"parakeet: model path is not valid UTF-8: {}",
p.display()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ParakeetConfig;
#[test]
fn pcm_i16_to_f32_boundary_values() {
let pcm = vec![i16::MIN, -1, 0, 1, i16::MAX];
let out = pcm_i16_to_f32_normalised(&pcm);
assert_eq!(out[0], -1.0);
assert_eq!(out[2], 0.0);
assert!(out[4] < 1.0);
assert!(out[4] > 0.999_9);
for v in &out {
assert!(*v >= -1.0 && *v <= 1.0, "sample out of range: {}", v);
}
}
#[test]
fn pcm_i16_to_f32_empty_is_empty() {
let out = pcm_i16_to_f32_normalised(&[]);
assert!(out.is_empty());
}
#[test]
fn filenames_for_int8_matches_expected() {
let names = filenames_for(ParakeetVariant::Int8);
assert_eq!(names[0], "encoder.int8.onnx");
assert_eq!(names[1], "decoder.int8.onnx");
assert_eq!(names[2], "joiner.int8.onnx");
assert_eq!(names[3], "tokens.txt");
}
#[test]
fn filenames_for_fp32_drops_int8_infix() {
let names = filenames_for(ParakeetVariant::Fp32);
assert_eq!(names[0], "encoder.onnx");
assert_eq!(names[1], "decoder.onnx");
assert_eq!(names[2], "joiner.onnx");
assert_eq!(names[3], "tokens.txt");
}
#[test]
fn with_policy_succeeds_without_model_files_present() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = ParakeetConfig {
variant: ParakeetVariant::Int8,
model_dir: Some(tmp.path().to_path_buf()),
num_threads: 1,
model: None,
};
let transcriber =
ParakeetOneShotTranscriber::with_policy(cfg, RequestTimeoutPolicy::Proportional)
.expect("with_policy must not touch the filesystem");
assert_eq!(transcriber.num_threads, 1);
assert_eq!(transcriber.variant, ParakeetVariant::Int8);
assert_eq!(transcriber.model_dir, tmp.path());
}
#[test]
fn model_file_paths_int8_layout() {
let tmp = tempfile::TempDir::new().unwrap();
let cfg = ParakeetConfig {
variant: ParakeetVariant::Int8,
model_dir: Some(tmp.path().to_path_buf()),
num_threads: 2,
model: None,
};
let t = ParakeetOneShotTranscriber::with_policy(cfg, RequestTimeoutPolicy::Proportional)
.unwrap();
let paths = t.model_file_paths();
assert!(paths[0].ends_with("encoder.int8.onnx"));
assert!(paths[1].ends_with("decoder.int8.onnx"));
assert!(paths[2].ends_with("joiner.int8.onnx"));
assert!(paths[3].ends_with("tokens.txt"));
}
}