Skip to main content

omni_dev/voice/
models.rs

1//! Model storage convention and path resolution.
2//!
3//! Two distinct kinds of model are tracked by this module:
4//!
5//! - **Whisper ASR** (`tiny.en`), loaded by the `whisper-candle` backend.
6//! - **Wespeaker speaker embedding** (`resnet34_LM`), loaded by the
7//!   speaker-embedding subsystem added in #805 / ADR-0034.
8//!
9//! Both follow the same three-tier resolution priority:
10//!
11//! 1. Explicit `--model <path>` (Whisper) or `--speaker-model <path>`
12//!    (wespeaker) on the relevant CLI command.
13//! 2. `OMNI_DEV_VOICE_WHISPER_MODEL` / `OMNI_DEV_VOICE_SPEAKER_MODEL`
14//!    env var.
15//! 3. Default install location under the user's home directory.
16//!
17//! Sharing the helper means the install command writes to exactly the
18//! place the backend later reads from — bugs can't diverge between
19//! download-target and load-target.
20
21use std::path::{Path, PathBuf};
22
23use anyhow::{anyhow, Context, Result};
24
25use crate::voice::VoiceOpts;
26
27// ── Whisper constants (retained for backwards compatibility) ──────────────
28
29/// HuggingFace repository identifier for the `tiny.en` Whisper variant.
30pub const MODEL_ID: &str = "openai/whisper-tiny.en";
31
32/// Pinned HuggingFace revision. `refs/pr/15` adds the safetensors weights
33/// to `openai/whisper-tiny.en`; the candle spike in #813 validated this
34/// exact revision end-to-end.
35pub const REVISION: &str = "refs/pr/15";
36
37/// The three files the Whisper backend needs to load. Order matters for
38/// the install command's progress messages; the backend itself loads them
39/// via [`required_files_in`] independent of order.
40pub const REQUIRED_FILES: &[&str] = &["config.json", "tokenizer.json", "model.safetensors"];
41
42/// Default subdirectory name beneath `~/.omni-dev/voice/models/`.
43///
44/// Derived from [`MODEL_ID`] by stripping the `openai/` org prefix; keeps
45/// room for future variants (`whisper-base.en`, multilingual) as sibling
46/// dirs.
47pub const DEFAULT_VARIANT_DIR: &str = "whisper-tiny.en";
48
49// ── ModelSpec shape ──────────────────────────────────────────────────────
50
51/// Where the bytes of a model come from. Each variant carries the
52/// transport-specific metadata the install command needs to fetch the
53/// model exactly once and verify its integrity.
54#[derive(Debug, Clone, Copy)]
55pub enum ModelSource {
56    /// HuggingFace Hub — Whisper's distribution. The install command
57    /// uses `hf_hub::api::sync::Api` to download `required_files` at a
58    /// pinned revision.
59    HfHub {
60        /// HF repository identifier, e.g. `"openai/whisper-tiny.en"`.
61        repo_id: &'static str,
62        /// Pinned revision (branch, tag, or ref).
63        revision: &'static str,
64    },
65    /// A single signed GitHub release asset — wespeaker's distribution.
66    /// The install command downloads the asset, verifies SHA-256, and
67    /// atomically installs into `required_files[0]`.
68    HttpReleaseAsset {
69        /// Direct download URL.
70        url: &'static str,
71        /// Expected SHA-256 of the downloaded bytes (hex).
72        sha256: &'static str,
73        /// Expected size in bytes; informational, for progress messages.
74        bytes: u64,
75    },
76}
77
78/// Fully describes a model variant's storage, install transport, and CLI
79/// surface. Static lifetime: every field is `&'static str` (or
80/// `&'static [&'static str]`) so `ModelSpec` is `Copy` and `'static`.
81#[derive(Debug, Clone, Copy)]
82pub struct ModelSpec {
83    /// CLI-facing variant identifier: `"whisper-tiny.en"` or
84    /// `"speaker-wespeaker-en"`. Matches the `--variant` value the user
85    /// passes to `voice install-model`.
86    pub variant: &'static str,
87    /// Human label used in error messages: `"Whisper"` or `"Speaker"`.
88    pub kind_label: &'static str,
89    /// Subdirectory beneath `~/.omni-dev/voice/models/` where this
90    /// model's files live.
91    pub default_subdir: &'static str,
92    /// Files that must exist in the install directory for the model to
93    /// be considered installed.
94    pub required_files: &'static [&'static str],
95    /// Environment-variable override for the install directory.
96    pub env_var: &'static str,
97    /// Recommended `install-model` invocation, used verbatim in the
98    /// `ensure_model_present` error hint.
99    pub install_command: &'static str,
100    /// CLI flag that overrides the model path on consumer commands,
101    /// e.g. `"--model"` (Whisper) or `"--speaker-model"` (wespeaker).
102    pub model_flag: &'static str,
103    /// How to fetch the bytes.
104    pub source: ModelSource,
105}
106
107impl ModelSpec {
108    /// Default install directory: `~/.omni-dev/voice/models/<default_subdir>/`.
109    ///
110    /// `None` when the user's home directory cannot be located — same
111    /// failure mode as `dirs::home_dir()`.
112    pub fn default_dir(&self) -> Option<PathBuf> {
113        dirs::home_dir().map(|home| self.default_dir_from(&home))
114    }
115
116    /// Builds the default install directory beneath an explicit `home`,
117    /// without consulting `dirs::home_dir()`/`HOME`. Lets callers (and
118    /// tests) resolve the default-dir layout against an injected base
119    /// rather than the process-global home, sidestepping env mutation.
120    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    /// Resolves the install directory for this spec.
128    ///
129    /// Priority: `override_path` → env var → default. The returned path
130    /// is *not* validated for existence; pair with [`Self::ensure_present`]
131    /// for fail-fast.
132    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    /// Returns the absolute path of each required file inside `dir`.
152    pub fn required_files_in(&self, dir: &Path) -> Vec<PathBuf> {
153        self.required_files.iter().map(|f| dir.join(f)).collect()
154    }
155
156    /// Verifies that `dir` contains every file in `self.required_files`.
157    ///
158    /// On failure, returns the install hint shaped for this spec (the
159    /// `install_command` / `model_flag` baked into the spec).
160    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
179// ── Registered specs ──────────────────────────────────────────────────────
180
181/// Whisper `tiny.en` — production ASR runtime per ADR-0033.
182pub 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
196/// Wespeaker `voxceleb_resnet34_LM` — production speaker-embedding
197/// runtime per ADR-0034. Not yet wired to consumers; the speaker
198/// install variant lands in a follow-up commit.
199pub 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
214// ── Backwards-compatible Whisper helpers (thin shims) ────────────────────
215
216/// Returns the absolute path of each required model file inside `dir`.
217pub fn required_files_in(dir: &Path) -> Vec<PathBuf> {
218    WHISPER_TINY_EN.required_files_in(dir)
219}
220
221/// Computes the default install location: `~/.omni-dev/voice/models/whisper-tiny.en/`.
222///
223/// Returns `None` only when the user's home directory cannot be located
224/// (i.e. `dirs::home_dir()` returns `None`) — vanishingly rare in practice.
225pub fn default_whisper_model_dir() -> Option<PathBuf> {
226    WHISPER_TINY_EN.default_dir()
227}
228
229/// Resolves the Whisper model directory for the current invocation.
230///
231/// Priority: `opts.model` → `OMNI_DEV_VOICE_WHISPER_MODEL` → default.
232/// The returned path is *not* validated for existence; callers that need
233/// to fail-fast on missing files should pair this with [`ensure_model_present`].
234pub fn resolve_whisper_model_dir(opts: &VoiceOpts) -> Result<PathBuf> {
235    WHISPER_TINY_EN.resolve_dir(opts.model.as_deref())
236}
237
238/// Verifies that `dir` contains every file in [`REQUIRED_FILES`].
239///
240/// On failure, returns the install hint specified by issue #802:
241/// `"no Whisper model found at <path>; run `omni-dev voice install-model`
242/// or pass --model <path>"`.
243pub 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        // Write two of three required files; tokenizer.json missing.
323        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    // ── ModelSpec-shaped API tests ──────────────────────────────────────
340
341    #[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}