diff --git a/Sources/AudioTeeCLI/AudioTee.swift b/Sources/AudioTeeCLI/AudioTee.swift index 4835d5a..cb9d1a9 100644 --- a/Sources/AudioTeeCLI/AudioTee.swift +++ b/Sources/AudioTeeCLI/AudioTee.swift @@ -45,7 +45,8 @@ struct AudioTee { name: "exclude-processes", help: "Process IDs to exclude (space-separated)") parser.addFlag(name: "mute", help: "Mute processes being tapped") parser.addFlag(name: "stereo", help: "Records in stereo") - parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)") + parser.addFlag( + name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)") parser.addOption( name: "sample-rate", help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") diff --git a/Sources/AudioTeeCore/Core/AudioBuffer.swift b/Sources/AudioTeeCore/Core/AudioBuffer.swift index 10ef775..d9bd9b9 100644 --- a/Sources/AudioTeeCore/Core/AudioBuffer.swift +++ b/Sources/AudioTeeCore/Core/AudioBuffer.swift @@ -1,8 +1,15 @@ import CoreAudio 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 { - private var buffer: [UInt8] + /// Raw heap-allocated ring buffer backing store. + private let buffer: UnsafeMutableRawPointer private var writeIndex: Int = 0 private var readIndex: Int = 0 private var availableBytes: Int = 0 @@ -12,56 +19,80 @@ public class AudioBuffer { private let chunkDuration: Double public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { - // Pre-calculate chunk parameters let bytesPerFrame = Int(format.mBytesPerFrame) let samplesPerChunk = Int(format.mSampleRate * chunkDuration) self.bytesPerChunk = samplesPerChunk * bytesPerFrame self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate - // Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow + // Calculate max buffer size to hold ~10 seconds of audio (safety limit) let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame self.maxBufferSize = bytesPerSecond * 10 - // Pre-allocated ring buffer - self.buffer = Array(repeating: 0, count: maxBufferSize) + // Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8] + // to eliminate Swift Array's COW ref-count check on every write/read. + self.buffer = UnsafeMutableRawPointer.allocate( + byteCount: maxBufferSize, + alignment: MemoryLayout.alignment + ) + buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize) } - public func append(_ data: Data) { - guard availableBytes + data.count <= maxBufferSize else { + deinit { + buffer.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", context: [ - "requested": String(data.count), + "requested": String(count), "available": String(maxBufferSize - availableBytes), ]) return } - data.withUnsafeBytes { bytes in - let sourceBytes = bytes.bindMemory(to: UInt8.self) - let dataSize = sourceBytes.count + if writeIndex + count <= maxBufferSize { + // Single contiguous write — no wrap-around needed + buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count) + writeIndex = (writeIndex + count) % maxBufferSize + } else { + // Two writes needed due to wrap-around at the end of the ring buffer + let firstChunkSize = maxBufferSize - writeIndex + let secondChunkSize = count - firstChunkSize - // Check if we can copy in one block (no wrap-around) - if writeIndex + dataSize <= maxBufferSize { - // only one write needed - buffer.replaceSubrange(writeIndex.. [AudioPacket] { var packets: [AudioPacket] = [] @@ -76,20 +107,26 @@ public class AudioBuffer { // Check if we have enough data for a complete chunk guard availableBytes >= bytesPerChunk else { return nil } - var chunkData = Data(capacity: bytesPerChunk) + let chunkData: Data // Check if we can copy in one block (no wrap-around) if readIndex + bytesPerChunk <= maxBufferSize { // one copy needed - chunkData.append(contentsOf: buffer[readIndex.. (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)) + ) + + // Reuse cached buffers if they have sufficient capacity + if let inputBuf = cachedInputBuffer, + let outputBuf = cachedOutputBuffer, + inputBuf.frameCapacity >= inputFrameCount, + outputBuf.frameCapacity >= outputFrameCount + { + // Reset frame lengths for reuse — the underlying memory is retained, + // we just tell AVAudioPCMBuffer how many frames are valid this time. + inputBuf.frameLength = 0 + outputBuf.frameLength = 0 + return (inputBuf, outputBuf) + } + + // Allocate new buffers (first call, or unexpected capacity increase) + 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) + } + public func transform(_ packet: AudioPacket) -> AudioPacket { let inputData = packet.data - // Calculate frame counts - let inputFrameCount = - inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) - let outputFrameCount = Int( - Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate)) + // Calculate frame count from the input data size + let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) + let inputFrameCount = AVAudioFrameCount(inputData.count / bytesPerFrame) - // Create input buffer - guard - let inputBuffer = AVAudioPCMBuffer( - pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount)) - else { - AudioTeeLogging.logger.error("Failed to create input buffer") + // Get or create pre-allocated buffers + guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else { return packet } - // Copy input data to buffer + // Copy input data into the reusable input buffer inputData.withUnsafeBytes { bytes in let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData! dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count) } - inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount) + inputBuffer.frameLength = inputFrameCount - // Create output buffer - guard - let outputBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount)) - else { - AudioTeeLogging.logger.error("Failed to create output buffer") - return packet - } - - // Perform conversion - simpler approach + // Perform conversion — the block-based API lets AVAudioConverter pull + // input data as needed. We do NOT call avConverter.reset() between + // calls because the resampler maintains internal state for continuity + // across chunks (avoiding discontinuity artifacts). var error: NSError? let status = avConverter.convert(to: outputBuffer, error: &error) { requestedPackets, outStatus in - // Always provide our input buffer and let converter manage it outStatus.pointee = .haveData return inputBuffer } @@ -115,12 +170,11 @@ public class AudioFormatConverter { return packet } - // Extract converted data + // Extract converted data from the reusable output buffer let outputData = Data( bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!, count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)) - // Return new packet with converted audio (keeping original metadata for simplicity) return AudioPacket( timestamp: packet.timestamp, duration: packet.duration, diff --git a/Sources/AudioTeeCore/Core/AudioFormatManager.swift b/Sources/AudioTeeCore/Core/AudioFormatManager.swift index c59a19f..d31a878 100644 --- a/Sources/AudioTeeCore/Core/AudioFormatManager.swift +++ b/Sources/AudioTeeCore/Core/AudioFormatManager.swift @@ -3,7 +3,8 @@ import CoreAudio import Foundation public class AudioFormatManager { - public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription { + public static func getDeviceFormat(deviceID: AudioObjectID) throws -> AudioStreamBasicDescription + { // First, wait for the device to become alive/ready let deviceReadyTimeout = 2.0 // 2 seconds max wait let pollInterval = 0.1 // 100ms poll interval @@ -49,7 +50,8 @@ public class AudioFormatManager { deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat) if status == noErr { - AudioTeeLogging.logger.debug("Successfully retrieved device format", context: ["attempt": String(attempt)]) + AudioTeeLogging.logger.debug( + "Successfully retrieved device format", context: ["attempt": String(attempt)]) return streamFormat } diff --git a/Sources/AudioTeeCore/Core/AudioRecorder.swift b/Sources/AudioTeeCore/Core/AudioRecorder.swift index ade0e6e..d0564c6 100644 --- a/Sources/AudioTeeCore/Core/AudioRecorder.swift +++ b/Sources/AudioTeeCore/Core/AudioRecorder.swift @@ -36,7 +36,8 @@ public class AudioRecorder { if let targetSampleRate = convertToSampleRate { // Validate sample rate guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else { - AudioTeeLogging.logger.error("Invalid sample rate", context: ["sample_rate": String(targetSampleRate)]) + AudioTeeLogging.logger.error( + "Invalid sample rate", context: ["sample_rate": String(targetSampleRate)]) self.converter = nil self.finalFormat = sourceFormat return @@ -108,14 +109,16 @@ public class AudioRecorder { let bufferList = inputData.pointee let firstBuffer = bufferList.mBuffers - guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else { + guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else { AudioTeeLogging.logger.error("Received empty audio buffer") return noErr } - // Append raw audio data to buffer - let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) - audioBuffer?.append(audioData) + // Copy directly from the Core Audio buffer into our ring buffer. + // This avoids creating an intermediate Data object (heap alloc + memcpy) + // 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() diff --git a/Sources/AudioTeeCore/Core/AudioTapManager.swift b/Sources/AudioTeeCore/Core/AudioTapManager.swift index ef4c9fc..e8fb079 100644 --- a/Sources/AudioTeeCore/Core/AudioTapManager.swift +++ b/Sources/AudioTeeCore/Core/AudioTapManager.swift @@ -8,7 +8,7 @@ public class AudioTapManager { private var deviceID: AudioObjectID? public init() {} - + deinit { AudioTeeLogging.logger.debug("Cleaning up audio tap manager") @@ -76,7 +76,8 @@ public class AudioTapManager { AudioTeeLogging.logger.debug( "AudioHardwareCreateProcessTap completed", context: ["status": String(status)]) guard status == kAudioHardwareNoError else { - AudioTeeLogging.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) } @@ -115,7 +116,8 @@ public class AudioTapManager { let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID) guard status == kAudioHardwareNoError else { - AudioTeeLogging.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) } diff --git a/Tests/AudioTeeCoreTests/AudioBufferTests.swift b/Tests/AudioTeeCoreTests/AudioBufferTests.swift new file mode 100644 index 0000000..72880ce --- /dev/null +++ b/Tests/AudioTeeCoreTests/AudioBufferTests.swift @@ -0,0 +1,250 @@ +import CoreAudio +import XCTest + +@testable import AudioTeeCore + +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) + } + + // 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 + + // Append exactly one chunk worth of data via Data path + let data = makeData(byte: 0xAB, count: chunkSize) + buffer.append(data) + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data.count, chunkSize) + XCTAssertEqual(packets[0].data, data) + } + + func testMultipleChunksExtracted() { + let format = makeFormat() + let buffer = AudioBuffer(format: format, chunkDuration: 0.1) + let chunkSize = 3200 + + // Append 2.5 chunks worth + buffer.append(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2)) + + let packets = buffer.processChunks() + // Should get 2 complete chunks, remainder stays in buffer + XCTAssertEqual(packets.count, 2) + XCTAssertEqual(packets[0].data.count, chunkSize) + XCTAssertEqual(packets[1].data.count, chunkSize) + } + + func testInsufficientDataReturnsNoChunks() { + let format = makeFormat() + let buffer = AudioBuffer(format: format, chunkDuration: 0.1) + let chunkSize = 3200 + + // Append less than one chunk + buffer.append(makeData(byte: 0xFF, count: chunkSize - 1)) + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 0) + } + + // MARK: - Zero-copy append(from:count:) + + func testZeroCopyAppend() { + let format = makeFormat() + let buffer = AudioBuffer(format: format, chunkDuration: 0.1) + let chunkSize = 3200 + + // Simulate what processAudio does: pass a raw pointer directly + let source = makeData(byte: 0xCD, count: chunkSize) + source.withUnsafeBytes { bytes in + buffer.append(from: bytes.baseAddress!, count: bytes.count) + } + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data, source) + } + + // 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 + let maxBuffer = 160000 // 8000 * 2 * 10 + + // Write 33 chunks (158400 bytes), drain them all. + // writeIndex = 158400, readIndex = 158400. 1600 bytes remain before boundary. + for _ in 0..<33 { + buffer.append(makeData(byte: 0x00, count: chunkSize)) + } + let drained = buffer.processChunks() + 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) + buffer.append(wrappingData) + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data, 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 { + buffer.append(makeData(byte: 0x00, count: chunkSize)) + } + _ = buffer.processChunks() + + // 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)) + buffer.append(crossBoundaryData) + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data, crossBoundaryData) + } + + func testZeroCopyAppendWrapAround() { + // Verify that the raw-pointer append path also wraps correctly, + // since it has its own copy logic separate from the Data-based path. + let format = makeFormat(sampleRate: 8000) + let buffer = AudioBuffer(format: format, chunkDuration: 0.3) + let chunkSize = 4800 + + // Position writeIndex at 158400 via write + drain + for _ in 0..<33 { + let data = makeData(byte: 0x00, count: chunkSize) + data.withUnsafeBytes { bytes in + buffer.append(from: bytes.baseAddress!, count: bytes.count) + } + } + _ = buffer.processChunks() + + // Write a wrapping chunk via the raw-pointer path + var wrappingData = Data() + wrappingData.append(makeData(byte: 0xEE, count: 1600)) + wrappingData.append(makeData(byte: 0xFF, count: 3200)) + + wrappingData.withUnsafeBytes { bytes in + buffer.append(from: bytes.baseAddress!, count: bytes.count) + } + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data, wrappingData) + } + + // 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 + buffer.append(makeData(byte: 0x01, count: maxBuffer)) + + // Try to append more — should be silently rejected (overflow guard) + buffer.append(makeData(byte: 0x02, count: 100)) + + // Drain and verify we only got the original data + let packets = buffer.processChunks() + let totalBytes = packets.reduce(0) { $0 + $1.data.count } + XCTAssertEqual(totalBytes, maxBuffer) + + // Every byte should be 0x01, not 0x02 + for packet in packets { + XCTAssertTrue(packet.data.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 { + buffer.append(makeData(byte: UInt8(i), count: callbackSize)) + } + + let packets = buffer.processChunks() + XCTAssertEqual(packets.count, 1) + XCTAssertEqual(packets[0].data.count, chunkSize) + + // Verify the data is in the correct order + for i in 0..<10 { + let slice = packets[0].data.subdata(in: (i * callbackSize)..<((i + 1) * callbackSize)) + XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) }) + } + } + + // MARK: - Packet metadata + + func testChunkDurationIsCorrect() { + let format = makeFormat() + let buffer = AudioBuffer(format: format, chunkDuration: 0.1) + + buffer.append(makeData(byte: 0x00, count: 3200)) + let packets = buffer.processChunks() + + XCTAssertEqual(packets[0].duration, 0.1, accuracy: 0.001) + } +} diff --git a/Tests/AudioTeeCoreTests/AudioPacketTests.swift b/Tests/AudioTeeCoreTests/AudioPacketTests.swift index 35898ac..e93b388 100644 --- a/Tests/AudioTeeCoreTests/AudioPacketTests.swift +++ b/Tests/AudioTeeCoreTests/AudioPacketTests.swift @@ -1,4 +1,5 @@ import XCTest + @testable import AudioTeeCore final class AudioPacketTests: XCTestCase { @@ -6,25 +7,25 @@ final class AudioPacketTests: XCTestCase { 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) } -} \ No newline at end of file +}