Skip to content

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

Open
ThomVanL wants to merge 39 commits intoLizardByte:masterfrom
ThomVanL:users/thomasvanlaere/feat-macos-ca-taps
Open

feat(macOS): Capture audio on macOS using Tap API#4209
ThomVanL wants to merge 39 commits intoLizardByte:masterfrom
ThomVanL:users/thomasvanlaere/feat-macos-ca-taps

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

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 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).
⚠️ Report is 8 commits behind head on master.
✅ 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.

@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

@ThomVanL
Copy link
Author

Hi @ReenigneArcher and @andygrundman, happy new year! 🎉

Also, my apologies for the delay in regards to this PR. I've removed some of the unit tests that turned out to be integration tests in disguise (particularly the TCC ones). After poking around for best practices on running integration tests in a CI/CD pipeline with TCC, I mostly found workarounds that felt a little hacky, so I decided to strip those integration tests out and stick with pure unit tests instead. Not sure if that sits right with you both, but feel free to let me know if it needs to be changed.

@andygrundman

This comment was marked as outdated.

@andygrundman
Copy link
Contributor

andygrundman commented Jan 20, 2026

This is looking pretty good, thanks! What I did was create a branch from yours, cherry-pick in some helper things (my build script, input fixes, my previous compile fix (how does this build for you in Tahoe?), and moved a bunch of noisy logging from debug to verbose). With all that out of the way, the main commit in my branch has a bunch of improvements for you to consider. Here's the items in that commit:

https://github.com/andygrundman/Sunshine/commits/andyg.feat-macos-ca-taps-review/

* Refactor microphone.mm sample() to memcpy the buffer directly instead of copying twice by using std::vector, and optimize the case where avail < neededBytes.
* Move initializeAudioBuffer before anything can use the buffer.
* Change size of ring buffer to 30ms (6 * 240 * channels * sizeof(float)).
* Avoid confusion in converter setup between float and int samplerate.
* Add helper for logging CoreAudio error codes in FourCC text mode. Remove some redundant logging, and always log the exact API call that is failing.
* Add optional way to create the aggregate in public mode so it's visible in Audio MIDI Setup/HALLab. Set SUNSHINE_PUBLIC_AUDIO_TAP=1.

I'm not married to these changes, so feel free to counter any of them. I'm not sure what the right buffer size should be, but I felt like 4096 * channels seemed small and just as arbitrary. The public tap thing is neat to look at, especially check out the Apple utility HALLab which exposes all the internals of audio devices. I worry that public taps may not get cleaned up in some situations such as a crash, although maybe private taps get automatically cleaned up. Using an env var for devs is a bit ugly but there's no other way to view the aggregate device.

Re: resampling: do we need to have so much or any resampling code? Doesn't CoreAudio handle a lot of this behind the scenes? I couldn't get the system tap to ever need resampling (I think because it's not using a specific device), and when using BlackHole 16ch sink directly, the resampling code doesn't seem to be called anyway. Even if I run BlackHole at 192k or something, it seems to work fine. I did a trace in Activity Monitor and you can find the bit where the OS is resampling from 192k -> 48k if you search for acv2::Resampler2Wrapper::ProduceOutput. I just need some help on how to test your resampling code, since I couldn't get it to use the code path with procData->audioConverter.

This all works quite well for stereo. For surround, I guess we need to think about it some more. I just stumbled across a project called SoundPusher that creates a virtual 5.1 device and does on-demand AC3 encoding. I want to study it some more. https://codeberg.org/q-p/SoundPusher I found it via https://www.maven.de/2025/04/coreaudio-taps-for-dummies/ by the way.

Edit: I think I just realized the conversion code is mostly there for multichannel right? I honestly have not even tested surround because I think that still requires a virtual audio device like what SoundPusher includes, or BlackHole. I'm not sure it works with Tap. This PR should probably just focus on making stereo taps work in the most user-friendly way possible.

@andygrundman
Copy link
Contributor

Another edit: I had a few moments of panic while testing this, you know the feeling: the audio is skipping and glitching out real bad, and your first thought is there's some bug in the realtime buffer code. Nope, it's all just AWDL. This code basically becomes unusable on wifi devices that don't have N1 chips. The iPad I have been using as a client still had 6ghz 6E mode enabled which is a death sentence for Moonlight. My N1-equipped iPhone Air streams it perfectly. Thanks Apple.

@LizardByte LizardByte deleted a comment from sonarqubecloud bot Feb 3, 2026
@LizardByte LizardByte deleted a comment from sonarqubecloud bot Feb 3, 2026
Comment on lines +21 to +23
if(OPENSSL_FOUND)
include_directories(SYSTEM ${OPENSSL_INCLUDE_DIR})
endif()
Copy link
Member

@ReenigneArcher ReenigneArcher Feb 3, 2026

Choose a reason for hiding this comment

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

This shouldn't be necessary?

${OPENSSL_LIBRARIES}

Copy link
Author

Choose a reason for hiding this comment

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

Done, I've removed this.

Comment on lines +20 to +21
include_directories(/opt/homebrew/opt/opus/include)
link_directories(/opt/homebrew/opt/opus/lib)
Copy link
Member

Choose a reason for hiding this comment

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

These paths depend on the architecture of homebrew, and can't be hardcoded.

Copy link
Author

Choose a reason for hiding this comment

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

I've removed it and managed to sort out my dev environment's quirks. :)

