6 Commits

Author SHA1 Message Date
Nick Payne a1eb465142 zero-alloc audio pipeline: pointer-based IO from ring buffer to stdout
Replace Data/AudioPacket allocations with raw pointer callbacks through
the entire audio pipeline. Ring buffer hands out direct pointers (or
linearizes into a pre-allocated scratch buffer on wrap-around), converter
accepts/emits pointers via its cached buffers, and output handler writes
to stdout via write(2) with EINTR handling.

Remove AudioPacket (dead code), --flush flag (no-op with raw write(2)).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 07:14:16 +00:00
Nick Payne 65c2d58c82 remove unused append(_ data: Data) overload from AudioBuffer
Only one append path exists now: append(from:count:), which is what the
IO proc callback uses. The Data-based overload had no callers in source
and added a dead code path to maintain.

Also resolves CoreAudio.AudioBuffer name collision in tests via typealias.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:38:14 +00:00
Nick Payne 85975d6cc3 optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers
- Replace Swift Array<UInt8> ring buffer with UnsafeMutableRawPointer to
  eliminate COW ref-count checks on every write/read
- Add append(from:count:) to copy directly from Core Audio buffer pointer
  into the ring buffer, removing the per-callback Data heap allocation
- Pre-allocate AVAudioPCMBuffer pair in AudioFormatConverter and reuse
  across transform() calls (lazy init, capacity-checked)
- Fix float-to-int truncation in output frame count calculation (ceil)
- Add comprehensive AudioBuffer test suite (12 tests) including proper
  wrap-around coverage for both append and read paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 21:32:42 +00:00
