#include "system_audio_capture.h" #if defined(__APPLE__) #import #import #import #import #include #include #include #include #include #include #include #include #include namespace { constexpr size_t kMaxQueuedChunks = 256; constexpr size_t kDefaultDrainChunkLimit = 64; constexpr AudioObjectID kUnknownObject = kAudioObjectUnknown; struct OutputDeviceInfo { std::string uid; std::string label; double sampleRate; UInt32 channelCount; bool isDefault; }; struct CapturedChunk { std::vector left; std::vector right; UInt32 channelCount = 2; double capturedAtMilliseconds = 0.0; uint64_t sequence = 0; }; double monotonicMilliseconds() { const auto now = std::chrono::steady_clock::now().time_since_epoch(); return std::chrono::duration(now).count(); } std::string cfStringToStdString(CFStringRef value) { if (value == nullptr) { return {}; } const CFIndex length = CFStringGetLength(value); const CFIndex maxBytes = CFStringGetMaximumSizeForEncoding(length, kCFStringEncodingUTF8) + 1; std::vector buffer(static_cast(std::max(1, maxBytes)), '\0'); if (!CFStringGetCString(value, buffer.data(), maxBytes, kCFStringEncodingUTF8)) { return {}; } return std::string(buffer.data()); } NSString* toNSString(const std::string& value) { return [[NSString alloc] initWithUTF8String:value.c_str()]; } template bool getPropertyData(AudioObjectID objectId, AudioObjectPropertySelector selector, AudioObjectPropertyScope scope, AudioObjectPropertyElement element, T* outValue) { if (outValue == nullptr) { return false; } AudioObjectPropertyAddress address{ selector, scope, element, }; UInt32 size = sizeof(T); return AudioObjectGetPropertyData(objectId, &address, 0, nullptr, &size, outValue) == noErr; } bool getDeviceStringProperty(AudioDeviceID deviceId, AudioObjectPropertySelector selector, std::string* outValue) { if (outValue == nullptr) { return false; } AudioObjectPropertyAddress address{ selector, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, }; CFStringRef stringValue = nullptr; UInt32 size = sizeof(stringValue); const OSStatus status = AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, &stringValue); if (status != noErr || stringValue == nullptr) { return false; } *outValue = cfStringToStdString(stringValue); CFRelease(stringValue); return !outValue->empty(); } AudioDeviceID getDefaultOutputDeviceId() { AudioDeviceID deviceId = kUnknownObject; if (!getPropertyData(kAudioObjectSystemObject, kAudioHardwarePropertyDefaultOutputDevice, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, &deviceId)) { return kUnknownObject; } return deviceId; } bool getDeviceNominalSampleRate(AudioDeviceID deviceId, double* outSampleRate) { if (outSampleRate == nullptr) { return false; } Float64 sampleRate = 0.0; if (!getPropertyData(deviceId, kAudioDevicePropertyNominalSampleRate, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, &sampleRate)) { return false; } if (sampleRate <= 0.0) { return false; } *outSampleRate = static_cast(sampleRate); return true; } UInt32 getOutputChannelCount(AudioDeviceID deviceId) { AudioObjectPropertyAddress address{ kAudioDevicePropertyStreamConfiguration, kAudioDevicePropertyScopeOutput, kAudioObjectPropertyElementMain, }; UInt32 size = 0; if (AudioObjectGetPropertyDataSize(deviceId, &address, 0, nullptr, &size) != noErr || size == 0) { return 0; } std::vector storage(size); auto* bufferList = reinterpret_cast(storage.data()); if (AudioObjectGetPropertyData(deviceId, &address, 0, nullptr, &size, bufferList) != noErr) { return 0; } UInt32 channelCount = 0; for (UInt32 index = 0; index < bufferList->mNumberBuffers; ++index) { channelCount += bufferList->mBuffers[index].mNumberChannels; } return channelCount; } std::vector enumerateOutputDevices() { AudioObjectPropertyAddress address{ kAudioHardwarePropertyDevices, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, }; UInt32 size = 0; if (AudioObjectGetPropertyDataSize(kAudioObjectSystemObject, &address, 0, nullptr, &size) != noErr || size == 0) { return {}; } const UInt32 deviceCount = size / sizeof(AudioDeviceID); std::vector deviceIds(deviceCount, kUnknownObject); if (AudioObjectGetPropertyData( kAudioObjectSystemObject, &address, 0, nullptr, &size, deviceIds.data()) != noErr) { return {}; } const AudioDeviceID defaultDeviceId = getDefaultOutputDeviceId(); std::vector devices; devices.reserve(deviceCount); for (AudioDeviceID deviceId : deviceIds) { const UInt32 channelCount = getOutputChannelCount(deviceId); if (channelCount == 0) { continue; } std::string uid; if (!getDeviceStringProperty(deviceId, kAudioDevicePropertyDeviceUID, &uid)) { continue; } std::string label; if (!getDeviceStringProperty(deviceId, kAudioObjectPropertyName, &label)) { label = uid; } double sampleRate = 0.0; if (!getDeviceNominalSampleRate(deviceId, &sampleRate)) { sampleRate = 48000.0; } devices.push_back(OutputDeviceInfo{ uid, label, sampleRate, channelCount, deviceId == defaultDeviceId, }); } return devices; } bool getTapFormat(AudioObjectID tapId, AudioStreamBasicDescription* outFormat) { if (outFormat == nullptr) { return false; } AudioObjectPropertyAddress address{ kAudioTapPropertyFormat, kAudioObjectPropertyScopeGlobal, kAudioObjectPropertyElementMain, }; UInt32 size = sizeof(AudioStreamBasicDescription); return AudioObjectGetPropertyData(tapId, &address, 0, nullptr, &size, outFormat) == noErr; } float decodeSignedIntegerSample(const uint8_t* data, UInt32 bytesPerSample, bool isBigEndian) { if (data == nullptr || bytesPerSample == 0 || bytesPerSample > 4) { return 0.0f; } int32_t rawValue = 0; if (isBigEndian) { for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) { rawValue = (rawValue << 8) | data[byteIndex]; } } else { for (UInt32 byteIndex = 0; byteIndex < bytesPerSample; ++byteIndex) { rawValue |= static_cast(data[byteIndex]) << (byteIndex * 8); } } const UInt32 totalBits = bytesPerSample * 8; const int32_t signMask = 1 << (totalBits - 1); if ((rawValue & signMask) != 0) { rawValue |= ~((1 << totalBits) - 1); } const double maxMagnitude = static_cast((1u << (totalBits - 1)) - 1u); if (maxMagnitude <= 0.0) { return 0.0f; } return static_cast(static_cast(rawValue) / maxMagnitude); } float readSampleFromFormat(const uint8_t* data, const AudioStreamBasicDescription& format, UInt32 sampleIndex) { if (data == nullptr) { return 0.0f; } const UInt32 bytesPerChannel = format.mBitsPerChannel / 8; if (bytesPerChannel == 0) { return 0.0f; } const uint8_t* samplePtr = data + static_cast(sampleIndex) * bytesPerChannel; const bool isFloat = (format.mFormatFlags & kAudioFormatFlagIsFloat) != 0; const bool isBigEndian = (format.mFormatFlags & kAudioFormatFlagIsBigEndian) != 0; if (isFloat && format.mBitsPerChannel == 32) { Float32 value = 0.0f; std::memcpy(&value, samplePtr, sizeof(Float32)); return value; } if (isFloat && format.mBitsPerChannel == 64) { Float64 value = 0.0; std::memcpy(&value, samplePtr, sizeof(Float64)); return static_cast(value); } if ((format.mFormatFlags & kAudioFormatFlagIsSignedInteger) != 0) { return decodeSignedIntegerSample(samplePtr, bytesPerChannel, isBigEndian); } return 0.0f; } bool isFormatInterleaved(const AudioStreamBasicDescription& format) { return (format.mFormatFlags & kAudioFormatFlagIsNonInterleaved) == 0; } std::string formatStatusMessage(const char* operation, OSStatus status) { return std::string(operation) + " failed (" + std::to_string(static_cast(status)) + ")"; } class MacOSNativeCaptureEngine final : public Prism::Capture::SystemAudioCapture { public: ~MacOSNativeCaptureEngine() override { stop(); } Prism::Capture::Support getSupport() const override { if (@available(macOS 14.2, *)) { return {true, {}}; } return {false, "Native output-device capture requires macOS 14.2 or newer."}; } std::vector listOutputDevices() override { std::vector result; if (!isSupported()) { return result; } const auto devices = enumerateOutputDevices(); result.reserve(devices.size()); for (const auto& device : devices) { result.push_back({ device.uid, device.label, device.sampleRate, static_cast(device.channelCount), device.isDefault, }); } return result; } bool start(const std::string& requestedDeviceId, Prism::Capture::StartResult* result, std::string* errorMessage) override { const auto support = getSupport(); if (!support.available) { if (errorMessage != nullptr) { *errorMessage = support.reason; } return false; } if (!startInternal(requestedDeviceId, errorMessage)) { return false; } if (result != nullptr) { std::lock_guard lock(stateMutex_); result->sampleRate = sampleRate_; result->channelCount = static_cast(channelCount_); result->deviceId = activeDeviceUid_; result->deviceLabel = activeDeviceLabel_; } return true; } void stop() override { std::lock_guard lock(stateMutex_); stopLocked(); } Prism::Capture::DrainResult drain(size_t maxChunks) override { const size_t drainLimit = maxChunks == 0 ? kDefaultDrainChunkLimit : std::min(maxChunks, kMaxQueuedChunks); std::deque drained; Prism::Capture::DrainResult result; { std::lock_guard queueLock(chunkMutex_); result.overwriteCount = overwriteCount_; const size_t count = std::min(drainLimit, chunkQueue_.size()); for (size_t index = 0; index < count; ++index) { drained.push_back(std::move(chunkQueue_.front())); chunkQueue_.pop_front(); } result.queueDepth = chunkQueue_.size(); } result.chunks.reserve(drained.size()); while (!drained.empty()) { auto chunk = std::move(drained.front()); drained.pop_front(); result.chunks.push_back({ std::move(chunk.left), std::move(chunk.right), static_cast(chunk.channelCount), chunk.capturedAtMilliseconds, chunk.sequence, }); } return result; } double nowMilliseconds() const override { return monotonicMilliseconds(); } const char* backendName() const override { return "CoreAudio"; } private: static OSStatus StaticIOProc(AudioObjectID inDevice, const AudioTimeStamp* inNow, const AudioBufferList* inInputData, const AudioTimeStamp* inInputTime, AudioBufferList* outOutputData, const AudioTimeStamp* inOutputTime, void* inClientData) { auto* self = static_cast(inClientData); if (self == nullptr) { return noErr; } return self->handleIO( inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime); } OSStatus handleIO(AudioObjectID, const AudioTimeStamp*, const AudioBufferList* inputData, const AudioTimeStamp*, AudioBufferList*, const AudioTimeStamp*) { AudioStreamBasicDescription format{}; UInt32 channelCount = 0; { std::lock_guard stateLock(stateMutex_); if (!active_ || inputData == nullptr) { return noErr; } format = tapFormat_; channelCount = channelCount_; } if (inputData->mNumberBuffers == 0 || format.mBytesPerFrame == 0) { return noErr; } const bool interleaved = isFormatInterleaved(format); const AudioBuffer& firstBuffer = inputData->mBuffers[0]; const UInt32 frames = format.mBytesPerFrame == 0 ? 0 : firstBuffer.mDataByteSize / format.mBytesPerFrame; if (frames == 0) { return noErr; } CapturedChunk chunk; chunk.channelCount = channelCount; chunk.capturedAtMilliseconds = monotonicMilliseconds(); { std::lock_guard stateLock(stateMutex_); chunk.sequence = ++sequence_; } chunk.left.resize(frames); chunk.right.resize(frames); if (interleaved) { const uint8_t* rawData = static_cast(firstBuffer.mData); for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) { const UInt32 sampleBaseIndex = frameIndex * std::max(1, channelCount); const float leftSample = readSampleFromFormat(rawData, format, sampleBaseIndex); const float rightSample = channelCount > 1 ? readSampleFromFormat(rawData, format, sampleBaseIndex + 1) : leftSample; chunk.left[frameIndex] = leftSample; chunk.right[frameIndex] = rightSample; } } else { const uint8_t* leftData = static_cast(inputData->mBuffers[0].mData); const uint8_t* rightData = static_cast( inputData->mNumberBuffers > 1 ? inputData->mBuffers[1].mData : inputData->mBuffers[0].mData); for (UInt32 frameIndex = 0; frameIndex < frames; ++frameIndex) { chunk.left[frameIndex] = readSampleFromFormat(leftData, format, frameIndex); chunk.right[frameIndex] = inputData->mNumberBuffers > 1 ? readSampleFromFormat(rightData, format, frameIndex) : chunk.left[frameIndex]; } } { std::lock_guard queueLock(chunkMutex_); if (chunkQueue_.size() >= kMaxQueuedChunks) { chunkQueue_.pop_front(); ++overwriteCount_; } chunkQueue_.push_back(std::move(chunk)); } return noErr; } bool startInternal(const std::string& requestedDeviceUid, std::string* outErrorMessage) { std::lock_guard lock(stateMutex_); stopLocked(); if (!isSupported()) { if (outErrorMessage != nullptr) { *outErrorMessage = "Native output-device capture requires macOS 14.2 or newer."; } return false; } const auto devices = enumerateOutputDevices(); if (devices.empty()) { if (outErrorMessage != nullptr) { *outErrorMessage = "No macOS output devices are available."; } return false; } const OutputDeviceInfo* selected = nullptr; if (!requestedDeviceUid.empty()) { for (const auto& device : devices) { if (device.uid == requestedDeviceUid) { selected = &device; break; } } if (selected == nullptr) { if (outErrorMessage != nullptr) { *outErrorMessage = "The selected macOS output device is no longer available."; } return false; } } else { for (const auto& device : devices) { if (device.isDefault) { selected = &device; break; } } if (selected == nullptr) { selected = &devices.front(); } } if (@available(macOS 14.2, *)) { @autoreleasepool { NSString* deviceUID = toNSString(selected->uid); NSString* deviceLabel = toNSString(selected->label); NSArray* excludedProcesses = @[]; CATapDescription* tapDescription = [[CATapDescription alloc] initExcludingProcesses:excludedProcesses andDeviceUID:deviceUID withStream:0]; tapDescription.name = [NSString stringWithFormat:@"Prism Tap %@", deviceLabel]; tapDescription.UUID = [NSUUID UUID]; tapDescription.privateTap = YES; tapDescription.muteBehavior = CATapUnmuted; const OSStatus tapStatus = AudioHardwareCreateProcessTap(tapDescription, &tapId_); if (tapStatus != noErr) { if (outErrorMessage != nullptr) { *outErrorMessage = formatStatusMessage("AudioHardwareCreateProcessTap", tapStatus); } tapId_ = kUnknownObject; return false; } NSString* aggregateUID = [NSString stringWithFormat:@"com.astra.prism.capture.%@", [NSUUID UUID].UUIDString]; NSDictionary* aggregateDescription = @{ [NSString stringWithUTF8String:kAudioAggregateDeviceNameKey]: [NSString stringWithFormat:@"Prism Capture %@", deviceLabel], [NSString stringWithUTF8String:kAudioAggregateDeviceUIDKey]: aggregateUID, [NSString stringWithUTF8String:kAudioAggregateDeviceIsPrivateKey]: @YES, [NSString stringWithUTF8String:kAudioAggregateDeviceMainSubDeviceKey]: deviceUID, [NSString stringWithUTF8String:kAudioAggregateDeviceTapAutoStartKey]: @YES, [NSString stringWithUTF8String:kAudioAggregateDeviceSubDeviceListKey]: @[ @{ [NSString stringWithUTF8String:kAudioSubDeviceUIDKey]: deviceUID, [NSString stringWithUTF8String:kAudioSubDeviceNameKey]: deviceLabel, [NSString stringWithUTF8String:kAudioSubDeviceDriftCompensationKey]: @YES, [NSString stringWithUTF8String:kAudioSubDeviceDriftCompensationQualityKey]: @(kAudioAggregateDriftCompensationMediumQuality), } ], [NSString stringWithUTF8String:kAudioAggregateDeviceTapListKey]: @[ @{ [NSString stringWithUTF8String:kAudioSubTapUIDKey]: tapDescription.UUID.UUIDString, [NSString stringWithUTF8String:kAudioSubTapDriftCompensationKey]: @YES, [NSString stringWithUTF8String:kAudioSubTapDriftCompensationQualityKey]: @(kAudioAggregateDriftCompensationMediumQuality), } ], }; const OSStatus aggregateStatus = AudioHardwareCreateAggregateDevice( (__bridge CFDictionaryRef)aggregateDescription, &aggregateDeviceId_); if (aggregateStatus != noErr) { if (outErrorMessage != nullptr) { *outErrorMessage = formatStatusMessage( "AudioHardwareCreateAggregateDevice", aggregateStatus); } AudioHardwareDestroyProcessTap(tapId_); tapId_ = kUnknownObject; aggregateDeviceId_ = kUnknownObject; return false; } } } if (!getTapFormat(tapId_, &tapFormat_)) { tapFormat_ = AudioStreamBasicDescription{}; tapFormat_.mSampleRate = selected->sampleRate; tapFormat_.mChannelsPerFrame = std::max(2, selected->channelCount); tapFormat_.mBitsPerChannel = 32; tapFormat_.mBytesPerFrame = tapFormat_.mChannelsPerFrame * sizeof(Float32); tapFormat_.mFramesPerPacket = 1; tapFormat_.mBytesPerPacket = tapFormat_.mBytesPerFrame * tapFormat_.mFramesPerPacket; tapFormat_.mFormatID = kAudioFormatLinearPCM; tapFormat_.mFormatFlags = kAudioFormatFlagIsFloat | kAudioFormatFlagIsPacked; } const OSStatus ioProcStatus = AudioDeviceCreateIOProcID(aggregateDeviceId_, StaticIOProc, this, &ioProcId_); if (ioProcStatus != noErr) { if (outErrorMessage != nullptr) { *outErrorMessage = formatStatusMessage("AudioDeviceCreateIOProcID", ioProcStatus); } stopLocked(); return false; } const OSStatus startStatus = AudioDeviceStart(aggregateDeviceId_, ioProcId_); if (startStatus != noErr) { if (outErrorMessage != nullptr) { *outErrorMessage = formatStatusMessage("AudioDeviceStart", startStatus); } stopLocked(); return false; } { std::lock_guard queueLock(chunkMutex_); chunkQueue_.clear(); overwriteCount_ = 0; } active_ = true; activeDeviceUid_ = selected->uid; activeDeviceLabel_ = selected->label; sampleRate_ = tapFormat_.mSampleRate > 0 ? tapFormat_.mSampleRate : selected->sampleRate; channelCount_ = std::max(1, tapFormat_.mChannelsPerFrame > 0 ? tapFormat_.mChannelsPerFrame : selected->channelCount); sequence_ = 0; return true; } void stopLocked() { active_ = false; if (aggregateDeviceId_ != kUnknownObject && ioProcId_ != nullptr) { AudioDeviceStop(aggregateDeviceId_, ioProcId_); AudioDeviceDestroyIOProcID(aggregateDeviceId_, ioProcId_); ioProcId_ = nullptr; } if (aggregateDeviceId_ != kUnknownObject) { AudioHardwareDestroyAggregateDevice(aggregateDeviceId_); aggregateDeviceId_ = kUnknownObject; } if (tapId_ != kUnknownObject) { if (@available(macOS 14.2, *)) { AudioHardwareDestroyProcessTap(tapId_); } tapId_ = kUnknownObject; } tapFormat_ = AudioStreamBasicDescription{}; activeDeviceUid_.clear(); activeDeviceLabel_.clear(); sampleRate_ = 48000.0; channelCount_ = 2; sequence_ = 0; std::lock_guard queueLock(chunkMutex_); chunkQueue_.clear(); overwriteCount_ = 0; } bool isSupported() const { if (@available(macOS 14.2, *)) { return true; } return false; } mutable std::mutex stateMutex_; std::mutex chunkMutex_; std::deque chunkQueue_; uint64_t overwriteCount_ = 0; uint64_t sequence_ = 0; AudioObjectID tapId_ = kUnknownObject; AudioObjectID aggregateDeviceId_ = kUnknownObject; AudioDeviceIOProcID ioProcId_ = nullptr; AudioStreamBasicDescription tapFormat_{}; bool active_ = false; std::string activeDeviceUid_; std::string activeDeviceLabel_; double sampleRate_ = 48000.0; UInt32 channelCount_ = 2; }; } // namespace namespace Prism::Capture { std::unique_ptr createSystemAudioCapture() { return std::make_unique(); } } // namespace Prism::Capture #endif // defined(__APPLE__)