Skip to content

Conversation

@ThomVanL
Copy link

@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
Copy link
Member

Thank you for the PR!

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.

This isn't a blocker, we just want to understand how much it was used when developing the PR.

I've went ahead and approved the workflow run so the basic linting and whatnot can be taken care of before full reviews take place.

Looks like there's an issue with the tests. I don't see an error, just kind of looks like it crashes out. https://github.com/LizardByte/Sunshine/actions/runs/17332988293/job/49216925169?pr=4209#step:8:3666

Linting:

We'll also need another way to compile the tests without the .mm file on non macOS platforms: https://github.com/LizardByte/Sunshine/actions/runs/17332988293/job/49216934161?pr=4209#step:11:1886

@ThomVanL
Copy link
Author

Hey @ReenigneArcher, thanks for kicking things off and for clarifying the AI usage policy!

I’ll dig into those CI issues next.

Got one question regarding the cross-platform test compilation issue: would you be okay with me updating tests/CMakeLists.txt so that those .mm files are only included when building on macOS? That seems like the cleanest way to keep non-macOS builds happy while still testing the new audio tap functionality where it applies.

I’ll push updates once I have fixes in place. And thanks again for the feedback! 🙂

@ReenigneArcher
Copy link
Member

Got one question regarding the cross-platform test compilation issue: would you be okay with me updating tests/CMakeLists.txt so that those .mm files are only included when building on macOS?

Sounds fine to me and I also believe that's the cleanest and easiest approach.

@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 121 bytes (0.01%) ⬆️. This is within the configured threshold ✅

Detailed changes
Bundle name Size Change
sunshine-esm 965.15kB 121 bytes (0.01%) ⬆️

Affected Assets, Files, and Routes:

view changes for bundle: sunshine-esm

Assets Changed:

Asset Name Size Change Total Size Change (%)
assets/_plugin-*.js 121 bytes 343.43kB 0.04%

Files in assets/_plugin-*.js:

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

@codecov
Copy link

codecov bot commented Aug 30, 2025

Codecov Report

❌ Patch coverage is 0% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 12.09%. Comparing base (eb72930) to head (f667865).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/audio.cpp 0.00% 3 Missing ⚠️
src/platform/linux/audio.cpp 0.00% 1 Missing ⚠️
src/platform/windows/audio.cpp 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #4209      +/-   ##
==========================================
- Coverage   12.09%   12.09%   -0.01%     
==========================================
  Files          87       87              
  Lines       17612    17613       +1     
  Branches     8097     8097              
==========================================
  Hits         2131     2131              
- Misses      14579    14580       +1     
  Partials      902      902              
Flag Coverage Δ
Linux-AppImage 11.62% <0.00%> (-0.01%) ⬇️
Windows-AMD64 13.41% <0.00%> (ø)

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 24.13% <ø> (ø)
src/platform/linux/audio.cpp 10.63% <0.00%> (ø)
src/platform/windows/audio.cpp 25.13% <0.00%> (ø)
src/audio.cpp 21.89% <0.00%> (-0.17%) ⬇️

@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
Copy link
Member

Posting this here for reference in case it's needed for permissions of unit tests. We used to need something like this for macports, but it was never necessary for homebrew.