Sunshine supports native system audio capture on macOS 14.0 (Sonoma) and newer via Apple’s Audio Tap API.
To use it, simply leave the **Audio Sink** setting blank.

If you are running macOS 13 (Ventura) or earlier—or if you prefer to manage your own loopback device—you can still use [Soundflower](https://github.com/mattingalls/Soundflower) or [BlackHole](https://github.com/ExistentialAudio/BlackHole) and enter its device name in the **Audio Sink** field.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
If you are running macOS 13 (Ventura) or earlier—or if you prefer to manage your own loopback device—you can still use [Soundflower](https://github.com/mattingalls/Soundflower) or [BlackHole](https://github.com/ExistentialAudio/BlackHole) and enter its device name in the **Audio Sink** field.
If you prefer to manage your own loopback device—you can still use [Soundflower](https://github.com/mattingalls/Soundflower) or [BlackHole](https://github.com/ExistentialAudio/BlackHole) and enter its device name in the **Audio Sink** field.

macos-13 isn't supported anymore

Copy link
Author

Choose a reason for hiding this comment

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

Done, removed it!

Comment on lines 197 to 203
Sunshine now supports system audio capture natively on macOS 14.0 (Sonoma) and later,
using the built-in Core Audio Tap API.

On macOS 13 or earlier, or if you prefer a virtual loopback device,
you can still use "Soundflower" or "BlackHole" for system audio capture.

Gamepads are not currently supported on macOS.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Sunshine now supports system audio capture natively on macOS 14.0 (Sonoma) and later,
using the built-in Core Audio Tap API.
On macOS 13 or earlier, or if you prefer a virtual loopback device,
you can still use "Soundflower" or "BlackHole" for system audio capture.
Gamepads are not currently supported on macOS.
Gamepads are not currently supported on macOS.

Since we don't need to worry about macos-13 now, and audio will work out of the box... we can remove the comment about audio altogether.

Copy link
Author

Choose a reason for hiding this comment

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

Done, I've removed that bit of info.

@@ -8,5 +8,8 @@
<string>Sunshine</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app requires access to your microphone to stream audio.</string>
Copy link
Member

Choose a reason for hiding this comment

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

Maybe this comment should be updated?

Copy link
Author

Choose a reason for hiding this comment

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

I've updated it with something similar to what we use in en.json.

.gitignore Outdated
venv/

# Caches
.cache/
Copy link
Member

Choose a reason for hiding this comment

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

What, specifically, is generating this directory?

Copy link
Author

Choose a reason for hiding this comment

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

This was due to one of the VS Code extensions I have installed, mainly clangd. I removed this entry and now keep it in a global .gitignore file in my home directory!

@@ -0,0 +1,388 @@
/**
* @file tests/unit/platform/test_macos_av_audio.mm
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
* @file tests/unit/platform/test_macos_av_audio.mm
* @file tests/unit/platform/macos/test_av_audio.mm

Can you rename the file to be in a macos specific folder. We also have a folder for windows there now as well.

Copy link
Author

Choose a reason for hiding this comment

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

Done!

TEST_F(AVAudioTest, ObjectLifecycle) {
AVAudio *avAudio = [[AVAudio alloc] init];
EXPECT_NE(avAudio, nil); // Should create successfully
[avAudio release]; // Should not crash
Copy link
Member

Choose a reason for hiding this comment

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

If this can crash, I think it should be wrapped in a try/catch. If we hit the catch, then it should trigger GTEST to fail.

Same for anywhere else it could crash. This is because when a test crashes it crashes the whole test run.

Copy link
Author

Choose a reason for hiding this comment

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

I've added some try/catch blocks and combined it with gtest's FAIL() but I have a feeling we can fine-tune the messages a bit more.

"audio_sink": "Audio Sink",
"audio_sink_desc_linux": "The name of the audio sink used for Audio Loopback. If you do not specify this variable, pulseaudio will select the default monitor device. You can find the name of the audio sink using either command:",
"audio_sink_desc_macos": "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.",
"audio_sink_desc_macos": "The name of the audio sink used for Audio Loopback. Leave this blank to use the built-in system audio capture (requires macOS 14.0 or later, using the Core Audio Tap API). Alternatively, specify a virtual device such as Soundflower or BlackHole if you prefer or are running an older version of macOS.",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"audio_sink_desc_macos": "The name of the audio sink used for Audio Loopback. Leave this blank to use the built-in system audio capture (requires macOS 14.0 or later, using the Core Audio Tap API). Alternatively, specify a virtual device such as Soundflower or BlackHole if you prefer or are running an older version of macOS.",
"audio_sink_desc_macos": "The name of the audio sink used for Audio Loopback. Leave this blank to use the built-in system audio capture. Alternatively, specify a virtual device such as Soundflower or BlackHole if you prefer or are running an older version of macOS.",

Copy link
Author

Choose a reason for hiding this comment

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

Done!

@ReenigneArcher ReenigneArcher added roadmap This PR closes a roadmap entry ai PR has signs of heavy ai usage (either indicated by user or assumed) labels Feb 3, 2026
Suggested improvements to Audio Tap code:
- Refactor microphone.mm sample() to memcpy the buffer directly instead of
  copying twice via std::vector, and optimize partial read case.
- Move initializeAudioBuffer before anything can use the buffer.
- Change size of ring buffer to 30ms (6 * 240 * channels * sizeof(float)).
- Avoid confusion in converter setup between float and int sample rate.
- Add helper for logging CoreAudio error codes in FourCC text mode. Remove
  redundant logging, and always log the exact API call that is failing.
- Add optional way to create the aggregate in public mode so it's visible
  in Audio MIDI Setup/HALLab. Set SUNSHINE_PUBLIC_AUDIO_TAP=1.
- Use non-deprecated AVCaptureDeviceType constants.
- Use serial dispatch queue for audio sampling.
- Update InitializeAudioBuffer test with buffer size verification.
- Retain [aggregateProperties release] after AudioHardwareCreateAggregateDevice
  to avoid a memory leak under MRC (mutableCopy returns a +1 retained object).
…rdByte#4209

- Alphabetize FIND_LIBRARY and SUNSHINE_EXTERNAL_LIBRARIES lists in CMake.
- Remove unnecessary include_directories(SYSTEM ${OPENSSL_INCLUDE_DIR}).
- Remove hardcoded /opt/homebrew/opt/opus/ paths from cmake/dependencies/macos.cmake.
- Remove outdated macOS 13 references from docs and packaging caveats.
- Simplify audio_sink_desc_macos locale string by removing version/API details.
- Update NSMicrophoneUsageDescription to reflect external device usage.
- Remove .cache/ from .gitignore. (originated from the clangd extension in VS Code)
- Move test file to tests/unit/platform/macos/test_av_audio.mm.
- Wrap Objective-C tests in @try/@catch to prevent crashes from aborting he entire test run.
…ere/feat-macos-ca-taps

# Conflicts:
#	src/platform/macos/microphone.mm
Copilot AI review requested due to automatic review settings February 28, 2026 23:19
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Adds native system-wide audio capture on macOS (14.0+) via Apple’s Audio Tap API, integrating it into the existing cross-platform audio capture interface and exposing it through configuration/UI strings and documentation.

Changes:

  • Introduces a new Objective-C++ AVAudio implementation supporting both microphone capture and Core Audio system taps, including format conversion + buffering.
  • Extends the platform audio_control_t::microphone API with a host_audio_enabled flag and wires it through the audio pipeline.
  • Adds macOS-specific unit tests and updates build/config/docs/UI strings to reflect the new capture option.

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/unit/platform/macos/test_av_audio.mm New macOS unit tests for AVAudio (microphone + system tap paths).
tests/CMakeLists.txt Includes .mm test sources when building on macOS.
src_assets/macos/assets/Info.plist Updates macOS permission usage strings for microphone/system audio/screen capture.
src_assets/common/assets/web/public/assets/locale/en.json Updates macOS audio sink description to mention built-in system audio capture.
src/platform/windows/audio.cpp Updates microphone(...) override signature to include host_audio_enabled.
src/platform/macos/microphone.mm Uses system tap when sink is blank; propagates host_audio_enabled.
src/platform/macos/coreaudio_helpers.h Adds helper to stringify Core Audio OSStatus values (FourCC-friendly).
src/platform/macos/av_audio.mm New Objective-C++ implementation of dual-path macOS audio capture + system tap IOProc.
src/platform/macos/av_audio.m Removes old Objective-C implementation (replaced by .mm).
src/platform/macos/av_audio.h Expands API/docs and adds system tap support declarations + shared buffer/semaphore.
src/platform/linux/audio.cpp Updates microphone(...) override signature to include host_audio_enabled.
src/platform/common.h Extends audio_control_t::microphone virtual interface with host_audio_enabled.
src/config.h Adds inline documentation to config::audio_t fields.
src/audio.cpp Passes HOST_AUDIO flag through to control->microphone(...).
packaging/sunshine.rb Removes outdated macOS limitation note about system audio capture.
docs/getting_started.md Updates macOS docs to describe native system audio capture on macOS 14+.
cmake/dependencies/macos.cmake Adds CoreAudio/AudioToolbox/AudioUnit libraries for macOS builds.
cmake/compile_definitions/unix.cmake Adjusts asset-dir prefixing logic for absolute paths/dev builds.
cmake/compile_definitions/macos.cmake Links new macOS audio libs and switches av_audio source to .mm.

Comment on lines +325 to +338
AudioBufferList audioBufferList;
CMBlockBufferRef blockBuffer;

CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer, NULL, &audioBufferList, sizeof(audioBufferList), NULL, NULL, 0, &blockBuffer);

// NSAssert(audioBufferList.mNumberBuffers == 1, @"Expected interleaved PCM format but buffer contained %u streams", audioBufferList.mNumberBuffers);

// this is safe, because an interleaved PCM stream has exactly one buffer,
// and we don't want to do sanity checks in a performance critical exec path
AudioBuffer audioBuffer = audioBufferList.mBuffers[0];

TPCircularBufferProduceBytes(&self->audioSampleBuffer, audioBuffer.mData, audioBuffer.mDataByteSize);
dispatch_semaphore_signal(self->audioSemaphore);
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer returns a retained blockBuffer, but it is never released. This will leak memory in the audio callback path; ensure blockBuffer is released (e.g., CFRelease) after producing bytes into the circular buffer.

Copilot uses AI. Check for mistakes.
Comment on lines +715 to +760
UInt32 aggregateDeviceSampleRate = 48000; // Default fallback
UInt32 aggregateDeviceChannels = 2; // Default fallback

// Get actual sample rate from the aggregate device
// XXX Do we need this, won't it always be 48000? We set it above in createAggregateDeviceWithTapDescription.
UInt32 sampleRateQuerySize = sizeof(Float64);
Float64 var = 0.0;
OSStatus sampleRateStatus = [self getDeviceProperty:self->aggregateDeviceID
selector:kAudioDevicePropertyNominalSampleRate
scope:kAudioObjectPropertyScopeGlobal
element:kAudioObjectPropertyElementMain
size:&sampleRateQuerySize
data:&var];

if (sampleRateStatus != noErr) {
BOOST_LOG(error) << "Failed to get device sample rate, using default 48kHz: " << ca::Status(sampleRateStatus);
}

// Get actual channel count from the device's input stream configuration
AudioObjectPropertyAddress streamConfigAddr = {
.mSelector = kAudioDevicePropertyStreamConfiguration,
.mScope = kAudioDevicePropertyScopeInput,
.mElement = kAudioObjectPropertyElementMain
};

UInt32 streamConfigSize = 0;
OSStatus streamConfigSizeStatus = AudioObjectGetPropertyDataSize(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize);

if (streamConfigSizeStatus == noErr && streamConfigSize > 0) {
AudioBufferList *streamConfig = (AudioBufferList *) malloc(streamConfigSize);
if (streamConfig) {
OSStatus streamConfigStatus = AudioObjectGetPropertyData(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize, streamConfig);
if (streamConfigStatus == noErr && streamConfig->mNumberBuffers > 0) {
aggregateDeviceChannels = streamConfig->mBuffers[0].mNumberChannels;
BOOST_LOG(debug) << "Device reports "sv << aggregateDeviceChannels << " input channels"sv;
} else {
BOOST_LOG(warning) << "Failed to get stream configuration, using default 2 channels: "sv << streamConfigStatus;
}
free(streamConfig);
}
} else {
BOOST_LOG(warning) << "Failed to get stream configuration size, using default 2 channels: "sv << streamConfigSizeStatus;
}

BOOST_LOG(debug) << "Device properties - Sample Rate: "sv << aggregateDeviceSampleRate << "Hz, Channels: "sv << aggregateDeviceChannels;

Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

The queried nominal sample rate is stored in var, but aggregateDeviceSampleRate is never updated from it, so the device sample rate is effectively always treated as 48kHz. Update aggregateDeviceSampleRate from the query result when it succeeds to avoid incorrect needsConversion decisions and misleading logs.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Made some changes to this code as part of commit 3831795.

Comment on lines +212 to +220
NSMutableArray *result = [[NSMutableArray alloc] init];

for (AVCaptureDevice *device in [AVAudio microphones]) {
[result addObject:[device localizedName]];
}

BOOST_LOG(info) << "Found "sv << [result count] << " microphones"sv;
return result;
}
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

microphoneNames allocates result with init and returns it without autoreleasing/releasing, which leaks under manual retain/release. Return an autoreleased object (e.g., use a convenience constructor) or autorelease result before returning.

Copilot uses AI. Check for mistakes.
Copy link
Author

Choose a reason for hiding this comment

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

Swapped the result to NSMutableArray *result = [NSMutableArray array].

BOOST_LOG(debug) << "Successfully added audio input to capture session"sv;
} else {
BOOST_LOG(error) << "Cannot add audio input to capture session"sv;
[audioInput dealloc];
Copy link

Copilot AI Feb 28, 2026

Choose a reason for hiding this comment

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

Calling [audioInput dealloc] directly is unsafe/undefined (objects should not receive dealloc outside their own deallocation path). Replace this with correct ownership handling (typically release if you own it, or simply return after letting the autorelease pool handle it).

Suggested change
[audioInput dealloc];
[audioInput release];

Copilot uses AI. Check for mistakes.
ThomVanL and others added 4 commits March 1, 2026 01:00
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
…reads fail.

Handle a Core Audio edge case where getDeviceProperty can leave aggregateDeviceSampleRate invalid (observed as 0) and avoid using hardcoded fallback formats.
Default aggregate sample rate/channels to clientSampleRate/clientChannels, validate non-positive sample-rate reads, and log explicit fallback behavior when property queries fail.
@sonarqubecloud
Copy link

sonarqubecloud bot commented Mar 1, 2026

Quality Gate Failed Quality Gate failed

Failed conditions
11 New issues
11 New Code Smells (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

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