diff --git a/.gitignore b/.gitignore index 30818d52ae3..bab45cd782a 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ package-lock.json # Python *.pyc venv/ + +# Caches +.cache/ diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index 448ad65ee0a..14da32ee8d9 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -24,13 +24,16 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CORE_MEDIA_LIBRARY} ${CORE_VIDEO_LIBRARY} ${FOUNDATION_LIBRARY} + ${AUDIO_TOOLBOX_LIBRARY} + ${AUDIO_UNIT_LIBRARY} + ${CORE_AUDIO_LIBRARY} ${VIDEO_TOOLBOX_LIBRARY}) set(APPLE_PLIST_FILE "${SUNSHINE_SOURCE_ASSETS_DIR}/macos/assets/Info.plist") set(PLATFORM_TARGET_FILES "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.h" - "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.m" + "${CMAKE_SOURCE_DIR}/src/platform/macos/av_audio.mm" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_img_t.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.h" "${CMAKE_SOURCE_DIR}/src/platform/macos/av_video.m" diff --git a/cmake/compile_definitions/unix.cmake b/cmake/compile_definitions/unix.cmake index 68660cd1b94..dc72c8ba655 100644 --- a/cmake/compile_definitions/unix.cmake +++ b/cmake/compile_definitions/unix.cmake @@ -5,6 +5,7 @@ list(APPEND SUNSHINE_EXTERNAL_LIBRARIES ${CURL_LIBRARIES}) # add install prefix to assets path if not already there -if(NOT SUNSHINE_ASSETS_DIR MATCHES "^${CMAKE_INSTALL_PREFIX}") +# 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() diff --git a/cmake/dependencies/common.cmake b/cmake/dependencies/common.cmake index 24a80883762..84d540e65d4 100644 --- a/cmake/dependencies/common.cmake +++ b/cmake/dependencies/common.cmake @@ -18,6 +18,9 @@ add_subdirectory("${CMAKE_SOURCE_DIR}/third-party/libdisplaydevice") # common dependencies include("${CMAKE_MODULE_PATH}/dependencies/nlohmann_json.cmake") find_package(OpenSSL REQUIRED) +if(OPENSSL_FOUND) + include_directories(SYSTEM ${OPENSSL_INCLUDE_DIR}) +endif() find_package(PkgConfig REQUIRED) find_package(Threads REQUIRED) pkg_check_modules(CURL REQUIRED libcurl) diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index 61efc6a902b..5a2556c9f1c 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -11,3 +11,11 @@ FIND_LIBRARY(VIDEO_TOOLBOX_LIBRARY VideoToolbox) if(SUNSHINE_ENABLE_TRAY) FIND_LIBRARY(COCOA Cocoa REQUIRED) endif() + +# Audio frameworks required for audio capture/processing +FIND_LIBRARY(AUDIO_TOOLBOX_LIBRARY AudioToolbox) +FIND_LIBRARY(AUDIO_UNIT_LIBRARY AudioUnit) +FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio) + +include_directories(/opt/homebrew/opt/opus/include) +link_directories(/opt/homebrew/opt/opus/lib) diff --git a/docs/getting_started.md b/docs/getting_started.md index c0d40752c65..e49cd68a0fe 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -455,9 +455,10 @@ systemctl --user enable sunshine ### macOS The first time you start Sunshine, you will be asked to grant access to screen recording and your microphone. -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). +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. > [!NOTE] > Command Keys are not forwarded by Moonlight. Right Option-Key is mapped to CMD-Key. diff --git a/packaging/sunshine.rb b/packaging/sunshine.rb index 33a45561105..032a3694a92 100644 --- a/packaging/sunshine.rb +++ b/packaging/sunshine.rb @@ -194,8 +194,11 @@ def post_install if OS.mac? opoo <<~EOS - Sunshine can only access microphones on macOS due to system limitations. - To stream system audio use "Soundflower" or "BlackHole". + 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. EOS diff --git a/src/audio.cpp b/src/audio.cpp index 4d306b48ddd..a2b23a20706 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -193,8 +193,9 @@ namespace audio { } auto frame_size = config.packetDuration * stream.sampleRate / 1000; + bool host_audio = config.flags[config_t::HOST_AUDIO]; bool continuous_audio = config.flags[config_t::CONTINUOUS_AUDIO]; - auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio); + auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio, host_audio); if (!mic) { return; } @@ -231,7 +232,7 @@ namespace audio { BOOST_LOG(info) << "Reinitializing audio capture"sv; mic.reset(); do { - mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio); + mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio, host_audio); if (!mic) { BOOST_LOG(warning) << "Couldn't re-initialize audio input"sv; } diff --git a/src/config.h b/src/config.h index e8d1594fba2..42aa3b57192 100644 --- a/src/config.h +++ b/src/config.h @@ -145,10 +145,10 @@ namespace config { }; struct audio_t { - std::string sink; - std::string virtual_sink; - bool stream; - bool install_steam_drivers; + std::string sink; ///< Audio output device/sink to use for audio capture + std::string virtual_sink; ///< Virtual audio sink for audio routing + bool stream; ///< Enable audio streaming to clients + bool install_steam_drivers; ///< Install Steam audio drivers for enhanced compatibility }; constexpr int ENCRYPTION_MODE_NEVER = 0; // Never use video encryption, even if the client supports it diff --git a/src/platform/common.h b/src/platform/common.h index d7d3edc4c6c..7c5cfc2919b 100644 --- a/src/platform/common.h +++ b/src/platform/common.h @@ -554,7 +554,7 @@ namespace platf { public: virtual int set_sink(const std::string &sink) = 0; - virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous) = 0; + virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous, [[maybe_unused]] bool host_audio_enabled) = 0; /** * @brief Check if the audio sink is available in the system. diff --git a/src/platform/linux/audio.cpp b/src/platform/linux/audio.cpp index a2df7bb05be..f6ce71c7b6c 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -440,7 +440,7 @@ namespace platf { return monitor_name; } - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, [[maybe_unused]] bool host_audio_enabled) override { // Sink choice priority: // 1. Config sink // 2. Last sink swapped to (Usually virtual in this case) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 9ef1cca2918..55efadca014 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -1,29 +1,171 @@ /** * @file src/platform/macos/av_audio.h - * @brief Declarations for audio capture on macOS. + * @brief Declarations for macOS audio capture with dual input paths. + * + * This header defines the AVAudio class which provides distinct audio capture methods: + * 1. **Microphone capture** - Uses AVFoundation framework to capture from specific microphone devices + * 2. **System-wide audio tap** - Uses Core Audio taps to capture all system audio output (macOS 14.0+) + * + * The system-wide audio tap allows capturing audio from all applications and system sounds, + * while microphone capture focuses on input from physical or virtual microphone devices. */ #pragma once // platform includes +#import #import +#import +#import // lib includes #include "third-party/TPCircularBuffer/TPCircularBuffer.h" +// Buffer length for audio processing #define kBufferLength 4096 +NS_ASSUME_NONNULL_BEGIN + +// Forward declarations +@class AVAudio; +@class CATapDescription; + +namespace platf { + OSStatus audioConverterComplexInputProc(AudioConverterRef _Nullable inAudioConverter, UInt32 *_Nonnull ioNumberDataPackets, AudioBufferList *_Nonnull ioData, AudioStreamPacketDescription *_Nullable *_Nullable outDataPacketDescription, void *_Nonnull inUserData); + OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *_Nullable inNow, const AudioBufferList *_Nullable inInputData, const AudioTimeStamp *_Nullable inInputTime, AudioBufferList *_Nullable outOutputData, const AudioTimeStamp *_Nullable inOutputTime, void *_Nullable inClientData); +} // namespace platf + +/** + * @brief Data structure for AudioConverter input callback. + * Contains audio data and metadata needed for format conversion during audio processing. + */ +struct AudioConverterInputData { + float *inputData; ///< Pointer to input audio data + UInt32 inputFrames; ///< Total number of input frames available + UInt32 framesProvided; ///< Number of frames already provided to converter + UInt32 deviceChannels; ///< Number of channels in the device audio + AVAudio *avAudio; ///< Reference to the AVAudio instance +}; + +/** + * @brief IOProc client data structure for Core Audio system taps. + * Contains configuration and conversion data for real-time audio processing. + */ +typedef struct { + AVAudio *avAudio; ///< Reference to AVAudio instance + UInt32 clientRequestedChannels; ///< Number of channels requested by client + UInt32 clientRequestedSampleRate; ///< Sample rate requested by client + UInt32 clientRequestedFrameSize; ///< Frame size requested by client + UInt32 aggregateDeviceSampleRate; ///< Sample rate of the aggregate device + UInt32 aggregateDeviceChannels; ///< Number of channels in aggregate device + AudioConverterRef _Nullable audioConverter; ///< Audio converter for format conversion + float *_Nullable conversionBuffer; ///< Pre-allocated buffer for audio conversion + UInt32 conversionBufferSize; ///< Size of the conversion buffer in bytes +} AVAudioIOProcData; + +/** + * @brief Core Audio capture class for macOS audio input and system-wide audio tapping. + * Provides functionality for both microphone capture via AVFoundation and system-wide + * audio capture via Core Audio taps (requires macOS 14.0+). + */ @interface AVAudio: NSObject { @public - TPCircularBuffer audioSampleBuffer; + TPCircularBuffer audioSampleBuffer; ///< Shared circular buffer for both audio capture paths + dispatch_semaphore_t audioSemaphore; ///< Real-time safe semaphore for signaling audio sample availability +@private + // System-wide audio tap components (Core Audio) + AudioObjectID tapObjectID; ///< Core Audio tap object identifier for system audio capture + AudioObjectID aggregateDeviceID; ///< Aggregate device ID for system tap audio routing + AudioDeviceIOProcID ioProcID; ///< IOProc identifier for real-time audio processing + AVAudioIOProcData *_Nullable ioProcData; ///< Context data for IOProc callbacks and format conversion } -@property (nonatomic, assign) AVCaptureSession *audioCaptureSession; -@property (nonatomic, assign) AVCaptureConnection *audioConnection; -@property (nonatomic, assign) NSCondition *samplesArrivedSignal; +// AVFoundation microphone capture properties +@property (nonatomic, assign, nullable) AVCaptureSession *audioCaptureSession; ///< AVFoundation capture session for microphone input +@property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; ///< Audio connection within the capture session +@property (nonatomic, assign) BOOL hostAudioEnabled; ///< Whether host audio playback should be enabled (affects tap mute behavior) -+ (NSArray *)microphoneNames; -+ (AVCaptureDevice *)findMicrophone:(NSString *)name; +/** + * @brief Get all available microphone devices on the system. + * @return Array of AVCaptureDevice objects representing available microphones + */ ++ (NSArray *)microphones; -- (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +/** + * @brief Get names of all available microphone devices. + * @return Array of NSString objects with microphone device names + */ ++ (NSArray *)microphoneNames; + +/** + * @brief Find a specific microphone device by name. + * @param name The name of the microphone to find (nullable - will return nil if name is nil) + * @return AVCaptureDevice object if found, nil otherwise + */ ++ (nullable AVCaptureDevice *)findMicrophone:(nullable NSString *)name; + +/** + * @brief Sets up microphone capture using AVFoundation framework. + * @param device The AVCaptureDevice to use for audio input (nullable - will return error if nil) + * @param sampleRate Target sample rate in Hz + * @param frameSize Number of frames per buffer + * @param channels Number of audio channels (1=mono, 2=stereo) + * @return 0 on success, -1 on failure + */ +- (int)setupMicrophone:(nullable AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; + +/** + * @brief Sets up system-wide audio tap for capturing all system audio. + * Requires macOS 14.0+ and appropriate permissions. + * @param sampleRate Target sample rate in Hz + * @param frameSize Number of frames per buffer + * @param channels Number of audio channels + * @return 0 on success, -1 on failure + */ +- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; + +// Buffer management methods for testing and internal use +/** + * @brief Initializes the circular audio buffer for the specified number of channels. + * @param channels Number of audio channels to configure the buffer for + */ +- (void)initializeAudioBuffer:(UInt8)channels; + +/** + * @brief Cleans up and deallocates the audio buffer resources. + */ +- (void)cleanupAudioBuffer; + +/** + * @brief Cleans up system tap resources in a safe, ordered manner. + * @param tapDescription Optional tap description object to release (can be nil) + */ +- (void)cleanupSystemTapContext:(nullable id)tapDescription; + +/** + * @brief Initializes the system tap context with specified audio parameters. + * @param sampleRate Target sample rate in Hz + * @param frameSize Number of frames per buffer + * @param channels Number of audio channels + * @return 0 on success, -1 on failure + */ +- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; + +/** + * @brief Creates a Core Audio tap description for system audio capture. + * @param channels Number of audio channels to configure the tap for + * @return CATapDescription object on success, nil on failure + */ +- (nullable CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels; + +/** + * @brief Creates an aggregate device with the specified tap description and audio parameters. + * @param tapDescription Core Audio tap description for system audio capture + * @param sampleRate Target sample rate in Hz + * @param frameSize Number of frames per buffer + * @return OSStatus indicating success (noErr) or error code + */ +- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize; @end + +NS_ASSUME_NONNULL_END diff --git a/src/platform/macos/av_audio.m b/src/platform/macos/av_audio.m deleted file mode 100644 index a274cd6c1ff..00000000000 --- a/src/platform/macos/av_audio.m +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @file src/platform/macos/av_audio.m - * @brief Definitions for audio capture on macOS. - */ -// local includes -#import "av_audio.h" - -@implementation AVAudio - -+ (NSArray *)microphones { - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {10, 15, 0})]) { - // This will generate a warning about AVCaptureDeviceDiscoverySession being - // unavailable before macOS 10.15, but we have a guard to prevent it from - // being called on those earlier systems. - // Unfortunately the supported way to silence this warning, using @available, - // produces linker errors for __isPlatformVersionAtLeast, so we have to use - // a different method. -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wunguarded-availability-new" - AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInMicrophone, AVCaptureDeviceTypeExternalUnknown] - mediaType:AVMediaTypeAudio - position:AVCaptureDevicePositionUnspecified]; - return discoverySession.devices; -#pragma clang diagnostic pop - } else { - // We're intentionally using a deprecated API here specifically for versions - // of macOS where it's not deprecated, so we can ignore any deprecation - // warnings: -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wdeprecated-declarations" - return [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio]; -#pragma clang diagnostic pop - } -} - -+ (NSArray *)microphoneNames { - NSMutableArray *result = [[NSMutableArray alloc] init]; - - for (AVCaptureDevice *device in [AVAudio microphones]) { - [result addObject:[device localizedName]]; - } - - return result; -} - -+ (AVCaptureDevice *)findMicrophone:(NSString *)name { - for (AVCaptureDevice *device in [AVAudio microphones]) { - if ([[device localizedName] isEqualToString:name]) { - return device; - } - } - - return nil; -} - -- (void)dealloc { - // make sure we don't process any further samples - self.audioConnection = nil; - // make sure nothing gets stuck on this signal - [self.samplesArrivedSignal signal]; - [self.samplesArrivedSignal release]; - TPCircularBufferCleanup(&audioSampleBuffer); - [super dealloc]; -} - -- (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { - self.audioCaptureSession = [[AVCaptureSession alloc] init]; - - NSError *error; - AVCaptureDeviceInput *audioInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error]; - if (audioInput == nil) { - return -1; - } - - if ([self.audioCaptureSession canAddInput:audioInput]) { - [self.audioCaptureSession addInput:audioInput]; - } else { - [audioInput dealloc]; - return -1; - } - - AVCaptureAudioDataOutput *audioOutput = [[AVCaptureAudioDataOutput alloc] init]; - - [audioOutput setAudioSettings:@{ - (NSString *) AVFormatIDKey: [NSNumber numberWithUnsignedInt:kAudioFormatLinearPCM], - (NSString *) AVSampleRateKey: [NSNumber numberWithUnsignedInt:sampleRate], - (NSString *) AVNumberOfChannelsKey: [NSNumber numberWithUnsignedInt:channels], - (NSString *) AVLinearPCMBitDepthKey: [NSNumber numberWithUnsignedInt:32], - (NSString *) AVLinearPCMIsFloatKey: @YES, - (NSString *) AVLinearPCMIsNonInterleaved: @NO - }]; - - dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_CONCURRENT, QOS_CLASS_USER_INITIATED, DISPATCH_QUEUE_PRIORITY_HIGH); - dispatch_queue_t recordingQueue = dispatch_queue_create("audioSamplingQueue", qos); - - [audioOutput setSampleBufferDelegate:self queue:recordingQueue]; - - if ([self.audioCaptureSession canAddOutput:audioOutput]) { - [self.audioCaptureSession addOutput:audioOutput]; - } else { - [audioInput release]; - [audioOutput release]; - return -1; - } - - self.audioConnection = [audioOutput connectionWithMediaType:AVMediaTypeAudio]; - - [self.audioCaptureSession startRunning]; - - [audioInput release]; - [audioOutput release]; - - self.samplesArrivedSignal = [[NSCondition alloc] init]; - TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); - - return 0; -} - -- (void)captureOutput:(AVCaptureOutput *)output - didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer - fromConnection:(AVCaptureConnection *)connection { - if (connection == self.audioConnection) { - 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); - [self.samplesArrivedSignal signal]; - } -} - -@end diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm new file mode 100644 index 00000000000..ebce8b59117 --- /dev/null +++ b/src/platform/macos/av_audio.mm @@ -0,0 +1,835 @@ +/** + * @file src/platform/macos/av_audio.mm + * @brief Implementation of macOS audio capture with dual input paths. + * + * This file implements the AVAudio class which provides two distinct audio capture methods: + * 1. **Microphone capture** - Uses AVFoundation framework to capture from specific microphone devices + * 2. **System-wide audio tap** - Uses Core Audio taps to capture all system audio output (macOS 14.0+) + * + * The implementation handles format conversion, real-time audio processing, and provides + * a unified interface for both capture methods through a shared circular buffer. + */ +#import "av_audio.h" + +#include "src/logging.h" +#include "src/utility.h" + +#import +#import + +namespace platf { + using namespace std::literals; + + /** + * @brief Real-time AudioConverter input callback for format conversion. + * Provides audio data to AudioConverter during format conversion process using pure C++ for optimal performance. + * This function must avoid all Objective-C runtime calls to meet real-time audio constraints. + * @param inAudioConverter The audio converter requesting input data + * @param ioNumberDataPackets Number of data packets to provide + * @param ioData Buffer list to fill with audio data + * @param outDataPacketDescription Packet description for output data + * @param inUserData User data containing AudioConverterInputData structure + * @return OSStatus indicating success (noErr) or error code + */ + OSStatus audioConverterComplexInputProc(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { + auto *inputInfo = static_cast(inUserData); + + // Check if we've already provided all available frames + if (inputInfo->framesProvided >= inputInfo->inputFrames) { + *ioNumberDataPackets = 0; + return noErr; + } + + // Calculate how many frames we can provide (don't exceed remaining frames) + UInt32 framesToProvide = std::min(*ioNumberDataPackets, inputInfo->inputFrames - inputInfo->framesProvided); + + // Set up the output buffer with the audio data + ioData->mNumberBuffers = 1; + ioData->mBuffers[0].mNumberChannels = inputInfo->deviceChannels; + ioData->mBuffers[0].mDataByteSize = framesToProvide * inputInfo->deviceChannels * sizeof(float); + ioData->mBuffers[0].mData = inputInfo->inputData + (inputInfo->framesProvided * inputInfo->deviceChannels); + + // Update the tracking of how many frames we've provided + inputInfo->framesProvided += framesToProvide; + *ioNumberDataPackets = framesToProvide; + + return noErr; + } + + /** + * @brief Real-time audio processing function for Core Audio IOProc callbacks. + * Handles system-wide audio capture with format conversion and buffering using pure C++ for optimal performance. + * This function must avoid all Objective-C runtime calls to meet real-time audio constraints. + * @param inDevice The audio device identifier + * @param inNow Current audio time stamp + * @param inInputData Input audio buffer list from the device + * @param inInputTime Time stamp for input data + * @param outOutputData Output audio buffer list (not used in our implementation) + * @param inOutputTime Time stamp for output data + * @param inClientData Client data containing AVAudioIOProcData structure + * @return OSStatus indicating success (noErr) or error code + */ + OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) { + auto *procData = static_cast(inClientData); + + // Get required parameters from procData + UInt32 clientChannels = procData->clientRequestedChannels; + UInt32 clientFrameSize = procData->clientRequestedFrameSize; + AVAudio *avAudio = procData->avAudio; + + // Always ensure we write to buffer and signal, even if input is empty/invalid + bool didWriteData = false; + + if (inInputData && inInputData->mNumberBuffers > 0) { + AudioBuffer inputBuffer = inInputData->mBuffers[0]; + + if (inputBuffer.mData && inputBuffer.mDataByteSize > 0) { + auto *inputSamples = static_cast(inputBuffer.mData); + UInt32 deviceChannels = procData->aggregateDeviceChannels; + UInt32 inputFrames = inputBuffer.mDataByteSize / (deviceChannels * sizeof(float)); + + // Use AudioConverter if we need any conversion, otherwise pass through + if (procData->audioConverter) { + // Use pre-allocated buffer instead of malloc for real-time safety! + UInt32 maxOutputFrames = procData->conversionBufferSize / (clientChannels * sizeof(float)); + UInt32 requestedOutputFrames = maxOutputFrames; + + AudioConverterInputData inputData = {0}; + inputData.inputData = inputSamples; + inputData.inputFrames = inputFrames; + inputData.framesProvided = 0; // Critical: must start at 0! + inputData.deviceChannels = deviceChannels; + inputData.avAudio = avAudio; + + AudioBufferList outputBufferList = {0}; + outputBufferList.mNumberBuffers = 1; + outputBufferList.mBuffers[0].mNumberChannels = clientChannels; + outputBufferList.mBuffers[0].mDataByteSize = procData->conversionBufferSize; + outputBufferList.mBuffers[0].mData = procData->conversionBuffer; + + UInt32 outputFrameCount = requestedOutputFrames; + OSStatus converterStatus = AudioConverterFillComplexBuffer( + procData->audioConverter, + audioConverterComplexInputProc, + &inputData, + &outputFrameCount, + &outputBufferList, + nullptr + ); + + if (converterStatus == noErr && outputFrameCount > 0) { + // AudioConverter did all the work: sample rate + channels + optimal frame count + UInt32 actualOutputBytes = outputFrameCount * clientChannels * sizeof(float); + TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, procData->conversionBuffer, actualOutputBytes); + didWriteData = true; + } else { + // Fallback: write original data + TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); + didWriteData = true; + } + } else { + // No conversion needed - direct passthrough + TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); + didWriteData = true; + } + } + } + + // Always signal, even if we didn't write data (ensures consumer doesn't block) + if (!didWriteData) { + // Write silence if no valid input data - use pre-allocated buffer or small stack buffer + UInt32 silenceFrames = clientFrameSize > 0 ? std::min(clientFrameSize, 2048U) : 512U; + + if (procData->conversionBuffer && procData->conversionBufferSize > 0) { + // Use pre-allocated conversion buffer for silence + UInt32 maxSilenceFrames = procData->conversionBufferSize / (clientChannels * sizeof(float)); + silenceFrames = std::min(silenceFrames, maxSilenceFrames); + UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float); + + // Creating actual silence + memset(procData->conversionBuffer, 0, silenceBytes); + TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, procData->conversionBuffer, silenceBytes); + } else { + // Fallback to small stack-allocated buffer for cases without conversion buffer + float silenceBuffer[512 * 8] = {0}; // Max 512 frames, 8 channels on stack + UInt32 maxStackFrames = sizeof(silenceBuffer) / (clientChannels * sizeof(float)); + silenceFrames = std::min(silenceFrames, maxStackFrames); + UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float); + + TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, silenceBuffer, silenceBytes); + } + } + + // Signal new data arrival - using real-time safe C-based semaphore + // instead of Objective-C NSCondition to meet real-time audio constraints + dispatch_semaphore_signal(avAudio->audioSemaphore); + + return noErr; + } +} // namespace platf + +@implementation AVAudio + ++ (NSArray *)microphones { + using namespace std::literals; + BOOST_LOG(debug) << "Discovering microphones"sv; + + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {10, 15, 0})]) { + BOOST_LOG(debug) << "Using modern AVCaptureDeviceDiscoverySession API"sv; + // This will generate a warning about AVCaptureDeviceDiscoverySession being + // unavailable before macOS 10.15, but we have a guard to prevent it from + // being called on those earlier systems. + // Unfortunately the supported way to silence this warning, using @available, + // produces linker errors for __isPlatformVersionAtLeast, so we have to use + // a different method. +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wunguarded-availability-new" + AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInMicrophone, AVCaptureDeviceTypeExternalUnknown] + 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 + } else { + BOOST_LOG(debug) << "Using legacy AVCaptureDevice API"sv; + // We're intentionally using a deprecated API here specifically for versions + // of macOS where it's not deprecated, so we can ignore any deprecation + // warnings: +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio]; + BOOST_LOG(debug) << "Found "sv << [devices count] << " devices using legacy API"sv; + return devices; +#pragma clang diagnostic pop + } +} + ++ (NSArray *)microphoneNames { + using namespace std::literals; + BOOST_LOG(debug) << "Retrieving microphone names"sv; + 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; +} + ++ (AVCaptureDevice *)findMicrophone:(NSString *)name { + using namespace std::literals; + + if (name == nil) { + BOOST_LOG(warning) << "Microphone not found: (nil)"sv; + return nil; + } + + BOOST_LOG(debug) << "Searching for microphone: "sv << [name UTF8String]; + + for (AVCaptureDevice *device in [AVAudio microphones]) { + if ([[device localizedName] isEqualToString:name]) { + BOOST_LOG(info) << "Found microphone: "sv << [name UTF8String]; + return device; + } + } + + BOOST_LOG(warning) << "Microphone not found: "sv << [name UTF8String]; + return nil; +} + +- (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { + using namespace std::literals; + + if (device == nil) { + BOOST_LOG(error) << "Cannot setup microphone: device is nil"sv; + return -1; + } + + BOOST_LOG(info) << "Setting up microphone: "sv << [[device localizedName] UTF8String] << " with "sv << sampleRate << "Hz, "sv << frameSize << " frames, "sv << (int) channels << " channels"sv; + + self.audioCaptureSession = [[AVCaptureSession alloc] init]; + + NSError *nsError; + AVCaptureDeviceInput *audioInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&nsError]; + if (audioInput == nil) { + BOOST_LOG(error) << "Failed to create audio input from device: "sv << (nsError ? [[nsError localizedDescription] UTF8String] : "unknown error"sv); + return -1; + } + + if ([self.audioCaptureSession canAddInput:audioInput]) { + [self.audioCaptureSession addInput:audioInput]; + 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]; + return -1; + } + + AVCaptureAudioDataOutput *audioOutput = [[AVCaptureAudioDataOutput alloc] init]; + + [audioOutput setAudioSettings:@{ + (NSString *) AVFormatIDKey: [NSNumber numberWithUnsignedInt:kAudioFormatLinearPCM], + (NSString *) AVSampleRateKey: [NSNumber numberWithUnsignedInt:sampleRate], + (NSString *) AVNumberOfChannelsKey: [NSNumber numberWithUnsignedInt:channels], + (NSString *) AVLinearPCMBitDepthKey: [NSNumber numberWithUnsignedInt:32], + (NSString *) AVLinearPCMIsFloatKey: @YES, + (NSString *) AVLinearPCMIsNonInterleaved: @NO + }]; + 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); + dispatch_queue_t recordingQueue = dispatch_queue_create("audioSamplingQueue", qos); + + [audioOutput setSampleBufferDelegate:self queue:recordingQueue]; + + if ([self.audioCaptureSession canAddOutput:audioOutput]) { + [self.audioCaptureSession addOutput:audioOutput]; + BOOST_LOG(debug) << "Successfully added audio output to capture session"sv; + } else { + BOOST_LOG(error) << "Cannot add audio output to capture session"sv; + [audioInput release]; + [audioOutput release]; + return -1; + } + + self.audioConnection = [audioOutput connectionWithMediaType:AVMediaTypeAudio]; + + [self.audioCaptureSession startRunning]; + BOOST_LOG(info) << "Audio capture session started successfully"sv; + + [audioInput release]; + [audioOutput release]; + + // Initialize buffer and signal + [self initializeAudioBuffer:channels]; + BOOST_LOG(debug) << "Audio buffer initialized for microphone capture"sv; + + return 0; +} + +/** + * @brief AVFoundation delegate method for processing microphone audio samples. + * Called automatically when new audio samples are available from the microphone capture session. + * Writes audio data directly to the shared circular buffer. + * @param output The capture output that produced the sample buffer + * @param sampleBuffer CMSampleBuffer containing the audio data + * @param connection The capture connection that provided the sample buffer + */ +- (void)captureOutput:(AVCaptureOutput *)output + didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer + fromConnection:(AVCaptureConnection *)connection { + if (connection == self.audioConnection) { + 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); + } +} + +- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { + using namespace std::literals; + BOOST_LOG(debug) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; + + // 1. Initialize system tap components + if ([self initializeSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { + return -1; + } + + // 2. Create tap description and process tap + CATapDescription *tapDescription = [self createSystemTapDescriptionForChannels:channels]; + if (!tapDescription) { + [self cleanupSystemTapContext:nil]; + return -1; + } + + // 3. Create and configure aggregate device + OSStatus aggregateStatus = [self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize]; + if (aggregateStatus != noErr) { + [self cleanupSystemTapContext:tapDescription]; + return -1; + } + + // 4. Configure device properties and AudioConverter + OSStatus configureStatus = [self configureDevicePropertiesAndConverter:sampleRate clientChannels:channels]; + if (configureStatus != noErr) { + [self cleanupSystemTapContext:tapDescription]; + return -1; + } + + // 5. Create and start IOProc + OSStatus ioProcStatus = [self createAndStartAggregateDeviceIOProc:tapDescription]; + if (ioProcStatus != noErr) { + [self cleanupSystemTapContext:tapDescription]; + return -1; + } + + // 6. Initialize buffer and signal + [self initializeAudioBuffer:channels]; + + [tapDescription release]; + + BOOST_LOG(info) << "System tap setup completed successfully!"sv; + return 0; +} + +/** + * @brief Helper method to query Core Audio device properties. + * Provides a centralized way to get device properties with error logging. + * @param deviceID The audio device to query + * @param selector The property selector to retrieve + * @param scope The property scope (global, input, output) + * @param element The property element identifier + * @param ioDataSize Pointer to size variable (input: max size, output: actual size) + * @param outData Buffer to store the property data + * @return OSStatus indicating success (noErr) or error code + */ +- (OSStatus)getDeviceProperty:(AudioObjectID)deviceID + selector:(AudioObjectPropertySelector)selector + scope:(AudioObjectPropertyScope)scope + element:(AudioObjectPropertyElement)element + size:(UInt32 *)ioDataSize + data:(void *)outData { + using namespace std::literals; + + AudioObjectPropertyAddress addr = { + .mSelector = selector, + .mScope = scope, + .mElement = element + }; + + OSStatus result = AudioObjectGetPropertyData(deviceID, &addr, 0, NULL, ioDataSize, outData); + + if (result != noErr) { + BOOST_LOG(warning) << "Failed to get device property (selector: "sv << selector << ", scope: "sv << scope << ", element: "sv << element << ") with status: "sv << result; + } + + return result; +} + +/** + * @brief Generalized method for cleaning up system tap resources. + * Safely cleans up Core Audio system tap components in reverse order of creation. + * @param tapDescription Optional tap description object to release (can be nil) + */ +- (void)cleanupSystemTapContext:(id)tapDescription { + using namespace std::literals; + BOOST_LOG(debug) << "Starting system tap context cleanup"sv; + + // Clean up in reverse order of creation + if (self->ioProcID && self->aggregateDeviceID != kAudioObjectUnknown) { + AudioDeviceStop(self->aggregateDeviceID, self->ioProcID); + AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID); + self->ioProcID = NULL; + BOOST_LOG(debug) << "IOProc stopped and destroyed"sv; + } + + if (self->aggregateDeviceID != kAudioObjectUnknown) { + AudioHardwareDestroyAggregateDevice(self->aggregateDeviceID); + self->aggregateDeviceID = kAudioObjectUnknown; + BOOST_LOG(debug) << "Aggregate device destroyed"sv; + } + + if (self->tapObjectID != kAudioObjectUnknown) { + AudioHardwareDestroyProcessTap(self->tapObjectID); + self->tapObjectID = kAudioObjectUnknown; + BOOST_LOG(debug) << "Process tap destroyed"sv; + } + + if (self->ioProcData) { + if (self->ioProcData->conversionBuffer) { + free(self->ioProcData->conversionBuffer); + self->ioProcData->conversionBuffer = NULL; + BOOST_LOG(debug) << "Conversion buffer freed"sv; + } + if (self->ioProcData->audioConverter) { + AudioConverterDispose(self->ioProcData->audioConverter); + self->ioProcData->audioConverter = NULL; + BOOST_LOG(debug) << "AudioConverter disposed"sv; + } + free(self->ioProcData); + self->ioProcData = NULL; + BOOST_LOG(debug) << "IOProc data freed"sv; + } + + if (tapDescription) { + [tapDescription release]; + BOOST_LOG(debug) << "Tap description released"sv; + } + + BOOST_LOG(debug) << "System tap context cleanup completed"sv; +} + +// MARK: - Buffer Management Methods +// Shared buffer management methods used by both audio capture paths + +- (void)initializeAudioBuffer:(UInt8)channels { + using namespace std::literals; + BOOST_LOG(debug) << "Initializing audio buffer for "sv << (int) channels << " channels"sv; + + // Cleanup any existing circular buffer first + TPCircularBufferCleanup(&self->audioSampleBuffer); + + // Initialize the circular buffer with proper size for the channel count + TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); + + // Initialize real-time safe semaphore for synchronization (cleanup any existing one first) + if (self->audioSemaphore) { + dispatch_release(self->audioSemaphore); + } + self->audioSemaphore = dispatch_semaphore_create(0); + + BOOST_LOG(info) << "Audio buffer initialized successfully with size: "sv << (kBufferLength * channels) << " bytes"sv; +} + +- (void)cleanupAudioBuffer { + using namespace std::literals; + BOOST_LOG(debug) << "Cleaning up audio buffer"sv; + + // Signal any waiting threads before cleanup and release semaphore + if (self->audioSemaphore) { + dispatch_semaphore_signal(self->audioSemaphore); // Wake up any waiting threads + dispatch_release(self->audioSemaphore); + self->audioSemaphore = NULL; + } + + // Cleanup the circular buffer + TPCircularBufferCleanup(&self->audioSampleBuffer); + + BOOST_LOG(info) << "Audio buffer cleanup completed"sv; +} + +/** + * @brief Destructor for AVAudio instances. + * Performs comprehensive cleanup of both audio capture paths and shared resources. + */ +- (void)dealloc { + using namespace std::literals; + BOOST_LOG(debug) << "AVAudio dealloc started"sv; + + // Cleanup system tap resources using the generalized method + [self cleanupSystemTapContext:nil]; + + // Cleanup microphone session (AVFoundation path) + if (self.audioCaptureSession) { + [self.audioCaptureSession stopRunning]; + self.audioCaptureSession = nil; + BOOST_LOG(debug) << "Audio capture session stopped and released"sv; + } + self.audioConnection = nil; + + // Use our centralized buffer cleanup method (handles signal and buffer cleanup) + [self cleanupAudioBuffer]; + + BOOST_LOG(debug) << "AVAudio dealloc completed"sv; + [super dealloc]; +} + +// MARK: - System Tap Initialization +// Private methods for initializing Core Audio system tap components + +- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { + using namespace std::literals; + + // Check macOS version requirement + if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 0, 0})]) { + BOOST_LOG(error) << "macOS version requirement not met (need 14.0+)"sv; + return -1; + } + + NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; + BOOST_LOG(debug) << "macOS version check passed (running "sv << version.majorVersion << "."sv << version.minorVersion << "."sv << version.patchVersion << ")"sv; + + // Initialize Core Audio objects + self->tapObjectID = kAudioObjectUnknown; + self->aggregateDeviceID = kAudioObjectUnknown; + self->ioProcID = NULL; + + // Create IOProc data structure with client requirements + self->ioProcData = (AVAudioIOProcData *) malloc(sizeof(AVAudioIOProcData)); + if (!self->ioProcData) { + BOOST_LOG(error) << "Failed to allocate IOProc data structure"sv; + return -1; + } + + self->ioProcData->avAudio = self; + self->ioProcData->clientRequestedChannels = channels; + self->ioProcData->clientRequestedFrameSize = frameSize; + self->ioProcData->clientRequestedSampleRate = sampleRate; + self->ioProcData->audioConverter = NULL; + self->ioProcData->conversionBuffer = NULL; + self->ioProcData->conversionBufferSize = 0; + + BOOST_LOG(debug) << "System tap initialization completed"sv; + return 0; +} + +- (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { + using namespace std::literals; + + BOOST_LOG(debug) << "Creating tap description for "sv << (int) channels << " channels (using stereo tap)"sv; + NSArray *excludeProcesses = @[]; + + // Always use stereo tap - it handles mono by duplicating to left/right channels + CATapDescription *tapDescription = [[CATapDescription alloc] initStereoGlobalTapButExcludeProcesses:excludeProcesses]; + + // Set unique name and UUID for this instance + NSString *uniqueName = [NSString stringWithFormat:@"SunshineAVAudio-Tap-%p", (void *) self]; + NSUUID *uniqueUUID = [[NSUUID alloc] init]; + + tapDescription.name = uniqueName; + tapDescription.UUID = uniqueUUID; + [tapDescription setPrivate:YES]; + + // Set mute behavior based on the hostAudioEnabled property + if (self.hostAudioEnabled) { + tapDescription.muteBehavior = CATapUnmuted; // Audio to both tap and speakers + BOOST_LOG(debug) << "Core Audio tap: Host audio enabled (unmuted)"sv; + } else { + tapDescription.muteBehavior = CATapMuted; // Audio to tap only, speakers muted + BOOST_LOG(debug) << "Core Audio tap: Host audio disabled (muted)"sv; + } + + // Create the tap + BOOST_LOG(debug) << "Creating process tap with name: "sv << [uniqueName UTF8String]; + + // Use direct API call like the reference implementation + OSStatus status = AudioHardwareCreateProcessTap((CATapDescription *) tapDescription, &self->tapObjectID); + BOOST_LOG(debug) << "AudioHardwareCreateProcessTap returned status: "sv << status; + + [uniqueUUID release]; + + if (status != noErr) { + BOOST_LOG(error) << "AudioHardwareCreateProcessTap failed with status: "sv << status << " (tapDescription: "sv << [[tapDescription description] UTF8String] << ")"sv; + [tapDescription release]; + return nil; + } + + BOOST_LOG(debug) << "Process tap created successfully with ID: "sv << self->tapObjectID; + return tapDescription; +} + +- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize { + using namespace std::literals; + + // Get Tap UUID string properly + NSString *tapUIDString = nil; + if ([tapDescription respondsToSelector:@selector(UUID)]) { + tapUIDString = [[tapDescription UUID] UUIDString]; + } + if (!tapUIDString) { + BOOST_LOG(error) << "Failed to get tap UUID from description"sv; + return kAudioHardwareUnspecifiedError; + } + + // Create aggregate device with better drift compensation and proper keys + NSDictionary *subTapDictionary = @{ + @kAudioSubTapUIDKey: tapUIDString, + @kAudioSubTapDriftCompensationKey: @YES, + }; + + NSDictionary *aggregateProperties = @{ + @kAudioAggregateDeviceNameKey: [NSString stringWithFormat:@"SunshineAggregate-%p", (void *) self], + @kAudioAggregateDeviceUIDKey: [NSString stringWithFormat:@"com.lizardbyte.sunshine.aggregate-%p", (void *) self], + @kAudioAggregateDeviceTapListKey: @[subTapDictionary], + @kAudioAggregateDeviceTapAutoStartKey: @NO, + @kAudioAggregateDeviceIsPrivateKey: @YES, + // Add clock domain configuration for better timing + @kAudioAggregateDeviceIsStackedKey: @NO, + }; + + BOOST_LOG(debug) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; + OSStatus status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID); + BOOST_LOG(debug) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; + if (status != noErr && status != 'ExtA') { + BOOST_LOG(error) << "Failed to create aggregate device with status: "sv << status; + return status; + } + + BOOST_LOG(info) << "Aggregate device created with ID: "sv << self->aggregateDeviceID; + + // Configure the aggregate device + if (self->aggregateDeviceID != kAudioObjectUnknown) { + // Set sample rate on the aggregate device + AudioObjectPropertyAddress sampleRateAddr = { + .mSelector = kAudioDevicePropertyNominalSampleRate, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + Float64 deviceSampleRate = (Float64) sampleRate; + UInt32 sampleRateSize = sizeof(Float64); + OSStatus sampleRateResult = AudioObjectSetPropertyData(self->aggregateDeviceID, &sampleRateAddr, 0, NULL, sampleRateSize, &deviceSampleRate); + if (sampleRateResult != noErr) { + BOOST_LOG(warning) << "Failed to set aggregate device sample rate: "sv << sampleRateResult; + } else { + BOOST_LOG(debug) << "Set aggregate device sample rate to "sv << sampleRate << "Hz"sv; + } + + // Set buffer size on the aggregate device + AudioObjectPropertyAddress bufferSizeAddr = { + .mSelector = kAudioDevicePropertyBufferFrameSize, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + UInt32 deviceFrameSize = frameSize; + UInt32 frameSizeSize = sizeof(UInt32); + OSStatus bufferSizeResult = AudioObjectSetPropertyData(self->aggregateDeviceID, &bufferSizeAddr, 0, NULL, frameSizeSize, &deviceFrameSize); + if (bufferSizeResult != noErr) { + BOOST_LOG(warning) << "Failed to set aggregate device buffer size: "sv << bufferSizeResult; + } else { + BOOST_LOG(debug) << "Set aggregate device buffer size to "sv << frameSize << " frames"sv; + } + } + + BOOST_LOG(info) << "Aggregate device created and configured successfully"sv; + return noErr; +} + +- (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate + clientChannels:(UInt8)clientChannels { + using namespace std::literals; + + // Query actual device properties to determine if conversion is needed + Float64 aggregateDeviceSampleRate = 48000.0; // Default fallback + UInt32 aggregateDeviceChannels = 2; // Default fallback + + // Get actual sample rate from the aggregate device + UInt32 sampleRateQuerySize = sizeof(Float64); + OSStatus sampleRateStatus = [self getDeviceProperty:self->aggregateDeviceID + selector:kAudioDevicePropertyNominalSampleRate + scope:kAudioObjectPropertyScopeGlobal + element:kAudioObjectPropertyElementMain + size:&sampleRateQuerySize + data:&aggregateDeviceSampleRate]; + + if (sampleRateStatus != noErr) { + BOOST_LOG(warning) << "Failed to get device sample rate, using default 48kHz: "sv << sampleRateStatus; + aggregateDeviceSampleRate = 48000.0; + } + + // 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; + + // Create AudioConverter based on actual device properties vs client requirements + BOOL needsConversion = ((UInt32) aggregateDeviceSampleRate != clientSampleRate) || (aggregateDeviceChannels != clientChannels); + BOOST_LOG(debug) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") + << " (device: "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch" + << " -> client: "sv << clientSampleRate << "Hz/" << (int) clientChannels << "ch)"sv; + + if (needsConversion) { + AudioStreamBasicDescription sourceFormat = {0}; + sourceFormat.mSampleRate = aggregateDeviceSampleRate; + sourceFormat.mFormatID = kAudioFormatLinearPCM; + sourceFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + sourceFormat.mBytesPerPacket = sizeof(float) * aggregateDeviceChannels; + sourceFormat.mFramesPerPacket = 1; + sourceFormat.mBytesPerFrame = sizeof(float) * aggregateDeviceChannels; + sourceFormat.mChannelsPerFrame = aggregateDeviceChannels; + sourceFormat.mBitsPerChannel = 32; + + AudioStreamBasicDescription targetFormat = {0}; + targetFormat.mSampleRate = clientSampleRate; + targetFormat.mFormatID = kAudioFormatLinearPCM; + targetFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + targetFormat.mBytesPerPacket = sizeof(float) * clientChannels; + targetFormat.mFramesPerPacket = 1; + targetFormat.mBytesPerFrame = sizeof(float) * clientChannels; + targetFormat.mChannelsPerFrame = clientChannels; + targetFormat.mBitsPerChannel = 32; + + OSStatus converterStatus = AudioConverterNew(&sourceFormat, &targetFormat, &self->ioProcData->audioConverter); + if (converterStatus != noErr) { + BOOST_LOG(error) << "Failed to create audio converter: "sv << converterStatus; + return converterStatus; + } + BOOST_LOG(info) << "AudioConverter created successfully for "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch -> " << clientSampleRate << "Hz/" << (int) clientChannels << "ch"sv; + } else { + BOOST_LOG(info) << "No conversion needed - formats match (device: "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch)"sv; + } + + // Pre-allocate conversion buffer for real-time use (eliminates malloc in audio callback) + UInt32 maxFrames = self->ioProcData->clientRequestedFrameSize * 8; // Generous buffer for upsampling scenarios + self->ioProcData->conversionBufferSize = maxFrames * clientChannels * sizeof(float); + self->ioProcData->conversionBuffer = (float *) malloc(self->ioProcData->conversionBufferSize); + + if (!self->ioProcData->conversionBuffer) { + BOOST_LOG(error) << "Failed to allocate conversion buffer"sv; + if (self->ioProcData->audioConverter) { + AudioConverterDispose(self->ioProcData->audioConverter); + self->ioProcData->audioConverter = NULL; + } + return kAudioHardwareUnspecifiedError; + } + + BOOST_LOG(debug) << "Pre-allocated conversion buffer: "sv << self->ioProcData->conversionBufferSize << " bytes ("sv << maxFrames << " frames)"sv; + + // Store the actual device format for use in the IOProc + self->ioProcData->aggregateDeviceSampleRate = (UInt32) aggregateDeviceSampleRate; + self->ioProcData->aggregateDeviceChannels = aggregateDeviceChannels; + + BOOST_LOG(info) << "Device properties and converter configuration completed"sv; + return noErr; +} + +- (OSStatus)createAndStartAggregateDeviceIOProc:(CATapDescription *)tapDescription { + using namespace std::literals; + + // Create IOProc + BOOST_LOG(debug) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; + OSStatus status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, platf::systemAudioIOProc, self->ioProcData, &self->ioProcID); + BOOST_LOG(debug) << "AudioDeviceCreateIOProcID returned status: "sv << status; + if (status != noErr) { + BOOST_LOG(error) << "Failed to create IOProc with status: "sv << status; + return status; + } + + // Start the IOProc + BOOST_LOG(debug) << "Starting IOProc for aggregate device"; + status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID); + BOOST_LOG(debug) << "AudioDeviceStart returned status: "sv << status; + if (status != noErr) { + BOOST_LOG(error) << "Failed to start IOProc with status: "sv << status; + AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID); + return status; + } + + BOOST_LOG(info) << "System tap IO proc created and started successfully"sv; + return noErr; +} + +@end diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index fb702916f5d..7771e50297e 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -25,7 +25,16 @@ capture_e sample(std::vector &sample_in) override { void *byteSampleBuffer = TPCircularBufferTail(&av_audio_capture->audioSampleBuffer, &length); while (length < sample_size * sizeof(float)) { - [av_audio_capture.samplesArrivedSignal wait]; + // Using 5 second timeout to prevent indefinite hanging + dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW, 5LL * NSEC_PER_SEC); + if (dispatch_semaphore_wait(av_audio_capture->audioSemaphore, timeout) != 0) { + BOOST_LOG(warning) << "Audio sample timeout - no audio data received within 5 seconds"sv; + + // Fill with silence and return to prevent hanging + std::fill(sample_in.begin(), sample_in.end(), 0.0f); + return capture_e::timeout; + } + byteSampleBuffer = TPCircularBufferTail(&av_audio_capture->audioSampleBuffer, &length); } @@ -49,30 +58,44 @@ int set_sink(const std::string &sink) override { return 0; } - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, bool host_audio_enabled) override { auto mic = std::make_unique(); - const char *audio_sink = ""; + mic->av_audio_capture = [[AVAudio alloc] init]; - if (!config::audio.sink.empty()) { - audio_sink = config::audio.sink.c_str(); - } + // Set the host audio enabled flag from the stream configuration + mic->av_audio_capture.hostAudioEnabled = host_audio_enabled ? YES : NO; + BOOST_LOG(debug) << "Set hostAudioEnabled to: "sv << (host_audio_enabled ? "YES" : "NO"); - if ((audio_capture_device = [AVAudio findMicrophone:[NSString stringWithUTF8String:audio_sink]]) == nullptr) { - BOOST_LOG(error) << "opening microphone '"sv << audio_sink << "' failed. Please set a valid input source in the Sunshine config."sv; - BOOST_LOG(error) << "Available inputs:"sv; + if (config::audio.sink.empty()) { + // Use macOS system-wide audio tap + BOOST_LOG(info) << "Using macOS system audio tap for capture."sv; + BOOST_LOG(info) << "Sample rate: "sv << sample_rate << ", Frame size: "sv << frame_size << ", Channels: "sv << channels; - for (NSString *name in [AVAudio microphoneNames]) { - BOOST_LOG(error) << "\t"sv << [name UTF8String]; + if ([mic->av_audio_capture setupSystemTap:sample_rate frameSize:frame_size channels:channels]) { + BOOST_LOG(error) << "Failed to setup system audio tap."sv; + return nullptr; } - return nullptr; - } + BOOST_LOG(info) << "macOS system audio tap capturing."sv; + } else { + // Use specified macOS audio sink + const char *audio_sink = config::audio.sink.c_str(); - mic->av_audio_capture = [[AVAudio alloc] init]; + if ((audio_capture_device = [AVAudio findMicrophone:[NSString stringWithUTF8String:audio_sink]]) == nullptr) { + BOOST_LOG(error) << "opening microphone '"sv << audio_sink << "' failed. Please set a valid input source in the Sunshine config."sv; + BOOST_LOG(error) << "Available inputs:"sv; + + for (NSString *name in [AVAudio microphoneNames]) { + BOOST_LOG(error) << "\t"sv << [name UTF8String]; + } - if ([mic->av_audio_capture setupMicrophone:audio_capture_device sampleRate:sample_rate frameSize:frame_size channels:channels]) { - BOOST_LOG(error) << "Failed to setup microphone."sv; - return nullptr; + return nullptr; + } + + if ([mic->av_audio_capture setupMicrophone:audio_capture_device sampleRate:sample_rate frameSize:frame_size channels:channels]) { + BOOST_LOG(error) << "Failed to setup microphone."sv; + return nullptr; + } } return mic; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 98af1e75bc8..1a2f973ab13 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -767,7 +767,7 @@ namespace platf::audio { return std::nullopt; } - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, [[maybe_unused]] bool host_audio_enabled) override { auto mic = std::make_unique(); if (mic->init(sample_rate, frame_size, channels, continuous_audio)) { diff --git a/src_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 331dc63150f..6a4b82799f0 100644 --- a/src_assets/common/assets/web/public/assets/locale/en.json +++ b/src_assets/common/assets/web/public/assets/locale/en.json @@ -126,7 +126,7 @@ "apply_note": "Click 'Apply' to restart Sunshine and apply changes. This will terminate any running sessions.", "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.", "audio_sink_desc_windows": "Manually specify a specific audio device to capture. If unset, the device is chosen automatically. We strongly recommend leaving this field blank to use automatic device selection! If you have multiple audio devices with identical names, you can get the Device ID using the following command:", "audio_sink_placeholder_macos": "BlackHole 2ch", "audio_sink_placeholder_windows": "Speakers (High Definition Audio Device)", diff --git a/src_assets/macos/assets/Info.plist b/src_assets/macos/assets/Info.plist index 0cd880953bf..2a6e82e47b0 100644 --- a/src_assets/macos/assets/Info.plist +++ b/src_assets/macos/assets/Info.plist @@ -8,5 +8,8 @@ Sunshine NSMicrophoneUsageDescription This app requires access to your microphone to stream audio. - + NSAudioCaptureUsageDescription + This app requires access to system audio to capture and stream audio output. + NSScreenCaptureUsageDescription + This app requires access to screen recording to capture and stream your screen content. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9a570fd3e7f..4e3cbecd1b0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -90,6 +90,12 @@ endif() file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/tests/*.h ${CMAKE_SOURCE_DIR}/tests/*.cpp) +# Add macOS-specific test files only when building tests for macOS +if (APPLE) + file(GLOB_RECURSE MACOS_TEST_SOURCES CONFIGURE_DEPENDS + ${CMAKE_SOURCE_DIR}/tests/*.mm) + list(APPEND TEST_SOURCES ${MACOS_TEST_SOURCES}) +endif () set(SUNSHINE_SOURCES ${SUNSHINE_TARGET_FILES}) diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm new file mode 100644 index 00000000000..1f1b89fa6db --- /dev/null +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -0,0 +1,388 @@ +/** + * @file tests/unit/platform/test_macos_av_audio.mm + * @brief Unit tests for src/platform/macos/av_audio.*. + */ + +// Only compile these tests on macOS +#ifdef __APPLE__ + + #include "../../tests_common.h" + + #import + #import + #import + #import + + #import + +/** + * @brief Test parameters for processSystemAudioIOProc tests. + * Contains various audio configuration parameters to test different scenarios. + */ +struct ProcessSystemAudioIOProcTestParams { + UInt32 frameCount; ///< Number of audio frames to process + UInt32 channels; ///< Number of audio channels (1=mono, 2=stereo) + UInt32 sampleRate; ///< Sample rate in Hz + bool useNilInput; ///< Whether to test with nil input data + const char *testName; ///< Descriptive name for the test case +}; + +/** + * @brief Test suite for AVAudio class functionality. + * Parameterized test class for testing Core Audio system tap functionality. + */ +class AVAudioTest: public PlatformTestSuite, public ::testing::WithParamInterface {}; + +/** + * @brief Test that findMicrophone handles nil input gracefully. + * Verifies the method returns nil when passed a nil microphone name. + */ +TEST_F(AVAudioTest, FindMicrophoneWithNilReturnsNil) { + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wnonnull" + AVCaptureDevice *device = [AVAudio findMicrophone:nil]; + #pragma clang diagnostic pop + EXPECT_EQ(device, nil); +} + +/** + * @brief Test that findMicrophone handles empty string input gracefully. + * Verifies the method returns nil when passed an empty microphone name. + */ +TEST_F(AVAudioTest, FindMicrophoneWithEmptyStringReturnsNil) { + AVCaptureDevice *device = [AVAudio findMicrophone:@""]; + EXPECT_EQ(device, nil); // Should return nil for empty string +} + +// REMOVED: FindMicrophoneWithInvalidNameReturnsNil - Integration test that queries real devices + +/** + * @brief Test that setupMicrophone handles nil device input properly. + * Verifies the method returns an error code when passed a nil device. + */ +TEST_F(AVAudioTest, SetupMicrophoneWithNilDeviceReturnsError) { + AVAudio *avAudio = [[AVAudio alloc] init]; + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wnonnull" + int result = [avAudio setupMicrophone:nil sampleRate:48000 frameSize:512 channels:2]; + #pragma clang diagnostic pop + [avAudio release]; + EXPECT_EQ(result, -1); // Should fail with nil device +} + +/** + * @brief Test basic AVAudio object lifecycle. + * Verifies that AVAudio objects can be created and destroyed without issues. + */ +TEST_F(AVAudioTest, ObjectLifecycle) { + AVAudio *avAudio = [[AVAudio alloc] init]; + EXPECT_NE(avAudio, nil); // Should create successfully + [avAudio release]; // Should not crash +} + +/** + * @brief Test that multiple AVAudio objects can coexist. + * Verifies that multiple instances can be created simultaneously. + */ +TEST_F(AVAudioTest, MultipleObjectsCoexist) { + AVAudio *avAudio1 = [[AVAudio alloc] init]; + AVAudio *avAudio2 = [[AVAudio alloc] init]; + + EXPECT_NE(avAudio1, nil); + EXPECT_NE(avAudio2, nil); + EXPECT_NE(avAudio1, avAudio2); // Should be different objects + + [avAudio1 release]; + [avAudio2 release]; +} + +/** + * @brief Test audio buffer initialization with various channel configurations. + * Verifies that the audio buffer can be initialized with different channel counts. + */ +TEST_F(AVAudioTest, InitializeAudioBuffer) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + // Test with various channel counts + [avAudio initializeAudioBuffer:1]; // Mono + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + + [avAudio initializeAudioBuffer:2]; // Stereo + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + + [avAudio initializeAudioBuffer:8]; // 7.1 Surround + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + + [avAudio release]; +} + +/** + * @brief Test audio buffer cleanup functionality. + * Verifies that cleanup works correctly even with uninitialized buffers. + */ +TEST_F(AVAudioTest, CleanupUninitializedBuffer) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + // Should not crash even if buffer was never initialized + [avAudio cleanupAudioBuffer]; + + // Initialize then cleanup + [avAudio initializeAudioBuffer:2]; + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + EXPECT_EQ(avAudio->audioSemaphore, nullptr); + + [avAudio release]; +} + +/** + * @brief Test audio converter complex input callback with valid data. + * Verifies that the audio converter callback properly processes valid audio data. + */ +TEST_F(AVAudioTest, AudioConverterComplexInputProc) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + // Create test input data + UInt32 frameCount = 256; + UInt32 channels = 2; + float *testData = (float *) calloc(frameCount * channels, sizeof(float)); + + // Fill with deterministic ramp data (channel-encoded constants) + for (UInt32 frame = 0; frame < frameCount; frame++) { + for (UInt32 channel = 0; channel < channels; channel++) { + testData[frame * channels + channel] = channel + frame * 0.001f; + } + } + + AudioConverterInputData inputInfo = {0}; + inputInfo.inputData = testData; + inputInfo.inputFrames = frameCount; + inputInfo.framesProvided = 0; + inputInfo.deviceChannels = channels; + inputInfo.avAudio = avAudio; + + // Test the method + UInt32 requestedPackets = 128; + AudioBufferList bufferList = {0}; + // Use a dummy AudioConverterRef (can be null for our test since our implementation doesn't use it) + AudioConverterRef dummyConverter = nullptr; + OSStatus result = platf::audioConverterComplexInputProc(dummyConverter, &requestedPackets, &bufferList, nullptr, &inputInfo); + + EXPECT_EQ(result, noErr); + EXPECT_EQ(requestedPackets, 128); // Should provide requested frames + EXPECT_EQ(inputInfo.framesProvided, 128); // Should update frames provided + + free(testData); + [avAudio release]; +} + +/** + * @brief Test audio converter callback when no more data is available. + * Verifies that the callback handles end-of-data scenarios correctly. + */ +TEST_F(AVAudioTest, AudioConverterInputProcNoMoreData) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + UInt32 frameCount = 256; + UInt32 channels = 2; + float *testData = (float *) calloc(frameCount * channels, sizeof(float)); + + AudioConverterInputData inputInfo = {0}; + inputInfo.inputData = testData; + inputInfo.inputFrames = frameCount; + inputInfo.framesProvided = frameCount; // Already provided all frames + inputInfo.deviceChannels = channels; + inputInfo.avAudio = avAudio; + + UInt32 requestedPackets = 128; + AudioBufferList bufferList = {0}; + // Use a dummy AudioConverterRef (can be null for our test since our implementation doesn't use it) + AudioConverterRef dummyConverter = nullptr; + OSStatus result = platf::audioConverterComplexInputProc(dummyConverter, &requestedPackets, &bufferList, nullptr, &inputInfo); + + EXPECT_EQ(result, noErr); + EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data + + free(testData); + [avAudio release]; +} + +/** + * @brief Test that audio buffer cleanup can be called multiple times safely. + * Verifies that repeated cleanup calls don't cause crashes or issues. + */ +TEST_F(AVAudioTest, CleanupAudioBufferMultipleTimes) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + [avAudio initializeAudioBuffer:2]; + EXPECT_NE(avAudio->audioSemaphore, nullptr); + + // Multiple cleanup calls should not crash + [avAudio cleanupAudioBuffer]; + EXPECT_EQ(avAudio->audioSemaphore, nullptr); + + [avAudio cleanupAudioBuffer]; // Second call should be safe + [avAudio cleanupAudioBuffer]; // Third call should be safe + + [avAudio release]; +} + +/** + * @brief Test buffer management with edge case channel configurations. + * Verifies that buffer management works with minimum and maximum channel counts. + */ +TEST_F(AVAudioTest, BufferManagementEdgeCases) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + // Test with minimum reasonable channel count (1 channel) + [avAudio initializeAudioBuffer:1]; + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + + // Test with very high channel count + [avAudio initializeAudioBuffer:32]; + EXPECT_NE(avAudio->audioSemaphore, nullptr); + [avAudio cleanupAudioBuffer]; + + [avAudio release]; +} + +// Type alias for parameterized audio processing tests +using ProcessSystemAudioIOProcTest = AVAudioTest; + +// Test parameters - representative configurations to cover a range of scenarios +// Channels: 1 (mono), 2 (stereo), 6 (5.1), 8 (7.1) +// Sample rates: 48000 (common), 44100 (legacy), 192000 (edge) +// Frame counts: 64 (small), 256 (typical), 1024 (large) +INSTANTIATE_TEST_SUITE_P( + AVAudioTest, + ProcessSystemAudioIOProcTest, + ::testing::Values( + // Representative channel configurations at common sample rate + ProcessSystemAudioIOProcTestParams {256, 1, 48000, false, "Mono48kHz"}, + ProcessSystemAudioIOProcTestParams {256, 2, 48000, false, "Stereo48kHz"}, + ProcessSystemAudioIOProcTestParams {256, 6, 48000, false, "Surround51_48kHz"}, + ProcessSystemAudioIOProcTestParams {256, 8, 48000, false, "Surround71_48kHz"}, + + // Frame count variations (small, typical, large) + ProcessSystemAudioIOProcTestParams {64, 2, 48000, false, "SmallFrameCount"}, + ProcessSystemAudioIOProcTestParams {1024, 2, 48000, false, "LargeFrameCount"}, + + // Sample rate edge cases + ProcessSystemAudioIOProcTestParams {256, 2, 44100, false, "LegacySampleRate44kHz"}, + ProcessSystemAudioIOProcTestParams {256, 2, 192000, false, "HighSampleRate192kHz"}, + + // Edge case: nil input handling + ProcessSystemAudioIOProcTestParams {256, 2, 48000, true, "NilInputHandling"}, + + // Combined edge case: max channels + large frames + ProcessSystemAudioIOProcTestParams {1024, 8, 48000, false, "MaxChannelsLargeFrames"} + ), + [](const ::testing::TestParamInfo &info) { + return std::string(info.param.testName); + } +); + +TEST_P(ProcessSystemAudioIOProcTest, ProcessAudioInput) { + ProcessSystemAudioIOProcTestParams params = GetParam(); + + AVAudio *avAudio = [[AVAudio alloc] init]; + + // Use the new buffer initialization method instead of manual setup + [avAudio initializeAudioBuffer:params.channels]; + + // Create timestamps + AudioTimeStamp timeStamp = {0}; + timeStamp.mFlags = kAudioTimeStampSampleTimeValid; + timeStamp.mSampleTime = 0; + + AudioBufferList *inputBufferList = nullptr; + float *testInputData = nullptr; + UInt32 inputDataSize = 0; + + // Only create input data if not testing nil input + if (!params.useNilInput) { + inputDataSize = params.frameCount * params.channels * sizeof(float); + testInputData = (float *) calloc(params.frameCount * params.channels, sizeof(float)); + + // Fill with deterministic ramp data (channel-encoded constants) + // This is faster than sine waves and provides channel separation + frame ordering + for (UInt32 frame = 0; frame < params.frameCount; frame++) { + for (UInt32 channel = 0; channel < params.channels; channel++) { + testInputData[frame * params.channels + channel] = channel + frame * 0.001f; + } + } + + // Create AudioBufferList + inputBufferList = (AudioBufferList *) malloc(sizeof(AudioBufferList)); + inputBufferList->mNumberBuffers = 1; + inputBufferList->mBuffers[0].mNumberChannels = params.channels; + inputBufferList->mBuffers[0].mDataByteSize = inputDataSize; + inputBufferList->mBuffers[0].mData = testInputData; + } + + // Get initial buffer state + uint32_t initialAvailableBytes = 0; + TPCircularBufferTail(&avAudio->audioSampleBuffer, &initialAvailableBytes); + + // Create IOProc data structure for the C++ function + AVAudioIOProcData procData = {0}; + procData.avAudio = avAudio; + procData.clientRequestedChannels = params.channels; + procData.clientRequestedFrameSize = params.frameCount; + procData.clientRequestedSampleRate = params.sampleRate; + procData.aggregateDeviceChannels = params.channels; // For simplicity in tests + procData.aggregateDeviceSampleRate = params.sampleRate; + procData.audioConverter = nullptr; // No conversion needed for most tests + + // Create a dummy output buffer (not used in our implementation but required by signature) + AudioBufferList dummyOutputBufferList = {0}; + + // Test the systemAudioIOProcWrapper function + OSStatus result = platf::systemAudioIOProc(0, // device ID (not used in our logic) + &timeStamp, + inputBufferList, + &timeStamp, + &dummyOutputBufferList, + &timeStamp, + &procData); + + // Verify the method returns success + EXPECT_EQ(result, noErr); + + if (!params.useNilInput) { + // Verify data was written to the circular buffer + uint32_t finalAvailableBytes = 0; + void *bufferData = TPCircularBufferTail(&avAudio->audioSampleBuffer, &finalAvailableBytes); + EXPECT_GT(finalAvailableBytes, initialAvailableBytes); // Should have more data than before + EXPECT_GT(finalAvailableBytes, 0); // Should have data in buffer + + // Verify we wrote the expected amount of data (input size for direct passthrough) + EXPECT_EQ(finalAvailableBytes, inputDataSize); + + // Verify the actual audio data matches what we put in (first few samples) + // Limit validation to min(8, channels * 2) samples to keep test efficient + UInt32 samplesToTest = std::min(8U, params.channels * 2); + if (bufferData && finalAvailableBytes >= sizeof(float) * samplesToTest) { + float *outputSamples = (float *) bufferData; + for (UInt32 i = 0; i < samplesToTest; i++) { + EXPECT_FLOAT_EQ(outputSamples[i], testInputData[i]) << "Sample " << i << " mismatch"; + } + } + } + + // Cleanup + if (testInputData) { + free(testInputData); + } + if (inputBufferList) { + free(inputBufferList); + } + [avAudio cleanupAudioBuffer]; + [avAudio release]; +} + +#endif // __APPLE__