- name: Fix permissions
run: |
# https://apple.stackexchange.com/questions/362865/macos-list-apps-authorized-for-full-disk-access
# https://github.com/actions/runner-images/issues/9529
# https://github.com/actions/runner-images/pull/9530
# function to execute sql query for each value
function execute_sql_query {
local value=$1
local dbPath=$2
echo "Executing SQL query for value: $value"
sudo sqlite3 "$dbPath" "INSERT OR IGNORE INTO access VALUES($value);"
}
# Find all provisioner paths and store them in an array
readarray -t provisioner_paths < <(sudo find /opt /usr -name provisioner)
echo "Provisioner paths: ${provisioner_paths[@]}"
# Create an empty array
declare -a values=()
# Loop through the provisioner paths and add them to the values array
for p_path in "${provisioner_paths[@]}"; do
# Adjust the service name and other parameters as needed
values+=("'kTCCServiceAccessibility','${p_path}',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,NULL,1592919552")
values+=("'kTCCServiceScreenCapture','${p_path}',1,2,4,1,NULL,NULL,0,'UNUSED',NULL,0,1687786159")
done
echo "Values: ${values[@]}"
if [[ "${{ matrix.os_version }}" == "14" ]]; then
# TCC access table in Sonoma has extra 4 columns: pid, pid_version, boot_uuid, last_reminded
for i in "${!values[@]}"; do
values[$i]="${values[$i]},NULL,NULL,'UNUSED',${values[$i]##*,}"
done
fi
# system and user databases
dbPaths=(
"/Library/Application Support/com.apple.TCC/TCC.db"
"$HOME/Library/Application Support/com.apple.TCC/TCC.db"
)
for value in "${values[@]}"; do
for dbPath in "${dbPaths[@]}"; do
echo "Column names for $dbPath"
echo "-------------------"
sudo sqlite3 "$dbPath" "PRAGMA table_info(access);"
echo "Current permissions for $dbPath"
echo "-------------------"
sudo sqlite3 "$dbPath" "SELECT * FROM access WHERE service='kTCCServiceScreenCapture';"
execute_sql_query "$value" "$dbPath"
echo "Updated permissions for $dbPath"
echo "-------------------"
sudo sqlite3 "$dbPath" "SELECT * FROM access WHERE service='kTCCServiceScreenCapture';"
done
done

@ThomVanL
Copy link
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
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.

@andygrundman
Copy link
Contributor

Great, adding usr/local/bin/sushine manually worked. (I run sudo make install probably because I used to have problems with the assets directory or something.)

I worry that this is a nightmare for the average user. I think we'll probably have to ship a proper dmg package so that only the actual app gets the permission and not whatever terminal the user ran brew install from. But I'm getting ahead of myself... I will put in some other review notes I have, after I do some more testing.

@ThomVanL
Copy link
Author

I completely agree that the user experience shouldn’t be tied to a specific terminal. At some point, you’ll likely want to create an app bundle. I've also added an additional property list key in the Info.plist file to help facilitate this a little:

<key>NSAudioCaptureUsageDescription</key>
<string>This app requires access to system audio to capture and stream audio output.</string>

I will check for a way to properly check those required permissions because right now this is missing.

By the way, I ran into the same issue with loading the assets. I initially worked around it by creating a symlink:

ln -s /Users/<username>/Sunshine/build/assets/web /usr/local/assets/web

However, I didn’t like having that lingering around so I updated the unix.cmake file to allow passing in the absolute path via -DSUNSHINE_ASSETS_DIR. But we could revert this if needed.

# unix specific compile definitions
# put anything here that applies to both linux and macos
list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
${CURL_LIBRARIES})
# add install prefix to assets path if not already there
# Skip prefix addition for absolute paths or development builds
if(NOT SUNSHINE_ASSETS_DIR MATCHES "^/" AND NOT SUNSHINE_ASSETS_DIR MATCHES "^${CMAKE_INSTALL_PREFIX}")
set(SUNSHINE_ASSETS_DIR "${CMAKE_INSTALL_PREFIX}/${SUNSHINE_ASSETS_DIR}")
endif()

@ReenigneArcher
Copy link
Member

By the way, I ran into the same issue with loading the assets. I initially worked around it by creating a symlink:

a make install is required after compiling

@andygrundman
Copy link
Contributor

I spent a bit of time looking about making a proper app bundle with cmake. It's certainly doable, and mostly just a matter of refactoring the maze of all the include files. App bundle would give us easier permissions, asset storage, signing, much easier for users.

