use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use crate::voice::VoiceOpts;
pub const MODEL_ID: &str = "openai/whisper-tiny.en";
pub const REVISION: &str = "refs/pr/15";
pub const REQUIRED_FILES: &[&str] = &["config.json", "tokenizer.json", "model.safetensors"];
pub const DEFAULT_VARIANT_DIR: &str = "whisper-tiny.en";
#[derive(Debug, Clone, Copy)]
pub enum ModelSource {
HfHub {
repo_id: &'static str,
revision: &'static str,
},
HttpReleaseAsset {
url: &'static str,
sha256: &'static str,
bytes: u64,
},
}
#[derive(Debug, Clone, Copy)]
pub struct ModelSpec {
pub variant: &'static str,
pub kind_label: &'static str,
pub default_subdir: &'static str,
pub required_files: &'static [&'static str],
pub env_var: &'static str,
pub install_command: &'static str,
pub model_flag: &'static str,
pub source: ModelSource,
}
impl ModelSpec {
pub fn default_dir(&self) -> Option<PathBuf> {
dirs::home_dir().map(|home| self.default_dir_from(&home))
}
pub fn default_dir_from(&self, home: &Path) -> PathBuf {
home.join(".omni-dev")
.join("voice")
.join("models")
.join(self.default_subdir)
}
pub fn resolve_dir(&self, override_path: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = override_path {
return Ok(p.to_path_buf());
}
if let Ok(env) = crate::utils::settings::get_env_var(self.env_var) {
if !env.is_empty() {
return Ok(PathBuf::from(env));
}
}
self.default_dir().ok_or_else(|| {
anyhow!(
"could not determine home directory; \
pass {} <path> or set {}",
self.model_flag,
self.env_var
)
})
}
pub fn required_files_in(&self, dir: &Path) -> Vec<PathBuf> {
self.required_files.iter().map(|f| dir.join(f)).collect()
}
pub fn ensure_present(&self, dir: &Path) -> Result<()> {
for file in self.required_files {
let path = dir.join(file);
if !path.is_file() {
return Err(anyhow!(
"no {} model found at {}; \
run `{}` or pass {} <path>",
self.kind_label,
dir.display(),
self.install_command,
self.model_flag,
))
.with_context(|| format!("missing required file: {}", path.display()));
}
}
Ok(())
}
}
pub const WHISPER_TINY_EN: ModelSpec = ModelSpec {
variant: "whisper-tiny.en",
kind_label: "Whisper",
default_subdir: DEFAULT_VARIANT_DIR,
required_files: REQUIRED_FILES,
env_var: "OMNI_DEV_VOICE_WHISPER_MODEL",
install_command: "omni-dev voice install-model",
model_flag: "--model",
source: ModelSource::HfHub {
repo_id: MODEL_ID,
revision: REVISION,
},
};
pub const SPEAKER_WESPEAKER_EN: ModelSpec = ModelSpec {
variant: "speaker-wespeaker-en",
kind_label: "Speaker",
default_subdir: "wespeaker-en-voxceleb-resnet34-LM",
required_files: &["wespeaker_en_voxceleb_resnet34_LM.onnx"],
env_var: "OMNI_DEV_VOICE_SPEAKER_MODEL",
install_command: "omni-dev voice install-model --variant speaker-wespeaker-en",
model_flag: "--speaker-model",
source: ModelSource::HttpReleaseAsset {
url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx",
sha256: "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012",
bytes: 26_530_550,
},
};
pub fn required_files_in(dir: &Path) -> Vec<PathBuf> {
WHISPER_TINY_EN.required_files_in(dir)
}
pub fn default_whisper_model_dir() -> Option<PathBuf> {
WHISPER_TINY_EN.default_dir()
}
pub fn resolve_whisper_model_dir(opts: &VoiceOpts) -> Result<PathBuf> {
WHISPER_TINY_EN.resolve_dir(opts.model.as_deref())
}
pub fn ensure_model_present(dir: &Path) -> Result<()> {
WHISPER_TINY_EN.ensure_present(dir)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard};
static ENV_GUARD: Mutex<()> = Mutex::new(());
fn env_guard() -> MutexGuard<'static, ()> {
match ENV_GUARD.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
#[test]
fn opts_model_takes_top_priority() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "/should/not/be/read");
let opts = VoiceOpts {
backend: None,
model: Some(PathBuf::from("/explicit/path")),
};
let resolved = resolve_whisper_model_dir(&opts).unwrap();
assert_eq!(resolved, PathBuf::from("/explicit/path"));
std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
}
#[test]
fn env_var_used_when_opts_absent() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "/from/env");
let resolved = resolve_whisper_model_dir(&VoiceOpts::default()).unwrap();
assert_eq!(resolved, PathBuf::from("/from/env"));
std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
}
#[test]
fn empty_env_var_falls_through_to_default() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "");
let resolved = resolve_whisper_model_dir(&VoiceOpts::default()).unwrap();
let expected = default_whisper_model_dir().unwrap();
assert_eq!(resolved, expected);
std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
}
#[test]
fn default_path_uses_omni_dev_voice_models_subdir() {
let dir = default_whisper_model_dir().unwrap();
assert!(dir.ends_with(".omni-dev/voice/models/whisper-tiny.en"));
}
#[test]
fn ensure_model_present_succeeds_when_all_files_exist() {
let tmp = tempfile::TempDir::new().unwrap();
for f in REQUIRED_FILES {
std::fs::write(tmp.path().join(f), b"placeholder").unwrap();
}
ensure_model_present(tmp.path()).unwrap();
}
#[test]
fn ensure_model_present_errors_with_hint_when_files_missing() {
let tmp = tempfile::TempDir::new().unwrap();
let err = ensure_model_present(tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no Whisper model found"), "got: {msg}");
assert!(msg.contains("voice install-model"), "got: {msg}");
assert!(msg.contains("--model"), "got: {msg}");
}
#[test]
fn ensure_model_present_errors_when_any_file_missing() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join("config.json"), b"x").unwrap();
std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
let err = ensure_model_present(tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("tokenizer.json"), "got: {msg}");
}
#[test]
fn required_files_in_returns_three_paths() {
let paths = required_files_in(Path::new("/x"));
assert_eq!(paths.len(), 3);
assert_eq!(paths[0], PathBuf::from("/x/config.json"));
assert_eq!(paths[1], PathBuf::from("/x/tokenizer.json"));
assert_eq!(paths[2], PathBuf::from("/x/model.safetensors"));
}
#[test]
fn speaker_spec_default_dir_ends_with_wespeaker_subdir() {
let dir = SPEAKER_WESPEAKER_EN.default_dir().unwrap();
assert!(dir.ends_with(".omni-dev/voice/models/wespeaker-en-voxceleb-resnet34-LM"));
}
#[test]
fn speaker_spec_resolve_dir_override_takes_priority() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_SPEAKER_MODEL", "/should/not/be/read");
let resolved = SPEAKER_WESPEAKER_EN
.resolve_dir(Some(Path::new("/explicit/path")))
.unwrap();
assert_eq!(resolved, PathBuf::from("/explicit/path"));
std::env::remove_var("OMNI_DEV_VOICE_SPEAKER_MODEL");
}
#[test]
fn speaker_spec_resolve_dir_env_var_used_when_override_absent() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_SPEAKER_MODEL", "/from/env");
let resolved = SPEAKER_WESPEAKER_EN.resolve_dir(None).unwrap();
assert_eq!(resolved, PathBuf::from("/from/env"));
std::env::remove_var("OMNI_DEV_VOICE_SPEAKER_MODEL");
}
#[test]
fn speaker_spec_ensure_present_errors_with_install_hint() {
let tmp = tempfile::TempDir::new().unwrap();
let err = SPEAKER_WESPEAKER_EN.ensure_present(tmp.path()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("no Speaker model found"), "got: {msg}");
assert!(msg.contains("--variant speaker-wespeaker-en"), "got: {msg}");
assert!(msg.contains("--speaker-model"), "got: {msg}");
assert!(
msg.contains("wespeaker_en_voxceleb_resnet34_LM.onnx"),
"got: {msg}"
);
}
#[test]
fn speaker_spec_ensure_present_succeeds_when_file_exists() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(
tmp.path().join("wespeaker_en_voxceleb_resnet34_LM.onnx"),
b"placeholder",
)
.unwrap();
SPEAKER_WESPEAKER_EN.ensure_present(tmp.path()).unwrap();
}
#[test]
fn whisper_spec_required_files_matches_legacy_helper() {
let dir = Path::new("/x");
assert_eq!(
WHISPER_TINY_EN.required_files_in(dir),
required_files_in(dir)
);
}
#[test]
fn whisper_spec_source_carries_pinned_hf_metadata() {
match WHISPER_TINY_EN.source {
ModelSource::HfHub { repo_id, revision } => {
assert_eq!(repo_id, MODEL_ID);
assert_eq!(revision, REVISION);
}
ModelSource::HttpReleaseAsset { .. } => {
panic!("WHISPER_TINY_EN should be HfHub-sourced");
}
}
}
#[test]
fn speaker_spec_source_carries_pinned_release_metadata() {
match SPEAKER_WESPEAKER_EN.source {
ModelSource::HttpReleaseAsset { url, sha256, bytes } => {
assert!(url.contains("wespeaker_en_voxceleb_resnet34_LM.onnx"));
assert_eq!(
sha256,
"e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012"
);
assert_eq!(bytes, 26_530_550);
}
ModelSource::HfHub { .. } => {
panic!("SPEAKER_WESPEAKER_EN should be HttpReleaseAsset-sourced");
}
}
}
}