Skip to content
Open
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
5 changes: 5 additions & 0 deletions src-tauri/src/audio_toolkit/audio/recorder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
self.device.as_ref().and_then(|device| device.name().ok())
}

pub fn close(&mut self) -> Result<(), Box<dyn std::error::Error>> {
if let Some(tx) = self.cmd_tx.take() {
let _ = tx.send(Cmd::Shutdown);
Expand Down
15 changes: 13 additions & 2 deletions src-tauri/src/commands/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,22 @@ pub fn get_microphone_mode(app: AppHandle) -> Result<bool, String> {

#[tauri::command]
#[specta::specta]
pub async fn get_available_microphones() -> Result<Vec<AudioDevice>, String> {
pub async fn get_available_microphones(app: AppHandle) -> Result<Vec<AudioDevice>, String> {
// cpal device enumeration can stall — run it off the webview/main run loop.
tokio::task::spawn_blocking(|| {
let manager = app.state::<Arc<AudioRecordingManager>>().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(),
Expand Down
82 changes: 80 additions & 2 deletions src-tauri/src/managers/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -543,16 +574,21 @@ 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.
trace!("Microphone stream already active");
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.
Expand Down Expand Up @@ -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<bool, anyhow::Error> {
// 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<u16>,
Expand Down
93 changes: 93 additions & 0 deletions src-tauri/src/managers/audio_device_refresh.rs
Original file line number Diff line number Diff line change
@@ -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"),
));
}
}
1 change: 1 addition & 0 deletions src-tauri/src/managers/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod audio;
mod audio_device_refresh;
pub mod gguf_meta;
pub mod history;
pub mod model;
Expand Down