Skip to content

feat(macOS): Capture audio on macOS using Tap API#4209

Merged
ReenigneArcher merged 57 commits intoLizardByte:masterfrom
ThomVanL:users/thomasvanlaere/feat-macos-ca-taps
Mar 21, 2026
Merged

feat(macOS): Capture audio on macOS using Tap API#4209
ReenigneArcher merged 57 commits intoLizardByte:masterfrom
ThomVanL:users/thomasvanlaere/feat-macos-ca-taps

Conversation

@ThomVanL
Copy link
Contributor

@ThomVanL ThomVanL commented Aug 29, 2025

Description

This PR adds system-wide audio tap support for macOS. The implementation introduces:

  • System-wide audio tap functionality to capture audio from all system sources
    • Uses an audio converter to handle varying client audio requirements
  • Unit tests covering the new system tap methods, along with additional coverage for the existing microphone path
  • Updated UI with a macOS-specific toggle (see screenshots)
  • Updated configuration options to support the new audio tap feature
  • Added Doxygen documentation for new APIs

Additional changes

  • Adjusted cmake files for compatibility with Homebrew-based setup.
    • Tweaked dependency detection so that openssl and opus are found automatically. This replaces the need to run manual ln commands, but I’m not sure if this is the best long-term approach. Feedback welcome.
    • Updated cmake/compile_definitions/unix.cmake to ensure SUNSHINE_ASSETS_DIR resolves correctly.
  • Updated src_assets/macos/assets/Info.plist to prepare for required macOS permission prompts.
  • Added a macos_system_wide_audio_tap config option to the audio_t struct.

Testing

  • Verified functionality with multiple (Moonlight) clients requiring mono, stereo, 5.1, and 7.1 audio configurations.
  • Confirmed audio conversion works as expected across varying client setups.
  • Stress-tested with multiple concurrent clients for several hours without memory leaks or race conditions observed.
  • Host ran an arm64 build during testing

Notes

  • My background is primarily in .NET, with some experience in C, C++, and Rust. Objective-C is new to me, but I carefully reviewed and tested the bits on memory management and synchronization.
  • I leaned on GitHub Copilot and AI assistance heavily for the Objective-C parts, especially syntax and boilerplate; but I reviewed, debugged and tested everything myself a bunch of times.
  • Some of the unit tests were bordering on integration tests, so I tried to keep them focused and not blur the lines too much.
  • I developed this feature to make Sunshine more accessible on macOS, especially for less technical users. I have to admit that I fumbled quite a bit getting BlackHole to work. 🙂 But I also wanted to make sure to not break the existing setupMicrophone functionality as it is also a viable option!
  • I am not entirely convinced on the naming or location of the macos_system_wide_audio_tap setting.
    • Open to suggestions here!
  • Built this on arm64 macOS 15.6.1 (24G90)

If the AI involvement is a blocker for merging, no worries. I can totally understand! This was also a learning project for me and I had a lot of fun building it.

Screenshot

Web UI – Audio/Video Configuration
New option for enabling system-wide audio recording on macOS. Disables the audio sink option when checked.
a

macOS Permission Prompt
System permission request when Sunshine first tries to access system audio:
b

System Settings – Screen & System Audio Recording
macOS privacy settings showing Sunshine/Terminal access to "System Audio Recording Only":
c

Issues Fixed or Closed

Roadmap Issues

Type of Change

  • feat: New feature (non-breaking change which adds functionality)
  • fix: Bug fix (non-breaking change which fixes an issue)
  • docs: Documentation only changes
  • style: Changes that do not affect the meaning of the code (white-space, formatting, missing semicolons, etc.)
  • refactor: Code change that neither fixes a bug nor adds a feature
  • perf: Code change that improves performance
  • test: Adding missing tests or correcting existing tests
  • build: Changes that affect the build system or external dependencies
  • ci: Changes to CI configuration files and scripts
  • chore: Other changes that don't modify src or test files
  • revert: Reverts a previous commit
  • BREAKING CHANGE: Introduces a breaking change (can be combined with any type above)

