Skip to content

Conversation

@jbbjbb
Copy link

@jbbjbb jbbjbb commented Nov 12, 2025

Fix: Audio device switching loop on macOS 15.7.2

Fixes #1371

Problem

On macOS 15.7.2 (M4 MacBook Pro), Hyprnote fails to capture audio due to an infinite device switching loop between the speaker (Device 84) and microphone (Device 91). This causes:

  • No audio frames processed
  • Recordings fail with "at least 100 words needed"
  • Continuous AUHAL instance creation/destruction visible in logs

Root Cause

When initializing audio streams, creating the speaker tap's aggregate device triggers system device change notifications. The device monitor responds to these notifications by restarting the streams, which triggers more notifications, creating an infinite loop.

The existing 1000ms debouncing is insufficient during initialization on newer macOS versions.

Solution

Add an initialization guard flag (initialization_complete) that prevents the device monitor from responding to device change events during audio stream initialization.

Key Changes

  • Add Arc<AtomicBool> flag to SourceState
  • Guard device change handler to ignore events when initialization_complete == false
  • Set flag to true after both mic and speaker streams are fully initialized
  • Reset flag to false when manually changing devices

Testing

Tested on macOS 15.7.2 M4 MacBook Pro:

  • ✅ Audio capture now works correctly
  • ✅ Device monitor still responds to legitimate device changes
  • ✅ No performance impact
  • ✅ Backward compatible with older macOS versions

Files Changed

  • plugins/listener/src/actors/source.rs - Single file modification

Log Output (After Fix)

[INFO] audio_streams_initialized
[INFO] device_event_ignored_during_init

Instead of the previous loop pattern showing repeated device switching.

Fixes fastrepl#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 fastrepl#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<AtomicBool> 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
@coderabbitai
Copy link

coderabbitai bot commented Nov 12, 2025

📝 Walkthrough

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: preventing device switching loops during audio initialization on macOS.
Description check ✅ Passed The description clearly explains the problem, root cause, and solution related to the audio device switching issue.
Linked Issues check ✅ Passed The PR addresses the core issue #1371 by preventing the audio device switching loop that caused silent recording failures through an initialization guard flag.
Out of Scope Changes check ✅ Passed All changes are focused on preventing device switching during initialization; no extraneous modifications detected beyond the stated scope.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (4)
plugins/listener/src/actors/source.rs (4)

69-70: Core fix looks good, but consider stronger memory ordering.

The initialization guard successfully prevents device events from triggering restarts during initialization. The logic is sound and the logging will aid debugging.

However, using Ordering::Relaxed at line 83 may allow subtle race conditions where the device event thread doesn't immediately observe the true value written by the tokio task. While unlikely to cause issues in practice (worst case: one extra event gets ignored after init completes), consider using Ordering::Acquire for the load and Ordering::Release for the stores at lines 264 and 329 to guarantee visibility across threads.

Apply this diff for stronger ordering guarantees:

-                            if !initialization_complete_clone.load(Ordering::Relaxed) {
+                            if !initialization_complete_clone.load(Ordering::Acquire) {

And update the stores at lines 264, 329, and 161:

-                initialization_complete.store(true, Ordering::Relaxed);
+                initialization_complete.store(true, Ordering::Release);
-                st.initialization_complete.store(false, Ordering::Relaxed);
+                st.initialization_complete.store(false, Ordering::Release);

Also applies to: 83-86, 132-132


264-265: Initialization signaling correctly placed.

Setting initialization_complete to true after both streams are created and pinned is the right timing—streams are ready to process audio, and device events should now be handled normally.

Optional improvement: If stream creation fails (e.g., at lines 250 or 256), the unwrap() calls will panic and the flag will never be set to true, causing all subsequent device events to be ignored until manual intervention. Consider propagating errors gracefully to ensure the system can recover or at least log a fatal error.


329-330: Initialization signaling correctly placed.

This mirrors the Single mode path—setting initialization_complete to true after streams are ready is correct.

Optional improvement: Same as the Single mode path (lines 264-265), consider propagating stream creation errors gracefully rather than panicking on unwrap() at lines 318 or 323 to prevent the system from entering an unrecoverable state where device events are permanently ignored.


312-314: Clarify unreachable non-macOS Single mode path.

The code at lines 312-314 spawns an empty task for non-macOS in Single mode. However, line 219 ensures non-macOS systems always use ChannelMode::Dual, making this branch unreachable in practice. If this branch somehow executed, initialization_complete would never be set to true, permanently blocking device event handling.

Consider adding a comment or assertion to make it explicit that this branch is defensive and should never execute, or handle it more explicitly.

         #[cfg(not(target_os = "macos"))]
         {
+            // Defensive: non-macOS should always use Dual mode (line 219)
+            tracing::error!("non_macos_single_mode_unexpected");
             tokio::spawn(async move {})
         }
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 0df8e3d and 5c72a06.

📒 Files selected for processing (1)
  • plugins/listener/src/actors/source.rs (8 hunks)
🔇 Additional comments (3)
plugins/listener/src/actors/source.rs (3)

1-2: LGTM: Appropriate types for the initialization guard.

The Arc<AtomicBool> is the correct choice for sharing the initialization state across the device event thread and the tokio task.

Also applies to: 43-43


161-161: Correct handling of manual device changes.

Resetting initialization_complete to false when the device is manually changed ensures that subsequent stream initialization is also protected from device event loops. The flag will be set back to true when start_source_loop completes initialization.


206-206: LGTM: Proper Arc cloning for task.

The clone correctly passes a reference to the tokio task so it can signal completion after stream initialization.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Recording is not working, silently fails to transcribe

1 participant