diff --git a/crates/voice-cli/src/cli.rs b/crates/voice-cli/src/cli.rs index 2f634ec..e1c3a44 100644 --- a/crates/voice-cli/src/cli.rs +++ b/crates/voice-cli/src/cli.rs @@ -299,6 +299,21 @@ impl TtsEngine { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum SttBackendArg { + Whisper, + Voxtral, +} + +impl SttBackendArg { + fn to_backend(self) -> voice_stt::SttBackend { + match self { + Self::Whisper => voice_stt::SttBackend::Whisper, + Self::Voxtral => voice_stt::SttBackend::Voxtral, + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] enum RealtimeAssistantBackend { Echo, @@ -399,6 +414,24 @@ fn validate_voice_for_engine(engine: TtsEngine, voice: &str) -> Result<(), Strin } } +fn stt_load_options( + backend: Option, + model: Option, + max_new_tokens: Option, +) -> listen::SttLoadOptions { + listen::SttLoadOptions { + backend: backend.map(SttBackendArg::to_backend), + model, + max_new_tokens, + } +} + +fn stt_selection_is_explicit(options: &listen::SttLoadOptions) -> bool { + options.is_explicit() + || std::env::var_os("STT_BACKEND").is_some() + || std::env::var_os("STT_MODEL").is_some() +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct EffectiveVoxtralOptions { max_frames: usize, @@ -1076,12 +1109,36 @@ struct ListenArgs { /// Segments are split on silence and transcribed in the background. #[arg(long)] continuous: bool, + + /// STT backend to use locally or via STT_BACKEND. Defaults to whisper. + #[arg(long = "stt-backend", value_enum)] + stt_backend: Option, + + /// STT model path or HuggingFace repo. Defaults from the selected backend. + #[arg(long = "stt-model")] + stt_model: Option, + + /// Maximum new text tokens for Voxtral Realtime STT generation. + #[arg(long = "stt-max-new-tokens")] + stt_max_new_tokens: Option, } #[derive(clap::Args, Debug)] struct TranscribeArgs { /// Path to an audio file file: PathBuf, + + /// STT backend to use locally or via STT_BACKEND. Defaults to whisper. + #[arg(long = "stt-backend", value_enum)] + stt_backend: Option, + + /// STT model path or HuggingFace repo. Defaults from the selected backend. + #[arg(long = "stt-model")] + stt_model: Option, + + /// Maximum new text tokens for Voxtral Realtime STT generation. + #[arg(long = "stt-max-new-tokens")] + stt_max_new_tokens: Option, } #[derive(clap::Args, Debug)] @@ -1713,33 +1770,55 @@ fn main() { match args.command { Some(Command::Listen(listen_args)) => { + let stt_options = stt_load_options( + listen_args.stt_backend, + listen_args.stt_model, + listen_args.stt_max_new_tokens, + ); if listen_args.continuous { - listen::listen_continuous(); - } else if let Some(mut daemon) = voice_protocol::client::DaemonClient::connect() { - match daemon.listen(None) { - Ok(resp) => { - if let Some(result) = resp.result { - if let Some(r) = result.get("result").and_then(|v| v.as_str()) { - println!("{}", r); + if stt_selection_is_explicit(&stt_options) { + listen::listen_continuous_with_options(stt_options); + } else { + listen::listen_continuous(); + } + } else if !stt_selection_is_explicit(&stt_options) { + if let Some(mut daemon) = voice_protocol::client::DaemonClient::connect() { + match daemon.listen(None) { + Ok(resp) => { + if let Some(result) = resp.result { + if let Some(r) = result.get("result").and_then(|v| v.as_str()) { + println!("{}", r); + } + } else if let Some(err) = resp.error { + eprintln!("Daemon error: {}", err.message); } - } else if let Some(err) = resp.error { - eprintln!("Daemon error: {}", err.message); + } + Err(e) => { + eprintln!("Daemon error: {e}, falling back to local"); + listen::listen_and_transcribe(); } } - Err(e) => { - eprintln!("Daemon error: {e}, falling back to local"); - listen::listen_and_transcribe(); - } + } else { + listen::listen_and_transcribe(); } } else { - listen::listen_and_transcribe(); + listen::listen_and_transcribe_with_options(stt_options); } } Some(Command::Converse(converse_args)) => { run_converse(converse_args); } Some(Command::Transcribe(transcribe_args)) => { - listen::transcribe_file(&transcribe_args.file); + let stt_options = stt_load_options( + transcribe_args.stt_backend, + transcribe_args.stt_model, + transcribe_args.stt_max_new_tokens, + ); + if stt_selection_is_explicit(&stt_options) { + listen::transcribe_file_with_options(&transcribe_args.file, stt_options); + } else { + listen::transcribe_file(&transcribe_args.file); + } } Some(Command::Serve(serve_args)) => { run_serve(serve_args); @@ -4343,9 +4422,10 @@ fn run_realtime_inner(args: RealtimeArgs) -> Result<(), String> { ); let mut stt_model = listen::load_stt(); + let stt_backend = stt_model.backend(); let warm_mic = listen::WarmMic::open().map_err(|e| format!("open microphone: {e}"))?; let mut metrics = RealtimeLiveMetrics { - stt_backend: "foreground_whisper_vad".to_string(), + stt_backend: format!("foreground_{}_vad", stt_backend.as_str()), tts_backend: if args.speaker { format!("daemon_stream_speak/{}", args.tts_engine.as_str()) } else { @@ -4436,7 +4516,7 @@ fn run_realtime_inner(args: RealtimeArgs) -> Result<(), String> { #[allow(clippy::too_many_arguments)] fn run_realtime_live_turn( daemon: Option<&mut voice_protocol::client::DaemonClient>, - stt_model: &mut voice_stt::WhisperModel, + stt_model: &mut voice_stt::SttModel, warm_mic: &listen::WarmMic, args: &RealtimeArgs, voice: Option<&str>, @@ -4681,7 +4761,7 @@ fn emit_realtime_live_speech_started( } fn maybe_emit_realtime_partial_transcription( - stt_model: &mut voice_stt::WhisperModel, + stt_model: &mut voice_stt::SttModel, args: &RealtimeArgs, event_names: &mut Vec, turn_index: u64, @@ -6484,7 +6564,7 @@ fn run_converse(args: ConverseArgs) { } fn finish_converse_listen( - stt_handle: std::thread::JoinHandle, + stt_handle: std::thread::JoinHandle, duration: u64, ) { // STT should be loaded by now (TTS playback took seconds) @@ -7344,6 +7424,51 @@ mod tests { assert!(!should_emit_stream_summaries(true, true)); } + #[test] + fn parses_stt_backend_options_for_listen_and_transcribe() { + let listen = Args::parse_from([ + "voice", + "listen", + "--stt-backend", + "voxtral", + "--stt-model", + "mistralai/Voxtral-Mini-4B-Realtime-2602", + "--stt-max-new-tokens", + "64", + ]); + let Some(Command::Listen(listen)) = listen.command else { + panic!("expected listen command"); + }; + assert_eq!(listen.stt_backend, Some(SttBackendArg::Voxtral)); + assert_eq!( + listen.stt_model.as_deref(), + Some("mistralai/Voxtral-Mini-4B-Realtime-2602") + ); + assert_eq!(listen.stt_max_new_tokens, Some(64)); + + let transcribe = Args::parse_from([ + "voice", + "transcribe", + "--stt-backend", + "whisper", + "--stt-model", + "distil-whisper/distil-large-v3.5", + "--stt-max-new-tokens", + "32", + "/tmp/recording.wav", + ]); + let Some(Command::Transcribe(transcribe)) = transcribe.command else { + panic!("expected transcribe command"); + }; + assert_eq!(transcribe.file, PathBuf::from("/tmp/recording.wav")); + assert_eq!(transcribe.stt_backend, Some(SttBackendArg::Whisper)); + assert_eq!( + transcribe.stt_model.as_deref(), + Some("distil-whisper/distil-large-v3.5") + ); + assert_eq!(transcribe.stt_max_new_tokens, Some(32)); + } + #[test] fn parses_realtime_smoke_goal_shape() { let args = Args::parse_from([ diff --git a/crates/voice-cli/src/jsonrpc.rs b/crates/voice-cli/src/jsonrpc.rs index fcc671c..d524ca4 100644 --- a/crates/voice-cli/src/jsonrpc.rs +++ b/crates/voice-cli/src/jsonrpc.rs @@ -180,7 +180,7 @@ struct Session { /// Cache of loaded voices so we don't re-load on every `speak`. voice_cache: HashMap, /// Lazily-loaded STT model (only initialized on first `listen` call). - stt_model: Option, + stt_model: Option, } impl Session { @@ -429,14 +429,11 @@ fn handle_listen(session: &mut Session, params: Value) -> Result // Lazily load STT model on first listen call if session.stt_model.is_none() { - let repo = std::env::var("STT_MODEL") - .unwrap_or_else(|_| voice_stt::builtin::DEFAULT_MODEL_REPO.to_string()); - if !QUIET.load(Ordering::Relaxed) { - eprintln!("Loading STT model ({repo})..."); + eprintln!("Loading STT model..."); } - let model = voice_stt::load_model(&repo) + let model = listen::try_load_stt() .map_err(|e| RpcErr::internal(format!("Failed to load STT model: {e}")))?; session.stt_model = Some(model); diff --git a/crates/voice-cli/src/listen.rs b/crates/voice-cli/src/listen.rs index 4fd1fef..2ea78c1 100644 --- a/crates/voice-cli/src/listen.rs +++ b/crates/voice-cli/src/listen.rs @@ -1160,12 +1160,12 @@ pub fn record_continuous( /// Consume segments from the recording queue and transcribe each one. /// /// Spawns a thread that pulls segments, trims silence, resamples, and -/// runs Whisper inference. Results are sent to the returned receiver. +/// runs STT inference. Results are sent to the returned receiver. /// /// The model is moved into this thread because single-threaded access is /// required by the decoder state. pub fn transcribe_segments( - mut model: voice_stt::WhisperModel, + mut model: voice_stt::SttModel, segments: mpsc::Receiver, ) -> mpsc::Receiver { let (tx, rx) = mpsc::channel::(); @@ -1182,7 +1182,7 @@ pub fn transcribe_segments( } let t0 = Instant::now(); - let result = voice_stt::transcribe_audio(&mut model, &trimmed, segment.sample_rate); + let result = model.transcribe_audio(&trimmed, segment.sample_rate); let transcribe_ms = t0.elapsed().as_millis() as u64; @@ -1217,7 +1217,11 @@ pub fn transcribe_segments( /// /// Entry point for `voice listen --continuous`. pub fn listen_continuous() { - let model = load_stt(); + listen_continuous_with_options(SttLoadOptions::default()); +} + +pub fn listen_continuous_with_options(options: SttLoadOptions) { + let model = load_stt_with_options(options); if !QUIET.load(Ordering::Relaxed) { eprintln!("Listening continuously... (Ctrl+C to stop)\n"); @@ -1268,7 +1272,7 @@ pub fn listen_continuous() { /// /// Bluetooth microphones (e.g. AirPods) can take ~0.5-1s before audio /// actually flows, producing a block of zeros at the start. Whisper -/// is sensitive to the silence-to-speech ratio, especially on short +/// STT is sensitive to the silence-to-speech ratio, especially on short /// recordings — trimming silence dramatically improves accuracy. fn trim_silence(samples: &[f32], sample_rate: u32) -> Vec { if samples.is_empty() { @@ -1367,44 +1371,102 @@ fn days_to_ymd(days: u64) -> (u64, u64, u64) { // ── STT model helpers ────────────────────────────────────────────────── -/// STT model the CLI loads. Defaults to the shared +#[derive(Debug, Clone, Default)] +pub struct SttLoadOptions { + pub backend: Option, + pub model: Option, + pub max_new_tokens: Option, +} + +impl SttLoadOptions { + pub fn is_explicit(&self) -> bool { + self.backend.is_some() || self.model.is_some() || self.max_new_tokens.is_some() + } +} + +/// STT backend/model the CLI loads. Defaults to Whisper with the shared /// [`voice_stt::builtin::DEFAULT_MODEL_REPO`] (distil-large-v3.5). Override with -/// `STT_MODEL`, e.g. `distil-whisper/distil-medium.en` for smaller/faster or -/// `openai/whisper-large-v3` for multilingual. -fn stt_model_repo() -> String { - std::env::var("STT_MODEL") - .unwrap_or_else(|_| voice_stt::builtin::DEFAULT_MODEL_REPO.to_string()) +/// `STT_BACKEND=voxtral` and/or `STT_MODEL`, or pass explicit CLI options. +fn resolve_stt_load_options(options: &SttLoadOptions) -> Result<(voice_stt::SttBackend, String), String> { + let env_backend = match std::env::var("STT_BACKEND") { + Ok(value) => Some(voice_stt::SttBackend::parse(value.trim()).map_err(|e| e.to_string())?), + Err(_) => None, + }; + let env_model = std::env::var("STT_MODEL").ok(); + let backend = options.backend.or(env_backend); + let model = options.model.as_deref().or(env_model.as_deref()); + Ok(voice_stt::resolve_backend_and_model(backend, model)) +} + +fn default_stt_device_label() -> &'static str { + #[cfg(target_os = "macos")] + { + "metal:0" + } + + #[cfg(not(target_os = "macos"))] + { + "cpu" + } } /// Load STT model. Prints progress to stderr unless quiet. /// /// The tokenizer is loaded internally by the model — no separate load needed. -pub fn load_stt() -> voice_stt::WhisperModel { - let repo = stt_model_repo(); - - if !QUIET.load(Ordering::Relaxed) { - eprintln!("Loading speech-to-text model ({repo})..."); - } +pub fn load_stt() -> voice_stt::SttModel { + load_stt_with_options(SttLoadOptions::default()) +} - let model = match voice_stt::load_model(&repo) { - Ok(m) => m, +pub fn load_stt_with_options(options: SttLoadOptions) -> voice_stt::SttModel { + match try_load_stt_with_options(options) { + Ok(model) => model, Err(e) => { eprintln!("Failed to load STT model: {e}"); eprintln!("Model weights will be downloaded from HuggingFace on first run."); std::process::exit(1); } + } +} + +pub fn try_load_stt() -> Result { + try_load_stt_with_options(SttLoadOptions::default()) +} + +pub fn try_load_stt_with_options(options: SttLoadOptions) -> Result { + let (backend, repo) = match resolve_stt_load_options(&options) { + Ok(resolved) => resolved, + Err(e) => return Err(format!("invalid STT configuration: {e}")), }; if !QUIET.load(Ordering::Relaxed) { - eprintln!("Model loaded. Ready to listen.\n"); + eprintln!( + "Loading speech-to-text model (backend={}, model={repo})...", + backend.as_str() + ); + } + + let device_label = default_stt_device_label(); + let load_start = Instant::now(); + let device = voice_stt::default_stt_device().map_err(|e| e.to_string())?; + let mut model = voice_stt::load_backend_model_on_device(backend, &repo, device) + .map_err(|e| e.to_string())?; + if let Some(max_new_tokens) = options.max_new_tokens { + model.set_max_new_tokens(max_new_tokens); + } + + if !QUIET.load(Ordering::Relaxed) { + eprintln!( + "Model loaded on {device_label} in {:.2}s. Ready to listen.\n", + load_start.elapsed().as_secs_f32() + ); } - model + Ok(model) } /// Run transcription on recorded audio with silence trimming. pub(crate) fn transcribe_samples( - model: &mut voice_stt::WhisperModel, + model: &mut voice_stt::SttModel, samples: &[f32], sample_rate: u32, ) -> Option { @@ -1414,7 +1476,7 @@ pub(crate) fn transcribe_samples( /// Run transcription on recorded audio with silence trimming, suppressing /// best-effort partial failures. pub(crate) fn transcribe_samples_best_effort( - model: &mut voice_stt::WhisperModel, + model: &mut voice_stt::SttModel, samples: &[f32], sample_rate: u32, ) -> Option { @@ -1422,7 +1484,7 @@ pub(crate) fn transcribe_samples_best_effort( } fn transcribe_samples_inner( - model: &mut voice_stt::WhisperModel, + model: &mut voice_stt::SttModel, samples: &[f32], sample_rate: u32, report_failures: bool, @@ -1451,7 +1513,7 @@ fn transcribe_samples_inner( eprintln!("Transcribing {:.1}s of audio...", trimmed_duration); } - match voice_stt::transcribe_audio(model, &trimmed, sample_rate) { + match model.transcribe_audio(&trimmed, sample_rate) { Ok(r) => Some(r), Err(e) => { if report_failures { @@ -1468,7 +1530,11 @@ fn transcribe_samples_inner( /// /// Entry point for `voice listen`. pub fn listen_and_transcribe() { - let mut model = load_stt(); + listen_and_transcribe_with_options(SttLoadOptions::default()); +} + +pub fn listen_and_transcribe_with_options(options: SttLoadOptions) { + let mut model = load_stt_with_options(options); let (samples, sample_rate) = match record_until_interrupt() { Ok(r) => r, @@ -1500,7 +1566,7 @@ pub fn listen_and_transcribe() { /// Used by the JSON-RPC `listen` method. Returns `None` if no speech /// was detected or transcription failed. pub fn listen_and_transcribe_vad( - model: &mut voice_stt::WhisperModel, + model: &mut voice_stt::SttModel, max_duration_ms: u64, silence_timeout_ms: u64, silence_threshold: f32, @@ -1532,7 +1598,7 @@ pub fn listen_and_transcribe_vad( /// /// The mic stays open after recording — caller retains ownership. pub fn listen_and_transcribe_vad_warm( - model: &mut voice_stt::WhisperModel, + model: &mut voice_stt::SttModel, warm_mic: &WarmMic, max_duration_ms: u64, silence_timeout_ms: u64, @@ -1565,7 +1631,11 @@ pub fn listen_and_transcribe_vad_warm( /// /// Entry point for `voice --transcribe `. pub fn transcribe_file(path: &Path) { - let mut model = load_stt(); + transcribe_file_with_options(path, SttLoadOptions::default()); +} + +pub fn transcribe_file_with_options(path: &Path, options: SttLoadOptions) { + let mut model = load_stt_with_options(options); if !QUIET.load(Ordering::Relaxed) { eprintln!("Transcribing: {}", path.display()); @@ -1589,7 +1659,7 @@ pub fn transcribe_file(path: &Path) { ); } - let result = match voice_stt::transcribe_audio(&mut model, &audio.samples, audio.sample_rate) { + let result = match model.transcribe_audio(&audio.samples, audio.sample_rate) { Ok(r) => r, Err(e) => { eprintln!("Transcription failed: {e}"); diff --git a/crates/voice-cli/src/mcp.rs b/crates/voice-cli/src/mcp.rs index 9d1897d..65a7587 100644 --- a/crates/voice-cli/src/mcp.rs +++ b/crates/voice-cli/src/mcp.rs @@ -149,7 +149,7 @@ struct Session { subs: Vec<(String, String)>, phoneme_overrides: HashMap, voice_cache: HashMap, - stt_model: Option, + stt_model: Option, /// Persistent mic — kept open across calls to avoid Bluetooth HFP switches. warm_mic: Option, mem_stats: bool, @@ -850,14 +850,11 @@ fn voice_listen(session: &mut Session, params: Value) -> Result { let calibration_ms = p.calibration_ms.unwrap_or(500); if session.stt_model.is_none() { - let repo = std::env::var("STT_MODEL") - .unwrap_or_else(|_| voice_stt::builtin::DEFAULT_MODEL_REPO.to_string()); - if !QUIET.load(Ordering::Relaxed) { - eprintln!("Loading STT model ({repo})..."); + eprintln!("Loading STT model..."); } - let model = voice_stt::load_model(&repo) + let model = listen::try_load_stt() .map_err(|e| RpcErr::internal(format!("Failed to load STT model: {e}")))?; session.stt_model = Some(model); diff --git a/crates/voice-daemon/src/worker.rs b/crates/voice-daemon/src/worker.rs index b49d03c..861447e 100644 --- a/crates/voice-daemon/src/worker.rs +++ b/crates/voice-daemon/src/worker.rs @@ -25,14 +25,31 @@ use voice_voxtral::{VoxtralGenerationOptions, VoxtralStreamingConfig, VoxtralTts const MODEL_REPO: &str = "prince-canuma/Kokoro-82M"; -/// STT model the daemon loads. Defaults to the shared -/// [`voice_stt::builtin::DEFAULT_MODEL_REPO`] but is overridable with the -/// `STT_MODEL` env var, so the service file can pin a larger/different model -/// (e.g. `openai/whisper-large-v3`) without recompiling. Mirrors the CLI's -/// `listen::stt_model_repo`. -fn stt_repo() -> String { - std::env::var("STT_MODEL") - .unwrap_or_else(|_| voice_stt::builtin::DEFAULT_MODEL_REPO.to_string()) +/// STT backend/model the daemon loads. Defaults to Whisper with the shared +/// [`voice_stt::builtin::DEFAULT_MODEL_REPO`] but is overridable with +/// `STT_BACKEND` and/or `STT_MODEL`, so the service file can pin a larger +/// Whisper model or the experimental Voxtral Realtime backend without +/// recompiling. +fn stt_backend_and_model() -> Result<(voice_stt::SttBackend, String), String> { + let backend = match std::env::var("STT_BACKEND") { + Ok(value) => Some(voice_stt::SttBackend::parse(value.trim()).map_err(|e| e.to_string())?), + Err(_) => None, + }; + let model = std::env::var("STT_MODEL").ok(); + Ok(voice_stt::resolve_backend_and_model( + backend, + model.as_deref(), + )) +} + +fn load_stt_model() -> Result { + let (backend, repo) = stt_backend_and_model()?; + eprintln!( + "voice daemon: loading STT model (backend={}, model={})...", + backend.as_str(), + repo + ); + voice_stt::load_backend_model(backend, &repo).map_err(|e| format!("stt load_model: {}", e)) } const KOKORO_ENGINE: &str = "kokoro"; const VOXTRAL_ENGINE: &str = "voxtral"; @@ -118,19 +135,13 @@ pub async fn run( start.elapsed().as_secs_f32() ); - let stt: Arc>> = if tts_only { + let stt: Arc>> = if tts_only { eprintln!("voice daemon: skipping eager STT load (TTS-only mode)"); Arc::new(Mutex::new(None)) } else { // Eagerly load STT model — daemon is long-lived, pay the cost once - let repo = stt_repo(); - eprintln!("voice daemon: loading STT model ({})...", repo); let stt_start = Instant::now(); - match tokio::task::spawn_blocking(move || { - voice_stt::load_model(&repo).map_err(|e| format!("stt: {}", e)) - }) - .await - { + match tokio::task::spawn_blocking(load_stt_model).await { Ok(Ok(model)) => { eprintln!( "voice daemon: STT model loaded in {:.1}s", @@ -1148,13 +1159,11 @@ fn send_stream_event( // -- STT listen --------------------------------------------------------------- -fn ensure_stt(stt: &Arc>>) -> Result<(), String> { +fn ensure_stt(stt: &Arc>>) -> Result<(), String> { let mut guard = stt.lock().map_err(|e| format!("stt lock: {}", e))?; if guard.is_none() { - let repo = stt_repo(); - eprintln!("voice daemon: loading STT model ({})...", repo); let start = Instant::now(); - let model = voice_stt::load_model(&repo).map_err(|e| format!("stt load_model: {}", e))?; + let model = load_stt_model()?; eprintln!( "voice daemon: STT model loaded in {:.1}s", start.elapsed().as_secs_f32() @@ -1165,7 +1174,7 @@ fn ensure_stt(stt: &Arc>>) -> Result<(), S } fn listen_bounded( - stt: &Arc>>, + stt: &Arc>>, max_duration_ms: Option, cancelled: &Arc, heard_speech: Option>, @@ -1206,7 +1215,7 @@ fn listen_bounded( } fn listen( - stt: &Arc>>, + stt: &Arc>>, max_duration_ms: Option, cancelled: &Arc, heard_speech: Option>, @@ -1371,7 +1380,8 @@ fn listen( let mut guard = stt.lock().map_err(|e| format!("stt lock: {}", e))?; let model = guard.as_mut().ok_or("STT model not loaded")?; - let result = voice_stt::transcribe_audio(model, &samples, sample_rate) + let result = model + .transcribe_audio(&samples, sample_rate) .map_err(|e| format!("transcribe: {}", e))?; let text = result.text.trim().to_string(); @@ -1387,7 +1397,7 @@ fn listen( } fn transcribe_stream( - stt: &Arc>>, + stt: &Arc>>, stream_id: &str, samples: &[f32], sample_rate: u32, @@ -1417,7 +1427,8 @@ fn transcribe_stream( let mut guard = stt.lock().map_err(|e| format!("stt lock: {}", e))?; let model = guard.as_mut().ok_or("STT model not loaded")?; - let result = voice_stt::transcribe_audio(model, samples, sample_rate) + let result = model + .transcribe_audio(samples, sample_rate) .map_err(|e| format!("transcribe: {}", e))?; let text = result.text.trim().to_string(); diff --git a/crates/voice-stt/src/lib.rs b/crates/voice-stt/src/lib.rs index 55d5725..b4e75db 100644 --- a/crates/voice-stt/src/lib.rs +++ b/crates/voice-stt/src/lib.rs @@ -84,7 +84,7 @@ impl SttBackend { pub fn parse(value: &str) -> Result { match value { "whisper" => Ok(Self::Whisper), - "voxtral" => Ok(Self::Voxtral), + "voxtral" | "voxtral-realtime" => Ok(Self::Voxtral), other => Err(SttError::Model(format!( "unsupported STT backend {other:?}; expected whisper or voxtral" ))), @@ -267,6 +267,33 @@ pub fn default_model_for_backend(backend: SttBackend) -> &'static str { } } +pub fn infer_backend_for_model(path_or_repo: &str) -> SttBackend { + let value = path_or_repo.to_ascii_lowercase(); + if value.contains("voxtral") && (value.contains("realtime") || value.contains("2602")) { + SttBackend::Voxtral + } else { + SttBackend::Whisper + } +} + +pub fn resolve_backend_and_model( + backend: Option, + path_or_repo: Option<&str>, +) -> (SttBackend, String) { + match (backend, path_or_repo) { + (Some(backend), Some(path_or_repo)) => (backend, path_or_repo.to_string()), + (Some(backend), None) => (backend, default_model_for_backend(backend).to_string()), + (None, Some(path_or_repo)) => ( + infer_backend_for_model(path_or_repo), + path_or_repo.to_string(), + ), + (None, None) => ( + SttBackend::Whisper, + default_model_for_backend(SttBackend::Whisper).to_string(), + ), + } +} + pub fn load_backend_model(backend: SttBackend, path_or_repo: &str) -> Result { let device = default_stt_device()?; load_backend_model_on_device(backend, path_or_repo, device) @@ -653,6 +680,10 @@ mod tests { fn parses_stt_backends() { assert_eq!(SttBackend::parse("whisper").unwrap(), SttBackend::Whisper); assert_eq!(SttBackend::parse("voxtral").unwrap(), SttBackend::Voxtral); + assert_eq!( + SttBackend::parse("voxtral-realtime").unwrap(), + SttBackend::Voxtral + ); assert!(SttBackend::parse("kokoro").is_err()); assert_eq!(SttBackend::Whisper.as_str(), "whisper"); assert_eq!(SttBackend::Voxtral.as_str(), "voxtral"); @@ -670,6 +701,54 @@ mod tests { ); } + #[test] + fn infers_backend_from_model_name() { + assert_eq!( + infer_backend_for_model("mistralai/Voxtral-Mini-4B-Realtime-2602"), + SttBackend::Voxtral + ); + assert_eq!( + infer_backend_for_model("/models/voxtral-realtime"), + SttBackend::Voxtral + ); + assert_eq!( + infer_backend_for_model("distil-whisper/distil-large-v3.5"), + SttBackend::Whisper + ); + } + + #[test] + fn resolves_backend_and_model_together() { + assert_eq!( + resolve_backend_and_model(None, None), + (SttBackend::Whisper, builtin::DEFAULT_MODEL_REPO.to_string()) + ); + assert_eq!( + resolve_backend_and_model(Some(SttBackend::Voxtral), None), + ( + SttBackend::Voxtral, + voice_voxtral::REALTIME_DEFAULT_REPO.to_string() + ) + ); + assert_eq!( + resolve_backend_and_model(None, Some("mistralai/Voxtral-Mini-4B-Realtime-2602")), + ( + SttBackend::Voxtral, + "mistralai/Voxtral-Mini-4B-Realtime-2602".to_string() + ) + ); + assert_eq!( + resolve_backend_and_model( + Some(SttBackend::Whisper), + Some("mistralai/Voxtral-Mini-4B-Realtime-2602") + ), + ( + SttBackend::Whisper, + "mistralai/Voxtral-Mini-4B-Realtime-2602".to_string() + ) + ); + } + #[test] #[ignore = "downloads Whisper weights and runs CPU inference"] fn test_cpu_model_transcribes_silence_smoke() {