Checklist

  • Code follows the style guidelines of this project
  • Code has been self-reviewed
  • Code has been commented, particularly in hard-to-understand areas
  • Code docstring/documentation-blocks for new or existing methods/components have been added or updated
  • Unit tests have been added or updated for any new or modified functionality

AI Usage

  • None: No AI tools were used in creating this PR
  • Light: AI provided minor assistance (formatting, simple suggestions)
  • Moderate: AI helped with code generation or debugging specific parts
  • Heavy: AI generated most or all of the code changes

@ThomVanL ThomVanL changed the title Capture audio on macOS using Tap API feat(macOS): Capture audio on macOS using Tap API Aug 29, 2025
@ReenigneArcher

This comment was marked as resolved.

@ThomVanL

This comment was marked as resolved.

@ReenigneArcher

This comment was marked as resolved.

@ReenigneArcher

This comment was marked as resolved.

@codecov
Copy link

codecov bot commented Aug 30, 2025

Bundle Report

Changes will increase total bundle size by 19 bytes (0.0%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
sunshine-esm 780.86kB 19 bytes (0.0%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: sunshine-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/_plugin-*.js 19 bytes 352.6kB 0.01%

Files in assets/_plugin-*.js:

  • ./src_assets/common/assets/web/public/assets/locale/en.json → Total Size: 37.87kB

@codecov
Copy link

codecov bot commented Aug 30, 2025

Codecov Report

❌ Patch coverage is 45.61069% with 285 lines in your changes missing coverage. Please review.
⚠️ Please upload report for BASE (master@a7ab814). Learn more about missing BASE report.
⚠️ Report is 1 commits behind head on master.
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/platform/macos/av_audio.mm 48.79% 143 Missing and 90 partials ⚠️
src/platform/macos/microphone.mm 5.00% 30 Missing and 8 partials ⚠️
src/platform/macos/coreaudio_helpers.h 50.00% 9 Missing and 3 partials ⚠️
src/audio.cpp 66.66% 0 Missing and 1 partial ⚠️
src/platform/windows/audio.cpp 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff            @@
##             master    #4209   +/-   ##
=========================================
  Coverage          ?   18.51%           
=========================================
  Files             ?      107           
  Lines             ?    22448           
  Branches          ?     9970           
=========================================
  Hits              ?     4156           
  Misses            ?    15929           
  Partials          ?     2363           
Flag Coverage Δ
Archlinux 12.27% <0.00%> (?)
FreeBSD-14.3-amd64 14.09% <0.00%> (?)
Homebrew-ubuntu-22.04 14.63% <75.00%> (?)
Linux-AppImage 12.77% <0.00%> (?)
Windows-AMD64 14.98% <0.00%> (?)
Windows-ARM64 13.39% <0.00%> (?)
macOS-arm64 19.11% <29.23%> (?)
macOS-x86_64 18.48% <44.52%> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/config.h 0.00% <ø> (ø)
src/platform/common.h 32.40% <ø> (ø)
src/platform/linux/audio.cpp 60.14% <100.00%> (ø)
src/audio.cpp 55.21% <66.66%> (ø)
src/platform/windows/audio.cpp 25.40% <0.00%> (ø)
src/platform/macos/coreaudio_helpers.h 50.00% <50.00%> (ø)
src/platform/macos/microphone.mm 25.75% <5.00%> (ø)
src/platform/macos/av_audio.mm 48.79% <48.79%> (ø)

@andygrundman
Copy link
Contributor

Thanks for the PR. I have built this on my M1 Pro MBP and have been trying to get it to work, but haven't had much success so far. I am not able to get any sound to be captured and/or sent. So far I am only testing with the simplest use case of audio playing out of my MBP speakers. I edited the code to make the tap and aggregate non-private, so I could try to view the tap/aggregate with Apple's sample app I can see the tap, but not the aggregate. I'm not sure what's wrong. Can you detail your testing process?

I do seem to be getting OK log entries and since I'm running from iTerm, all my permissions seem to be in order (iTerm has access to many things).

[2025-08-29 23:56:39.783]: Info: Detected display: Built-in Retina Display (id: 1) connected: true
[2025-08-29 23:56:39.783]: Info: Configuring selected display (1) to stream
[2025-08-29 23:56:39.841]: Info: Creating encoder [hevc_videotoolbox]
[2025-08-29 23:56:39.841]: Info: Color coding: SDR (Rec. 601)
[2025-08-29 23:56:39.841]: Info: Color depth: 10-bit
[2025-08-29 23:56:39.841]: Info: Color range: MPEG
[2025-08-29 23:56:39.841]: Info: Streaming bitrate is 55987000
[2025-08-29 23:56:39.850]: Info: [hevc_videotoolbox @ 0x143e60e70] This device does not support the max_ref_frames option. Value ignored.
[2025-08-29 23:56:40.712]: Info: Using macOS system audio tap for capture.
[2025-08-29 23:56:40.712]: Info: Sample rate: 48000, Frame size: 240, Channels: 2
[2025-08-29 23:56:40.738]: Info: Aggregate device created with ID: 203
[2025-08-29 23:56:40.738]: Info: Aggregate device created and configured successfully
[2025-08-29 23:56:40.739]: Info: No conversion needed - formats match (device: 48000Hz/2ch)
[2025-08-29 23:56:40.739]: Info: Device properties and converter configuration completed
[2025-08-29 23:56:40.793]: Info: System tap IO proc created and started successfully
[2025-08-29 23:56:40.793]: Info: Audio buffer initialized successfully with size: 8192 bytes
[2025-08-29 23:56:40.793]: Info: System tap setup completed successfully!
[2025-08-29 23:56:40.793]: Info: macOS system audio tap capturing.
[2025-08-29 23:56:40.794]: Info: Opus initialized: 48 kHz, 2 channels, 512 kbps (total), LOWDELAY

I had to make the following changes to get it to build:

-#pragma clang diagnostic push
-#pragma clang diagnostic ignored "-Wunguarded-availability-new"
-    AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInMicrophone, AVCaptureDeviceTypeExternalUnknown]
+    AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeMicrophone, AVCaptureDeviceTypeExternal]
                                                                                                                mediaType:AVMediaTypeAudio
                                                                                                                 position:AVCaptureDevicePositionUnspecified];
     NSArray *devices = discoverySession.devices;
     BOOST_LOG(debug) << "Found "sv << [devices count] << " devices using discovery session"sv;
     return devices;
