use std::path::PathBuf;
use anyhow::{bail, Result};
use crate::voice::backends::candle::CandleTranscriber;
use crate::voice::backends::mock::MockTranscriber;
use crate::voice::models::resolve_whisper_model_dir;
use crate::voice::transcriber::Transcriber;
#[derive(Debug, Default, Clone)]
pub struct VoiceOpts {
pub backend: Option<String>,
pub model: Option<PathBuf>,
}
pub fn create_default_transcriber(opts: &VoiceOpts) -> Result<Box<dyn Transcriber>> {
let backend = opts
.backend
.clone()
.or_else(|| crate::utils::settings::get_env_var("OMNI_DEV_VOICE_BACKEND").ok())
.unwrap_or_else(|| "mock".to_string());
match backend.as_str() {
"mock" => Ok(Box::new(MockTranscriber::new(
MockTranscriber::default_script(),
))),
"whisper-candle" => {
let dir = resolve_whisper_model_dir(opts)?;
Ok(Box::new(CandleTranscriber::new(&dir)?))
}
other => {
bail!("unknown voice backend: {other:?} (supported: \"mock\", \"whisper-candle\")")
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use crate::voice::transcriber::{TranscriptEvent, VecAudioInput};
use std::sync::{Mutex, MutexGuard};
static ENV_GUARD: Mutex<()> = Mutex::new(());
fn env_guard() -> MutexGuard<'static, ()> {
match ENV_GUARD.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
}
}
fn collect(transcriber: &dyn Transcriber) -> Vec<TranscriptEvent> {
let input = VecAudioInput::from_samples(vec![0; 16_000], 1024);
transcriber
.transcribe(Box::new(input))
.unwrap()
.map(Result::unwrap)
.collect()
}
#[test]
fn default_backend_is_mock() {
let _g = env_guard();
std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
let t = create_default_transcriber(&VoiceOpts::default()).unwrap();
let events = collect(t.as_ref());
assert_eq!(events.len(), 3);
assert!(matches!(events[0], TranscriptEvent::Final { .. }));
assert!(matches!(events[1], TranscriptEvent::Final { .. }));
assert!(matches!(events[2], TranscriptEvent::Endpoint { .. }));
}
#[test]
fn opts_backend_takes_precedence_over_env() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_BACKEND", "this-would-fail-if-read");
let opts = VoiceOpts {
backend: Some("mock".to_string()),
model: None,
};
let t = create_default_transcriber(&opts).unwrap();
let _ = collect(t.as_ref());
std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
}
#[test]
fn env_var_selects_backend_when_opts_absent() {
let _g = env_guard();
std::env::set_var("OMNI_DEV_VOICE_BACKEND", "mock");
let t = create_default_transcriber(&VoiceOpts::default()).unwrap();
let _ = collect(t.as_ref());
std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
}
#[test]
fn unknown_backend_errors() {
let _g = env_guard();
std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
let opts = VoiceOpts {
backend: Some("klingon".to_string()),
model: None,
};
let Err(err) = create_default_transcriber(&opts) else {
panic!("expected unknown backend to error");
};
let msg = err.to_string();
assert!(msg.contains("klingon"), "got: {msg}");
assert!(msg.contains("supported"), "got: {msg}");
assert!(msg.contains("whisper-candle"), "got: {msg}");
}
#[test]
fn whisper_candle_arm_propagates_missing_model_error() {
let _g = env_guard();
std::env::remove_var("OMNI_DEV_VOICE_BACKEND");
let tmp = tempfile::TempDir::new().unwrap();
let opts = VoiceOpts {
backend: Some("whisper-candle".to_string()),
model: Some(tmp.path().to_path_buf()),
};
let Err(err) = create_default_transcriber(&opts) else {
panic!("expected whisper-candle with empty model dir to error");
};
let msg = format!("{err:#}");
assert!(msg.contains("no Whisper model found"), "got: {msg}");
assert!(msg.contains("voice install-model"), "got: {msg}");
}
}