diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index c06d28ecae..e303627b2b 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -151,21 +151,26 @@ pub fn open_microphone_privacy_settings() -> Result<(), String> { #[tauri::command] #[specta::specta] -pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { - // Update settings +pub async fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> { + // Update settings (fast, stays inline) let mut settings = get_settings(&app); settings.always_on_microphone = always_on; write_settings(&app, settings); - // Update the audio manager mode - let rm = app.state::>(); + // Update the audio manager mode. update_mode can stop/start the cpal stream + // (blocking CoreAudio) and takes the manager std mutexes — run it on a + // blocking thread, NOT inline on the webview/main run loop (a slow device + // open/close would freeze the UI). + let rm = app.state::>().inner().clone(); let new_mode = if always_on { MicrophoneMode::AlwaysOn } else { MicrophoneMode::OnDemand }; - rm.update_mode(new_mode) + tokio::task::spawn_blocking(move || rm.update_mode(new_mode)) + .await + .map_err(|e| format!("audio task join failed: {}", e))? .map_err(|e| format!("Failed to update microphone mode: {}", e)) } @@ -178,28 +183,33 @@ pub fn get_microphone_mode(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_microphones() -> Result, String> { - let devices = - list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_microphones() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] #[specta::specta] -pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { +pub async fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> { let mut settings = get_settings(&app); settings.selected_microphone = if device_name == "default" { None @@ -208,12 +218,14 @@ pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<() }; write_settings(&app, settings); - // Update the audio manager to use the new device - let rm = app.state::>(); - rm.update_selected_device() - .map_err(|e| format!("Failed to update selected device: {}", e))?; - - Ok(()) + // Update the audio manager to use the new device. update_selected_device + // can restart the cpal stream (blocking CoreAudio) — run it on a blocking + // thread, not inline on the webview/main run loop. + let rm = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || rm.update_selected_device()) + .await + .map_err(|e| format!("audio task join failed: {}", e))? + .map_err(|e| format!("Failed to update selected device: {}", e)) } #[tauri::command] @@ -227,23 +239,28 @@ pub fn get_selected_microphone(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub fn get_available_output_devices() -> Result, String> { - let devices = - list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; - - let mut result = vec![AudioDevice { - index: "default".to_string(), - name: "Default".to_string(), - is_default: true, - }]; - - result.extend(devices.into_iter().map(|d| AudioDevice { - index: d.index, - name: d.name, - is_default: false, // The explicit default is handled separately - })); - - Ok(result) +pub async fn get_available_output_devices() -> Result, String> { + // cpal device enumeration can stall — run it off the webview/main run loop. + tokio::task::spawn_blocking(|| { + let devices = + list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?; + + let mut result = vec![AudioDevice { + index: "default".to_string(), + name: "Default".to_string(), + is_default: true, + }]; + + result.extend(devices.into_iter().map(|d| AudioDevice { + index: d.index, + name: d.name, + is_default: false, // The explicit default is handled separately + })); + + Ok::<_, String>(result) + }) + .await + .map_err(|e| format!("audio task join failed: {}", e))? } #[tauri::command] diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index ddcd6c0bc5..c9c7c09c8c 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -12,7 +12,7 @@ use crate::settings::{get_settings, AppSettings}; use crate::utils; use log::{debug, error, info, warn}; use std::path::Path; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tauri::Manager; @@ -175,6 +175,8 @@ fn create_audio_recorder( #[derive(Clone)] pub struct AudioRecordingManager { + /// Never assign through this directly — route every write through + /// `set_state()`, which keeps `recording_active` in sync. state: Arc>, mode: Arc>, app_handle: tauri::AppHandle, @@ -186,6 +188,12 @@ pub struct AudioRecordingManager { close_generation: Arc, cancel_generation: Arc, stream_router: Arc, + /// Lock-free mirror of "is the state in {Recording, Stopping}", + /// maintained by `set_state()`. The hot-path `is_recording()` reads THIS + /// instead of the std `state` mutex, so a UI poll can no longer deadlock + /// the main/webview thread when a worker holds `state` across a slow + /// CoreAudio open/close. + recording_active: Arc, /// Resolution of a *named* microphone (selected or clamshell) to its cpal /// device, cached so on-demand recording starts skip the full device /// enumeration (~40-110ms). Keyed by the resolved name, so a settings @@ -221,6 +229,7 @@ impl AudioRecordingManager { close_generation: Arc::new(AtomicU64::new(0)), cancel_generation: Arc::new(AtomicU64::new(0)), stream_router, + recording_active: Arc::new(AtomicBool::new(false)), cached_device: Arc::new(Mutex::new(None)), }; @@ -478,6 +487,21 @@ impl AudioRecordingManager { /* ---------- recording --------------------------------------------------- */ + /// The one place `state` is written. Derives `recording_active` (the + /// lock-free mirror read by `is_recording()`) from the new value itself, + /// so the two can never drift: a new `RecordingState` variant only needs + /// its active-set membership decided here, once. + fn set_state(&self, guard: &mut RecordingState, new_state: RecordingState) { + *guard = new_state; + self.recording_active.store( + matches!( + *guard, + RecordingState::Recording { .. } | RecordingState::Stopping + ), + Ordering::SeqCst, + ); + } + pub fn try_start_recording( &self, binding_id: &str, @@ -500,9 +524,12 @@ impl AudioRecordingManager { if let Some(rec) = self.recorder.lock().unwrap().as_ref() { if rec.start(vad_policy).is_ok() { *self.is_recording.lock().unwrap() = true; - *state = RecordingState::Recording { - binding_id: binding_id.to_string(), - }; + self.set_state( + &mut state, + RecordingState::Recording { + binding_id: binding_id.to_string(), + }, + ); debug!("Recording started for binding {binding_id}"); return Ok(()); } @@ -542,7 +569,7 @@ impl AudioRecordingManager { RecordingState::Recording { binding_id: ref active, } if active == binding_id => { - *state = RecordingState::Stopping; + self.set_state(&mut state, RecordingState::Stopping); drop(state); // Optionally keep recording for a bit longer to capture trailing audio. @@ -581,7 +608,7 @@ impl AudioRecordingManager { }; *self.is_recording.lock().unwrap() = false; - *self.state.lock().unwrap() = RecordingState::Idle; + self.set_state(&mut self.state.lock().unwrap(), RecordingState::Idle); // In on-demand mode, close the mic (lazily if the setting is enabled) if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { @@ -612,10 +639,12 @@ impl AudioRecordingManager { } } pub fn is_recording(&self) -> bool { - matches!( - *self.state.lock().unwrap(), - RecordingState::Recording { .. } | RecordingState::Stopping - ) + // Lock-free: mirrors the `state` {Recording, Stopping} membership via + // an atomic maintained by `set_state()`. Polled from the webview/main + // thread, so it MUST NOT take the `state` mutex (a worker can hold it + // across a slow CoreAudio open/close → main-thread deadlock / UI + // freeze). + self.recording_active.load(Ordering::SeqCst) } /// Cancel any ongoing recording without returning audio samples @@ -625,7 +654,7 @@ impl AudioRecordingManager { match *state { RecordingState::Recording { .. } => { - *state = RecordingState::Idle; + self.set_state(&mut state, RecordingState::Idle); drop(state); if let Some(rec) = self.recorder.lock().unwrap().as_ref() {