diff --git a/src/AudioCaptureImpl_CoreAudioTap.h b/src/AudioCaptureImpl_CoreAudioTap.h new file mode 100644 index 00000000..7e80097f --- /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 CoreAudioTapCapture +{ +public: + CoreAudioTapCapture(); + + ~CoreAudioTapCapture(); + + /** + * @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..8cdc7ecb --- /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 + +CoreAudioTapCapture::CoreAudioTapCapture() = default; + +CoreAudioTapCapture::~CoreAudioTapCapture() +{ + StopTap(); +} + +std::map CoreAudioTapCapture::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 CoreAudioTapCapture::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 CoreAudioTapCapture::StopRecording() +{ + StopTap(); +} + +void CoreAudioTapCapture::NextAudioDevice() +{ + // v1 exposes a single system-audio source, so there is nothing to switch to. +} + +void CoreAudioTapCapture::AudioDeviceIndex(int index) +{ + // v1 exposes a single system-audio source; ignore any other index. + _currentAudioDeviceIndex = -1; +} + +int CoreAudioTapCapture::AudioDeviceIndex() const +{ + return _currentAudioDeviceIndex; +} + +std::string CoreAudioTapCapture::AudioDeviceName() const +{ + return _systemAudioDeviceName; +} + +std::string CoreAudioTapCapture::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 CoreAudioTapCapture::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 CoreAudioTapCapture::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/AudioCaptureImpl_macOS.h b/src/AudioCaptureImpl_macOS.h new file mode 100644 index 00000000..57300b24 --- /dev/null +++ b/src/AudioCaptureImpl_macOS.h @@ -0,0 +1,52 @@ +#pragma once + +#include + +#include +#include + +class projectm; +class CoreAudioTapCapture; +class SdlAudioCapture; + +/** + * @brief macOS audio-capture backend selector. + * + * macOS can capture system output directly with the Core Audio process-tap API, + * but that API only exists on macOS 14.4 and newer. To keep working on older + * systems, this class picks a backend at runtime: the Core Audio tap on 14.4+, + * and the shared SDL microphone capture (the same backend used on Linux) below + * that. Exactly one backend is instantiated for the lifetime of the object. + * + * It exposes the interface AudioCapture expects and forwards every call to the + * selected backend, so AudioCapture itself stays platform-agnostic. + */ +class AudioCaptureImpl +{ +public: + AudioCaptureImpl(); + + ~AudioCaptureImpl(); + + std::map AudioDeviceList(); + + void StartRecording(projectm* projectMHandle, int audioDeviceIndex); + + void StopRecording(); + + void NextAudioDevice(); + + void AudioDeviceIndex(int index); + + int AudioDeviceIndex() const; + + std::string AudioDeviceName() const; + + void FillBuffer(); + +protected: + CoreAudioTapCapture* _tap{nullptr}; //!< Core Audio process-tap backend (macOS 14.4+), or null. + SdlAudioCapture* _sdl{nullptr}; //!< SDL microphone backend (pre-14.4 fallback), or null. + + Poco::Logger& _logger{Poco::Logger::get("AudioCapture.macOS")}; //!< The class logger. +}; diff --git a/src/AudioCaptureImpl_macOS.mm b/src/AudioCaptureImpl_macOS.mm new file mode 100644 index 00000000..b4f07090 --- /dev/null +++ b/src/AudioCaptureImpl_macOS.mm @@ -0,0 +1,111 @@ +#include "AudioCaptureImpl_macOS.h" + +#include "AudioCaptureImpl_CoreAudioTap.h" + +// The SDL backend is the shared cross-platform microphone capture. On macOS it is +// compiled a second time under a distinct class name (see src/CMakeLists.txt) so it +// can coexist with the Core Audio tap in the same binary. Pull in its declaration +// here under that same name. +#define AudioCaptureImpl SdlAudioCapture +#include "AudioCaptureImpl_SDL.h" +#undef AudioCaptureImpl + +AudioCaptureImpl::AudioCaptureImpl() +{ + // The Core Audio process-tap API landed in macOS 14.4. On 14.4+ we tap system + // output directly; below that we fall back to the SDL microphone backend so the + // visualizer still reacts to a (user-selectable) input device. + if (@available(macOS 14.4, *)) + { + poco_information(_logger, "macOS 14.4 or newer: capturing system audio via Core Audio tap."); + _tap = new CoreAudioTapCapture(); + } + else + { + poco_information(_logger, "macOS older than 14.4: falling back to SDL microphone capture."); + _sdl = new SdlAudioCapture(); + } +} + +AudioCaptureImpl::~AudioCaptureImpl() +{ + delete _tap; + delete _sdl; +} + +std::map AudioCaptureImpl::AudioDeviceList() +{ + if (_tap) + { + return _tap->AudioDeviceList(); + } + return _sdl->AudioDeviceList(); +} + +void AudioCaptureImpl::StartRecording(projectm* projectMHandle, int audioDeviceIndex) +{ + if (_tap) + { + _tap->StartRecording(projectMHandle, audioDeviceIndex); + return; + } + _sdl->StartRecording(projectMHandle, audioDeviceIndex); +} + +void AudioCaptureImpl::StopRecording() +{ + if (_tap) + { + _tap->StopRecording(); + return; + } + _sdl->StopRecording(); +} + +void AudioCaptureImpl::NextAudioDevice() +{ + if (_tap) + { + _tap->NextAudioDevice(); + return; + } + _sdl->NextAudioDevice(); +} + +void AudioCaptureImpl::AudioDeviceIndex(int index) +{ + if (_tap) + { + _tap->AudioDeviceIndex(index); + return; + } + _sdl->AudioDeviceIndex(index); +} + +int AudioCaptureImpl::AudioDeviceIndex() const +{ + if (_tap) + { + return _tap->AudioDeviceIndex(); + } + return _sdl->AudioDeviceIndex(); +} + +std::string AudioCaptureImpl::AudioDeviceName() const +{ + if (_tap) + { + return _tap->AudioDeviceName(); + } + return _sdl->AudioDeviceName(); +} + +void AudioCaptureImpl::FillBuffer() +{ + if (_tap) + { + _tap->FillBuffer(); + return; + } + _sdl->FillBuffer(); +} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2e814e75..d4e5accf 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -51,6 +51,36 @@ if (CMAKE_SYSTEM_NAME STREQUAL "Windows") PRIVATE AUDIO_IMPL_HEADER="AudioCaptureImpl_WASAPI.h" ) +elseif (CMAKE_SYSTEM_NAME STREQUAL "Darwin") + # macOS selects its audio backend at runtime (see AudioCaptureImpl_macOS): the + # native Core Audio process tap on macOS 14.4+ (captures system output directly, + # no virtual loopback driver needed), falling back to the shared SDL microphone + # capture on older systems. + target_sources(projectMSDL + PRIVATE + AudioCaptureImpl_macOS.h + AudioCaptureImpl_macOS.mm + AudioCaptureImpl_CoreAudioTap.h + AudioCaptureImpl_CoreAudioTap.mm + AudioCaptureImpl_SDL.h + AudioCaptureImpl_SDL.cpp + ) + # Compile the shared SDL backend a second time under a distinct class name so it + # can coexist with the Core Audio tap in the same binary. Leaves the SDL sources + # themselves untouched for the Linux build. + set_source_files_properties(AudioCaptureImpl_SDL.cpp PROPERTIES + COMPILE_DEFINITIONS "AudioCaptureImpl=SdlAudioCapture" + ) + target_compile_definitions(projectMSDL + PRIVATE + AUDIO_IMPL_HEADER="AudioCaptureImpl_macOS.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.