potential split between CLI and core library
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioBuffer {
|
||||
private var buffer: [UInt8]
|
||||
private var writeIndex: Int = 0
|
||||
private var readIndex: Int = 0
|
||||
private var availableBytes: Int = 0
|
||||
private let maxBufferSize: Int
|
||||
|
||||
private let bytesPerChunk: Int
|
||||
private let chunkDuration: Double
|
||||
|
||||
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
|
||||
|
||||
// Pre-calculate chunk parameters
|
||||
let bytesPerFrame = Int(format.mBytesPerFrame)
|
||||
let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
|
||||
self.bytesPerChunk = samplesPerChunk * bytesPerFrame
|
||||
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
|
||||
|
||||
// Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow
|
||||
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
|
||||
self.maxBufferSize = bytesPerSecond * 10
|
||||
|
||||
// Pre-allocated ring buffer
|
||||
self.buffer = Array(repeating: 0, count: maxBufferSize)
|
||||
}
|
||||
|
||||
public func append(_ data: Data) {
|
||||
guard availableBytes + data.count <= maxBufferSize else {
|
||||
Logger.error(
|
||||
"Audio buffer overflow",
|
||||
context: [
|
||||
"requested": String(data.count),
|
||||
"available": String(maxBufferSize - availableBytes),
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
data.withUnsafeBytes { bytes in
|
||||
let sourceBytes = bytes.bindMemory(to: UInt8.self)
|
||||
let dataSize = sourceBytes.count
|
||||
|
||||
// Check if we can copy in one block (no wrap-around)
|
||||
if writeIndex + dataSize <= maxBufferSize {
|
||||
// only one write needed
|
||||
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
|
||||
writeIndex = (writeIndex + dataSize) % maxBufferSize
|
||||
} else {
|
||||
// two writes needed due to wrap-around
|
||||
let firstChunkSize = maxBufferSize - writeIndex
|
||||
let secondChunkSize = dataSize - firstChunkSize
|
||||
|
||||
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
|
||||
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
|
||||
|
||||
writeIndex = secondChunkSize
|
||||
}
|
||||
}
|
||||
|
||||
availableBytes += data.count
|
||||
}
|
||||
|
||||
public func processChunks() -> [AudioPacket] {
|
||||
var packets: [AudioPacket] = []
|
||||
|
||||
while let packet = nextChunk() {
|
||||
packets.append(packet)
|
||||
}
|
||||
|
||||
return packets
|
||||
}
|
||||
|
||||
private func nextChunk() -> AudioPacket? {
|
||||
// Check if we have enough data for a complete chunk
|
||||
guard availableBytes >= bytesPerChunk else { return nil }
|
||||
|
||||
var chunkData = Data(capacity: bytesPerChunk)
|
||||
|
||||
// Check if we can copy in one block (no wrap-around)
|
||||
if readIndex + bytesPerChunk <= maxBufferSize {
|
||||
// one copy needed
|
||||
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk])
|
||||
readIndex = (readIndex + bytesPerChunk) % maxBufferSize
|
||||
} else {
|
||||
// two copies needed due to wrap-around
|
||||
let firstChunkSize = maxBufferSize - readIndex
|
||||
let secondChunkSize = bytesPerChunk - firstChunkSize
|
||||
|
||||
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
|
||||
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
|
||||
|
||||
readIndex = secondChunkSize
|
||||
}
|
||||
|
||||
availableBytes -= bytesPerChunk
|
||||
|
||||
return AudioPacket(
|
||||
timestamp: Date(),
|
||||
duration: chunkDuration,
|
||||
data: chunkData
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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.data
|
||||
|
||||
// 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,
|
||||
data: outputData
|
||||
)
|
||||
}
|
||||
|
||||
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.mFramesPerPacket = 1
|
||||
targetFormat.mBitsPerChannel = 16
|
||||
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
|
||||
targetFormat.mBytesPerFrame =
|
||||
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
|
||||
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
|
||||
|
||||
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
||||
}
|
||||
|
||||
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
||||
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioFormatManager {
|
||||
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
|
||||
// First, wait for the device to become alive/ready
|
||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||
let pollInterval = 0.1 // 100ms poll interval
|
||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||
|
||||
Logger.debug(
|
||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||
|
||||
// Poll device readiness
|
||||
for poll in 1...maxPolls {
|
||||
if isAudioDeviceValid(deviceID) {
|
||||
Logger.debug(
|
||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||
break
|
||||
}
|
||||
|
||||
if poll == maxPolls {
|
||||
Logger.info(
|
||||
"Device did not become ready within timeout, proceeding anyway",
|
||||
context: [
|
||||
"device_id": String(deviceID),
|
||||
"timeout_seconds": String(deviceReadyTimeout),
|
||||
])
|
||||
break
|
||||
}
|
||||
|
||||
Logger.info("------- not ready; retrying...")
|
||||
|
||||
Thread.sleep(forTimeInterval: pollInterval)
|
||||
}
|
||||
|
||||
// Now attempt to get the stream format with limited retries
|
||||
let maxRetries = 3 // Reduced since device should be ready
|
||||
let retryDelayMs = 20 // Shorter delay since we've already waited for readiness
|
||||
|
||||
for attempt in 1...maxRetries {
|
||||
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)
|
||||
|
||||
if status == noErr {
|
||||
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
|
||||
return streamFormat
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
"------- Failed to get stream format after device ready check, retrying...",
|
||||
context: [
|
||||
"attempt": String(attempt),
|
||||
"max_retries": String(maxRetries),
|
||||
"status": String(status),
|
||||
"device_id": String(deviceID),
|
||||
])
|
||||
|
||||
// Don't delay on the last attempt
|
||||
if attempt < maxRetries {
|
||||
Thread.sleep(forTimeInterval: Double(retryDelayMs) / 1000.0)
|
||||
}
|
||||
}
|
||||
|
||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
||||
Logger.error(
|
||||
"Failed to get device format after device readiness check and retries",
|
||||
context: [
|
||||
"device_id": String(deviceID),
|
||||
"device_was_ready": "true",
|
||||
])
|
||||
|
||||
fatalError(
|
||||
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
|
||||
)
|
||||
}
|
||||
|
||||
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,17 @@
|
||||
import Foundation
|
||||
|
||||
public struct AudioPacket {
|
||||
public let timestamp: Date
|
||||
public let duration: Double
|
||||
public let data: Data
|
||||
|
||||
public init(
|
||||
timestamp: Date,
|
||||
duration: Double,
|
||||
data: Data
|
||||
) {
|
||||
self.timestamp = timestamp
|
||||
self.duration = duration
|
||||
self.data = data
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioRecorder {
|
||||
private var deviceID: AudioObjectID
|
||||
private var ioProcID: AudioDeviceIOProcID?
|
||||
private var finalFormat: AudioStreamBasicDescription!
|
||||
private var audioBuffer: AudioBuffer?
|
||||
private var outputHandler: AudioOutputHandler
|
||||
private var converter: AudioFormatConverter?
|
||||
|
||||
public init(
|
||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||
chunkDuration: Double = 0.2
|
||||
) {
|
||||
self.deviceID = deviceID
|
||||
self.outputHandler = outputHandler
|
||||
|
||||
// Get source format and set up conversion if requested
|
||||
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
|
||||
|
||||
// Set up the audio buffer using source format and configurable chunk duration
|
||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
public func startRecording() {
|
||||
Logger.debug("Starting audio recording")
|
||||
|
||||
// Log format info and send metadata for final format
|
||||
AudioFormatManager.logFormatInfo(finalFormat)
|
||||
let metadata = AudioFormatManager.createMetadata(for: finalFormat)
|
||||
outputHandler.handleMetadata(metadata)
|
||||
outputHandler.handleStreamStart()
|
||||
|
||||
setupAndStartIOProc()
|
||||
|
||||
Logger.info("Audio device started successfully")
|
||||
}
|
||||
|
||||
// Note to self, what about installTap? Would require audio engine and a node?
|
||||
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||
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)
|
||||
|
||||
processAudioBuffer()
|
||||
|
||||
return noErr
|
||||
}
|
||||
|
||||
public func stopRecording() {
|
||||
processAudioBuffer()
|
||||
outputHandler.handleStreamStop()
|
||||
cleanupIOProc()
|
||||
}
|
||||
|
||||
private func processAudioBuffer() {
|
||||
// Process and send complete chunks, applying conversion if needed
|
||||
audioBuffer?.processChunks().forEach { packet in
|
||||
let processedPacket = converter?.transform(packet) ?? packet
|
||||
outputHandler.handleAudioPacket(processedPacket)
|
||||
}
|
||||
}
|
||||
|
||||
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,150 @@
|
||||
import AVFoundation
|
||||
import AudioToolbox
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
public class AudioTapManager {
|
||||
private var tapID: AudioObjectID?
|
||||
private var deviceID: AudioObjectID?
|
||||
|
||||
public init() {}
|
||||
|
||||
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
|
||||
public 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
|
||||
public func getDeviceID() -> AudioObjectID? {
|
||||
return deviceID
|
||||
}
|
||||
|
||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||
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
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
public struct TapConfiguration {
|
||||
public let processes: [Int32]
|
||||
public let muteBehavior: TapMuteBehavior
|
||||
public let isExclusive: Bool
|
||||
public let isMono: Bool
|
||||
|
||||
public init(processes: [Int32], muteBehavior: TapMuteBehavior, isExclusive: Bool, isMono: Bool) {
|
||||
self.processes = processes
|
||||
self.muteBehavior = muteBehavior
|
||||
self.isExclusive = isExclusive
|
||||
self.isMono = isMono
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import CoreAudio
|
||||
|
||||
public enum TapMuteBehavior: String, CaseIterable {
|
||||
case unmuted = "unmuted"
|
||||
case muted = "muted"
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .unmuted:
|
||||
return "Don't mute processes (default)"
|
||||
case .muted:
|
||||
return "Mute processes being tapped"
|
||||
}
|
||||
}
|
||||
|
||||
public var coreAudioValue: CATapMuteBehavior {
|
||||
switch self {
|
||||
case .unmuted:
|
||||
return .unmuted
|
||||
case .muted:
|
||||
return .muted
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user