Skip to main content

omni_dev/cli/voice/
install_model.rs

1//! `omni-dev voice install-model` — one-time fetch of model artefacts.
2//!
3//! Supports two variants: `whisper-tiny.en` for the `whisper-candle` ASR
4//! backend, and `speaker-wespeaker-en` for the speaker-embedding runtime
5//! added in #805 / ADR-0034. Files land in the conventional install
6//! locations beneath `~/.omni-dev/voice/models/`.
7//!
8//! Bumps the model-download cost to install time rather than transcribe/
9//! enrol time, so network failures surface explicitly when the user opts
10//! in to installing rather than silently on first use.
11
12use std::io::Write;
13use std::path::{Path, PathBuf};
14use std::time::Instant;
15
16use anyhow::{anyhow, bail, Context, Result};
17use clap::{Parser, ValueEnum};
18use hf_hub::{api::sync::Api, Repo, RepoType};
19use sha2::{Digest, Sha256};
20
21use crate::voice::models::{ModelSource, ModelSpec, SPEAKER_WESPEAKER_EN, WHISPER_TINY_EN};
22
23/// Which model variant to install.
24///
25/// `--variant` defaults to `whisper-tiny.en` so bare
26/// `voice install-model` continues to install the ASR model — the
27/// pre-#805 behaviour.
28#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, ValueEnum)]
29pub enum Variant {
30    /// OpenAI Whisper `tiny.en` (ADR-0033).
31    #[default]
32    #[value(name = "whisper-tiny.en")]
33    WhisperTinyEn,
34    /// Wespeaker `resnet34_LM` English-only speaker embedding (ADR-0034).
35    #[value(name = "speaker-wespeaker-en")]
36    SpeakerWespeakerEn,
37}
38
39impl Variant {
40    /// Returns the [`ModelSpec`] for this variant.
41    pub fn spec(self) -> &'static ModelSpec {
42        match self {
43            Self::WhisperTinyEn => &WHISPER_TINY_EN,
44            Self::SpeakerWespeakerEn => &SPEAKER_WESPEAKER_EN,
45        }
46    }
47}
48
49/// Downloads the model files for a chosen variant into the conventional
50/// install location at `~/.omni-dev/voice/models/<variant-subdir>/` (or
51/// `--dest` to override).
52///
53/// Idempotent: if every required file is already present and non-empty,
54/// the command prints a "model already installed" line and exits 0. Pass
55/// `--force` to re-download anyway.
56#[derive(Parser)]
57pub struct InstallModelCommand {
58    /// Override the install directory. Defaults to the variant's
59    /// canonical location under `~/.omni-dev/voice/models/`.
60    #[arg(long)]
61    pub dest: Option<PathBuf>,
62
63    /// Re-download even if all required files are already present.
64    #[arg(long)]
65    pub force: bool,
66
67    /// Which model variant to install. Defaults to `whisper-tiny.en`.
68    #[arg(long, value_enum, default_value_t = Variant::WhisperTinyEn)]
69    pub variant: Variant,
70}
71
72impl InstallModelCommand {
73    /// Entry point. Writes user-facing progress to stderr so stdout stays
74    /// reserved for machine-readable output (parity with `voice
75    /// transcribe`'s JSONL pipe-detection convention).
76    pub fn execute(self) -> Result<()> {
77        let mut err = std::io::stderr().lock();
78        self.run(&mut err)
79    }
80
81    /// Writer-generic core, parameterised over stderr so tests can drive
82    /// the success/idempotency paths without touching the global stream.
83    fn run<W: Write>(self, w: &mut W) -> Result<()> {
84        let home = dirs::home_dir();
85        self.run_in(w, home.as_deref())
86    }
87
88    /// `run` with the home directory injected rather than read from
89    /// `dirs::home_dir()`/`HOME`. The `dest: None` default-dir branch
90    /// resolves against `home`, so tests can exercise it against a
91    /// tempdir base without mutating the process-global `HOME` (the race
92    /// behind #978).
93    fn run_in<W: Write>(self, w: &mut W, home: Option<&Path>) -> Result<()> {
94        let spec = self.variant.spec();
95        let dest = if let Some(p) = self.dest {
96            p
97        } else {
98            let home = home
99                .ok_or_else(|| anyhow!("could not determine home directory; pass --dest <path>"))?;
100            spec.default_dir_from(home)
101        };
102
103        if !self.force && all_present(spec, &dest) {
104            writeln!(w, "model already installed at {}", dest.display())?;
105            return Ok(());
106        }
107
108        match spec.source {
109            ModelSource::HfHub { repo_id, revision } => {
110                download_hf_hub(spec, repo_id, revision, &dest, w)
111            }
112            ModelSource::HttpReleaseAsset { url, sha256, bytes } => {
113                download_release_asset(spec, url, sha256, bytes, &dest, w)
114            }
115        }
116    }
117}
118
119fn all_present(spec: &ModelSpec, dir: &Path) -> bool {
120    spec.required_files_in(dir)
121        .iter()
122        .all(|p| p.is_file() && p.metadata().is_ok_and(|m| m.len() > 0))
123}
124
125fn download_hf_hub<W: Write>(
126    spec: &ModelSpec,
127    repo_id: &str,
128    revision: &str,
129    dest: &Path,
130    w: &mut W,
131) -> Result<()> {
132    writeln!(
133        w,
134        "Installing {repo_id} (revision {revision}) -> {}",
135        dest.display()
136    )?;
137    std::fs::create_dir_all(dest)
138        .with_context(|| format!("create install directory at {}", dest.display()))?;
139
140    let api = Api::new().context("initialise HuggingFace Hub client")?;
141    let repo = api.repo(Repo::with_revision(
142        repo_id.to_string(),
143        RepoType::Model,
144        revision.to_string(),
145    ));
146
147    for file in spec.required_files {
148        let start = Instant::now();
149        write!(w, "  fetching {file}... ")?;
150        w.flush()?;
151        let downloaded = repo.get(file).with_context(|| {
152            format!(
153                "download {file} from {repo_id} (revision {revision}). \
154                 Check your network or set HTTPS_PROXY"
155            )
156        })?;
157        let target = dest.join(file);
158        atomic_install_copy(&downloaded, &target).with_context(|| {
159            format!(
160                "install {file} into {} (atomic rename failed)",
161                target.display()
162            )
163        })?;
164        let bytes = std::fs::metadata(&target).map_or(0, |m| m.len());
165        writeln!(
166            w,
167            "done ({bytes} bytes in {:.1}s)",
168            start.elapsed().as_secs_f64()
169        )?;
170    }
171
172    writeln!(
173        w,
174        "{} model installed at {}",
175        spec.kind_label,
176        dest.display()
177    )?;
178    Ok(())
179}
180
181fn download_release_asset<W: Write>(
182    spec: &ModelSpec,
183    url: &str,
184    expected_sha256: &str,
185    expected_bytes: u64,
186    dest: &Path,
187    w: &mut W,
188) -> Result<()> {
189    // Wespeaker (and any future single-asset release-driven model) ships
190    // exactly one file. The check is defensive: if a future spec mis-
191    // declares N!=1 with HttpReleaseAsset, fail loudly rather than
192    // silently install only the first.
193    if spec.required_files.len() != 1 {
194        bail!(
195            "HttpReleaseAsset source expects exactly one required_file, \
196             got {} for variant {}",
197            spec.required_files.len(),
198            spec.variant
199        );
200    }
201    let file_name = spec.required_files[0];
202    let target = dest.join(file_name);
203
204    writeln!(
205        w,
206        "Installing {file_name} ({expected_bytes} B) -> {}",
207        dest.display()
208    )?;
209    std::fs::create_dir_all(dest)
210        .with_context(|| format!("create install directory at {}", dest.display()))?;
211
212    let start = Instant::now();
213    write!(w, "  fetching {url}... ")?;
214    w.flush()?;
215
216    let resp = ureq::get(url)
217        .call()
218        .with_context(|| format!("HTTP GET {url}"))?;
219    let status = resp.status();
220    if !status.is_success() {
221        bail!(
222            "HTTP {} fetching {url}: {}",
223            status.as_u16(),
224            status.canonical_reason().unwrap_or("Unknown"),
225        );
226    }
227    let bytes = resp
228        .into_body()
229        .read_to_vec()
230        .with_context(|| format!("read response body for {url}"))?;
231
232    let actual_sha = {
233        let mut hasher = Sha256::new();
234        hasher.update(&bytes);
235        let digest = hasher.finalize();
236        let mut hex = String::with_capacity(digest.len() * 2);
237        for byte in digest {
238            use std::fmt::Write as _;
239            // `write!` into a `String` is infallible.
240            let _ = write!(&mut hex, "{byte:02x}");
241        }
242        hex
243    };
244    if !actual_sha.eq_ignore_ascii_case(expected_sha256) {
245        bail!("SHA-256 mismatch for {file_name}: expected {expected_sha256}, got {actual_sha}");
246    }
247
248    atomic_install_bytes(&bytes, &target).with_context(|| {
249        format!(
250            "install {file_name} into {} (atomic rename failed)",
251            target.display()
252        )
253    })?;
254    writeln!(
255        w,
256        "done ({} bytes in {:.1}s; sha256 verified)",
257        bytes.len(),
258        start.elapsed().as_secs_f64()
259    )?;
260    writeln!(
261        w,
262        "{} model installed at {}",
263        spec.kind_label,
264        dest.display()
265    )?;
266    Ok(())
267}
268
269/// Writes `bytes` to a `.part` sibling of `to`, then atomically renames
270/// so a partial download never leaves a half-written file at `to`.
271fn atomic_install_bytes(bytes: &[u8], to: &Path) -> Result<()> {
272    if let Some(parent) = to.parent() {
273        std::fs::create_dir_all(parent)
274            .with_context(|| format!("create parent dir {}", parent.display()))?;
275    }
276    let tmp = part_sibling(to)?;
277    std::fs::write(&tmp, bytes)
278        .with_context(|| format!("write {} bytes -> {}", bytes.len(), tmp.display()))?;
279    std::fs::rename(&tmp, to)
280        .with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
281    Ok(())
282}
283
284/// Copies `from` into `to` via a temp file sibling + rename so a partial
285/// download never leaves a half-written file at the destination.
286fn atomic_install_copy(from: &Path, to: &Path) -> Result<()> {
287    if let Some(parent) = to.parent() {
288        std::fs::create_dir_all(parent)
289            .with_context(|| format!("create parent dir {}", parent.display()))?;
290    }
291    let tmp = part_sibling(to)?;
292    std::fs::copy(from, &tmp)
293        .with_context(|| format!("copy {} -> {}", from.display(), tmp.display()))?;
294    std::fs::rename(&tmp, to)
295        .with_context(|| format!("rename {} -> {}", tmp.display(), to.display()))?;
296    Ok(())
297}
298
299fn part_sibling(to: &Path) -> Result<PathBuf> {
300    let file_name = to
301        .file_name()
302        .ok_or_else(|| anyhow!("destination path has no file name: {}", to.display()))?;
303    let mut tmp_name = std::ffi::OsString::from(".");
304    tmp_name.push(file_name);
305    tmp_name.push(".part");
306    Ok(to.with_file_name(tmp_name))
307}
308
309#[cfg(test)]
310#[allow(clippy::unwrap_used, clippy::expect_used)]
311mod tests {
312    use super::*;
313    use crate::voice::models::REQUIRED_FILES;
314
315    fn stage_complete_whisper_model(dir: &Path) {
316        std::fs::create_dir_all(dir).unwrap();
317        for f in REQUIRED_FILES {
318            std::fs::write(dir.join(f), b"placeholder").unwrap();
319        }
320    }
321
322    fn stage_complete_speaker_model(dir: &Path) {
323        std::fs::create_dir_all(dir).unwrap();
324        for f in SPEAKER_WESPEAKER_EN.required_files {
325            std::fs::write(dir.join(f), b"placeholder").unwrap();
326        }
327    }
328
329    #[test]
330    fn idempotent_when_all_files_present() {
331        let tmp = tempfile::TempDir::new().unwrap();
332        stage_complete_whisper_model(tmp.path());
333
334        let cmd = InstallModelCommand {
335            dest: Some(tmp.path().to_path_buf()),
336            force: false,
337            variant: Variant::WhisperTinyEn,
338        };
339        let mut out: Vec<u8> = Vec::new();
340        cmd.run(&mut out).unwrap();
341        let msg = String::from_utf8(out).unwrap();
342        assert!(msg.contains("already installed"), "got: {msg}");
343    }
344
345    #[test]
346    fn idempotent_when_speaker_model_present() {
347        let tmp = tempfile::TempDir::new().unwrap();
348        stage_complete_speaker_model(tmp.path());
349
350        let cmd = InstallModelCommand {
351            dest: Some(tmp.path().to_path_buf()),
352            force: false,
353            variant: Variant::SpeakerWespeakerEn,
354        };
355        let mut out: Vec<u8> = Vec::new();
356        cmd.run(&mut out).unwrap();
357        let msg = String::from_utf8(out).unwrap();
358        assert!(msg.contains("already installed"), "got: {msg}");
359    }
360
361    #[test]
362    fn idempotent_skip_treats_zero_byte_file_as_missing() {
363        let tmp = tempfile::TempDir::new().unwrap();
364        std::fs::create_dir_all(tmp.path()).unwrap();
365        for f in REQUIRED_FILES {
366            // Zero-byte file would normally pass `is_file()` but is_not
367            // a valid model artifact; the idempotency check must reject it.
368            std::fs::write(tmp.path().join(f), b"").unwrap();
369        }
370        assert!(!all_present(&WHISPER_TINY_EN, tmp.path()));
371    }
372
373    #[test]
374    fn atomic_install_copy_replaces_target() {
375        let tmp = tempfile::TempDir::new().unwrap();
376        let src = tmp.path().join("src");
377        let dst = tmp.path().join("dst");
378        std::fs::write(&src, b"hello").unwrap();
379        std::fs::write(&dst, b"old").unwrap();
380        atomic_install_copy(&src, &dst).unwrap();
381        let got = std::fs::read(&dst).unwrap();
382        assert_eq!(got, b"hello");
383        // No leftover temp file.
384        let leftover = std::fs::read_dir(tmp.path())
385            .unwrap()
386            .filter_map(Result::ok)
387            .any(|e| e.file_name().to_string_lossy().ends_with(".part"));
388        assert!(!leftover, "atomic_install_copy must not leave .part files");
389    }
390
391    #[test]
392    fn atomic_install_bytes_writes_and_renames() {
393        let tmp = tempfile::TempDir::new().unwrap();
394        let dst = tmp.path().join("out");
395        atomic_install_bytes(b"hello", &dst).unwrap();
396        assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
397        let leftover = std::fs::read_dir(tmp.path())
398            .unwrap()
399            .filter_map(Result::ok)
400            .any(|e| e.file_name().to_string_lossy().ends_with(".part"));
401        assert!(!leftover, "atomic_install_bytes must not leave .part files");
402    }
403
404    #[test]
405    fn parses_no_args() {
406        #[derive(Parser)]
407        struct T {
408            #[command(flatten)]
409            c: InstallModelCommand,
410        }
411        let t = T::try_parse_from(["test"]).unwrap();
412        assert!(t.c.dest.is_none());
413        assert!(!t.c.force);
414        assert_eq!(t.c.variant, Variant::WhisperTinyEn);
415    }
416
417    #[test]
418    fn parses_dest_and_force() {
419        #[derive(Parser)]
420        struct T {
421            #[command(flatten)]
422            c: InstallModelCommand,
423        }
424        let t = T::try_parse_from(["test", "--dest", "/opt/x", "--force"]).unwrap();
425        assert_eq!(t.c.dest.as_deref(), Some(Path::new("/opt/x")));
426        assert!(t.c.force);
427    }
428
429    #[test]
430    fn parses_speaker_variant() {
431        #[derive(Parser)]
432        struct T {
433            #[command(flatten)]
434            c: InstallModelCommand,
435        }
436        let t = T::try_parse_from(["test", "--variant", "speaker-wespeaker-en"]).unwrap();
437        assert_eq!(t.c.variant, Variant::SpeakerWespeakerEn);
438    }
439
440    #[test]
441    fn parses_whisper_variant_explicit() {
442        #[derive(Parser)]
443        struct T {
444            #[command(flatten)]
445            c: InstallModelCommand,
446        }
447        let t = T::try_parse_from(["test", "--variant", "whisper-tiny.en"]).unwrap();
448        assert_eq!(t.c.variant, Variant::WhisperTinyEn);
449    }
450
451    #[test]
452    fn rejects_unknown_variant() {
453        #[derive(Parser)]
454        struct T {
455            #[command(flatten)]
456            c: InstallModelCommand,
457        }
458        let err = T::try_parse_from(["test", "--variant", "klingon"]);
459        assert!(err.is_err(), "unknown variant should fail to parse");
460    }
461
462    #[test]
463    fn run_with_dest_none_resolves_default_install_dir_from_home() {
464        // Covers the `match self.dest { None => spec.default_dir_from()… }`
465        // arm — the priority-3 path that the explicit-dest tests skip.
466        // The home base is injected via `run_in`, so we stage the model
467        // files at the default location beneath a tempdir without
468        // mutating the process-global `HOME` (the #978 race).
469        let tmp = tempfile::TempDir::new().unwrap();
470        let default_dir = WHISPER_TINY_EN.default_dir_from(tmp.path());
471        stage_complete_whisper_model(&default_dir);
472
473        let cmd = InstallModelCommand {
474            dest: None,
475            force: false,
476            variant: Variant::WhisperTinyEn,
477        };
478        let mut out: Vec<u8> = Vec::new();
479        cmd.run_in(&mut out, Some(tmp.path())).unwrap();
480
481        let msg = String::from_utf8(out).unwrap();
482        assert!(msg.contains("already installed"), "got: {msg}");
483        assert!(
484            msg.contains("whisper-tiny.en"),
485            "expected resolved default dir in message, got: {msg}"
486        );
487    }
488
489    #[test]
490    fn run_speaker_variant_with_dest_none_resolves_default() {
491        let tmp = tempfile::TempDir::new().unwrap();
492        let default_dir = SPEAKER_WESPEAKER_EN.default_dir_from(tmp.path());
493        stage_complete_speaker_model(&default_dir);
494
495        let cmd = InstallModelCommand {
496            dest: None,
497            force: false,
498            variant: Variant::SpeakerWespeakerEn,
499        };
500        let mut out: Vec<u8> = Vec::new();
501        cmd.run_in(&mut out, Some(tmp.path())).unwrap();
502
503        let msg = String::from_utf8(out).unwrap();
504        assert!(msg.contains("already installed"), "got: {msg}");
505        assert!(
506            msg.contains("wespeaker-en-voxceleb-resnet34-LM"),
507            "expected resolved default dir in message, got: {msg}"
508        );
509    }
510
511    #[test]
512    fn run_in_errors_when_home_unavailable_and_dest_none() {
513        // The `home: None` arm of the default-dir branch — deterministically
514        // reachable via `run_in` without simulating a missing `HOME`.
515        let cmd = InstallModelCommand {
516            dest: None,
517            force: false,
518            variant: Variant::WhisperTinyEn,
519        };
520        let mut out: Vec<u8> = Vec::new();
521        let err = cmd.run_in(&mut out, None).unwrap_err();
522        assert!(
523            format!("{err:#}").contains("could not determine home directory"),
524            "got: {err:#}"
525        );
526    }
527
528    #[test]
529    fn variant_spec_returns_correct_spec() {
530        assert_eq!(
531            Variant::WhisperTinyEn.spec().variant,
532            WHISPER_TINY_EN.variant
533        );
534        assert_eq!(
535            Variant::SpeakerWespeakerEn.spec().variant,
536            SPEAKER_WESPEAKER_EN.variant
537        );
538    }
539}