From 983caaece94df8f98d38196763223d519aa8086e Mon Sep 17 00:00:00 2001 From: xronocode Date: Sat, 18 Jul 2026 00:58:22 +0500 Subject: [PATCH 1/2] fix(audio): move blocking cpal work off the main thread + lock-free is_recording Synchronous #[tauri::command] handlers run inline on the webview/main run loop, and the audio manager guards its state with a std Mutex held across blocking CoreAudio syscalls (cpal stream start/stop, device enumeration). A worker holding that mutex across a slow device open/close (Bluetooth/USB mic) serializes the main thread, freezing the UI (spinning beachball). Fix A: is_recording() reads a lock-free Arc mirror of the "state in {Recording, Stopping}" membership, flipped at the state transitions, instead of locking `state`. The hot-path UI poll can no longer deadlock against a worker holding `state`. Fix B: the four cpal-running commands (update_microphone_mode, get_available_microphones, set_selected_microphone, get_available_output_devices) become async and run their blocking cpal work via tokio::task::spawn_blocking. Tauri's invoke is identical for sync/async commands, so there is no frontend/binding change. Live verification (Bluetooth/USB mic recording + device change mid-use) is left to manual testing; concurrency/hardware behavior is not unit-testable. --- src-tauri/src/commands/audio.rs | 109 ++++++++++++++++++-------------- src-tauri/src/managers/audio.rs | 22 +++++-- 2 files changed, 80 insertions(+), 51 deletions(-) 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..a4d0e8a1d9 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; @@ -186,6 +186,12 @@ pub struct AudioRecordingManager { close_generation: Arc, cancel_generation: Arc, stream_router: Arc, + /// Lock-free mirror of "is the state in {Recording, Stopping}", flipped + /// exactly at the state transitions that change that membership. 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 +227,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)), }; @@ -503,6 +510,7 @@ impl AudioRecordingManager { *state = RecordingState::Recording { binding_id: binding_id.to_string(), }; + self.recording_active.store(true, Ordering::SeqCst); debug!("Recording started for binding {binding_id}"); return Ok(()); } @@ -582,6 +590,7 @@ impl AudioRecordingManager { *self.is_recording.lock().unwrap() = false; *self.state.lock().unwrap() = RecordingState::Idle; + self.recording_active.store(false, Ordering::SeqCst); // In on-demand mode, close the mic (lazily if the setting is enabled) if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) { @@ -612,10 +621,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 flipped at the state transitions. 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 @@ -626,6 +637,7 @@ impl AudioRecordingManager { match *state { RecordingState::Recording { .. } => { *state = RecordingState::Idle; + self.recording_active.store(false, Ordering::SeqCst); drop(state); if let Some(rec) = self.recorder.lock().unwrap().as_ref() { From d8f01669bfaaa59d1149d34f247b0e14fb3d90fa Mon Sep 17 00:00:00 2001 From: CJ Pais Date: Wed, 5 Aug 2026 15:06:33 +0800 Subject: [PATCH 2/2] add helper function --- src-tauri/src/managers/audio.rs | 53 ++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index a4d0e8a1d9..c9c7c09c8c 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -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,11 +188,11 @@ pub struct AudioRecordingManager { close_generation: Arc, cancel_generation: Arc, stream_router: Arc, - /// Lock-free mirror of "is the state in {Recording, Stopping}", flipped - /// exactly at the state transitions that change that membership. 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. + /// 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 @@ -485,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, @@ -507,10 +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.recording_active.store(true, Ordering::SeqCst); + self.set_state( + &mut state, + RecordingState::Recording { + binding_id: binding_id.to_string(), + }, + ); debug!("Recording started for binding {binding_id}"); return Ok(()); } @@ -550,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. @@ -589,8 +608,7 @@ impl AudioRecordingManager { }; *self.is_recording.lock().unwrap() = false; - *self.state.lock().unwrap() = RecordingState::Idle; - self.recording_active.store(false, Ordering::SeqCst); + 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) { @@ -622,10 +640,10 @@ impl AudioRecordingManager { } pub fn is_recording(&self) -> bool { // Lock-free: mirrors the `state` {Recording, Stopping} membership via - // an atomic flipped at the state transitions. 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). + // 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) } @@ -636,8 +654,7 @@ impl AudioRecordingManager { match *state { RecordingState::Recording { .. } => { - *state = RecordingState::Idle; - self.recording_active.store(false, Ordering::SeqCst); + self.set_state(&mut state, RecordingState::Idle); drop(state); if let Some(rec) = self.recorder.lock().unwrap().as_ref() {