The more I run the Mac version the more I realize the video side of things needs a lot of work too: host latency stat is missing, min/max framerate support, HDR doesn't work, frametime is uneven, it's using BGRA textures so probably using much more memory/cpu/gpu than it should be. It's one of the only apps that runs my MBP fan... Lots of fun stuff to work on I guess.

What's your use case with Mac Sunshine, if you don't mind me asking? We should assume controller support will also be a lot of work. Apple's "Screen Sharing" app already covers the remote desktop use cases, and no one plays games on their Mac (yet).

@ReenigneArcher

This comment was marked as off-topic.

@andygrundman

This comment was marked as off-topic.

Copy link
Contributor

@andygrundman andygrundman left a comment

Choose a reason for hiding this comment

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

Overall I think this is a well done patch, and is a good example of how to do an AI-assisted PR.

I originally was worried about the frequent use of manual release, e.g.

[audioInput release];
[audioOutput release];

but then I learned that ObjC built by cmake doesn't use the newer ARC memory management model that I was more familiar with from developing in Xcode. ARC dates from 2012 in OSX Lion if you can believe it, but since devs had to opt into using ARC, nobody ever did for this code. So, I guess that's a point for AI here. I would have probably not thought about this and been quite confused at all the memory leaks I was creating. Or more likely it would just result in a lot of compiler errors. (In -fobjc-arc mode, these kinds of release calls are compiler errors.)

</tr>
</table>

### macos_system_wide_audio_tap
Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a reason this needs to be a setting? The main point of confusion with users will be the fact that you don't link your capture to an audio device, the way you do on Windows. If we just defaulted to this, is there a downside?

Just FYI, when I was first playing around with the Tap API I went about it using the

CATapDescription *tapDesc = [[CATapDescription alloc] initExcludingProcesses:toExclude
                                                                andDeviceUID:audioSinkUID
                                                                  withStream:0];

version which used the default audio device. I never tried the system audio tap which I suppose works better. For example system tap captures audio even when the default audio device is muted, I think device capture would not capture audio in this case, but I haven't tested it.

Copy link
Author

Choose a reason for hiding this comment

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

I kept it as a setting to avoid breaking existing setups (users relying on BlackHole or similar). This way an update wouldn’t disrupt their workflow. That said, I'm happy to defer to you and the other maintainers on whether it makes sense to just default to the system tap.

Also, I’m not entirely sure initStereoGlobalTapButExcludeProcesses is the best long-term choice, since it enforces stereo and might complicate 5.1/7.1 setups? I don’t have a great way to test multichannel properly, so I’d appreciate your input here.

Copy link
Member

Choose a reason for hiding this comment

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

Without looking at the code super closely, I believe it should just use the Tap API if audio/virtual sink are unset. If those are set then use whatever they are set to. With this approach their setup will not change as they had to manually set blackhole or whatever.

* @param inClientData Client data containing AVAudioIOProcData structure
* @return OSStatus indicating success (noErr) or error code
*/
static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Do you think this ObjC call will have any performance overhead? One of the main things the CoreAudio docs drill into you is that these callbacks that run at the highest priority on the audio thread should stay in pure C++ and avoid a lot of things that you wouldn't normally worry about: No malloc/free, no locks, no system calls, and I think no Objective-C method calls, because the ObjC runtime isn't realtime safe.

That said, maybe it's not something we need to worry about.

Copy link
Author

Choose a reason for hiding this comment

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

Good point... My bad! Malloc definitely shouldn’t be in this callback. I went with the ObjC approach initially, but I agree that staying pure C/C++ here would be safer. I’ll adjust that.

// produces linker errors for __isPlatformVersionAtLeast, so we have to use
// a different method.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunguarded-availability-new"
Copy link
Contributor

Choose a reason for hiding this comment

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

I had to patch this section to get it to compile, due to AVCaptureDeviceTypeBuiltInMicrophone and AVCaptureDeviceTypeExternalUnknown being deprecated. It probably wouldn't be the end of the world if we simply required at least macOS 15 and dropped any of this legacy stuff completely.

