From 5c72a064cda61ce0f119a8e2630af306326ef3ea Mon Sep 17 00:00:00 2001 From: Jeff Boles Date: Tue, 11 Nov 2025 17:18:05 -0700 Subject: [PATCH] fix(audio): prevent device switching loop during initialization on macOS Fixes #1371 On macOS 15.x, creating a private aggregate device for speaker audio capture triggers system-wide device change notifications. The DeviceMonitor was reacting to these events during initialization, causing the audio pipeline to restart in a loop and never stabilize. This change adds an initialization_complete flag that prevents device change events from being processed until both mic and speaker streams have successfully initialized. The flag is reset when the user explicitly changes devices to allow proper reinitialization. Root Cause: - PR #1471 introduced aggregate device for speaker capture - Creating "private" aggregate device unexpectedly triggers device change events - DeviceMonitor caught in reinitialization loop during startup - Audio pipeline never stabilized, no audio captured Solution: - Added Arc to track initialization completion - Device events ignored during initialization phase - Flag set to true after both mic+speaker streams start - Flag reset on explicit device changes for proper reinitialization Testing: - Verified on macOS 15.7.2 (M4 MacBook Pro) - Audio capture now works correctly - No device switching loop observed - Device changes handled gracefully during recording - Success message "audio_streams_initialized" confirms stable pipeline Changes: - Modified: plugins/listener/src/actors/source.rs (+17 lines) - No breaking changes - Backward compatible --- plugins/listener/src/actors/source.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/plugins/listener/src/actors/source.rs b/plugins/listener/src/actors/source.rs index 6e9a670fa..83e38dcd4 100644 --- a/plugins/listener/src/actors/source.rs +++ b/plugins/listener/src/actors/source.rs @@ -40,6 +40,7 @@ pub struct SourceState { _silence_stream_tx: Option>, _device_event_thread: Option>, current_mode: ChannelMode, + initialization_complete: Arc, } pub struct SourceActor; @@ -65,6 +66,8 @@ impl Actor for SourceActor { let device_monitor_handle = DeviceMonitor::spawn(event_tx); let myself_clone = myself.clone(); + let initialization_complete = Arc::new(AtomicBool::new(false)); + let initialization_complete_clone = initialization_complete.clone(); let device_event_thread = std::thread::spawn(move || { use std::sync::mpsc::RecvTimeoutError; @@ -77,6 +80,11 @@ impl Actor for SourceActor { Ok(event) => match event { DeviceEvent::DefaultInputChanged { .. } | DeviceEvent::DefaultOutputChanged { .. } => { + if !initialization_complete_clone.load(Ordering::Relaxed) { + tracing::info!(event = ?event, "device_event_ignored_during_init"); + continue; + } + tracing::info!(event = ?event, "device_event_outer"); loop { @@ -121,6 +129,7 @@ impl Actor for SourceActor { _silence_stream_tx: silence_stream_tx, _device_event_thread: Some(device_event_thread), current_mode: ChannelMode::Dual, + initialization_complete, }; start_source_loop(&myself, &mut st).await?; @@ -149,6 +158,7 @@ impl Actor for SourceActor { } SourceMsg::SetMicDevice(dev) => { st.mic_device = dev; + st.initialization_complete.store(false, Ordering::Relaxed); if let Some(cancel_token) = st.stream_cancel_token.take() { cancel_token.cancel(); @@ -193,6 +203,7 @@ async fn start_source_loop( let token = st.token.clone(); let mic_muted = st.mic_muted.clone(); let mic_device = st.mic_device.clone(); + let initialization_complete = st.initialization_complete.clone(); let stream_cancel_token = CancellationToken::new(); st.stream_cancel_token = Some(stream_cancel_token.clone()); @@ -250,6 +261,9 @@ async fn start_source_loop( tokio::pin!(mic_stream); tokio::pin!(spk_stream); + initialization_complete.store(true, Ordering::Relaxed); + tracing::info!("audio_streams_initialized"); + loop { let Some(cell) = registry::where_is(ProcessorActor::name()) else { tracing::warn!("processor_actor_not_found"); @@ -312,6 +326,9 @@ async fn start_source_loop( tokio::pin!(mic_stream); tokio::pin!(spk_stream); + initialization_complete.store(true, Ordering::Relaxed); + tracing::info!("audio_streams_initialized"); + loop { let Some(cell) = registry::where_is(ProcessorActor::name()) else { tracing::warn!("processor_actor_not_found");