From 5bd8e54db415b6f6df23a05e0c49342950f495bb Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Thu, 28 Aug 2025 21:00:33 +0200 Subject: [PATCH 01/30] wip(macos): add system-wide audio tap support --- .gitignore | 3 + cmake/compile_definitions/macos.cmake | 5 +- cmake/compile_definitions/unix.cmake | 3 +- cmake/dependencies/common.cmake | 3 + cmake/dependencies/macos.cmake | 8 + docs/configuration.md | 25 + src/config.cpp | 2 + src/config.h | 1 + src/platform/macos/av_audio.h | 30 + src/platform/macos/av_audio.m | 139 ----- src/platform/macos/av_audio.mm | 513 ++++++++++++++++++ src/platform/macos/microphone.mm | 16 +- src_assets/common/assets/web/config.html | 1 + .../assets/web/configs/tabs/AudioVideo.vue | 15 +- .../assets/web/public/assets/locale/en.json | 2 + src_assets/macos/assets/Info.plist | 5 +- 16 files changed, 627 insertions(+), 144 deletions(-) delete mode 100644 src/platform/macos/av_audio.m create mode 100644 src/platform/macos/av_audio.mm diff --git a/.gitignore b/.gitignore index 30818d52ae3..1ae015dc32b 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,6 @@ package-lock.json # Python *.pyc venv/ + + +.cache/ \ No newline at end of file diff --git a/cmake/compile_definitions/macos.cmake b/cmake/compile_definitions/macos.cmake index fb33d3bf235..d518c19637b 100644 --- a/cmake/compile_definitions/macos.cmake +++ b/cmake/compile_definitions/macos.cmake @@ -24,6 +24,9 @@ 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") @@ -33,7 +36,7 @@ set(SUNSHINE_TRAY 0) 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 97319be6f01..58204e63b8f 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..a3f84e3faaa 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) \ No newline at end of file diff --git a/docs/configuration.md b/docs/configuration.md index 70e2a97bec4..b3c47c826e4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -825,6 +825,31 @@ editing the `conf` file in a text editor. Use the examples as reference. +### macos_system_wide_audio_tap + + + + + + + + + + + + + + +
Description + @tip{Overrides Audio Sink settings.} + Toggles the creation of a system-wide audio tap that captures outgoing audio from all processes. + This tap can act as an input in a HAL aggregate device, like a virtual microphone. + @note{Requirement: macOS 14.2 or later.} + @attention{macOS Privacy Settings: The user must add Terminal or Sunshine to Privacy & Security > Screen & System Audio Recording > System Audio Recording Only in System Settings.} +
Defaultdisabled
Example@code{} + macos_system_wide_audio_tap = disabled + @endcode
+ ### install_steam_audio_drivers diff --git a/src/config.cpp b/src/config.cpp index 5268af669d5..6e09bbeebc9 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -513,6 +513,7 @@ namespace config { {}, // virtual_sink true, // stream audio true, // install_steam_drivers + true, // macos_system_wide_audio_tap }; stream_t stream { @@ -1166,6 +1167,7 @@ namespace config { string_f(vars, "virtual_sink", audio.virtual_sink); bool_f(vars, "stream_audio", audio.stream); bool_f(vars, "install_steam_audio_drivers", audio.install_steam_drivers); + bool_f(vars, "macos_system_wide_audio_tap", audio.macos_system_wide_audio_tap); string_restricted_f(vars, "origin_web_ui_allowed", nvhttp.origin_web_ui_allowed, {"pc"sv, "lan"sv, "wan"sv}); diff --git a/src/config.h b/src/config.h index cda1f7c69e4..2cecafd4696 100644 --- a/src/config.h +++ b/src/config.h @@ -149,6 +149,7 @@ namespace config { std::string virtual_sink; bool stream; bool install_steam_drivers; + bool macos_system_wide_audio_tap; }; constexpr int ENCRYPTION_MODE_NEVER = 0; // Never use video encryption, even if the client supports it diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 9ef1cca2918..d173f0ca25a 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -6,15 +6,35 @@ // platform includes #import +#import +#import +#import // lib includes #include "third-party/TPCircularBuffer/TPCircularBuffer.h" #define kBufferLength 4096 +// Forward declaration +@class AVAudio; + +// IOProc client data structure + typedef struct { + AVAudio *avAudio; + UInt32 clientRequestedChannels; + UInt32 clientRequestedSampleRate; + UInt32 clientRequestedFrameSize; + AudioConverterRef sampleRateConverter; + } AVAudioIOProcData; + @interface AVAudio: NSObject { @public TPCircularBuffer audioSampleBuffer; +@private + AudioObjectID tapObjectID; + AudioObjectID aggregateDeviceID; + AudioDeviceIOProcID ioProcID; + AVAudioIOProcData *ioProcData; } @property (nonatomic, assign) AVCaptureSession *audioCaptureSession; @@ -25,5 +45,15 @@ + (AVCaptureDevice *)findMicrophone:(NSString *)name; - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +- (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice + inNow:(const AudioTimeStamp *)inNow + inInputData:(const AudioBufferList *)inInputData + inInputTime:(const AudioTimeStamp *)inInputTime + outOutputData:(AudioBufferList *)outOutputData + inOutputTime:(const AudioTimeStamp *)inOutputTime + clientChannels:(UInt32)clientChannels + clientFrameSize:(UInt32)clientFrameSize + clientSampleRate:(UInt32)clientSampleRate; @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..cae618962d6 --- /dev/null +++ b/src/platform/macos/av_audio.mm @@ -0,0 +1,513 @@ +/** + * @file src/platform/macos/av_audio.mm + * @brief Simplified audio capture on macOS with system tap. + */ +#import "av_audio.h" +#include "src/logging.h" + +#import +#import + + +// AudioConverter input callback +typedef struct { + float *inputData; + UInt32 inputFrames; + UInt32 framesProvided; +} AudioConverterInputData; + +OSStatus audioConverterInputProc(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { + AudioConverterInputData *inputInfo = (AudioConverterInputData *) inUserData; + + if (inputInfo->framesProvided >= inputInfo->inputFrames) { + *ioNumberDataPackets = 0; + return noErr; + } + + UInt32 framesToProvide = MIN(*ioNumberDataPackets, inputInfo->inputFrames - inputInfo->framesProvided); + + ioData->mNumberBuffers = 1; + ioData->mBuffers[0].mNumberChannels = 2; // Source is always stereo + ioData->mBuffers[0].mDataByteSize = framesToProvide * 2 * sizeof(float); + ioData->mBuffers[0].mData = inputInfo->inputData + (inputInfo->framesProvided * 2); + + inputInfo->framesProvided += framesToProvide; + *ioNumberDataPackets = framesToProvide; + + return noErr; +} + +// C wrapper for IOProc callback +static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) { + AVAudioIOProcData *procData = (AVAudioIOProcData *) inClientData; + AVAudio *avAudio = procData->avAudio; + return [avAudio processSystemAudioIOProc:inDevice + inNow:inNow + inInputData:inInputData + inInputTime:inInputTime + outOutputData:outOutputData + inOutputTime:inOutputTime + clientChannels:procData->clientRequestedChannels + clientFrameSize:procData->clientRequestedFrameSize + clientSampleRate:procData->clientRequestedSampleRate]; +} + +@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; +} + +- (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]; + } +} + +- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { + using namespace std::literals; + BOOST_LOG(info) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; + + // Check macOS version requirement + if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 2, 0})]) { + BOOST_LOG(error) << "macOS version requirement not met (need 14.2+)"sv; + return -1; + } + + NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; + BOOST_LOG(info) << "macOS version check passed (running "sv << version.majorVersion << "."sv << version.minorVersion << "."sv << version.patchVersion << ")"sv; + + // Initialize + 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) { + return -1; + } + self->ioProcData->avAudio = self; + self->ioProcData->clientRequestedChannels = channels; + self->ioProcData->clientRequestedFrameSize = frameSize; + self->ioProcData->clientRequestedSampleRate = sampleRate; + self->ioProcData->sampleRateConverter = NULL; + + // Create AudioConverter for sample rate and/or channel conversion if needed + BOOL needsConversion = (sampleRate != 48000) || (channels != 2); // System tap is always 48kHz stereo + BOOST_LOG(info) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") << " (sampleRate="sv << sampleRate << ", channels="sv << (int) channels << ")"sv; + if (needsConversion) { + AudioStreamBasicDescription sourceFormat = {0}; + sourceFormat.mSampleRate = 48000.0; // System tap is always 48kHz + sourceFormat.mFormatID = kAudioFormatLinearPCM; + sourceFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + sourceFormat.mBytesPerPacket = sizeof(float) * 2; // Stereo + sourceFormat.mFramesPerPacket = 1; + sourceFormat.mBytesPerFrame = sizeof(float) * 2; + sourceFormat.mChannelsPerFrame = 2; + sourceFormat.mBitsPerChannel = 32; + + AudioStreamBasicDescription targetFormat = {0}; + targetFormat.mSampleRate = sampleRate; + targetFormat.mFormatID = kAudioFormatLinearPCM; + targetFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + targetFormat.mBytesPerPacket = sizeof(float) * channels; + targetFormat.mFramesPerPacket = 1; + targetFormat.mBytesPerFrame = sizeof(float) * channels; + targetFormat.mChannelsPerFrame = channels; + targetFormat.mBitsPerChannel = 32; + + OSStatus converterStatus = AudioConverterNew(&sourceFormat, &targetFormat, &self->ioProcData->sampleRateConverter); + if (converterStatus != noErr) { + BOOST_LOG(error) << "Failed to create audio converter: "sv << converterStatus; + free(self->ioProcData); + self->ioProcData = NULL; + return -1; + } + BOOST_LOG(info) << "AudioConverter created successfully"sv; + } + + // 1. Create tap description + BOOST_LOG(info) << "Creating tap description for "sv << (int) channels << " channels"sv; + CATapDescription *tapDescription; + NSArray *excludeProcesses = @[]; + + if (channels == 1) { + tapDescription = [[CATapDescription alloc] initMonoGlobalTapButExcludeProcesses:excludeProcesses]; + } else { + 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]; + + // Create the tap + BOOST_LOG(info) << "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(info) << "AudioHardwareCreateProcessTap returned status: "sv << status; + + if (status != noErr) { + BOOST_LOG(error) << "AudioHardwareCreateProcessTap failed with status: "sv << status << " (tapDescription: "sv << [tapDescription description] << ")"sv; + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + + // 2. Create aggregate device + // 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; + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + + // 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(info) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; + status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID); + BOOST_LOG(info) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; + if (status != noErr && status != 'ExtA') { + BOOST_LOG(error) << "Failed to create aggregate device with status: "sv << status; + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + + // 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); + AudioObjectSetPropertyData(self->aggregateDeviceID, &sampleRateAddr, 0, NULL, sampleRateSize, &deviceSampleRate); + + // Set buffer size on the aggregate device + AudioObjectPropertyAddress bufferSizeAddr = { + .mSelector = kAudioDevicePropertyBufferFrameSize, + .mScope = kAudioObjectPropertyScopeGlobal, + .mElement = kAudioObjectPropertyElementMain + }; + UInt32 deviceFrameSize = frameSize; + UInt32 frameSizeSize = sizeof(UInt32); + AudioObjectSetPropertyData(self->aggregateDeviceID, &bufferSizeAddr, 0, NULL, frameSizeSize, &deviceFrameSize); + } + + // 3. Configure IOProc + BOOST_LOG(info) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; + status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); + BOOST_LOG(info) << "AudioDeviceCreateIOProcID returned status: "sv << status; + if (status != noErr) { + BOOST_LOG(error) << "Failed to create IOProc with status: "sv << status; + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + + // Start the IOProc + BOOST_LOG(info) << "Starting IOProc for aggregate device"; + status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID); + BOOST_LOG(info) << "AudioDeviceStart returned status: "sv << status; + if (status != noErr) { + BOOST_LOG(error) << "Failed to start IOProc with status: "sv << status; + AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID); + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + + // Initialize buffer and signal + self.samplesArrivedSignal = [[NSCondition alloc] init]; + TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); + + [uniqueUUID release]; + [tapDescription release]; + + BOOST_LOG(info) << "System tap setup completed successfully!"; + return 0; +} + +- (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice + inNow:(const AudioTimeStamp *)inNow + inInputData:(const AudioBufferList *)inInputData + inInputTime:(const AudioTimeStamp *)inInputTime + outOutputData:(AudioBufferList *)outOutputData + inOutputTime:(const AudioTimeStamp *)inOutputTime + clientChannels:(UInt32)clientChannels + clientFrameSize:(UInt32)clientFrameSize + clientSampleRate:(UInt32)clientSampleRate { + // Always ensure we write to buffer and signal, even if input is empty/invalid + BOOL didWriteData = NO; + + if (inInputData && inInputData->mNumberBuffers > 0) { + AudioBuffer inputBuffer = inInputData->mBuffers[0]; + + if (inputBuffer.mData && inputBuffer.mDataByteSize > 0) { + float *inputSamples = (float *) inputBuffer.mData; + UInt32 inputFrames = inputBuffer.mDataByteSize / (2 * sizeof(float)); // System tap is always stereo + + // Use AudioConverter if we need any conversion, otherwise pass through + if (self->ioProcData && self->ioProcData->sampleRateConverter) { + // Let AudioConverter determine optimal output size - it knows best! + // We'll provide a generous buffer and let it tell us what it actually used + UInt32 maxOutputFrames = inputFrames * 4; // Very generous for any upsampling scenario + UInt32 outputBytes = maxOutputFrames * clientChannels * sizeof(float); + float *outputBuffer = (float *) malloc(outputBytes); + + if (outputBuffer) { + AudioConverterInputData inputData = { + .inputData = inputSamples, + .inputFrames = inputFrames, + .framesProvided = 0 + }; + + AudioBufferList outputBufferList = {0}; + outputBufferList.mNumberBuffers = 1; + outputBufferList.mBuffers[0].mNumberChannels = clientChannels; + outputBufferList.mBuffers[0].mDataByteSize = outputBytes; + outputBufferList.mBuffers[0].mData = outputBuffer; + + UInt32 outputFrameCount = maxOutputFrames; + OSStatus converterStatus = AudioConverterFillComplexBuffer( + self->ioProcData->sampleRateConverter, + audioConverterInputProc, + &inputData, + &outputFrameCount, + &outputBufferList, + NULL + ); + + if (converterStatus == noErr && outputFrameCount > 0) { + // AudioConverter did all the work: sample rate + channels + optimal frame count + UInt32 actualOutputBytes = outputFrameCount * clientChannels * sizeof(float); + TPCircularBufferProduceBytes(&self->audioSampleBuffer, outputBuffer, actualOutputBytes); + didWriteData = YES; + } else { + // Fallback: write original data + TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); + didWriteData = YES; + } + + free(outputBuffer); + } else { + // Memory allocation failed, fallback to original data + TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); + didWriteData = YES; + } + } else { + // No conversion needed - direct passthrough (48kHz stereo to 48kHz stereo) + TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); + didWriteData = YES; + } + } + } + + // Always signal, even if we didn't write data (ensures consumer doesn't block) + if (!didWriteData) { + // Write silence if no valid input data + UInt32 silenceFrames = clientFrameSize > 0 ? clientFrameSize : 2048; + UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float); + + float *silenceBuffer = (float *) calloc(silenceFrames * clientChannels, sizeof(float)); + if (silenceBuffer) { + TPCircularBufferProduceBytes(&self->audioSampleBuffer, silenceBuffer, silenceBytes); + free(silenceBuffer); + } + } + + [self.samplesArrivedSignal signal]; + + return noErr; +} + +// Generalized method for cleaning up system tap resources +- (void)cleanupSystemTapResources:(id)tapDescription { + // 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; + } + + if (self->aggregateDeviceID != kAudioObjectUnknown) { + AudioHardwareDestroyAggregateDevice(self->aggregateDeviceID); + self->aggregateDeviceID = kAudioObjectUnknown; + } + + if (self->tapObjectID != kAudioObjectUnknown) { + AudioHardwareDestroyProcessTap(self->tapObjectID); + self->tapObjectID = kAudioObjectUnknown; + } + + if (self->ioProcData) { + if (self->ioProcData->sampleRateConverter) { + AudioConverterDispose(self->ioProcData->sampleRateConverter); + self->ioProcData->sampleRateConverter = NULL; + } + free(self->ioProcData); + self->ioProcData = NULL; + } + + if (tapDescription) { + [tapDescription release]; + } +} + +- (void)dealloc { + // Cleanup system tap resources using the generalized method + [self cleanupSystemTapResources:nil]; + + // Cleanup microphone session (AVFoundation path) + if (self.audioCaptureSession) { + [self.audioCaptureSession stopRunning]; + self.audioCaptureSession = nil; + } + self.audioConnection = nil; + + // Signal any waiting threads before destroying the condition + if (self.samplesArrivedSignal) { + [self.samplesArrivedSignal signal]; + [self.samplesArrivedSignal release]; + self.samplesArrivedSignal = nil; + } + + // Cleanup circular buffer last (shared by both paths) + TPCircularBufferCleanup(&audioSampleBuffer); + + [super dealloc]; +} + +@end diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 06b9c19a899..4f6abfe5a36 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -51,6 +51,21 @@ int set_sink(const std::string &sink) override { std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) override { auto mic = std::make_unique(); + mic->av_audio_capture = [[AVAudio alloc] init]; + + // Check if macOS system-wide audio tap is enabled + if (config::audio.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; + 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; + } + BOOST_LOG(info) << "macOS system audio tap capturing."sv; + return mic; + } + + // Setup microphone approach const char *audio_sink = ""; if (!config::audio.sink.empty()) { @@ -68,7 +83,6 @@ int set_sink(const std::string &sink) override { return nullptr; } - mic->av_audio_capture = [[AVAudio alloc] init]; 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; diff --git a/src_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 9c1fb5e9dc7..4f8d158af77 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -166,6 +166,7 @@

{{ $t('config.configuration') }}

