library improvements
This commit is contained in:
@@ -99,11 +99,11 @@ struct AudioTee {
|
|||||||
func run() throws {
|
func run() throws {
|
||||||
setupSignalHandlers()
|
setupSignalHandlers()
|
||||||
|
|
||||||
Logger.info("Starting AudioTee...")
|
AudioTeeLogging.logger.info("Starting AudioTee...")
|
||||||
|
|
||||||
// Validate chunk duration
|
// Validate chunk duration
|
||||||
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
guard chunkDuration > 0 && chunkDuration <= 5.0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Invalid chunk duration",
|
"Invalid chunk duration",
|
||||||
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
context: ["chunk_duration": String(chunkDuration), "valid_range": "0.0 < duration <= 5.0"])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
@@ -123,7 +123,7 @@ struct AudioTee {
|
|||||||
do {
|
do {
|
||||||
try audioTapManager.setupAudioTap(with: tapConfig)
|
try audioTapManager.setupAudioTap(with: tapConfig)
|
||||||
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
} catch AudioTeeError.pidTranslationFailed(let failedPIDs) {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to translate process IDs to audio objects",
|
"Failed to translate process IDs to audio objects",
|
||||||
context: [
|
context: [
|
||||||
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
"failed_pids": failedPIDs.map(String.init).joined(separator: ", "),
|
||||||
@@ -131,21 +131,21 @@ struct AudioTee {
|
|||||||
])
|
])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
"Failed to setup audio tap", context: ["error": String(describing: error)])
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let deviceID = audioTapManager.getDeviceID() else {
|
guard let deviceID = audioTapManager.getDeviceID() else {
|
||||||
Logger.error("Failed to get device ID from audio tap manager")
|
AudioTeeLogging.logger.error("Failed to get device ID from audio tap manager")
|
||||||
throw ExitCode.failure
|
throw ExitCode.failure
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
|
let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush)
|
||||||
let recorder = AudioRecorder(
|
let recorder = try AudioRecorder(
|
||||||
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate,
|
||||||
chunkDuration: chunkDuration)
|
chunkDuration: chunkDuration)
|
||||||
recorder.startRecording()
|
try recorder.startRecording()
|
||||||
|
|
||||||
// Run until the run loop is stopped (by signal handler)
|
// Run until the run loop is stopped (by signal handler)
|
||||||
while true {
|
while true {
|
||||||
@@ -155,17 +155,17 @@ struct AudioTee {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("Shutting down...")
|
AudioTeeLogging.logger.info("Shutting down...")
|
||||||
recorder.stopRecording()
|
recorder.stopRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupSignalHandlers() {
|
private func setupSignalHandlers() {
|
||||||
signal(SIGINT) { _ in
|
signal(SIGINT) { _ in
|
||||||
Logger.info("Received SIGINT, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGINT, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
signal(SIGTERM) { _ in
|
signal(SIGTERM) { _ in
|
||||||
Logger.info("Received SIGTERM, initiating graceful shutdown...")
|
AudioTeeLogging.logger.info("Received SIGTERM, initiating graceful shutdown...")
|
||||||
CFRunLoopStop(CFRunLoopGetMain())
|
CFRunLoopStop(CFRunLoopGetMain())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import AudioTeeCore
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// CLI-specific output handler that writes raw PCM audio to stdout
|
||||||
|
/// and lifecycle messages to stderr via the logger.
|
||||||
|
class BinaryAudioOutputHandler: AudioOutputHandler {
|
||||||
|
private let flushAfterWrite: Bool
|
||||||
|
|
||||||
|
init(flushAfterWrite: Bool = false) {
|
||||||
|
self.flushAfterWrite = flushAfterWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleAudioPacket(_ packet: AudioPacket) {
|
||||||
|
// Write raw binary audio data directly to stdout
|
||||||
|
FileHandle.standardOutput.write(packet.data)
|
||||||
|
if flushAfterWrite {
|
||||||
|
fflush(stdout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMetadata(_ metadata: AudioStreamMetadata) {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.metadata, data: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStreamStart() {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.streamStart, data: Optional<String>.none)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleStreamStop() {
|
||||||
|
AudioTeeLogging.logger.writeMessage(.streamStop, data: Optional<String>.none)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ public class AudioBuffer {
|
|||||||
|
|
||||||
public func append(_ data: Data) {
|
public func append(_ data: Data) {
|
||||||
guard availableBytes + data.count <= maxBufferSize else {
|
guard availableBytes + data.count <= maxBufferSize else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Audio buffer overflow",
|
"Audio buffer overflow",
|
||||||
context: [
|
context: [
|
||||||
"requested": String(data.count),
|
"requested": String(data.count),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ public class AudioFormatConverter {
|
|||||||
self.targetFormat = targetAVFormat
|
self.targetFormat = targetAVFormat
|
||||||
self.avConverter = converter
|
self.avConverter = converter
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio converter created",
|
"Audio converter created",
|
||||||
context: [
|
context: [
|
||||||
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
||||||
@@ -39,7 +39,7 @@ public class AudioFormatConverter {
|
|||||||
|
|
||||||
// Warn about upsampling once during initialization
|
// Warn about upsampling once during initialization
|
||||||
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
||||||
context: [
|
context: [
|
||||||
"source_rate": String(sourceAVFormat.sampleRate),
|
"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 {
|
public var targetFormatDescription: AudioStreamBasicDescription {
|
||||||
return targetFormat.streamDescription.pointee
|
return targetFormat.streamDescription.pointee
|
||||||
}
|
}
|
||||||
@@ -67,7 +72,7 @@ public class AudioFormatConverter {
|
|||||||
let inputBuffer = AVAudioPCMBuffer(
|
let inputBuffer = AVAudioPCMBuffer(
|
||||||
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
|
||||||
else {
|
else {
|
||||||
Logger.error("Failed to create input buffer")
|
AudioTeeLogging.logger.error("Failed to create input buffer")
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +88,7 @@ public class AudioFormatConverter {
|
|||||||
let outputBuffer = AVAudioPCMBuffer(
|
let outputBuffer = AVAudioPCMBuffer(
|
||||||
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
|
||||||
else {
|
else {
|
||||||
Logger.error("Failed to create output buffer")
|
AudioTeeLogging.logger.error("Failed to create output buffer")
|
||||||
return packet
|
return packet
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +104,7 @@ public class AudioFormatConverter {
|
|||||||
|
|
||||||
// Check if conversion produced output (regardless of status code)
|
// Check if conversion produced output (regardless of status code)
|
||||||
guard outputBuffer.frameLength > 0 else {
|
guard outputBuffer.frameLength > 0 else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Audio conversion produced no output",
|
"Audio conversion produced no output",
|
||||||
context: [
|
context: [
|
||||||
"status": String(describing: status),
|
"status": String(describing: status),
|
||||||
|
|||||||
@@ -3,25 +3,25 @@ import CoreAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class AudioFormatManager {
|
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
|
// First, wait for the device to become alive/ready
|
||||||
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
let deviceReadyTimeout = 2.0 // 2 seconds max wait
|
||||||
let pollInterval = 0.1 // 100ms poll interval
|
let pollInterval = 0.1 // 100ms poll interval
|
||||||
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
let maxPolls = Int(deviceReadyTimeout / pollInterval)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
"Waiting for audio device to become ready", context: ["device_id": String(deviceID)])
|
||||||
|
|
||||||
// Poll device readiness
|
// Poll device readiness
|
||||||
for poll in 1...maxPolls {
|
for poll in 1...maxPolls {
|
||||||
if isAudioDeviceValid(deviceID) {
|
if isAudioDeviceValid(deviceID) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
"Audio device is ready", context: ["device_id": String(deviceID), "polls": String(poll)])
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if poll == maxPolls {
|
if poll == maxPolls {
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Device did not become ready within timeout, proceeding anyway",
|
"Device did not become ready within timeout, proceeding anyway",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -30,7 +30,7 @@ public class AudioFormatManager {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info("------- not ready; retrying...")
|
AudioTeeLogging.logger.info("------- not ready; retrying...")
|
||||||
|
|
||||||
Thread.sleep(forTimeInterval: pollInterval)
|
Thread.sleep(forTimeInterval: pollInterval)
|
||||||
}
|
}
|
||||||
@@ -49,11 +49,11 @@ public class AudioFormatManager {
|
|||||||
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
|
||||||
|
|
||||||
if status == noErr {
|
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
|
return streamFormat
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"------- Failed to get stream format after device ready check, retrying...",
|
"------- Failed to get stream format after device ready check, retrying...",
|
||||||
context: [
|
context: [
|
||||||
"attempt": String(attempt),
|
"attempt": String(attempt),
|
||||||
@@ -69,16 +69,14 @@ public class AudioFormatManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If all attempts failed after device readiness confirmation, this is a genuine error
|
// 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",
|
"Failed to get device format after device readiness check and retries",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
"device_was_ready": "true",
|
"device_was_ready": "true",
|
||||||
])
|
])
|
||||||
|
|
||||||
fatalError(
|
throw AudioTeeError.deviceFormatUnavailable(deviceID)
|
||||||
"Failed to get stream format from ready device: \(deviceID). This indicates a Core Audio subsystem error."
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func createMetadata(for format: AudioStreamBasicDescription) -> AudioStreamMetadata {
|
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) {
|
public static func logFormatInfo(_ format: AudioStreamBasicDescription) {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Using device's native format",
|
"Using device's native format",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(format.mChannelsPerFrame),
|
"channels": String(format.mChannelsPerFrame),
|
||||||
|
|||||||
@@ -10,15 +10,25 @@ public class AudioRecorder {
|
|||||||
private var outputHandler: AudioOutputHandler
|
private var outputHandler: AudioOutputHandler
|
||||||
private var converter: AudioFormatConverter?
|
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(
|
public init(
|
||||||
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
deviceID: AudioObjectID, outputHandler: AudioOutputHandler, convertToSampleRate: Double? = nil,
|
||||||
chunkDuration: Double = 0.2
|
chunkDuration: Double = 0.2
|
||||||
) {
|
) throws {
|
||||||
self.deviceID = deviceID
|
self.deviceID = deviceID
|
||||||
self.outputHandler = outputHandler
|
self.outputHandler = outputHandler
|
||||||
|
|
||||||
// Get source format and set up conversion if requested
|
// 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
|
// Set up the audio buffer using source format and configurable chunk duration
|
||||||
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
self.audioBuffer = AudioBuffer(format: sourceFormat, chunkDuration: chunkDuration)
|
||||||
@@ -26,7 +36,7 @@ public class AudioRecorder {
|
|||||||
if let targetSampleRate = convertToSampleRate {
|
if let targetSampleRate = convertToSampleRate {
|
||||||
// Validate sample rate
|
// Validate sample rate
|
||||||
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
|
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.converter = nil
|
||||||
self.finalFormat = sourceFormat
|
self.finalFormat = sourceFormat
|
||||||
return
|
return
|
||||||
@@ -36,10 +46,10 @@ public class AudioRecorder {
|
|||||||
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
let converter = try AudioFormatConverter.toSampleRate(targetSampleRate, from: sourceFormat)
|
||||||
self.converter = converter
|
self.converter = converter
|
||||||
self.finalFormat = converter.targetFormatDescription
|
self.finalFormat = converter.targetFormatDescription
|
||||||
Logger.info(
|
AudioTeeLogging.logger.info(
|
||||||
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
"Audio conversion enabled", context: ["target_sample_rate": String(targetSampleRate)])
|
||||||
} catch {
|
} catch {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to create audio converter, using original format",
|
"Failed to create audio converter, using original format",
|
||||||
context: ["error": String(describing: error)])
|
context: ["error": String(describing: error)])
|
||||||
self.converter = nil
|
self.converter = nil
|
||||||
@@ -51,8 +61,8 @@ public class AudioRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public func startRecording() {
|
public func startRecording() throws {
|
||||||
Logger.debug("Starting audio recording")
|
AudioTeeLogging.logger.debug("Starting audio recording")
|
||||||
|
|
||||||
// Log format info and send metadata for final format
|
// Log format info and send metadata for final format
|
||||||
AudioFormatManager.logFormatInfo(finalFormat)
|
AudioFormatManager.logFormatInfo(finalFormat)
|
||||||
@@ -60,15 +70,15 @@ public class AudioRecorder {
|
|||||||
outputHandler.handleMetadata(metadata)
|
outputHandler.handleMetadata(metadata)
|
||||||
outputHandler.handleStreamStart()
|
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?
|
// 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
|
// No; AudioEngine.installTap() can only fire as often as 100ms. too slow for us
|
||||||
private func setupAndStartIOProc() {
|
private func setupAndStartIOProc() throws {
|
||||||
Logger.debug("Creating IO proc")
|
AudioTeeLogging.logger.debug("Creating IO proc")
|
||||||
var status = AudioDeviceCreateIOProcID(
|
var status = AudioDeviceCreateIOProcID(
|
||||||
deviceID,
|
deviceID,
|
||||||
{
|
{
|
||||||
@@ -82,15 +92,15 @@ public class AudioRecorder {
|
|||||||
)
|
)
|
||||||
|
|
||||||
guard status == noErr else {
|
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)
|
status = AudioDeviceStart(deviceID, ioProcID)
|
||||||
|
|
||||||
if status != noErr {
|
if status != noErr {
|
||||||
cleanupIOProc()
|
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
|
let firstBuffer = bufferList.mBuffers
|
||||||
|
|
||||||
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else {
|
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
|
return noErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class AudioTapManager {
|
|||||||
public init() {}
|
public init() {}
|
||||||
|
|
||||||
deinit {
|
deinit {
|
||||||
Logger.debug("Cleaning up audio tap manager")
|
AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
|
||||||
|
|
||||||
if let tapID = tapID {
|
if let tapID = tapID {
|
||||||
AudioHardwareDestroyProcessTap(tapID)
|
AudioHardwareDestroyProcessTap(tapID)
|
||||||
@@ -25,7 +25,7 @@ public class AudioTapManager {
|
|||||||
|
|
||||||
/// Sets up the audio tap and aggregate device
|
/// Sets up the audio tap and aggregate device
|
||||||
public func setupAudioTap(with config: TapConfiguration) throws {
|
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)
|
tapID = try createSystemAudioTap(with: config)
|
||||||
deviceID = try createAggregateDevice()
|
deviceID = try createAggregateDevice()
|
||||||
@@ -36,7 +36,7 @@ public class AudioTapManager {
|
|||||||
|
|
||||||
try addTapToAggregateDevice(tapID: tapID, deviceID: deviceID)
|
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
|
/// Returns the aggregate device ID for recording
|
||||||
@@ -45,7 +45,7 @@ public class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
private func createSystemAudioTap(with config: TapConfiguration) throws -> AudioObjectID {
|
||||||
Logger.debug("Creating tap description")
|
AudioTeeLogging.logger.debug("Creating tap description")
|
||||||
let description = CATapDescription()
|
let description = CATapDescription()
|
||||||
|
|
||||||
description.name = "audiotee-tap"
|
description.name = "audiotee-tap"
|
||||||
@@ -58,7 +58,7 @@ public class AudioTapManager {
|
|||||||
description.deviceUID = nil // system default
|
description.deviceUID = nil // system default
|
||||||
description.stream = 0 // first stream of output device
|
description.stream = 0 // first stream of output device
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap description configured",
|
"Tap description configured",
|
||||||
context: [
|
context: [
|
||||||
"name": description.name,
|
"name": description.name,
|
||||||
@@ -69,14 +69,14 @@ public class AudioTapManager {
|
|||||||
])
|
])
|
||||||
|
|
||||||
// Create the tap
|
// Create the tap
|
||||||
Logger.debug("Creating tap")
|
AudioTeeLogging.logger.debug("Creating tap")
|
||||||
var tapID = AudioObjectID(kAudioObjectUnknown)
|
var tapID = AudioObjectID(kAudioObjectUnknown)
|
||||||
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
let status = AudioHardwareCreateProcessTap(description, &tapID)
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
|
||||||
guard status == kAudioHardwareNoError else {
|
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)
|
throw AudioTeeError.tapCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ public class AudioTapManager {
|
|||||||
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
tapID, &propertyAddress, 0, nil, &propertySize, &streamDescription)
|
||||||
|
|
||||||
if formatStatus == noErr {
|
if formatStatus == noErr {
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Tap format retrieved",
|
"Tap format retrieved",
|
||||||
context: [
|
context: [
|
||||||
"channels": String(streamDescription.mChannelsPerFrame),
|
"channels": String(streamDescription.mChannelsPerFrame),
|
||||||
@@ -115,7 +115,7 @@ public class AudioTapManager {
|
|||||||
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
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)
|
throw AudioTeeError.aggregateDeviceCreationFailed(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ public class AudioTapManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
guard status == kAudioHardwareNoError else {
|
guard status == kAudioHardwareNoError else {
|
||||||
Logger.error(
|
AudioTeeLogging.logger.error(
|
||||||
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
"Failed to add tap to aggregate device", context: ["status": String(status)])
|
||||||
throw AudioTeeError.tapAssignmentFailed(status)
|
throw AudioTeeError.tapAssignmentFailed(status)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import CoreAudio
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
// MARK: - Core AudioTee Errors
|
// MARK: - Core AudioTee Errors
|
||||||
@@ -8,6 +9,9 @@ public enum AudioTeeError: Error {
|
|||||||
case aggregateDeviceCreationFailed(OSStatus)
|
case aggregateDeviceCreationFailed(OSStatus)
|
||||||
case tapAssignmentFailed(OSStatus)
|
case tapAssignmentFailed(OSStatus)
|
||||||
case pidTranslationFailed([Int32])
|
case pidTranslationFailed([Int32])
|
||||||
|
case deviceFormatUnavailable(AudioObjectID)
|
||||||
|
case ioProcCreationFailed(OSStatus)
|
||||||
|
case deviceStartFailed(OSStatus)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Audio Format Conversion Errors
|
// MARK: - Audio Format Conversion Errors
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
public class BinaryAudioOutputHandler: AudioOutputHandler {
|
|
||||||
private let flushAfterWrite: Bool
|
|
||||||
|
|
||||||
public init(flushAfterWrite: Bool = false) {
|
|
||||||
self.flushAfterWrite = flushAfterWrite
|
|
||||||
}
|
|
||||||
public func handleAudioPacket(_ packet: AudioPacket) {
|
|
||||||
// Write raw binary audio data directly to stdout
|
|
||||||
FileHandle.standardOutput.write(packet.data)
|
|
||||||
if flushAfterWrite {
|
|
||||||
fflush(stdout)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleMetadata(_ metadata: AudioStreamMetadata) {
|
|
||||||
Logger.writeMessage(.metadata, data: metadata)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStart() {
|
|
||||||
Logger.writeMessage(.streamStart, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
|
|
||||||
public func handleStreamStop() {
|
|
||||||
Logger.writeMessage(.streamStop, data: Optional<String>.none)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
// MARK: - Logging protocol
|
||||||
|
|
||||||
|
/// Protocol that library consumers implement to receive log output.
|
||||||
|
/// The library never writes to stderr directly — it calls through this.
|
||||||
|
public protocol AudioTeeLogger {
|
||||||
|
func debug(_ message: String, context: [String: String]?)
|
||||||
|
func info(_ message: String, context: [String: String]?)
|
||||||
|
func error(_ message: String, context: [String: String]?)
|
||||||
|
|
||||||
|
/// Called for structured lifecycle messages (metadata, stream_start, stream_stop).
|
||||||
|
/// Default implementation is a no-op — pure library consumers get metadata
|
||||||
|
/// via AudioOutputHandler instead.
|
||||||
|
func writeMessage<T: Codable>(_ type: MessageType, data: T?)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Defaults
|
||||||
|
|
||||||
|
extension AudioTeeLogger {
|
||||||
|
/// Library consumers typically don't need structured message output;
|
||||||
|
/// they receive metadata via the AudioOutputHandler protocol instead.
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {}
|
||||||
|
|
||||||
|
/// Convenience overloads so callers can omit context when it's nil.
|
||||||
|
public func debug(_ message: String) { debug(message, context: nil) }
|
||||||
|
public func info(_ message: String) { info(message, context: nil) }
|
||||||
|
public func error(_ message: String) { error(message, context: nil) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Global logging configuration
|
||||||
|
|
||||||
|
/// Global logger instance. Defaults to StderrJSONLogger (CLI behavior).
|
||||||
|
/// Library consumers can replace this before calling any AudioTeeCore API.
|
||||||
|
///
|
||||||
|
/// // Silence all logging:
|
||||||
|
/// AudioTeeLogging.logger = NullLogger()
|
||||||
|
///
|
||||||
|
/// // Custom logging:
|
||||||
|
/// AudioTeeLogging.logger = MyOSLogLogger()
|
||||||
|
///
|
||||||
|
public enum AudioTeeLogging {
|
||||||
|
nonisolated(unsafe) public static var logger: AudioTeeLogger = StderrJSONLogger()
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
public class Logger {
|
/// Default logger implementation that writes JSON messages to stderr.
|
||||||
nonisolated(unsafe) private static let dateFormatter: ISO8601DateFormatter = {
|
/// This is the CLI-appropriate logger; library consumers can replace it
|
||||||
|
/// via AudioTeeLogging.logger.
|
||||||
|
public class StderrJSONLogger: AudioTeeLogger {
|
||||||
|
private let dateFormatter: ISO8601DateFormatter = {
|
||||||
let formatter = ISO8601DateFormatter()
|
let formatter = ISO8601DateFormatter()
|
||||||
formatter.formatOptions = [
|
formatter.formatOptions = [
|
||||||
.withInternetDateTime,
|
.withInternetDateTime,
|
||||||
@@ -10,17 +13,22 @@ public class Logger {
|
|||||||
return formatter
|
return formatter
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private static let jsonEncoder: JSONEncoder = {
|
private let jsonEncoder: JSONEncoder = {
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
encoder.dateEncodingStrategy = .custom { date, encoder in
|
|
||||||
var container = encoder.singleValueContainer()
|
|
||||||
try container.encode(dateFormatter.string(from: date))
|
|
||||||
}
|
|
||||||
return encoder
|
return encoder
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Write any message with the unified envelope
|
public init() {
|
||||||
public static func writeMessage<T: Codable>(_ type: MessageType, data: T? = nil) {
|
// Configured in init because stored property initializers can't
|
||||||
|
// reference other instance properties (self.dateFormatter).
|
||||||
|
jsonEncoder.dateEncodingStrategy = .custom { [dateFormatter] date, encoder in
|
||||||
|
var container = encoder.singleValueContainer()
|
||||||
|
try container.encode(dateFormatter.string(from: date))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write any message with the unified envelope to stderr
|
||||||
|
public func writeMessage<T: Codable>(_ type: MessageType, data: T?) {
|
||||||
let message = Message(type: type, data: data)
|
let message = Message(type: type, data: data)
|
||||||
do {
|
do {
|
||||||
let jsonData = try jsonEncoder.encode(message)
|
let jsonData = try jsonEncoder.encode(message)
|
||||||
@@ -32,17 +40,17 @@ public class Logger {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Convenience methods for different message types
|
// Convenience methods for different message types
|
||||||
public static func info(_ message: String, context: [String: String]? = nil) {
|
public func info(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.info, data: logData)
|
writeMessage(.info, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func error(_ message: String, context: [String: String]? = nil) {
|
public func error(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.error, data: logData)
|
writeMessage(.error, data: logData)
|
||||||
}
|
}
|
||||||
|
|
||||||
public static func debug(_ message: String, context: [String: String]? = nil) {
|
public func debug(_ message: String, context: [String: String]? = nil) {
|
||||||
let logData = LogData(message: message, context: context)
|
let logData = LogData(message: message, context: context)
|
||||||
writeMessage(.debug, data: logData)
|
writeMessage(.debug, data: logData)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func isAudioDeviceValid(_ deviceID: AudioObjectID) -> Bool {
|
|||||||
|
|
||||||
let valid = status == kAudioHardwareNoError && isAlive == 1
|
let valid = status == kAudioHardwareNoError && isAlive == 1
|
||||||
|
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Checked device validity",
|
"Checked device validity",
|
||||||
context: [
|
context: [
|
||||||
"device_id": String(deviceID),
|
"device_id": String(deviceID),
|
||||||
@@ -63,7 +63,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
if status == kAudioHardwareNoError && processObject != kAudioObjectUnknown {
|
||||||
processObjects.append(processObject)
|
processObjects.append(processObject)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Translated PID to process object",
|
"Translated PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -71,7 +71,7 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
])
|
])
|
||||||
} else {
|
} else {
|
||||||
failedPIDs.append(pid)
|
failedPIDs.append(pid)
|
||||||
Logger.debug(
|
AudioTeeLogging.logger.debug(
|
||||||
"Failed to translate PID to process object",
|
"Failed to translate PID to process object",
|
||||||
context: [
|
context: [
|
||||||
"pid": String(pid),
|
"pid": String(pid),
|
||||||
@@ -87,11 +87,3 @@ func translatePIDsToProcessObjects(_ pids: [Int32]) throws -> [AudioObjectID] {
|
|||||||
|
|
||||||
return processObjects
|
return processObjects
|
||||||
}
|
}
|
||||||
|
|
||||||
extension String {
|
|
||||||
func print(to fileHandle: FileHandle) {
|
|
||||||
if let data = (self + "\n").data(using: .utf8) {
|
|
||||||
fileHandle.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user