omni_dev/voice/
factory.rs1use 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#[derive(Debug, Default, Clone)]
33pub struct VoiceOpts {
34 pub backend: Option<String>,
37 pub model: Option<PathBuf>,
39}
40
41pub 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 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 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 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}