potential split between CLI and core library

This commit is contained in:
Nick Payne
2025-08-19 08:51:14 +01:00
parent a311cc583f
commit b7455d26d1
20 changed files with 74 additions and 8 deletions
+105
View File
@@ -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
}
}
}