diff --git a/src-tauri/src/audio_toolkit/audio/recorder.rs b/src-tauri/src/audio_toolkit/audio/recorder.rs index 9bc77d05f0..3a219573bf 100644 --- a/src-tauri/src/audio_toolkit/audio/recorder.rs +++ b/src-tauri/src/audio_toolkit/audio/recorder.rs @@ -384,6 +384,11 @@ impl AudioRecorder { .is_some_and(|handle| handle.is_finished()) } + /// Name of the device backing the currently open stream. + pub fn active_device_name(&self) -> Option { + self.device.as_ref().and_then(|device| device.name().ok()) + } + pub fn close(&mut self) -> Result<(), Box> { if let Some(tx) = self.cmd_tx.take() { let _ = tx.send(Cmd::Shutdown); diff --git a/src-tauri/src/commands/audio.rs b/src-tauri/src/commands/audio.rs index 07920e2527..02fadd3c32 100644 --- a/src-tauri/src/commands/audio.rs +++ b/src-tauri/src/commands/audio.rs @@ -188,11 +188,22 @@ pub fn get_microphone_mode(app: AppHandle) -> Result { #[tauri::command] #[specta::specta] -pub async fn get_available_microphones() -> Result, String> { +pub async fn get_available_microphones(app: AppHandle) -> Result, String> { // cpal device enumeration can stall — run it off the webview/main run loop. - tokio::task::spawn_blocking(|| { + let manager = app.state::>().inner().clone(); + tokio::task::spawn_blocking(move || { let devices = list_input_devices().map_err(|e| format!("Failed to list audio devices: {}", e))?; + let current_default_name = devices + .iter() + .find(|device| device.is_default) + .map(|device| device.name.clone()); + + if let Err(error) = + manager.refresh_default_device_if_changed(current_default_name.as_deref()) + { + warn!("Failed to refresh the active default microphone: {error}"); + } let mut result = vec![AudioDevice { index: "default".to_string(), diff --git a/src-tauri/src/managers/audio.rs b/src-tauri/src/managers/audio.rs index 8cf50a2e5a..d44cae393d 100644 --- a/src-tauri/src/managers/audio.rs +++ b/src-tauri/src/managers/audio.rs @@ -7,9 +7,11 @@ use crate::audio_toolkit::{ AudioRecorder, SileroVad, VadPolicy, }; use crate::helpers::clamshell; +use crate::managers::audio_device_refresh::should_reopen_default_microphone; use crate::managers::transcription::StreamRouter; use crate::settings::{get_settings, AppSettings}; use crate::utils; +use cpal::traits::{DeviceTrait, HostTrait}; use log::{debug, error, info, trace, warn}; use std::path::Path; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; @@ -440,6 +442,35 @@ impl AudioRecordingManager { device } + /// Checks whether an open stream that follows the system default is still + /// attached to the device the OS currently reports as default. + fn default_stream_device_changed(&self) -> bool { + let settings = get_settings(&self.app_handle); + // Keep the always-on keypress path cheap. A configured selected or + // clamshell device has explicit resolution rules and is reconciled by + // the UI refresh path instead of probing clamshell state here. + if settings.selected_microphone.is_some() || settings.clamshell_microphone.is_some() { + return false; + } + let active_device_name = self + .recorder + .lock() + .unwrap() + .as_ref() + .and_then(AudioRecorder::active_device_name); + let current_default_name = crate::audio_toolkit::get_cpal_host() + .default_input_device() + .and_then(|device| device.name().ok()); + + should_reopen_default_microphone( + true, + true, + None, + active_device_name.as_deref(), + current_default_name.as_deref(), + ) + } + fn schedule_lazy_close(&self) { let gen = self.close_generation.fetch_add(1, Ordering::SeqCst) + 1; let app = self.app_handle.clone(); @@ -543,8 +574,9 @@ impl AudioRecordingManager { .unwrap() .as_ref() .is_some_and(|rec| rec.is_capture_worker_dead()); + let default_device_changed = !worker_dead && self.default_stream_device_changed(); - if !worker_dead { + if !worker_dead && !default_device_changed { // trace, not debug: with the aliveness check in // try_start_recording this now fires on every keypress in // always-on mode. @@ -552,7 +584,11 @@ impl AudioRecordingManager { return Ok(()); } - warn!("Microphone stream is no longer running (device disconnected?); reopening"); + if worker_dead { + warn!("Microphone stream is no longer running (device disconnected?); reopening"); + } else { + info!("System default microphone changed; reopening stream before recording"); + } // Torn down inline rather than via stop_microphone_stream(), which // takes the `is_open` lock we are already holding. @@ -756,6 +792,48 @@ impl AudioRecordingManager { Ok(()) } + /// Reconciles an open system-default stream with the default device found + /// by a UI refresh. Always-on capture otherwise stays attached to the + /// device that was default when the stream first opened. + pub fn refresh_default_device_if_changed( + &self, + current_default_name: Option<&str>, + ) -> Result { + // Serialize against recording start/stop so refresh never discards an + // active session. The potentially blocking restart runs on the caller's + // spawn_blocking thread, not the webview/main run loop. + let state = self.state.lock().unwrap(); + let recording_is_idle = matches!(*state, RecordingState::Idle); + let settings = get_settings(&self.app_handle); + let desired_device_name = self.desired_device_name(&settings); + let stream_is_open = *self.is_open.lock().unwrap(); + let active_device_name = self + .recorder + .lock() + .unwrap() + .as_ref() + .and_then(AudioRecorder::active_device_name); + + if !should_reopen_default_microphone( + stream_is_open, + recording_is_idle, + desired_device_name.as_deref(), + active_device_name.as_deref(), + current_default_name, + ) { + return Ok(false); + } + + info!( + "System default microphone changed from {:?} to {:?}; reopening stream", + active_device_name, current_default_name + ); + self.close_generation.fetch_add(1, Ordering::SeqCst); + self.stop_microphone_stream(); + self.start_microphone_stream()?; + Ok(true) + } + pub fn update_selected_channel( &self, selected_channel: Option, diff --git a/src-tauri/src/managers/audio_device_refresh.rs b/src-tauri/src/managers/audio_device_refresh.rs new file mode 100644 index 0000000000..d89dbfaf91 --- /dev/null +++ b/src-tauri/src/managers/audio_device_refresh.rs @@ -0,0 +1,93 @@ +/// Returns whether an open microphone stream should be reopened after device +/// enumeration discovers a different system default. +/// +/// A refresh must never interrupt an active recording or override an explicit +/// microphone choice. It also cannot safely replace the stream until the OS +/// exposes a resolvable default device. +pub(crate) fn should_reopen_default_microphone( + stream_is_open: bool, + recording_is_idle: bool, + desired_device_name: Option<&str>, + active_device_name: Option<&str>, + current_default_name: Option<&str>, +) -> bool { + stream_is_open + && recording_is_idle + && desired_device_name.is_none() + && current_default_name.is_some() + && active_device_name != current_default_name +} + +#[cfg(test)] +mod tests { + use super::should_reopen_default_microphone; + + #[test] + fn reopens_an_idle_default_stream_when_the_system_default_changes() { + assert!(should_reopen_default_microphone( + true, + true, + None, + Some("AirPods"), + Some("USB microphone"), + )); + } + + #[test] + fn keeps_the_stream_when_the_default_device_is_unchanged() { + assert!(!should_reopen_default_microphone( + true, + true, + None, + Some("AirPods"), + Some("AirPods"), + )); + } + + #[test] + fn does_not_interrupt_recording_or_an_explicit_device() { + assert!(!should_reopen_default_microphone( + true, + false, + None, + Some("AirPods"), + Some("USB microphone"), + )); + assert!(!should_reopen_default_microphone( + true, + true, + Some("AirPods"), + Some("AirPods"), + Some("USB microphone"), + )); + } + + #[test] + fn waits_for_a_resolvable_default_and_an_open_stream() { + assert!(!should_reopen_default_microphone( + false, + true, + None, + Some("AirPods"), + Some("USB microphone"), + )); + assert!(!should_reopen_default_microphone( + true, + true, + None, + Some("AirPods"), + None, + )); + } + + #[test] + fn reopens_when_an_open_stream_has_no_known_active_device() { + assert!(should_reopen_default_microphone( + true, + true, + None, + None, + Some("AirPods"), + )); + } +} diff --git a/src-tauri/src/managers/mod.rs b/src-tauri/src/managers/mod.rs index 83450a0d7a..a39d82d663 100644 --- a/src-tauri/src/managers/mod.rs +++ b/src-tauri/src/managers/mod.rs @@ -1,4 +1,5 @@ pub mod audio; +mod audio_device_refresh; pub mod gguf_meta; pub mod history; pub mod model;