Nick Payne 1cd2e83060 Merge pull request #12 from makeusabrew/lib-improvements
library improvements
2026-03-01 11:09:09 +00:00
Nick Payne 4600e34bfb Merge pull request #11 from makeusabrew/lib-split-cli
Split source into library and CLI targets
2026-02-26 13:50:24 +00:00
Nick Payne 08f0bc8f6c library improvements 2026-02-25 20:26:39 +00:00
16 changed files with 589 additions and 280 deletions
+11 -15
View File
@@ -9,7 +9,6 @@ struct AudioTee {
var stereo: Bool = false var stereo: Bool = false
var sampleRate: Double? var sampleRate: Double?
var chunkDuration: Double = 0.2 var chunkDuration: Double = 0.2
var flush: Bool = false
init() {} init() {}
@@ -33,7 +32,6 @@ struct AudioTee {
audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --include-processes 1234 5678 9012 # Tap only these processes
audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --exclude-processes 1234 5678 # Tap everything except these
audiotee --mute # Mute processes being tapped audiotee --mute # Mute processes being tapped
audiotee --flush # Flush stdout after each chunk
""" """
) )
@@ -45,7 +43,6 @@ struct AudioTee {
name: "exclude-processes", help: "Process IDs to exclude (space-separated)") name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped") parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addFlag(name: "stereo", help: "Records in stereo") parser.addFlag(name: "stereo", help: "Records in stereo")
parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption( parser.addOption(
name: "sample-rate", name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
@@ -63,7 +60,6 @@ struct AudioTee {
audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self)
audioTee.mute = parser.getFlag("mute") audioTee.mute = parser.getFlag("mute")
audioTee.stereo = parser.getFlag("stereo") audioTee.stereo = parser.getFlag("stereo")
audioTee.flush = parser.getFlag("flush")
audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self)
audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self) audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self)
@@ -99,11 +95,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 +119,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 +127,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()
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 +151,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,34 @@
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 fd = STDOUT_FILENO
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int) {
var written = 0
while written < count {
let result = write(fd, pointer.advanced(by: written), count - written)
if result >= 0 {
written += result
} else if errno == EINTR {
continue
} else {
break // EPIPE, EIO, etc consumer gone or real error
}
}
}
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)
}
}
+72 -56
View File
@@ -1,105 +1,121 @@
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
///
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
/// copy-on-write reference-count checks on every mutation. This buffer
/// lives on the real-time audio IO thread and is never shared, so COW
/// semantics are pure overhead.
public class AudioBuffer { public class AudioBuffer {
private var buffer: [UInt8] /// Raw heap-allocated ring buffer backing store.
private let buffer: UnsafeMutableRawPointer
/// Pre-allocated buffer for linearizing chunks that straddle the ring
/// buffer boundary. Avoids a heap allocation on the wrap-around path.
private let linearizationBuffer: UnsafeMutableRawPointer
private var writeIndex: Int = 0 private var writeIndex: Int = 0
private var readIndex: Int = 0 private var readIndex: Int = 0
private var availableBytes: Int = 0 private var availableBytes: Int = 0
private let maxBufferSize: Int private let maxBufferSize: Int
private let bytesPerChunk: Int public let bytesPerChunk: Int
private let chunkDuration: Double
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
// Pre-calculate chunk parameters // Pre-calculate chunk parameters
let bytesPerFrame = Int(format.mBytesPerFrame) let bytesPerFrame = Int(format.mBytesPerFrame)
let samplesPerChunk = Int(format.mSampleRate * chunkDuration) let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
self.bytesPerChunk = samplesPerChunk * bytesPerFrame 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 // Calculate max buffer size to hold ~10 seconds of audio (safety limit)
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
self.maxBufferSize = bytesPerSecond * 10 self.maxBufferSize = bytesPerSecond * 10
// Pre-allocated ring buffer // Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
self.buffer = Array(repeating: 0, count: maxBufferSize) // to eliminate Swift Array's COW ref-count check on every write/read.
self.buffer = UnsafeMutableRawPointer.allocate(
byteCount: maxBufferSize,
alignment: MemoryLayout<UInt8>.alignment
)
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
byteCount: bytesPerChunk,
alignment: MemoryLayout<UInt8>.alignment
)
} }
public func append(_ data: Data) { deinit {
guard availableBytes + data.count <= maxBufferSize else { buffer.deallocate()
Logger.error( linearizationBuffer.deallocate()
}
/// Appends audio data directly from a raw pointer into the ring buffer.
/// This is the fast path used by the IO proc callback: one memcpy from
/// the Core Audio buffer into our ring buffer, with no intermediate
/// Data allocation.
public func append(from source: UnsafeRawPointer, count: Int) {
guard count >= 0 else {
AudioTeeLogging.logger.error(
"Audio buffer append called with negative count",
context: ["count": String(count)])
return
}
guard availableBytes + count <= maxBufferSize else {
AudioTeeLogging.logger.error(
"Audio buffer overflow", "Audio buffer overflow",
context: [ context: [
"requested": String(data.count), "requested": String(count),
"available": String(maxBufferSize - availableBytes), "available": String(maxBufferSize - availableBytes),
]) ])
return return
} }
data.withUnsafeBytes { bytes in if writeIndex + count <= maxBufferSize {
let sourceBytes = bytes.bindMemory(to: UInt8.self) // Single contiguous write no wrap-around needed
let dataSize = sourceBytes.count buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
writeIndex = (writeIndex + count) % maxBufferSize
// 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 { } else {
// two writes needed due to wrap-around // Two writes needed due to wrap-around at the end of the ring buffer
let firstChunkSize = maxBufferSize - writeIndex let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize let secondChunkSize = count - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize)) buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize)) buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
writeIndex = secondChunkSize writeIndex = secondChunkSize
} }
availableBytes += count
} }
availableBytes += data.count /// Calls `handler` once for each complete chunk available in the buffer.
} /// The pointer passed to the handler is valid only for the duration of
/// that call. In the common (contiguous) case this points directly into
public func processChunks() -> [AudioPacket] { /// the ring buffer zero copies. In the wrap-around case the chunk is
var packets: [AudioPacket] = [] /// linearized into a pre-allocated scratch buffer one memcpy, zero
/// heap allocations.
while let packet = nextChunk() { public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) {
packets.append(packet) while availableBytes >= bytesPerChunk {
}
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 { if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed // Contiguous: point directly into the ring buffer
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk]) handler(buffer.advanced(by: readIndex), bytesPerChunk)
readIndex = (readIndex + bytesPerChunk) % maxBufferSize readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else { } else {
// two copies needed due to wrap-around // Wrap-around: linearize into the pre-allocated scratch buffer
let firstChunkSize = maxBufferSize - readIndex let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize]) linearizationBuffer.copyMemory(
chunkData.append(contentsOf: buffer[0..<secondChunkSize]) from: buffer.advanced(by: readIndex), byteCount: firstChunkSize)
linearizationBuffer.advanced(by: firstChunkSize).copyMemory(
from: buffer, byteCount: secondChunkSize)
handler(linearizationBuffer, bytesPerChunk)
readIndex = secondChunkSize readIndex = secondChunkSize
} }
availableBytes -= bytesPerChunk availableBytes -= bytesPerChunk
}
return AudioPacket(
timestamp: Date(),
duration: chunkDuration,
data: chunkData
)
} }
} }
@@ -2,12 +2,22 @@ import AVFoundation
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Simple audio format converter using AVFoundation /// Audio format converter using AVFoundation's AVAudioConverter.
///
/// Pre-allocates input/output buffers on first use and reuses them across
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
/// per chunk significant when chunks are small (50ms = 20 calls/sec).
public class AudioFormatConverter { public class AudioFormatConverter {
private let avConverter: AVAudioConverter private let avConverter: AVAudioConverter
private let sourceFormat: AVAudioFormat private let sourceFormat: AVAudioFormat
private let targetFormat: AVAudioFormat private let targetFormat: AVAudioFormat
/// Pre-allocated buffers reused across transform() calls. Lazily created
/// on first transform() since we need the actual input frame count to
/// size them correctly.
private var cachedInputBuffer: AVAudioPCMBuffer?
private var cachedOutputBuffer: AVAudioPCMBuffer?
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription) public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
throws throws
{ {
@@ -28,7 +38,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 +49,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,58 +58,108 @@ 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
} }
public func transform(_ packet: AudioPacket) -> AudioPacket { /// Returns pre-allocated input and output buffers sized for the given
let inputData = packet.data /// input frame count. Allocates once on first call; reuses on subsequent
/// calls when capacity is sufficient. Re-allocates if a larger frame
/// count arrives (shouldn't happen with fixed chunk sizes, but handled
/// gracefully).
private func getBuffers(inputFrameCount: AVAudioFrameCount)
-> (input: AVAudioPCMBuffer, output: AVAudioPCMBuffer)?
{
// ceil() prevents float-to-int truncation from undersizing the buffer
// by one frame (e.g. 3199.9999 3199 instead of 3200).
let outputFrameCount = AVAudioFrameCount(
ceil(Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
)
// Calculate frame counts // Reuse cached buffers if they have sufficient capacity
let inputFrameCount = if let inputBuf = cachedInputBuffer,
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) let outputBuf = cachedOutputBuffer,
let outputFrameCount = Int( inputBuf.frameCapacity >= inputFrameCount,
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate)) outputBuf.frameCapacity >= outputFrameCount
{
// Create input buffer // Reset frame lengths for reuse the underlying memory is retained,
guard // we just tell AVAudioPCMBuffer how many frames are valid this time.
let inputBuffer = AVAudioPCMBuffer( inputBuf.frameLength = 0
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount)) outputBuf.frameLength = 0
else { return (inputBuf, outputBuf)
Logger.error("Failed to create input buffer")
return packet
} }
// Copy input data to buffer // Allocate new buffers (first call, or unexpected capacity increase)
inputData.withUnsafeBytes { bytes in guard
let inputBuf = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: inputFrameCount)
else {
AudioTeeLogging.logger.error("Failed to create input buffer")
return nil
}
guard
let outputBuf = AVAudioPCMBuffer(
pcmFormat: targetFormat, frameCapacity: outputFrameCount)
else {
AudioTeeLogging.logger.error("Failed to create output buffer")
return nil
}
// Cache for reuse on subsequent calls
cachedInputBuffer = inputBuf
cachedOutputBuffer = outputBuf
AudioTeeLogging.logger.debug(
"Allocated converter buffers",
context: [
"input_frame_capacity": String(inputFrameCount),
"output_frame_capacity": String(outputFrameCount),
])
return (inputBuf, outputBuf)
}
/// Converts audio data in-place through the pre-allocated converter buffers.
/// Calls `handler` with a pointer to the converted output, valid only for
/// the duration of that call. Returns false on failure (caller should
/// pass through the original data or drop it).
@discardableResult
public func transform(
from source: UnsafeRawPointer, count: Int,
handler: (UnsafeRawPointer, Int) -> Void
) -> Bool {
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame)
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
return false
}
// Copy source data into the reusable input buffer
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData! let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count) dest.copyMemory(from: source, byteCount: count)
} inputBuffer.frameLength = inputFrameCount
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount)
// Create output buffer // Perform conversion we do NOT call avConverter.reset() between
guard // calls because the resampler maintains internal state for continuity
let outputBuffer = AVAudioPCMBuffer( // across chunks (avoiding discontinuity artifacts).
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount))
else {
Logger.error("Failed to create output buffer")
return packet
}
// Perform conversion - simpler approach
var error: NSError? var error: NSError?
let status = avConverter.convert(to: outputBuffer, error: &error) { let status = avConverter.convert(to: outputBuffer, error: &error) {
requestedPackets, outStatus in requestedPackets, outStatus in
// Always provide our input buffer and let converter manage it
outStatus.pointee = .haveData outStatus.pointee = .haveData
return inputBuffer return inputBuffer
} }
// 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),
@@ -107,20 +167,13 @@ public class AudioFormatConverter {
"input_frames": String(inputBuffer.frameLength), "input_frames": String(inputBuffer.frameLength),
"output_capacity": String(outputBuffer.frameCapacity), "output_capacity": String(outputBuffer.frameCapacity),
]) ])
return packet return false
} }
// Extract converted data let outputCount = Int(
let outputData = Data( outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!, handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount)
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)) return true
// 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( public static func toSampleRate(
@@ -3,25 +3,26 @@ 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 +31,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 +50,12 @@ 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 +71,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 +94,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),
@@ -1,17 +0,0 @@
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
}
}
+43 -23
View File
@@ -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,8 @@ 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 +47,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 +62,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 +71,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 +93,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)
} }
} }
@@ -98,14 +109,16 @@ public class AudioRecorder {
let bufferList = inputData.pointee let bufferList = inputData.pointee
let firstBuffer = bufferList.mBuffers let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else { guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
"Warning: Received empty audio buffer".print(to: .standardError) AudioTeeLogging.logger.error("Received empty audio buffer")
return noErr return noErr
} }
// Append raw audio data to buffer // Copy directly from the Core Audio buffer into our ring buffer.
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) // This avoids creating an intermediate Data object (heap alloc + memcpy)
audioBuffer?.append(audioData) // on every IO callback (~10ms). The pointer is valid for the duration
// of this callback, so this is safe.
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
processAudioBuffer() processAudioBuffer()
@@ -119,10 +132,17 @@ public class AudioRecorder {
} }
private func processAudioBuffer() { private func processAudioBuffer() {
// Process and send complete chunks, applying conversion if needed audioBuffer?.processChunks { pointer, count in
audioBuffer?.processChunks().forEach { packet in if let converter = self.converter {
let processedPacket = converter?.transform(packet) ?? packet if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in
outputHandler.handleAudioPacket(processedPacket) self.outputHandler.handleAudioData(outPtr, count: outCount)
}) {
// Conversion failed pass through unconverted audio
self.outputHandler.handleAudioData(pointer, count: count)
}
} else {
self.outputHandler.handleAudioData(pointer, count: count)
}
} }
} }
+13 -11
View File
@@ -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,15 @@ 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 +89,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 +116,8 @@ 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 +144,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
@@ -2,7 +2,9 @@ import Foundation
/// Protocol for handling audio output in different formats /// Protocol for handling audio output in different formats
public protocol AudioOutputHandler { public protocol AudioOutputHandler {
func handleAudioPacket(_ packet: AudioPacket) /// Called with a pointer to raw PCM audio data. The pointer is only
/// valid for the duration of this call.
func handleAudioData(_ pointer: UnsafeRawPointer, count: Int)
func handleMetadata(_ metadata: AudioStreamMetadata) func handleMetadata(_ metadata: AudioStreamMetadata)
func handleStreamStart() func handleStreamStart()
func handleStreamStop() func handleStreamStop()
@@ -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()
}
+20 -12
View File
@@ -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)
} }
+3 -11
View File
@@ -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)
}
}
}
@@ -0,0 +1,219 @@
import CoreAudio
import XCTest
@testable import AudioTeeCore
// CoreAudio defines its own AudioBuffer struct, which collides with ours.
// Explicit module qualification avoids ambiguity in tests that import both.
private typealias AudioBuffer = AudioTeeCore.AudioBuffer
final class AudioBufferTests: XCTestCase {
// MARK: - Helpers
/// Creates a minimal AudioStreamBasicDescription for testing.
/// 16kHz, 16-bit, mono = 2 bytes per frame, 32000 bytes/sec.
private func makeFormat(
sampleRate: Double = 16000,
bytesPerFrame: UInt32 = 2,
bitsPerChannel: UInt32 = 16
) -> AudioStreamBasicDescription {
return AudioStreamBasicDescription(
mSampleRate: sampleRate,
mFormatID: kAudioFormatLinearPCM,
mFormatFlags: kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger,
mBytesPerPacket: bytesPerFrame,
mFramesPerPacket: 1,
mBytesPerFrame: bytesPerFrame,
mChannelsPerFrame: 1,
mBitsPerChannel: bitsPerChannel,
mReserved: 0
)
}
/// Creates a repeating byte pattern of the given length.
private func makeData(byte: UInt8, count: Int) -> Data {
return Data(repeating: byte, count: count)
}
/// Appends Data to an AudioBuffer via the raw pointer path,
/// matching how processAudio() calls append(from:count:).
private func appendData(_ data: Data, to buffer: AudioBuffer) {
data.withUnsafeBytes { bytes in
buffer.append(from: bytes.baseAddress!, count: bytes.count)
}
}
/// Collects chunks from the buffer as Data objects for test verification.
private func collectChunks(from buffer: AudioBuffer) -> [Data] {
var chunks: [Data] = []
buffer.processChunks { pointer, count in
chunks.append(Data(bytes: pointer, count: count))
}
return chunks
}
// MARK: - Basic append + processChunks
func testSingleChunkExtraction() {
// 16kHz, 2 bytes/frame, 0.1s chunk = 3200 bytes per chunk
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200 // 16000 * 0.1 * 2
let data = makeData(byte: 0xAB, count: chunkSize)
appendData(data, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[0], data)
}
func testMultipleChunksExtracted() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append 2.5 chunks worth
appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer)
let chunks = collectChunks(from: buffer)
// Should get 2 complete chunks, remainder stays in buffer
XCTAssertEqual(chunks.count, 2)
XCTAssertEqual(chunks[0].count, chunkSize)
XCTAssertEqual(chunks[1].count, chunkSize)
}
func testInsufficientDataReturnsNoChunks() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Append less than one chunk
appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 0)
}
// MARK: - Wrap-around
func testWrapAroundWrite() {
// 8kHz, 2 bytes/frame, 0.3s chunks chunkSize = 4800, maxBuffer = 160000.
// 160000 / 4800 = 33.33 chunks do NOT divide evenly into the buffer,
// so after enough writes the writeIndex will straddle the boundary.
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800 // 8000 * 0.3 * 2
// Write 33 chunks (158400 bytes), drain them all.
// writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
let drained = collectChunks(from: buffer)
XCTAssertEqual(drained.count, 33)
// Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000.
// This MUST take the wrap-around else branch in append():
// firstChunkSize = 160000 - 158400 = 1600
// secondChunkSize = 4800 - 1600 = 3200
// Verify by using distinct byte patterns for the portion before and after the boundary.
var wrappingData = Data()
wrappingData.append(makeData(byte: 0xAA, count: 1600)) // fills to boundary
wrappingData.append(makeData(byte: 0xBB, count: 3200)) // wraps to start
XCTAssertEqual(wrappingData.count, chunkSize)
appendData(wrappingData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], wrappingData)
}
func testWrapAroundRead() {
// Same setup as above: position readIndex so that a chunk extraction
// straddles the ring buffer boundary, exercising the else branch in nextChunk().
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.3)
let chunkSize = 4800
// Write and drain 33 chunks. Both indices land at 158400.
for _ in 0..<33 {
appendData(makeData(byte: 0x00, count: chunkSize), to: buffer)
}
_ = collectChunks(from: buffer)
// Write one chunk starting at 158400. The write itself wraps (tested above),
// but crucially the READ will also wrap: readIndex = 158400,
// 158400 + 4800 = 163200 > 160000 else branch in nextChunk():
// firstChunkSize = 160000 - 158400 = 1600 (read from end of buffer)
// secondChunkSize = 4800 - 1600 = 3200 (read from start of buffer)
var crossBoundaryData = Data()
crossBoundaryData.append(makeData(byte: 0xCC, count: 1600))
crossBoundaryData.append(makeData(byte: 0xDD, count: 3200))
appendData(crossBoundaryData, to: buffer)
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0], crossBoundaryData)
}
// MARK: - Overflow guard
func testOverflowPreventsWrite() {
let format = makeFormat(sampleRate: 8000)
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let maxBuffer = 160000
// Fill the buffer completely
appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer)
// Try to append more should be silently rejected (overflow guard)
appendData(makeData(byte: 0x02, count: 100), to: buffer)
// Drain and verify we only got the original data
let chunks = collectChunks(from: buffer)
let totalBytes = chunks.reduce(0) { $0 + $1.count }
XCTAssertEqual(totalBytes, maxBuffer)
// Every byte should be 0x01, not 0x02
for chunk in chunks {
XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 })
}
}
// MARK: - Incremental appends accumulate correctly
func testIncrementalAppendsThenChunk() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
let chunkSize = 3200
// Simulate many small IO callbacks building up to one chunk
let callbackSize = 320 // 10 callbacks to fill one chunk
for i in 0..<10 {
appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer)
}
let chunks = collectChunks(from: buffer)
XCTAssertEqual(chunks.count, 1)
XCTAssertEqual(chunks[0].count, chunkSize)
// Verify the data is in the correct order
for i in 0..<10 {
let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize))
XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) })
}
}
// MARK: - Chunk size
func testBytesPerChunkIsCorrect() {
let format = makeFormat()
let buffer = AudioBuffer(format: format, chunkDuration: 0.1)
// 16kHz * 0.1s * 2 bytes/frame = 3200
XCTAssertEqual(buffer.bytesPerChunk, 3200)
}
}
@@ -1,30 +0,0 @@
import XCTest
@testable import AudioTeeCore
final class AudioPacketTests: XCTestCase {
func testPacketCreation() {
let timestamp = Date()
let duration = 1.0
let data = Data([0x01, 0x02, 0x03, 0x04])
let packet = AudioPacket(
timestamp: timestamp,
duration: duration,
data: data
)
XCTAssertEqual(packet.timestamp, timestamp)
XCTAssertEqual(packet.duration, duration)
XCTAssertEqual(packet.data, data)
}
func testPacketDataSize() {
let packet = AudioPacket(
timestamp: Date(),
duration: 0.5,
data: Data(repeating: 0xFF, count: 1024)
)
XCTAssertEqual(packet.data.count, 1024)
}
}