Skip to content

Commit 983caae

Browse files
committed
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<AtomicBool> 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.
1 parent d861e24 commit 983caae

2 files changed

Lines changed: 80 additions & 51 deletions

File tree

src-tauri/src/commands/audio.rs

Lines changed: 63 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -151,21 +151,26 @@ pub fn open_microphone_privacy_settings() -> Result<(), String> {
151151

152152
#[tauri::command]
153153
#[specta::specta]
154-
pub fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {
155-
// Update settings
154+
pub async fn update_microphone_mode(app: AppHandle, always_on: bool) -> Result<(), String> {
155+
// Update settings (fast, stays inline)
156156
let mut settings = get_settings(&app);
157157
settings.always_on_microphone = always_on;
158158
write_settings(&app, settings);
159159

160-
// Update the audio manager mode
161-
let rm = app.state::<Arc<AudioRecordingManager>>();
160+
// Update the audio manager mode. update_mode can stop/start the cpal stream
161+
// (blocking CoreAudio) and takes the manager std mutexes — run it on a
162+
// blocking thread, NOT inline on the webview/main run loop (a slow device
163+
// open/close would freeze the UI).
164+
let rm = app.state::<Arc<AudioRecordingManager>>().inner().clone();
162165
let new_mode = if always_on {
163166
MicrophoneMode::AlwaysOn
164167
} else {
165168
MicrophoneMode::OnDemand
166169
};
167170

168-
rm.update_mode(new_mode)
171+
tokio::task::spawn_blocking(move || rm.update_mode(new_mode))
172+
.await
173+
.map_err(|e| format!("audio task join failed: {}", e))?
169174
.map_err(|e| format!("Failed to update microphone mode: {}", e))
170175
}
171176

@@ -178,28 +183,33 @@ pub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {
178183

179184
#[tauri::command]
180185
#[specta::specta]
181-
pub fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
182-
let devices =
183-
list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?;
184-
185-
let mut result = vec![AudioDevice {
186-
index: "default".to_string(),
187-
name: "Default".to_string(),
188-
is_default: true,
189-
}];
190-
191-
result.extend(devices.into_iter().map(|d| AudioDevice {
192-
index: d.index,
193-
name: d.name,
194-
is_default: false, // The explicit default is handled separately
195-
}));
196-
197-
Ok(result)
186+
pub async fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
187+
// cpal device enumeration can stall — run it off the webview/main run loop.
188+
tokio::task::spawn_blocking(|| {
189+
let devices =
190+
list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?;
191+
192+
let mut result = vec![AudioDevice {
193+
index: "default".to_string(),
194+
name: "Default".to_string(),
195+
is_default: true,
196+
}];
197+
198+
result.extend(devices.into_iter().map(|d| AudioDevice {
199+
index: d.index,
200+
name: d.name,
201+
is_default: false, // The explicit default is handled separately
202+
}));
203+
204+
Ok::<_, String>(result)
205+
})
206+
.await
207+
.map_err(|e| format!("audio task join failed: {}", e))?
198208
}
199209

200210
#[tauri::command]
201211
#[specta::specta]
202-
pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
212+
pub async fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<(), String> {
203213
let mut settings = get_settings(&app);
204214
settings.selected_microphone = if device_name == "default" {
205215
None
@@ -208,12 +218,14 @@ pub fn set_selected_microphone(app: AppHandle, device_name: String) -> Result<()
208218
};
209219
write_settings(&app, settings);
210220

211-
// Update the audio manager to use the new device
212-
let rm = app.state::<Arc<AudioRecordingManager>>();
213-
rm.update_selected_device()
214-
.map_err(|e| format!("Failed to update selected device: {}", e))?;
215-
216-
Ok(())
221+
// Update the audio manager to use the new device. update_selected_device
222+
// can restart the cpal stream (blocking CoreAudio) — run it on a blocking
223+
// thread, not inline on the webview/main run loop.
224+
let rm = app.state::<Arc<AudioRecordingManager>>().inner().clone();
225+
tokio::task::spawn_blocking(move || rm.update_selected_device())
226+
.await
227+
.map_err(|e| format!("audio task join failed: {}", e))?
228+
.map_err(|e| format!("Failed to update selected device: {}", e))
217229
}
218230

219231
#[tauri::command]
@@ -227,23 +239,28 @@ pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {
227239

228240
#[tauri::command]
229241
#[specta::specta]
230-
pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
231-
let devices =
232-
list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?;
233-
234-
let mut result = vec![AudioDevice {
235-
index: "default".to_string(),
236-
name: "Default".to_string(),
237-
is_default: true,
238-
}];
239-
240-
result.extend(devices.into_iter().map(|d| AudioDevice {
241-
index: d.index,
242-
name: d.name,
243-
is_default: false, // The explicit default is handled separately
244-
}));
245-
246-
Ok(result)
242+
pub async fn get_available_output_devices() -> Result<Vec<AudioDevice>, String> {
243+
// cpal device enumeration can stall — run it off the webview/main run loop.
244+
tokio::task::spawn_blocking(|| {
245+
let devices =
246+
list_output_devices().map_err(|e| format!("Failed to list output devices: {}", e))?;
247+
248+
let mut result = vec![AudioDevice {
249+
index: "default".to_string(),
250+
name: "Default".to_string(),
251+
is_default: true,
252+
}];
253+
254+
result.extend(devices.into_iter().map(|d| AudioDevice {
255+
index: d.index,
256+
name: d.name,
257+
is_default: false, // The explicit default is handled separately
258+
}));
259+
260+
Ok::<_, String>(result)
261+
})
262+
.await
263+
.map_err(|e| format!("audio task join failed: {}", e))?
247264
}
248265

249266
#[tauri::command]

src-tauri/src/managers/audio.rs

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use crate::settings::{get_settings, AppSettings};
1212
use crate::utils;
1313
use log::{debug, error, info, warn};
1414
use std::path::Path;
15-
use std::sync::atomic::{AtomicU64, Ordering};
15+
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1616
use std::sync::{Arc, Mutex};
1717
use std::time::{Duration, Instant};
1818
use tauri::Manager;
@@ -186,6 +186,12 @@ pub struct AudioRecordingManager {
186186
close_generation: Arc<AtomicU64>,
187187
cancel_generation: Arc<AtomicU64>,
188188
stream_router: Arc<StreamRouter>,
189+
/// Lock-free mirror of "is the state in {Recording, Stopping}", flipped
190+
/// exactly at the state transitions that change that membership. The
191+
/// hot-path `is_recording()` reads THIS instead of the std `state` mutex,
192+
/// so a UI poll can no longer deadlock the main/webview thread when a
193+
/// worker holds `state` across a slow CoreAudio open/close.
194+
recording_active: Arc<AtomicBool>,
189195
/// Resolution of a *named* microphone (selected or clamshell) to its cpal
190196
/// device, cached so on-demand recording starts skip the full device
191197
/// enumeration (~40-110ms). Keyed by the resolved name, so a settings
@@ -221,6 +227,7 @@ impl AudioRecordingManager {
221227
close_generation: Arc::new(AtomicU64::new(0)),
222228
cancel_generation: Arc::new(AtomicU64::new(0)),
223229
stream_router,
230+
recording_active: Arc::new(AtomicBool::new(false)),
224231
cached_device: Arc::new(Mutex::new(None)),
225232
};
226233

@@ -503,6 +510,7 @@ impl AudioRecordingManager {
503510
*state = RecordingState::Recording {
504511
binding_id: binding_id.to_string(),
505512
};
513+
self.recording_active.store(true, Ordering::SeqCst);
506514
debug!("Recording started for binding {binding_id}");
507515
return Ok(());
508516
}
@@ -582,6 +590,7 @@ impl AudioRecordingManager {
582590

583591
*self.is_recording.lock().unwrap() = false;
584592
*self.state.lock().unwrap() = RecordingState::Idle;
593+
self.recording_active.store(false, Ordering::SeqCst);
585594

586595
// In on-demand mode, close the mic (lazily if the setting is enabled)
587596
if matches!(*self.mode.lock().unwrap(), MicrophoneMode::OnDemand) {
@@ -612,10 +621,12 @@ impl AudioRecordingManager {
612621
}
613622
}
614623
pub fn is_recording(&self) -> bool {
615-
matches!(
616-
*self.state.lock().unwrap(),
617-
RecordingState::Recording { .. } | RecordingState::Stopping
618-
)
624+
// Lock-free: mirrors the `state` {Recording, Stopping} membership via
625+
// an atomic flipped at the state transitions. Polled from the
626+
// webview/main thread, so it MUST NOT take the `state` mutex (a worker
627+
// can hold it across a slow CoreAudio open/close → main-thread
628+
// deadlock / UI freeze).
629+
self.recording_active.load(Ordering::SeqCst)
619630
}
620631

621632
/// Cancel any ongoing recording without returning audio samples
@@ -626,6 +637,7 @@ impl AudioRecordingManager {
626637
match *state {
627638
RecordingState::Recording { .. } => {
628639
*state = RecordingState::Idle;
640+
self.recording_active.store(false, Ordering::SeqCst);
629641
drop(state);
630642

631643
if let Some(rec) = self.recorder.lock().unwrap().as_ref() {

0 commit comments

Comments
 (0)