"audio_sink": "", "virtual_sink": "", "install_steam_audio_drivers": "enabled", + "macos_system_wide_audio_tap": "macOS false", "adapter_name": "", "output_name": "", "dd_configuration_option": "disabled", diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index 27f58365d70..b55b34b0307 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -23,7 +23,8 @@ const config = ref(props.config) + v-model="config.audio_sink" + :disabled="platform === 'macos' && (config.macos_system_wide_audio_tap === true || config.macos_system_wide_audio_tap === 'true')" />
{{ $tp('config.audio_sink_desc') }}
@@ -63,6 +64,18 @@ const config = ref(props.config) + + + + Security & Privacy -> Privacy -> Screen and System Audio Recording.", "max_bitrate": "Maximum Bitrate", "max_bitrate_desc": "The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.", "minimum_fps_target": "Minimum FPS Target", 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. From 74c2cf12ce1a944654ac24cd066fb42419f1df3c Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Thu, 28 Aug 2025 23:24:25 +0200 Subject: [PATCH 02/30] feat(macos): some unit tests --- src/platform/macos/av_audio.h | 5 + src/platform/macos/av_audio.mm | 42 ++-- tests/CMakeLists.txt | 3 +- tests/unit/platform/test_macos_av_audio.mm | 224 +++++++++++++++++++++ 4 files changed, 260 insertions(+), 14 deletions(-) create mode 100644 tests/unit/platform/test_macos_av_audio.mm diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index d173f0ca25a..eed138b1b31 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -46,6 +46,11 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; + +// Buffer management methods for testing and internal use +- (void)initializeAudioBuffer:(UInt8)channels; +- (void)cleanupAudioBuffer; + - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index cae618962d6..b8632fec3da 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -147,8 +147,8 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f [audioInput release]; [audioOutput release]; - self.samplesArrivedSignal = [[NSCondition alloc] init]; - TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); + // Initialize buffer and signal + [self initializeAudioBuffer:channels]; return 0; } @@ -350,8 +350,7 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U } // Initialize buffer and signal - self.samplesArrivedSignal = [[NSCondition alloc] init]; - TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); + [self initializeAudioBuffer:channels]; [uniqueUUID release]; [tapDescription release]; @@ -486,6 +485,30 @@ - (void)cleanupSystemTapResources:(id)tapDescription { } } +#pragma mark - Buffer Management Methods + +- (void)initializeAudioBuffer:(UInt8)channels { + // Initialize the circular buffer with proper size for the channel count + TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); + + // Initialize the condition signal for synchronization + if (!self.samplesArrivedSignal) { + self.samplesArrivedSignal = [[NSCondition alloc] init]; + } +} + +- (void)cleanupAudioBuffer { + // Signal any waiting threads before cleanup + if (self.samplesArrivedSignal) { + [self.samplesArrivedSignal signal]; + [self.samplesArrivedSignal release]; + self.samplesArrivedSignal = nil; + } + + // Cleanup the circular buffer + TPCircularBufferCleanup(&self->audioSampleBuffer); +} + - (void)dealloc { // Cleanup system tap resources using the generalized method [self cleanupSystemTapResources:nil]; @@ -497,15 +520,8 @@ - (void)dealloc { } self.audioConnection = nil; - // Signal any waiting threads before destroying the condition - if (self.samplesArrivedSignal) { - [self.samplesArrivedSignal signal]; - [self.samplesArrivedSignal release]; - self.samplesArrivedSignal = nil; - } - - // Cleanup circular buffer last (shared by both paths) - TPCircularBufferCleanup(&audioSampleBuffer); + // Use our centralized buffer cleanup method (handles signal and buffer cleanup) + [self cleanupAudioBuffer]; [super dealloc]; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b15a53fdc59..5cab9e4c409 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,7 +40,8 @@ list(APPEND TEST_DEFINITIONS SUNSHINE_TESTS) file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/tests/*.h - ${CMAKE_SOURCE_DIR}/tests/*.cpp) + ${CMAKE_SOURCE_DIR}/tests/*.cpp + ${CMAKE_SOURCE_DIR}/tests/*.mm) 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..c0b9807369f --- /dev/null +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -0,0 +1,224 @@ +/** + * @file tests/unit/platform/test_macos_av_audio.mm + * @brief Test src/platform/macos/av_audio.*. + */ + +// Only compile these tests on macOS +#ifdef __APPLE__ + +#include "../../tests_common.h" + +#import +#import +#import + +// Include the header for the class we're testing +#import + +// C++ Test Cases using GoogleTest + +// Test parameters for processSystemAudioIOProc tests +struct ProcessSystemAudioIOProcTestParams { + UInt32 frameCount; + UInt32 channels; + UInt32 sampleRate; + bool useNilInput; + const char* testName; +}; + +// Make AVAudioTest itself parameterized for the processSystemAudioIOProc tests +class AVAudioTest : public PlatformTestSuite, public ::testing::WithParamInterface {}; + +TEST_F(AVAudioTest, MicrophoneNamesReturnsArray) { + NSArray* names = [AVAudio microphoneNames]; + + EXPECT_NE(names, nil); // Should always return an array, even if empty + EXPECT_TRUE([names isKindOfClass:[NSArray class]]); // Should be an NSArray +} + +TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) { + AVCaptureDevice* device = [AVAudio findMicrophone:nil]; + EXPECT_EQ(device, nil); +} + +TEST_F(AVAudioTest, FindMicrophoneWithEmptyNameReturnsNil) { + AVCaptureDevice* device = [AVAudio findMicrophone:@""]; + EXPECT_EQ(device, nil); // Should return nil for empty string +} + +TEST_F(AVAudioTest, FindMicrophoneWithInvalidNameReturnsNil) { + NSString* invalidName = @"NonExistentMicrophone123456789ABCDEF"; + AVCaptureDevice* device = [AVAudio findMicrophone:invalidName]; + EXPECT_EQ(device, nil); // Should return nil for non-existent device +} + +TEST_F(AVAudioTest, SetupMicrophoneWithNilDeviceReturnsError) { + AVAudio* avAudio = [[AVAudio alloc] init]; + int result = [avAudio setupMicrophone:nil sampleRate:48000 frameSize:512 channels:2]; + [avAudio release]; + EXPECT_EQ(result, -1); // Should fail with nil device +} + +TEST_F(AVAudioTest, SetupSystemTapWithZeroChannelsReturnsError) { + AVAudio* avAudio = [[AVAudio alloc] init]; + int result = [avAudio setupSystemTap:48000 frameSize:512 channels:0]; + [avAudio release]; + EXPECT_EQ(result, -1); // Should fail with zero channels +} + +TEST_F(AVAudioTest, AVAudioObjectCreationAndDestruction) { + AVAudio* avAudio = [[AVAudio alloc] init]; + EXPECT_NE(avAudio, nil); // Should create successfully + [avAudio release]; // Should not crash +} + +TEST_F(AVAudioTest, AVAudioMultipleObjectsCanBeCreated) { + 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]; +} + + +// Type alias for parameterized audio processing tests +using ProcessSystemAudioIOProcTest = AVAudioTest; + +// Test parameters - covering various audio configurations +INSTANTIATE_TEST_SUITE_P( + AVAudioTest, + ProcessSystemAudioIOProcTest, + ::testing::Values( + // Original test cases + ProcessSystemAudioIOProcTestParams{240, 2, 48000, false, "ValidStereo48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{240, 2, 48000, true, "NilInputHandlesGracefully"}, + ProcessSystemAudioIOProcTestParams{480, 2, 48000, false, "ValidStereo48kHz480Frames"}, + ProcessSystemAudioIOProcTestParams{512, 2, 44100, false, "ValidStereo44kHz512Frames"}, + + // Mono channel variants + ProcessSystemAudioIOProcTestParams{240, 1, 48000, false, "ValidMono48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{512, 1, 44100, false, "ValidMono44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{1024, 1, 96000, false, "ValidMono96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams{128, 1, 22050, false, "ValidMono22kHz128Frames"}, + + // Quad (4 channel) variants + ProcessSystemAudioIOProcTestParams{256, 4, 48000, false, "ValidQuad48kHz256Frames"}, + ProcessSystemAudioIOProcTestParams{512, 4, 44100, false, "ValidQuad44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{1024, 4, 96000, false, "ValidQuad96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams{128, 4, 22050, false, "ValidQuad22kHz128Frames"}, + + // 5.1 Surround (6 channel) variants + ProcessSystemAudioIOProcTestParams{240, 6, 48000, false, "Valid51Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{512, 6, 44100, false, "Valid51Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{1024, 6, 96000, false, "Valid51Surround96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams{256, 6, 88200, false, "Valid51Surround88kHz256Frames"}, + + // 7.1 Surround (8 channel) variants + ProcessSystemAudioIOProcTestParams{240, 8, 48000, false, "Valid71Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{512, 8, 44100, false, "Valid71Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{1024, 8, 96000, false, "Valid71Surround96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams{128, 8, 192000, false, "Valid71Surround192kHz128Frames"}, + + // Edge cases with various configurations + ProcessSystemAudioIOProcTestParams{64, 2, 8000, false, "ValidStereo8kHz64Frames"}, + ProcessSystemAudioIOProcTestParams{2048, 1, 48000, false, "ValidMono48kHz2048Frames"}, + ProcessSystemAudioIOProcTestParams{32, 4, 176400, false, "ValidQuad176kHz32Frames"}, + ProcessSystemAudioIOProcTestParams{128, 6, 44100, false, "Valid51Surround44kHz128Frames"} // Reduced from 4096 to fit buffer + ), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.testName); + } +); + +TEST_P(ProcessSystemAudioIOProcTest, ProcessSystemAudioIOProc) { + 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 test sine wave data (different frequency per channel) + for (UInt32 frame = 0; frame < params.frameCount; frame++) { + for (UInt32 channel = 0; channel < params.channels; channel++) { + // Generate different frequencies for each channel for testing + // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. + double frequency = 440.0 * (channel + 1); + testInputData[frame * params.channels + channel] = + (float)(sin(2.0 * M_PI * frequency * frame / (double)params.sampleRate) * 0.5); + } + } + + // 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); + + // Test the processSystemAudioIOProc method + OSStatus result = [avAudio processSystemAudioIOProc:0 // device ID (not used in our logic) + inNow:&timeStamp + inInputData:inputBufferList + inInputTime:&timeStamp + outOutputData:nil // not used in our implementation + inOutputTime:&timeStamp + clientChannels:params.channels + clientFrameSize:params.frameCount + clientSampleRate:params.sampleRate]; + + // 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) + if (bufferData && finalAvailableBytes >= sizeof(float) * std::min(4U, params.channels * 2)) { + float* outputSamples = (float*)bufferData; + for (UInt32 i = 0; i < std::min(4U, params.channels * 2); 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__ From 0c2e096f90aa8e73900e74e8435e26f9f895bd5d Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 00:06:40 +0200 Subject: [PATCH 03/30] wip(macos): converter creation now queries device to get accurate info --- src/platform/macos/av_audio.h | 23 +++- src/platform/macos/av_audio.mm | 221 ++++++++++++++++++++++----------- 2 files changed, 171 insertions(+), 73 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index eed138b1b31..dcbedcbafbf 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -18,13 +18,24 @@ // Forward declaration @class AVAudio; +// AudioConverter input callback data +struct AudioConverterInputData { + float *inputData; + UInt32 inputFrames; + UInt32 framesProvided; + UInt32 deviceChannels; + AVAudio *avAudio; // Reference to the AVAudio instance +}; + // IOProc client data structure typedef struct { AVAudio *avAudio; UInt32 clientRequestedChannels; UInt32 clientRequestedSampleRate; UInt32 clientRequestedFrameSize; - AudioConverterRef sampleRateConverter; + UInt32 aggregateDeviceSampleRate; + UInt32 aggregateDeviceChannels; + AudioConverterRef audioConverter; } AVAudioIOProcData; @interface AVAudio: NSObject { @@ -44,6 +55,9 @@ + (NSArray *)microphoneNames; + (AVCaptureDevice *)findMicrophone:(NSString *)name; ++ (NSArray *)microphoneNames; ++ (AVCaptureDevice *)findMicrophone:(NSString *)name; + - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; @@ -51,6 +65,13 @@ - (void)initializeAudioBuffer:(UInt8)channels; - (void)cleanupAudioBuffer; +// AudioConverter callback method +- (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter + ioNumberDataPackets:(UInt32 *)ioNumberDataPackets + ioData:(AudioBufferList *)ioData + outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription + inputInfo:(struct AudioConverterInputData *)inputInfo; + - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index b8632fec3da..271cb1028f9 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -8,33 +8,16 @@ #import #import - -// AudioConverter input callback -typedef struct { - float *inputData; - UInt32 inputFrames; - UInt32 framesProvided; -} AudioConverterInputData; - -OSStatus audioConverterInputProc(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { - AudioConverterInputData *inputInfo = (AudioConverterInputData *) inUserData; - - if (inputInfo->framesProvided >= inputInfo->inputFrames) { - *ioNumberDataPackets = 0; - return noErr; - } - - UInt32 framesToProvide = MIN(*ioNumberDataPackets, inputInfo->inputFrames - inputInfo->framesProvided); - - ioData->mNumberBuffers = 1; - ioData->mBuffers[0].mNumberChannels = 2; // Source is always stereo - ioData->mBuffers[0].mDataByteSize = framesToProvide * 2 * sizeof(float); - ioData->mBuffers[0].mData = inputInfo->inputData + (inputInfo->framesProvided * 2); - - inputInfo->framesProvided += framesToProvide; - *ioNumberDataPackets = framesToProvide; - - return noErr; +// C wrapper for AudioConverter input callback +static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { + struct AudioConverterInputData *inputInfo = (struct AudioConverterInputData *) inUserData; + AVAudio *avAudio = inputInfo->avAudio; + + return [avAudio audioConverterComplexInputProc:inAudioConverter + ioNumberDataPackets:ioNumberDataPackets + ioData:ioData + outDataPacketDescription:outDataPacketDescription + inputInfo:inputInfo]; } // C wrapper for IOProc callback @@ -200,41 +183,7 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U self->ioProcData->clientRequestedChannels = channels; self->ioProcData->clientRequestedFrameSize = frameSize; self->ioProcData->clientRequestedSampleRate = sampleRate; - self->ioProcData->sampleRateConverter = NULL; - - // Create AudioConverter for sample rate and/or channel conversion if needed - BOOL needsConversion = (sampleRate != 48000) || (channels != 2); // System tap is always 48kHz stereo - BOOST_LOG(info) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") << " (sampleRate="sv << sampleRate << ", channels="sv << (int) channels << ")"sv; - if (needsConversion) { - AudioStreamBasicDescription sourceFormat = {0}; - sourceFormat.mSampleRate = 48000.0; // System tap is always 48kHz - sourceFormat.mFormatID = kAudioFormatLinearPCM; - sourceFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; - sourceFormat.mBytesPerPacket = sizeof(float) * 2; // Stereo - sourceFormat.mFramesPerPacket = 1; - sourceFormat.mBytesPerFrame = sizeof(float) * 2; - sourceFormat.mChannelsPerFrame = 2; - sourceFormat.mBitsPerChannel = 32; - - AudioStreamBasicDescription targetFormat = {0}; - targetFormat.mSampleRate = sampleRate; - targetFormat.mFormatID = kAudioFormatLinearPCM; - targetFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; - targetFormat.mBytesPerPacket = sizeof(float) * channels; - targetFormat.mFramesPerPacket = 1; - targetFormat.mBytesPerFrame = sizeof(float) * channels; - targetFormat.mChannelsPerFrame = channels; - targetFormat.mBitsPerChannel = 32; - - OSStatus converterStatus = AudioConverterNew(&sourceFormat, &targetFormat, &self->ioProcData->sampleRateConverter); - if (converterStatus != noErr) { - BOOST_LOG(error) << "Failed to create audio converter: "sv << converterStatus; - free(self->ioProcData); - self->ioProcData = NULL; - return -1; - } - BOOST_LOG(info) << "AudioConverter created successfully"sv; - } + self->ioProcData->audioConverter = NULL; // 1. Create tap description BOOST_LOG(info) << "Creating tap description for "sv << (int) channels << " channels"sv; @@ -328,6 +277,92 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U AudioObjectSetPropertyData(self->aggregateDeviceID, &bufferSizeAddr, 0, NULL, frameSizeSize, &deviceFrameSize); } + // 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(info) << "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(info) << "Device properties - Sample Rate: "sv << aggregateDeviceSampleRate << "Hz, Channels: "sv << aggregateDeviceChannels; + + // Create AudioConverter based on actual device properties vs client requirements + BOOL needsConversion = ((UInt32)aggregateDeviceSampleRate != sampleRate) || (aggregateDeviceChannels != channels); + BOOST_LOG(info) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") + << " (device: "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch" + << " -> client: "sv << sampleRate << "Hz/" << (int)channels << "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 = sampleRate; + targetFormat.mFormatID = kAudioFormatLinearPCM; + targetFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; + targetFormat.mBytesPerPacket = sizeof(float) * channels; + targetFormat.mFramesPerPacket = 1; + targetFormat.mBytesPerFrame = sizeof(float) * channels; + targetFormat.mChannelsPerFrame = channels; + targetFormat.mBitsPerChannel = 32; + + OSStatus converterStatus = AudioConverterNew(&sourceFormat, &targetFormat, &self->ioProcData->audioConverter); + if (converterStatus != noErr) { + BOOST_LOG(error) << "Failed to create audio converter: "sv << converterStatus; + [self cleanupSystemTapResources:tapDescription]; + return -1; + } + BOOST_LOG(info) << "AudioConverter created successfully for "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch -> " << sampleRate << "Hz/" << (int)channels << "ch"sv; + } + + // Store the actual device format for use in the IOProc + self->ioProcData->aggregateDeviceSampleRate = (UInt32)aggregateDeviceSampleRate; + self->ioProcData->aggregateDeviceChannels = aggregateDeviceChannels; + // 3. Configure IOProc BOOST_LOG(info) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); @@ -376,10 +411,11 @@ - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice if (inputBuffer.mData && inputBuffer.mDataByteSize > 0) { float *inputSamples = (float *) inputBuffer.mData; - UInt32 inputFrames = inputBuffer.mDataByteSize / (2 * sizeof(float)); // System tap is always stereo + UInt32 deviceChannels = self->ioProcData ? self->ioProcData->aggregateDeviceChannels : 2; + UInt32 inputFrames = inputBuffer.mDataByteSize / (deviceChannels * sizeof(float)); // Use AudioConverter if we need any conversion, otherwise pass through - if (self->ioProcData && self->ioProcData->sampleRateConverter) { + if (self->ioProcData && self->ioProcData->audioConverter) { // Let AudioConverter determine optimal output size - it knows best! // We'll provide a generous buffer and let it tell us what it actually used UInt32 maxOutputFrames = inputFrames * 4; // Very generous for any upsampling scenario @@ -387,10 +423,12 @@ - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice float *outputBuffer = (float *) malloc(outputBytes); if (outputBuffer) { - AudioConverterInputData inputData = { + struct AudioConverterInputData inputData = { .inputData = inputSamples, .inputFrames = inputFrames, - .framesProvided = 0 + .framesProvided = 0, + .deviceChannels = deviceChannels, + .avAudio = self }; AudioBufferList outputBufferList = {0}; @@ -401,8 +439,8 @@ - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice UInt32 outputFrameCount = maxOutputFrames; OSStatus converterStatus = AudioConverterFillComplexBuffer( - self->ioProcData->sampleRateConverter, - audioConverterInputProc, + self->ioProcData->audioConverter, + audioConverterComplexInputProcWrapper, &inputData, &outputFrameCount, &outputBufferList, @@ -427,7 +465,7 @@ - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice didWriteData = YES; } } else { - // No conversion needed - direct passthrough (48kHz stereo to 48kHz stereo) + // No conversion needed - direct passthrough TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); didWriteData = YES; } @@ -452,6 +490,45 @@ - (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice return noErr; } +// AudioConverter input callback as Objective-C method +- (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter + ioNumberDataPackets:(UInt32 *)ioNumberDataPackets + ioData:(AudioBufferList *)ioData + outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription + inputInfo:(struct AudioConverterInputData *)inputInfo { + if (inputInfo->framesProvided >= inputInfo->inputFrames) { + *ioNumberDataPackets = 0; + return noErr; + } + + UInt32 framesToProvide = MIN(*ioNumberDataPackets, inputInfo->inputFrames - inputInfo->framesProvided); + + 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); + + inputInfo->framesProvided += framesToProvide; + *ioNumberDataPackets = framesToProvide; + + return noErr; +} + +// Helper method to get device properties +- (OSStatus)getDeviceProperty:(AudioObjectID)deviceID + selector:(AudioObjectPropertySelector)selector + scope:(AudioObjectPropertyScope)scope + element:(AudioObjectPropertyElement)element + size:(UInt32 *)ioDataSize + data:(void *)outData { + AudioObjectPropertyAddress addr = { + .mSelector = selector, + .mScope = scope, + .mElement = element + }; + return AudioObjectGetPropertyData(deviceID, &addr, 0, NULL, ioDataSize, outData); +} + // Generalized method for cleaning up system tap resources - (void)cleanupSystemTapResources:(id)tapDescription { // Clean up in reverse order of creation @@ -472,9 +549,9 @@ - (void)cleanupSystemTapResources:(id)tapDescription { } if (self->ioProcData) { - if (self->ioProcData->sampleRateConverter) { - AudioConverterDispose(self->ioProcData->sampleRateConverter); - self->ioProcData->sampleRateConverter = NULL; + if (self->ioProcData->audioConverter) { + AudioConverterDispose(self->ioProcData->audioConverter); + self->ioProcData->audioConverter = NULL; } free(self->ioProcData); self->ioProcData = NULL; From 48ef28d10f698054eeb303834ab2578c801710ff Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 01:12:36 +0200 Subject: [PATCH 04/30] wip(macos): refactored setupSystemTap and split into methods --- src/platform/macos/av_audio.h | 10 +- src/platform/macos/av_audio.mm | 496 ++++++++++++--------- tests/unit/platform/test_macos_av_audio.mm | 2 +- 3 files changed, 289 insertions(+), 219 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index dcbedcbafbf..a5086f054c3 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -15,8 +15,9 @@ #define kBufferLength 4096 -// Forward declaration +// Forward declarations @class AVAudio; +@class CATapDescription; // AudioConverter input callback data struct AudioConverterInputData { @@ -65,14 +66,15 @@ struct AudioConverterInputData { - (void)initializeAudioBuffer:(UInt8)channels; - (void)cleanupAudioBuffer; -// AudioConverter callback method +- (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +- (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels; +- (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize; - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter ioNumberDataPackets:(UInt32 *)ioNumberDataPackets ioData:(AudioBufferList *)ioData outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription inputInfo:(struct AudioConverterInputData *)inputInfo; - -- (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice +- (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData inInputTime:(const AudioTimeStamp *)inInputTime diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 271cb1028f9..ab28976913e 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -24,7 +24,7 @@ static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioC static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) { AVAudioIOProcData *procData = (AVAudioIOProcData *) inClientData; AVAudio *avAudio = procData->avAudio; - return [avAudio processSystemAudioIOProc:inDevice + return [avAudio systemAudioIOProc:inDevice inNow:inNow inInputData:inInputData inInputTime:inInputTime @@ -160,241 +160,48 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U using namespace std::literals; BOOST_LOG(info) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; - // Check macOS version requirement - if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 2, 0})]) { - BOOST_LOG(error) << "macOS version requirement not met (need 14.2+)"sv; + // Initialize system tap components + if ([self initSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { return -1; } - NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; - BOOST_LOG(info) << "macOS version check passed (running "sv << version.majorVersion << "."sv << version.minorVersion << "."sv << version.patchVersion << ")"sv; - - // Initialize - 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) { + // 1. Create tap description and process tap + CATapDescription *tapDescription = [self createSystemTapDescriptionForChannels:channels]; + if (!tapDescription) { + [self cleanupSystemTapContext:nil]; return -1; } - self->ioProcData->avAudio = self; - self->ioProcData->clientRequestedChannels = channels; - self->ioProcData->clientRequestedFrameSize = frameSize; - self->ioProcData->clientRequestedSampleRate = sampleRate; - self->ioProcData->audioConverter = NULL; - // 1. Create tap description - BOOST_LOG(info) << "Creating tap description for "sv << (int) channels << " channels"sv; - CATapDescription *tapDescription; - NSArray *excludeProcesses = @[]; - - if (channels == 1) { - tapDescription = [[CATapDescription alloc] initMonoGlobalTapButExcludeProcesses:excludeProcesses]; - } else { - 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]; - - // Create the tap - BOOST_LOG(info) << "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(info) << "AudioHardwareCreateProcessTap returned status: "sv << status; - - if (status != noErr) { - BOOST_LOG(error) << "AudioHardwareCreateProcessTap failed with status: "sv << status << " (tapDescription: "sv << [tapDescription description] << ")"sv; - [self cleanupSystemTapResources:tapDescription]; + // 2. Create and configure aggregate device + if ([self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize] != 0) { + [self cleanupSystemTapContext:tapDescription]; return -1; } - // 2. Create aggregate device - // 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; - [self cleanupSystemTapResources:tapDescription]; + // 3. Configure device properties and AudioConverter + OSStatus configureStatus = [self configureDevicePropertiesAndConverter:sampleRate clientChannels:channels]; + if (configureStatus != noErr) { + [self cleanupSystemTapContext:tapDescription]; return -1; } - // 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(info) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; - status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID); - BOOST_LOG(info) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; - if (status != noErr && status != 'ExtA') { - BOOST_LOG(error) << "Failed to create aggregate device with status: "sv << status; - [self cleanupSystemTapResources:tapDescription]; - return -1; - } - - // 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); - AudioObjectSetPropertyData(self->aggregateDeviceID, &sampleRateAddr, 0, NULL, sampleRateSize, &deviceSampleRate); - - // Set buffer size on the aggregate device - AudioObjectPropertyAddress bufferSizeAddr = { - .mSelector = kAudioDevicePropertyBufferFrameSize, - .mScope = kAudioObjectPropertyScopeGlobal, - .mElement = kAudioObjectPropertyElementMain - }; - UInt32 deviceFrameSize = frameSize; - UInt32 frameSizeSize = sizeof(UInt32); - AudioObjectSetPropertyData(self->aggregateDeviceID, &bufferSizeAddr, 0, NULL, frameSizeSize, &deviceFrameSize); - } - - // 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(info) << "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(info) << "Device properties - Sample Rate: "sv << aggregateDeviceSampleRate << "Hz, Channels: "sv << aggregateDeviceChannels; - - // Create AudioConverter based on actual device properties vs client requirements - BOOL needsConversion = ((UInt32)aggregateDeviceSampleRate != sampleRate) || (aggregateDeviceChannels != channels); - BOOST_LOG(info) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") - << " (device: "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch" - << " -> client: "sv << sampleRate << "Hz/" << (int)channels << "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 = sampleRate; - targetFormat.mFormatID = kAudioFormatLinearPCM; - targetFormat.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; - targetFormat.mBytesPerPacket = sizeof(float) * channels; - targetFormat.mFramesPerPacket = 1; - targetFormat.mBytesPerFrame = sizeof(float) * channels; - targetFormat.mChannelsPerFrame = channels; - targetFormat.mBitsPerChannel = 32; - - OSStatus converterStatus = AudioConverterNew(&sourceFormat, &targetFormat, &self->ioProcData->audioConverter); - if (converterStatus != noErr) { - BOOST_LOG(error) << "Failed to create audio converter: "sv << converterStatus; - [self cleanupSystemTapResources:tapDescription]; - return -1; - } - BOOST_LOG(info) << "AudioConverter created successfully for "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch -> " << sampleRate << "Hz/" << (int)channels << "ch"sv; - } - - // Store the actual device format for use in the IOProc - self->ioProcData->aggregateDeviceSampleRate = (UInt32)aggregateDeviceSampleRate; - self->ioProcData->aggregateDeviceChannels = aggregateDeviceChannels; - - // 3. Configure IOProc - BOOST_LOG(info) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; - status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); - BOOST_LOG(info) << "AudioDeviceCreateIOProcID returned status: "sv << status; - if (status != noErr) { - BOOST_LOG(error) << "Failed to create IOProc with status: "sv << status; - [self cleanupSystemTapResources:tapDescription]; - return -1; - } - - // Start the IOProc - BOOST_LOG(info) << "Starting IOProc for aggregate device"; - status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID); - BOOST_LOG(info) << "AudioDeviceStart returned status: "sv << status; - if (status != noErr) { - BOOST_LOG(error) << "Failed to start IOProc with status: "sv << status; - AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID); - [self cleanupSystemTapResources:tapDescription]; + // 4. Create and start IOProc + OSStatus ioProcStatus = [self createAndStartIOProc:tapDescription]; + if (ioProcStatus != noErr) { + [self cleanupSystemTapContext:tapDescription]; return -1; } // Initialize buffer and signal [self initializeAudioBuffer:channels]; - [uniqueUUID release]; [tapDescription release]; BOOST_LOG(info) << "System tap setup completed successfully!"; return 0; } -- (OSStatus)processSystemAudioIOProc:(AudioObjectID)inDevice +- (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData inInputTime:(const AudioTimeStamp *)inInputTime @@ -530,7 +337,7 @@ - (OSStatus)getDeviceProperty:(AudioObjectID)deviceID } // Generalized method for cleaning up system tap resources -- (void)cleanupSystemTapResources:(id)tapDescription { +- (void)cleanupSystemTapContext:(id)tapDescription { // Clean up in reverse order of creation if (self->ioProcID && self->aggregateDeviceID != kAudioObjectUnknown) { AudioDeviceStop(self->aggregateDeviceID, self->ioProcID); @@ -588,7 +395,7 @@ - (void)cleanupAudioBuffer { - (void)dealloc { // Cleanup system tap resources using the generalized method - [self cleanupSystemTapResources:nil]; + [self cleanupSystemTapContext:nil]; // Cleanup microphone session (AVFoundation path) if (self.audioCaptureSession) { @@ -603,4 +410,265 @@ - (void)dealloc { [super dealloc]; } +#pragma mark - System Tap Initialization + +- (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { + using namespace std::literals; + + // Check macOS version requirement + if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 2, 0})]) { + BOOST_LOG(error) << "macOS version requirement not met (need 14.2+)"sv; + return -1; + } + + NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; + BOOST_LOG(info) << "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; + + BOOST_LOG(info) << "System tap initialization completed"sv; + return 0; +} + +- (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { + using namespace std::literals; + + BOOST_LOG(info) << "Creating tap description for "sv << (int) channels << " channels"sv; + CATapDescription *tapDescription; + NSArray *excludeProcesses = @[]; + + if (channels == 1) { + tapDescription = [[CATapDescription alloc] initMonoGlobalTapButExcludeProcesses:excludeProcesses]; + } else { + 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]; + + // Create the tap + BOOST_LOG(info) << "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(info) << "AudioHardwareCreateProcessTap returned status: "sv << status; + + [uniqueUUID release]; + + if (status != noErr) { + BOOST_LOG(error) << "AudioHardwareCreateProcessTap failed with status: "sv << status << " (tapDescription: "sv << [tapDescription description] << ")"sv; + [tapDescription release]; + return nil; + } + + return tapDescription; +} + +- (int)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 -1; + } + + // 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(info) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; + OSStatus status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID); + BOOST_LOG(info) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; + if (status != noErr && status != 'ExtA') { + BOOST_LOG(error) << "Failed to create aggregate device with status: "sv << status; + return -1; + } + + // 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; + } + + // 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; + } + } + + BOOST_LOG(info) << "Aggregate device created and configured successfully"sv; + return 0; +} + +- (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(info) << "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(info) << "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(info) << "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; + } + + // Store the actual device format for use in the IOProc + self->ioProcData->aggregateDeviceSampleRate = (UInt32)aggregateDeviceSampleRate; + self->ioProcData->aggregateDeviceChannels = aggregateDeviceChannels; + + return noErr; +} + +- (OSStatus)createAndStartIOProc:(CATapDescription *)tapDescription { + using namespace std::literals; + + // Create IOProc + BOOST_LOG(info) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; + OSStatus status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); + BOOST_LOG(info) << "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(info) << "Starting IOProc for aggregate device"; + status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID); + BOOST_LOG(info) << "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; + } + + return noErr; +} @end diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index c0b9807369f..193ed1dbe2b 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -180,7 +180,7 @@ TPCircularBufferTail(&avAudio->audioSampleBuffer, &initialAvailableBytes); // Test the processSystemAudioIOProc method - OSStatus result = [avAudio processSystemAudioIOProc:0 // device ID (not used in our logic) + OSStatus result = [avAudio systemAudioIOProc:0 // device ID (not used in our logic) inNow:&timeStamp inInputData:inputBufferList inInputTime:&timeStamp From 05c76c13b02676200241974b157c986cf56ce079 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 01:30:47 +0200 Subject: [PATCH 05/30] wip(macos): more unit tests to cover refactored system tap methods --- tests/unit/platform/test_macos_av_audio.mm | 233 ++++++++++++++++++++- 1 file changed, 223 insertions(+), 10 deletions(-) diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 193ed1dbe2b..28bf4b35602 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -15,8 +15,6 @@ // Include the header for the class we're testing #import -// C++ Test Cases using GoogleTest - // Test parameters for processSystemAudioIOProc tests struct ProcessSystemAudioIOProcTestParams { UInt32 frameCount; @@ -84,6 +82,219 @@ [avAudio2 release]; } +// Test for initializeAudioBuffer method +TEST_F(AVAudioTest, InitializeAudioBufferSucceeds) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + // Test with various channel counts + [avAudio initializeAudioBuffer:1]; // Mono + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + + [avAudio initializeAudioBuffer:2]; // Stereo + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + + [avAudio initializeAudioBuffer:8]; // 7.1 Surround + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + + [avAudio release]; +} + +// Test for cleanupAudioBuffer method +TEST_F(AVAudioTest, CleanupAudioBufferHandlesNilSignal) { + 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.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + EXPECT_EQ(avAudio.samplesArrivedSignal, nil); + + [avAudio release]; +} + +// Test for initSystemTapContext method +TEST_F(AVAudioTest, InitSystemTapContextWithValidParameters) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + int result = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + + // On systems with macOS 14.2+, this should succeed + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + EXPECT_EQ(result, 0); + } else { + // On older systems, should fail gracefully + EXPECT_EQ(result, -1); + } + + [avAudio release]; +} + +// Test for initSystemTapContext with edge case parameters +TEST_F(AVAudioTest, InitSystemTapContextWithEdgeCases) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Test with minimum values + int result1 = [avAudio initSystemTapContext:8000 frameSize:64 channels:1]; + EXPECT_EQ(result1, 0); + + // Test with maximum reasonable values + int result2 = [avAudio initSystemTapContext:192000 frameSize:4096 channels:8]; + EXPECT_EQ(result2, 0); + } + + [avAudio release]; +} + +// Test for createSystemTapDescriptionForChannels method +TEST_F(AVAudioTest, CreateSystemTapDescriptionForChannels) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Initialize context first + int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + EXPECT_EQ(initResult, 0); + + // Test mono tap description + CATapDescription* monoTap = [avAudio createSystemTapDescriptionForChannels:1]; + if (monoTap) { + EXPECT_NE(monoTap, nil); + // Note: Can't test properties due to forward declaration limitations + [monoTap release]; + } + + // Test stereo tap description + CATapDescription* stereoTap = [avAudio createSystemTapDescriptionForChannels:2]; + if (stereoTap) { + EXPECT_NE(stereoTap, nil); + // Note: Can't test properties due to forward declaration limitations + [stereoTap release]; + } + } + + [avAudio release]; +} + +// Test for audioConverterComplexInputProc method +TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesValidData) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + // Create test input data + UInt32 frameCount = 256; + UInt32 channels = 2; + UInt32 sampleRate = 48000; + float* testData = (float*)calloc(frameCount * channels, sizeof(float)); + + // Fill with test sine wave data (different frequency per channel) - same as parameterized test + for (UInt32 frame = 0; frame < frameCount; frame++) { + for (UInt32 channel = 0; channel < channels; channel++) { + // Generate different frequencies for each channel for testing + // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. + double frequency = 440.0 * (channel + 1); + testData[frame * channels + channel] = + (float)(sin(2.0 * M_PI * frequency * frame / (double)sampleRate) * 0.5); + } + } + + struct AudioConverterInputData inputInfo = { + .inputData = testData, + .inputFrames = frameCount, + .framesProvided = 0, + .deviceChannels = channels, + .avAudio = avAudio + }; + + // Test the method + UInt32 requestedPackets = 128; + AudioBufferList bufferList = {0}; + OSStatus result = [avAudio audioConverterComplexInputProc:nil + ioNumberDataPackets:&requestedPackets + ioData:&bufferList + outDataPacketDescription:nil + inputInfo:&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]; +} + +// Test for audioConverterComplexInputProc with no more data +TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesNoMoreData) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + UInt32 frameCount = 256; + UInt32 channels = 2; + float* testData = (float*)calloc(frameCount * channels, sizeof(float)); + + struct AudioConverterInputData inputInfo = { + .inputData = testData, + .inputFrames = frameCount, + .framesProvided = frameCount, // Already provided all frames + .deviceChannels = channels, + .avAudio = avAudio + }; + + UInt32 requestedPackets = 128; + AudioBufferList bufferList = {0}; + OSStatus result = [avAudio audioConverterComplexInputProc:nil + ioNumberDataPackets:&requestedPackets + ioData:&bufferList + outDataPacketDescription:nil + inputInfo:&inputInfo]; + + EXPECT_EQ(result, noErr); + EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data + + free(testData); + [avAudio release]; +} + +// Test for cleanupAudioBuffer handling multiple calls +TEST_F(AVAudioTest, CleanupAudioBufferMultipleCalls) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + [avAudio initializeAudioBuffer:2]; + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + + // Multiple cleanup calls should not crash + [avAudio cleanupAudioBuffer]; + EXPECT_EQ(avAudio.samplesArrivedSignal, nil); + + [avAudio cleanupAudioBuffer]; // Second call should be safe + [avAudio cleanupAudioBuffer]; // Third call should be safe + + [avAudio release]; +} + +// Test for buffer management edge cases +TEST_F(AVAudioTest, BufferManagementEdgeCases) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + // Test with minimum reasonable channel count (1 channel) + [avAudio initializeAudioBuffer:1]; + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + + // Test with very high channel count + [avAudio initializeAudioBuffer:32]; + EXPECT_NE(avAudio.samplesArrivedSignal, nil); + [avAudio cleanupAudioBuffer]; + + [avAudio release]; +} + // Type alias for parameterized audio processing tests using ProcessSystemAudioIOProcTest = AVAudioTest; @@ -93,18 +304,17 @@ AVAudioTest, ProcessSystemAudioIOProcTest, ::testing::Values( - // Original test cases - ProcessSystemAudioIOProcTestParams{240, 2, 48000, false, "ValidStereo48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{240, 2, 48000, true, "NilInputHandlesGracefully"}, - ProcessSystemAudioIOProcTestParams{480, 2, 48000, false, "ValidStereo48kHz480Frames"}, - ProcessSystemAudioIOProcTestParams{512, 2, 44100, false, "ValidStereo44kHz512Frames"}, - // Mono channel variants ProcessSystemAudioIOProcTestParams{240, 1, 48000, false, "ValidMono48kHz240Frames"}, ProcessSystemAudioIOProcTestParams{512, 1, 44100, false, "ValidMono44kHz512Frames"}, ProcessSystemAudioIOProcTestParams{1024, 1, 96000, false, "ValidMono96kHz1024Frames"}, ProcessSystemAudioIOProcTestParams{128, 1, 22050, false, "ValidMono22kHz128Frames"}, + // Stereo channel variants + ProcessSystemAudioIOProcTestParams{240, 2, 48000, false, "ValidStereo48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{480, 2, 48000, false, "ValidStereo48kHz480Frames"}, + ProcessSystemAudioIOProcTestParams{512, 2, 44100, false, "ValidStereo44kHz512Frames"}, + // Quad (4 channel) variants ProcessSystemAudioIOProcTestParams{256, 4, 48000, false, "ValidQuad48kHz256Frames"}, ProcessSystemAudioIOProcTestParams{512, 4, 44100, false, "ValidQuad44kHz512Frames"}, @@ -124,6 +334,7 @@ ProcessSystemAudioIOProcTestParams{128, 8, 192000, false, "Valid71Surround192kHz128Frames"}, // Edge cases with various configurations + ProcessSystemAudioIOProcTestParams{240, 2, 48000, true, "NilInputHandlesGracefully"}, ProcessSystemAudioIOProcTestParams{64, 2, 8000, false, "ValidStereo8kHz64Frames"}, ProcessSystemAudioIOProcTestParams{2048, 1, 48000, false, "ValidMono48kHz2048Frames"}, ProcessSystemAudioIOProcTestParams{32, 4, 176400, false, "ValidQuad176kHz32Frames"}, @@ -204,9 +415,11 @@ EXPECT_EQ(finalAvailableBytes, inputDataSize); // Verify the actual audio data matches what we put in (first few samples) - if (bufferData && finalAvailableBytes >= sizeof(float) * std::min(4U, params.channels * 2)) { + // Test up to 16 samples or 4 complete frames, whichever is smaller + UInt32 samplesToTest = std::min(16U, params.channels * 4); // Up to 4 frames worth + if (bufferData && finalAvailableBytes >= sizeof(float) * samplesToTest) { float* outputSamples = (float*)bufferData; - for (UInt32 i = 0; i < std::min(4U, params.channels * 2); i++) { + for (UInt32 i = 0; i < samplesToTest; i++) { EXPECT_FLOAT_EQ(outputSamples[i], testInputData[i]) << "Sample " << i << " mismatch"; } } From d594c0699e531e748f42f1421e815f95d1e955fb Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 11:47:24 +0200 Subject: [PATCH 06/30] wip(macos): nullability and cleanup return types --- src/platform/macos/av_audio.h | 42 +++++++++++----------- src/platform/macos/av_audio.mm | 21 ++++++----- tests/unit/platform/test_macos_av_audio.mm | 12 +++++++ 3 files changed, 45 insertions(+), 30 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index a5086f054c3..0f1f2c4b210 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -29,15 +29,15 @@ struct AudioConverterInputData { }; // IOProc client data structure - typedef struct { - AVAudio *avAudio; - UInt32 clientRequestedChannels; - UInt32 clientRequestedSampleRate; - UInt32 clientRequestedFrameSize; - UInt32 aggregateDeviceSampleRate; - UInt32 aggregateDeviceChannels; - AudioConverterRef audioConverter; - } AVAudioIOProcData; +typedef struct { + AVAudio *avAudio; + UInt32 clientRequestedChannels; + UInt32 clientRequestedSampleRate; + UInt32 clientRequestedFrameSize; + UInt32 aggregateDeviceSampleRate; + UInt32 aggregateDeviceChannels; + AudioConverterRef _Nullable audioConverter; +} AVAudioIOProcData; @interface AVAudio: NSObject { @public @@ -46,18 +46,16 @@ struct AudioConverterInputData { AudioObjectID tapObjectID; AudioObjectID aggregateDeviceID; AudioDeviceIOProcID ioProcID; - AVAudioIOProcData *ioProcData; + AVAudioIOProcData * _Nullable ioProcData; } -@property (nonatomic, assign) AVCaptureSession *audioCaptureSession; -@property (nonatomic, assign) AVCaptureConnection *audioConnection; -@property (nonatomic, assign) NSCondition *samplesArrivedSignal; +@property (nonatomic, assign, nullable) AVCaptureSession *audioCaptureSession; +@property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; +@property (nonatomic, assign, nullable) NSCondition *samplesArrivedSignal; -+ (NSArray *)microphoneNames; -+ (AVCaptureDevice *)findMicrophone:(NSString *)name; - -+ (NSArray *)microphoneNames; -+ (AVCaptureDevice *)findMicrophone:(NSString *)name; ++ (NSArray *)microphones; ++ (NSArray *)microphoneNames; ++ (nullable AVCaptureDevice *)findMicrophone:(NSString *)name; - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; @@ -67,18 +65,18 @@ struct AudioConverterInputData { - (void)cleanupAudioBuffer; - (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; -- (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels; -- (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize; +- (nullable CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels; +- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize; - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter ioNumberDataPackets:(UInt32 *)ioNumberDataPackets ioData:(AudioBufferList *)ioData - outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription + outDataPacketDescription:(AudioStreamPacketDescription * _Nullable * _Nullable)outDataPacketDescription inputInfo:(struct AudioConverterInputData *)inputInfo; - (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData inInputTime:(const AudioTimeStamp *)inInputTime - outOutputData:(AudioBufferList *)outOutputData + outOutputData:(nullable AudioBufferList *)outOutputData inOutputTime:(const AudioTimeStamp *)inOutputTime clientChannels:(UInt32)clientChannels clientFrameSize:(UInt32)clientFrameSize diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index ab28976913e..f50358b7f30 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -173,7 +173,8 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U } // 2. Create and configure aggregate device - if ([self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize] != 0) { + OSStatus aggregateStatus = [self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize]; + if (aggregateStatus != noErr) { [self cleanupSystemTapContext:tapDescription]; return -1; } @@ -372,13 +373,17 @@ - (void)cleanupSystemTapContext:(id)tapDescription { #pragma mark - Buffer Management Methods - (void)initializeAudioBuffer:(UInt8)channels { + // 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 the condition signal for synchronization - if (!self.samplesArrivedSignal) { - self.samplesArrivedSignal = [[NSCondition alloc] init]; + // Initialize the condition signal for synchronization (cleanup any existing one first) + if (self.samplesArrivedSignal) { + [self.samplesArrivedSignal release]; } + self.samplesArrivedSignal = [[NSCondition alloc] init]; } - (void)cleanupAudioBuffer { @@ -485,7 +490,7 @@ - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { return tapDescription; } -- (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize { +- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize { using namespace std::literals; // Get Tap UUID string properly @@ -495,7 +500,7 @@ - (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescriptio } if (!tapUIDString) { BOOST_LOG(error) << "Failed to get tap UUID from description"sv; - return -1; + return kAudioHardwareUnspecifiedError; } // Create aggregate device with better drift compensation and proper keys @@ -519,7 +524,7 @@ - (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescriptio BOOST_LOG(info) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; if (status != noErr && status != 'ExtA') { BOOST_LOG(error) << "Failed to create aggregate device with status: "sv << status; - return -1; + return status; } // Configure the aggregate device @@ -552,7 +557,7 @@ - (int)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescriptio } BOOST_LOG(info) << "Aggregate device created and configured successfully"sv; - return 0; + return noErr; } - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 28bf4b35602..b4806814534 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -35,7 +35,10 @@ } TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) { +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnonnull" AVCaptureDevice* device = [AVAudio findMicrophone:nil]; +#pragma clang diagnostic pop EXPECT_EQ(device, nil); } @@ -52,7 +55,10 @@ 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 } @@ -216,11 +222,14 @@ // Test the method UInt32 requestedPackets = 128; AudioBufferList bufferList = {0}; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnonnull" OSStatus result = [avAudio audioConverterComplexInputProc:nil ioNumberDataPackets:&requestedPackets ioData:&bufferList outDataPacketDescription:nil inputInfo:&inputInfo]; +#pragma clang diagnostic pop EXPECT_EQ(result, noErr); EXPECT_EQ(requestedPackets, 128); // Should provide requested frames @@ -248,11 +257,14 @@ UInt32 requestedPackets = 128; AudioBufferList bufferList = {0}; +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wnonnull" OSStatus result = [avAudio audioConverterComplexInputProc:nil ioNumberDataPackets:&requestedPackets ioData:&bufferList outDataPacketDescription:nil inputInfo:&inputInfo]; +#pragma clang diagnostic pop EXPECT_EQ(result, noErr); EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data From 2b0ba99bf83bbd21a3f2d8846c8d72e6b3d8470d Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 12:02:24 +0200 Subject: [PATCH 07/30] wip(macos): NS_ASSUME_NONNULL_BEGIN should be included --- src/platform/macos/av_audio.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 0f1f2c4b210..0b6a59744bf 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -15,6 +15,8 @@ #define kBufferLength 4096 +NS_ASSUME_NONNULL_BEGIN + // Forward declarations @class AVAudio; @class CATapDescription; @@ -83,3 +85,5 @@ typedef struct { clientSampleRate:(UInt32)clientSampleRate; @end + +NS_ASSUME_NONNULL_END From 1dc8217bd70722b2a1457129a8b9166148e860db Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 12:17:00 +0200 Subject: [PATCH 08/30] wip(macos): added some info log statements --- src/platform/macos/av_audio.mm | 118 +++++++++++++++++++++++++++------ 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index f50358b7f30..517c7f8f6dd 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -38,7 +38,11 @@ static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTime @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. @@ -50,51 +54,70 @@ @implementation AVAudio AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeBuiltInMicrophone, AVCaptureDeviceTypeExternalUnknown] mediaType:AVMediaTypeAudio position:AVCaptureDevicePositionUnspecified]; - return discoverySession.devices; + 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" - return [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio]; + 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; + 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; + 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 *error; - AVCaptureDeviceInput *audioInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error]; + 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; } @@ -109,6 +132,7 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f (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); @@ -117,7 +141,9 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f 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; @@ -126,12 +152,14 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f 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; } @@ -158,7 +186,7 @@ - (void)captureOutput:(AVCaptureOutput *)output - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { using namespace std::literals; - BOOST_LOG(info) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; + BOOST_LOG(debug) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; // Initialize system tap components if ([self initSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { @@ -198,7 +226,7 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U [tapDescription release]; - BOOST_LOG(info) << "System tap setup completed successfully!"; + BOOST_LOG(info) << "System tap setup completed successfully!"sv; return 0; } @@ -329,50 +357,73 @@ - (OSStatus)getDeviceProperty:(AudioObjectID)deviceID element:(AudioObjectPropertyElement)element size:(UInt32 *)ioDataSize data:(void *)outData { + using namespace std::literals; + AudioObjectPropertyAddress addr = { .mSelector = selector, .mScope = scope, .mElement = element }; - return AudioObjectGetPropertyData(deviceID, &addr, 0, NULL, ioDataSize, outData); + + 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; } // Generalized method for cleaning up system tap resources - (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->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; } #pragma mark - Buffer Management Methods - (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); @@ -384,9 +435,14 @@ - (void)initializeAudioBuffer:(UInt8)channels { [self.samplesArrivedSignal release]; } self.samplesArrivedSignal = [[NSCondition alloc] init]; + + 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 if (self.samplesArrivedSignal) { [self.samplesArrivedSignal signal]; @@ -396,9 +452,14 @@ - (void)cleanupAudioBuffer { // Cleanup the circular buffer TPCircularBufferCleanup(&self->audioSampleBuffer); + + BOOST_LOG(info) << "Audio buffer cleanup completed"sv; } - (void)dealloc { + using namespace std::literals; + BOOST_LOG(debug) << "AVAudio dealloc started"sv; + // Cleanup system tap resources using the generalized method [self cleanupSystemTapContext:nil]; @@ -406,12 +467,14 @@ - (void)dealloc { 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]; } @@ -427,7 +490,7 @@ - (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize chann } NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion]; - BOOST_LOG(info) << "macOS version check passed (running "sv << version.majorVersion << "."sv << version.minorVersion << "."sv << version.patchVersion << ")"sv; + 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; @@ -447,14 +510,14 @@ - (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize chann self->ioProcData->clientRequestedSampleRate = sampleRate; self->ioProcData->audioConverter = NULL; - BOOST_LOG(info) << "System tap initialization completed"sv; + BOOST_LOG(debug) << "System tap initialization completed"sv; return 0; } - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { using namespace std::literals; - BOOST_LOG(info) << "Creating tap description for "sv << (int) channels << " channels"sv; + BOOST_LOG(debug) << "Creating tap description for "sv << (int) channels << " channels"sv; CATapDescription *tapDescription; NSArray *excludeProcesses = @[]; @@ -473,20 +536,21 @@ - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { [tapDescription setPrivate:YES]; // Create the tap - BOOST_LOG(info) << "Creating process tap with name: "sv << [uniqueName UTF8String]; + 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(info) << "AudioHardwareCreateProcessTap returned status: "sv << status; + 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] << ")"sv; + 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; } @@ -519,14 +583,16 @@ - (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescr @kAudioAggregateDeviceIsStackedKey: @NO, }; - BOOST_LOG(info) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; + BOOST_LOG(debug) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String]; OSStatus status = AudioHardwareCreateAggregateDevice((__bridge CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID); - BOOST_LOG(info) << "AudioHardwareCreateAggregateDevice returned status: "sv << status; + 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 @@ -540,6 +606,8 @@ - (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescr 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 @@ -553,6 +621,8 @@ - (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescr 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; } } @@ -598,7 +668,7 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate OSStatus streamConfigStatus = AudioObjectGetPropertyData(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize, streamConfig); if (streamConfigStatus == noErr && streamConfig->mNumberBuffers > 0) { aggregateDeviceChannels = streamConfig->mBuffers[0].mNumberChannels; - BOOST_LOG(info) << "Device reports "sv << aggregateDeviceChannels << " input channels"sv; + 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; } @@ -608,11 +678,11 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate BOOST_LOG(warning) << "Failed to get stream configuration size, using default 2 channels: "sv << streamConfigSizeStatus; } - BOOST_LOG(info) << "Device properties - Sample Rate: "sv << aggregateDeviceSampleRate << "Hz, Channels: "sv << aggregateDeviceChannels; + 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(info) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") + BOOST_LOG(debug) << "needsConversion: "sv << (needsConversion ? "YES" : "NO") << " (device: "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch" << " -> client: "sv << clientSampleRate << "Hz/" << (int)clientChannels << "ch)"sv; @@ -643,12 +713,15 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate 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; } // 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; } @@ -656,24 +729,25 @@ - (OSStatus)createAndStartIOProc:(CATapDescription *)tapDescription { using namespace std::literals; // Create IOProc - BOOST_LOG(info) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; + BOOST_LOG(debug) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; OSStatus status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); - BOOST_LOG(info) << "AudioDeviceCreateIOProcID returned status: "sv << status; + 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(info) << "Starting IOProc for aggregate device"; + BOOST_LOG(debug) << "Starting IOProc for aggregate device"; status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID); - BOOST_LOG(info) << "AudioDeviceStart returned status: "sv << status; + 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 From 1a3bc522e79c68fde1ebfdd731bdceb437615ea1 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 13:59:58 +0200 Subject: [PATCH 09/30] wip(macos): doxygen documentation --- src/config.h | 10 +- src/platform/macos/av_audio.h | 156 ++++++++++++++++++--- src/platform/macos/av_audio.mm | 12 +- tests/unit/platform/test_macos_av_audio.mm | 97 ++++++++++--- 4 files changed, 225 insertions(+), 50 deletions(-) diff --git a/src/config.h b/src/config.h index 2cecafd4696..49c7d7f31ac 100644 --- a/src/config.h +++ b/src/config.h @@ -145,11 +145,11 @@ namespace config { }; struct audio_t { - std::string sink; - std::string virtual_sink; - bool stream; - bool install_steam_drivers; - bool macos_system_wide_audio_tap; + 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 + bool macos_system_wide_audio_tap; ///< Enable system-wide audio capture on macOS using Core Audio taps (requires macOS 14.2+) }; constexpr int ENCRYPTION_MODE_NEVER = 0; // Never use video encryption, even if the client supports it diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 0b6a59744bf..1c3b0ab1b82 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -1,6 +1,13 @@ /** * @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 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.2+) + * + * 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 @@ -13,6 +20,7 @@ // lib includes #include "third-party/TPCircularBuffer/TPCircularBuffer.h" +// Buffer length for audio processing #define kBufferLength 4096 NS_ASSUME_NONNULL_BEGIN @@ -21,59 +29,161 @@ NS_ASSUME_NONNULL_BEGIN @class AVAudio; @class CATapDescription; -// AudioConverter input callback data +/** + * @brief Data structure for AudioConverter input callback. + * Contains audio data and metadata needed for format conversion during audio processing. + */ struct AudioConverterInputData { - float *inputData; - UInt32 inputFrames; - UInt32 framesProvided; - UInt32 deviceChannels; - AVAudio *avAudio; // Reference to the AVAudio instance + 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 }; -// IOProc client data structure +/** + * @brief IOProc client data structure for Core Audio system taps. + * Contains configuration and conversion data for real-time audio processing. + */ typedef struct { - AVAudio *avAudio; - UInt32 clientRequestedChannels; - UInt32 clientRequestedSampleRate; - UInt32 clientRequestedFrameSize; - UInt32 aggregateDeviceSampleRate; - UInt32 aggregateDeviceChannels; - AudioConverterRef _Nullable audioConverter; + 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 } 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.2+). + */ @interface AVAudio: NSObject { @public - TPCircularBuffer audioSampleBuffer; + TPCircularBuffer audioSampleBuffer; ///< Shared circular buffer for both audio capture paths @private - AudioObjectID tapObjectID; - AudioObjectID aggregateDeviceID; - AudioDeviceIOProcID ioProcID; - AVAudioIOProcData * _Nullable ioProcData; + // 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, nullable) AVCaptureSession *audioCaptureSession; -@property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; -@property (nonatomic, assign, nullable) 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 + +// Shared synchronization property (used by both audio paths) +@property (nonatomic, assign, nullable) NSCondition *samplesArrivedSignal; ///< Condition variable to signal when audio samples are available +/** + * @brief Get all available microphone devices on the system. + * @return Array of AVCaptureDevice objects representing available microphones + */ + (NSArray *)microphones; + +/** + * @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 + * @return AVCaptureDevice object if found, nil otherwise + */ + (nullable AVCaptureDevice *)findMicrophone:(NSString *)name; +/** + * @brief Sets up microphone capture using AVFoundation framework. + * @param device The AVCaptureDevice to use for audio input + * @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:(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.2+ 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 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)initSystemTapContext:(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; + +/** + * @brief Audio converter complex input callback for format conversion. + * Handles audio data conversion between different formats during system audio capture. + * @param inAudioConverter The audio converter reference + * @param ioNumberDataPackets Number of data packets to convert + * @param ioData Audio buffer list for converted data + * @param outDataPacketDescription Packet description for output data + * @param inputInfo Input data structure containing source audio + * @return OSStatus indicating success (noErr) or error code + */ - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter ioNumberDataPackets:(UInt32 *)ioNumberDataPackets ioData:(AudioBufferList *)ioData outDataPacketDescription:(AudioStreamPacketDescription * _Nullable * _Nullable)outDataPacketDescription inputInfo:(struct AudioConverterInputData *)inputInfo; + +/** + * @brief Core Audio IOProc callback for processing system audio data. + * Handles real-time audio processing, format conversion, and writes to circular buffer. + * @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 (nullable for input-only devices) + * @param inOutputTime Time stamp for output data + * @param clientChannels Number of channels requested by client + * @param clientFrameSize Frame size requested by client + * @param clientSampleRate Sample rate requested by client + * @return OSStatus indicating success (noErr) or error code + */ - (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice inNow:(const AudioTimeStamp *)inNow inInputData:(const AudioBufferList *)inInputData diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 517c7f8f6dd..b849ca6b1f7 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -188,40 +188,40 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U using namespace std::literals; BOOST_LOG(debug) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; - // Initialize system tap components + // 1. Initialize system tap components if ([self initSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { return -1; } - // 1. Create tap description and process tap + // 2. Create tap description and process tap CATapDescription *tapDescription = [self createSystemTapDescriptionForChannels:channels]; if (!tapDescription) { [self cleanupSystemTapContext:nil]; return -1; } - // 2. Create and configure aggregate device + // 3. Create and configure aggregate device OSStatus aggregateStatus = [self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize]; if (aggregateStatus != noErr) { [self cleanupSystemTapContext:tapDescription]; return -1; } - // 3. Configure device properties and AudioConverter + // 4. Configure device properties and AudioConverter OSStatus configureStatus = [self configureDevicePropertiesAndConverter:sampleRate clientChannels:channels]; if (configureStatus != noErr) { [self cleanupSystemTapContext:tapDescription]; return -1; } - // 4. Create and start IOProc + // 5. Create and start IOProc OSStatus ioProcStatus = [self createAndStartIOProc:tapDescription]; if (ioProcStatus != noErr) { [self cleanupSystemTapContext:tapDescription]; return -1; } - // Initialize buffer and signal + // 6. Initialize buffer and signal [self initializeAudioBuffer:channels]; [tapDescription release]; diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index b4806814534..c8dc7d1714b 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -15,18 +15,28 @@ // Include the header for the class we're testing #import -// Test parameters for processSystemAudioIOProc tests +/** + * @brief Test parameters for processSystemAudioIOProc tests. + * Contains various audio configuration parameters to test different scenarios. + */ struct ProcessSystemAudioIOProcTestParams { - UInt32 frameCount; - UInt32 channels; - UInt32 sampleRate; - bool useNilInput; - const char* testName; + 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 }; -// Make AVAudioTest itself parameterized for the processSystemAudioIOProc tests +/** + * @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 microphoneNames returns a valid NSArray. + * Verifies the static method returns a non-nil array object. + */ TEST_F(AVAudioTest, MicrophoneNamesReturnsArray) { NSArray* names = [AVAudio microphoneNames]; @@ -34,6 +44,10 @@ EXPECT_TRUE([names isKindOfClass:[NSArray class]]); // Should be an NSArray } +/** + * @brief Test that findMicrophone handles nil input gracefully. + * Verifies the method returns nil when passed a nil microphone name. + */ TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" @@ -42,17 +56,29 @@ 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, FindMicrophoneWithEmptyNameReturnsNil) { AVCaptureDevice* device = [AVAudio findMicrophone:@""]; EXPECT_EQ(device, nil); // Should return nil for empty string } +/** + * @brief Test that findMicrophone handles non-existent microphone names. + * Verifies the method returns nil when passed an invalid microphone name. + */ TEST_F(AVAudioTest, FindMicrophoneWithInvalidNameReturnsNil) { NSString* invalidName = @"NonExistentMicrophone123456789ABCDEF"; AVCaptureDevice* device = [AVAudio findMicrophone:invalidName]; EXPECT_EQ(device, nil); // Should return nil for non-existent device } +/** + * @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 @@ -63,6 +89,10 @@ EXPECT_EQ(result, -1); // Should fail with nil device } +/** + * @brief Test that setupSystemTap validates channel count parameter. + * Verifies the method returns an error when passed zero channels. + */ TEST_F(AVAudioTest, SetupSystemTapWithZeroChannelsReturnsError) { AVAudio* avAudio = [[AVAudio alloc] init]; int result = [avAudio setupSystemTap:48000 frameSize:512 channels:0]; @@ -70,12 +100,20 @@ EXPECT_EQ(result, -1); // Should fail with zero channels } +/** + * @brief Test basic AVAudio object lifecycle. + * Verifies that AVAudio objects can be created and destroyed without issues. + */ TEST_F(AVAudioTest, AVAudioObjectCreationAndDestruction) { 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, AVAudioMultipleObjectsCanBeCreated) { AVAudio* avAudio1 = [[AVAudio alloc] init]; AVAudio* avAudio2 = [[AVAudio alloc] init]; @@ -88,7 +126,10 @@ [avAudio2 release]; } -// Test for initializeAudioBuffer method +/** + * @brief Test audio buffer initialization with various channel configurations. + * Verifies that the audio buffer can be initialized with different channel counts. + */ TEST_F(AVAudioTest, InitializeAudioBufferSucceeds) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -108,7 +149,10 @@ [avAudio release]; } -// Test for cleanupAudioBuffer method +/** + * @brief Test audio buffer cleanup functionality. + * Verifies that cleanup works correctly even with uninitialized buffers. + */ TEST_F(AVAudioTest, CleanupAudioBufferHandlesNilSignal) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -124,7 +168,10 @@ [avAudio release]; } -// Test for initSystemTapContext method +/** + * @brief Test system tap context initialization with valid parameters. + * Verifies that system tap context can be initialized on supported macOS versions. + */ TEST_F(AVAudioTest, InitSystemTapContextWithValidParameters) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -142,7 +189,10 @@ [avAudio release]; } -// Test for initSystemTapContext with edge case parameters +/** + * @brief Test system tap context initialization with edge case parameters. + * Verifies that system tap handles minimum and maximum reasonable audio parameters. + */ TEST_F(AVAudioTest, InitSystemTapContextWithEdgeCases) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -160,7 +210,10 @@ [avAudio release]; } -// Test for createSystemTapDescriptionForChannels method +/** + * @brief Test Core Audio tap description creation for different channel configurations. + * Verifies that system tap descriptions can be created for various channel counts. + */ TEST_F(AVAudioTest, CreateSystemTapDescriptionForChannels) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -190,7 +243,10 @@ [avAudio release]; } -// Test for audioConverterComplexInputProc method +/** + * @brief Test audio converter complex input callback with valid data. + * Verifies that the audio converter callback properly processes valid audio data. + */ TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesValidData) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -239,7 +295,10 @@ [avAudio release]; } -// Test for audioConverterComplexInputProc with no more data +/** + * @brief Test audio converter callback when no more data is available. + * Verifies that the callback handles end-of-data scenarios correctly. + */ TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesNoMoreData) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -273,7 +332,10 @@ [avAudio release]; } -// Test for cleanupAudioBuffer handling multiple calls +/** + * @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, CleanupAudioBufferMultipleCalls) { AVAudio* avAudio = [[AVAudio alloc] init]; @@ -290,7 +352,10 @@ [avAudio release]; } -// Test for buffer management edge cases +/** + * @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]; From cab7da6d7006f9beaa21e6077c3df1e58a599f50 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 14:28:32 +0200 Subject: [PATCH 10/30] wip(macos): added cleanupSystemTapContext instance method to header and created tests --- src/platform/macos/av_audio.h | 6 + src/platform/macos/av_audio.mm | 69 +++++++++-- tests/unit/platform/test_macos_av_audio.mm | 134 +++++++++++++++++++++ 3 files changed, 202 insertions(+), 7 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 1c3b0ab1b82..2c403ad401a 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -129,6 +129,12 @@ typedef struct { */ - (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 diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index b849ca6b1f7..0f0ebb7ecf2 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -1,6 +1,13 @@ /** * @file src/platform/macos/av_audio.mm - * @brief Simplified audio capture on macOS with system tap. + * @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.2+) + * + * 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" @@ -8,7 +15,16 @@ #import #import -// C wrapper for AudioConverter input callback +/** + * @brief C wrapper for AudioConverter input callback. + * Bridges C-style Core Audio callbacks to Objective-C++ method calls. + * @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 + */ static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { struct AudioConverterInputData *inputInfo = (struct AudioConverterInputData *) inUserData; AVAudio *avAudio = inputInfo->avAudio; @@ -20,7 +36,18 @@ static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioC inputInfo:inputInfo]; } -// C wrapper for IOProc callback +/** + * @brief C wrapper for Core Audio IOProc callback. + * Bridges C-style Core Audio IOProc callbacks to Objective-C++ method calls for system-wide audio capture. + * @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 + */ static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) { AVAudioIOProcData *procData = (AVAudioIOProcData *) inClientData; AVAudio *avAudio = procData->avAudio; @@ -164,6 +191,14 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f 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 { @@ -350,7 +385,17 @@ - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter return noErr; } -// Helper method to get device properties +/** + * @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 @@ -374,7 +419,11 @@ - (OSStatus)getDeviceProperty:(AudioObjectID)deviceID return result; } -// Generalized method for cleaning up system tap resources +/** + * @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; @@ -418,7 +467,8 @@ - (void)cleanupSystemTapContext:(id)tapDescription { BOOST_LOG(debug) << "System tap context cleanup completed"sv; } -#pragma mark - Buffer Management Methods +// MARK: - Buffer Management Methods +// Shared buffer management methods used by both audio capture paths - (void)initializeAudioBuffer:(UInt8)channels { using namespace std::literals; @@ -456,6 +506,10 @@ - (void)cleanupAudioBuffer { 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; @@ -478,7 +532,8 @@ - (void)dealloc { [super dealloc]; } -#pragma mark - System Tap Initialization +// MARK: - System Tap Initialization +// Private methods for initializing Core Audio system tap components - (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { using namespace std::literals; diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index c8dc7d1714b..6a851ddd046 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -243,6 +243,140 @@ [avAudio release]; } +/** + * @brief Test system tap context cleanup functionality. + * Verifies that system tap context can be cleaned up safely and multiple times. + */ +TEST_F(AVAudioTest, CleanupSystemTapContext) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Test cleanup without initialization (should not crash) + [avAudio cleanupSystemTapContext:nil]; // Should be safe to call + + // Initialize system tap context + int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + EXPECT_EQ(initResult, 0); + + // Cleanup should work without issues + [avAudio cleanupSystemTapContext:nil]; + + // Multiple cleanup calls should be safe + [avAudio cleanupSystemTapContext:nil]; // Second call should not crash + [avAudio cleanupSystemTapContext:nil]; // Third call should not crash + + // Re-initialize after cleanup should work + int reinitResult = [avAudio initSystemTapContext:44100 frameSize:256 channels:1]; + EXPECT_EQ(reinitResult, 0); + + // Final cleanup + [avAudio cleanupSystemTapContext:nil]; + } else { + // On older systems, cleanup should still be safe even though init fails + [avAudio cleanupSystemTapContext:nil]; + } + + [avAudio release]; +} + + +// Type alias for parameterized cleanup system tap context tests +using CleanupSystemTapContextTest = AVAudioTest; + +// Test parameters for cleanup system tap context tests (reusing same configurations) +INSTANTIATE_TEST_SUITE_P( + AVAudioTest, + CleanupSystemTapContextTest, + ::testing::Values( + // Representative subset focusing on different channel configurations + ProcessSystemAudioIOProcTestParams{512, 1, 48000, false, "CleanupMono48kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{512, 2, 48000, false, "CleanupStereo48kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{256, 4, 48000, false, "CleanupQuad48kHz256Frames"}, + ProcessSystemAudioIOProcTestParams{512, 6, 44100, false, "Cleanup51Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams{240, 8, 48000, false, "Cleanup71Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams{128, 1, 22050, false, "CleanupMono22kHz128Frames"}, + ProcessSystemAudioIOProcTestParams{1024, 2, 96000, false, "CleanupStereo96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams{128, 8, 192000, false, "Cleanup71Surround192kHz128Frames"} + ), + [](const ::testing::TestParamInfo& info) { + return std::string(info.param.testName); + } +); + +/** + * @brief Parameterized test for system tap context cleanup with various audio configurations. + * Tests init/cleanup cycles across different channel counts, sample rates, and frame sizes. + */ +TEST_P(CleanupSystemTapContextTest, CleanupSystemTapContextParameterized) { + ProcessSystemAudioIOProcTestParams params = GetParam(); + + AVAudio* avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Test initialization with the parameterized configuration + int initResult = [avAudio initSystemTapContext:params.sampleRate + frameSize:params.frameCount + channels:params.channels]; + EXPECT_EQ(initResult, 0) << "Failed to initialize system tap context for " << params.testName; + + // Test cleanup after successful initialization + [avAudio cleanupSystemTapContext:nil]; + + // Test re-initialization after cleanup (should work) + int reinitResult = [avAudio initSystemTapContext:params.sampleRate + frameSize:params.frameCount + channels:params.channels]; + EXPECT_EQ(reinitResult, 0) << "Failed to re-initialize system tap context after cleanup for " << params.testName; + + // Test multiple cleanup calls (should be safe) + [avAudio cleanupSystemTapContext:nil]; + [avAudio cleanupSystemTapContext:nil]; // Second call should not crash + + // Test cleanup without prior initialization (should be safe) + [avAudio cleanupSystemTapContext:nil]; + } else { + // On older systems, cleanup should still be safe even though init fails + [avAudio cleanupSystemTapContext:nil]; + } + + [avAudio release]; +} + +/** + * @brief Test system tap context cleanup with tap description object. + * Verifies cleanup works properly when a tap description is provided. + */ +TEST_F(AVAudioTest, CleanupSystemTapContextWithTapDescription) { + AVAudio* avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Initialize system tap context + int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + EXPECT_EQ(initResult, 0); + + // Create a tap description + CATapDescription* tapDescription = [avAudio createSystemTapDescriptionForChannels:2]; + if (tapDescription) { + EXPECT_NE(tapDescription, nil); + + // Test cleanup with the tap description object + [avAudio cleanupSystemTapContext:tapDescription]; + // Note: tapDescription should be released by the cleanup method + } else { + // If tap description creation failed, just cleanup normally + [avAudio cleanupSystemTapContext:nil]; + } + + // Additional cleanup should be safe + [avAudio cleanupSystemTapContext:nil]; + } + + [avAudio release]; +} + /** * @brief Test audio converter complex input callback with valid data. * Verifies that the audio converter callback properly processes valid audio data. From a729f78a5ccfab17ef75f433fbc6c6ccb1691e77 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Fri, 29 Aug 2025 14:33:25 +0200 Subject: [PATCH 11/30] wip(macos): renamed instance method --- src/platform/macos/av_audio.h | 2 +- src/platform/macos/av_audio.mm | 8 ++++---- tests/unit/platform/test_macos_av_audio.mm | 18 +++++++++--------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 2c403ad401a..843f0bc16c6 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -142,7 +142,7 @@ typedef struct { * @param channels Number of audio channels * @return 0 on success, -1 on failure */ -- (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; /** * @brief Creates a Core Audio tap description for system audio capture. diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 0f0ebb7ecf2..10a580e6792 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -224,7 +224,7 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U BOOST_LOG(debug) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels; // 1. Initialize system tap components - if ([self initSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { + if ([self initializeSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) { return -1; } @@ -250,7 +250,7 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U } // 5. Create and start IOProc - OSStatus ioProcStatus = [self createAndStartIOProc:tapDescription]; + OSStatus ioProcStatus = [self createAndStartAggregateDeviceIOProc:tapDescription]; if (ioProcStatus != noErr) { [self cleanupSystemTapContext:tapDescription]; return -1; @@ -535,7 +535,7 @@ - (void)dealloc { // MARK: - System Tap Initialization // Private methods for initializing Core Audio system tap components -- (int)initSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { +- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { using namespace std::literals; // Check macOS version requirement @@ -780,7 +780,7 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate return noErr; } -- (OSStatus)createAndStartIOProc:(CATapDescription *)tapDescription { +- (OSStatus)createAndStartAggregateDeviceIOProc:(CATapDescription *)tapDescription { using namespace std::literals; // Create IOProc diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 6a851ddd046..3caeb52590e 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -175,7 +175,7 @@ TEST_F(AVAudioTest, InitSystemTapContextWithValidParameters) { AVAudio* avAudio = [[AVAudio alloc] init]; - int result = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + int result = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; // On systems with macOS 14.2+, this should succeed NSOperatingSystemVersion minVersion = {14, 2, 0}; @@ -199,11 +199,11 @@ NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test with minimum values - int result1 = [avAudio initSystemTapContext:8000 frameSize:64 channels:1]; + int result1 = [avAudio initializeSystemTapContext:8000 frameSize:64 channels:1]; EXPECT_EQ(result1, 0); // Test with maximum reasonable values - int result2 = [avAudio initSystemTapContext:192000 frameSize:4096 channels:8]; + int result2 = [avAudio initializeSystemTapContext:192000 frameSize:4096 channels:8]; EXPECT_EQ(result2, 0); } @@ -220,7 +220,7 @@ NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize context first - int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); // Test mono tap description @@ -256,7 +256,7 @@ [avAudio cleanupSystemTapContext:nil]; // Should be safe to call // Initialize system tap context - int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); // Cleanup should work without issues @@ -267,7 +267,7 @@ [avAudio cleanupSystemTapContext:nil]; // Third call should not crash // Re-initialize after cleanup should work - int reinitResult = [avAudio initSystemTapContext:44100 frameSize:256 channels:1]; + int reinitResult = [avAudio initializeSystemTapContext:44100 frameSize:256 channels:1]; EXPECT_EQ(reinitResult, 0); // Final cleanup @@ -316,7 +316,7 @@ NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test initialization with the parameterized configuration - int initResult = [avAudio initSystemTapContext:params.sampleRate + int initResult = [avAudio initializeSystemTapContext:params.sampleRate frameSize:params.frameCount channels:params.channels]; EXPECT_EQ(initResult, 0) << "Failed to initialize system tap context for " << params.testName; @@ -325,7 +325,7 @@ [avAudio cleanupSystemTapContext:nil]; // Test re-initialization after cleanup (should work) - int reinitResult = [avAudio initSystemTapContext:params.sampleRate + int reinitResult = [avAudio initializeSystemTapContext:params.sampleRate frameSize:params.frameCount channels:params.channels]; EXPECT_EQ(reinitResult, 0) << "Failed to re-initialize system tap context after cleanup for " << params.testName; @@ -354,7 +354,7 @@ NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize system tap context - int initResult = [avAudio initSystemTapContext:48000 frameSize:512 channels:2]; + int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); // Create a tap description From b29445e099665253a3565ce22eac38fe2dab5b21 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 00:47:42 +0200 Subject: [PATCH 12/30] fix(macos): add macOS-specific test files only when building tests for macOS. --- tests/CMakeLists.txt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5cab9e4c409..6f2cf2408da 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -40,8 +40,13 @@ list(APPEND TEST_DEFINITIONS SUNSHINE_TESTS) file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/tests/*.h - ${CMAKE_SOURCE_DIR}/tests/*.cpp - ${CMAKE_SOURCE_DIR}/tests/*.mm) + ${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}) From 976265155560c5b47545c92bbadb5756ee16013a Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 01:24:16 +0200 Subject: [PATCH 13/30] fix(cmake): add missing newline --- cmake/dependencies/macos.cmake | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmake/dependencies/macos.cmake b/cmake/dependencies/macos.cmake index a3f84e3faaa..5a2556c9f1c 100644 --- a/cmake/dependencies/macos.cmake +++ b/cmake/dependencies/macos.cmake @@ -18,4 +18,4 @@ 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) \ No newline at end of file +link_directories(/opt/homebrew/opt/opus/lib) From 13bc467bb48bc4815f944397e79099614deb36d8 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 01:26:50 +0200 Subject: [PATCH 14/30] style: format C++ code with clang-format --- src/platform/macos/av_audio.h | 66 +-- src/platform/macos/av_audio.mm | 156 ++++---- src/platform/macos/microphone.mm | 3 +- tests/unit/platform/test_macos_av_audio.mm | 444 ++++++++++----------- 4 files changed, 335 insertions(+), 334 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 843f0bc16c6..d5144bcd33c 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -1,21 +1,21 @@ /** * @file src/platform/macos/av_audio.h * @brief Declarations for macOS audio capture with dual input paths. - * + * * This header defines 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.2+) - * + * * 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 #import +#import // lib includes #include "third-party/TPCircularBuffer/TPCircularBuffer.h" @@ -34,11 +34,11 @@ NS_ASSUME_NONNULL_BEGIN * 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 + 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 + AVAudio *avAudio; ///< Reference to the AVAudio instance }; /** @@ -46,13 +46,13 @@ struct AudioConverterInputData { * 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 + 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 } AVAudioIOProcData; /** @@ -65,18 +65,18 @@ typedef struct { TPCircularBuffer audioSampleBuffer; ///< Shared circular buffer for both audio capture paths @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 + 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 } // 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, nullable) AVCaptureSession *audioCaptureSession; ///< AVFoundation capture session for microphone input +@property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; ///< Audio connection within the capture session // Shared synchronization property (used by both audio paths) -@property (nonatomic, assign, nullable) NSCondition *samplesArrivedSignal; ///< Condition variable to signal when audio samples are available +@property (nonatomic, assign, nullable) NSCondition *samplesArrivedSignal; ///< Condition variable to signal when audio samples are available /** * @brief Get all available microphone devices on the system. @@ -171,10 +171,10 @@ typedef struct { * @return OSStatus indicating success (noErr) or error code */ - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter - ioNumberDataPackets:(UInt32 *)ioNumberDataPackets - ioData:(AudioBufferList *)ioData - outDataPacketDescription:(AudioStreamPacketDescription * _Nullable * _Nullable)outDataPacketDescription - inputInfo:(struct AudioConverterInputData *)inputInfo; + ioNumberDataPackets:(UInt32 *)ioNumberDataPackets + ioData:(AudioBufferList *)ioData + outDataPacketDescription:(AudioStreamPacketDescription *_Nullable *_Nullable)outDataPacketDescription + inputInfo:(struct AudioConverterInputData *)inputInfo; /** * @brief Core Audio IOProc callback for processing system audio data. @@ -191,14 +191,14 @@ typedef struct { * @return OSStatus indicating success (noErr) or error code */ - (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice - inNow:(const AudioTimeStamp *)inNow - inInputData:(const AudioBufferList *)inInputData - inInputTime:(const AudioTimeStamp *)inInputTime - outOutputData:(nullable AudioBufferList *)outOutputData - inOutputTime:(const AudioTimeStamp *)inOutputTime - clientChannels:(UInt32)clientChannels - clientFrameSize:(UInt32)clientFrameSize - clientSampleRate:(UInt32)clientSampleRate; + inNow:(const AudioTimeStamp *)inNow + inInputData:(const AudioBufferList *)inInputData + inInputTime:(const AudioTimeStamp *)inInputTime + outOutputData:(nullable AudioBufferList *)outOutputData + inOutputTime:(const AudioTimeStamp *)inOutputTime + clientChannels:(UInt32)clientChannels + clientFrameSize:(UInt32)clientFrameSize + clientSampleRate:(UInt32)clientSampleRate; @end diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 10a580e6792..e2ad359a57e 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -1,15 +1,16 @@ /** * @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.2+) - * + * * 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" #import @@ -28,11 +29,11 @@ static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { struct AudioConverterInputData *inputInfo = (struct AudioConverterInputData *) inUserData; AVAudio *avAudio = inputInfo->avAudio; - + return [avAudio audioConverterComplexInputProc:inAudioConverter ioNumberDataPackets:ioNumberDataPackets ioData:ioData - outDataPacketDescription:outDataPacketDescription + outDataPacketDescription:outDataPacketDescription inputInfo:inputInfo]; } @@ -52,14 +53,14 @@ static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTime AVAudioIOProcData *procData = (AVAudioIOProcData *) inClientData; AVAudio *avAudio = procData->avAudio; return [avAudio systemAudioIOProc:inDevice - inNow:inNow - inInputData:inInputData - inInputTime:inInputTime - outOutputData:outOutputData - inOutputTime:inOutputTime - clientChannels:procData->clientRequestedChannels - clientFrameSize:procData->clientRequestedFrameSize - clientSampleRate:procData->clientRequestedSampleRate]; + inNow:inNow + inInputData:inInputData + inInputTime:inInputTime + outOutputData:outOutputData + inOutputTime:inOutputTime + clientChannels:procData->clientRequestedChannels + clientFrameSize:procData->clientRequestedFrameSize + clientSampleRate:procData->clientRequestedSampleRate]; } @implementation AVAudio @@ -67,7 +68,7 @@ @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 @@ -115,7 +116,7 @@ @implementation AVAudio + (AVCaptureDevice *)findMicrophone:(NSString *)name { using namespace std::literals; 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]; @@ -129,8 +130,8 @@ + (AVCaptureDevice *)findMicrophone:(NSString *)name { - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { using namespace std::literals; - BOOST_LOG(info) << "Setting up microphone: "sv << [[device localizedName] UTF8String] << " with "sv << sampleRate << "Hz, "sv << frameSize << " frames, "sv << (int)channels << " channels"sv; - + 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; @@ -159,7 +160,7 @@ - (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate f (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; + 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); @@ -266,14 +267,14 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U } - (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice - inNow:(const AudioTimeStamp *)inNow - inInputData:(const AudioBufferList *)inInputData - inInputTime:(const AudioTimeStamp *)inInputTime - outOutputData:(AudioBufferList *)outOutputData - inOutputTime:(const AudioTimeStamp *)inOutputTime - clientChannels:(UInt32)clientChannels - clientFrameSize:(UInt32)clientFrameSize - clientSampleRate:(UInt32)clientSampleRate { + inNow:(const AudioTimeStamp *)inNow + inInputData:(const AudioBufferList *)inInputData + inInputTime:(const AudioTimeStamp *)inInputTime + outOutputData:(AudioBufferList *)outOutputData + inOutputTime:(const AudioTimeStamp *)inOutputTime + clientChannels:(UInt32)clientChannels + clientFrameSize:(UInt32)clientFrameSize + clientSampleRate:(UInt32)clientSampleRate { // Always ensure we write to buffer and signal, even if input is empty/invalid BOOL didWriteData = NO; @@ -363,10 +364,10 @@ - (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice // AudioConverter input callback as Objective-C method - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter - ioNumberDataPackets:(UInt32 *)ioNumberDataPackets - ioData:(AudioBufferList *)ioData - outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription - inputInfo:(struct AudioConverterInputData *)inputInfo { + ioNumberDataPackets:(UInt32 *)ioNumberDataPackets + ioData:(AudioBufferList *)ioData + outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription + inputInfo:(struct AudioConverterInputData *)inputInfo { if (inputInfo->framesProvided >= inputInfo->inputFrames) { *ioNumberDataPackets = 0; return noErr; @@ -396,26 +397,26 @@ - (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter * @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 +- (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; } @@ -427,7 +428,7 @@ - (OSStatus)getDeviceProperty:(AudioObjectID)deviceID - (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); @@ -463,7 +464,7 @@ - (void)cleanupSystemTapContext:(id)tapDescription { [tapDescription release]; BOOST_LOG(debug) << "Tap description released"sv; } - + BOOST_LOG(debug) << "System tap context cleanup completed"sv; } @@ -472,37 +473,37 @@ - (void)cleanupSystemTapContext:(id)tapDescription { - (void)initializeAudioBuffer:(UInt8)channels { using namespace std::literals; - BOOST_LOG(debug) << "Initializing audio buffer for "sv << (int)channels << " channels"sv; - + 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 the condition signal for synchronization (cleanup any existing one first) if (self.samplesArrivedSignal) { [self.samplesArrivedSignal release]; } self.samplesArrivedSignal = [[NSCondition alloc] init]; - + 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 if (self.samplesArrivedSignal) { [self.samplesArrivedSignal signal]; [self.samplesArrivedSignal release]; self.samplesArrivedSignal = nil; } - + // Cleanup the circular buffer TPCircularBufferCleanup(&self->audioSampleBuffer); - + BOOST_LOG(info) << "Audio buffer cleanup completed"sv; } @@ -513,7 +514,7 @@ - (void)cleanupAudioBuffer { - (void)dealloc { using namespace std::literals; BOOST_LOG(debug) << "AVAudio dealloc started"sv; - + // Cleanup system tap resources using the generalized method [self cleanupSystemTapContext:nil]; @@ -537,7 +538,7 @@ - (void)dealloc { - (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels { using namespace std::literals; - + // Check macOS version requirement if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 2, 0})]) { BOOST_LOG(error) << "macOS version requirement not met (need 14.2+)"sv; @@ -558,20 +559,20 @@ - (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize 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; - + 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"sv; CATapDescription *tapDescription; NSArray *excludeProcesses = @[]; @@ -611,7 +612,7 @@ - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { - (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)]) { @@ -680,19 +681,19 @@ - (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescr 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 { +- (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 - + 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 @@ -701,24 +702,24 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate 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); + AudioBufferList *streamConfig = (AudioBufferList *) malloc(streamConfigSize); if (streamConfig) { OSStatus streamConfigStatus = AudioObjectGetPropertyData(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize, streamConfig); if (streamConfigStatus == noErr && streamConfig->mNumberBuffers > 0) { @@ -736,11 +737,11 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate 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; - + 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; @@ -767,22 +768,22 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate 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; + 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; } - + // Store the actual device format for use in the IOProc - self->ioProcData->aggregateDeviceSampleRate = (UInt32)aggregateDeviceSampleRate; + 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, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); @@ -801,8 +802,9 @@ - (OSStatus)createAndStartAggregateDeviceIOProc:(CATapDescription *)tapDescripti 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 4f6abfe5a36..69dc79abfcf 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -52,7 +52,7 @@ int set_sink(const std::string &sink) override { std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size) override { auto mic = std::make_unique(); mic->av_audio_capture = [[AVAudio alloc] init]; - + // Check if macOS system-wide audio tap is enabled if (config::audio.macos_system_wide_audio_tap) { BOOST_LOG(info) << "Using macOS system audio tap for capture."sv; @@ -83,7 +83,6 @@ int set_sink(const std::string &sink) override { 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; diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 3caeb52590e..a5f9ea2d1f0 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -6,42 +6,42 @@ // Only compile these tests on macOS #ifdef __APPLE__ -#include "../../tests_common.h" + #include "../../tests_common.h" -#import -#import -#import + #import + #import + #import -// Include the header for the class we're testing -#import + // Include the header for the class we're testing + #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 + 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 {}; +class AVAudioTest: public PlatformTestSuite, public ::testing::WithParamInterface {}; /** * @brief Test that microphoneNames returns a valid NSArray. * Verifies the static method returns a non-nil array object. */ TEST_F(AVAudioTest, MicrophoneNamesReturnsArray) { - NSArray* names = [AVAudio microphoneNames]; + NSArray *names = [AVAudio microphoneNames]; - EXPECT_NE(names, nil); // Should always return an array, even if empty - EXPECT_TRUE([names isKindOfClass:[NSArray class]]); // Should be an NSArray + EXPECT_NE(names, nil); // Should always return an array, even if empty + EXPECT_TRUE([names isKindOfClass:[NSArray class]]); // Should be an NSArray } /** @@ -49,10 +49,10 @@ * Verifies the method returns nil when passed a nil microphone name. */ TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wnonnull" - AVCaptureDevice* device = [AVAudio findMicrophone:nil]; -#pragma clang diagnostic pop + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wnonnull" + AVCaptureDevice *device = [AVAudio findMicrophone:nil]; + #pragma clang diagnostic pop EXPECT_EQ(device, nil); } @@ -61,8 +61,8 @@ * Verifies the method returns nil when passed an empty microphone name. */ TEST_F(AVAudioTest, FindMicrophoneWithEmptyNameReturnsNil) { - AVCaptureDevice* device = [AVAudio findMicrophone:@""]; - EXPECT_EQ(device, nil); // Should return nil for empty string + AVCaptureDevice *device = [AVAudio findMicrophone:@""]; + EXPECT_EQ(device, nil); // Should return nil for empty string } /** @@ -70,9 +70,9 @@ * Verifies the method returns nil when passed an invalid microphone name. */ TEST_F(AVAudioTest, FindMicrophoneWithInvalidNameReturnsNil) { - NSString* invalidName = @"NonExistentMicrophone123456789ABCDEF"; - AVCaptureDevice* device = [AVAudio findMicrophone:invalidName]; - EXPECT_EQ(device, nil); // Should return nil for non-existent device + NSString *invalidName = @"NonExistentMicrophone123456789ABCDEF"; + AVCaptureDevice *device = [AVAudio findMicrophone:invalidName]; + EXPECT_EQ(device, nil); // Should return nil for non-existent device } /** @@ -80,13 +80,13 @@ * 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" + 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 + #pragma clang diagnostic pop [avAudio release]; - EXPECT_EQ(result, -1); // Should fail with nil device + EXPECT_EQ(result, -1); // Should fail with nil device } /** @@ -94,10 +94,10 @@ * Verifies the method returns an error when passed zero channels. */ TEST_F(AVAudioTest, SetupSystemTapWithZeroChannelsReturnsError) { - AVAudio* avAudio = [[AVAudio alloc] init]; + AVAudio *avAudio = [[AVAudio alloc] init]; int result = [avAudio setupSystemTap:48000 frameSize:512 channels:0]; [avAudio release]; - EXPECT_EQ(result, -1); // Should fail with zero channels + EXPECT_EQ(result, -1); // Should fail with zero channels } /** @@ -105,9 +105,9 @@ * Verifies that AVAudio objects can be created and destroyed without issues. */ TEST_F(AVAudioTest, AVAudioObjectCreationAndDestruction) { - AVAudio* avAudio = [[AVAudio alloc] init]; - EXPECT_NE(avAudio, nil); // Should create successfully - [avAudio release]; // Should not crash + AVAudio *avAudio = [[AVAudio alloc] init]; + EXPECT_NE(avAudio, nil); // Should create successfully + [avAudio release]; // Should not crash } /** @@ -115,13 +115,13 @@ * Verifies that multiple instances can be created simultaneously. */ TEST_F(AVAudioTest, AVAudioMultipleObjectsCanBeCreated) { - AVAudio* avAudio1 = [[AVAudio alloc] init]; - AVAudio* avAudio2 = [[AVAudio alloc] init]; - + 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 - + EXPECT_NE(avAudio1, avAudio2); // Should be different objects + [avAudio1 release]; [avAudio2 release]; } @@ -131,21 +131,21 @@ * Verifies that the audio buffer can be initialized with different channel counts. */ TEST_F(AVAudioTest, InitializeAudioBufferSucceeds) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + // Test with various channel counts - [avAudio initializeAudioBuffer:1]; // Mono + [avAudio initializeAudioBuffer:1]; // Mono EXPECT_NE(avAudio.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; - - [avAudio initializeAudioBuffer:2]; // Stereo + + [avAudio initializeAudioBuffer:2]; // Stereo EXPECT_NE(avAudio.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; - - [avAudio initializeAudioBuffer:8]; // 7.1 Surround + + [avAudio initializeAudioBuffer:8]; // 7.1 Surround EXPECT_NE(avAudio.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; - + [avAudio release]; } @@ -154,17 +154,17 @@ * Verifies that cleanup works correctly even with uninitialized buffers. */ TEST_F(AVAudioTest, CleanupAudioBufferHandlesNilSignal) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + 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.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; EXPECT_EQ(avAudio.samplesArrivedSignal, nil); - + [avAudio release]; } @@ -173,10 +173,10 @@ * Verifies that system tap context can be initialized on supported macOS versions. */ TEST_F(AVAudioTest, InitSystemTapContextWithValidParameters) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + int result = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - + // On systems with macOS 14.2+, this should succeed NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { @@ -185,7 +185,7 @@ // On older systems, should fail gracefully EXPECT_EQ(result, -1); } - + [avAudio release]; } @@ -194,19 +194,19 @@ * Verifies that system tap handles minimum and maximum reasonable audio parameters. */ TEST_F(AVAudioTest, InitSystemTapContextWithEdgeCases) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test with minimum values int result1 = [avAudio initializeSystemTapContext:8000 frameSize:64 channels:1]; EXPECT_EQ(result1, 0); - + // Test with maximum reasonable values int result2 = [avAudio initializeSystemTapContext:192000 frameSize:4096 channels:8]; EXPECT_EQ(result2, 0); } - + [avAudio release]; } @@ -215,31 +215,31 @@ * Verifies that system tap descriptions can be created for various channel counts. */ TEST_F(AVAudioTest, CreateSystemTapDescriptionForChannels) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize context first int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); - + // Test mono tap description - CATapDescription* monoTap = [avAudio createSystemTapDescriptionForChannels:1]; + CATapDescription *monoTap = [avAudio createSystemTapDescriptionForChannels:1]; if (monoTap) { EXPECT_NE(monoTap, nil); // Note: Can't test properties due to forward declaration limitations [monoTap release]; } - + // Test stereo tap description - CATapDescription* stereoTap = [avAudio createSystemTapDescriptionForChannels:2]; + CATapDescription *stereoTap = [avAudio createSystemTapDescriptionForChannels:2]; if (stereoTap) { EXPECT_NE(stereoTap, nil); // Note: Can't test properties due to forward declaration limitations [stereoTap release]; } } - + [avAudio release]; } @@ -248,39 +248,38 @@ * Verifies that system tap context can be cleaned up safely and multiple times. */ TEST_F(AVAudioTest, CleanupSystemTapContext) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test cleanup without initialization (should not crash) - [avAudio cleanupSystemTapContext:nil]; // Should be safe to call - + [avAudio cleanupSystemTapContext:nil]; // Should be safe to call + // Initialize system tap context int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); - + // Cleanup should work without issues [avAudio cleanupSystemTapContext:nil]; - + // Multiple cleanup calls should be safe - [avAudio cleanupSystemTapContext:nil]; // Second call should not crash - [avAudio cleanupSystemTapContext:nil]; // Third call should not crash - + [avAudio cleanupSystemTapContext:nil]; // Second call should not crash + [avAudio cleanupSystemTapContext:nil]; // Third call should not crash + // Re-initialize after cleanup should work int reinitResult = [avAudio initializeSystemTapContext:44100 frameSize:256 channels:1]; EXPECT_EQ(reinitResult, 0); - + // Final cleanup [avAudio cleanupSystemTapContext:nil]; } else { // On older systems, cleanup should still be safe even though init fails [avAudio cleanupSystemTapContext:nil]; } - + [avAudio release]; } - // Type alias for parameterized cleanup system tap context tests using CleanupSystemTapContextTest = AVAudioTest; @@ -290,16 +289,16 @@ CleanupSystemTapContextTest, ::testing::Values( // Representative subset focusing on different channel configurations - ProcessSystemAudioIOProcTestParams{512, 1, 48000, false, "CleanupMono48kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{512, 2, 48000, false, "CleanupStereo48kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{256, 4, 48000, false, "CleanupQuad48kHz256Frames"}, - ProcessSystemAudioIOProcTestParams{512, 6, 44100, false, "Cleanup51Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{240, 8, 48000, false, "Cleanup71Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{128, 1, 22050, false, "CleanupMono22kHz128Frames"}, - ProcessSystemAudioIOProcTestParams{1024, 2, 96000, false, "CleanupStereo96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams{128, 8, 192000, false, "Cleanup71Surround192kHz128Frames"} + ProcessSystemAudioIOProcTestParams {512, 1, 48000, false, "CleanupMono48kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {512, 2, 48000, false, "CleanupStereo48kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {256, 4, 48000, false, "CleanupQuad48kHz256Frames"}, + ProcessSystemAudioIOProcTestParams {512, 6, 44100, false, "Cleanup51Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {240, 8, 48000, false, "Cleanup71Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams {128, 1, 22050, false, "CleanupMono22kHz128Frames"}, + ProcessSystemAudioIOProcTestParams {1024, 2, 96000, false, "CleanupStereo96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams {128, 8, 192000, false, "Cleanup71Surround192kHz128Frames"} ), - [](const ::testing::TestParamInfo& info) { + [](const ::testing::TestParamInfo &info) { return std::string(info.param.testName); } ); @@ -310,37 +309,37 @@ */ TEST_P(CleanupSystemTapContextTest, CleanupSystemTapContextParameterized) { ProcessSystemAudioIOProcTestParams params = GetParam(); - - AVAudio* avAudio = [[AVAudio alloc] init]; - + + AVAudio *avAudio = [[AVAudio alloc] init]; + NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test initialization with the parameterized configuration - int initResult = [avAudio initializeSystemTapContext:params.sampleRate - frameSize:params.frameCount - channels:params.channels]; + int initResult = [avAudio initializeSystemTapContext:params.sampleRate + frameSize:params.frameCount + channels:params.channels]; EXPECT_EQ(initResult, 0) << "Failed to initialize system tap context for " << params.testName; - + // Test cleanup after successful initialization [avAudio cleanupSystemTapContext:nil]; - + // Test re-initialization after cleanup (should work) - int reinitResult = [avAudio initializeSystemTapContext:params.sampleRate - frameSize:params.frameCount - channels:params.channels]; + int reinitResult = [avAudio initializeSystemTapContext:params.sampleRate + frameSize:params.frameCount + channels:params.channels]; EXPECT_EQ(reinitResult, 0) << "Failed to re-initialize system tap context after cleanup for " << params.testName; - + // Test multiple cleanup calls (should be safe) [avAudio cleanupSystemTapContext:nil]; - [avAudio cleanupSystemTapContext:nil]; // Second call should not crash - + [avAudio cleanupSystemTapContext:nil]; // Second call should not crash + // Test cleanup without prior initialization (should be safe) [avAudio cleanupSystemTapContext:nil]; } else { // On older systems, cleanup should still be safe even though init fails [avAudio cleanupSystemTapContext:nil]; } - + [avAudio release]; } @@ -349,19 +348,19 @@ * Verifies cleanup works properly when a tap description is provided. */ TEST_F(AVAudioTest, CleanupSystemTapContextWithTapDescription) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + NSOperatingSystemVersion minVersion = {14, 2, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize system tap context int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; EXPECT_EQ(initResult, 0); - + // Create a tap description - CATapDescription* tapDescription = [avAudio createSystemTapDescriptionForChannels:2]; + CATapDescription *tapDescription = [avAudio createSystemTapDescriptionForChannels:2]; if (tapDescription) { EXPECT_NE(tapDescription, nil); - + // Test cleanup with the tap description object [avAudio cleanupSystemTapContext:tapDescription]; // Note: tapDescription should be released by the cleanup method @@ -369,11 +368,11 @@ // If tap description creation failed, just cleanup normally [avAudio cleanupSystemTapContext:nil]; } - + // Additional cleanup should be safe [avAudio cleanupSystemTapContext:nil]; } - + [avAudio release]; } @@ -382,25 +381,25 @@ * Verifies that the audio converter callback properly processes valid audio data. */ TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesValidData) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + // Create test input data UInt32 frameCount = 256; UInt32 channels = 2; UInt32 sampleRate = 48000; - float* testData = (float*)calloc(frameCount * channels, sizeof(float)); - + float *testData = (float *) calloc(frameCount * channels, sizeof(float)); + // Fill with test sine wave data (different frequency per channel) - same as parameterized test for (UInt32 frame = 0; frame < frameCount; frame++) { for (UInt32 channel = 0; channel < channels; channel++) { // Generate different frequencies for each channel for testing // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. double frequency = 440.0 * (channel + 1); - testData[frame * channels + channel] = - (float)(sin(2.0 * M_PI * frequency * frame / (double)sampleRate) * 0.5); + testData[frame * channels + channel] = + (float) (sin(2.0 * M_PI * frequency * frame / (double) sampleRate) * 0.5); } } - + struct AudioConverterInputData inputInfo = { .inputData = testData, .inputFrames = frameCount, @@ -408,23 +407,23 @@ .deviceChannels = channels, .avAudio = avAudio }; - + // Test the method UInt32 requestedPackets = 128; AudioBufferList bufferList = {0}; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wnonnull" + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wnonnull" OSStatus result = [avAudio audioConverterComplexInputProc:nil ioNumberDataPackets:&requestedPackets ioData:&bufferList - outDataPacketDescription:nil - inputInfo:&inputInfo]; -#pragma clang diagnostic pop - + outDataPacketDescription:nil + inputInfo:&inputInfo]; + #pragma clang diagnostic pop + EXPECT_EQ(result, noErr); - EXPECT_EQ(requestedPackets, 128); // Should provide requested frames - EXPECT_EQ(inputInfo.framesProvided, 128); // Should update frames provided - + EXPECT_EQ(requestedPackets, 128); // Should provide requested frames + EXPECT_EQ(inputInfo.framesProvided, 128); // Should update frames provided + free(testData); [avAudio release]; } @@ -434,34 +433,34 @@ * Verifies that the callback handles end-of-data scenarios correctly. */ TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesNoMoreData) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + UInt32 frameCount = 256; UInt32 channels = 2; - float* testData = (float*)calloc(frameCount * channels, sizeof(float)); - + float *testData = (float *) calloc(frameCount * channels, sizeof(float)); + struct AudioConverterInputData inputInfo = { .inputData = testData, .inputFrames = frameCount, - .framesProvided = frameCount, // Already provided all frames + .framesProvided = frameCount, // Already provided all frames .deviceChannels = channels, .avAudio = avAudio }; - + UInt32 requestedPackets = 128; AudioBufferList bufferList = {0}; -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Wnonnull" + #pragma clang diagnostic push + #pragma clang diagnostic ignored "-Wnonnull" OSStatus result = [avAudio audioConverterComplexInputProc:nil ioNumberDataPackets:&requestedPackets ioData:&bufferList - outDataPacketDescription:nil - inputInfo:&inputInfo]; -#pragma clang diagnostic pop - + outDataPacketDescription:nil + inputInfo:&inputInfo]; + #pragma clang diagnostic pop + EXPECT_EQ(result, noErr); - EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data - + EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data + free(testData); [avAudio release]; } @@ -471,18 +470,18 @@ * Verifies that repeated cleanup calls don't cause crashes or issues. */ TEST_F(AVAudioTest, CleanupAudioBufferMultipleCalls) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + [avAudio initializeAudioBuffer:2]; EXPECT_NE(avAudio.samplesArrivedSignal, nil); - + // Multiple cleanup calls should not crash [avAudio cleanupAudioBuffer]; EXPECT_EQ(avAudio.samplesArrivedSignal, nil); - - [avAudio cleanupAudioBuffer]; // Second call should be safe - [avAudio cleanupAudioBuffer]; // Third call should be safe - + + [avAudio cleanupAudioBuffer]; // Second call should be safe + [avAudio cleanupAudioBuffer]; // Third call should be safe + [avAudio release]; } @@ -491,22 +490,21 @@ * Verifies that buffer management works with minimum and maximum channel counts. */ TEST_F(AVAudioTest, BufferManagementEdgeCases) { - AVAudio* avAudio = [[AVAudio alloc] init]; - + AVAudio *avAudio = [[AVAudio alloc] init]; + // Test with minimum reasonable channel count (1 channel) [avAudio initializeAudioBuffer:1]; EXPECT_NE(avAudio.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; - + // Test with very high channel count [avAudio initializeAudioBuffer:32]; EXPECT_NE(avAudio.samplesArrivedSignal, nil); [avAudio cleanupAudioBuffer]; - + [avAudio release]; } - // Type alias for parameterized audio processing tests using ProcessSystemAudioIOProcTest = AVAudioTest; @@ -516,133 +514,135 @@ ProcessSystemAudioIOProcTest, ::testing::Values( // Mono channel variants - ProcessSystemAudioIOProcTestParams{240, 1, 48000, false, "ValidMono48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{512, 1, 44100, false, "ValidMono44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{1024, 1, 96000, false, "ValidMono96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams{128, 1, 22050, false, "ValidMono22kHz128Frames"}, - + ProcessSystemAudioIOProcTestParams {240, 1, 48000, false, "ValidMono48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams {512, 1, 44100, false, "ValidMono44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {1024, 1, 96000, false, "ValidMono96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams {128, 1, 22050, false, "ValidMono22kHz128Frames"}, + // Stereo channel variants - ProcessSystemAudioIOProcTestParams{240, 2, 48000, false, "ValidStereo48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{480, 2, 48000, false, "ValidStereo48kHz480Frames"}, - ProcessSystemAudioIOProcTestParams{512, 2, 44100, false, "ValidStereo44kHz512Frames"}, - + ProcessSystemAudioIOProcTestParams {240, 2, 48000, false, "ValidStereo48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams {480, 2, 48000, false, "ValidStereo48kHz480Frames"}, + ProcessSystemAudioIOProcTestParams {512, 2, 44100, false, "ValidStereo44kHz512Frames"}, + // Quad (4 channel) variants - ProcessSystemAudioIOProcTestParams{256, 4, 48000, false, "ValidQuad48kHz256Frames"}, - ProcessSystemAudioIOProcTestParams{512, 4, 44100, false, "ValidQuad44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{1024, 4, 96000, false, "ValidQuad96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams{128, 4, 22050, false, "ValidQuad22kHz128Frames"}, - + ProcessSystemAudioIOProcTestParams {256, 4, 48000, false, "ValidQuad48kHz256Frames"}, + ProcessSystemAudioIOProcTestParams {512, 4, 44100, false, "ValidQuad44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {1024, 4, 96000, false, "ValidQuad96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams {128, 4, 22050, false, "ValidQuad22kHz128Frames"}, + // 5.1 Surround (6 channel) variants - ProcessSystemAudioIOProcTestParams{240, 6, 48000, false, "Valid51Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{512, 6, 44100, false, "Valid51Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{1024, 6, 96000, false, "Valid51Surround96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams{256, 6, 88200, false, "Valid51Surround88kHz256Frames"}, - + ProcessSystemAudioIOProcTestParams {240, 6, 48000, false, "Valid51Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams {512, 6, 44100, false, "Valid51Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {1024, 6, 96000, false, "Valid51Surround96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams {256, 6, 88200, false, "Valid51Surround88kHz256Frames"}, + // 7.1 Surround (8 channel) variants - ProcessSystemAudioIOProcTestParams{240, 8, 48000, false, "Valid71Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams{512, 8, 44100, false, "Valid71Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams{1024, 8, 96000, false, "Valid71Surround96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams{128, 8, 192000, false, "Valid71Surround192kHz128Frames"}, - + ProcessSystemAudioIOProcTestParams {240, 8, 48000, false, "Valid71Surround48kHz240Frames"}, + ProcessSystemAudioIOProcTestParams {512, 8, 44100, false, "Valid71Surround44kHz512Frames"}, + ProcessSystemAudioIOProcTestParams {1024, 8, 96000, false, "Valid71Surround96kHz1024Frames"}, + ProcessSystemAudioIOProcTestParams {128, 8, 192000, false, "Valid71Surround192kHz128Frames"}, + // Edge cases with various configurations - ProcessSystemAudioIOProcTestParams{240, 2, 48000, true, "NilInputHandlesGracefully"}, - ProcessSystemAudioIOProcTestParams{64, 2, 8000, false, "ValidStereo8kHz64Frames"}, - ProcessSystemAudioIOProcTestParams{2048, 1, 48000, false, "ValidMono48kHz2048Frames"}, - ProcessSystemAudioIOProcTestParams{32, 4, 176400, false, "ValidQuad176kHz32Frames"}, - ProcessSystemAudioIOProcTestParams{128, 6, 44100, false, "Valid51Surround44kHz128Frames"} // Reduced from 4096 to fit buffer + ProcessSystemAudioIOProcTestParams {240, 2, 48000, true, "NilInputHandlesGracefully"}, + ProcessSystemAudioIOProcTestParams {64, 2, 8000, false, "ValidStereo8kHz64Frames"}, + ProcessSystemAudioIOProcTestParams {2048, 1, 48000, false, "ValidMono48kHz2048Frames"}, + ProcessSystemAudioIOProcTestParams {32, 4, 176400, false, "ValidQuad176kHz32Frames"}, + ProcessSystemAudioIOProcTestParams {128, 6, 44100, false, "Valid51Surround44kHz128Frames"} // Reduced from 4096 to fit buffer ), - [](const ::testing::TestParamInfo& info) { + [](const ::testing::TestParamInfo &info) { return std::string(info.param.testName); } ); TEST_P(ProcessSystemAudioIOProcTest, ProcessSystemAudioIOProc) { ProcessSystemAudioIOProcTestParams params = GetParam(); - - AVAudio* avAudio = [[AVAudio alloc] init]; - + + 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; + + 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)); - + testInputData = (float *) calloc(params.frameCount * params.channels, sizeof(float)); + // Fill with test sine wave data (different frequency per channel) for (UInt32 frame = 0; frame < params.frameCount; frame++) { for (UInt32 channel = 0; channel < params.channels; channel++) { // Generate different frequencies for each channel for testing // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. double frequency = 440.0 * (channel + 1); - testInputData[frame * params.channels + channel] = - (float)(sin(2.0 * M_PI * frequency * frame / (double)params.sampleRate) * 0.5); + testInputData[frame * params.channels + channel] = + (float) (sin(2.0 * M_PI * frequency * frame / (double) params.sampleRate) * 0.5); } } - + // Create AudioBufferList - inputBufferList = (AudioBufferList*)malloc(sizeof(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); - + // Test the processSystemAudioIOProc method - OSStatus result = [avAudio systemAudioIOProc:0 // device ID (not used in our logic) - inNow:&timeStamp - inInputData:inputBufferList - inInputTime:&timeStamp - outOutputData:nil // not used in our implementation - inOutputTime:&timeStamp - clientChannels:params.channels - clientFrameSize:params.frameCount - clientSampleRate:params.sampleRate]; - + OSStatus result = [avAudio systemAudioIOProc:0 // device ID (not used in our logic) + inNow:&timeStamp + inInputData:inputBufferList + inInputTime:&timeStamp + outOutputData:nil // not used in our implementation + inOutputTime:&timeStamp + clientChannels:params.channels + clientFrameSize:params.frameCount + clientSampleRate:params.sampleRate]; + // 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 - + 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) // Test up to 16 samples or 4 complete frames, whichever is smaller - UInt32 samplesToTest = std::min(16U, params.channels * 4); // Up to 4 frames worth + UInt32 samplesToTest = std::min(16U, params.channels * 4); // Up to 4 frames worth if (bufferData && finalAvailableBytes >= sizeof(float) * samplesToTest) { - float* outputSamples = (float*)bufferData; + 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); + if (testInputData) { + free(testInputData); + } + if (inputBufferList) { + free(inputBufferList); + } [avAudio cleanupAudioBuffer]; [avAudio release]; } - - -#endif // __APPLE__ +#endif // __APPLE__ From 5aa03e2362eacf9c8dcf27e230974bb71bb47bbd Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 02:13:03 +0200 Subject: [PATCH 15/30] fix(macos): improve nil-safety in av_audio microphone code-path --- src/platform/macos/av_audio.h | 8 ++++---- src/platform/macos/av_audio.mm | 12 ++++++++++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index d5144bcd33c..e4a4ee8e2df 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -92,20 +92,20 @@ typedef struct { /** * @brief Find a specific microphone device by name. - * @param name The name of the microphone to find + * @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:(NSString *)name; ++ (nullable AVCaptureDevice *)findMicrophone:(nullable NSString *)name; /** * @brief Sets up microphone capture using AVFoundation framework. - * @param device The AVCaptureDevice to use for audio input + * @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:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels; +- (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. diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index e2ad359a57e..9196946ddc7 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -115,6 +115,12 @@ @implementation AVAudio + (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]) { @@ -130,6 +136,12 @@ + (AVCaptureDevice *)findMicrophone:(NSString *)name { - (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]; From a847d1f1a78d7641ee2b9d34c1b32a6071685699 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 02:28:01 +0200 Subject: [PATCH 16/30] style(windows): code incorrectly formatted --- src/platform/windows/display_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp index 2538a690bf4..a8cb0edc0b7 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -19,7 +19,7 @@ typedef long NTSTATUS; // Definition from the WDK's d3dkmthk.h -typedef enum _D3DKMT_GPU_PREFERENCE_QUERY_STATE: DWORD { +typedef enum _D3DKMT_GPU_PREFERENCE_QUERY_STATE : DWORD { D3DKMT_GPU_PREFERENCE_STATE_UNINITIALIZED, ///< The GPU preference isn't initialized. D3DKMT_GPU_PREFERENCE_STATE_HIGH_PERFORMANCE, ///< The highest performing GPU is preferred. D3DKMT_GPU_PREFERENCE_STATE_MINIMUM_POWER, ///< The minimum-powered GPU is preferred. From 64047055b86c378fb79835101a78e55b2e33f6e2 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 30 Aug 2025 02:43:23 +0200 Subject: [PATCH 17/30] revert: style(windows): code incorrectly formatted --- src/platform/windows/display_base.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/platform/windows/display_base.cpp b/src/platform/windows/display_base.cpp index a8cb0edc0b7..2538a690bf4 100644 --- a/src/platform/windows/display_base.cpp +++ b/src/platform/windows/display_base.cpp @@ -19,7 +19,7 @@ typedef long NTSTATUS; // Definition from the WDK's d3dkmthk.h -typedef enum _D3DKMT_GPU_PREFERENCE_QUERY_STATE : DWORD { +typedef enum _D3DKMT_GPU_PREFERENCE_QUERY_STATE: DWORD { D3DKMT_GPU_PREFERENCE_STATE_UNINITIALIZED, ///< The GPU preference isn't initialized. D3DKMT_GPU_PREFERENCE_STATE_HIGH_PERFORMANCE, ///< The highest performing GPU is preferred. D3DKMT_GPU_PREFERENCE_STATE_MINIMUM_POWER, ///< The minimum-powered GPU is preferred. From faa11700716c10b1eb5baf8776499bc9cc11a57e Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Tue, 2 Sep 2025 23:18:52 +0200 Subject: [PATCH 18/30] wip(macos): refactor ioprocs to c/c++. --- src/platform/macos/av_audio.h | 53 +-- src/platform/macos/av_audio.mm | 360 +++++++++++---------- src/platform/macos/microphone.mm | 11 +- tests/unit/platform/test_macos_av_audio.mm | 97 +++--- 4 files changed, 251 insertions(+), 270 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index e4a4ee8e2df..47c7a8a8016 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -2,7 +2,7 @@ * @file src/platform/macos/av_audio.h * @brief Declarations for macOS audio capture with dual input paths. * - * This header defines the AVAudio class which provides two distinct audio capture methods: + * 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.2+) * @@ -29,6 +29,11 @@ NS_ASSUME_NONNULL_BEGIN @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); +} + /** * @brief Data structure for AudioConverter input callback. * Contains audio data and metadata needed for format conversion during audio processing. @@ -53,6 +58,8 @@ typedef struct { 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; /** @@ -63,6 +70,7 @@ typedef struct { @interface AVAudio: NSObject { @public 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 @@ -75,9 +83,6 @@ typedef struct { @property (nonatomic, assign, nullable) AVCaptureSession *audioCaptureSession; ///< AVFoundation capture session for microphone input @property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; ///< Audio connection within the capture session -// Shared synchronization property (used by both audio paths) -@property (nonatomic, assign, nullable) NSCondition *samplesArrivedSignal; ///< Condition variable to signal when audio samples are available - /** * @brief Get all available microphone devices on the system. * @return Array of AVCaptureDevice objects representing available microphones @@ -160,46 +165,6 @@ typedef struct { */ - (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize; -/** - * @brief Audio converter complex input callback for format conversion. - * Handles audio data conversion between different formats during system audio capture. - * @param inAudioConverter The audio converter reference - * @param ioNumberDataPackets Number of data packets to convert - * @param ioData Audio buffer list for converted data - * @param outDataPacketDescription Packet description for output data - * @param inputInfo Input data structure containing source audio - * @return OSStatus indicating success (noErr) or error code - */ -- (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter - ioNumberDataPackets:(UInt32 *)ioNumberDataPackets - ioData:(AudioBufferList *)ioData - outDataPacketDescription:(AudioStreamPacketDescription *_Nullable *_Nullable)outDataPacketDescription - inputInfo:(struct AudioConverterInputData *)inputInfo; - -/** - * @brief Core Audio IOProc callback for processing system audio data. - * Handles real-time audio processing, format conversion, and writes to circular buffer. - * @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 (nullable for input-only devices) - * @param inOutputTime Time stamp for output data - * @param clientChannels Number of channels requested by client - * @param clientFrameSize Frame size requested by client - * @param clientSampleRate Sample rate requested by client - * @return OSStatus indicating success (noErr) or error code - */ -- (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice - inNow:(const AudioTimeStamp *)inNow - inInputData:(const AudioBufferList *)inInputData - inInputTime:(const AudioTimeStamp *)inInputTime - outOutputData:(nullable AudioBufferList *)outOutputData - inOutputTime:(const AudioTimeStamp *)inOutputTime - clientChannels:(UInt32)clientChannels - clientFrameSize:(UInt32)clientFrameSize - clientSampleRate:(UInt32)clientSampleRate; - @end NS_ASSUME_NONNULL_END diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 9196946ddc7..c248125c50e 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -12,56 +12,161 @@ #import "av_audio.h" #include "src/logging.h" +#include "src/utility.h" #import #import -/** - * @brief C wrapper for AudioConverter input callback. - * Bridges C-style Core Audio callbacks to Objective-C++ method calls. - * @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 - */ -static OSStatus audioConverterComplexInputProcWrapper(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) { - struct AudioConverterInputData *inputInfo = (struct AudioConverterInputData *) inUserData; - AVAudio *avAudio = inputInfo->avAudio; - - return [avAudio audioConverterComplexInputProc:inAudioConverter - ioNumberDataPackets:ioNumberDataPackets - ioData:ioData - outDataPacketDescription:outDataPacketDescription - inputInfo:inputInfo]; -} +namespace platf { + using namespace std::literals; -/** - * @brief C wrapper for Core Audio IOProc callback. - * Bridges C-style Core Audio IOProc callbacks to Objective-C++ method calls for system-wide audio capture. - * @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 - */ -static OSStatus systemAudioIOProcWrapper(AudioObjectID inDevice, const AudioTimeStamp *inNow, const AudioBufferList *inInputData, const AudioTimeStamp *inInputTime, AudioBufferList *outOutputData, const AudioTimeStamp *inOutputTime, void *inClientData) { - AVAudioIOProcData *procData = (AVAudioIOProcData *) inClientData; - AVAudio *avAudio = procData->avAudio; - return [avAudio systemAudioIOProc:inDevice - inNow:inNow - inInputData:inInputData - inInputTime:inInputTime - outOutputData:outOutputData - inOutputTime:inOutputTime - clientChannels:procData->clientRequestedChannels - clientFrameSize:procData->clientRequestedFrameSize - clientSampleRate:procData->clientRequestedSampleRate]; -} + /** + * @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 @@ -228,7 +333,7 @@ - (void)captureOutput:(AVCaptureOutput *)output AudioBuffer audioBuffer = audioBufferList.mBuffers[0]; TPCircularBufferProduceBytes(&self->audioSampleBuffer, audioBuffer.mData, audioBuffer.mDataByteSize); - [self.samplesArrivedSignal signal]; + dispatch_semaphore_signal(self->audioSemaphore); } } @@ -278,126 +383,6 @@ - (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(U return 0; } -- (OSStatus)systemAudioIOProc:(AudioObjectID)inDevice - inNow:(const AudioTimeStamp *)inNow - inInputData:(const AudioBufferList *)inInputData - inInputTime:(const AudioTimeStamp *)inInputTime - outOutputData:(AudioBufferList *)outOutputData - inOutputTime:(const AudioTimeStamp *)inOutputTime - clientChannels:(UInt32)clientChannels - clientFrameSize:(UInt32)clientFrameSize - clientSampleRate:(UInt32)clientSampleRate { - // Always ensure we write to buffer and signal, even if input is empty/invalid - BOOL didWriteData = NO; - - if (inInputData && inInputData->mNumberBuffers > 0) { - AudioBuffer inputBuffer = inInputData->mBuffers[0]; - - if (inputBuffer.mData && inputBuffer.mDataByteSize > 0) { - float *inputSamples = (float *) inputBuffer.mData; - UInt32 deviceChannels = self->ioProcData ? self->ioProcData->aggregateDeviceChannels : 2; - UInt32 inputFrames = inputBuffer.mDataByteSize / (deviceChannels * sizeof(float)); - - // Use AudioConverter if we need any conversion, otherwise pass through - if (self->ioProcData && self->ioProcData->audioConverter) { - // Let AudioConverter determine optimal output size - it knows best! - // We'll provide a generous buffer and let it tell us what it actually used - UInt32 maxOutputFrames = inputFrames * 4; // Very generous for any upsampling scenario - UInt32 outputBytes = maxOutputFrames * clientChannels * sizeof(float); - float *outputBuffer = (float *) malloc(outputBytes); - - if (outputBuffer) { - struct AudioConverterInputData inputData = { - .inputData = inputSamples, - .inputFrames = inputFrames, - .framesProvided = 0, - .deviceChannels = deviceChannels, - .avAudio = self - }; - - AudioBufferList outputBufferList = {0}; - outputBufferList.mNumberBuffers = 1; - outputBufferList.mBuffers[0].mNumberChannels = clientChannels; - outputBufferList.mBuffers[0].mDataByteSize = outputBytes; - outputBufferList.mBuffers[0].mData = outputBuffer; - - UInt32 outputFrameCount = maxOutputFrames; - OSStatus converterStatus = AudioConverterFillComplexBuffer( - self->ioProcData->audioConverter, - audioConverterComplexInputProcWrapper, - &inputData, - &outputFrameCount, - &outputBufferList, - NULL - ); - - if (converterStatus == noErr && outputFrameCount > 0) { - // AudioConverter did all the work: sample rate + channels + optimal frame count - UInt32 actualOutputBytes = outputFrameCount * clientChannels * sizeof(float); - TPCircularBufferProduceBytes(&self->audioSampleBuffer, outputBuffer, actualOutputBytes); - didWriteData = YES; - } else { - // Fallback: write original data - TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); - didWriteData = YES; - } - - free(outputBuffer); - } else { - // Memory allocation failed, fallback to original data - TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); - didWriteData = YES; - } - } else { - // No conversion needed - direct passthrough - TPCircularBufferProduceBytes(&self->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize); - didWriteData = YES; - } - } - } - - // Always signal, even if we didn't write data (ensures consumer doesn't block) - if (!didWriteData) { - // Write silence if no valid input data - UInt32 silenceFrames = clientFrameSize > 0 ? clientFrameSize : 2048; - UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float); - - float *silenceBuffer = (float *) calloc(silenceFrames * clientChannels, sizeof(float)); - if (silenceBuffer) { - TPCircularBufferProduceBytes(&self->audioSampleBuffer, silenceBuffer, silenceBytes); - free(silenceBuffer); - } - } - - [self.samplesArrivedSignal signal]; - - return noErr; -} - -// AudioConverter input callback as Objective-C method -- (OSStatus)audioConverterComplexInputProc:(AudioConverterRef)inAudioConverter - ioNumberDataPackets:(UInt32 *)ioNumberDataPackets - ioData:(AudioBufferList *)ioData - outDataPacketDescription:(AudioStreamPacketDescription **)outDataPacketDescription - inputInfo:(struct AudioConverterInputData *)inputInfo { - if (inputInfo->framesProvided >= inputInfo->inputFrames) { - *ioNumberDataPackets = 0; - return noErr; - } - - UInt32 framesToProvide = MIN(*ioNumberDataPackets, inputInfo->inputFrames - inputInfo->framesProvided); - - 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); - - inputInfo->framesProvided += framesToProvide; - *ioNumberDataPackets = framesToProvide; - - return noErr; -} - /** * @brief Helper method to query Core Audio device properties. * Provides a centralized way to get device properties with error logging. @@ -462,6 +447,11 @@ - (void)cleanupSystemTapContext:(id)tapDescription { } 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; @@ -493,11 +483,11 @@ - (void)initializeAudioBuffer:(UInt8)channels { // Initialize the circular buffer with proper size for the channel count TPCircularBufferInit(&self->audioSampleBuffer, kBufferLength * channels); - // Initialize the condition signal for synchronization (cleanup any existing one first) - if (self.samplesArrivedSignal) { - [self.samplesArrivedSignal release]; + // Initialize real-time safe semaphore for synchronization (cleanup any existing one first) + if (self->audioSemaphore) { + dispatch_release(self->audioSemaphore); } - self.samplesArrivedSignal = [[NSCondition alloc] init]; + self->audioSemaphore = dispatch_semaphore_create(0); BOOST_LOG(info) << "Audio buffer initialized successfully with size: "sv << (kBufferLength * channels) << " bytes"sv; } @@ -506,11 +496,11 @@ - (void)cleanupAudioBuffer { using namespace std::literals; BOOST_LOG(debug) << "Cleaning up audio buffer"sv; - // Signal any waiting threads before cleanup - if (self.samplesArrivedSignal) { - [self.samplesArrivedSignal signal]; - [self.samplesArrivedSignal release]; - self.samplesArrivedSignal = nil; + // 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 @@ -577,6 +567,8 @@ - (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize 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; @@ -785,6 +777,22 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate 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; @@ -798,7 +806,7 @@ - (OSStatus)createAndStartAggregateDeviceIOProc:(CATapDescription *)tapDescripti // Create IOProc BOOST_LOG(debug) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID; - OSStatus status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, systemAudioIOProcWrapper, self->ioProcData, &self->ioProcID); + 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; diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 69dc79abfcf..0ed3aeb19e8 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); } diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index a5f9ea2d1f0..01199095b30 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -135,15 +135,15 @@ // Test with various channel counts [avAudio initializeAudioBuffer:1]; // Mono - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; [avAudio initializeAudioBuffer:2]; // Stereo - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; [avAudio initializeAudioBuffer:8]; // 7.1 Surround - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; [avAudio release]; @@ -161,9 +161,9 @@ // Initialize then cleanup [avAudio initializeAudioBuffer:2]; - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; - EXPECT_EQ(avAudio.samplesArrivedSignal, nil); + EXPECT_EQ(avAudio->audioSemaphore, nullptr); [avAudio release]; } @@ -400,25 +400,19 @@ } } - struct AudioConverterInputData inputInfo = { - .inputData = testData, - .inputFrames = frameCount, - .framesProvided = 0, - .deviceChannels = channels, - .avAudio = avAudio - }; + 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}; - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wnonnull" - OSStatus result = [avAudio audioConverterComplexInputProc:nil - ioNumberDataPackets:&requestedPackets - ioData:&bufferList - outDataPacketDescription:nil - inputInfo:&inputInfo]; - #pragma clang diagnostic pop + // 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 @@ -439,24 +433,18 @@ UInt32 channels = 2; float *testData = (float *) calloc(frameCount * channels, sizeof(float)); - struct AudioConverterInputData inputInfo = { - .inputData = testData, - .inputFrames = frameCount, - .framesProvided = frameCount, // Already provided all frames - .deviceChannels = channels, - .avAudio = avAudio - }; + 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}; - #pragma clang diagnostic push - #pragma clang diagnostic ignored "-Wnonnull" - OSStatus result = [avAudio audioConverterComplexInputProc:nil - ioNumberDataPackets:&requestedPackets - ioData:&bufferList - outDataPacketDescription:nil - inputInfo:&inputInfo]; - #pragma clang diagnostic pop + // 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 @@ -473,11 +461,11 @@ AVAudio *avAudio = [[AVAudio alloc] init]; [avAudio initializeAudioBuffer:2]; - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); // Multiple cleanup calls should not crash [avAudio cleanupAudioBuffer]; - EXPECT_EQ(avAudio.samplesArrivedSignal, nil); + EXPECT_EQ(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; // Second call should be safe [avAudio cleanupAudioBuffer]; // Third call should be safe @@ -494,12 +482,12 @@ // Test with minimum reasonable channel count (1 channel) [avAudio initializeAudioBuffer:1]; - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; // Test with very high channel count [avAudio initializeAudioBuffer:32]; - EXPECT_NE(avAudio.samplesArrivedSignal, nil); + EXPECT_NE(avAudio->audioSemaphore, nullptr); [avAudio cleanupAudioBuffer]; [avAudio release]; @@ -599,16 +587,27 @@ uint32_t initialAvailableBytes = 0; TPCircularBufferTail(&avAudio->audioSampleBuffer, &initialAvailableBytes); - // Test the processSystemAudioIOProc method - OSStatus result = [avAudio systemAudioIOProc:0 // device ID (not used in our logic) - inNow:&timeStamp - inInputData:inputBufferList - inInputTime:&timeStamp - outOutputData:nil // not used in our implementation - inOutputTime:&timeStamp - clientChannels:params.channels - clientFrameSize:params.frameCount - clientSampleRate:params.sampleRate]; + // 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); From fc3609b25662b2cef45b8f58a9ec51e28398f17b Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Tue, 2 Sep 2025 23:21:19 +0200 Subject: [PATCH 19/30] style(macos): formatting --- src/platform/macos/av_audio.h | 8 ++--- src/platform/macos/av_audio.mm | 34 +++++++++++----------- src/platform/macos/microphone.mm | 4 +-- tests/unit/platform/test_macos_av_audio.mm | 12 ++++---- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 47c7a8a8016..9acf31cb681 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -30,9 +30,9 @@ NS_ASSUME_NONNULL_BEGIN @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); -} + 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. @@ -58,7 +58,7 @@ typedef struct { 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 + float *_Nullable conversionBuffer; ///< Pre-allocated buffer for audio conversion UInt32 conversionBufferSize; ///< Size of the conversion buffer in bytes } AVAudioIOProcData; diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index c248125c50e..dd817be8a47 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -25,7 +25,7 @@ * 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 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 @@ -33,7 +33,7 @@ */ 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; @@ -61,7 +61,7 @@ OSStatus audioConverterComplexInputProc(AudioConverterRef inAudioConverter, UInt * 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 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) @@ -71,12 +71,12 @@ OSStatus audioConverterComplexInputProc(AudioConverterRef inAudioConverter, UInt */ 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; @@ -93,7 +93,7 @@ OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *inNow, // 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; @@ -106,7 +106,7 @@ OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *inNow, 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, @@ -139,23 +139,23 @@ OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *inNow, 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 + + // 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 + 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); } } @@ -166,7 +166,7 @@ OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *inNow, return noErr; } -} // namespace platf +} // namespace platf @implementation AVAudio @@ -778,10 +778,10 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate } // Pre-allocate conversion buffer for real-time use (eliminates malloc in audio callback) - UInt32 maxFrames = self->ioProcData->clientRequestedFrameSize * 8; // Generous buffer for upsampling scenarios + 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); - + 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) { @@ -790,7 +790,7 @@ - (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate } 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 diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 0ed3aeb19e8..bf6d8b953e9 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -29,12 +29,12 @@ capture_e sample(std::vector &sample_in) override { 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); } diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 01199095b30..6d2bc26e53e 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -602,12 +602,12 @@ // Test the systemAudioIOProcWrapper function OSStatus result = platf::systemAudioIOProc(0, // device ID (not used in our logic) - &timeStamp, - inputBufferList, - &timeStamp, - &dummyOutputBufferList, - &timeStamp, - &procData); + &timeStamp, + inputBufferList, + &timeStamp, + &dummyOutputBufferList, + &timeStamp, + &procData); // Verify the method returns success EXPECT_EQ(result, noErr); From b05603672f35019c235e1397d2f3c4034ec36bb4 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Tue, 2 Sep 2025 23:31:26 +0200 Subject: [PATCH 20/30] refactor(macos): simplify audio tap to always use stereo configuration --- src/platform/macos/av_audio.mm | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index dd817be8a47..7c03407f3b8 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -577,15 +577,11 @@ - (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { using namespace std::literals; - BOOST_LOG(debug) << "Creating tap description for "sv << (int) channels << " channels"sv; - CATapDescription *tapDescription; + BOOST_LOG(debug) << "Creating tap description for "sv << (int) channels << " channels (using stereo tap)"sv; NSArray *excludeProcesses = @[]; - if (channels == 1) { - tapDescription = [[CATapDescription alloc] initMonoGlobalTapButExcludeProcesses:excludeProcesses]; - } else { - tapDescription = [[CATapDescription alloc] initStereoGlobalTapButExcludeProcesses: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]; From 2c40470e5efcf8c9aacfcb5d2104e4caa9165811 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Thu, 4 Sep 2025 00:07:21 +0200 Subject: [PATCH 21/30] feat(audio): Core Audio tap mute behavior for macOS host audio control. --- src/audio.cpp | 4 +- src/platform/common.h | 2 +- src/platform/linux/audio.cpp | 2 +- src/platform/macos/av_audio.h | 1 + src/platform/macos/av_audio.mm | 9 +++++ src/platform/macos/microphone.mm | 6 ++- src/platform/windows/audio.cpp | 2 +- tests/unit/platform/test_macos_av_audio.mm | 44 ++++++++++++++++++++++ 8 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/audio.cpp b/src/audio.cpp index 0d287071a25..cea8eb915be 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -193,7 +193,7 @@ namespace audio { } auto frame_size = config.packetDuration * stream.sampleRate / 1000; - auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size); + auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, config.flags[config_t::HOST_AUDIO]); if (!mic) { return; } @@ -230,7 +230,7 @@ namespace audio { BOOST_LOG(info) << "Reinitializing audio capture"sv; mic.reset(); do { - mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size); + mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, config.flags[config_t::HOST_AUDIO]); if (!mic) { BOOST_LOG(warning) << "Couldn't re-initialize audio input"sv; } diff --git a/src/platform/common.h b/src/platform/common.h index 28704bb128e..765693f83e8 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) = 0; + virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, 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 0e53e939b2a..4b710a69ef9 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -68,7 +68,7 @@ namespace platf { } }; - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name) { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name, bool host_audio_enabled) { auto mic = std::make_unique(); pa_sample_spec ss {PA_SAMPLE_FLOAT32, sample_rate, (std::uint8_t) channels}; diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index 9acf31cb681..d6c3ee0ebff 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -82,6 +82,7 @@ typedef struct { // 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) /** * @brief Get all available microphone devices on the system. diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index 7c03407f3b8..a0a2de07c49 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -591,6 +591,15 @@ - (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels { 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]; diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index bf6d8b953e9..596439e1c2b 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -58,10 +58,14 @@ 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) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool host_audio_enabled = true) override { auto mic = std::make_unique(); mic->av_audio_capture = [[AVAudio alloc] init]; + // 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"); + // Check if macOS system-wide audio tap is enabled if (config::audio.macos_system_wide_audio_tap) { BOOST_LOG(info) << "Using macOS system audio tap for capture."sv; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 964a6afb1f0..11ef3bc7c95 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -761,7 +761,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) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool host_audio_enabled) override { auto mic = std::make_unique(); if (mic->init(sample_rate, frame_size, channels)) { diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 6d2bc26e53e..c19d3af6d28 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -9,6 +9,7 @@ #include "../../tests_common.h" #import + #import #import #import @@ -276,6 +277,49 @@ // On older systems, cleanup should still be safe even though init fails [avAudio cleanupSystemTapContext:nil]; } + [avAudio release]; +} + +/** + * @brief Test Core Audio tap mute behavior with hostAudioEnabled property. + * Verifies that tap descriptions have correct mute behavior based on hostAudioEnabled setting. + */ +TEST_F(AVAudioTest, CoreAudioTapMuteBehavior) { + AVAudio *avAudio = [[AVAudio alloc] init]; + + NSOperatingSystemVersion minVersion = {14, 2, 0}; + if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { + // Initialize context first + int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; + EXPECT_EQ(initResult, 0); + + // Test with host audio disabled (muted) + avAudio.hostAudioEnabled = NO; + CATapDescription *mutedTap = [avAudio createSystemTapDescriptionForChannels:2]; + if (mutedTap) { + EXPECT_NE(mutedTap, nil); + // On macOS 14.2+, we should be able to check the mute behavior + if (@available(macOS 14.2, *)) { + EXPECT_EQ(mutedTap.muteBehavior, CATapMuted); + } + [mutedTap release]; + } + + // Test with host audio enabled (unmuted) + avAudio.hostAudioEnabled = YES; + CATapDescription *unmutedTap = [avAudio createSystemTapDescriptionForChannels:2]; + if (unmutedTap) { + EXPECT_NE(unmutedTap, nil); + // On macOS 14.2+, we should be able to check the mute behavior + if (@available(macOS 14.2, *)) { + EXPECT_EQ(unmutedTap.muteBehavior, CATapUnmuted); + } + [unmutedTap release]; + } + + // Cleanup + [avAudio cleanupSystemTapContext:nil]; + } [avAudio release]; } From 1ba920895162e74e89dcaad7790fe7de91fa9547 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Thu, 4 Sep 2025 00:22:41 +0200 Subject: [PATCH 22/30] fix(audio): mark unused host_audio_enabled parameter as [[maybe_unused]] --- src/platform/common.h | 2 +- src/platform/linux/audio.cpp | 2 +- src/platform/windows/audio.cpp | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/common.h b/src/platform/common.h index 765693f83e8..f4098bd3fb3 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 host_audio_enabled) = 0; + virtual std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, [[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 4b710a69ef9..dbf2f4d2bf9 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -68,7 +68,7 @@ namespace platf { } }; - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name, bool host_audio_enabled) { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name, [[maybe_unused]] bool host_audio_enabled) { auto mic = std::make_unique(); pa_sample_spec ss {PA_SAMPLE_FLOAT32, sample_rate, (std::uint8_t) channels}; diff --git a/src/platform/windows/audio.cpp b/src/platform/windows/audio.cpp index 11ef3bc7c95..29e0ef73b3b 100644 --- a/src/platform/windows/audio.cpp +++ b/src/platform/windows/audio.cpp @@ -761,7 +761,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 host_audio_enabled) override { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, [[maybe_unused]] bool host_audio_enabled) override { auto mic = std::make_unique(); if (mic->init(sample_rate, frame_size, channels)) { From 3ce6572d206a04527b2f8b2e3e1b4a96f77a2c9b Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Mon, 3 Nov 2025 13:13:19 +0100 Subject: [PATCH 23/30] refactor(config): macos_system_wide_audio_tap removed --- docs/configuration.md | 25 ------------ src/config.cpp | 2 - src/config.h | 1 - src/platform/macos/microphone.mm | 39 +++++++++---------- src_assets/common/assets/web/config.html | 1 - .../assets/web/configs/tabs/AudioVideo.vue | 15 +------ .../assets/web/public/assets/locale/en.json | 2 - 7 files changed, 19 insertions(+), 66 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 1f3d010ff3e..2264aa8d461 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -848,31 +848,6 @@ editing the `conf` file in a text editor. Use the examples as reference.
-### macos_system_wide_audio_tap - - - - - - - - - - - - - - -
Description - @tip{Overrides Audio Sink settings.} - Toggles the creation of a system-wide audio tap that captures outgoing audio from all processes. - This tap can act as an input in a HAL aggregate device, like a virtual microphone. - @note{Requirement: macOS 14.2 or later.} - @attention{macOS Privacy Settings: The user must add Terminal or Sunshine to Privacy & Security > Screen & System Audio Recording > System Audio Recording Only in System Settings.} -
Defaultdisabled
Example@code{} - macos_system_wide_audio_tap = disabled - @endcode
- ### install_steam_audio_drivers diff --git a/src/config.cpp b/src/config.cpp index 593acc95166..8e231a1f826 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -513,7 +513,6 @@ namespace config { {}, // virtual_sink true, // stream audio true, // install_steam_drivers - true, // macos_system_wide_audio_tap }; stream_t stream { @@ -1174,7 +1173,6 @@ namespace config { string_f(vars, "virtual_sink", audio.virtual_sink); bool_f(vars, "stream_audio", audio.stream); bool_f(vars, "install_steam_audio_drivers", audio.install_steam_drivers); - bool_f(vars, "macos_system_wide_audio_tap", audio.macos_system_wide_audio_tap); string_restricted_f(vars, "origin_web_ui_allowed", nvhttp.origin_web_ui_allowed, {"pc"sv, "lan"sv, "wan"sv}); diff --git a/src/config.h b/src/config.h index cabc3ea148b..a3bf0a8dfb0 100644 --- a/src/config.h +++ b/src/config.h @@ -149,7 +149,6 @@ namespace config { 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 - bool macos_system_wide_audio_tap; ///< Enable system-wide audio capture on macOS using Core Audio taps (requires macOS 14.2+) }; constexpr int ENCRYPTION_MODE_NEVER = 0; // Never use video encryption, even if the client supports it diff --git a/src/platform/macos/microphone.mm b/src/platform/macos/microphone.mm index 5069f30000c..7771e50297e 100644 --- a/src/platform/macos/microphone.mm +++ b/src/platform/macos/microphone.mm @@ -66,39 +66,36 @@ int set_sink(const std::string &sink) override { mic->av_audio_capture.hostAudioEnabled = host_audio_enabled ? YES : NO; BOOST_LOG(debug) << "Set hostAudioEnabled to: "sv << (host_audio_enabled ? "YES" : "NO"); - // Check if macOS system-wide audio tap is enabled - if (config::audio.macos_system_wide_audio_tap) { + 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; + 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; } - BOOST_LOG(info) << "macOS system audio tap capturing."sv; - return mic; - } - // Setup microphone approach - const char *audio_sink = ""; + BOOST_LOG(info) << "macOS system audio tap capturing."sv; + } else { + // Use specified macOS audio sink + const char *audio_sink = config::audio.sink.c_str(); - if (!config::audio.sink.empty()) { - audio_sink = config::audio.sink.c_str(); - } + 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 ((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]; + } - for (NSString *name in [AVAudio microphoneNames]) { - BOOST_LOG(error) << "\t"sv << [name UTF8String]; + 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; + 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_assets/common/assets/web/config.html b/src_assets/common/assets/web/config.html index 26b681ae17a..62a37906ed6 100644 --- a/src_assets/common/assets/web/config.html +++ b/src_assets/common/assets/web/config.html @@ -169,7 +169,6 @@