-#pragma clang diagnostic pop

plus a fix in our input.cpp that breaks the latest Xcode 16.4, I'm surprised if you didn't run into this one. I am running Xcode 16.4 clang-1700.0.13.5).

--- a/src/platform/macos/input.cpp
+++ b/src/platform/macos/input.cpp
@@ -534,7 +534,7 @@ const KeyCodeMap kKeyCodesMap[] = {
     if (!output_name.empty()) {
       uint32_t max_display = 32;
       uint32_t display_count;
-      CGDirectDisplayID displays[max_display];
+      CGDirectDisplayID displays[32];

@ReenigneArcher

This comment was marked as outdated.

@ThomVanL
Copy link
Contributor Author

Hey @andygrundman, thanks for taking the time to test the PR. Sorry to hear it’s not working correctly.

Here’s my setup on an M4 MBP and what I did.

clang --version
Apple clang version 17.0.0 (clang-1700.0.13.5)
Target: arm64-apple-darwin24.6.0
Thread model: posix
InstalledDir: /Library/Developer/CommandLineTools/usr/bin

I made the changes in VS Code and followed the steps in building.md:

mkdir build
cmake -B build -G Ninja -S . 
ninja -C build

For testing I did the following steps:

  • In the VS Code terminal: ./build/sunshine
  • Accept the allow connections prompt
  • Connect with multiple Moonlight clients with the following sample rate/frame size/channel combos:
    • iPad @ 1080p → 48000Hz/240/2ch audio → works
    • Android TV @ 1080p → 48000Hz/240/8ch audio → works
    • iOS @ 360p → 48000Hz/480/2ch audio → works

So at one point I'm running three streams simultaneously and the audio plays through the devices.

My sunshine.conf looks like:

audio_sink = Steam Streaming Speakers
macos_system_wide_audio_tap = true
origin_web_ui_allowed = pc
stream_audio = true
wan_encryption_mode = 2

With min_log_level = debug, here’s the output when connecting from the 360p device (trimmed for brevity).

[2025-08-30 10:42:27.456]: Info: CLIENT CONNECTED
[2025-08-30 10:42:27.463]: Debug: Start capturing Video
[2025-08-30 10:42:27.463]: Info: Detecting displays
[2025-08-30 10:42:27.463]: Info: Detected display: Built-in Retina Display (id: 1) connected: true
[2025-08-30 10:42:27.463]: Info: Configuring selected display (1) to stream
[2025-08-30 10:42:27.541]: Info: Creating encoder [hevc_videotoolbox]
[2025-08-30 10:42:27.541]: Info: Color coding: SDR (Rec. 601)
[2025-08-30 10:42:27.541]: Info: Color depth: 8-bit
[2025-08-30 10:42:27.541]: Info: Color range: MPEG
[2025-08-30 10:42:27.541]: Info: Streaming bitrate is 1268000
[2025-08-30 10:42:27.550]: Info: [hevc_videotoolbox @ 0x13d01de00] This device does not support the max_ref_frames option. Value ignored.
[2025-08-30 10:42:27.945]: Debug: Start capturing Audio
[2025-08-30 10:42:27.946]: Warning: audio_control_t::set_sink() unimplemented: Steam Streaming Speakers
[2025-08-30 10:42:27.946]: Info: Using macOS system audio tap for capture.
[2025-08-30 10:42:27.946]: Info: Sample rate: 48000, Frame size: 480, Channels: 2
[2025-08-30 10:42:27.946]: Debug: setupSystemTap called with sampleRate:48000 frameSize:480 channels:2
[2025-08-30 10:42:27.946]: Debug: macOS version check passed (running 15.6.1)
[2025-08-30 10:42:27.946]: Debug: System tap initialization completed
[2025-08-30 10:42:27.946]: Debug: Creating tap description for 2 channels
[2025-08-30 10:42:27.946]: Debug: Creating process tap with name: SunshineAVAudio-Tap-0x6000007d00c0
[2025-08-30 10:42:27.949]: Debug: AudioHardwareCreateProcessTap returned status: 0
[2025-08-30 10:42:27.949]: Debug: Process tap created successfully with ID: 140
[2025-08-30 10:42:27.949]: Debug: Creating aggregate device with tap UID: 675EC59C-D7CC-4A25-A093-F2B4B4227895
[2025-08-30 10:42:27.957]: Debug: AudioHardwareCreateAggregateDevice returned status: 0
[2025-08-30 10:42:27.957]: Info: Aggregate device created with ID: 141
[2025-08-30 10:42:27.959]: Debug: Set aggregate device sample rate to 48000Hz
[2025-08-30 10:42:27.959]: Debug: Set aggregate device buffer size to 480 frames
[2025-08-30 10:42:27.959]: Info: Aggregate device created and configured successfully
[2025-08-30 10:42:27.960]: Debug: Device reports 2 input channels
[2025-08-30 10:42:27.960]: Debug: Device properties - Sample Rate: 48000Hz, Channels: 2
[2025-08-30 10:42:27.960]: Debug: needsConversion: NO (device: 48000Hz/2ch -> client: 48000Hz/2ch)
[2025-08-30 10:42:27.960]: Info: No conversion needed - formats match (device: 48000Hz/2ch)
[2025-08-30 10:42:27.960]: Info: Device properties and converter configuration completed
[2025-08-30 10:42:27.960]: Debug: Creating IOProc for aggregate device ID: 141
[2025-08-30 10:42:27.977]: Debug: AudioDeviceCreateIOProcID returned status: 0
[2025-08-30 10:42:27.978]: Debug: Starting IOProc for aggregate device
[2025-08-30 10:42:27.995]: Debug: AudioDeviceStart returned status: 0
[2025-08-30 10:42:27.995]: Info: System tap IO proc created and started successfully
[2025-08-30 10:42:27.995]: Debug: Initializing audio buffer for 2 channels
[2025-08-30 10:42:27.995]: Info: Audio buffer initialized successfully with size: 8192 bytes
[2025-08-30 10:42:27.995]: Info: System tap setup completed successfully!
[2025-08-30 10:42:27.995]: Info: macOS system audio tap capturing.
[2025-08-30 10:42:27.995]: Info: Opus initialized: 48 kHz, 2 channels, 96 kbps (total), LOWDELAY

I’m familiar with the sample app! It might be the case that the aggregate device settings are probably still marked as private. You’ll need to flip those to NO in two places, once for the tap description and once for the aggregate device.

[tapDescription setPrivate:YES];

@kAudioAggregateDeviceIsPrivateKey: @YES,

Then the tap will show up in Apple's sample app's UI.

Screenshot 2025-08-30 at 10 56 15

And the aggregate device shows up as well.

Screenshot 2025-08-30 at 10 56 22

But even with those values flipped to YES, I can still hear audio on the 360p device!

@andygrundman
Copy link
Contributor

Thanks for the detailed info. I forgot my log only had Info level, when using Debug my log does look exactly like yours. I feel like I must just have a permission issue, maybe using iTerm as the permission "owner" isn't correct.

Do you see both of these items? What process names are they using? I only get a System Audio item when recording a test file in AudioTapSample.
permissions

Here's basically what my Screen & System Audio Recording settings look like:
privacy

If this is the issue, I wonder how Sunshine can detect that it doesn't actually have permission, hmm.

@ThomVanL
Copy link
Contributor Author

ThomVanL commented Aug 30, 2025

You're right, because I ran into a similar issue with VS Code. Granting it "Screen & System Audio Recording" wasn’t enough; I had to explicitly allow "System Audio Recording Only." From what I found online, the VS Code app bundle itself might be causing the problem. I also tried running tccutil reset All to clear permissions, but the behavior stayed the same. I had to explicitly add permissions, but only for VS Code.

Screenshot 2025-08-30 at 13 26 59

I did not even notice the little privacy notice at the top until just now, thanks for that. Here's what it's like on my end.

Screenshot 2025-08-30 at 13 28 52

When I launched ./build/sunshine directly from the macOS Terminal (not iTerm), I did get the prompt mentioned in my initial message on this PR.

Edit: just to be clear, my dev loop consists of launching sunshine builds through the VS Code integrated terminal.

@ReenigneArcher
Copy link
Member

ReenigneArcher commented Mar 10, 2026

It appears that tests are crashing on macOS 15 and 26, at these tests

  • 15 - Configurations/AudioTest.TestEncode/HIGH_STEREO
  • 26 - Configurations/AudioTest.TestEncode/SURROUND51

Also, still one lint issue: https://github.com/LizardByte/Sunshine/actions/runs/22879151110/job/66380516025?pr=4209#step:10:57

@ThomVanL
Copy link
Contributor Author

It appears that tests are crashing on macOS 15 and 26, at these tests

  • 15 - Configurations/AudioTest.TestEncode/HIGH_STEREO
  • 26 - Configurations/AudioTest.TestEncode/SURROUND51

It looks like we might still need your TCC fix after all… but I will run a few tests first!

@ReenigneArcher
Copy link
Member

It looks like we might still need your TCC fix after all… but I will run a few tests first!

It's somewhat odd because the macOS-26 completes some of the audio tests before crashing out.

@ThomVanL
Copy link
Contributor Author

ThomVanL commented Mar 14, 2026

Hey @ReenigneArcher I've had some time to really dive into this a bit more and it's, like you mentioned, worth noting that the TCC prompting is not happening during the Homebrew validation on the master branch. I thought that was a little strange, as well.

You can see that I've pushed a small (preliminary) change that I'd love to see run through the workflow. Your feedback is very much welcome.

I had a closer look at the CI workflow logs (the Homebrew macOS validation) and I noticed other parts of the code also interact with TCC but appear to skip cleanly:

On macOS 14:

  [----------] 2 tests from EncoderVariants/EncoderTest
  [ RUN      ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox
  IOServiceMatchingfailed for: AppleM2ScalerCSCDriver
  /private/tmp/sunshine-20260314-59779-wfzgqc/tests/unit/test_video.cpp:18: Skipped
  Encoder not available
  [  SKIPPED ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox (1055 ms)

macOS 15

  [----------] 2 tests from EncoderVariants/EncoderTest
  [ RUN      ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox
  IOServiceMatchingfailed for: AppleM2ScalerParavirtDriver
  /private/tmp/sunshine-20260314-30146-nfg1wh/tests/unit/test_video.cpp:18: Skipped
  Encoder not available
  
  [  SKIPPED ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox (2287 ms)
  [ RUN      ] EncoderVariants/EncoderTest.ValidateEncoder/software
  [       OK ] EncoderVariants/EncoderTest.ValidateEncoder/software (86 ms)
  [----------] 2 tests from EncoderVariants/EncoderTest (2374 ms total)

macOS 26

  [----------] 2 tests from EncoderVariants/EncoderTest
  [ RUN      ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox
  IOServiceMatchingfailed for: AppleM2ScalerParavirtDriver
  /private/tmp/sunshine-20260314-71238-cegf4y/tests/unit/test_video.cpp:18: Skipped
  Encoder not available
  
  [  SKIPPED ] EncoderVariants/EncoderTest.ValidateEncoder/videotoolbox (1954 ms)
  [ RUN      ] EncoderVariants/EncoderTest.ValidateEncoder/software
  [       OK ] EncoderVariants/EncoderTest.ValidateEncoder/software (88 ms)
  [----------] 2 tests from EncoderVariants/EncoderTest (2043 ms total)

So, I went and implemented something in test_audio.cpp similar to what happens in test_video.cpp, where encoder availability is checked before running.

But about those audio tests... I think the reason these pass on the master branch is a bit subtle. When no audio sink is set in AudioTest, config::audio.sink is empty, so the local audio_sink variable inside microphone() stays at its default of "", which gets passed to findMicrophone(""). That function iterates through all available AVCaptureDevice instances and compares each device's localizedName to "". Since no device matches, it returns nil. That in turn propagates back up as nullptr from microphone() and the null check in audio.cpp returns immediately without starting capture. The test's timer fires after 100ms, stops the queue, and the capture thread exits cleanly. Since no packets ever arrive, the FAIL() on empty packet data is never hit, and the test "passes". In other words, that test can pass even when audio capture never initialized at all — it isn't actually verifying that audio is working.

With the CoreAudio tap implementation, however, not setting a sink in AudioTest now causes execution to reach the CoreAudio tap creation flow. I don't think I can insert a guard in microphone() to fail gracefully, as creating the tap itself triggers the TCC prompt and blocks.

As an alternative, we could inject permissions directly into the TCC database, but that feels a little brittle and error-prone.

So the question is: should we add a GTEST_SKIP() guard (which would skip the test on machines without the permission rather than actually verifying capture works) or should we go ahead with TCC injection in CI? I'm leaning toward the skip at the moment, but wanted to get input before going any further.

(I'll sort out rebasing my commits, too.)

ReenigneArcher
ReenigneArcher previously approved these changes Mar 14, 2026
@ReenigneArcher
Copy link
Member

I also noticed the audio test wasn't really the greatest test. We could probably improve it in a later PR. For now, I think I'm good with this PR as is. Would you want to submit a follow up PR to help improve the tests?

My preference would be to actually verify audio works, but I understand sometimes this isn't the easiest to do in a CI environment where we don't have control of the hardware. For the encoder tests, I just make them skip if they don't work, unless software encoding fails in which case it is a failure. Software encoding should always work, whether in a CI environment or not.

@ReenigneArcher
Copy link
Member

Looks like the audio tests are still failing on macos 15 and 26, so maybe it needs a better fix before this is merged.

@ThomVanL
Copy link
Contributor Author

I also noticed the audio test wasn't really the greatest test. We could probably improve it in a later PR. For now, I think I'm good with this PR as is. Would you want to submit a follow up PR to help improve the tests?

Sure thing, I don't mind taking a look.

Looks like the audio tests are still failing on macos 15 and 26, so maybe it needs a better fix before this is merged.

That's unfortunate... Though I don't mind digging into it some more!

@ReenigneArcher
Copy link
Member

@ThomVanL so you think it's a permissions issue?

  • macOS-14: are all skipped now
  • macOS-15: HIGH_STEREO crashes
  • macOS-26: HIGH_STEREO is skipped, and the next one crashes

Would adding a virtual audio device allow it to pass? Looks like blackhole is available in homebrew with 3 variants. We could make these dependencies in the homebrew package?

  1. https://formulae.brew.sh/cask/blackhole-2ch#default
  2. https://formulae.brew.sh/cask/blackhole-16ch#default
  3. https://formulae.brew.sh/cask/blackhole-64ch#default

@ThomVanL
Copy link
Contributor Author

ThomVanL commented Mar 17, 2026

@ReenigneArcher Yes, I think it's got to do with the kTCCServiceAudioCapture permission. I have been also considering adding a sink, so it's definitely something we could try.

I have been conducting quite a bit of testing on a separate repository, with just the code path that creates the tap and aggregate device, gtest and a workflow; least with enough code to prompt the TCC. But so far I've been unable to add those permissions to the TCC database.

It also looks like the permission is not configured in GitHub's macOS runners, based on what I can tell.

I will do some more testing this evening.

@ReenigneArcher
Copy link
Member

@ThomVanL the script you linked is where I originally discovered how to adjust the permissions in the runner as well. You can add anything you want in the same way they do because SIP is disabled in the runners. The hard part, when I originally did it, was figuring out what to give the permission to... but that was with macports. I think with homebrew it should be easier as it's supported on the runners out of the box.

@ThomVanL
Copy link
Contributor Author

ThomVanL commented Mar 19, 2026

@ReenigneArcher I did some more testing on this across a few different approaches, but I wasn’t able to get the TCC settings to apply correctly. I tried configuring it in multiple ways, but no success so far.

As a fallback, I replicated the current behavior by forcing the sink to a non-existent value:

(I have not yet committed this, by the way)

struct AudioTest: PlatformTestSuite, testing::WithParamInterface<std::tuple<std::basic_string_view<char>, config_t>> {
  void SetUp() override {
    m_config = std::get<1>(GetParam());
    m_mail = std::make_shared<safe::mail_raw_t>();

#ifdef __APPLE__
    m_saved_sink = config::audio.sink;
    if (config::audio.sink.empty()) {
      config::audio.sink = "__nonexistent_audio_sink__";
      m_overrode_sink = true;
    }
#endif
  }

  void TearDown() override {
#ifdef __APPLE__
    if (m_overrode_sink) {
      config::audio.sink = m_saved_sink;
    }
#endif
  }

  config_t m_config;
  safe::mail_t m_mail;
#ifdef __APPLE__
  std::string m_saved_sink;
  bool m_overrode_sink {};
#endif
};

That said, I’m not particularly a big fan of this approach. I find it to be more along the lines of a quick fix rather than a proper solution.. Though as far as I can tell it mirrors the current behavior in master.

I’ll keep digging into the TCC side tomorrow evening to see if there’s a way to get the permissions set correctly in the runner.

@ThomVanL ThomVanL force-pushed the users/thomasvanlaere/feat-macos-ca-taps branch from dd24999 to 50b2105 Compare March 21, 2026 00:56
@ThomVanL
Copy link
Contributor Author

ThomVanL commented Mar 21, 2026

@ReenigneArcher Even better, I've been able to add the correct TCC permissions, and in my testing I've been able to get OK results from all four Configurations/AudioTest tests. I've added the script as a conditional step in the ci-homebrew.yaml and rolled back the changes to test_audio.cpp.

I ended up just expanding upon that script from the "actions/runner-images" I linked earlier. It didn't occur to me to add kTCCServiceAudioCapture to those recurring clients, my bad.

@sonarqubecloud
Copy link

@ReenigneArcher
Copy link
Member

@ThomVanL nice work! Looks like everything is green!

@ReenigneArcher ReenigneArcher merged commit 0d3be0b into LizardByte:master Mar 21, 2026
73 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai PR has signs of heavy ai usage (either indicated by user or assumed) roadmap This PR closes a roadmap entry

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Sunshine: Capture audio on macOS using Tap API

4 participants