Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 63 additions & 46 deletions src-tauri/src/commands/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Arc<AudioRecordingManager>>();
// 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::<Arc<AudioRecordingManager>>().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))
}

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

#[tauri::command]
#[specta::specta]
pub fn get_available_microphones() -> Result<Vec<AudioDevice>, 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<Vec<AudioDevice>, 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
Expand All @@ -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::<Arc<AudioRecordingManager>>();
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::<Arc<AudioRecordingManager>>().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]
Expand All @@ -227,23 +239,28 @@ pub fn get_selected_microphone(app: AppHandle) -> Result<String, String> {

#[tauri::command]
#[specta::specta]
pub fn get_available_output_devices() -> Result<Vec<AudioDevice>, 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<Vec<AudioDevice>, 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]
Expand Down
51 changes: 40 additions & 11 deletions src-tauri/src/managers/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Mutex<RecordingState>>,
mode: Arc<Mutex<MicrophoneMode>>,
app_handle: tauri::AppHandle,
Expand All @@ -186,6 +188,12 @@ pub struct AudioRecordingManager {
close_generation: Arc<AtomicU64>,
cancel_generation: Arc<AtomicU64>,
stream_router: Arc<StreamRouter>,
/// 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<AtomicBool>,
/// 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
Expand Down Expand Up @@ -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)),
};

Expand Down Expand Up @@ -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,
Expand All @@ -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(());
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand Down