Files
audiotee/Sources/AudioTeeCore/Core/AudioTapManager.swift
T
Nick Payne 85975d6cc3 optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers
- Replace Swift Array<UInt8> ring buffer with UnsafeMutableRawPointer to
  eliminate COW ref-count checks on every write/read
- Add append(from:count:) to copy directly from Core Audio buffer pointer
  into the ring buffer, removing the per-callback Data heap allocation
- Pre-allocate AVAudioPCMBuffer pair in AudioFormatConverter and reuse
  across transform() calls (lazy init, capacity-checked)
- Fix float-to-int truncation in output frame count calculation (ceil)
- Add comprehensive AudioBuffer test suite (12 tests) including proper
  wrap-around coverage for both append and read paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:32:42 +00:00

153 lines
5.2 KiB
Swift

import AVFoundation
import AudioToolbox
import CoreAudio
import Foundation
public class AudioTapManager {
private var tapID: AudioObjectID?
private var deviceID: AudioObjectID?
public init() {}
deinit {
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
if let tapID = tapID {
AudioHardwareDestroyProcessTap(tapID)
self.tapID = nil
}
if let deviceID = deviceID {
AudioHardwareDestroyAggregateDevice(deviceID)
self.deviceID = nil
}
}
/// Sets up the audio tap and aggregate device
public func setupAudioTap(with config: TapConfiguration) throws {
AudioTeeLogging.logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config)
deviceID = try createAggregateDevice()
guard let tapID = tapID, let deviceID = deviceID else {
throw AudioTeeError.setupFailed
}
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
}
/// Returns the aggregate device ID for recording
public func getDeviceID() -> AudioObjectID? {
return deviceID
}
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
AudioTeeLogging.logger.debug("Creating tap description")
let description = CATapDescription()
description.name = "audiotee-tap"
description.processes = try translatePIDsToProcessObjects(config.processes) // Properly translate PIDs
description.isPrivate = true
description.muteBehavior = config.muteBehavior.coreAudioValue
description.isMixdown = true
description.isMono = config.isMono
description.isExclusive = config.isExclusive
description.deviceUID = nil // system default
description.stream = 0 // first stream of output device
AudioTeeLogging.logger.debug(
"Tap description configured",
context: [
"name": description.name,
"processes": String(describing: config.processes),
"mute": String(describing: description.muteBehavior),
"mono": String(description.isMono),
"exclusive": String(description.isExclusive),
])
// Create the tap
AudioTeeLogging.logger.debug("Creating tap")
var tapID = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tapID)
AudioTeeLogging.logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error(
"Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status)
}
// Get the format of the audio tap
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyFormat)
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
var streamDescription = AudioStreamBasicDescription()
let formatStatus = AudioObjectGetPropertyData(
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
if formatStatus == noErr {
AudioTeeLogging.logger.debug(
"Tap format retrieved",
context: [
"channels": String(streamDescription.mChannelsPerFrame),
"sample_rate": String(Int(streamDescription.mSampleRate)),
])
}
return tapID
}
private func createAggregateDevice() throws -> AudioObjectID {
let uid = UUID().uuidString
let description =
[
kAudioAggregateDeviceNameKey: "audiotee-aggregate-device",
kAudioAggregateDeviceUIDKey: uid,
kAudioAggregateDeviceSubDeviceListKey: [] as CFArray,
kAudioAggregateDeviceMasterSubDeviceKey: 0,
kAudioAggregateDeviceIsPrivateKey: true,
kAudioAggregateDeviceIsStackedKey: false,
] as [String: Any]
var deviceID: AudioObjectID = 0
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else {
AudioTeeLogging.logger.error(
"Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status)
}
return deviceID
}
private func addTapToAggregateDevice(tapID: AudioObjectID, deviceID: AudioObjectID) throws {
// Get the tap's UID
var propertyAddress = getPropertyAddress(selector: kAudioTapPropertyUID)
var propertySize = UInt32(MemoryLayout<CFString>.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<CFArray>.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)
}
}
}