use crate::config::Config;
use std::path::PathBuf;
#[cfg(feature = "engine-sherpa")]
use sherpa_rs::transducer::{TransducerConfig, TransducerRecognizer};
pub const DEFAULT_SHERPA_MODEL: &str = "parakeet-tdt-0.6b-v3-int8";
pub fn installs_root(config: &Config) -> PathBuf {
config.transcription.model_path.join("sherpa")
}
pub fn model_dir(config: &Config) -> PathBuf {
let configured = config.transcription.sherpa_model_dir.trim();
if !configured.is_empty() {
return PathBuf::from(configured);
}
if let Ok(dir) = std::env::var("MINUTES_SHERPA_MODEL_DIR") {
let dir = dir.trim();
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
installs_root(config).join(DEFAULT_SHERPA_MODEL)
}
pub const MODEL_FILES: [(&str, u64); 4] = [
("encoder.int8.onnx", 500_000_000),
("decoder.int8.onnx", 5_000_000),
("joiner.int8.onnx", 3_000_000),
("tokens.txt", 10_000),
];
pub fn model_files_present(dir: &std::path::Path) -> bool {
MODEL_FILES.iter().all(|(name, min)| {
std::fs::metadata(dir.join(name))
.map(|m| m.is_file() && m.len() >= *min)
.unwrap_or(false)
})
}
#[cfg(feature = "engine-sherpa")]
const WINDOW_SAMPLES: usize = 16_000 * 15;
#[cfg(feature = "engine-sherpa")]
fn build_recognizer(config: &Config) -> Result<TransducerRecognizer, String> {
let dir = model_dir(config);
if !model_files_present(&dir) {
return Err(format!(
"sherpa model not found in {}. Run `minutes setup --sherpa` to download it \
(or set transcription.sherpa_model_dir / MINUTES_SHERPA_MODEL_DIR).",
dir.display()
));
}
let path = |file: &str| dir.join(file).to_string_lossy().into_owned();
let cfg = TransducerConfig {
encoder: path("encoder.int8.onnx"),
decoder: path("decoder.int8.onnx"),
joiner: path("joiner.int8.onnx"),
tokens: path("tokens.txt"),
num_threads: 4,
decoding_method: "greedy_search".into(),
model_type: String::new(),
debug: false,
..Default::default()
};
TransducerRecognizer::new(cfg).map_err(|e| format!("failed to load sherpa model: {e}"))
}
#[cfg(feature = "engine-sherpa")]
pub fn transcribe_segments(samples: &[f32], config: &Config) -> Result<Vec<(u64, String)>, String> {
let mut recognizer = build_recognizer(config)?;
let mut segments = Vec::new();
for (i, window) in samples.chunks(WINDOW_SAMPLES).enumerate() {
let start_ms = (i * WINDOW_SAMPLES) as u64 * 1000 / 16_000;
let text = recognizer.transcribe(16_000, window).trim().to_string();
if !text.is_empty() {
segments.push((start_ms, text));
}
}
Ok(segments)
}
#[cfg(feature = "engine-sherpa")]
pub fn transcribe_samples(samples: &[f32], config: &Config) -> Result<String, String> {
Ok(transcribe_segments(samples, config)?
.into_iter()
.map(|(_, text)| text)
.collect::<Vec<_>>()
.join(" "))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn model_dir_prefers_explicit_config_field() {
let mut config = Config::default();
config.transcription.sherpa_model_dir = "/custom/sherpa".into();
assert_eq!(model_dir(&config), PathBuf::from("/custom/sherpa"));
}
#[test]
fn model_dir_defaults_under_model_path() {
let mut config = Config::default();
config.transcription.sherpa_model_dir = String::new();
config.transcription.model_path = PathBuf::from("/models");
if std::env::var("MINUTES_SHERPA_MODEL_DIR").is_err() {
assert_eq!(
model_dir(&config),
PathBuf::from("/models/sherpa").join(DEFAULT_SHERPA_MODEL)
);
}
}
#[test]
fn model_files_present_requires_all_and_size_floor() {
let tmp = std::env::temp_dir().join(format!("sherpa-files-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&tmp);
std::fs::create_dir_all(&tmp).unwrap();
assert!(!model_files_present(&tmp));
for (name, _min) in MODEL_FILES {
std::fs::write(tmp.join(name), b"x").unwrap();
}
assert!(!model_files_present(&tmp));
for (name, min) in MODEL_FILES {
let f = std::fs::File::create(tmp.join(name)).unwrap();
f.set_len(min).unwrap();
}
assert!(model_files_present(&tmp));
let _ = std::fs::remove_dir_all(&tmp);
}
}