From 01ff685c4af7c741c73daca7122436d86ab20b1f Mon Sep 17 00:00:00 2001 From: Sean Dang Date: Sun, 5 Apr 2026 02:30:14 +0700 Subject: [PATCH] fix(audio): include tap in aggregate device creation dictionary The aggregate device was created without the tap list, then the tap was added afterward via AudioObjectSetPropertyData with flat UID strings. This delivers all-zero (silent) audio on macOS 26 Tahoe. Restructure to include kAudioAggregateDeviceTapListKey with structured sub-tap dictionaries (kAudioSubTapUIDKey, kAudioSubTapDriftCompensationKey) directly in the AudioHardwareCreateAggregateDevice creation dictionary, matching Apple's reference implementation. Constraint: Must remain compatible with macOS 14.2+ where the tap API was introduced Rejected: Keep post-creation property-set approach | silently fails on macOS 26, not used by reference implementations Confidence: high Scope-risk: narrow Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AudioTeeCore/Core/AudioTapManager.swift | 72 ++++++++----------- 1 file changed, 30 insertions(+), 42 deletions(-) diff --git a/Sources/AudioTeeCore/Core/AudioTapManager.swift b/Sources/AudioTeeCore/Core/AudioTapManager.swift index e8fb079..f711814 100644 --- a/Sources/AudioTeeCore/Core/AudioTapManager.swift +++ b/Sources/AudioTeeCore/Core/AudioTapManager.swift @@ -27,15 +27,14 @@ public class AudioTapManager { public func setupAudioTap(with config: TapConfiguration) throws { AudioTeeLogging.logger.debug("Setting up audio tap manager") - tapID = try createSystemAudioTap(with: config) - deviceID = try createAggregateDevice() + let (createdTapID, tapUUID) = try createSystemAudioTap(with: config) + tapID = createdTapID + deviceID = try createAggregateDevice(tapUUID: tapUUID) - guard let tapID = tapID, let deviceID = deviceID else { + guard tapID != nil, deviceID != nil else { throw AudioTeeError.setupFailed } - try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID) - AudioTeeLogging.logger.debug("Audio tap manager setup complete") } @@ -44,7 +43,7 @@ public class AudioTapManager { return deviceID } - private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID { + private func createSystemAudioTap(with config: TapConfiguration) throws -> (AudioObjectID, String) { AudioTeeLogging.logger.debug("Creating tap description") let description = CATapDescription() @@ -58,6 +57,9 @@ public class AudioTapManager { description.deviceUID = nil // system default description.stream = 0 // first stream of output device + // Get the UUID from the description before creating the tap + let tapUUID = description.uuid.uuidString + AudioTeeLogging.logger.debug( "Tap description configured", context: [ @@ -66,6 +68,7 @@ public class AudioTapManager { "mute": String(describing: description.muteBehavior), "mono": String(description.isMono), "exclusive": String(description.isExclusive), + "uuid": tapUUID, ]) // Create the tap @@ -97,20 +100,31 @@ public class AudioTapManager { ]) } - return tapID + return (tapID, tapUUID) } - private func createAggregateDevice() throws -> AudioObjectID { + private func createAggregateDevice(tapUUID: String) throws -> AudioObjectID { let uid = UUID().uuidString - let description = + + // Include the tap in the aggregate device creation dictionary using + // structured sub-tap dictionaries. This is required on macOS 26+ where + // adding the tap via AudioObjectSetPropertyData after creation no longer + // delivers audio data. + let tapList: [[String: Any]] = [ [ - kAudioAggregateDeviceNameKey: "audiotee-aggregate-device", - kAudioAggregateDeviceUIDKey: uid, - kAudioAggregateDeviceSubDeviceListKey: [] as CFArray, - kAudioAggregateDeviceMasterSubDeviceKey: 0, - kAudioAggregateDeviceIsPrivateKey: true, - kAudioAggregateDeviceIsStackedKey: false, - ] as [String: Any] + kAudioSubTapUIDKey: tapUUID, + kAudioSubTapDriftCompensationKey: true, + ] + ] + + let description: [String: Any] = [ + kAudioAggregateDeviceNameKey: "audiotee-aggregate-device", + kAudioAggregateDeviceUIDKey: uid, + kAudioAggregateDeviceTapListKey: tapList, + kAudioAggregateDeviceTapAutoStartKey: false, + kAudioAggregateDeviceIsPrivateKey: true, + kAudioAggregateDeviceIsStackedKey: false, + ] var deviceID: AudioObjectID = 0 let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID) @@ -123,30 +137,4 @@ public class AudioTapManager { return deviceID } - - private func addTapToAggregateDevice(tapID: AudioObjectID, deviceID: AudioObjectID) throws { - // Get the tap's UID - var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID) - var propertySize = UInt32(MemoryLayout.stride) - var tapUID: CFString = "" as CFString - _ = withUnsafeMutablePointer(to: &tapUID) { tapUID in - AudioObjectGetPropertyData(tapID, &propertyAddress, 0, nil, &propertySize, tapUID) - } - - // Add the tap to the aggregate device - propertyAddress = getPropertyAddress( - selector: kAudioAggregateDevicePropertyTapList) - let tapArray = [tapUID] as CFArray - propertySize = UInt32(MemoryLayout.stride) - - let status = withUnsafePointer(to: tapArray) { ptr in - AudioObjectSetPropertyData(deviceID, &propertyAddress, 0, nil, propertySize, ptr) - } - - guard status == kAudioHardwareNoError else { - AudioTeeLogging.logger.error( - "Failed to add tap to aggregate device", context: ["status": String(status)]) - throw AudioTeeError.tapAssignmentFailed(status) - } - } }