From 964a03bf7b81f087bd41b1aae008cdd426850e9a Mon Sep 17 00:00:00 2001 From: Logan Prosser Date: Tue, 21 Jul 2026 10:15:43 -0400 Subject: [PATCH] Add native macOS system audio capture using a Core Audio tap On macOS the SDL backend can only grab a microphone, so to visualize whatever's actually playing you currently have to install BlackHole (or similar) and reroute your output through it. This adds a Core Audio process-tap backend that captures system output directly, the same idea as the existing WASAPI loopback path on Windows. It hangs a global CATapDescription off a private aggregate device and pushes the PCM straight into projectM from the IO proc. The tap setup is wrapped in @available(macOS 14.4, *) so it weak-links and just logs instead of crashing on older systems. Also links CoreAudio/AudioToolbox/Foundation on Darwin and adds the NSAudioCaptureUsageDescription key so the permission prompt has a message. Tested on macOS 26.5: tap comes up at 48kHz stereo and only produces audio while something is actually playing. Capture needs the bundle to be code-signed since the permission is tied to the signing identity. --- src/AudioCaptureImpl_CoreAudioTap.h | 115 +++++++++++ src/AudioCaptureImpl_CoreAudioTap.mm | 276 +++++++++++++++++++++++++++ src/CMakeLists.txt | 18 ++ src/resources/Info.plist.in | 2 + 4 files changed, 411 insertions(+) create mode 100644 src/AudioCaptureImpl_CoreAudioTap.h create mode 100644 src/AudioCaptureImpl_CoreAudioTap.mm diff --git a/src/AudioCaptureImpl_CoreAudioTap.h b/src/AudioCaptureImpl_CoreAudioTap.h new file mode 100644 index 00000000..dca4ef51 --- /dev/null +++ b/src/AudioCaptureImpl_CoreAudioTap.h @@ -0,0 +1,115 @@ +#pragma once + +#include + +#include + +#include +#include + +class projectm; + +/** + * @brief macOS Core Audio process-tap capturing implementation. + * + * Captures system-wide audio output using the Core Audio process-tap API + * (available since macOS 14.4). A global tap is attached to a private aggregate + * device; an IO proc on that aggregate device forwards the captured PCM data + * directly into projectM. This is the macOS equivalent of the WASAPI loopback + * backend used on Windows and requires no third-party virtual audio driver. + * + * v1 exposes a single synthetic "System Audio" device. The device-list structure + * is preserved so per-process capture can be added later without changing the + * AudioCapture proxy. + */ +class AudioCaptureImpl +{ +public: + AudioCaptureImpl(); + + ~AudioCaptureImpl(); + + /** + * @brief Returns a map of available capture sources. + * + * v1 returns a single synthetic entry representing whole-system audio output. + * + * @return A map with device index/name pairs. + */ + std::map AudioDeviceList(); + + /** + * @brief Starts capturing system audio and forwarding it to projectM. + * @param projectMHandle projectM instance handle that will receive the captured data. + * @param audioDeviceIndex Ignored in v1 (single system-audio source). + */ + void StartRecording(projectm* projectMHandle, int audioDeviceIndex); + + /** + * @brief Stops capturing and tears down the tap and aggregate device. + */ + void StopRecording(); + + /** + * @brief Switches to the next available capture source. No-op in v1. + */ + void NextAudioDevice(); + + /** + * @brief Activates the source with the given index. Clamped/no-op in v1. + * @param index The index, as listed by @a AudioDeviceList(). + */ + void AudioDeviceIndex(int index); + + /** + * @brief Returns the currently used source index. + * @return The index of the current source. + */ + int AudioDeviceIndex() const; + + /** + * @brief Retrieves the current source name. + * @return The human-readable name of the current capture source. + */ + std::string AudioDeviceName() const; + + /** + * @brief Asks the capture client to fill projectM's audio buffer for the next frame. + * + * No-op: the Core Audio IO proc pushes PCM directly to projectM as it arrives, + * matching the SDL backend's callback-driven model. + */ + void FillBuffer(){}; + +protected: + /** + * @brief Creates the process tap and private aggregate device, then starts the IO proc. + * @return True on success, false if any Core Audio call failed (state is torn down on failure). + */ + bool StartTap(); + + /** + * @brief Stops the IO proc and destroys the aggregate device and tap in the correct order. + */ + void StopTap(); + + /** + * @brief Returns the UID of the current default output device, or empty on failure. + * + * The aggregate device requires a real output device as its main sub-device. + */ + std::string DefaultOutputDeviceUID() const; + + projectm* _projectMHandle{nullptr}; //!< projectM instance that receives the audio data. + int _currentAudioDeviceIndex{-1}; //!< Currently selected source index (always -1 in v1). + + AudioObjectID _tapObjectID{kAudioObjectUnknown}; //!< The process tap object. + AudioObjectID _aggregateDeviceID{kAudioObjectUnknown}; //!< The private aggregate device wrapping the tap. + AudioDeviceIOProcID _ioProcID{nullptr}; //!< The installed IO proc handle. + + uint32_t _channels{2}; //!< Channel count of the captured stream, from the aggregate device format. + + static constexpr char _systemAudioDeviceName[] = "System Audio (Core Audio Tap)"; + + Poco::Logger& _logger{Poco::Logger::get("AudioCapture.CoreAudioTap")}; //!< The class logger. +}; diff --git a/src/AudioCaptureImpl_CoreAudioTap.mm b/src/AudioCaptureImpl_CoreAudioTap.mm new file mode 100644 index 00000000..a8d66a64 --- /dev/null +++ b/src/AudioCaptureImpl_CoreAudioTap.mm @@ -0,0 +1,276 @@ +#include "AudioCaptureImpl_CoreAudioTap.h" + +#include + +#import +#import + +// CATapDescription and the process-tap C API live in the CoreAudio umbrella framework +// on macOS 14.4+. The Objective-C class is declared in ; +// the AudioHardwareCreate/DestroyProcessTap C functions live in AudioHardwareTapping.h, +// which the CoreAudio umbrella header does not pull in automatically. +#import +#import + +AudioCaptureImpl::AudioCaptureImpl() = default; + +AudioCaptureImpl::~AudioCaptureImpl() +{ + StopTap(); +} + +std::map AudioCaptureImpl::AudioDeviceList() +{ + // v1: a single synthetic entry for whole-system audio. Kept as a map so per-process + // entries can be added later without changing the AudioCapture proxy. + return {{-1, _systemAudioDeviceName}}; +} + +void AudioCaptureImpl::StartRecording(projectm* projectMHandle, int audioDeviceIndex) +{ + _projectMHandle = projectMHandle; + _currentAudioDeviceIndex = -1; // Only one source in v1. + + if (StartTap()) + { + poco_information(_logger, "Started system audio capture via Core Audio tap."); + } + else + { + poco_error(_logger, "Failed to start system audio capture. Visualizer will idle until audio is available."); + } +} + +void AudioCaptureImpl::StopRecording() +{ + StopTap(); +} + +void AudioCaptureImpl::NextAudioDevice() +{ + // v1 exposes a single system-audio source, so there is nothing to switch to. +} + +void AudioCaptureImpl::AudioDeviceIndex(int index) +{ + // v1 exposes a single system-audio source; ignore any other index. + _currentAudioDeviceIndex = -1; +} + +int AudioCaptureImpl::AudioDeviceIndex() const +{ + return _currentAudioDeviceIndex; +} + +std::string AudioCaptureImpl::AudioDeviceName() const +{ + return _systemAudioDeviceName; +} + +std::string AudioCaptureImpl::DefaultOutputDeviceUID() const +{ + AudioObjectPropertyAddress defaultDeviceAddress{ + kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain}; + + AudioObjectID defaultDeviceID{kAudioObjectUnknown}; + UInt32 dataSize = sizeof(defaultDeviceID); + OSStatus status = AudioObjectGetPropertyData(kAudioObjectSystemObject, &defaultDeviceAddress, + 0, nullptr, &dataSize, &defaultDeviceID); + if (status != noErr || defaultDeviceID == kAudioObjectUnknown) + { + poco_error_f1(_logger, "Could not resolve default output device: OSStatus %?d", static_cast(status)); + return {}; + } + + AudioObjectPropertyAddress uidAddress{ + kAudioDevicePropertyDeviceUID, + kAudioObjectPropertyScopeGlobal, + kAudioObjectPropertyElementMain}; + + CFStringRef deviceUID = nullptr; + dataSize = sizeof(deviceUID); + status = AudioObjectGetPropertyData(defaultDeviceID, &uidAddress, 0, nullptr, &dataSize, &deviceUID); + if (status != noErr || deviceUID == nullptr) + { + poco_error_f1(_logger, "Could not read default output device UID: OSStatus %?d", static_cast(status)); + return {}; + } + + std::string uid([(__bridge NSString*) deviceUID UTF8String]); + CFRelease(deviceUID); + return uid; +} + +bool AudioCaptureImpl::StartTap() +{ + // The process-tap API is only available at runtime on macOS 14.4+. Guarding the + // call sites lets the symbols weak-link and avoids a hard crash on older systems; + // there we simply report and capture nothing rather than failing the whole app. + if (@available(macOS 14.4, *)) + { + @autoreleasepool + { + // 1. Create a global tap that captures output from all processes. + CATapDescription* tapDescription = + [[CATapDescription alloc] initStereoGlobalTapButExcludeProcesses:@[]]; + tapDescription.name = @"projectMSDL System Audio Tap"; + tapDescription.privateTap = YES; + tapDescription.muteBehavior = CATapUnmuted; + + OSStatus status = AudioHardwareCreateProcessTap(tapDescription, &_tapObjectID); + if (status != noErr || _tapObjectID == kAudioObjectUnknown) + { + poco_error_f1(_logger, "AudioHardwareCreateProcessTap failed: OSStatus %?d", static_cast(status)); + _tapObjectID = kAudioObjectUnknown; + return false; + } + + NSString* tapUUID = tapDescription.UUID.UUIDString; + + // 2. Create a private aggregate device wrapping the tap. It needs a real output + // device as its main sub-device; the tap is attached via the tap list. + std::string mainDeviceUID = DefaultOutputDeviceUID(); + if (mainDeviceUID.empty()) + { + StopTap(); + return false; + } + + NSString* mainUID = [NSString stringWithUTF8String:mainDeviceUID.c_str()]; + + NSDictionary* aggregateDescription = @{ + @(kAudioAggregateDeviceNameKey): @"projectMSDL System Audio", + @(kAudioAggregateDeviceUIDKey): @"org.projectm-visualizer.projectmsdl.systemaudio", + @(kAudioAggregateDeviceMainSubDeviceKey): mainUID, + @(kAudioAggregateDeviceIsPrivateKey): @YES, + @(kAudioAggregateDeviceIsStackedKey): @NO, + @(kAudioAggregateDeviceTapAutoStartKey): @YES, + @(kAudioAggregateDeviceTapListKey): @[ + @{ + @(kAudioSubTapUIDKey): tapUUID, + @(kAudioSubTapDriftCompensationKey): @YES, + }, + ], + }; + + status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateDescription, + &_aggregateDeviceID); + if (status != noErr || _aggregateDeviceID == kAudioObjectUnknown) + { + poco_error_f1(_logger, "AudioHardwareCreateAggregateDevice failed: OSStatus %?d", static_cast(status)); + _aggregateDeviceID = kAudioObjectUnknown; + StopTap(); + return false; + } + + // 3. Determine the captured stream format (sample rate + channels). + AudioObjectPropertyAddress formatAddress{ + kAudioDevicePropertyStreamFormat, + kAudioObjectPropertyScopeInput, + kAudioObjectPropertyElementMain}; + + AudioStreamBasicDescription streamFormat{}; + UInt32 dataSize = sizeof(streamFormat); + status = AudioObjectGetPropertyData(_aggregateDeviceID, &formatAddress, 0, nullptr, + &dataSize, &streamFormat); + if (status != noErr || streamFormat.mChannelsPerFrame == 0) + { + poco_error_f1(_logger, "Could not read aggregate device stream format: OSStatus %?d", + static_cast(status)); + StopTap(); + return false; + } + + _channels = streamFormat.mChannelsPerFrame; + + // 4. Install the IO proc. Runs on Core Audio's realtime thread; keep it allocation- + // and lock-free. Core Audio delivers deinterleaved float buffers (one buffer per + // channel) for tap aggregates, so forward the first buffer's frames to projectM. + projectm* handle = _projectMHandle; + uint32_t channels = _channels; + + status = AudioDeviceCreateIOProcIDWithBlock( + &_ioProcID, _aggregateDeviceID, nullptr, + ^(const AudioTimeStamp* /*inNow*/, const AudioBufferList* inInputData, + const AudioTimeStamp* /*inInputTime*/, AudioBufferList* /*outOutputData*/, + const AudioTimeStamp* /*inOutputTime*/) { + if (handle == nullptr || inInputData == nullptr || inInputData->mNumberBuffers == 0) + { + return; + } + + const AudioBuffer& buffer = inInputData->mBuffers[0]; + if (buffer.mData == nullptr || buffer.mDataByteSize == 0) + { + return; + } + + auto* samples = static_cast(buffer.mData); + // mNumberChannels reflects the interleaving of this buffer. + uint32_t bufferChannels = buffer.mNumberChannels > 0 ? buffer.mNumberChannels : channels; + unsigned int frameCount = buffer.mDataByteSize / sizeof(float) / bufferChannels; + + projectm_pcm_add_float(handle, samples, frameCount, + static_cast(bufferChannels)); + }); + + if (status != noErr || _ioProcID == nullptr) + { + poco_error_f1(_logger, "AudioDeviceCreateIOProcIDWithBlock failed: OSStatus %?d", + static_cast(status)); + _ioProcID = nullptr; + StopTap(); + return false; + } + + // 5. Start the aggregate device. + status = AudioDeviceStart(_aggregateDeviceID, _ioProcID); + if (status != noErr) + { + poco_error_f1(_logger, "AudioDeviceStart failed: OSStatus %?d", static_cast(status)); + StopTap(); + return false; + } + + poco_information_f2(_logger, "System audio tap running (%hu channels, %.0f Hz).", + static_cast(_channels), streamFormat.mSampleRate); + return true; + } + } + else + { + poco_error(_logger, "System audio capture requires macOS 14.4 or newer; no audio will be captured."); + return false; + } +} + +void AudioCaptureImpl::StopTap() +{ + // Tear down in reverse order of creation. Guard each handle so a partially-created + // setup cleans up correctly. Both the aggregate device and the tap must be destroyed + // together to avoid the documented "all-zero samples" state on restart. + if (_aggregateDeviceID != kAudioObjectUnknown && _ioProcID != nullptr) + { + AudioDeviceStop(_aggregateDeviceID, _ioProcID); + } + + if (_aggregateDeviceID != kAudioObjectUnknown && _ioProcID != nullptr) + { + AudioDeviceDestroyIOProcID(_aggregateDeviceID, _ioProcID); + _ioProcID = nullptr; + } + + if (_aggregateDeviceID != kAudioObjectUnknown) + { + AudioHardwareDestroyAggregateDevice(_aggregateDeviceID); + _aggregateDeviceID = kAudioObjectUnknown; + } + + if (_tapObjectID != kAudioObjectUnknown) + { + AudioHardwareDestroyProcessTap(_tapObjectID); + _tapObjectID = kAudioObjectUnknown; + } +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2e814e75..834e0fa8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -51,6 +51,24 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Windows") PRIVATE AUDIO_IMPL_HEADER="AudioCaptureImpl_WASAPI.h" ) +elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + # Native macOS system-audio capture via the Core Audio process-tap API + # (macOS 14.4+). Taps system output directly, no virtual loopback driver needed. + target_sources(projectMSDL + PRIVATE + AudioCaptureImpl_CoreAudioTap.h + AudioCaptureImpl_CoreAudioTap.mm + ) + target_compile_definitions(projectMSDL + PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_CoreAudioTap.h" + ) + target_link_libraries(projectMSDL + PRIVATE + "-framework CoreAudio" + "-framework AudioToolbox" + "-framework Foundation" + ) else () target_sources(projectMSDL PRIVATE diff --git a/src/resources/Info.plist.in b/src/resources/Info.plist.in index 84bb6ceb..53d8eb8f 100644 --- a/src/resources/Info.plist.in +++ b/src/resources/Info.plist.in @@ -26,5 +26,7 @@ NSMicrophoneUsageDescription projectM requires microphone access to visualize audio input. + NSAudioCaptureUsageDescription + projectM captures system audio output so it can visualize whatever is playing on your Mac.