AudioTee Swift CLI repo
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioBuffer {
|
||||
private var buffer = Data()
|
||||
private let targetChunkDuration: Double
|
||||
private let streamFormat: AudioStreamBasicDescription
|
||||
|
||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||
self.streamFormat = format
|
||||
self.targetChunkDuration = chunkDuration
|
||||
}
|
||||
|
||||
public func append(_ data: Data) {
|
||||
buffer.append(data)
|
||||
}
|
||||
|
||||
public func processChunks() -> [AudioPacket] {
|
||||
var packets: [AudioPacket] = []
|
||||
|
||||
while let packet = nextChunk() {
|
||||
packets.append(packet)
|
||||
}
|
||||
|
||||
return packets
|
||||
}
|
||||
|
||||
public func flushRemaining() -> AudioPacket? {
|
||||
guard !buffer.isEmpty else { return nil }
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: 0.0, // Unknown duration for final chunk
|
||||
peakAmplitude: 0.0,
|
||||
rawAudioData: buffer
|
||||
)
|
||||
|
||||
buffer.removeAll()
|
||||
return packet
|
||||
}
|
||||
|
||||
private func nextChunk() -> AudioPacket? {
|
||||
let bytesPerFrame = Int(streamFormat.mBytesPerFrame)
|
||||
let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration)
|
||||
let bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||
|
||||
guard buffer.count >= bytesPerChunk else { return nil }
|
||||
|
||||
let chunkData = buffer.prefix(bytesPerChunk)
|
||||
|
||||
let packet = AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: Double(samplesPerChunk) / streamFormat.mSampleRate,
|
||||
peakAmplitude: 0.0, // No analysis in raw mode
|
||||
rawAudioData: Data(chunkData)
|
||||
)
|
||||
|
||||
buffer.removeFirst(bytesPerChunk)
|
||||
return packet
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
/// Simple audio format converter using AVFoundation
|
||||
public class AudioFormatConverter {
|
||||
private let avConverter: AVAudioConverter
|
||||
private let sourceFormat: AVAudioFormat
|
||||
private let targetFormat: AVAudioFormat
|
||||
|
||||
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
|
||||
throws
|
||||
{
|
||||
var mutableSourceFormat = sourceFormat
|
||||
var mutableTargetFormat = targetFormat
|
||||
|
||||
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
||||
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
||||
else {
|
||||
throw AudioConverterError.invalidFormat
|
||||
}
|
||||
|
||||
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
||||
throw AudioConverterError.creationFailed
|
||||
}
|
||||
|
||||
self.sourceFormat = sourceAVFormat
|
||||
self.targetFormat = targetAVFormat
|
||||
self.avConverter = converter
|
||||
|
||||
Logger.debug(
|
||||
"Audio converter created",
|
||||
context: [
|
||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||
"target_sample_rate": String(targetAVFormat.sampleRate),
|
||||
"source_channels": String(sourceAVFormat.channelCount),
|
||||
"target_channels": String(targetAVFormat.channelCount),
|
||||
])
|
||||
|
||||
// Warn about upsampling once during initialization
|
||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||
Logger.info(
|
||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||
context: [
|
||||
"source_rate": String(sourceAVFormat.sampleRate),
|
||||
"target_rate": String(targetAVFormat.sampleRate),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the target format as AudioStreamBasicDescription
|
||||
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||
return targetFormat.streamDescription.pointee
|
||||
}
|
||||
|
||||
public func transform(_ packet: AudioPacket) -> AudioPacket {
|
||||
let inputData = packet.rawAudioData
|
||||
|
||||
// Short-circuit if no conversion needed
|
||||
if sourceFormat.sampleRate == targetFormat.sampleRate {
|
||||
return packet
|
||||
}
|
||||
|
||||
// Calculate frame counts
|
||||
let inputFrameCount =
|
||||
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
||||
let outputFrameCount = Int(
|
||||
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
|
||||
|
||||
// Create input buffer
|
||||
guard
|
||||
let inputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
||||
else {
|
||||
Logger.error("Failed to create input buffer")
|
||||
return packet
|
||||
}
|
||||
|
||||
// Copy input data to buffer
|
||||
inputData.withUnsafeBytes { bytes in
|
||||
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
|
||||
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
|
||||
}
|
||||
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
|
||||
|
||||
// Create output buffer
|
||||
guard
|
||||
let outputBuffer = AVAudioPCMBuffer(
|
||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
||||
else {
|
||||
Logger.error("Failed to create output buffer")
|
||||
return packet
|
||||
}
|
||||
|
||||
// Perform conversion - simpler approach
|
||||
var error: NSError?
|
||||
|
||||
let status = avConverter.convert(to: outputBuffer, error: &error) {
|
||||
requestedPackets, outStatus in
|
||||
// Always provide our input buffer and let converter manage it
|
||||
outStatus.pointee = .haveData
|
||||
return inputBuffer
|
||||
}
|
||||
|
||||
// Check if conversion produced output (regardless of status code)
|
||||
guard outputBuffer.frameLength > 0 else {
|
||||
Logger.error(
|
||||
"Audio conversion produced no output",
|
||||
context: [
|
||||
"status": String(describing: status),
|
||||
"error": String(describing: error),
|
||||
"input_frames": String(inputBuffer.frameLength),
|
||||
"output_capacity": String(outputBuffer.frameCapacity),
|
||||
])
|
||||
return packet
|
||||
}
|
||||
|
||||
// Extract converted data
|
||||
let outputData = Data(
|
||||
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
|
||||
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
|
||||
|
||||
// Return new packet with converted audio (keeping original metadata for simplicity)
|
||||
return AudioPacket(
|
||||
timestamp: packet.timestamp,
|
||||
duration: packet.duration,
|
||||
peakAmplitude: packet.peakAmplitude,
|
||||
rawAudioData: outputData
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Convenience Constructors
|
||||
|
||||
extension AudioFormatConverter {
|
||||
/// Create a converter to a specific sample rate with mono PCM 16-bit output
|
||||
/// Since the tap already converts to mono, we hardcode channels to 1
|
||||
public static func toSampleRate(
|
||||
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
||||
) throws -> AudioFormatConverter {
|
||||
var targetFormat = AudioStreamBasicDescription()
|
||||
targetFormat.mSampleRate = sampleRate
|
||||
targetFormat.mFormatID = kAudioFormatLinearPCM
|
||||
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
||||
targetFormat.mBytesPerPacket = 2
|
||||
targetFormat.mFramesPerPacket = 1
|
||||
targetFormat.mBytesPerFrame = 2
|
||||
targetFormat.mChannelsPerFrame = 1 // Always mono since tap handles this
|
||||
targetFormat.mBitsPerChannel = 16
|
||||
|
||||
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
||||
}
|
||||
|
||||
/// Common sample rates for validation
|
||||
public static let supportedSampleRates: [Double] = [
|
||||
8000, 16000, 22050, 24000, 32000, 44100, 48000,
|
||||
]
|
||||
|
||||
/// Validate if a sample rate is supported
|
||||
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||
return supportedSampleRates.contains(sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Error Types
|
||||
|
||||
// AudioConverterError moved to Sources/Core/Errors/AudioTeeErrors.swift
|
||||
@@ -0,0 +1,54 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioFormatManager {
|
||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
||||
var propertyAddress = getPropertyAddress(
|
||||
selector: kAudioDevicePropertyStreamFormat,
|
||||
scope: kAudioDevicePropertyScopeInput)
|
||||
var propertySize = UInt32(MemoryLayout<AudioStreamBasicDescription>.stride)
|
||||
var streamFormat = AudioStreamBasicDescription()
|
||||
let status = AudioObjectGetPropertyData(
|
||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to get stream format: \(status)")
|
||||
}
|
||||
|
||||
return streamFormat
|
||||
}
|
||||
|
||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
||||
return AudioStreamMetadata(
|
||||
sampleRate: format.mSampleRate,
|
||||
channelsPerFrame: format.mChannelsPerFrame,
|
||||
bitsPerChannel: format.mBitsPerChannel,
|
||||
isFloat: format.mFormatFlags & kAudioFormatFlagIsFloat != 0,
|
||||
captureMode: "audio",
|
||||
deviceName: nil, // TODO: Get device name if needed
|
||||
deviceUID: nil, // TODO: Get device UID if needed
|
||||
encoding: format.mFormatFlags & kAudioFormatFlagIsFloat != 0 ? "pcm_f32le" : "pcm_s16le"
|
||||
)
|
||||
}
|
||||
|
||||
public static func writeMetadata(for format: AudioStreamBasicDescription) {
|
||||
let metadata = createMetadata(for: format)
|
||||
Logger.writeMessage(.metadata, data: metadata)
|
||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||
}
|
||||
|
||||
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||
Logger.debug(
|
||||
"Using device's native format",
|
||||
context: [
|
||||
"channels": String(format.mChannelsPerFrame),
|
||||
"sample_rate": String(format.mSampleRate),
|
||||
"bits_per_channel": String(format.mBitsPerChannel),
|
||||
"format_id": String(format.mFormatID),
|
||||
"format_flags": String(format: "0x%08x", format.mFormatFlags),
|
||||
"bytes_per_frame": String(format.mBytesPerFrame),
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioPacket {
|
||||
public let timestamp: Date
|
||||
public let duration: Double
|
||||
public let peakAmplitude: Float // useful for level monitoring
|
||||
public let rawAudioData: Data
|
||||
|
||||
public init(
|
||||
timestamp: Date,
|
||||
duration: Double,
|
||||
peakAmplitude: Float,
|
||||
rawAudioData: Data
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.duration = duration
|
||||
self.peakAmplitude = peakAmplitude
|
||||
self.rawAudioData = rawAudioData
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioRecorder {
|
||||
private var deviceID: AudioObjectID
|
||||
private var ioProcID: AudioDeviceIOProcID?
|
||||
private var sourceFormat: AudioStreamBasicDescription?
|
||||
private var finalFormat: AudioStreamBasicDescription?
|
||||
private var audioBuffer: AudioBuffer?
|
||||
private var outputHandler: AudioOutputHandler
|
||||
private var converter: AudioFormatConverter?
|
||||
private var chunkDuration: Double
|
||||
|
||||
init(
|
||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||
chunkDuration: Double = 0.2
|
||||
) {
|
||||
self.deviceID = deviceID
|
||||
self.outputHandler = outputHandler
|
||||
self.chunkDuration = chunkDuration
|
||||
|
||||
// Get source format and set up conversion if requested
|
||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||
self.sourceFormat = sourceFormat
|
||||
|
||||
if let targetSampleRate = convertToSampleRate {
|
||||
// Validate sample rate
|
||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
||||
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||
self.converter = converter
|
||||
self.finalFormat = converter.targetFormatDescription
|
||||
Logger.info(
|
||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||
} catch {
|
||||
Logger.error(
|
||||
"Failed to create audio converter, using original format",
|
||||
context: ["error": String(describing: error)])
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
}
|
||||
} else {
|
||||
self.converter = nil
|
||||
self.finalFormat = sourceFormat
|
||||
}
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
Logger.debug("Starting audio recording")
|
||||
|
||||
guard let sourceFormat = sourceFormat, let finalFormat = finalFormat else {
|
||||
fatalError("Audio formats not initialized")
|
||||
}
|
||||
|
||||
// Set up the audio buffer using source format and configurable chunk duration
|
||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||
|
||||
// Log format info and send metadata for FINAL format
|
||||
AudioFormatManager.logFormatInfo(finalFormat)
|
||||
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
||||
outputHandler.handleMetadata(metadata)
|
||||
outputHandler.handleStreamStart()
|
||||
|
||||
// Set up and start the IO proc
|
||||
setupAndStartIOProc()
|
||||
|
||||
Logger.info("Audio device started successfully")
|
||||
}
|
||||
|
||||
// FIXME: note to self, what about installTap? Would require audio engine and a node?
|
||||
private func setupAndStartIOProc() {
|
||||
Logger.debug("Creating IO proc")
|
||||
var status = AudioDeviceCreateIOProcID(
|
||||
deviceID,
|
||||
{
|
||||
(inDevice, inNow, inInputData, inInputTime, outOutputData, inOutputTime, inClientData)
|
||||
-> OSStatus in
|
||||
let recorder = Unmanaged<AudioRecorder>.fromOpaque(inClientData!).takeUnretainedValue()
|
||||
return recorder.processAudio(inInputData)
|
||||
},
|
||||
Unmanaged.passUnretained(self).toOpaque(),
|
||||
&ioProcID
|
||||
)
|
||||
|
||||
guard status == noErr else {
|
||||
fatalError("Failed to create IO proc: \(status)")
|
||||
}
|
||||
|
||||
Logger.debug("Starting audio device")
|
||||
status = AudioDeviceStart(deviceID, ioProcID)
|
||||
|
||||
if status != noErr {
|
||||
cleanupIOProc()
|
||||
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processAudio(_ inputData: UnsafePointer<AudioBufferList>) -> OSStatus {
|
||||
let bufferList = inputData.pointee
|
||||
let firstBuffer = bufferList.mBuffers
|
||||
|
||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
||||
"Warning: Received empty audio buffer".print(to: .standardError)
|
||||
return noErr
|
||||
}
|
||||
|
||||
// Append raw audio data to buffer
|
||||
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize))
|
||||
audioBuffer?.append(audioData)
|
||||
|
||||
// Process and send complete chunks, applying conversion if needed
|
||||
audioBuffer?.processChunks().forEach { packet in
|
||||
let processedPacket = converter?.transform(packet) ?? packet
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
// Send any remaining buffered audio, applying conversion if needed
|
||||
if let finalPacket = audioBuffer?.flushRemaining() {
|
||||
let processedPacket = converter?.transform(finalPacket) ?? finalPacket
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
|
||||
outputHandler.handleStreamStop()
|
||||
cleanupIOProc()
|
||||
}
|
||||
|
||||
private func cleanupIOProc() {
|
||||
if let ioProcID = ioProcID {
|
||||
AudioDeviceStop(deviceID, ioProcID)
|
||||
AudioDeviceDestroyIOProcID(deviceID, ioProcID)
|
||||
self.ioProcID = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioStreamMetadata: Codable {
|
||||
public let sampleRate: Double
|
||||
public let channelsPerFrame: UInt32
|
||||
public let bitsPerChannel: UInt32
|
||||
public let isFloat: Bool
|
||||
public let captureMode: String
|
||||
public let deviceName: String?
|
||||
public let deviceUID: String?
|
||||
public let encoding: String
|
||||
|
||||
public enum CodingKeys: String, CodingKey {
|
||||
case sampleRate = "sample_rate"
|
||||
case channelsPerFrame = "channels_per_frame"
|
||||
case bitsPerChannel = "bits_per_channel"
|
||||
case isFloat = "is_float"
|
||||
case captureMode = "capture_mode"
|
||||
case deviceName = "device_name"
|
||||
case deviceUID = "device_uid"
|
||||
case encoding
|
||||
}
|
||||
|
||||
public init(
|
||||
sampleRate: Double, channelsPerFrame: UInt32, bitsPerChannel: UInt32, isFloat: Bool,
|
||||
captureMode: String, deviceName: String?, deviceUID: String?, encoding: String
|
||||
) {
|
||||
self.sampleRate = sampleRate
|
||||
self.channelsPerFrame = channelsPerFrame
|
||||
self.bitsPerChannel = bitsPerChannel
|
||||
self.isFloat = isFloat
|
||||
self.captureMode = captureMode
|
||||
self.deviceName = deviceName
|
||||
self.deviceUID = deviceUID
|
||||
self.encoding = encoding
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
class AudioTapManager {
|
||||
private var tapID: AudioObjectID?
|
||||
private var deviceID: AudioObjectID?
|
||||
|
||||
init() {
|
||||
// Empty init - setup happens in setupAudioTap()
|
||||
}
|
||||
|
||||
deinit {
|
||||
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
|
||||
func setupAudioTap(with config: TapConfiguration) throws {
|
||||
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)
|
||||
|
||||
Logger.debug("Audio tap manager setup complete")
|
||||
}
|
||||
|
||||
/// Returns the aggregate device ID for recording
|
||||
func getDeviceID() -> AudioObjectID? {
|
||||
return deviceID
|
||||
}
|
||||
|
||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||
Logger.debug("Creating tap description")
|
||||
// Create a tap description
|
||||
let description = CATapDescription()
|
||||
|
||||
// Configure the tap to capture all system audio
|
||||
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 = true
|
||||
description.isExclusive = config.isExclusive
|
||||
description.deviceUID = nil // system default
|
||||
description.stream = 0 // first stream of output device
|
||||
|
||||
Logger.debug(
|
||||
"Tap description configured",
|
||||
context: [
|
||||
"name": description.name,
|
||||
"processes": String(describing: config.processes),
|
||||
"private": String(description.isPrivate),
|
||||
"mute": String(describing: description.muteBehavior),
|
||||
"mixdown": String(description.isMixdown),
|
||||
"mono": String(description.isMono),
|
||||
"exclusive": String(description.isExclusive),
|
||||
])
|
||||
|
||||
// Create the tap
|
||||
Logger.debug("Creating tap")
|
||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||
|
||||
Logger.debug(
|
||||
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
||||
guard status == kAudioHardwareNoError else {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
Logger.error(
|
||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||
throw AudioTeeError.tapAssignmentFailed(status)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Core AudioTee Errors
|
||||
|
||||
public enum AudioTeeError: Error {
|
||||
case setupFailed
|
||||
case tapCreationFailed(OSStatus)
|
||||
case aggregateDeviceCreationFailed(OSStatus)
|
||||
case tapAssignmentFailed(OSStatus)
|
||||
case pidTranslationFailed([Int32])
|
||||
}
|
||||
|
||||
// MARK: - Audio Format Conversion Errors
|
||||
|
||||
public enum AudioConverterError: Error {
|
||||
case invalidFormat
|
||||
case creationFailed
|
||||
}
|
||||
Reference in New Issue
Block a user