Skip to main content

omni_dev/voice/
factory.rs

1//! Backend factory for [`crate::voice::Transcriber`].
2//!
3//! Mirrors the [`create_default_claude_client`] dispatch pattern (one
4//! short-circuit per supported backend, sensible default last). Backend
5//! choice flows from, in order:
6//!
7//! 1. `opts.backend` (set by `--backend` from the CLI in #802),
8//! 2. `OMNI_DEV_VOICE_BACKEND` (env var, with project settings.json
9//!    fallback via [`crate::utils::settings::get_env_var`]),
10//! 3. Default — `"mock"` until the real ASR backend has been through a
11//!    release cycle; pick `--backend whisper-candle` explicitly. See
12//!    [`crate::voice::backends::candle`] and ADR-0033.
13//!
14//! [`create_default_claude_client`]: crate::claude::client::create_default_claude_client
15
16use std::path::PathBuf;
17
18use anyhow::{bail, Result};
19
20use crate::voice::backends::candle::CandleTranscriber;
21use crate::voice::backends::mock::MockTranscriber;
22use crate::voice::models::resolve_whisper_model_dir;
23use crate::voice::transcriber::Transcriber;
24
25/// Backend-selection options carried from the CLI (or constructed
26/// programmatically for tests).
27///
28/// `model` is plumbed through for future backends; `MockTranscriber`
29/// ignores it. When the real ASR backend lands this field will resolve
30/// the model file path (see #801 spec — `--model` → env
31/// `OMNI_DEV_VOICE_WHISPER_MODEL` → `~/.omni-dev/voice/models/...`).
32#[derive(Debug, Default, Clone)]
33pub struct VoiceOpts {
34    /// Explicit backend choice from `--backend`. `None` means "fall back
35    /// to env var, then default."
36    pub backend: Option<String>,
37    /// Path to a backend-specific model file. Ignored by the mock.
38    pub model: Option<PathBuf>,
39}
40
41/// Constructs the appropriate [`Transcriber`] given `opts` and the
42/// process environment.
43///
44/// Errors only on an unrecognised backend name. Backend-specific
45/// construction errors (missing model file, failed initialisation) bubble
46/// up from the backend's own `new`.
47pub fn create_default_transcriber(opts: &VoiceOpts) -> Result<Box<dyn Transcriber>> {
48    let backend = opts
49        .backend
50        .clone()
51        .or_else(|| crate::utils::settings::get_env_var("OMNI_DEV_VOICE_BACKEND").ok())
52        .unwrap_or_else(|| "mock".to_string());
53
54    match backend.as_str() {
55        "mock" => Ok(Box::new(MockTranscriber::new(
56            MockTranscriber::default_script(),
57        ))),
58        "whisper-candle" => {
59            let dir = resolve_whisper_model_dir(opts)?;
60            Ok(Box::new(CandleTranscriber::new(&dir)?))
61        }
62        other => {
63            bail!("unknown voice backend: {other:?} (supported: \"mock\", \"whisper-candle\")")
64        }
65    }
66}
67
68#[cfg(test)]
69#[allow(clippy::unwrap_used, clippy::expect_used)]
70mod tests {
71    use super::*;
72    use crate::voice::transcriber::{TranscriptEvent, VecAudioInput};
73    use std::sync::{Mutex, MutexGuard};
74
75    // Guard so env-var-mutating tests in this module don't race each other.
76    static ENV_GUARD: Mutex<()> = Mutex::new(());
77
78    fn env_guard() -> MutexGuard<'static, ()> {
79        match ENV_GUARD.lock() {
80            Ok(g) => g,
81            Err(poisoned) => poisoned.into_inner(),
82        }
83    }
84
85    fn collect(transcriber: &dyn Transcriber) -> Vec<TranscriptEvent> {
86        let input = VecAudioInput::from_samples(vec![0; 16_000], 1024);
87        transcriber
88            .transcribe(Box::new(input))
89            .unwrap()
90            .map(Result::unwrap)
91            .collect()
92    }
93
94    #[test]
95    fn default_backend_is_mock() {
96        let _g = env_guard();
97        std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
98        let t = create_default_transcriber(&VoiceOpts::default()).unwrap();
99        let events = collect(t.as_ref());
100        // Default script has 2 Finals; mock always appends 1 Endpoint.
101        assert_eq!(events.len(), 3);
102        assert!(matches!(events[0], TranscriptEvent::Final { .. }));
103        assert!(matches!(events[1], TranscriptEvent::Final { .. }));
104        assert!(matches!(events[2], TranscriptEvent::Endpoint { .. }));
105    }
106
107    #[test]
108    fn opts_backend_takes_precedence_over_env() {
109        let _g = env_guard();
110        std::env::set_var("OMNI_DEV_VOICE_BACKEND", "this-would-fail-if-read");
111        let opts = VoiceOpts {
112            backend: Some("mock".to_string()),
113            model: None,
114        };
115        let t = create_default_transcriber(&opts).unwrap();
116        let _ = collect(t.as_ref());
117        std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
118    }
119
120    #[test]
121    fn env_var_selects_backend_when_opts_absent() {
122        let _g = env_guard();
123        std::env::set_var("OMNI_DEV_VOICE_BACKEND", "mock");
124        let t = create_default_transcriber(&VoiceOpts::default()).unwrap();
125        let _ = collect(t.as_ref());
126        std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
127    }
128
129    #[test]
130    fn unknown_backend_errors() {
131        let _g = env_guard();
132        std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
133        let opts = VoiceOpts {
134            backend: Some("klingon".to_string()),
135            model: None,
136        };
137        let Err(err) = create_default_transcriber(&opts) else {
138            panic!("expected unknown backend to error");
139        };
140        let msg = err.to_string();
141        assert!(msg.contains("klingon"), "got: {msg}");
142        assert!(msg.contains("supported"), "got: {msg}");
143        assert!(msg.contains("whisper-candle"), "got: {msg}");
144    }
145
146    #[test]
147    fn whisper_candle_arm_propagates_missing_model_error() {
148        // The factory routes "whisper-candle" through CandleTranscriber::new,
149        // which calls ensure_model_present. Point --model at an empty dir
150        // and verify the install hint reaches the caller without partial
151        // initialisation.
152        let _g = env_guard();
153        std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
154        let tmp = tempfile::TempDir::new().unwrap();
155        let opts = VoiceOpts {
156            backend: Some("whisper-candle".to_string()),
157            model: Some(tmp.path().to_path_buf()),
158        };
159        let Err(err) = create_default_transcriber(&opts) else {
160            panic!("expected whisper-candle with empty model dir to error");
161        };
162        let msg = format!("{err:#}");
163        assert!(msg.contains("no Whisper model found"), "got: {msg}");
164        assert!(msg.contains("voice install-model"), "got: {msg}");
165    }
166}