{{ $t('config.configuration') }}

"virtual_sink": "", "stream_audio": "enabled", "install_steam_audio_drivers": "enabled", - "macos_system_wide_audio_tap": "macOS false", "adapter_name": "", "output_name": "", "dd_configuration_option": "disabled", diff --git a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue index b55b34b0307..27f58365d70 100644 --- a/src_assets/common/assets/web/configs/tabs/AudioVideo.vue +++ b/src_assets/common/assets/web/configs/tabs/AudioVideo.vue @@ -23,8 +23,7 @@ const config = ref(props.config) + v-model="config.audio_sink" />
{{ $tp('config.audio_sink_desc') }}
@@ -64,18 +63,6 @@ const config = ref(props.config) - - - - Security & Privacy -> Privacy -> Screen and System Audio Recording.", "max_bitrate": "Maximum Bitrate", "max_bitrate_desc": "The maximum bitrate (in Kbps) that Sunshine will encode the stream at. If set to 0, it will always use the bitrate requested by Moonlight.", "minimum_fps_target": "Minimum FPS Target", From fe2ef3f1918b81b259528a264d41ec7805713a26 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Mon, 3 Nov 2025 13:33:12 +0100 Subject: [PATCH 24/30] fix(macos): correct minimum macOS version for Core Audio taps from 14.2 to 14.0 --- src/platform/macos/av_audio.h | 6 +++--- src/platform/macos/av_audio.mm | 6 +++--- tests/unit/platform/test_macos_av_audio.mm | 24 +++++++++++----------- 3 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/platform/macos/av_audio.h b/src/platform/macos/av_audio.h index d6c3ee0ebff..55efadca014 100644 --- a/src/platform/macos/av_audio.h +++ b/src/platform/macos/av_audio.h @@ -4,7 +4,7 @@ * * 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.2+) + * 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. @@ -65,7 +65,7 @@ typedef struct { /** * @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.2+). + * audio capture via Core Audio taps (requires macOS 14.0+). */ @interface AVAudio: NSObject { @public @@ -115,7 +115,7 @@ typedef struct { /** * @brief Sets up system-wide audio tap for capturing all system audio. - * Requires macOS 14.2+ and appropriate permissions. + * 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 diff --git a/src/platform/macos/av_audio.mm b/src/platform/macos/av_audio.mm index a0a2de07c49..ebce8b59117 100644 --- a/src/platform/macos/av_audio.mm +++ b/src/platform/macos/av_audio.mm @@ -4,7 +4,7 @@ * * 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.2+) + * 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. @@ -542,8 +542,8 @@ - (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize using namespace std::literals; // Check macOS version requirement - if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 2, 0})]) { - BOOST_LOG(error) << "macOS version requirement not met (need 14.2+)"sv; + if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 0, 0})]) { + BOOST_LOG(error) << "macOS version requirement not met (need 14.0+)"sv; return -1; } diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index c19d3af6d28..78648157b1e 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -178,8 +178,8 @@ int result = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - // On systems with macOS 14.2+, this should succeed - NSOperatingSystemVersion minVersion = {14, 2, 0}; + // On systems with macOS 14.0+, this should succeed + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { EXPECT_EQ(result, 0); } else { @@ -197,7 +197,7 @@ TEST_F(AVAudioTest, InitSystemTapContextWithEdgeCases) { AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test with minimum values int result1 = [avAudio initializeSystemTapContext:8000 frameSize:64 channels:1]; @@ -218,7 +218,7 @@ TEST_F(AVAudioTest, CreateSystemTapDescriptionForChannels) { AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize context first int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; @@ -251,7 +251,7 @@ TEST_F(AVAudioTest, CleanupSystemTapContext) { AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test cleanup without initialization (should not crash) [avAudio cleanupSystemTapContext:nil]; // Should be safe to call @@ -287,7 +287,7 @@ TEST_F(AVAudioTest, CoreAudioTapMuteBehavior) { AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize context first int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; @@ -298,8 +298,8 @@ CATapDescription *mutedTap = [avAudio createSystemTapDescriptionForChannels:2]; if (mutedTap) { EXPECT_NE(mutedTap, nil); - // On macOS 14.2+, we should be able to check the mute behavior - if (@available(macOS 14.2, *)) { + // On macOS 14.0+, we should be able to check the mute behavior + if (@available(macOS 14.0, *)) { EXPECT_EQ(mutedTap.muteBehavior, CATapMuted); } [mutedTap release]; @@ -310,8 +310,8 @@ CATapDescription *unmutedTap = [avAudio createSystemTapDescriptionForChannels:2]; if (unmutedTap) { EXPECT_NE(unmutedTap, nil); - // On macOS 14.2+, we should be able to check the mute behavior - if (@available(macOS 14.2, *)) { + // On macOS 14.0+, we should be able to check the mute behavior + if (@available(macOS 14.0, *)) { EXPECT_EQ(unmutedTap.muteBehavior, CATapUnmuted); } [unmutedTap release]; @@ -356,7 +356,7 @@ AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Test initialization with the parameterized configuration int initResult = [avAudio initializeSystemTapContext:params.sampleRate @@ -394,7 +394,7 @@ TEST_F(AVAudioTest, CleanupSystemTapContextWithTapDescription) { AVAudio *avAudio = [[AVAudio alloc] init]; - NSOperatingSystemVersion minVersion = {14, 2, 0}; + NSOperatingSystemVersion minVersion = {14, 0, 0}; if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { // Initialize system tap context int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; From 186c21c666594759f7ef2a2d80602dca2aa94136 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Mon, 3 Nov 2025 13:55:59 +0100 Subject: [PATCH 25/30] feat(macos): use system audio tap on macOS 14+ and update related docs --- docs/getting_started.md | 7 ++++--- packaging/sunshine.rb | 7 +++++-- src_assets/common/assets/web/public/assets/locale/en.json | 2 +- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/docs/getting_started.md b/docs/getting_started.md index 0ffc38ae4a0..51c0bbe856f 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -418,9 +418,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 159778a86b8..87c49c653b2 100644 --- a/packaging/sunshine.rb +++ b/packaging/sunshine.rb @@ -147,8 +147,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_assets/common/assets/web/public/assets/locale/en.json b/src_assets/common/assets/web/public/assets/locale/en.json index 1a9e8fa7986..1f839b9aa10 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)", From db3d2dfc674ca5e322e820e0332897c7cbd9abbf Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Mon, 3 Nov 2025 21:07:21 +0100 Subject: [PATCH 26/30] fix(audio): add missing host_audio_enabled parameter to Linux audio. --- src/platform/linux/audio.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/linux/audio.cpp b/src/platform/linux/audio.cpp index bb0e47a408b..f6ce71c7b6c 100644 --- a/src/platform/linux/audio.cpp +++ b/src/platform/linux/audio.cpp @@ -68,7 +68,7 @@ namespace platf { } }; - std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name, [[maybe_unused]] bool host_audio_enabled) { + std::unique_ptr microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, std::string source_name) { auto mic = std::make_unique(); pa_sample_spec ss {PA_SAMPLE_FLOAT32, sample_rate, (std::uint8_t) channels}; @@ -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) From f667865f22dd39bd39c2d9c1d00642e224649901 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Mon, 3 Nov 2025 22:47:43 +0100 Subject: [PATCH 27/30] fix: gitignore did not end with a newline character. --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1ae015dc32b..bab45cd782a 100644 --- a/.gitignore +++ b/.gitignore @@ -57,5 +57,5 @@ package-lock.json *.pyc venv/ - -.cache/ \ No newline at end of file +# Caches +.cache/ From ee2bc0dfc1c272a5e044d0a1f140471a53705f13 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 3 Jan 2026 16:09:39 +0100 Subject: [PATCH 28/30] test(macos): remove TCC-dependent integration tests and optimize unit tests --- tests/unit/platform/test_macos_av_audio.mm | 391 +++------------------ 1 file changed, 48 insertions(+), 343 deletions(-) diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 78648157b1e..89f4a6e2d3a 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -1,6 +1,6 @@ /** * @file tests/unit/platform/test_macos_av_audio.mm - * @brief Test src/platform/macos/av_audio.*. + * @brief Unit tests for src/platform/macos/av_audio.*. */ // Only compile these tests on macOS @@ -34,22 +34,15 @@ */ class AVAudioTest: public PlatformTestSuite, public ::testing::WithParamInterface {}; -/** - * @brief Test that microphoneNames returns a valid NSArray. - * Verifies the static method returns a non-nil array object. - */ -TEST_F(AVAudioTest, MicrophoneNamesReturnsArray) { - NSArray *names = [AVAudio microphoneNames]; +// ===== UNIT TESTS: Tests that do NOT require TCC permissions or real hardware ===== - EXPECT_NE(names, nil); // Should always return an array, even if empty - EXPECT_TRUE([names isKindOfClass:[NSArray class]]); // Should be an NSArray -} +// REMOVED: MicrophoneNamesReturnsArray - Integration test that queries real AVFoundation devices /** * @brief Test that findMicrophone handles nil input gracefully. * Verifies the method returns nil when passed a nil microphone name. */ -TEST_F(AVAudioTest, FindMicrophoneWithNilNameReturnsNil) { +TEST_F(AVAudioTest, When_NilMicrophoneName_Then_ReturnsNil) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" AVCaptureDevice *device = [AVAudio findMicrophone:nil]; @@ -61,26 +54,18 @@ * @brief Test that findMicrophone handles empty string input gracefully. * Verifies the method returns nil when passed an empty microphone name. */ -TEST_F(AVAudioTest, FindMicrophoneWithEmptyNameReturnsNil) { +TEST_F(AVAudioTest, When_EmptyMicrophoneName_Then_ReturnsNil) { AVCaptureDevice *device = [AVAudio findMicrophone:@""]; EXPECT_EQ(device, nil); // Should return nil for empty string } -/** - * @brief Test that findMicrophone handles non-existent microphone names. - * Verifies the method returns nil when passed an invalid microphone name. - */ -TEST_F(AVAudioTest, FindMicrophoneWithInvalidNameReturnsNil) { - NSString *invalidName = @"NonExistentMicrophone123456789ABCDEF"; - AVCaptureDevice *device = [AVAudio findMicrophone:invalidName]; - EXPECT_EQ(device, nil); // Should return nil for non-existent device -} +// 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) { +TEST_F(AVAudioTest, When_NilDevice_Then_SetupMicrophoneReturnsError) { AVAudio *avAudio = [[AVAudio alloc] init]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" @@ -90,22 +75,14 @@ EXPECT_EQ(result, -1); // Should fail with nil device } -/** - * @brief Test that setupSystemTap validates channel count parameter. - * Verifies the method returns an error when passed zero channels. - */ -TEST_F(AVAudioTest, SetupSystemTapWithZeroChannelsReturnsError) { - AVAudio *avAudio = [[AVAudio alloc] init]; - int result = [avAudio setupSystemTap:48000 frameSize:512 channels:0]; - [avAudio release]; - EXPECT_EQ(result, -1); // Should fail with zero channels -} +// REMOVED: SetupSystemTapWithZeroChannelsReturnsError - Integration test that calls setupSystemTap which requires TCC +// The setupSystemTap method calls AudioHardwareCreateProcessTap and other Core Audio APIs that require TCC permissions /** * @brief Test basic AVAudio object lifecycle. * Verifies that AVAudio objects can be created and destroyed without issues. */ -TEST_F(AVAudioTest, AVAudioObjectCreationAndDestruction) { +TEST_F(AVAudioTest, When_ObjectCreated_Then_CanBeDestroyedSafely) { AVAudio *avAudio = [[AVAudio alloc] init]; EXPECT_NE(avAudio, nil); // Should create successfully [avAudio release]; // Should not crash @@ -115,7 +92,7 @@ * @brief Test that multiple AVAudio objects can coexist. * Verifies that multiple instances can be created simultaneously. */ -TEST_F(AVAudioTest, AVAudioMultipleObjectsCanBeCreated) { +TEST_F(AVAudioTest, When_MultipleInstancesCreated_Then_TheyCoexistIndependently) { AVAudio *avAudio1 = [[AVAudio alloc] init]; AVAudio *avAudio2 = [[AVAudio alloc] init]; @@ -131,7 +108,7 @@ * @brief Test audio buffer initialization with various channel configurations. * Verifies that the audio buffer can be initialized with different channel counts. */ -TEST_F(AVAudioTest, InitializeAudioBufferSucceeds) { +TEST_F(AVAudioTest, When_BufferInitialized_Then_SemaphoreIsCreated) { AVAudio *avAudio = [[AVAudio alloc] init]; // Test with various channel counts @@ -154,7 +131,7 @@ * @brief Test audio buffer cleanup functionality. * Verifies that cleanup works correctly even with uninitialized buffers. */ -TEST_F(AVAudioTest, CleanupAudioBufferHandlesNilSignal) { +TEST_F(AVAudioTest, When_BufferNotInitialized_Then_CleanupIsNoOp) { AVAudio *avAudio = [[AVAudio alloc] init]; // Should not crash even if buffer was never initialized @@ -169,278 +146,22 @@ [avAudio release]; } -/** - * @brief Test system tap context initialization with valid parameters. - * Verifies that system tap context can be initialized on supported macOS versions. - */ -TEST_F(AVAudioTest, InitSystemTapContextWithValidParameters) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - int result = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - - // On systems with macOS 14.0+, this should succeed - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - EXPECT_EQ(result, 0); - } else { - // On older systems, should fail gracefully - EXPECT_EQ(result, -1); - } - - [avAudio release]; -} - -/** - * @brief Test system tap context initialization with edge case parameters. - * Verifies that system tap handles minimum and maximum reasonable audio parameters. - */ -TEST_F(AVAudioTest, InitSystemTapContextWithEdgeCases) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Test with minimum values - int result1 = [avAudio initializeSystemTapContext:8000 frameSize:64 channels:1]; - EXPECT_EQ(result1, 0); - - // Test with maximum reasonable values - int result2 = [avAudio initializeSystemTapContext:192000 frameSize:4096 channels:8]; - EXPECT_EQ(result2, 0); - } - - [avAudio release]; -} - -/** - * @brief Test Core Audio tap description creation for different channel configurations. - * Verifies that system tap descriptions can be created for various channel counts. - */ -TEST_F(AVAudioTest, CreateSystemTapDescriptionForChannels) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Initialize context first - int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - EXPECT_EQ(initResult, 0); - - // Test mono tap description - CATapDescription *monoTap = [avAudio createSystemTapDescriptionForChannels:1]; - if (monoTap) { - EXPECT_NE(monoTap, nil); - // Note: Can't test properties due to forward declaration limitations - [monoTap release]; - } - - // Test stereo tap description - CATapDescription *stereoTap = [avAudio createSystemTapDescriptionForChannels:2]; - if (stereoTap) { - EXPECT_NE(stereoTap, nil); - // Note: Can't test properties due to forward declaration limitations - [stereoTap release]; - } - } - - [avAudio release]; -} - -/** - * @brief Test system tap context cleanup functionality. - * Verifies that system tap context can be cleaned up safely and multiple times. - */ -TEST_F(AVAudioTest, CleanupSystemTapContext) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Test cleanup without initialization (should not crash) - [avAudio cleanupSystemTapContext:nil]; // Should be safe to call - - // Initialize system tap context - int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - EXPECT_EQ(initResult, 0); - - // Cleanup should work without issues - [avAudio cleanupSystemTapContext:nil]; - - // Multiple cleanup calls should be safe - [avAudio cleanupSystemTapContext:nil]; // Second call should not crash - [avAudio cleanupSystemTapContext:nil]; // Third call should not crash - - // Re-initialize after cleanup should work - int reinitResult = [avAudio initializeSystemTapContext:44100 frameSize:256 channels:1]; - EXPECT_EQ(reinitResult, 0); - - // Final cleanup - [avAudio cleanupSystemTapContext:nil]; - } else { - // On older systems, cleanup should still be safe even though init fails - [avAudio cleanupSystemTapContext:nil]; - } - [avAudio release]; -} - -/** - * @brief Test Core Audio tap mute behavior with hostAudioEnabled property. - * Verifies that tap descriptions have correct mute behavior based on hostAudioEnabled setting. - */ -TEST_F(AVAudioTest, CoreAudioTapMuteBehavior) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Initialize context first - int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - EXPECT_EQ(initResult, 0); - - // Test with host audio disabled (muted) - avAudio.hostAudioEnabled = NO; - CATapDescription *mutedTap = [avAudio createSystemTapDescriptionForChannels:2]; - if (mutedTap) { - EXPECT_NE(mutedTap, nil); - // On macOS 14.0+, we should be able to check the mute behavior - if (@available(macOS 14.0, *)) { - EXPECT_EQ(mutedTap.muteBehavior, CATapMuted); - } - [mutedTap release]; - } - - // Test with host audio enabled (unmuted) - avAudio.hostAudioEnabled = YES; - CATapDescription *unmutedTap = [avAudio createSystemTapDescriptionForChannels:2]; - if (unmutedTap) { - EXPECT_NE(unmutedTap, nil); - // On macOS 14.0+, we should be able to check the mute behavior - if (@available(macOS 14.0, *)) { - EXPECT_EQ(unmutedTap.muteBehavior, CATapUnmuted); - } - [unmutedTap release]; - } - - // Cleanup - [avAudio cleanupSystemTapContext:nil]; - } - - [avAudio release]; -} - -// Type alias for parameterized cleanup system tap context tests -using CleanupSystemTapContextTest = AVAudioTest; - -// Test parameters for cleanup system tap context tests (reusing same configurations) -INSTANTIATE_TEST_SUITE_P( - AVAudioTest, - CleanupSystemTapContextTest, - ::testing::Values( - // Representative subset focusing on different channel configurations - ProcessSystemAudioIOProcTestParams {512, 1, 48000, false, "CleanupMono48kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {512, 2, 48000, false, "CleanupStereo48kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {256, 4, 48000, false, "CleanupQuad48kHz256Frames"}, - ProcessSystemAudioIOProcTestParams {512, 6, 44100, false, "Cleanup51Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {240, 8, 48000, false, "Cleanup71Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams {128, 1, 22050, false, "CleanupMono22kHz128Frames"}, - ProcessSystemAudioIOProcTestParams {1024, 2, 96000, false, "CleanupStereo96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams {128, 8, 192000, false, "Cleanup71Surround192kHz128Frames"} - ), - [](const ::testing::TestParamInfo &info) { - return std::string(info.param.testName); - } -); - -/** - * @brief Parameterized test for system tap context cleanup with various audio configurations. - * Tests init/cleanup cycles across different channel counts, sample rates, and frame sizes. - */ -TEST_P(CleanupSystemTapContextTest, CleanupSystemTapContextParameterized) { - ProcessSystemAudioIOProcTestParams params = GetParam(); - - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Test initialization with the parameterized configuration - int initResult = [avAudio initializeSystemTapContext:params.sampleRate - frameSize:params.frameCount - channels:params.channels]; - EXPECT_EQ(initResult, 0) << "Failed to initialize system tap context for " << params.testName; - - // Test cleanup after successful initialization - [avAudio cleanupSystemTapContext:nil]; - - // Test re-initialization after cleanup (should work) - int reinitResult = [avAudio initializeSystemTapContext:params.sampleRate - frameSize:params.frameCount - channels:params.channels]; - EXPECT_EQ(reinitResult, 0) << "Failed to re-initialize system tap context after cleanup for " << params.testName; - - // Test multiple cleanup calls (should be safe) - [avAudio cleanupSystemTapContext:nil]; - [avAudio cleanupSystemTapContext:nil]; // Second call should not crash - - // Test cleanup without prior initialization (should be safe) - [avAudio cleanupSystemTapContext:nil]; - } else { - // On older systems, cleanup should still be safe even though init fails - [avAudio cleanupSystemTapContext:nil]; - } - - [avAudio release]; -} - -/** - * @brief Test system tap context cleanup with tap description object. - * Verifies cleanup works properly when a tap description is provided. - */ -TEST_F(AVAudioTest, CleanupSystemTapContextWithTapDescription) { - AVAudio *avAudio = [[AVAudio alloc] init]; - - NSOperatingSystemVersion minVersion = {14, 0, 0}; - if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:minVersion]) { - // Initialize system tap context - int initResult = [avAudio initializeSystemTapContext:48000 frameSize:512 channels:2]; - EXPECT_EQ(initResult, 0); - - // Create a tap description - CATapDescription *tapDescription = [avAudio createSystemTapDescriptionForChannels:2]; - if (tapDescription) { - EXPECT_NE(tapDescription, nil); - - // Test cleanup with the tap description object - [avAudio cleanupSystemTapContext:tapDescription]; - // Note: tapDescription should be released by the cleanup method - } else { - // If tap description creation failed, just cleanup normally - [avAudio cleanupSystemTapContext:nil]; - } - - // Additional cleanup should be safe - [avAudio cleanupSystemTapContext:nil]; - } - - [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, AudioConverterComplexInputProcHandlesValidData) { +TEST_F(AVAudioTest, When_ValidDataProvided_Then_AudioConverterCallbackProcessesCorrectly) { AVAudio *avAudio = [[AVAudio alloc] init]; // Create test input data UInt32 frameCount = 256; UInt32 channels = 2; - UInt32 sampleRate = 48000; float *testData = (float *) calloc(frameCount * channels, sizeof(float)); - // Fill with test sine wave data (different frequency per channel) - same as parameterized test + // Fill with deterministic ramp data (channel-encoded constants) for (UInt32 frame = 0; frame < frameCount; frame++) { for (UInt32 channel = 0; channel < channels; channel++) { - // Generate different frequencies for each channel for testing - // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. - double frequency = 440.0 * (channel + 1); - testData[frame * channels + channel] = - (float) (sin(2.0 * M_PI * frequency * frame / (double) sampleRate) * 0.5); + testData[frame * channels + channel] = channel + frame * 0.001f; } } @@ -470,7 +191,7 @@ * @brief Test audio converter callback when no more data is available. * Verifies that the callback handles end-of-data scenarios correctly. */ -TEST_F(AVAudioTest, AudioConverterComplexInputProcHandlesNoMoreData) { +TEST_F(AVAudioTest, When_AllFramesConsumed_Then_CallbackReturnsZeroPackets) { AVAudio *avAudio = [[AVAudio alloc] init]; UInt32 frameCount = 256; @@ -501,7 +222,7 @@ * @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, CleanupAudioBufferMultipleCalls) { +TEST_F(AVAudioTest, When_CleanupCalledMultipleTimes_Then_NocrashOccurs) { AVAudio *avAudio = [[AVAudio alloc] init]; [avAudio initializeAudioBuffer:2]; @@ -521,7 +242,7 @@ * @brief Test buffer management with edge case channel configurations. * Verifies that buffer management works with minimum and maximum channel counts. */ -TEST_F(AVAudioTest, BufferManagementEdgeCases) { +TEST_F(AVAudioTest, When_EdgeCaseChannelCounts_Then_BufferManagesCorrectly) { AVAudio *avAudio = [[AVAudio alloc] init]; // Test with minimum reasonable channel count (1 channel) @@ -540,53 +261,40 @@ // Type alias for parameterized audio processing tests using ProcessSystemAudioIOProcTest = AVAudioTest; -// Test parameters - covering various audio configurations +// Test parameters - representative configurations per spec (≤12 cases) +// 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( - // Mono channel variants - ProcessSystemAudioIOProcTestParams {240, 1, 48000, false, "ValidMono48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams {512, 1, 44100, false, "ValidMono44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {1024, 1, 96000, false, "ValidMono96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams {128, 1, 22050, false, "ValidMono22kHz128Frames"}, - - // Stereo channel variants - ProcessSystemAudioIOProcTestParams {240, 2, 48000, false, "ValidStereo48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams {480, 2, 48000, false, "ValidStereo48kHz480Frames"}, - ProcessSystemAudioIOProcTestParams {512, 2, 44100, false, "ValidStereo44kHz512Frames"}, - - // Quad (4 channel) variants - ProcessSystemAudioIOProcTestParams {256, 4, 48000, false, "ValidQuad48kHz256Frames"}, - ProcessSystemAudioIOProcTestParams {512, 4, 44100, false, "ValidQuad44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {1024, 4, 96000, false, "ValidQuad96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams {128, 4, 22050, false, "ValidQuad22kHz128Frames"}, - - // 5.1 Surround (6 channel) variants - ProcessSystemAudioIOProcTestParams {240, 6, 48000, false, "Valid51Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams {512, 6, 44100, false, "Valid51Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {1024, 6, 96000, false, "Valid51Surround96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams {256, 6, 88200, false, "Valid51Surround88kHz256Frames"}, - - // 7.1 Surround (8 channel) variants - ProcessSystemAudioIOProcTestParams {240, 8, 48000, false, "Valid71Surround48kHz240Frames"}, - ProcessSystemAudioIOProcTestParams {512, 8, 44100, false, "Valid71Surround44kHz512Frames"}, - ProcessSystemAudioIOProcTestParams {1024, 8, 96000, false, "Valid71Surround96kHz1024Frames"}, - ProcessSystemAudioIOProcTestParams {128, 8, 192000, false, "Valid71Surround192kHz128Frames"}, - - // Edge cases with various configurations - ProcessSystemAudioIOProcTestParams {240, 2, 48000, true, "NilInputHandlesGracefully"}, - ProcessSystemAudioIOProcTestParams {64, 2, 8000, false, "ValidStereo8kHz64Frames"}, - ProcessSystemAudioIOProcTestParams {2048, 1, 48000, false, "ValidMono48kHz2048Frames"}, - ProcessSystemAudioIOProcTestParams {32, 4, 176400, false, "ValidQuad176kHz32Frames"}, - ProcessSystemAudioIOProcTestParams {128, 6, 44100, false, "Valid51Surround44kHz128Frames"} // Reduced from 4096 to fit buffer + // Representative channel configurations at common sample rate + ProcessSystemAudioIOProcTestParams {256, 1, 48000, false, "When_MonoInput_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {256, 2, 48000, false, "When_StereoInput_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {256, 6, 48000, false, "When_51SurroundInput_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {256, 8, 48000, false, "When_71SurroundInput_Then_DataWrittenToBuffer"}, + + // Frame count variations (small, typical, large) + ProcessSystemAudioIOProcTestParams {64, 2, 48000, false, "When_SmallFrameCount_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {1024, 2, 48000, false, "When_LargeFrameCount_Then_DataWrittenToBuffer"}, + + // Sample rate edge cases + ProcessSystemAudioIOProcTestParams {256, 2, 44100, false, "When_LegacySampleRate_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {256, 2, 192000, false, "When_HighSampleRate_Then_DataWrittenToBuffer"}, + + // Edge case: nil input handling + ProcessSystemAudioIOProcTestParams {256, 2, 48000, true, "When_NilInput_Then_HandledGracefully"}, + + // Combined edge case: max channels + large frames + ProcessSystemAudioIOProcTestParams {1024, 8, 48000, false, "When_MaxChannelsLargeFrames_Then_DataWrittenToBuffer"} ), [](const ::testing::TestParamInfo &info) { return std::string(info.param.testName); } ); -TEST_P(ProcessSystemAudioIOProcTest, ProcessSystemAudioIOProc) { +TEST_P(ProcessSystemAudioIOProcTest, When_InputProvided_Then_BehaviorMatchesExpectation) { ProcessSystemAudioIOProcTestParams params = GetParam(); AVAudio *avAudio = [[AVAudio alloc] init]; @@ -608,14 +316,11 @@ inputDataSize = params.frameCount * params.channels * sizeof(float); testInputData = (float *) calloc(params.frameCount * params.channels, sizeof(float)); - // Fill with test sine wave data (different frequency per channel) + // 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++) { - // Generate different frequencies for each channel for testing - // Channel 0: 440Hz, Channel 1: 880Hz, Channel 2: 1320Hz, etc. - double frequency = 440.0 * (channel + 1); - testInputData[frame * params.channels + channel] = - (float) (sin(2.0 * M_PI * frequency * frame / (double) params.sampleRate) * 0.5); + testInputData[frame * params.channels + channel] = channel + frame * 0.001f; } } @@ -667,8 +372,8 @@ EXPECT_EQ(finalAvailableBytes, inputDataSize); // Verify the actual audio data matches what we put in (first few samples) - // Test up to 16 samples or 4 complete frames, whichever is smaller - UInt32 samplesToTest = std::min(16U, params.channels * 4); // Up to 4 frames worth + // Limit validation to min(8, channels * 2) per optimization spec + 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++) { From db69ca2f5287fa623a179b6362acffcded028367 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 3 Jan 2026 16:28:04 +0100 Subject: [PATCH 29/30] test(macos): fixed typos --- tests/unit/platform/test_macos_av_audio.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index 89f4a6e2d3a..fd9d34945d3 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -372,7 +372,7 @@ EXPECT_EQ(finalAvailableBytes, inputDataSize); // Verify the actual audio data matches what we put in (first few samples) - // Limit validation to min(8, channels * 2) per optimization spec + // 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; From f5a6672508c19dab0563badb02f7ef77cd594a60 Mon Sep 17 00:00:00 2001 From: Thomas Van Laere Date: Sat, 10 Jan 2026 12:31:55 +0100 Subject: [PATCH 30/30] tests(macos): clean up and align AVAudioTest naming with project conventions --- tests/unit/platform/test_macos_av_audio.mm | 54 +++++++++------------- 1 file changed, 23 insertions(+), 31 deletions(-) diff --git a/tests/unit/platform/test_macos_av_audio.mm b/tests/unit/platform/test_macos_av_audio.mm index fd9d34945d3..1f1b89fa6db 100644 --- a/tests/unit/platform/test_macos_av_audio.mm +++ b/tests/unit/platform/test_macos_av_audio.mm @@ -13,7 +13,6 @@ #import #import - // Include the header for the class we're testing #import /** @@ -34,15 +33,11 @@ */ class AVAudioTest: public PlatformTestSuite, public ::testing::WithParamInterface {}; -// ===== UNIT TESTS: Tests that do NOT require TCC permissions or real hardware ===== - -// REMOVED: MicrophoneNamesReturnsArray - Integration test that queries real AVFoundation devices - /** * @brief Test that findMicrophone handles nil input gracefully. * Verifies the method returns nil when passed a nil microphone name. */ -TEST_F(AVAudioTest, When_NilMicrophoneName_Then_ReturnsNil) { +TEST_F(AVAudioTest, FindMicrophoneWithNilReturnsNil) { #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" AVCaptureDevice *device = [AVAudio findMicrophone:nil]; @@ -54,7 +49,7 @@ * @brief Test that findMicrophone handles empty string input gracefully. * Verifies the method returns nil when passed an empty microphone name. */ -TEST_F(AVAudioTest, When_EmptyMicrophoneName_Then_ReturnsNil) { +TEST_F(AVAudioTest, FindMicrophoneWithEmptyStringReturnsNil) { AVCaptureDevice *device = [AVAudio findMicrophone:@""]; EXPECT_EQ(device, nil); // Should return nil for empty string } @@ -65,7 +60,7 @@ * @brief Test that setupMicrophone handles nil device input properly. * Verifies the method returns an error code when passed a nil device. */ -TEST_F(AVAudioTest, When_NilDevice_Then_SetupMicrophoneReturnsError) { +TEST_F(AVAudioTest, SetupMicrophoneWithNilDeviceReturnsError) { AVAudio *avAudio = [[AVAudio alloc] init]; #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wnonnull" @@ -75,14 +70,11 @@ EXPECT_EQ(result, -1); // Should fail with nil device } -// REMOVED: SetupSystemTapWithZeroChannelsReturnsError - Integration test that calls setupSystemTap which requires TCC -// The setupSystemTap method calls AudioHardwareCreateProcessTap and other Core Audio APIs that require TCC permissions - /** * @brief Test basic AVAudio object lifecycle. * Verifies that AVAudio objects can be created and destroyed without issues. */ -TEST_F(AVAudioTest, When_ObjectCreated_Then_CanBeDestroyedSafely) { +TEST_F(AVAudioTest, ObjectLifecycle) { AVAudio *avAudio = [[AVAudio alloc] init]; EXPECT_NE(avAudio, nil); // Should create successfully [avAudio release]; // Should not crash @@ -92,7 +84,7 @@ * @brief Test that multiple AVAudio objects can coexist. * Verifies that multiple instances can be created simultaneously. */ -TEST_F(AVAudioTest, When_MultipleInstancesCreated_Then_TheyCoexistIndependently) { +TEST_F(AVAudioTest, MultipleObjectsCoexist) { AVAudio *avAudio1 = [[AVAudio alloc] init]; AVAudio *avAudio2 = [[AVAudio alloc] init]; @@ -108,7 +100,7 @@ * @brief Test audio buffer initialization with various channel configurations. * Verifies that the audio buffer can be initialized with different channel counts. */ -TEST_F(AVAudioTest, When_BufferInitialized_Then_SemaphoreIsCreated) { +TEST_F(AVAudioTest, InitializeAudioBuffer) { AVAudio *avAudio = [[AVAudio alloc] init]; // Test with various channel counts @@ -131,7 +123,7 @@ * @brief Test audio buffer cleanup functionality. * Verifies that cleanup works correctly even with uninitialized buffers. */ -TEST_F(AVAudioTest, When_BufferNotInitialized_Then_CleanupIsNoOp) { +TEST_F(AVAudioTest, CleanupUninitializedBuffer) { AVAudio *avAudio = [[AVAudio alloc] init]; // Should not crash even if buffer was never initialized @@ -150,7 +142,7 @@ * @brief Test audio converter complex input callback with valid data. * Verifies that the audio converter callback properly processes valid audio data. */ -TEST_F(AVAudioTest, When_ValidDataProvided_Then_AudioConverterCallbackProcessesCorrectly) { +TEST_F(AVAudioTest, AudioConverterComplexInputProc) { AVAudio *avAudio = [[AVAudio alloc] init]; // Create test input data @@ -191,7 +183,7 @@ * @brief Test audio converter callback when no more data is available. * Verifies that the callback handles end-of-data scenarios correctly. */ -TEST_F(AVAudioTest, When_AllFramesConsumed_Then_CallbackReturnsZeroPackets) { +TEST_F(AVAudioTest, AudioConverterInputProcNoMoreData) { AVAudio *avAudio = [[AVAudio alloc] init]; UInt32 frameCount = 256; @@ -222,7 +214,7 @@ * @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, When_CleanupCalledMultipleTimes_Then_NocrashOccurs) { +TEST_F(AVAudioTest, CleanupAudioBufferMultipleTimes) { AVAudio *avAudio = [[AVAudio alloc] init]; [avAudio initializeAudioBuffer:2]; @@ -242,7 +234,7 @@ * @brief Test buffer management with edge case channel configurations. * Verifies that buffer management works with minimum and maximum channel counts. */ -TEST_F(AVAudioTest, When_EdgeCaseChannelCounts_Then_BufferManagesCorrectly) { +TEST_F(AVAudioTest, BufferManagementEdgeCases) { AVAudio *avAudio = [[AVAudio alloc] init]; // Test with minimum reasonable channel count (1 channel) @@ -261,7 +253,7 @@ // Type alias for parameterized audio processing tests using ProcessSystemAudioIOProcTest = AVAudioTest; -// Test parameters - representative configurations per spec (≤12 cases) +// 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) @@ -270,31 +262,31 @@ ProcessSystemAudioIOProcTest, ::testing::Values( // Representative channel configurations at common sample rate - ProcessSystemAudioIOProcTestParams {256, 1, 48000, false, "When_MonoInput_Then_DataWrittenToBuffer"}, - ProcessSystemAudioIOProcTestParams {256, 2, 48000, false, "When_StereoInput_Then_DataWrittenToBuffer"}, - ProcessSystemAudioIOProcTestParams {256, 6, 48000, false, "When_51SurroundInput_Then_DataWrittenToBuffer"}, - ProcessSystemAudioIOProcTestParams {256, 8, 48000, false, "When_71SurroundInput_Then_DataWrittenToBuffer"}, + 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, "When_SmallFrameCount_Then_DataWrittenToBuffer"}, - ProcessSystemAudioIOProcTestParams {1024, 2, 48000, false, "When_LargeFrameCount_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {64, 2, 48000, false, "SmallFrameCount"}, + ProcessSystemAudioIOProcTestParams {1024, 2, 48000, false, "LargeFrameCount"}, // Sample rate edge cases - ProcessSystemAudioIOProcTestParams {256, 2, 44100, false, "When_LegacySampleRate_Then_DataWrittenToBuffer"}, - ProcessSystemAudioIOProcTestParams {256, 2, 192000, false, "When_HighSampleRate_Then_DataWrittenToBuffer"}, + ProcessSystemAudioIOProcTestParams {256, 2, 44100, false, "LegacySampleRate44kHz"}, + ProcessSystemAudioIOProcTestParams {256, 2, 192000, false, "HighSampleRate192kHz"}, // Edge case: nil input handling - ProcessSystemAudioIOProcTestParams {256, 2, 48000, true, "When_NilInput_Then_HandledGracefully"}, + ProcessSystemAudioIOProcTestParams {256, 2, 48000, true, "NilInputHandling"}, // Combined edge case: max channels + large frames - ProcessSystemAudioIOProcTestParams {1024, 8, 48000, false, "When_MaxChannelsLargeFrames_Then_DataWrittenToBuffer"} + ProcessSystemAudioIOProcTestParams {1024, 8, 48000, false, "MaxChannelsLargeFrames"} ), [](const ::testing::TestParamInfo &info) { return std::string(info.param.testName); } ); -TEST_P(ProcessSystemAudioIOProcTest, When_InputProvided_Then_BehaviorMatchesExpectation) { +TEST_P(ProcessSystemAudioIOProcTest, ProcessAudioInput) { ProcessSystemAudioIOProcTestParams params = GetParam(); AVAudio *avAudio = [[AVAudio alloc] init];