-#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

Copy link
Author

Choose a reason for hiding this comment

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

I’m fine with either direction, whether we keep backward compatibility or just require macOS 15+. I’ll follow whatever you and the team prefer.

Copy link
Member

Choose a reason for hiding this comment

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

I don't have a strong preference about supporting 14. We just recently dropped support for 13 a little bit earlier than Apple because of missing c++23 features.

If we decide to drop support for 14, we can set that in the homebrew formula here:

on_macos do
depends_on xcode: ["15.3", :build]
end
by adding depends_on macos: :sequoia

Also, we'll need to update the minimum version in the readme.

}];
BOOST_LOG(debug) << "Configured audio output with settings: "sv << sampleRate << "Hz, "sv << (int) channels << " channels, 32-bit float"sv;

dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_CONCURRENT, QOS_CLASS_USER_INITIATED, DISPATCH_QUEUE_PRIORITY_HIGH);
Copy link
Contributor

Choose a reason for hiding this comment

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

It might not ever matter in practice, but the docs for AVCaptureAudioDataOutput say "A serial dispatch queue (DISPATCH_QUEUE_SERIAL) must be used to guarantee that audio samples will be delivered in order.".

// We'll provide a generous buffer and let it tell us what it actually used
UInt32 maxOutputFrames = inputFrames * 4; // Very generous for any upsampling scenario
UInt32 outputBytes = maxOutputFrames * clientChannels * sizeof(float);
float *outputBuffer = (float *) malloc(outputBytes);
Copy link
Contributor

Choose a reason for hiding this comment

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

