1use std::path::{Path, PathBuf};
22
23use anyhow::{anyhow, Context, Result};
24
25use crate::voice::VoiceOpts;
26
27pub const MODEL_ID: &str = "openai/whisper-tiny.en";
31
32pub const REVISION: &str = "refs/pr/15";
36
37pub const REQUIRED_FILES: &[&str] = &["config.json", "tokenizer.json", "model.safetensors"];
41
42pub const DEFAULT_VARIANT_DIR: &str = "whisper-tiny.en";
48
49#[derive(Debug, Clone, Copy)]
55pub enum ModelSource {
56 HfHub {
60 repo_id: &'static str,
62 revision: &'static str,
64 },
65 HttpReleaseAsset {
69 url: &'static str,
71 sha256: &'static str,
73 bytes: u64,
75 },
76}
77
78#[derive(Debug, Clone, Copy)]
82pub struct ModelSpec {
83 pub variant: &'static str,
87 pub kind_label: &'static str,
89 pub default_subdir: &'static str,
92 pub required_files: &'static [&'static str],
95 pub env_var: &'static str,
97 pub install_command: &'static str,
100 pub model_flag: &'static str,
103 pub source: ModelSource,
105}
106
107impl ModelSpec {
108 pub fn default_dir(&self) -> Option<PathBuf> {
113 dirs::home_dir().map(|home| self.default_dir_from(&home))
114 }
115
116 pub fn default_dir_from(&self, home: &Path) -> PathBuf {
121 home.join(".omni-dev")
122 .join("voice")
123 .join("models")
124 .join(self.default_subdir)
125 }
126
127 pub fn resolve_dir(&self, override_path: Option<&Path>) -> Result<PathBuf> {
133 if let Some(p) = override_path {
134 return Ok(p.to_path_buf());
135 }
136 if let Ok(env) = crate::utils::settings::get_env_var(self.env_var) {
137 if !env.is_empty() {
138 return Ok(PathBuf::from(env));
139 }
140 }
141 self.default_dir().ok_or_else(|| {
142 anyhow!(
143 "could not determine home directory; \
144 pass {} <path> or set {}",
145 self.model_flag,
146 self.env_var
147 )
148 })
149 }
150
151 pub fn required_files_in(&self, dir: &Path) -> Vec<PathBuf> {
153 self.required_files.iter().map(|f| dir.join(f)).collect()
154 }
155
156 pub fn ensure_present(&self, dir: &Path) -> Result<()> {
161 for file in self.required_files {
162 let path = dir.join(file);
163 if !path.is_file() {
164 return Err(anyhow!(
165 "no {} model found at {}; \
166 run `{}` or pass {} <path>",
167 self.kind_label,
168 dir.display(),
169 self.install_command,
170 self.model_flag,
171 ))
172 .with_context(|| format!("missing required file: {}", path.display()));
173 }
174 }
175 Ok(())
176 }
177}
178
179pub const WHISPER_TINY_EN: ModelSpec = ModelSpec {
183 variant: "whisper-tiny.en",
184 kind_label: "Whisper",
185 default_subdir: DEFAULT_VARIANT_DIR,
186 required_files: REQUIRED_FILES,
187 env_var: "OMNI_DEV_VOICE_WHISPER_MODEL",
188 install_command: "omni-dev voice install-model",
189 model_flag: "--model",
190 source: ModelSource::HfHub {
191 repo_id: MODEL_ID,
192 revision: REVISION,
193 },
194};
195
196pub const SPEAKER_WESPEAKER_EN: ModelSpec = ModelSpec {
200 variant: "speaker-wespeaker-en",
201 kind_label: "Speaker",
202 default_subdir: "wespeaker-en-voxceleb-resnet34-LM",
203 required_files: &["wespeaker_en_voxceleb_resnet34_LM.onnx"],
204 env_var: "OMNI_DEV_VOICE_SPEAKER_MODEL",
205 install_command: "omni-dev voice install-model --variant speaker-wespeaker-en",
206 model_flag: "--speaker-model",
207 source: ModelSource::HttpReleaseAsset {
208 url: "https://github.com/k2-fsa/sherpa-onnx/releases/download/speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx",
209 sha256: "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012",
210 bytes: 26_530_550,
211 },
212};
213
214pub fn required_files_in(dir: &Path) -> Vec<PathBuf> {
218 WHISPER_TINY_EN.required_files_in(dir)
219}
220
221pub fn default_whisper_model_dir() -> Option<PathBuf> {
226 WHISPER_TINY_EN.default_dir()
227}
228
229pub fn resolve_whisper_model_dir(opts: &VoiceOpts) -> Result<PathBuf> {
235 WHISPER_TINY_EN.resolve_dir(opts.model.as_deref())
236}
237
238pub fn ensure_model_present(dir: &Path) -> Result<()> {
244 WHISPER_TINY_EN.ensure_present(dir)
245}
246
247#[cfg(test)]
248#[allow(clippy::unwrap_used, clippy::expect_used)]
249mod tests {
250 use super::*;
251 use std::sync::{Mutex, MutexGuard};
252
253 static ENV_GUARD: Mutex<()> = Mutex::new(());
254
255 fn env_guard() -> MutexGuard<'static, ()> {
256 match ENV_GUARD.lock() {
257 Ok(g) => g,
258 Err(poisoned) => poisoned.into_inner(),
259 }
260 }
261
262 #[test]
263 fn opts_model_takes_top_priority() {
264 let _g = env_guard();
265 std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "/should/not/be/read");
266 let opts = VoiceOpts {
267 backend: None,
268 model: Some(PathBuf::from("/explicit/path")),
269 };
270 let resolved = resolve_whisper_model_dir(&opts).unwrap();
271 assert_eq!(resolved, PathBuf::from("/explicit/path"));
272 std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
273 }
274
275 #[test]
276 fn env_var_used_when_opts_absent() {
277 let _g = env_guard();
278 std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "/from/env");
279 let resolved = resolve_whisper_model_dir(&VoiceOpts::default()).unwrap();
280 assert_eq!(resolved, PathBuf::from("/from/env"));
281 std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
282 }
283
284 #[test]
285 fn empty_env_var_falls_through_to_default() {
286 let _g = env_guard();
287 std::env::set_var("OMNI_DEV_VOICE_WHISPER_MODEL", "");
288 let resolved = resolve_whisper_model_dir(&VoiceOpts::default()).unwrap();
289 let expected = default_whisper_model_dir().unwrap();
290 assert_eq!(resolved, expected);
291 std::env::remove_var("OMNI_DEV_VOICE_WHISPER_MODEL");
292 }
293
294 #[test]
295 fn default_path_uses_omni_dev_voice_models_subdir() {
296 let dir = default_whisper_model_dir().unwrap();
297 assert!(dir.ends_with(".omni-dev/voice/models/whisper-tiny.en"));
298 }
299
300 #[test]
301 fn ensure_model_present_succeeds_when_all_files_exist() {
302 let tmp = tempfile::TempDir::new().unwrap();
303 for f in REQUIRED_FILES {
304 std::fs::write(tmp.path().join(f), b"placeholder").unwrap();
305 }
306 ensure_model_present(tmp.path()).unwrap();
307 }
308
309 #[test]
310 fn ensure_model_present_errors_with_hint_when_files_missing() {
311 let tmp = tempfile::TempDir::new().unwrap();
312 let err = ensure_model_present(tmp.path()).unwrap_err();
313 let msg = format!("{err:#}");
314 assert!(msg.contains("no Whisper model found"), "got: {msg}");
315 assert!(msg.contains("voice install-model"), "got: {msg}");
316 assert!(msg.contains("--model"), "got: {msg}");
317 }
318
319 #[test]
320 fn ensure_model_present_errors_when_any_file_missing() {
321 let tmp = tempfile::TempDir::new().unwrap();
322 std::fs::write(tmp.path().join("config.json"), b"x").unwrap();
324 std::fs::write(tmp.path().join("model.safetensors"), b"x").unwrap();
325 let err = ensure_model_present(tmp.path()).unwrap_err();
326 let msg = format!("{err:#}");
327 assert!(msg.contains("tokenizer.json"), "got: {msg}");
328 }
329
330 #[test]
331 fn required_files_in_returns_three_paths() {
332 let paths = required_files_in(Path::new("/x"));
333 assert_eq!(paths.len(), 3);
334 assert_eq!(paths[0], PathBuf::from("/x/config.json"));
335 assert_eq!(paths[1], PathBuf::from("/x/tokenizer.json"));
336 assert_eq!(paths[2], PathBuf::from("/x/model.safetensors"));
337 }
338
339 #[test]
342 fn speaker_spec_default_dir_ends_with_wespeaker_subdir() {
343 let dir = SPEAKER_WESPEAKER_EN.default_dir().unwrap();
344 assert!(dir.ends_with(".omni-dev/voice/models/wespeaker-en-voxceleb-resnet34-LM"));
345 }
346
347 #[test]
348 fn speaker_spec_resolve_dir_override_takes_priority() {
349 let _g = env_guard();
350 std::env::set_var("OMNI_DEV_VOICE_SPEAKER_MODEL", "/should/not/be/read");
351 let resolved = SPEAKER_WESPEAKER_EN
352 .resolve_dir(Some(Path::new("/explicit/path")))
353 .unwrap();
354 assert_eq!(resolved, PathBuf::from("/explicit/path"));
355 std::env::remove_var("OMNI_DEV_VOICE_SPEAKER_MODEL");
356 }
357
358 #[test]
359 fn speaker_spec_resolve_dir_env_var_used_when_override_absent() {
360 let _g = env_guard();
361 std::env::set_var("OMNI_DEV_VOICE_SPEAKER_MODEL", "/from/env");
362 let resolved = SPEAKER_WESPEAKER_EN.resolve_dir(None).unwrap();
363 assert_eq!(resolved, PathBuf::from("/from/env"));
364 std::env::remove_var("OMNI_DEV_VOICE_SPEAKER_MODEL");
365 }
366
367 #[test]
368 fn speaker_spec_ensure_present_errors_with_install_hint() {
369 let tmp = tempfile::TempDir::new().unwrap();
370 let err = SPEAKER_WESPEAKER_EN.ensure_present(tmp.path()).unwrap_err();
371 let msg = format!("{err:#}");
372 assert!(msg.contains("no Speaker model found"), "got: {msg}");
373 assert!(msg.contains("--variant speaker-wespeaker-en"), "got: {msg}");
374 assert!(msg.contains("--speaker-model"), "got: {msg}");
375 assert!(
376 msg.contains("wespeaker_en_voxceleb_resnet34_LM.onnx"),
377 "got: {msg}"
378 );
379 }
380
381 #[test]
382 fn speaker_spec_ensure_present_succeeds_when_file_exists() {
383 let tmp = tempfile::TempDir::new().unwrap();
384 std::fs::write(
385 tmp.path().join("wespeaker_en_voxceleb_resnet34_LM.onnx"),
386 b"placeholder",
387 )
388 .unwrap();
389 SPEAKER_WESPEAKER_EN.ensure_present(tmp.path()).unwrap();
390 }
391
392 #[test]
393 fn whisper_spec_required_files_matches_legacy_helper() {
394 let dir = Path::new("/x");
395 assert_eq!(
396 WHISPER_TINY_EN.required_files_in(dir),
397 required_files_in(dir)
398 );
399 }
400
401 #[test]
402 fn whisper_spec_source_carries_pinned_hf_metadata() {
403 match WHISPER_TINY_EN.source {
404 ModelSource::HfHub { repo_id, revision } => {
405 assert_eq!(repo_id, MODEL_ID);
406 assert_eq!(revision, REVISION);
407 }
408 ModelSource::HttpReleaseAsset { .. } => {
409 panic!("WHISPER_TINY_EN should be HfHub-sourced");
410 }
411 }
412 }
413
414 #[test]
415 fn speaker_spec_source_carries_pinned_release_metadata() {
416 match SPEAKER_WESPEAKER_EN.source {
417 ModelSource::HttpReleaseAsset { url, sha256, bytes } => {
418 assert!(url.contains("wespeaker_en_voxceleb_resnet34_LM.onnx"));
419 assert_eq!(
420 sha256,
421 "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012"
422 );
423 assert_eq!(bytes, 26_530_550);
424 }
425 ModelSource::HfHub { .. } => {
426 panic!("SPEAKER_WESPEAKER_EN should be HttpReleaseAsset-sourced");
427 }
428 }
429 }
430}