mirror of
https://github.com/LizardByte/Sunshine.git
synced 2026-05-06 13:40:23 +08:00
feat(macOS): Capture audio on macOS using Tap API (#4209)
Co-authored-by: David Lane <42013603+ReenigneArcher@users.noreply.github.com>
This commit is contained in:
41
.github/workflows/ci-homebrew.yml
vendored
41
.github/workflows/ci-homebrew.yml
vendored
@@ -61,6 +61,47 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Seed additional macOS TCC permissions
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
configure_system_tccdb() {
|
||||
local values=$1
|
||||
local dbPath="/Library/Application Support/com.apple.TCC/TCC.db"
|
||||
local sqlQuery="INSERT OR IGNORE INTO access VALUES($values);"
|
||||
sudo sqlite3 "$dbPath" "$sqlQuery"
|
||||
}
|
||||
|
||||
configure_user_tccdb() {
|
||||
local values=$1
|
||||
local dbPath="$HOME/Library/Application Support/com.apple.TCC/TCC.db"
|
||||
local sqlQuery="INSERT OR IGNORE INTO access VALUES($values);"
|
||||
sqlite3 "$dbPath" "$sqlQuery"
|
||||
}
|
||||
|
||||
systemValuesArray=(
|
||||
"'kTCCServiceAudioCapture','/opt/hca/hosted-compute-agent',1,2,4,1,NULL,NULL,NULL,'UNUSED',NULL,0,1736467200"
|
||||
"'kTCCServiceAudioCapture','/opt/hca/start_hca.sh',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1576661342"
|
||||
"'kTCCServiceAudioCapture','/usr/local/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,NULL,'UNUSED',NULL,0,1736467200"
|
||||
"'kTCCServiceAudioCapture','/usr/local/opt/runner/runprovisioner.sh',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1576661342"
|
||||
)
|
||||
for values in "${systemValuesArray[@]}"; do
|
||||
configure_system_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}"
|
||||
done
|
||||
|
||||
userValuesArray=(
|
||||
"'kTCCServiceAudioCapture','/opt/hca/hosted-compute-agent',1,2,4,1,NULL,NULL,NULL,'UNUSED',NULL,0,1736467200"
|
||||
"'kTCCServiceAudioCapture','/opt/hca/start_hca.sh',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1576661342"
|
||||
"'kTCCServiceAudioCapture','/usr/local/opt/runner/provisioner/provisioner',1,2,4,1,NULL,NULL,NULL,'UNUSED',NULL,0,1736467200"
|
||||
"'kTCCServiceAudioCapture','/usr/local/opt/runner/runprovisioner.sh',1,2,0,1,NULL,NULL,NULL,'UNUSED',NULL,0,1576661342"
|
||||
)
|
||||
for values in "${userValuesArray[@]}"; do
|
||||
configure_user_tccdb "$values,NULL,NULL,'UNUSED',${values##*,}"
|
||||
done
|
||||
|
||||
echo "macOS TCC permissions configured."
|
||||
|
||||
- name: Configure formula
|
||||
env:
|
||||
INPUT_RELEASE_VERSION: ${{ inputs.release_version }}
|
||||
|
||||
1
.github/workflows/ci-macos.yml
vendored
1
.github/workflows/ci-macos.yml
vendored
@@ -39,6 +39,7 @@ env:
|
||||
BRANCH: ${{ github.head_ref || github.ref_name }}
|
||||
BUILD_VERSION: ${{ inputs.release_version }}
|
||||
COMMIT: ${{ inputs.release_commit }}
|
||||
MACOSX_DEPLOYMENT_TARGET: 14.2
|
||||
|
||||
jobs:
|
||||
build_dmg:
|
||||
|
||||
@@ -242,7 +242,7 @@ LizardByte has the full documentation hosted on [Read the Docs](https://docs.liz
|
||||
<td>Linux/Ubuntu: 22.04+ (jammy)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>macOS: 14+</td>
|
||||
<td>macOS: 14.2+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Windows: 11+ (Windows Server does not support virtual gamepads)</td>
|
||||
|
||||
@@ -28,7 +28,10 @@ endif()
|
||||
list(APPEND SUNSHINE_EXTERNAL_LIBRARIES
|
||||
${APP_KIT_LIBRARY}
|
||||
${APP_SERVICES_LIBRARY}
|
||||
${AUDIO_TOOLBOX_LIBRARY}
|
||||
${AUDIO_UNIT_LIBRARY}
|
||||
${AV_FOUNDATION_LIBRARY}
|
||||
${CORE_AUDIO_LIBRARY}
|
||||
${CORE_MEDIA_LIBRARY}
|
||||
${CORE_VIDEO_LIBRARY}
|
||||
${FOUNDATION_LIBRARY}
|
||||
@@ -40,7 +43,7 @@ configure_file("${APPLE_PLIST_TEMPLATE}" "${APPLE_PLIST_FILE}" @ONLY)
|
||||
|
||||
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"
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
FIND_LIBRARY(APP_KIT_LIBRARY AppKit)
|
||||
FIND_LIBRARY(APP_SERVICES_LIBRARY ApplicationServices)
|
||||
FIND_LIBRARY(AUDIO_TOOLBOX_LIBRARY AudioToolbox)
|
||||
FIND_LIBRARY(AUDIO_UNIT_LIBRARY AudioUnit)
|
||||
FIND_LIBRARY(AV_FOUNDATION_LIBRARY AVFoundation)
|
||||
FIND_LIBRARY(CORE_AUDIO_LIBRARY CoreAudio)
|
||||
FIND_LIBRARY(CORE_MEDIA_LIBRARY CoreMedia)
|
||||
FIND_LIBRARY(CORE_VIDEO_LIBRARY CoreVideo)
|
||||
FIND_LIBRARY(FOUNDATION_LIBRARY Foundation)
|
||||
|
||||
@@ -464,9 +464,13 @@ systemctl --user --now enable app-dev.lizardbyte.app.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
|
||||
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 prefer to manage your own loopback device, you can still use
|
||||
[Soundflower](https://github.com/mattingalls/Soundflower) or
|
||||
[BlackHole](https://github.com/ExistentialAudio/BlackHole).
|
||||
[BlackHole](https://github.com/ExistentialAudio/BlackHole)
|
||||
and enter its device name in the [audio_sink](configuration.md#audio_sink) field.
|
||||
|
||||
> [!NOTE]
|
||||
> Command Keys are not forwarded by Moonlight. Right Option-Key is mapped to CMD-Key.
|
||||
|
||||
@@ -296,9 +296,6 @@ class Sunshine < Formula
|
||||
|
||||
if OS.mac?
|
||||
opoo <<~EOS
|
||||
Sunshine can only access microphones on macOS due to system limitations.
|
||||
To stream system audio use "Soundflower" or "BlackHole".
|
||||
|
||||
Gamepads are not currently supported on macOS.
|
||||
EOS
|
||||
end
|
||||
|
||||
@@ -194,8 +194,9 @@ namespace audio {
|
||||
}
|
||||
|
||||
auto frame_size = config.packetDuration * stream.sampleRate / 1000;
|
||||
bool host_audio = config.flags[config_t::HOST_AUDIO];
|
||||
bool continuous_audio = config.flags[config_t::CONTINUOUS_AUDIO];
|
||||
auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio);
|
||||
auto mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio, host_audio);
|
||||
if (!mic) {
|
||||
return;
|
||||
}
|
||||
@@ -232,7 +233,7 @@ namespace audio {
|
||||
BOOST_LOG(info) << "Reinitializing audio capture"sv;
|
||||
mic.reset();
|
||||
do {
|
||||
mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio);
|
||||
mic = control->microphone(stream.mapping, stream.channelCount, stream.sampleRate, frame_size, continuous_audio, host_audio);
|
||||
if (!mic) {
|
||||
BOOST_LOG(warning) << "Couldn't re-initialize audio input"sv;
|
||||
}
|
||||
|
||||
@@ -145,10 +145,10 @@ namespace config {
|
||||
};
|
||||
|
||||
struct audio_t {
|
||||
std::string sink;
|
||||
std::string virtual_sink;
|
||||
bool stream;
|
||||
bool install_steam_drivers;
|
||||
std::string sink; ///< Audio output device/sink to use for audio capture
|
||||
std::string virtual_sink; ///< Virtual audio sink for audio routing
|
||||
bool stream; ///< Enable audio streaming to clients
|
||||
bool install_steam_drivers; ///< Install Steam audio drivers for enhanced compatibility
|
||||
};
|
||||
|
||||
constexpr int ENCRYPTION_MODE_NEVER = 0; // Never use video encryption, even if the client supports it
|
||||
|
||||
@@ -568,7 +568,7 @@ namespace platf {
|
||||
public:
|
||||
virtual int set_sink(const std::string &sink) = 0;
|
||||
|
||||
virtual std::unique_ptr<mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous) = 0;
|
||||
virtual std::unique_ptr<mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous, [[maybe_unused]] bool host_audio_enabled) = 0;
|
||||
|
||||
/**
|
||||
* @brief Check if the audio sink is available in the system.
|
||||
|
||||
@@ -441,7 +441,7 @@ namespace platf {
|
||||
return monitor_name;
|
||||
}
|
||||
|
||||
std::unique_ptr<mic_t> 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<mic_t> 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)
|
||||
|
||||
@@ -1,29 +1,168 @@
|
||||
/**
|
||||
* @file src/platform/macos/av_audio.h
|
||||
* @brief Declarations for audio capture on macOS.
|
||||
* @brief Declarations for macOS audio capture with dual input paths.
|
||||
*
|
||||
* This header defines the AVAudio class which provides distinct audio capture methods:
|
||||
* 1. **Microphone capture** - Uses AVFoundation framework to capture from specific microphone devices
|
||||
* 2. **System-wide audio tap** - Uses Core Audio taps to capture all system audio output (macOS 14.0+)
|
||||
*
|
||||
* The system-wide audio tap allows capturing audio from all applications and system sounds,
|
||||
* while microphone capture focuses on input from physical or virtual microphone devices.
|
||||
*/
|
||||
#pragma once
|
||||
|
||||
// platform includes
|
||||
#import <AudioToolbox/AudioToolbox.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <CoreAudio/AudioHardwareTapping.h>
|
||||
#import <CoreAudio/CoreAudio.h>
|
||||
|
||||
// lib includes
|
||||
#include "third-party/TPCircularBuffer/TPCircularBuffer.h"
|
||||
|
||||
static const int kBufferLength = 4096;
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Forward declarations
|
||||
@class AVAudio;
|
||||
@class CATapDescription;
|
||||
|
||||
namespace platf {
|
||||
OSStatus audioConverterComplexInputProc(AudioConverterRef _Nullable inAudioConverter, UInt32 *_Nonnull ioNumberDataPackets, AudioBufferList *_Nonnull ioData, AudioStreamPacketDescription *_Nullable *_Nullable outDataPacketDescription, void *_Nonnull inUserData);
|
||||
OSStatus systemAudioIOProc(AudioObjectID inDevice, const AudioTimeStamp *_Nullable inNow, const AudioBufferList *_Nullable inInputData, const AudioTimeStamp *_Nullable inInputTime, AudioBufferList *_Nullable outOutputData, const AudioTimeStamp *_Nullable inOutputTime, void *_Nullable inClientData);
|
||||
} // namespace platf
|
||||
|
||||
/**
|
||||
* @brief Data structure for AudioConverter input callback.
|
||||
* Contains audio data and metadata needed for format conversion during audio processing.
|
||||
*/
|
||||
struct AudioConverterInputData {
|
||||
float *inputData; ///< Pointer to input audio data
|
||||
UInt32 inputFrames; ///< Total number of input frames available
|
||||
UInt32 framesProvided; ///< Number of frames already provided to converter
|
||||
UInt32 deviceChannels; ///< Number of channels in the device audio
|
||||
AVAudio *avAudio; ///< Reference to the AVAudio instance
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief IOProc client data structure for Core Audio system taps.
|
||||
* Contains configuration and conversion data for real-time audio processing.
|
||||
*/
|
||||
typedef struct {
|
||||
AVAudio *avAudio; ///< Reference to AVAudio instance
|
||||
UInt32 clientRequestedChannels; ///< Number of channels requested by client
|
||||
UInt32 clientRequestedSampleRate; ///< Sample rate requested by client
|
||||
UInt32 clientRequestedFrameSize; ///< Frame size requested by client
|
||||
UInt32 aggregateDeviceSampleRate; ///< Sample rate of the aggregate device
|
||||
UInt32 aggregateDeviceChannels; ///< Number of channels in aggregate device
|
||||
AudioConverterRef _Nullable audioConverter; ///< Audio converter for format conversion
|
||||
float *_Nullable conversionBuffer; ///< Pre-allocated buffer for audio conversion
|
||||
UInt32 conversionBufferSize; ///< Size of the conversion buffer in bytes
|
||||
} AVAudioIOProcData;
|
||||
|
||||
/**
|
||||
* @brief Core Audio capture class for macOS audio input and system-wide audio tapping.
|
||||
* Provides functionality for both microphone capture via AVFoundation and system-wide
|
||||
* audio capture via Core Audio taps (requires macOS 14.0+).
|
||||
*/
|
||||
@interface AVAudio: NSObject <AVCaptureAudioDataOutputSampleBufferDelegate> {
|
||||
@public
|
||||
TPCircularBuffer audioSampleBuffer;
|
||||
TPCircularBuffer audioSampleBuffer; ///< Shared circular buffer for both audio capture paths
|
||||
dispatch_semaphore_t audioSemaphore; ///< Real-time safe semaphore for signaling audio sample availability
|
||||
@private
|
||||
// System-wide audio tap components (Core Audio)
|
||||
AudioObjectID tapObjectID; ///< Core Audio tap object identifier for system audio capture
|
||||
AudioObjectID aggregateDeviceID; ///< Aggregate device ID for system tap audio routing
|
||||
AudioDeviceIOProcID ioProcID; ///< IOProc identifier for real-time audio processing
|
||||
AVAudioIOProcData *_Nullable ioProcData; ///< Context data for IOProc callbacks and format conversion
|
||||
}
|
||||
|
||||
@property (nonatomic, assign) AVCaptureSession *audioCaptureSession;
|
||||
@property (nonatomic, assign) AVCaptureConnection *audioConnection;
|
||||
@property (nonatomic, assign) NSCondition *samplesArrivedSignal;
|
||||
// AVFoundation microphone capture properties
|
||||
@property (nonatomic, assign, nullable) AVCaptureSession *audioCaptureSession; ///< AVFoundation capture session for microphone input
|
||||
@property (nonatomic, assign, nullable) AVCaptureConnection *audioConnection; ///< Audio connection within the capture session
|
||||
@property (nonatomic, assign) BOOL hostAudioEnabled; ///< Whether host audio playback should be enabled (affects tap mute behavior)
|
||||
|
||||
+ (NSArray *)microphoneNames;
|
||||
+ (AVCaptureDevice *)findMicrophone:(NSString *)name;
|
||||
/**
|
||||
* @brief Get all available microphone devices on the system.
|
||||
* @return Array of AVCaptureDevice objects representing available microphones
|
||||
*/
|
||||
+ (NSArray<AVCaptureDevice *> *)microphones;
|
||||
|
||||
- (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels;
|
||||
/**
|
||||
* @brief Get names of all available microphone devices.
|
||||
* @return Array of NSString objects with microphone device names
|
||||
*/
|
||||
+ (NSArray<NSString *> *)microphoneNames;
|
||||
|
||||
/**
|
||||
* @brief Find a specific microphone device by name.
|
||||
* @param name The name of the microphone to find (nullable - will return nil if name is nil)
|
||||
* @return AVCaptureDevice object if found, nil otherwise
|
||||
*/
|
||||
+ (nullable AVCaptureDevice *)findMicrophone:(nullable NSString *)name;
|
||||
|
||||
/**
|
||||
* @brief Sets up microphone capture using AVFoundation framework.
|
||||
* @param device The AVCaptureDevice to use for audio input (nullable - will return error if nil)
|
||||
* @param sampleRate Target sample rate in Hz
|
||||
* @param frameSize Number of frames per buffer
|
||||
* @param channels Number of audio channels (1=mono, 2=stereo)
|
||||
* @return 0 on success, -1 on failure
|
||||
*/
|
||||
- (int)setupMicrophone:(nullable AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels;
|
||||
|
||||
/**
|
||||
* @brief Sets up system-wide audio tap for capturing all system audio.
|
||||
* Requires macOS 14.0+ and appropriate permissions.
|
||||
* @param sampleRate Target sample rate in Hz
|
||||
* @param frameSize Number of frames per buffer
|
||||
* @param channels Number of audio channels
|
||||
* @return 0 on success, -1 on failure
|
||||
*/
|
||||
- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels;
|
||||
|
||||
// Buffer management methods for testing and internal use
|
||||
/**
|
||||
* @brief Initializes the circular audio buffer for the specified number of channels.
|
||||
* @param channels Number of audio channels to configure the buffer for
|
||||
*/
|
||||
- (void)initializeAudioBuffer:(UInt8)channels;
|
||||
|
||||
/**
|
||||
* @brief Cleans up and deallocates the audio buffer resources.
|
||||
*/
|
||||
- (void)cleanupAudioBuffer;
|
||||
|
||||
/**
|
||||
* @brief Cleans up system tap resources in a safe, ordered manner.
|
||||
* @param tapDescription Optional tap description object to release (can be nil)
|
||||
*/
|
||||
- (void)cleanupSystemTapContext:(nullable id)tapDescription;
|
||||
|
||||
/**
|
||||
* @brief Initializes the system tap context with specified audio parameters.
|
||||
* @param sampleRate Target sample rate in Hz
|
||||
* @param frameSize Number of frames per buffer
|
||||
* @param channels Number of audio channels
|
||||
* @return 0 on success, -1 on failure
|
||||
*/
|
||||
- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels;
|
||||
|
||||
/**
|
||||
* @brief Creates a Core Audio tap description for system audio capture.
|
||||
* @param channels Number of audio channels to configure the tap for
|
||||
* @return CATapDescription object on success, nil on failure
|
||||
*/
|
||||
- (nullable CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels;
|
||||
|
||||
/**
|
||||
* @brief Creates an aggregate device with the specified tap description and audio parameters.
|
||||
* @param tapDescription Core Audio tap description for system audio capture
|
||||
* @param sampleRate Target sample rate in Hz
|
||||
* @param frameSize Number of frames per buffer
|
||||
* @return OSStatus indicating success (noErr) or error code
|
||||
*/
|
||||
- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
|
||||
@@ -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<AVCaptureDevice *> *)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<NSString *> *)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
|
||||
850
src/platform/macos/av_audio.mm
Normal file
850
src/platform/macos/av_audio.mm
Normal file
@@ -0,0 +1,850 @@
|
||||
/**
|
||||
* @file src/platform/macos/av_audio.mm
|
||||
* @brief Implementation of macOS audio capture with dual input paths.
|
||||
*
|
||||
* This file implements the AVAudio class which provides two distinct audio capture methods:
|
||||
* 1. **Microphone capture** - Uses AVFoundation framework to capture from specific microphone devices
|
||||
* 2. **System-wide audio tap** - Uses Core Audio taps to capture all system audio output (macOS 14.0+)
|
||||
*
|
||||
* The implementation handles format conversion, real-time audio processing, and provides
|
||||
* a unified interface for both capture methods through a shared circular buffer.
|
||||
*/
|
||||
#import "av_audio.h"
|
||||
|
||||
#include "coreaudio_helpers.h"
|
||||
#include "src/logging.h"
|
||||
#include "src/utility.h"
|
||||
|
||||
#import <AudioToolbox/AudioConverter.h>
|
||||
#import <CoreAudio/CATapDescription.h>
|
||||
|
||||
namespace platf {
|
||||
using namespace std::literals;
|
||||
|
||||
/**
|
||||
* @brief Real-time AudioConverter input callback for format conversion.
|
||||
* Provides audio data to AudioConverter during format conversion process using pure C++ for optimal performance.
|
||||
* This function must avoid all Objective-C runtime calls to meet real-time audio constraints.
|
||||
* @param inAudioConverter The audio converter requesting input data
|
||||
* @param ioNumberDataPackets Number of data packets to provide
|
||||
* @param ioData Buffer list to fill with audio data
|
||||
* @param outDataPacketDescription Packet description for output data
|
||||
* @param inUserData User data containing AudioConverterInputData structure
|
||||
* @return OSStatus indicating success (noErr) or error code
|
||||
*/
|
||||
OSStatus audioConverterComplexInputProc(AudioConverterRef inAudioConverter, UInt32 *ioNumberDataPackets, AudioBufferList *ioData, AudioStreamPacketDescription **outDataPacketDescription, void *inUserData) {
|
||||
auto *inputInfo = static_cast<AudioConverterInputData *>(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<AVAudioIOProcData *>(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<float *>(inputBuffer.mData);
|
||||
UInt32 deviceChannels = procData->aggregateDeviceChannels;
|
||||
UInt32 inputFrames = inputBuffer.mDataByteSize / (deviceChannels * sizeof(float));
|
||||
|
||||
// Use AudioConverter if we need any conversion, otherwise pass through
|
||||
if (procData->audioConverter) {
|
||||
// Use pre-allocated buffer instead of malloc for real-time safety!
|
||||
UInt32 maxOutputFrames = procData->conversionBufferSize / (clientChannels * sizeof(float));
|
||||
UInt32 requestedOutputFrames = maxOutputFrames;
|
||||
|
||||
AudioConverterInputData inputData = {0};
|
||||
inputData.inputData = inputSamples;
|
||||
inputData.inputFrames = inputFrames;
|
||||
inputData.framesProvided = 0; // Critical: must start at 0!
|
||||
inputData.deviceChannels = deviceChannels;
|
||||
inputData.avAudio = avAudio;
|
||||
|
||||
AudioBufferList outputBufferList = {0};
|
||||
outputBufferList.mNumberBuffers = 1;
|
||||
outputBufferList.mBuffers[0].mNumberChannels = clientChannels;
|
||||
outputBufferList.mBuffers[0].mDataByteSize = procData->conversionBufferSize;
|
||||
outputBufferList.mBuffers[0].mData = procData->conversionBuffer;
|
||||
|
||||
UInt32 outputFrameCount = requestedOutputFrames;
|
||||
OSStatus converterStatus = AudioConverterFillComplexBuffer(
|
||||
procData->audioConverter,
|
||||
audioConverterComplexInputProc,
|
||||
&inputData,
|
||||
&outputFrameCount,
|
||||
&outputBufferList,
|
||||
nullptr
|
||||
);
|
||||
|
||||
if (converterStatus == noErr && outputFrameCount > 0) {
|
||||
// AudioConverter did all the work: sample rate + channels + optimal frame count
|
||||
UInt32 actualOutputBytes = outputFrameCount * clientChannels * sizeof(float);
|
||||
TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, procData->conversionBuffer, actualOutputBytes);
|
||||
didWriteData = true;
|
||||
} else {
|
||||
// Fallback: write original data
|
||||
TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize);
|
||||
didWriteData = true;
|
||||
}
|
||||
} else {
|
||||
// No conversion needed - direct passthrough
|
||||
TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, inputBuffer.mData, inputBuffer.mDataByteSize);
|
||||
didWriteData = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always signal, even if we didn't write data (ensures consumer doesn't block)
|
||||
if (!didWriteData) {
|
||||
// Write silence if no valid input data - use pre-allocated buffer or small stack buffer
|
||||
UInt32 silenceFrames = clientFrameSize > 0 ? std::min(clientFrameSize, 2048U) : 512U;
|
||||
|
||||
if (procData->conversionBuffer && procData->conversionBufferSize > 0) {
|
||||
// Use pre-allocated conversion buffer for silence
|
||||
UInt32 maxSilenceFrames = procData->conversionBufferSize / (clientChannels * sizeof(float));
|
||||
silenceFrames = std::min(silenceFrames, maxSilenceFrames);
|
||||
UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float);
|
||||
|
||||
// Creating actual silence
|
||||
memset(procData->conversionBuffer, 0, silenceBytes);
|
||||
TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, procData->conversionBuffer, silenceBytes);
|
||||
} else {
|
||||
// Fallback to small stack-allocated buffer for cases without conversion buffer
|
||||
float silenceBuffer[512 * 8] = {0}; // Max 512 frames, 8 channels on stack
|
||||
UInt32 maxStackFrames = sizeof(silenceBuffer) / (clientChannels * sizeof(float));
|
||||
silenceFrames = std::min(silenceFrames, maxStackFrames);
|
||||
UInt32 silenceBytes = silenceFrames * clientChannels * sizeof(float);
|
||||
|
||||
TPCircularBufferProduceBytes(&avAudio->audioSampleBuffer, silenceBuffer, silenceBytes);
|
||||
}
|
||||
}
|
||||
|
||||
// Signal new data arrival - using real-time safe C-based semaphore
|
||||
// instead of Objective-C NSCondition to meet real-time audio constraints
|
||||
dispatch_semaphore_signal(avAudio->audioSemaphore);
|
||||
|
||||
return noErr;
|
||||
}
|
||||
} // namespace platf
|
||||
|
||||
@implementation AVAudio
|
||||
|
||||
+ (NSArray<AVCaptureDevice *> *)microphones {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "Discovering microphones"sv;
|
||||
|
||||
if ([[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {10, 15, 0})]) {
|
||||
BOOST_LOG(debug) << "Using modern AVCaptureDeviceDiscoverySession API"sv;
|
||||
// This will generate a warning about AVCaptureDeviceDiscoverySession being
|
||||
// unavailable before macOS 10.15, but we have a guard to prevent it from
|
||||
// being called on those earlier systems.
|
||||
// Unfortunately the supported way to silence this warning, using @available,
|
||||
// produces linker errors for __isPlatformVersionAtLeast, so we have to use
|
||||
// a different method.
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wunguarded-availability-new"
|
||||
AVCaptureDeviceDiscoverySession *discoverySession = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[AVCaptureDeviceTypeMicrophone, AVCaptureDeviceTypeExternal]
|
||||
mediaType:AVMediaTypeAudio
|
||||
position:AVCaptureDevicePositionUnspecified];
|
||||
NSArray *devices = discoverySession.devices;
|
||||
BOOST_LOG(debug) << "Found "sv << [devices count] << " devices using discovery session"sv;
|
||||
return devices;
|
||||
#pragma clang diagnostic pop
|
||||
} else {
|
||||
BOOST_LOG(debug) << "Using legacy AVCaptureDevice API"sv;
|
||||
// We're intentionally using a deprecated API here specifically for versions
|
||||
// of macOS where it's not deprecated, so we can ignore any deprecation
|
||||
// warnings:
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
NSArray *devices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeAudio];
|
||||
BOOST_LOG(debug) << "Found "sv << [devices count] << " devices using legacy API"sv;
|
||||
return devices;
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)microphoneNames {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "Retrieving microphone names"sv;
|
||||
NSMutableArray *result = [NSMutableArray array];
|
||||
|
||||
for (AVCaptureDevice *device in [AVAudio microphones]) {
|
||||
[result addObject:[device localizedName]];
|
||||
}
|
||||
|
||||
BOOST_LOG(info) << "Found "sv << [result count] << " microphones"sv;
|
||||
return result;
|
||||
}
|
||||
|
||||
+ (AVCaptureDevice *)findMicrophone:(NSString *)name {
|
||||
using namespace std::literals;
|
||||
|
||||
if (name == nil) {
|
||||
BOOST_LOG(warning) << "Microphone not found: (nil)"sv;
|
||||
return nil;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "Searching for microphone: "sv << [name UTF8String];
|
||||
|
||||
for (AVCaptureDevice *device in [AVAudio microphones]) {
|
||||
if ([[device localizedName] isEqualToString:name]) {
|
||||
BOOST_LOG(info) << "Found microphone: "sv << [name UTF8String];
|
||||
return device;
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_LOG(warning) << "Microphone not found: "sv << [name UTF8String];
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (int)setupMicrophone:(AVCaptureDevice *)device sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels {
|
||||
using namespace std::literals;
|
||||
|
||||
if (device == nil) {
|
||||
BOOST_LOG(error) << "Cannot setup microphone: device is nil"sv;
|
||||
return -1;
|
||||
}
|
||||
|
||||
BOOST_LOG(info) << "Setting up microphone: "sv << [[device localizedName] UTF8String] << " with "sv << sampleRate << "Hz, "sv << frameSize << " frames, "sv << (int) channels << " channels"sv;
|
||||
|
||||
self.audioCaptureSession = [[AVCaptureSession alloc] init];
|
||||
|
||||
NSError *nsError;
|
||||
AVCaptureDeviceInput *audioInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&nsError];
|
||||
if (audioInput == nil) {
|
||||
BOOST_LOG(error) << "Failed to create audio input from device: "sv << (nsError ? [[nsError localizedDescription] UTF8String] : "unknown error"sv);
|
||||
return -1;
|
||||
}
|
||||
|
||||
if ([self.audioCaptureSession canAddInput:audioInput]) {
|
||||
[self.audioCaptureSession addInput:audioInput];
|
||||
BOOST_LOG(debug) << "Successfully added audio input to capture session"sv;
|
||||
} else {
|
||||
BOOST_LOG(error) << "Cannot add audio input to capture session"sv;
|
||||
[audioInput dealloc];
|
||||
return -1;
|
||||
}
|
||||
|
||||
AVCaptureAudioDataOutput *audioOutput = [[AVCaptureAudioDataOutput alloc] init];
|
||||
|
||||
[audioOutput setAudioSettings:@{
|
||||
(NSString *) AVFormatIDKey: [NSNumber numberWithUnsignedInt:kAudioFormatLinearPCM],
|
||||
(NSString *) AVSampleRateKey: [NSNumber numberWithUnsignedInt:sampleRate],
|
||||
(NSString *) AVNumberOfChannelsKey: [NSNumber numberWithUnsignedInt:channels],
|
||||
(NSString *) AVLinearPCMBitDepthKey: [NSNumber numberWithUnsignedInt:32],
|
||||
(NSString *) AVLinearPCMIsFloatKey: @YES,
|
||||
(NSString *) AVLinearPCMIsNonInterleaved: @NO
|
||||
}];
|
||||
BOOST_LOG(debug) << "Configured audio output with settings: "sv << sampleRate << "Hz, "sv << (int) channels << " channels, 32-bit float"sv;
|
||||
|
||||
dispatch_queue_attr_t qos = dispatch_queue_attr_make_with_qos_class(DISPATCH_QUEUE_SERIAL, QOS_CLASS_USER_INITIATED, DISPATCH_QUEUE_PRIORITY_HIGH);
|
||||
dispatch_queue_t recordingQueue = dispatch_queue_create("audioSamplingQueue", qos);
|
||||
|
||||
[audioOutput setSampleBufferDelegate:self queue:recordingQueue];
|
||||
|
||||
if ([self.audioCaptureSession canAddOutput:audioOutput]) {
|
||||
[self.audioCaptureSession addOutput:audioOutput];
|
||||
BOOST_LOG(debug) << "Successfully added audio output to capture session"sv;
|
||||
} else {
|
||||
BOOST_LOG(error) << "Cannot add audio output to capture session"sv;
|
||||
[audioInput release];
|
||||
[audioOutput release];
|
||||
return -1;
|
||||
}
|
||||
|
||||
self.audioConnection = [audioOutput connectionWithMediaType:AVMediaTypeAudio];
|
||||
|
||||
// Initialize buffer and signal
|
||||
[self initializeAudioBuffer:channels];
|
||||
BOOST_LOG(debug) << "Audio buffer initialized for microphone capture"sv;
|
||||
|
||||
[self.audioCaptureSession startRunning];
|
||||
BOOST_LOG(info) << "Audio capture session started successfully"sv;
|
||||
|
||||
[audioInput release];
|
||||
[audioOutput release];
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief AVFoundation delegate method for processing microphone audio samples.
|
||||
* Called automatically when new audio samples are available from the microphone capture session.
|
||||
* Writes audio data directly to the shared circular buffer.
|
||||
* @param output The capture output that produced the sample buffer
|
||||
* @param sampleBuffer CMSampleBuffer containing the audio data
|
||||
* @param connection The capture connection that provided the sample buffer
|
||||
*/
|
||||
- (void)captureOutput:(AVCaptureOutput *)output
|
||||
didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer
|
||||
fromConnection:(AVCaptureConnection *)connection {
|
||||
if (connection == self.audioConnection) {
|
||||
AudioBufferList audioBufferList;
|
||||
CMBlockBufferRef blockBuffer;
|
||||
|
||||
CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(sampleBuffer, NULL, &audioBufferList, sizeof(audioBufferList), NULL, NULL, 0, &blockBuffer);
|
||||
|
||||
// NSAssert(audioBufferList.mNumberBuffers == 1, @"Expected interleaved PCM format but buffer contained %u streams", audioBufferList.mNumberBuffers);
|
||||
|
||||
// this is safe, because an interleaved PCM stream has exactly one buffer,
|
||||
// and we don't want to do sanity checks in a performance critical exec path
|
||||
AudioBuffer audioBuffer = audioBufferList.mBuffers[0];
|
||||
|
||||
TPCircularBufferProduceBytes(&self->audioSampleBuffer, audioBuffer.mData, audioBuffer.mDataByteSize);
|
||||
dispatch_semaphore_signal(self->audioSemaphore);
|
||||
}
|
||||
}
|
||||
|
||||
- (int)setupSystemTap:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "setupSystemTap called with sampleRate:"sv << sampleRate << " frameSize:"sv << frameSize << " channels:"sv << (int) channels;
|
||||
|
||||
// 1. Initialize system tap components
|
||||
if ([self initializeSystemTapContext:sampleRate frameSize:frameSize channels:channels] != 0) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 2. Create tap description and process tap
|
||||
CATapDescription *tapDescription = [self createSystemTapDescriptionForChannels:channels];
|
||||
if (!tapDescription) {
|
||||
[self cleanupSystemTapContext:nil];
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 3. Create and configure aggregate device
|
||||
OSStatus aggregateStatus = [self createAggregateDeviceWithTapDescription:tapDescription sampleRate:sampleRate frameSize:frameSize];
|
||||
if (aggregateStatus != noErr) {
|
||||
[self cleanupSystemTapContext:tapDescription];
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 4. Configure device properties and AudioConverter
|
||||
OSStatus configureStatus = [self configureDevicePropertiesAndConverter:sampleRate clientChannels:channels];
|
||||
if (configureStatus != noErr) {
|
||||
[self cleanupSystemTapContext:tapDescription];
|
||||
return -1;
|
||||
}
|
||||
|
||||
// 5. Initialize buffer and signal
|
||||
[self initializeAudioBuffer:channels];
|
||||
|
||||
// 6. Create and start IOProc
|
||||
OSStatus ioProcStatus = [self createAndStartAggregateDeviceIOProc:tapDescription];
|
||||
if (ioProcStatus != noErr) {
|
||||
[self cleanupSystemTapContext:tapDescription];
|
||||
return -1;
|
||||
}
|
||||
|
||||
[tapDescription release];
|
||||
|
||||
BOOST_LOG(debug) << "System tap setup completed successfully!"sv;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Helper method to query Core Audio device properties.
|
||||
* Provides a centralized way to get device properties with error logging.
|
||||
* @param deviceID The audio device to query
|
||||
* @param selector The property selector to retrieve
|
||||
* @param scope The property scope (global, input, output)
|
||||
* @param element The property element identifier
|
||||
* @param ioDataSize Pointer to size variable (input: max size, output: actual size)
|
||||
* @param outData Buffer to store the property data
|
||||
* @return OSStatus indicating success (noErr) or error code
|
||||
*/
|
||||
- (OSStatus)getDeviceProperty:(AudioObjectID)deviceID
|
||||
selector:(AudioObjectPropertySelector)selector
|
||||
scope:(AudioObjectPropertyScope)scope
|
||||
element:(AudioObjectPropertyElement)element
|
||||
size:(UInt32 *)ioDataSize
|
||||
data:(void *)outData {
|
||||
using namespace std::literals;
|
||||
|
||||
AudioObjectPropertyAddress addr = {
|
||||
.mSelector = selector,
|
||||
.mScope = scope,
|
||||
.mElement = element
|
||||
};
|
||||
|
||||
OSStatus result = AudioObjectGetPropertyData(deviceID, &addr, 0, NULL, ioDataSize, outData);
|
||||
|
||||
if (result != noErr) {
|
||||
BOOST_LOG(warning) << "Failed to get device property (selector: "sv << selector << ", scope: "sv << scope << ", element: "sv << element << ") with status: "sv << result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Generalized method for cleaning up system tap resources.
|
||||
* Safely cleans up Core Audio system tap components in reverse order of creation.
|
||||
* @param tapDescription Optional tap description object to release (can be nil)
|
||||
*/
|
||||
- (void)cleanupSystemTapContext:(id)tapDescription {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "Starting system tap context cleanup"sv;
|
||||
|
||||
// Clean up in reverse order of creation
|
||||
if (self->ioProcID && self->aggregateDeviceID != kAudioObjectUnknown) {
|
||||
AudioDeviceStop(self->aggregateDeviceID, self->ioProcID);
|
||||
AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID);
|
||||
self->ioProcID = NULL;
|
||||
BOOST_LOG(debug) << "IOProc stopped and destroyed"sv;
|
||||
}
|
||||
|
||||
if (self->aggregateDeviceID != kAudioObjectUnknown) {
|
||||
AudioHardwareDestroyAggregateDevice(self->aggregateDeviceID);
|
||||
self->aggregateDeviceID = kAudioObjectUnknown;
|
||||
BOOST_LOG(debug) << "Aggregate device destroyed"sv;
|
||||
}
|
||||
|
||||
if (self->tapObjectID != kAudioObjectUnknown) {
|
||||
AudioHardwareDestroyProcessTap(self->tapObjectID);
|
||||
self->tapObjectID = kAudioObjectUnknown;
|
||||
BOOST_LOG(debug) << "Process tap destroyed"sv;
|
||||
}
|
||||
|
||||
if (self->ioProcData) {
|
||||
if (self->ioProcData->conversionBuffer) {
|
||||
free(self->ioProcData->conversionBuffer);
|
||||
self->ioProcData->conversionBuffer = NULL;
|
||||
BOOST_LOG(debug) << "Conversion buffer freed"sv;
|
||||
}
|
||||
if (self->ioProcData->audioConverter) {
|
||||
AudioConverterDispose(self->ioProcData->audioConverter);
|
||||
self->ioProcData->audioConverter = NULL;
|
||||
BOOST_LOG(debug) << "AudioConverter disposed"sv;
|
||||
}
|
||||
free(self->ioProcData);
|
||||
self->ioProcData = NULL;
|
||||
BOOST_LOG(debug) << "IOProc data freed"sv;
|
||||
}
|
||||
|
||||
if (tapDescription) {
|
||||
[tapDescription release];
|
||||
BOOST_LOG(debug) << "Tap description released"sv;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "System tap context cleanup completed"sv;
|
||||
}
|
||||
|
||||
// MARK: - Buffer Management Methods
|
||||
// Shared buffer management methods used by both audio capture paths
|
||||
|
||||
- (void)initializeAudioBuffer:(UInt8)channels {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "Initializing audio buffer for "sv << (int) channels << " channels"sv;
|
||||
|
||||
// Cleanup any existing circular buffer first
|
||||
TPCircularBufferCleanup(&self->audioSampleBuffer);
|
||||
|
||||
// Size the buffer to hold 30ms of audio (6 packets of 240 32-bit samples per channel)
|
||||
int ringBufferSize = 6 * 240 * channels * sizeof(float);
|
||||
|
||||
// Initialize the circular buffer with proper size for the channel count
|
||||
TPCircularBufferInit(&self->audioSampleBuffer, ringBufferSize);
|
||||
|
||||
// Initialize real-time safe semaphore for synchronization (cleanup any existing one first)
|
||||
if (self->audioSemaphore) {
|
||||
dispatch_release(self->audioSemaphore);
|
||||
}
|
||||
self->audioSemaphore = dispatch_semaphore_create(0);
|
||||
|
||||
BOOST_LOG(debug) << "Audio buffer initialized successfully with size: "sv << ringBufferSize << " bytes"sv;
|
||||
}
|
||||
|
||||
- (void)cleanupAudioBuffer {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "Cleaning up audio buffer"sv;
|
||||
|
||||
// Signal any waiting threads before cleanup and release semaphore
|
||||
if (self->audioSemaphore) {
|
||||
dispatch_semaphore_signal(self->audioSemaphore); // Wake up any waiting threads
|
||||
dispatch_release(self->audioSemaphore);
|
||||
self->audioSemaphore = NULL;
|
||||
}
|
||||
|
||||
// Cleanup the circular buffer
|
||||
TPCircularBufferCleanup(&self->audioSampleBuffer);
|
||||
|
||||
BOOST_LOG(debug) << "Audio buffer cleanup completed"sv;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Destructor for AVAudio instances.
|
||||
* Performs comprehensive cleanup of both audio capture paths and shared resources.
|
||||
*/
|
||||
- (void)dealloc {
|
||||
using namespace std::literals;
|
||||
BOOST_LOG(debug) << "AVAudio dealloc started"sv;
|
||||
|
||||
// Cleanup system tap resources using the generalized method
|
||||
[self cleanupSystemTapContext:nil];
|
||||
|
||||
// Cleanup microphone session (AVFoundation path)
|
||||
if (self.audioCaptureSession) {
|
||||
[self.audioCaptureSession stopRunning];
|
||||
self.audioCaptureSession = nil;
|
||||
BOOST_LOG(debug) << "Audio capture session stopped and released"sv;
|
||||
}
|
||||
self.audioConnection = nil;
|
||||
|
||||
// Use our centralized buffer cleanup method (handles signal and buffer cleanup)
|
||||
[self cleanupAudioBuffer];
|
||||
|
||||
BOOST_LOG(debug) << "AVAudio dealloc completed"sv;
|
||||
[super dealloc];
|
||||
}
|
||||
|
||||
// MARK: - System Tap Initialization
|
||||
// Private methods for initializing Core Audio system tap components
|
||||
|
||||
- (int)initializeSystemTapContext:(UInt32)sampleRate frameSize:(UInt32)frameSize channels:(UInt8)channels {
|
||||
using namespace std::literals;
|
||||
|
||||
// Check macOS version requirement
|
||||
if (![[NSProcessInfo processInfo] isOperatingSystemAtLeastVersion:((NSOperatingSystemVersion) {14, 0, 0})]) {
|
||||
BOOST_LOG(error) << "macOS version requirement not met (need 14.0+)"sv;
|
||||
return -1;
|
||||
}
|
||||
|
||||
NSOperatingSystemVersion version = [[NSProcessInfo processInfo] operatingSystemVersion];
|
||||
BOOST_LOG(debug) << "macOS version check passed (running "sv << version.majorVersion << "."sv << version.minorVersion << "."sv << version.patchVersion << ")"sv;
|
||||
|
||||
// Initialize Core Audio objects
|
||||
self->tapObjectID = kAudioObjectUnknown;
|
||||
self->aggregateDeviceID = kAudioObjectUnknown;
|
||||
self->ioProcID = NULL;
|
||||
|
||||
// Create IOProc data structure with client requirements
|
||||
self->ioProcData = (AVAudioIOProcData *) malloc(sizeof(AVAudioIOProcData));
|
||||
if (!self->ioProcData) {
|
||||
BOOST_LOG(error) << "Failed to allocate IOProc data structure"sv;
|
||||
return -1;
|
||||
}
|
||||
|
||||
self->ioProcData->avAudio = self;
|
||||
self->ioProcData->clientRequestedChannels = channels;
|
||||
self->ioProcData->clientRequestedFrameSize = frameSize;
|
||||
self->ioProcData->clientRequestedSampleRate = sampleRate;
|
||||
self->ioProcData->audioConverter = NULL;
|
||||
self->ioProcData->conversionBuffer = NULL;
|
||||
self->ioProcData->conversionBufferSize = 0;
|
||||
|
||||
BOOST_LOG(debug) << "System tap initialization completed"sv;
|
||||
return 0;
|
||||
}
|
||||
|
||||
- (CATapDescription *)createSystemTapDescriptionForChannels:(UInt8)channels {
|
||||
using namespace std::literals;
|
||||
|
||||
BOOST_LOG(debug) << "Creating tap description for "sv << (int) channels << " channels (using stereo tap)"sv;
|
||||
NSArray *excludeProcesses = @[];
|
||||
|
||||
// Always use stereo tap - it handles mono by duplicating to left/right channels
|
||||
CATapDescription *tapDescription = [[CATapDescription alloc] initStereoGlobalTapButExcludeProcesses:excludeProcesses];
|
||||
|
||||
// Set unique name and UUID for this instance
|
||||
NSString *uniqueName = [NSString stringWithFormat:@"SunshineAVAudio-Tap-%p", (void *) self];
|
||||
NSUUID *uniqueUUID = [[NSUUID alloc] init];
|
||||
|
||||
tapDescription.name = uniqueName;
|
||||
tapDescription.UUID = uniqueUUID;
|
||||
|
||||
if (std::getenv("SUNSHINE_PUBLIC_AUDIO_TAP")) {
|
||||
// Shows the tap in Audio MIDI Setup and HALLab where it's easier to inspect
|
||||
BOOST_LOG(debug) << "Setting tap as public (visible in Audio MIDI Setup and HALLab)"sv;
|
||||
[tapDescription setPrivate:NO];
|
||||
} else {
|
||||
BOOST_LOG(debug) << "Setting tap as private (hidden from Audio MIDI Setup and HALLab)"sv;
|
||||
[tapDescription setPrivate:YES];
|
||||
}
|
||||
|
||||
// Set mute behavior based on the hostAudioEnabled property
|
||||
if (self.hostAudioEnabled) {
|
||||
tapDescription.muteBehavior = CATapUnmuted; // Audio to both tap and speakers
|
||||
BOOST_LOG(debug) << "Core Audio tap: Host audio enabled (unmuted)"sv;
|
||||
} else {
|
||||
tapDescription.muteBehavior = CATapMuted; // Audio to tap only, speakers muted
|
||||
BOOST_LOG(debug) << "Core Audio tap: Host audio disabled (muted)"sv;
|
||||
}
|
||||
|
||||
// Create the tap
|
||||
BOOST_LOG(debug) << "Creating process tap with name: "sv << [uniqueName UTF8String];
|
||||
|
||||
// Use direct API call like the reference implementation
|
||||
OSStatus status = AudioHardwareCreateProcessTap(tapDescription, &self->tapObjectID);
|
||||
|
||||
[uniqueUUID release];
|
||||
|
||||
if (status != noErr) {
|
||||
BOOST_LOG(error) << "AudioHardwareCreateProcessTap failed: "sv << ca::Status(status);
|
||||
[tapDescription release];
|
||||
return nil;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "Process tap created successfully with ID: "sv << self->tapObjectID;
|
||||
return tapDescription;
|
||||
}
|
||||
|
||||
- (OSStatus)createAggregateDeviceWithTapDescription:(CATapDescription *)tapDescription sampleRate:(UInt32)sampleRate frameSize:(UInt32)frameSize {
|
||||
using namespace std::literals;
|
||||
|
||||
// Get Tap UUID string properly
|
||||
NSString *tapUIDString = nil;
|
||||
if ([tapDescription respondsToSelector:@selector(UUID)]) {
|
||||
tapUIDString = [[tapDescription UUID] UUIDString];
|
||||
}
|
||||
if (!tapUIDString) {
|
||||
BOOST_LOG(error) << "Failed to get tap UUID from description"sv;
|
||||
return kAudioHardwareUnspecifiedError;
|
||||
}
|
||||
|
||||
// Create aggregate device with better drift compensation and proper keys
|
||||
NSDictionary *subTapDictionary = @{
|
||||
@kAudioSubTapUIDKey: tapUIDString,
|
||||
@kAudioSubTapDriftCompensationKey: @YES,
|
||||
};
|
||||
|
||||
NSDictionary *aggregateProperties = @{
|
||||
@kAudioAggregateDeviceNameKey: [NSString stringWithFormat:@"SunshineAggregate-%p", (void *) self],
|
||||
@kAudioAggregateDeviceUIDKey: [NSString stringWithFormat:@"com.lizardbyte.sunshine.aggregate-%p", (void *) self],
|
||||
@kAudioAggregateDeviceTapListKey: @[subTapDictionary],
|
||||
@kAudioAggregateDeviceTapAutoStartKey: @NO,
|
||||
// Shows the tap in Audio MIDI Setup and HALLab where it's easier to inspect when set
|
||||
@kAudioAggregateDeviceIsPrivateKey: std::getenv("SUNSHINE_PUBLIC_AUDIO_TAP") ? @NO : @YES,
|
||||
};
|
||||
|
||||
BOOST_LOG(debug) << "Creating aggregate device with tap UID: "sv << [tapUIDString UTF8String];
|
||||
OSStatus status = AudioHardwareCreateAggregateDevice((CFDictionaryRef) aggregateProperties, &self->aggregateDeviceID);
|
||||
if (status != noErr && status != 'ExtA') {
|
||||
BOOST_LOG(error) << "AudioHardwareCreateAggregateDevice failed: " << ca::Status(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "Aggregate device created with ID: "sv << self->aggregateDeviceID;
|
||||
|
||||
// Configure the aggregate device
|
||||
if (self->aggregateDeviceID != kAudioObjectUnknown) {
|
||||
// Set sample rate on the aggregate device
|
||||
AudioObjectPropertyAddress sampleRateAddr = {
|
||||
.mSelector = kAudioDevicePropertyNominalSampleRate,
|
||||
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||
.mElement = kAudioObjectPropertyElementMain
|
||||
};
|
||||
Float64 deviceSampleRate = (Float64) sampleRate;
|
||||
UInt32 sampleRateSize = sizeof(Float64);
|
||||
OSStatus sampleRateResult = AudioObjectSetPropertyData(self->aggregateDeviceID, &sampleRateAddr, 0, NULL, sampleRateSize, &deviceSampleRate);
|
||||
if (sampleRateResult != noErr) {
|
||||
BOOST_LOG(warning) << "Failed to set aggregate device sample rate: "sv << sampleRateResult;
|
||||
} else {
|
||||
BOOST_LOG(debug) << "Set aggregate device sample rate to "sv << sampleRate << "Hz"sv;
|
||||
}
|
||||
|
||||
// Set buffer size on the aggregate device
|
||||
AudioObjectPropertyAddress bufferSizeAddr = {
|
||||
.mSelector = kAudioDevicePropertyBufferFrameSize,
|
||||
.mScope = kAudioObjectPropertyScopeGlobal,
|
||||
.mElement = kAudioObjectPropertyElementMain
|
||||
};
|
||||
UInt32 deviceFrameSize = frameSize;
|
||||
UInt32 frameSizeSize = sizeof(UInt32);
|
||||
OSStatus bufferSizeResult = AudioObjectSetPropertyData(self->aggregateDeviceID, &bufferSizeAddr, 0, NULL, frameSizeSize, &deviceFrameSize);
|
||||
if (bufferSizeResult != noErr) {
|
||||
BOOST_LOG(warning) << "Failed to set aggregate device buffer size: "sv << bufferSizeResult;
|
||||
} else {
|
||||
BOOST_LOG(debug) << "Set aggregate device buffer size to "sv << frameSize << " frames"sv;
|
||||
}
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "Aggregate device created and configured successfully"sv;
|
||||
return noErr;
|
||||
}
|
||||
|
||||
- (OSStatus)configureDevicePropertiesAndConverter:(UInt32)clientSampleRate
|
||||
clientChannels:(UInt8)clientChannels {
|
||||
using namespace std::literals;
|
||||
|
||||
// Query actual device properties to determine if conversion is needed.
|
||||
// Default to the client's own values: if a query fails we assume the device matches
|
||||
// what was requested, so no conversion is attempted rather than using a wrong guess.
|
||||
Float64 aggregateDeviceSampleRate = (Float64) clientSampleRate;
|
||||
UInt32 aggregateDeviceChannels = clientChannels;
|
||||
|
||||
// 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(error) << "Failed to get device sample rate, falling back to client rate (" << clientSampleRate << "Hz): " << ca::Status(sampleRateStatus);
|
||||
aggregateDeviceSampleRate = (Float64) clientSampleRate;
|
||||
} else if (aggregateDeviceSampleRate <= 0.0) {
|
||||
// API returned noErr but the value is invalid (e.g. 0 from a UInt32→Float64 casting issue).
|
||||
// Treat it as a failure rather than silently proceeding with an invalid rate.
|
||||
BOOST_LOG(error) << "getDeviceProperty returned noErr but got invalid sample rate: "sv
|
||||
<< aggregateDeviceSampleRate << "Hz - falling back to client rate (" << clientSampleRate << "Hz)"sv;
|
||||
aggregateDeviceSampleRate = (Float64) clientSampleRate;
|
||||
}
|
||||
|
||||
// Get actual channel count from the device's input stream configuration
|
||||
AudioObjectPropertyAddress streamConfigAddr = {
|
||||
.mSelector = kAudioDevicePropertyStreamConfiguration,
|
||||
.mScope = kAudioDevicePropertyScopeInput,
|
||||
.mElement = kAudioObjectPropertyElementMain
|
||||
};
|
||||
|
||||
UInt32 streamConfigSize = 0;
|
||||
OSStatus streamConfigSizeStatus = AudioObjectGetPropertyDataSize(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize);
|
||||
|
||||
if (streamConfigSizeStatus == noErr && streamConfigSize > 0) {
|
||||
AudioBufferList *streamConfig = (AudioBufferList *) malloc(streamConfigSize);
|
||||
if (streamConfig) {
|
||||
OSStatus streamConfigStatus = AudioObjectGetPropertyData(self->aggregateDeviceID, &streamConfigAddr, 0, NULL, &streamConfigSize, streamConfig);
|
||||
if (streamConfigStatus == noErr && streamConfig->mNumberBuffers > 0) {
|
||||
aggregateDeviceChannels = streamConfig->mBuffers[0].mNumberChannels;
|
||||
BOOST_LOG(debug) << "Device reports "sv << aggregateDeviceChannels << " input channels"sv;
|
||||
} else {
|
||||
BOOST_LOG(error) << "Failed to get stream configuration, falling back to client channels (" << (int) clientChannels << "): "sv << streamConfigStatus;
|
||||
}
|
||||
free(streamConfig);
|
||||
}
|
||||
} else {
|
||||
BOOST_LOG(error) << "Failed to get stream configuration size, falling back to client channels (" << (int) clientChannels << "): "sv << streamConfigSizeStatus;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "Device properties - Sample Rate: "sv << aggregateDeviceSampleRate << "Hz, Channels: "sv << aggregateDeviceChannels;
|
||||
|
||||
// Create AudioConverter based on actual device properties vs client requirements
|
||||
BOOL needsConversion = (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 = (Float64) 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 = (Float64) 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) << "AudioConverterNew failed: " << ca::Status(converterStatus);
|
||||
return converterStatus;
|
||||
}
|
||||
BOOST_LOG(debug) << "AudioConverter created successfully for "sv << aggregateDeviceSampleRate << "Hz/" << aggregateDeviceChannels << "ch -> " << clientSampleRate << "Hz/" << (int) clientChannels << "ch"sv;
|
||||
} else {
|
||||
BOOST_LOG(debug) << "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 = aggregateDeviceSampleRate;
|
||||
self->ioProcData->aggregateDeviceChannels = aggregateDeviceChannels;
|
||||
|
||||
BOOST_LOG(debug) << "Device properties and converter configuration completed"sv;
|
||||
return noErr;
|
||||
}
|
||||
|
||||
- (OSStatus)createAndStartAggregateDeviceIOProc:(CATapDescription *)tapDescription {
|
||||
using namespace std::literals;
|
||||
|
||||
// Create IOProc
|
||||
BOOST_LOG(debug) << "Creating IOProc for aggregate device ID: "sv << self->aggregateDeviceID;
|
||||
OSStatus status = AudioDeviceCreateIOProcID(self->aggregateDeviceID, platf::systemAudioIOProc, self->ioProcData, &self->ioProcID);
|
||||
if (status != kAudioHardwareNoError) {
|
||||
BOOST_LOG(error) << "AudioDeviceCreateIOProcID failed: " << ca::Status(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
// Start the IOProc
|
||||
BOOST_LOG(debug) << "Starting IOProc for aggregate device";
|
||||
status = AudioDeviceStart(self->aggregateDeviceID, self->ioProcID);
|
||||
if (status != kAudioHardwareNoError) {
|
||||
BOOST_LOG(error) << "AudioDeviceStart failed: " << ca::Status(status);
|
||||
AudioDeviceDestroyIOProcID(self->aggregateDeviceID, self->ioProcID);
|
||||
return status;
|
||||
}
|
||||
|
||||
BOOST_LOG(debug) << "System tap IO proc created and started successfully"sv;
|
||||
return noErr;
|
||||
}
|
||||
|
||||
@end
|
||||
68
src/platform/macos/coreaudio_helpers.h
Normal file
68
src/platform/macos/coreaudio_helpers.h
Normal file
@@ -0,0 +1,68 @@
|
||||
#pragma once
|
||||
|
||||
#include <CoreFoundation/CoreFoundation.h>
|
||||
#include <cstdint>
|
||||
#include <ostream>
|
||||
#include <string>
|
||||
|
||||
namespace ca {
|
||||
|
||||
// Display FourCC error codes, with fallback to integer.
|
||||
// Usage: BOOST_LOG(error) << ca::Status(err);
|
||||
|
||||
// Some CoreAudio error examples:
|
||||
// kAudioHardwareNoError = 0,
|
||||
// kAudioHardwareNotRunningError = 'stop',
|
||||
// kAudioHardwareUnspecifiedError = 'what',
|
||||
// kAudioHardwareUnknownPropertyError = 'who?',
|
||||
// kAudioHardwareBadPropertySizeError = '!siz',
|
||||
// kAudioHardwareIllegalOperationError = 'nope',
|
||||
// kAudioHardwareBadObjectError = '!obj',
|
||||
// kAudioHardwareBadDeviceError = '!dev',
|
||||
// kAudioHardwareBadStreamError = '!str',
|
||||
// kAudioHardwareUnsupportedOperationError = 'unop',
|
||||
// kAudioHardwareNotReadyError = 'nrdy',
|
||||
// kAudioDeviceUnsupportedFormatError = '!dat',
|
||||
// kAudioDevicePermissionsError = '!hog'
|
||||
|
||||
inline std::string OSStatusToString(OSStatus error) {
|
||||
uint32_t be = CFSwapInt32HostToBig(static_cast<uint32_t>(error));
|
||||
const unsigned char c1 = static_cast<unsigned char>((be >> 24) & 0xFF);
|
||||
const unsigned char c2 = static_cast<unsigned char>((be >> 16) & 0xFF);
|
||||
const unsigned char c3 = static_cast<unsigned char>((be >> 8) & 0xFF);
|
||||
const unsigned char c4 = static_cast<unsigned char>((be >> 0) & 0xFF);
|
||||
|
||||
auto is_printable = [](unsigned char c) -> bool {
|
||||
return c >= 32 && c <= 126;
|
||||
};
|
||||
|
||||
if (is_printable(c1) && is_printable(c2) && is_printable(c3) && is_printable(c4)) {
|
||||
char buf[8] = {};
|
||||
buf[0] = '\'';
|
||||
buf[1] = static_cast<char>(c1);
|
||||
buf[2] = static_cast<char>(c2);
|
||||
buf[3] = static_cast<char>(c3);
|
||||
buf[4] = static_cast<char>(c4);
|
||||
buf[5] = '\'';
|
||||
buf[6] = '\0';
|
||||
return std::string(buf);
|
||||
}
|
||||
|
||||
return std::to_string(static_cast<int32_t>(error));
|
||||
}
|
||||
|
||||
namespace detail {
|
||||
struct StatusView {
|
||||
OSStatus e;
|
||||
};
|
||||
|
||||
inline std::ostream &operator<<(std::ostream &os, StatusView v) {
|
||||
return os << OSStatusToString(v.e);
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
inline detail::StatusView Status(OSStatus e) {
|
||||
return detail::StatusView {e};
|
||||
}
|
||||
|
||||
} // namespace ca
|
||||
@@ -19,23 +19,37 @@ namespace platf {
|
||||
}
|
||||
|
||||
capture_e sample(std::vector<float> &sample_in) override {
|
||||
auto sample_size = sample_in.size();
|
||||
const uint32_t neededBytes = static_cast<uint32_t>(sample_in.size() * sizeof(float));
|
||||
uint8_t *dst = reinterpret_cast<uint8_t *>(sample_in.data());
|
||||
|
||||
uint32_t length = 0;
|
||||
void *byteSampleBuffer = TPCircularBufferTail(&av_audio_capture->audioSampleBuffer, &length);
|
||||
uint32_t remaining = neededBytes;
|
||||
|
||||
while (length < sample_size * sizeof(float)) {
|
||||
[av_audio_capture.samplesArrivedSignal wait];
|
||||
byteSampleBuffer = TPCircularBufferTail(&av_audio_capture->audioSampleBuffer, &length);
|
||||
while (remaining > 0) {
|
||||
uint32_t avail = 0;
|
||||
void *tail = TPCircularBufferTail(&av_audio_capture->audioSampleBuffer, &avail);
|
||||
|
||||
if (avail == 0) {
|
||||
// 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;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const uint32_t toCopy = (avail < remaining) ? avail : remaining;
|
||||
std::memcpy(dst, tail, toCopy);
|
||||
|
||||
TPCircularBufferConsume(&av_audio_capture->audioSampleBuffer, toCopy);
|
||||
|
||||
dst += toCopy;
|
||||
remaining -= toCopy;
|
||||
}
|
||||
|
||||
const float *sampleBuffer = (float *) byteSampleBuffer;
|
||||
std::vector<float> vectorBuffer(sampleBuffer, sampleBuffer + sample_size);
|
||||
|
||||
std::copy_n(std::begin(vectorBuffer), sample_size, std::begin(sample_in));
|
||||
|
||||
TPCircularBufferConsume(&av_audio_capture->audioSampleBuffer, (uint32_t) sample_size * sizeof(float));
|
||||
|
||||
return capture_e::ok;
|
||||
}
|
||||
};
|
||||
@@ -49,30 +63,43 @@ namespace platf {
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::unique_ptr<mic_t> 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<mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, bool host_audio_enabled) override {
|
||||
auto mic = std::make_unique<av_mic_t>();
|
||||
const char *audio_sink = "";
|
||||
|
||||
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;
|
||||
|
||||
for (NSString *name in [AVAudio microphoneNames]) {
|
||||
BOOST_LOG(error) << "\t"sv << [name UTF8String];
|
||||
}
|
||||
|
||||
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;
|
||||
return nullptr;
|
||||
// Set the host audio enabled flag from the stream configuration
|
||||
mic->av_audio_capture.hostAudioEnabled = host_audio_enabled ? YES : NO;
|
||||
BOOST_LOG(debug) << "Set hostAudioEnabled to: "sv << (host_audio_enabled ? "YES" : "NO");
|
||||
|
||||
if (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;
|
||||
}
|
||||
} else {
|
||||
// Use specified macOS audio sink
|
||||
const char *audio_sink = config::audio.sink.c_str();
|
||||
BOOST_LOG(info) << "Using configured audio sink "sv << audio_sink << " for capture."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];
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -767,7 +767,7 @@ namespace platf::audio {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::unique_ptr<mic_t> 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<mic_t> microphone(const std::uint8_t *mapping, int channels, std::uint32_t sample_rate, std::uint32_t frame_size, bool continuous_audio, [[maybe_unused]] bool host_audio_enabled) override {
|
||||
auto mic = std::make_unique<mic_wasapi_t>();
|
||||
|
||||
if (mic->init(sample_rate, frame_size, channels, continuous_audio)) {
|
||||
|
||||
@@ -134,7 +134,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. Alternatively, specify a virtual device such as Soundflower or BlackHole if you prefer.",
|
||||
"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)",
|
||||
|
||||
@@ -24,6 +24,10 @@
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>@CMAKE_PROJECT_NAME@ requires access to your microphone to stream audio.</string>
|
||||
<key>NSAudioCaptureUsageDescription</key>
|
||||
<string>@CMAKE_PROJECT_NAME@ requires access to system audio to capture and stream audio output.</string>
|
||||
<key>NSScreenCaptureUsageDescription</key>
|
||||
<string>@CMAKE_PROJECT_NAME@ requires access to screen recording to capture and stream your screen content.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_nvstream._tcp</string>
|
||||
|
||||
@@ -95,6 +95,12 @@ endif()
|
||||
file(GLOB_RECURSE TEST_SOURCES CONFIGURE_DEPENDS
|
||||
${CMAKE_SOURCE_DIR}/tests/*.h
|
||||
${CMAKE_SOURCE_DIR}/tests/*.cpp)
|
||||
# Add macOS-specific test files only when building tests for macOS
|
||||
if (APPLE)
|
||||
file(GLOB_RECURSE MACOS_TEST_SOURCES CONFIGURE_DEPENDS
|
||||
${CMAKE_SOURCE_DIR}/tests/*.mm)
|
||||
list(APPEND TEST_SOURCES ${MACOS_TEST_SOURCES})
|
||||
endif ()
|
||||
|
||||
set(SUNSHINE_SOURCES
|
||||
${SUNSHINE_TARGET_FILES})
|
||||
|
||||
452
tests/unit/platform/macos/test_av_audio.mm
Normal file
452
tests/unit/platform/macos/test_av_audio.mm
Normal file
@@ -0,0 +1,452 @@
|
||||
/**
|
||||
* @file tests/unit/platform/macos/test_av_audio.mm
|
||||
* @brief Unit tests for src/platform/macos/av_audio.*.
|
||||
*/
|
||||
|
||||
// Only compile these tests on macOS
|
||||
#ifdef __APPLE__
|
||||
|
||||
#include "../../../tests_common.h"
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <CoreAudio/CATapDescription.h>
|
||||
#import <CoreAudio/CoreAudio.h>
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <src/platform/macos/av_audio.h>
|
||||
|
||||
/**
|
||||
* @brief Test parameters for processSystemAudioIOProc tests.
|
||||
* Contains various audio configuration parameters to test different scenarios.
|
||||
*/
|
||||
struct ProcessSystemAudioIOProcTestParams {
|
||||
UInt32 frameCount; ///< Number of audio frames to process
|
||||
UInt32 channels; ///< Number of audio channels (1=mono, 2=stereo)
|
||||
UInt32 sampleRate; ///< Sample rate in Hz
|
||||
bool useNilInput; ///< Whether to test with nil input data
|
||||
const char *testName; ///< Descriptive name for the test case
|
||||
};
|
||||
|
||||
/**
|
||||
* @brief Test suite for AVAudio class functionality.
|
||||
* Parameterized test class for testing Core Audio system tap functionality.
|
||||
*/
|
||||
class AVAudioTest: public PlatformTestSuite, public ::testing::WithParamInterface<ProcessSystemAudioIOProcTestParams> {};
|
||||
|
||||
/**
|
||||
* @brief Test that findMicrophone handles nil input gracefully.
|
||||
* Verifies the method returns nil when passed a nil microphone name.
|
||||
*/
|
||||
TEST_F(AVAudioTest, FindMicrophoneWithNilReturnsNil) {
|
||||
@try {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wnonnull"
|
||||
AVCaptureDevice *device = [AVAudio findMicrophone:nil];
|
||||
#pragma clang diagnostic pop
|
||||
EXPECT_EQ(device, nil);
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test that findMicrophone handles empty string input gracefully.
|
||||
* Verifies the method returns nil when passed an empty microphone name.
|
||||
*/
|
||||
TEST_F(AVAudioTest, FindMicrophoneWithEmptyStringReturnsNil) {
|
||||
@try {
|
||||
AVCaptureDevice *device = [AVAudio findMicrophone:@""];
|
||||
EXPECT_EQ(device, nil); // Should return nil for empty string
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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) {
|
||||
@try {
|
||||
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
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test basic AVAudio object lifecycle.
|
||||
* Verifies that AVAudio objects can be created and destroyed without issues.
|
||||
*/
|
||||
TEST_F(AVAudioTest, ObjectLifecycle) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
EXPECT_NE(avAudio, nil); // Should create successfully
|
||||
[avAudio release]; // Should not crash
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test that multiple AVAudio objects can coexist.
|
||||
* Verifies that multiple instances can be created simultaneously.
|
||||
*/
|
||||
TEST_F(AVAudioTest, MultipleObjectsCoexist) {
|
||||
@try {
|
||||
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];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test audio buffer initialization with various channel configurations.
|
||||
* Verifies that the audio buffer can be initialized with different channel counts.
|
||||
*/
|
||||
TEST_F(AVAudioTest, InitializeAudioBuffer) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
uint32_t avail = 0;
|
||||
|
||||
// Test with various channel counts
|
||||
[avAudio initializeAudioBuffer:1]; // Mono
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
TPCircularBufferHead(&avAudio->audioSampleBuffer, &avail);
|
||||
EXPECT_GE(avail, 5760);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
[avAudio initializeAudioBuffer:2]; // Stereo
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
TPCircularBufferHead(&avAudio->audioSampleBuffer, &avail);
|
||||
EXPECT_GE(avail, 11520);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
[avAudio initializeAudioBuffer:8]; // 7.1 Surround
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
TPCircularBufferHead(&avAudio->audioSampleBuffer, &avail);
|
||||
EXPECT_GE(avail, 46080);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test audio buffer cleanup functionality.
|
||||
* Verifies that cleanup works correctly even with uninitialized buffers.
|
||||
*/
|
||||
TEST_F(AVAudioTest, CleanupUninitializedBuffer) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
// Should not crash even if buffer was never initialized
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
// Initialize then cleanup
|
||||
[avAudio initializeAudioBuffer:2];
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
EXPECT_EQ(avAudio->audioSemaphore, nullptr);
|
||||
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test audio converter complex input callback with valid data.
|
||||
* Verifies that the audio converter callback properly processes valid audio data.
|
||||
*/
|
||||
TEST_F(AVAudioTest, AudioConverterComplexInputProc) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
// Create test input data
|
||||
UInt32 frameCount = 256;
|
||||
UInt32 channels = 2;
|
||||
float *testData = (float *) calloc(frameCount * channels, sizeof(float));
|
||||
|
||||
// Fill with deterministic ramp data (channel-encoded constants)
|
||||
for (UInt32 frame = 0; frame < frameCount; frame++) {
|
||||
for (UInt32 channel = 0; channel < channels; channel++) {
|
||||
testData[frame * channels + channel] = channel + frame * 0.001f;
|
||||
}
|
||||
}
|
||||
|
||||
AudioConverterInputData inputInfo = {0};
|
||||
inputInfo.inputData = testData;
|
||||
inputInfo.inputFrames = frameCount;
|
||||
inputInfo.framesProvided = 0;
|
||||
inputInfo.deviceChannels = channels;
|
||||
inputInfo.avAudio = avAudio;
|
||||
|
||||
// Test the method
|
||||
UInt32 requestedPackets = 128;
|
||||
AudioBufferList bufferList = {0};
|
||||
// Use a dummy AudioConverterRef (can be null for our test since our implementation doesn't use it)
|
||||
AudioConverterRef dummyConverter = nullptr;
|
||||
OSStatus result = platf::audioConverterComplexInputProc(dummyConverter, &requestedPackets, &bufferList, nullptr, &inputInfo);
|
||||
|
||||
EXPECT_EQ(result, noErr);
|
||||
EXPECT_EQ(requestedPackets, 128); // Should provide requested frames
|
||||
EXPECT_EQ(inputInfo.framesProvided, 128); // Should update frames provided
|
||||
|
||||
free(testData);
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test audio converter callback when no more data is available.
|
||||
* Verifies that the callback handles end-of-data scenarios correctly.
|
||||
*/
|
||||
TEST_F(AVAudioTest, AudioConverterInputProcNoMoreData) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
UInt32 frameCount = 256;
|
||||
UInt32 channels = 2;
|
||||
float *testData = (float *) calloc(frameCount * channels, sizeof(float));
|
||||
|
||||
AudioConverterInputData inputInfo = {0};
|
||||
inputInfo.inputData = testData;
|
||||
inputInfo.inputFrames = frameCount;
|
||||
inputInfo.framesProvided = frameCount; // Already provided all frames
|
||||
inputInfo.deviceChannels = channels;
|
||||
inputInfo.avAudio = avAudio;
|
||||
|
||||
UInt32 requestedPackets = 128;
|
||||
AudioBufferList bufferList = {0};
|
||||
// Use a dummy AudioConverterRef (can be null for our test since our implementation doesn't use it)
|
||||
AudioConverterRef dummyConverter = nullptr;
|
||||
OSStatus result = platf::audioConverterComplexInputProc(dummyConverter, &requestedPackets, &bufferList, nullptr, &inputInfo);
|
||||
|
||||
EXPECT_EQ(result, noErr);
|
||||
EXPECT_EQ(requestedPackets, 0); // Should return 0 packets when no more data
|
||||
|
||||
free(testData);
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test that audio buffer cleanup can be called multiple times safely.
|
||||
* Verifies that repeated cleanup calls don't cause crashes or issues.
|
||||
*/
|
||||
TEST_F(AVAudioTest, CleanupAudioBufferMultipleTimes) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
[avAudio initializeAudioBuffer:2];
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
|
||||
// Multiple cleanup calls should not crash
|
||||
[avAudio cleanupAudioBuffer];
|
||||
EXPECT_EQ(avAudio->audioSemaphore, nullptr);
|
||||
|
||||
[avAudio cleanupAudioBuffer]; // Second call should be safe
|
||||
[avAudio cleanupAudioBuffer]; // Third call should be safe
|
||||
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Test buffer management with edge case channel configurations.
|
||||
* Verifies that buffer management works with minimum and maximum channel counts.
|
||||
*/
|
||||
TEST_F(AVAudioTest, BufferManagementEdgeCases) {
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
// Test with minimum reasonable channel count (1 channel)
|
||||
[avAudio initializeAudioBuffer:1];
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
// Test with very high channel count
|
||||
[avAudio initializeAudioBuffer:32];
|
||||
EXPECT_NE(avAudio->audioSemaphore, nullptr);
|
||||
[avAudio cleanupAudioBuffer];
|
||||
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
// Type alias for parameterized audio processing tests
|
||||
using ProcessSystemAudioIOProcTest = AVAudioTest;
|
||||
|
||||
// Test parameters - representative configurations to cover a range of scenarios
|
||||
// Channels: 1 (mono), 2 (stereo), 6 (5.1), 8 (7.1)
|
||||
// Sample rates: 48000 (common), 44100 (legacy), 192000 (edge)
|
||||
// Frame counts: 64 (small), 256 (typical), 1024 (large)
|
||||
INSTANTIATE_TEST_SUITE_P(
|
||||
AVAudioTest,
|
||||
ProcessSystemAudioIOProcTest,
|
||||
::testing::Values(
|
||||
// Representative channel configurations at common sample rate
|
||||
ProcessSystemAudioIOProcTestParams {256, 1, 48000, false, "Mono48kHz"},
|
||||
ProcessSystemAudioIOProcTestParams {256, 2, 48000, false, "Stereo48kHz"},
|
||||
ProcessSystemAudioIOProcTestParams {256, 6, 48000, false, "Surround51_48kHz"},
|
||||
ProcessSystemAudioIOProcTestParams {256, 8, 48000, false, "Surround71_48kHz"},
|
||||
|
||||
// Frame count variations (small, typical, large)
|
||||
ProcessSystemAudioIOProcTestParams {64, 2, 48000, false, "SmallFrameCount"},
|
||||
ProcessSystemAudioIOProcTestParams {1024, 2, 48000, false, "LargeFrameCount"},
|
||||
|
||||
// Sample rate edge cases
|
||||
ProcessSystemAudioIOProcTestParams {256, 2, 44100, false, "LegacySampleRate44kHz"},
|
||||
ProcessSystemAudioIOProcTestParams {256, 2, 192000, false, "HighSampleRate192kHz"},
|
||||
|
||||
// Edge case: nil input handling
|
||||
ProcessSystemAudioIOProcTestParams {256, 2, 48000, true, "NilInputHandling"},
|
||||
|
||||
// Combined edge case: max channels + large frames
|
||||
ProcessSystemAudioIOProcTestParams {1024, 8, 48000, false, "MaxChannelsLargeFrames"}
|
||||
),
|
||||
[](const ::testing::TestParamInfo<ProcessSystemAudioIOProcTestParams> &info) {
|
||||
return std::string(info.param.testName);
|
||||
}
|
||||
);
|
||||
|
||||
TEST_P(ProcessSystemAudioIOProcTest, ProcessAudioInput) {
|
||||
ProcessSystemAudioIOProcTestParams params = GetParam();
|
||||
|
||||
@try {
|
||||
AVAudio *avAudio = [[AVAudio alloc] init];
|
||||
|
||||
// Use the new buffer initialization method instead of manual setup
|
||||
[avAudio initializeAudioBuffer:params.channels];
|
||||
|
||||
// Create timestamps
|
||||
AudioTimeStamp timeStamp = {0};
|
||||
timeStamp.mFlags = kAudioTimeStampSampleTimeValid;
|
||||
timeStamp.mSampleTime = 0;
|
||||
|
||||
AudioBufferList *inputBufferList = nullptr;
|
||||
float *testInputData = nullptr;
|
||||
UInt32 inputDataSize = 0;
|
||||
|
||||
// Only create input data if not testing nil input
|
||||
if (!params.useNilInput) {
|
||||
inputDataSize = params.frameCount * params.channels * sizeof(float);
|
||||
testInputData = (float *) calloc(params.frameCount * params.channels, sizeof(float));
|
||||
|
||||
// Fill with deterministic ramp data (channel-encoded constants)
|
||||
// This is faster than sine waves and provides channel separation + frame ordering
|
||||
for (UInt32 frame = 0; frame < params.frameCount; frame++) {
|
||||
for (UInt32 channel = 0; channel < params.channels; channel++) {
|
||||
testInputData[frame * params.channels + channel] = channel + frame * 0.001f;
|
||||
}
|
||||
}
|
||||
|
||||
// Create AudioBufferList
|
||||
inputBufferList = (AudioBufferList *) malloc(sizeof(AudioBufferList));
|
||||
inputBufferList->mNumberBuffers = 1;
|
||||
inputBufferList->mBuffers[0].mNumberChannels = params.channels;
|
||||
inputBufferList->mBuffers[0].mDataByteSize = inputDataSize;
|
||||
inputBufferList->mBuffers[0].mData = testInputData;
|
||||
}
|
||||
|
||||
// Get initial buffer state
|
||||
uint32_t initialAvailableBytes = 0;
|
||||
TPCircularBufferTail(&avAudio->audioSampleBuffer, &initialAvailableBytes);
|
||||
|
||||
// Create IOProc data structure for the C++ function
|
||||
AVAudioIOProcData procData = {0};
|
||||
procData.avAudio = avAudio;
|
||||
procData.clientRequestedChannels = params.channels;
|
||||
procData.clientRequestedFrameSize = params.frameCount;
|
||||
procData.clientRequestedSampleRate = params.sampleRate;
|
||||
procData.aggregateDeviceChannels = params.channels; // For simplicity in tests
|
||||
procData.aggregateDeviceSampleRate = params.sampleRate;
|
||||
procData.audioConverter = nullptr; // No conversion needed for most tests
|
||||
|
||||
// Create a dummy output buffer (not used in our implementation but required by signature)
|
||||
AudioBufferList dummyOutputBufferList = {0};
|
||||
|
||||
// Test the systemAudioIOProcWrapper function
|
||||
OSStatus result = platf::systemAudioIOProc(0, // device ID (not used in our logic)
|
||||
&timeStamp,
|
||||
inputBufferList,
|
||||
&timeStamp,
|
||||
&dummyOutputBufferList,
|
||||
&timeStamp,
|
||||
&procData);
|
||||
|
||||
// Verify the method returns success
|
||||
EXPECT_EQ(result, noErr);
|
||||
|
||||
if (!params.useNilInput) {
|
||||
// Verify data was written to the circular buffer
|
||||
uint32_t finalAvailableBytes = 0;
|
||||
void *bufferData = TPCircularBufferTail(&avAudio->audioSampleBuffer, &finalAvailableBytes);
|
||||
EXPECT_GT(finalAvailableBytes, initialAvailableBytes); // Should have more data than before
|
||||
EXPECT_GT(finalAvailableBytes, 0); // Should have data in buffer
|
||||
|
||||
// Verify we wrote the expected amount of data (input size for direct passthrough)
|
||||
EXPECT_EQ(finalAvailableBytes, inputDataSize);
|
||||
|
||||
// Verify the actual audio data matches what we put in (first few samples)
|
||||
// Limit validation to min(8, channels * 2) samples to keep test efficient
|
||||
UInt32 samplesToTest = std::min(8U, params.channels * 2);
|
||||
if (bufferData && finalAvailableBytes >= sizeof(float) * samplesToTest) {
|
||||
float *outputSamples = (float *) bufferData;
|
||||
for (UInt32 i = 0; i < samplesToTest; i++) {
|
||||
EXPECT_FLOAT_EQ(outputSamples[i], testInputData[i]) << "Sample " << i << " mismatch";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (testInputData) {
|
||||
free(testInputData);
|
||||
}
|
||||
if (inputBufferList) {
|
||||
free(inputBufferList);
|
||||
}
|
||||
[avAudio cleanupAudioBuffer];
|
||||
[avAudio release];
|
||||
}
|
||||
@catch (NSException *exception) {
|
||||
FAIL() << "Caught NSException: " << ([exception.reason UTF8String] ?: "unknown reason");
|
||||
}
|
||||
}
|
||||
|
||||
#endif // __APPLE__
|
||||
Reference in New Issue
Block a user