(Here's a malloc in an IOProc.) But I'm not clear on how AudioConverter works with system taps. Since it's not tied to a device running at a particular sample rate, do you always create the tap at 48k? When do you have to do explicit resampling? It would be great if the OS just handled this automatically.

Copy link
Author

Choose a reason for hiding this comment

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

My bad, that malloc/free definitely shouldn’t be there in a real-time callback. I remember noticing the __attribute__((nonblocking)) in the CoreAudio docs but forgot to account for it here. I’ll fix that.

CATapDescription *tapDescription;
NSArray *excludeProcesses = @[];

if (channels == 1) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't think we need to support mono, since the way initStereo handles mono is to duplicate it to left/right, which is what you want.

Speaking of this, I am assuming that stereo is the only supported configuration here. So we should probably be setting the tapDescription's stereo mixdown setting to true. If you've tested multichannel, how does it work? Here is the main use case I often use:

Moonlight 5.1 -> Sunshine playback of a 5.1 FLAC file, 5.1 mkv movies, and obviously games. What audio device has to be used to support 5.1 playback like this, without a virtual multichannel driver like Blackhole?

Copy link
Author

Choose a reason for hiding this comment

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

I can remove the mono handling as you suggested.

In regards to multichannel, I should clarify this a little. I tested on my Android TV Moonlight client by toggling 5.1/7.1 and did get audio, but I’m starting to think it was probably just L+R playback (hard to tell for sure.

In a previous iteration of the code, enabling 5.1 or 7.1 caused crackling on the client, while stereo worked fine. With the changes in this PR, all settings seem to play without issues. On the host side, I tested by launching a game or playing audio through YouTube, but I’m not sure how to verify that all channels are actually coming through correctly. I imagine properly testing multichannel would require additional hardware on the Android TV client.

Since the tap is set up with initStereoGlobalTapButExcludeProcesses, the host always mixes down to stereo. I’m not sure what would happen if we switched to initExcludingProcesses instead and tried it that way... As you suggested, we’d probably need a virtual multichannel driver to tap into that device’s full stream.


tapDescription.name = uniqueName;
tapDescription.UUID = uniqueUUID;
[tapDescription setPrivate:YES];
Copy link
Contributor

Choose a reason for hiding this comment

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

We probably want to use tapDescription.muteBehavior to support Moonlight's "Mute host" option. If Mute Host is set, use CATapMuted. The WhenTapped option seems dangerous if it would suddenly unmute, so I wouldn't use that.

@constant CATapUnmuted
Audio is captured by the tap and also sent to the audio hardware
@constant CATapMuted
Audio is captured by the tap but no audio is sent from the process to the audio hardware
@constant CATapMutedWhenTapped
Audio is captured by the tap and also sent to the audio hardware until the tap is read by another audio client.
For the duration of the read activity on the tap no audio is sent to the audio hardware.

Copy link
Author

Choose a reason for hiding this comment

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

Sure thing, I can hook up tapDescription.muteBehavior to support the “Mute host” option, as you suggested.

@endcode</td>
</tr>
</table>

Copy link
Contributor

Choose a reason for hiding this comment

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

At the top of this page there is the text "The name of the audio sink used for Audio Loopback. Sunshine can only access microphones on macOS due to system limitations. To stream system audio using Soundflower or BlackHole." We should remove that text.

Copy link
Member

Choose a reason for hiding this comment

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

Also here:

  • Sunshine can only access microphones on macOS due to system limitations.
    To stream system audio use "Soundflower" or "BlackHole".
  • Sunshine can only access microphones on macOS due to system limitations. To stream system audio use
    [Soundflower](https://github.com/mattingalls/Soundflower) or
    [BlackHole](https://github.com/ExistentialAudio/BlackHole).
  • <template #macos>
    <a href="https://github.com/mattingalls/Soundflower" target="_blank">Soundflower</a><br>
    <a href="https://github.com/ExistentialAudio/BlackHole" target="_blank">BlackHole</a>.
    </template>

Maybe we should keep a little blurb somewhere about how to use these two if they don't want to use the Tap API?

Copy link
Author

Choose a reason for hiding this comment

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

I'll remove that.

TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wnonnull"
AVCaptureDevice *device = [AVAudio findMicrophone:nil];
Copy link
Contributor

Choose a reason for hiding this comment

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

Many of these tests that check for nil input are crashing, because the first line in findMicrophone is BOOST_LOG(debug) << "Searching for microphone: "sv << [name UTF8String]; and you can't call UTF8String on nil. So there are probably a lot of additional checks for nil that need to go into those methods, or maybe the nil tests are a bit overkill.

Copy link
Author

Choose a reason for hiding this comment

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

Understood, I can fix that.

@ThomVanL
Copy link
Author

Overall I think this is a well done patch, and is a good example of how to do an AI-assisted PR.

I originally was worried about the frequent use of manual release, e.g.

[audioInput release];
[audioOutput release];

but then I learned that ObjC built by cmake doesn't use the newer ARC memory management model that I was more familiar with from developing in Xcode. ARC dates from 2012 in OSX Lion if you can believe it, but since devs had to opt into using ARC, nobody ever did for this code. So, I guess that's a point for AI here. I would have probably not thought about this and been quite confused at all the memory leaks I was creating. Or more likely it would just result in a lot of compiler errors. (In -fobjc-arc mode, these kinds of release calls are compiler errors.)

Yeah, I wondered about ARC too and did a quick search for -fobjc-arc and @autoreleasepool usage. Everything pointed to ARC not being enabled, so I followed the existing pattern of manual release calls.

Good to hear that lines up with your findings as well.

@sonarqubecloud
Copy link

sonarqubecloud bot commented Sep 3, 2025

@ThomVanL ThomVanL force-pushed the users/thomasvanlaere/feat-macos-ca-taps branch from d75fde0 to db3d2df Compare November 3, 2025 21:00
@sonarqubecloud
Copy link

sonarqubecloud bot commented Nov 3, 2025

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.

Sunshine: Capture audio on macOS using Tap API

3 participants