use std::sync::{ Arc, Mutex, mpsc::{self, Receiver, Sender}, }; use std::time::Duration; use crate::runtime_compute_status; use origin_speak_lib::{ AppState, DeliveryPhase, ShortcutEvent, ShortcutService, State, VoiceProcessingResult, get_audio_level, log_capture_lifecycle_timing, normalize_hotkey_string, process_captured_audio, start_listening, take_capture_for_processing, }; use tokio::runtime::{Builder, Runtime}; use tokio::sync::{mpsc as tokio_mpsc, watch}; const AUDIO_LEVEL_INTERVAL: Duration = Duration::from_millis(45); const MODEL_PREWARM_DELAY: Duration = Duration::from_secs(8); const CAPTURE_RELEASE_HANGOVER: Duration = Duration::from_millis(250); #[derive(Debug, Clone, Copy, PartialEq)] pub enum RuntimeEvent { AudioLevel(f32), Idle, Listening, Processing, Success, Error, } #[derive(Debug)] enum ControllerCommand { Shortcut(ShortcutEvent), FinishCapture(std::time::Instant), ProcessingFinished(Result), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CaptureMode { Idle, Trigger, Processing, } fn shortcut_command(event: ShortcutEvent) -> ControllerCommand { ControllerCommand::Shortcut(event) } pub struct RuntimeController { _state: Arc, events: Arc>>, commands: tokio_mpsc::UnboundedSender, shortcuts: Option, trigger_hotkey: String, _runtime: Runtime, } impl RuntimeController { pub fn new() -> Result { let runtime = Builder::new_multi_thread() .enable_all() .thread_name("origin-speak-native-runtime") .build() .map_err(|error| format!("Failed to create native runtime: {error}"))?; let state = Arc::new(AppState::default()); let model_status = state.transcription.runtime_status(); if !model_status.model_downloaded { return Err(format!( "Selected local transcription model '{}' is not installed or is invalid. Run `origin model download {}` before starting the resident runtime.", model_status.model, model_status.model )); } let (event_tx, event_rx) = mpsc::channel::(); let events = Arc::new(Mutex::new(event_rx)); let (command_tx, command_rx) = tokio_mpsc::unbounded_channel::(); let (audio_level_mode_tx, audio_level_mode_rx) = watch::channel(false); let trigger_hotkey = runtime.block_on(async { let mut config = state.config.lock().await; let normalized = normalize_hotkey_string(&config.trigger_hotkey)?; if normalized != config.trigger_hotkey { config.trigger_hotkey = normalized.clone(); config.save_to_disk()?; } Ok::(normalized) })?; runtime.spawn(run_command_loop( command_rx, command_tx.clone(), state.clone(), event_tx.clone(), audio_level_mode_tx, )); runtime.spawn(run_audio_level_loop( state.clone(), event_tx, audio_level_mode_rx, )); // Keep login/startup cheap: global shortcuts and the GPUI event loop must // become responsive before we begin large model I/O. The selected model // is warmed only after a short idle window; a dictation started sooner // can still load it on demand through the normal transcription path. let transcription = state.transcription.clone(); runtime.spawn(async move { tokio::time::sleep(MODEL_PREWARM_DELAY).await; if transcription.runtime_status().model_downloaded { let result = transcription.preload_selected_model().await; let _ = runtime_compute_status::publish(&transcription.runtime_status()); #[cfg(debug_assertions)] if let Err(error) = result { eprintln!("[Origin Speak] model prewarm failed: {error}"); } #[cfg(not(debug_assertions))] let _ = result; } }); Ok(Self { _state: state, events, commands: command_tx, shortcuts: None, trigger_hotkey, _runtime: runtime, }) } /// Register native global shortcuts on the GPUI/platform event-loop thread. pub fn initialize_shortcuts(&mut self) -> Result<(), String> { if self.shortcuts.is_some() { return Ok(()); } let dispatch = self.commands.clone(); let service = ShortcutService::new(&self.trigger_hotkey, move |event| { let _ = dispatch.send(shortcut_command(event)); }) .map_err(|error| format!("Global dictation shortcut unavailable: {error}"))?; self.shortcuts = Some(service); Ok(()) } pub fn events(&self) -> Arc>> { self.events.clone() } } async fn run_command_loop( mut commands: tokio_mpsc::UnboundedReceiver, command_tx: tokio_mpsc::UnboundedSender, state: Arc, events: Sender, audio_level_mode: watch::Sender, ) { let mut capture_mode = CaptureMode::Idle; while let Some(command) = commands.recv().await { match command { ControllerCommand::Shortcut(ShortcutEvent::TriggerPressed) if capture_mode == CaptureMode::Idle => { let pressed_at = std::time::Instant::now(); // Do not expose Listening until CPAL has actually acquired and // started the input stream. This prevents a false listening // flash when the device is unavailable or permission is denied. match start_listening(State::new(state.as_ref())).await { Ok(_) => { log_capture_lifecycle_timing( "trigger_pressed_to_stream_started", pressed_at.elapsed().as_millis(), ); capture_mode = CaptureMode::Trigger; let _ = audio_level_mode.send(true); let _ = events.send(RuntimeEvent::Listening); } Err(_) => { let _ = audio_level_mode.send(false); let _ = events.send(RuntimeEvent::Error); } } } ControllerCommand::Shortcut(ShortcutEvent::TriggerReleased) if capture_mode == CaptureMode::Trigger => { let _ = audio_level_mode.send(false); // The overlay changes immediately, while capture remains live // briefly so a key-up cannot clip the final syllable. capture_mode = CaptureMode::Processing; let _ = events.send(RuntimeEvent::Processing); let released_at = std::time::Instant::now(); let finish_commands = command_tx.clone(); tokio::spawn(async move { tokio::time::sleep(CAPTURE_RELEASE_HANGOVER).await; let _ = finish_commands.send(ControllerCommand::FinishCapture(released_at)); }); } ControllerCommand::FinishCapture(released_at) if capture_mode == CaptureMode::Processing => { let captured = take_capture_for_processing(State::new(state.as_ref())).await; log_capture_lifecycle_timing( "trigger_released_to_capture_stopped", released_at.elapsed().as_millis(), ); match captured { Err(_) => { capture_mode = CaptureMode::Idle; let _ = events.send(RuntimeEvent::Error); } Ok(captured) => { // Only one dictation may own the model at a time. let task_state = state.clone(); let task_commands = command_tx.clone(); tokio::spawn(async move { let result = process_captured_audio(State::new(task_state.as_ref()), captured) .await; let _ = runtime_compute_status::publish( &task_state.transcription.runtime_status(), ); let _ = task_commands.send(ControllerCommand::ProcessingFinished(result)); }); } } } ControllerCommand::FinishCapture(_) => {} ControllerCommand::ProcessingFinished(result) if capture_mode == CaptureMode::Processing => { capture_mode = CaptureMode::Idle; emit_processing_result(result, &events); } ControllerCommand::ProcessingFinished(_) => {} ControllerCommand::Shortcut( ShortcutEvent::TriggerPressed | ShortcutEvent::TriggerReleased, ) => {} } } } #[cfg(test)] fn release_hangover() -> Duration { CAPTURE_RELEASE_HANGOVER } fn emit_processing_result( result: Result, events: &Sender, ) { match result { Ok(result) if result.transcription.text.trim().is_empty() => { let _ = events.send(RuntimeEvent::Idle); } Ok(result) if result.delivery_status.phase == DeliveryPhase::RecoverableFailure => { let _ = events.send(RuntimeEvent::Error); } Ok(result) if !result.delivered => { let _ = events.send(RuntimeEvent::Error); } Ok(_) => { let _ = events.send(RuntimeEvent::Success); } Err(_) => { let _ = events.send(RuntimeEvent::Error); } } } async fn run_audio_level_loop( state: Arc, events: Sender, mut active: watch::Receiver, ) { let mut last_level = 0.0_f32; loop { if !*active.borrow() { if last_level != 0.0 { if events.send(RuntimeEvent::AudioLevel(0.0)).is_err() { break; } last_level = 0.0; } if active.changed().await.is_err() { break; } continue; } tokio::select! { changed = active.changed() => { if changed.is_err() { break; } continue; } _ = tokio::time::sleep(AUDIO_LEVEL_INTERVAL) => {} } if !*active.borrow() { continue; } let level = get_audio_level(State::new(state.as_ref())) .await .unwrap_or(0.0) .clamp(0.0, 1.0); if events.send(RuntimeEvent::AudioLevel(level)).is_err() { break; } last_level = level; } } #[cfg(test)] mod tests { use super::*; #[test] fn dictation_shortcut_keeps_press_and_release() { assert!(matches!( shortcut_command(ShortcutEvent::TriggerPressed), ControllerCommand::Shortcut(ShortcutEvent::TriggerPressed) )); assert!(matches!( shortcut_command(ShortcutEvent::TriggerReleased), ControllerCommand::Shortcut(ShortcutEvent::TriggerReleased) )); } #[test] fn processing_is_a_distinct_capture_mode() { assert_ne!(CaptureMode::Processing, CaptureMode::Idle); assert_ne!(CaptureMode::Processing, CaptureMode::Trigger); } #[test] fn release_hangover_is_long_enough_for_trailing_syllables() { assert_eq!(release_hangover(), Duration::from_millis(250)); } }