library improvements

This commit is contained in:
Nick Payne
2026-02-25 20:26:39 +00:00
parent c4cf27553a
commit 08f0bc8f6c
12 changed files with 171 additions and 112 deletions
+1 -1
View File
@@ -29,7 +29,7 @@ public class AudioBuffer {
public func append(_ data: Data) {
guard availableBytes + data.count <= maxBufferSize else {
Logger.error(
AudioTeeLogging.logger.error(
"Audio buffer overflow",
context: [
"requested": String(data.count),
@@ -28,7 +28,7 @@ public class AudioFormatConverter {
self.targetFormat = targetAVFormat
self.avConverter = converter
Logger.debug(
AudioTeeLogging.logger.debug(
"Audio converter created",
context: [
"source_sample_rate": String(sourceAVFormat.sampleRate),
@@ -39,7 +39,7 @@ public class AudioFormatConverter {
// Warn about upsampling once during initialization
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
Logger.info(
AudioTeeLogging.logger.info(
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
context: [
"source_rate": String(sourceAVFormat.sampleRate),
@@ -48,7 +48,12 @@ public class AudioFormatConverter {
}
}
/// Get the target format as AudioStreamBasicDescription
/// The source format this converter reads from.
public var sourceFormatDescription: AudioStreamBasicDescription {
return sourceFormat.streamDescription.pointee
}
/// The target format this converter produces.
public var targetFormatDescription: AudioStreamBasicDescription {
return targetFormat.streamDescription.pointee
}
@@ -67,7 +72,7 @@ public class AudioFormatConverter {
let inputBuffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
else {
Logger.error("Failed to create input buffer")
AudioTeeLogging.logger.error("Failed to create input buffer")
return packet
}
@@ -83,7 +88,7 @@ public class AudioFormatConverter {
let outputBuffer = AVAudioPCMBuffer(
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
else {
Logger.error("Failed to create output buffer")
AudioTeeLogging.logger.error("Failed to create output buffer")
return packet
}
@@ -99,7 +104,7 @@ public class AudioFormatConverter {
// Check if conversion produced output (regardless of status code)
guard outputBuffer.frameLength > 0 else {
Logger.error(
AudioTeeLogging.logger.error(
"Audio conversion produced no output",
context: [
"status": String(describing: status),
@@ -3,25 +3,25 @@ import CoreAudio
import Foundation
public class AudioFormatManager {
public static func getDeviceFormat(deviceID: AudioObjectID) -> AudioStreamBasicDescription {
public static func getDeviceFormat(deviceID: AudioObjectID) throws -> 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(
AudioTeeLogging.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(
AudioTeeLogging.logger.debug(
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
break
}
if poll == maxPolls {
Logger.info(
AudioTeeLogging.logger.info(
"Device did not become ready within timeout, proceeding anyway",
context: [
"device_id": String(deviceID),
@@ -30,7 +30,7 @@ public class AudioFormatManager {
break
}
Logger.info("------- not ready; retrying...")
AudioTeeLogging.logger.info("------- not ready; retrying...")
Thread.sleep(forTimeInterval: pollInterval)
}
@@ -49,11 +49,11 @@ public class AudioFormatManager {
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr {
Logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
AudioTeeLogging.logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)])
return streamFormat
}
Logger.info(
AudioTeeLogging.logger.info(
"------- Failed to get stream format after device ready check, retrying...",
context: [
"attempt": String(attempt),
@@ -69,16 +69,14 @@ public class AudioFormatManager {
}
// If all attempts failed after device readiness confirmation, this is a genuine error
Logger.error(
AudioTeeLogging.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."
)
throw AudioTeeError.deviceFormatUnavailable(deviceID)
}
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
@@ -94,14 +92,8 @@ public class AudioFormatManager {
)
}
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(
AudioTeeLogging.logger.debug(
"Using device's native format",
context: [
"channels": String(format.mChannelsPerFrame),
+25 -15
View File
@@ -10,15 +10,25 @@ public class AudioRecorder {
private var outputHandler: AudioOutputHandler
private var converter: AudioFormatConverter?
/// The audio format this recorder produces (after any conversion).
public var outputFormat: AudioStreamBasicDescription {
return finalFormat
}
/// Whether this recorder is performing sample rate conversion.
public var isConverting: Bool {
return converter != nil
}
public init(
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
chunkDuration: Double = 0.2
) {
) throws {
self.deviceID = deviceID
self.outputHandler = outputHandler
// Get source format and set up conversion if requested
let sourceFormat = AudioFormatManager.getDeviceFormat(deviceID: deviceID)
let sourceFormat = try AudioFormatManager.getDeviceFormat(deviceID: deviceID)
// Set up the audio buffer using source format and configurable chunk duration
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
@@ -26,7 +36,7 @@ public class AudioRecorder {
if let targetSampleRate = convertToSampleRate {
// Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
Logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
AudioTeeLogging.logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)])
self.converter = nil
self.finalFormat = sourceFormat
return
@@ -36,10 +46,10 @@ public class AudioRecorder {
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
self.converter = converter
self.finalFormat = converter.targetFormatDescription
Logger.info(
AudioTeeLogging.logger.info(
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
} catch {
Logger.error(
AudioTeeLogging.logger.error(
"Failed to create audio converter, using original format",
context: ["error": String(describing: error)])
self.converter = nil
@@ -51,8 +61,8 @@ public class AudioRecorder {
}
}
public func startRecording() {
Logger.debug("Starting audio recording")
public func startRecording() throws {
AudioTeeLogging.logger.debug("Starting audio recording")
// Log format info and send metadata for final format
AudioFormatManager.logFormatInfo(finalFormat)
@@ -60,15 +70,15 @@ public class AudioRecorder {
outputHandler.handleMetadata(metadata)
outputHandler.handleStreamStart()
setupAndStartIOProc()
try setupAndStartIOProc()
Logger.info("Audio device started successfully")
AudioTeeLogging.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")
private func setupAndStartIOProc() throws {
AudioTeeLogging.logger.debug("Creating IO proc")
var status = AudioDeviceCreateIOProcID(
deviceID,
{
@@ -82,15 +92,15 @@ public class AudioRecorder {
)
guard status == noErr else {
fatalError("Failed to create IO proc: \(status)")
throw AudioTeeError.ioProcCreationFailed(status)
}
Logger.debug("Starting audio device")
AudioTeeLogging.logger.debug("Starting audio device")
status = AudioDeviceStart(deviceID, ioProcID)
if status != noErr {
cleanupIOProc()
fatalError("Failed to start audio device: \(status). Device ID: \(deviceID)")
throw AudioTeeError.deviceStartFailed(status)
}
}
@@ -99,7 +109,7 @@ public class AudioRecorder {
let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
"Warning: Received empty audio buffer".print(to: .standardError)
AudioTeeLogging.logger.error("Received empty audio buffer")
return noErr
}
+11 -11
View File
@@ -10,7 +10,7 @@ public class AudioTapManager {
public init() {}
deinit {
Logger.debug("Cleaning up audio tap manager")
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
if let tapID = tapID {
AudioHardwareDestroyProcessTap(tapID)
@@ -25,7 +25,7 @@ public class AudioTapManager {
/// Sets up the audio tap and aggregate device
public func setupAudioTap(with config: TapConfiguration) throws {
Logger.debug("Setting up audio tap manager")
AudioTeeLogging.logger.debug("Setting up audio tap manager")
tapID = try createSystemAudioTap(with: config)
deviceID = try createAggregateDevice()
@@ -36,7 +36,7 @@ public class AudioTapManager {
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
Logger.debug("Audio tap manager setup complete")
AudioTeeLogging.logger.debug("Audio tap manager setup complete")
}
/// Returns the aggregate device ID for recording
@@ -45,7 +45,7 @@ public class AudioTapManager {
}
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
Logger.debug("Creating tap description")
AudioTeeLogging.logger.debug("Creating tap description")
let description = CATapDescription()
description.name = "audiotee-tap"
@@ -58,7 +58,7 @@ public class AudioTapManager {
description.deviceUID = nil // system default
description.stream = 0 // first stream of output device
Logger.debug(
AudioTeeLogging.logger.debug(
"Tap description configured",
context: [
"name": description.name,
@@ -69,14 +69,14 @@ public class AudioTapManager {
])
// Create the tap
Logger.debug("Creating tap")
AudioTeeLogging.logger.debug("Creating tap")
var tapID = AudioObjectID(kAudioObjectUnknown)
let status = AudioHardwareCreateProcessTap(description, &tapID)
Logger.debug(
AudioTeeLogging.logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create audio tap", context: ["status": String(status)])
AudioTeeLogging.logger.error("Failed to create audio tap", context: ["status": String(status)])
throw AudioTeeError.tapCreationFailed(status)
}
@@ -88,7 +88,7 @@ public class AudioTapManager {
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
if formatStatus == noErr {
Logger.debug(
AudioTeeLogging.logger.debug(
"Tap format retrieved",
context: [
"channels": String(streamDescription.mChannelsPerFrame),
@@ -115,7 +115,7 @@ public class AudioTapManager {
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else {
Logger.error("Failed to create aggregate device", context: ["status": String(status)])
AudioTeeLogging.logger.error("Failed to create aggregate device", context: ["status": String(status)])
throw AudioTeeError.aggregateDeviceCreationFailed(status)
}
@@ -142,7 +142,7 @@ public class AudioTapManager {
}
guard status == kAudioHardwareNoError else {
Logger.error(
AudioTeeLogging.logger.error(
"Failed to add tap to aggregate device", context: ["status": String(status)])
throw AudioTeeError.tapAssignmentFailed(status)
}
@@ -1,3 +1,4 @@
import CoreAudio
import Foundation
// MARK: - Core AudioTee Errors
@@ -8,6 +9,9 @@ public enum AudioTeeError: Error {
case aggregateDeviceCreationFailed(OSStatus)
case tapAssignmentFailed(OSStatus)
case pidTranslationFailed([Int32])
case deviceFormatUnavailable(AudioObjectID)
case ioProcCreationFailed(OSStatus)
case deviceStartFailed(OSStatus)
}
// MARK: - Audio Format Conversion Errors