From 85975d6cc33a72961da905fa969d031531eef7c0 Mon Sep 17 00:00:00 2001 From: Nick Payne Date: Fri, 6 Mar 2026 21:32:42 +0000 Subject: [PATCH 1/3] optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers - Replace Swift Array 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 --- Sources/AudioTeeCLI/AudioTee.swift | 3 +- Sources/AudioTeeCore/Core/AudioBuffer.swift | 97 ++++--- .../Core/AudioFormatConverter.swift | 108 ++++++-- .../Core/AudioFormatManager.swift | 6 +- Sources/AudioTeeCore/Core/AudioRecorder.swift | 13 +- .../AudioTeeCore/Core/AudioTapManager.swift | 8 +- .../AudioTeeCoreTests/AudioBufferTests.swift | 250 ++++++++++++++++++ .../AudioTeeCoreTests/AudioPacketTests.swift | 11 +- 8 files changed, 423 insertions(+), 73 deletions(-) create mode 100644 Tests/AudioTeeCoreTests/AudioBufferTests.swift 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 +} From 65c2d58c82a7eb383d098858ee06367c9d54ab1d Mon Sep 17 00:00:00 2001 From: Nick Payne Date: Fri, 6 Mar 2026 21:38:14 +0000 Subject: [PATCH 2/3] 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 --- Sources/AudioTeeCore/Core/AudioBuffer.swift | 10 --- .../AudioTeeCoreTests/AudioBufferTests.swift | 84 +++++-------------- 2 files changed, 23 insertions(+), 71 deletions(-) diff --git a/Sources/AudioTeeCore/Core/AudioBuffer.swift b/Sources/AudioTeeCore/Core/AudioBuffer.swift index d9bd9b9..636cd9c 100644 --- a/Sources/AudioTeeCore/Core/AudioBuffer.swift +++ b/Sources/AudioTeeCore/Core/AudioBuffer.swift @@ -82,16 +82,6 @@ public class AudioBuffer { availableBytes += count } - /// Appends audio data from a Data value. Delegates to the raw pointer - /// path; prefer append(from:count:) when you already have a pointer to - /// avoid creating a Data object. - public func append(_ data: Data) { - data.withUnsafeBytes { bytes in - guard let baseAddress = bytes.baseAddress else { return } - append(from: baseAddress, count: bytes.count) - } - } - /// Extracts all complete chunks currently available in the buffer. public func processChunks() -> [AudioPacket] { var packets: [AudioPacket] = [] diff --git a/Tests/AudioTeeCoreTests/AudioBufferTests.swift b/Tests/AudioTeeCoreTests/AudioBufferTests.swift index 72880ce..d2153d1 100644 --- a/Tests/AudioTeeCoreTests/AudioBufferTests.swift +++ b/Tests/AudioTeeCoreTests/AudioBufferTests.swift @@ -3,6 +3,10 @@ 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 @@ -32,6 +36,14 @@ final class AudioBufferTests: XCTestCase { 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) + } + } + // MARK: - Basic append + processChunks func testSingleChunkExtraction() { @@ -40,9 +52,8 @@ final class AudioBufferTests: XCTestCase { 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) + appendData(data, to: buffer) let packets = buffer.processChunks() XCTAssertEqual(packets.count, 1) @@ -56,7 +67,7 @@ final class AudioBufferTests: XCTestCase { let chunkSize = 3200 // Append 2.5 chunks worth - buffer.append(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2)) + appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer) let packets = buffer.processChunks() // Should get 2 complete chunks, remainder stays in buffer @@ -71,30 +82,12 @@ final class AudioBufferTests: XCTestCase { let chunkSize = 3200 // Append less than one chunk - buffer.append(makeData(byte: 0xFF, count: chunkSize - 1)) + appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer) 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() { @@ -104,12 +97,11 @@ final class AudioBufferTests: XCTestCase { 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)) + appendData(makeData(byte: 0x00, count: chunkSize), to: buffer) } let drained = buffer.processChunks() XCTAssertEqual(drained.count, 33) @@ -123,7 +115,7 @@ final class AudioBufferTests: XCTestCase { 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) + appendData(wrappingData, to: buffer) let packets = buffer.processChunks() XCTAssertEqual(packets.count, 1) @@ -139,7 +131,7 @@ final class AudioBufferTests: XCTestCase { // Write and drain 33 chunks. Both indices land at 158400. for _ in 0..<33 { - buffer.append(makeData(byte: 0x00, count: chunkSize)) + appendData(makeData(byte: 0x00, count: chunkSize), to: buffer) } _ = buffer.processChunks() @@ -151,43 +143,13 @@ final class AudioBufferTests: XCTestCase { var crossBoundaryData = Data() crossBoundaryData.append(makeData(byte: 0xCC, count: 1600)) crossBoundaryData.append(makeData(byte: 0xDD, count: 3200)) - buffer.append(crossBoundaryData) + appendData(crossBoundaryData, to: buffer) 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() { @@ -196,10 +158,10 @@ final class AudioBufferTests: XCTestCase { let maxBuffer = 160000 // Fill the buffer completely - buffer.append(makeData(byte: 0x01, count: maxBuffer)) + appendData(makeData(byte: 0x01, count: maxBuffer), to: buffer) // Try to append more — should be silently rejected (overflow guard) - buffer.append(makeData(byte: 0x02, count: 100)) + appendData(makeData(byte: 0x02, count: 100), to: buffer) // Drain and verify we only got the original data let packets = buffer.processChunks() @@ -222,7 +184,7 @@ final class AudioBufferTests: XCTestCase { // 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)) + appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer) } let packets = buffer.processChunks() @@ -242,7 +204,7 @@ final class AudioBufferTests: XCTestCase { let format = makeFormat() let buffer = AudioBuffer(format: format, chunkDuration: 0.1) - buffer.append(makeData(byte: 0x00, count: 3200)) + appendData(makeData(byte: 0x00, count: 3200), to: buffer) let packets = buffer.processChunks() XCTAssertEqual(packets[0].duration, 0.1, accuracy: 0.001) From a1eb4651421f8a565899ed68dbbd2051422166fc Mon Sep 17 00:00:00 2001 From: Nick Payne Date: Sat, 7 Mar 2026 07:14:16 +0000 Subject: [PATCH 3/3] 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 --- Sources/AudioTeeCLI/AudioTee.swift | 7 +- Sources/AudioTeeCLI/BinaryOutputHandler.swift | 22 ++--- Sources/AudioTeeCore/Core/AudioBuffer.swift | 83 ++++++++----------- .../Core/AudioFormatConverter.swift | 46 +++++----- Sources/AudioTeeCore/Core/AudioPacket.swift | 17 ---- Sources/AudioTeeCore/Core/AudioRecorder.swift | 15 +++- .../Output/AudioOutputProtocol.swift | 4 +- .../AudioTeeCoreTests/AudioBufferTests.swift | 71 +++++++++------- .../AudioTeeCoreTests/AudioPacketTests.swift | 31 ------- 9 files changed, 122 insertions(+), 174 deletions(-) delete mode 100644 Sources/AudioTeeCore/Core/AudioPacket.swift delete mode 100644 Tests/AudioTeeCoreTests/AudioPacketTests.swift diff --git a/Sources/AudioTeeCLI/AudioTee.swift b/Sources/AudioTeeCLI/AudioTee.swift index cb9d1a9..e7f8973 100644 --- a/Sources/AudioTeeCLI/AudioTee.swift +++ b/Sources/AudioTeeCLI/AudioTee.swift @@ -9,7 +9,6 @@ struct AudioTee { var stereo: Bool = false var sampleRate: Double? var chunkDuration: Double = 0.2 - var flush: Bool = false init() {} @@ -33,7 +32,6 @@ struct AudioTee { audiotee --include-processes 1234 5678 9012 # Tap only these processes audiotee --exclude-processes 1234 5678 # Tap everything except these audiotee --mute # Mute processes being tapped - audiotee --flush # Flush stdout after each chunk """ ) @@ -45,8 +43,6 @@ 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.addOption( name: "sample-rate", help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") @@ -64,7 +60,6 @@ struct AudioTee { audioTee.excludeProcesses = try parser.getArrayValue("exclude-processes", as: Int32.self) audioTee.mute = parser.getFlag("mute") audioTee.stereo = parser.getFlag("stereo") - audioTee.flush = parser.getFlag("flush") audioTee.sampleRate = try parser.getOptionalValue("sample-rate", as: Double.self) audioTee.chunkDuration = try parser.getValue("chunk-duration", as: Double.self) @@ -142,7 +137,7 @@ struct AudioTee { throw ExitCode.failure } - let outputHandler = BinaryAudioOutputHandler(flushAfterWrite: flush) + let outputHandler = BinaryAudioOutputHandler() let recorder = try AudioRecorder( deviceID: deviceID, outputHandler: outputHandler, convertToSampleRate: sampleRate, chunkDuration: chunkDuration) diff --git a/Sources/AudioTeeCLI/BinaryOutputHandler.swift b/Sources/AudioTeeCLI/BinaryOutputHandler.swift index 9f8a5b6..732b837 100644 --- a/Sources/AudioTeeCLI/BinaryOutputHandler.swift +++ b/Sources/AudioTeeCLI/BinaryOutputHandler.swift @@ -4,17 +4,19 @@ 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 + private let fd = STDOUT_FILENO - 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 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 + } } } diff --git a/Sources/AudioTeeCore/Core/AudioBuffer.swift b/Sources/AudioTeeCore/Core/AudioBuffer.swift index 636cd9c..7b5c781 100644 --- a/Sources/AudioTeeCore/Core/AudioBuffer.swift +++ b/Sources/AudioTeeCore/Core/AudioBuffer.swift @@ -10,20 +10,21 @@ import Foundation public class AudioBuffer { /// 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 readIndex: Int = 0 private var availableBytes: Int = 0 private let maxBufferSize: Int - private let bytesPerChunk: Int - private let chunkDuration: Double + public let bytesPerChunk: Int 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 (safety limit) let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame @@ -36,10 +37,16 @@ public class AudioBuffer { alignment: MemoryLayout.alignment ) buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize) + + self.linearizationBuffer = UnsafeMutableRawPointer.allocate( + byteCount: bytesPerChunk, + alignment: MemoryLayout.alignment + ) } deinit { buffer.deallocate() + linearizationBuffer.deallocate() } /// Appends audio data directly from a raw pointer into the ring buffer. @@ -82,51 +89,33 @@ public class AudioBuffer { availableBytes += count } - /// Extracts all complete chunks currently available in the buffer. - public func processChunks() -> [AudioPacket] { - var packets: [AudioPacket] = [] + /// 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 + /// the ring buffer — zero copies. In the wrap-around case the chunk is + /// linearized into a pre-allocated scratch buffer — one memcpy, zero + /// heap allocations. + public func processChunks(_ handler: (UnsafeRawPointer, Int) -> Void) { + while availableBytes >= bytesPerChunk { + if readIndex + bytesPerChunk <= maxBufferSize { + // Contiguous: point directly into the ring buffer + handler(buffer.advanced(by: readIndex), bytesPerChunk) + readIndex = (readIndex + bytesPerChunk) % maxBufferSize + } else { + // Wrap-around: linearize into the pre-allocated scratch buffer + let firstChunkSize = maxBufferSize - readIndex + let secondChunkSize = bytesPerChunk - firstChunkSize - while let packet = nextChunk() { - packets.append(packet) + linearizationBuffer.copyMemory( + from: buffer.advanced(by: readIndex), byteCount: firstChunkSize) + linearizationBuffer.advanced(by: firstChunkSize).copyMemory( + from: buffer, byteCount: secondChunkSize) + + handler(linearizationBuffer, bytesPerChunk) + readIndex = secondChunkSize + } + + availableBytes -= bytesPerChunk } - - return packets - } - - private func nextChunk() -> AudioPacket? { - // Check if we have enough data for a complete chunk - guard availableBytes >= bytesPerChunk else { return nil } - - let chunkData: Data - - // Check if we can copy in one block (no wrap-around) - if readIndex + bytesPerChunk <= maxBufferSize { - // one copy needed - chunkData = Data(bytes: buffer.advanced(by: readIndex), count: bytesPerChunk) - readIndex = (readIndex + bytesPerChunk) % maxBufferSize - } else { - // two copies needed due to wrap-around - let firstChunkSize = maxBufferSize - readIndex - let secondChunkSize = bytesPerChunk - firstChunkSize - - var assembled = Data(capacity: bytesPerChunk) - assembled.append( - buffer.advanced(by: readIndex).assumingMemoryBound(to: UInt8.self), - count: firstChunkSize) - assembled.append( - buffer.assumingMemoryBound(to: UInt8.self), - count: secondChunkSize) - chunkData = assembled - - readIndex = secondChunkSize - } - - availableBytes -= bytesPerChunk - - return AudioPacket( - timestamp: Date(), - duration: chunkDuration, - data: chunkData - ) } } diff --git a/Sources/AudioTeeCore/Core/AudioFormatConverter.swift b/Sources/AudioTeeCore/Core/AudioFormatConverter.swift index 7bcc95f..5a52284 100644 --- a/Sources/AudioTeeCore/Core/AudioFormatConverter.swift +++ b/Sources/AudioTeeCore/Core/AudioFormatConverter.swift @@ -126,27 +126,28 @@ public class AudioFormatConverter { return (inputBuf, outputBuf) } - public func transform(_ packet: AudioPacket) -> AudioPacket { - let inputData = packet.data - - // Calculate frame count from the input data size + /// 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(inputData.count / bytesPerFrame) + let inputFrameCount = AVAudioFrameCount(count / bytesPerFrame) - // Get or create pre-allocated buffers guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else { - return packet + return false } - // 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) - } + // Copy source data into the reusable input buffer + let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData! + dest.copyMemory(from: source, byteCount: count) inputBuffer.frameLength = inputFrameCount - // Perform conversion — the block-based API lets AVAudioConverter pull - // input data as needed. We do NOT call avConverter.reset() between + // Perform conversion — we do NOT call avConverter.reset() between // calls because the resampler maintains internal state for continuity // across chunks (avoiding discontinuity artifacts). var error: NSError? @@ -157,7 +158,6 @@ public class AudioFormatConverter { return inputBuffer } - // Check if conversion produced output (regardless of status code) guard outputBuffer.frameLength > 0 else { AudioTeeLogging.logger.error( "Audio conversion produced no output", @@ -167,19 +167,13 @@ public class AudioFormatConverter { "input_frames": String(inputBuffer.frameLength), "output_capacity": String(outputBuffer.frameCapacity), ]) - return packet + return false } - // 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 AudioPacket( - timestamp: packet.timestamp, - duration: packet.duration, - data: outputData - ) + let outputCount = Int( + outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame) + handler(outputBuffer.audioBufferList.pointee.mBuffers.mData!, outputCount) + return true } public static func toSampleRate( diff --git a/Sources/AudioTeeCore/Core/AudioPacket.swift b/Sources/AudioTeeCore/Core/AudioPacket.swift deleted file mode 100644 index 8db7d13..0000000 --- a/Sources/AudioTeeCore/Core/AudioPacket.swift +++ /dev/null @@ -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 - } -} diff --git a/Sources/AudioTeeCore/Core/AudioRecorder.swift b/Sources/AudioTeeCore/Core/AudioRecorder.swift index d0564c6..6d8dce2 100644 --- a/Sources/AudioTeeCore/Core/AudioRecorder.swift +++ b/Sources/AudioTeeCore/Core/AudioRecorder.swift @@ -132,10 +132,17 @@ public class AudioRecorder { } private func processAudioBuffer() { - // Process and send complete chunks, applying conversion if needed - audioBuffer?.processChunks().forEach { packet in - let processedPacket = converter?.transform(packet) ?? packet - outputHandler.handleAudioPacket(processedPacket) + audioBuffer?.processChunks { pointer, count in + if let converter = self.converter { + if !converter.transform(from: pointer, count: count, handler: { outPtr, outCount in + 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) + } } } diff --git a/Sources/AudioTeeCore/Output/AudioOutputProtocol.swift b/Sources/AudioTeeCore/Output/AudioOutputProtocol.swift index 6175a8b..5a100f5 100644 --- a/Sources/AudioTeeCore/Output/AudioOutputProtocol.swift +++ b/Sources/AudioTeeCore/Output/AudioOutputProtocol.swift @@ -2,7 +2,9 @@ import Foundation /// Protocol for handling audio output in different formats 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 handleStreamStart() func handleStreamStop() diff --git a/Tests/AudioTeeCoreTests/AudioBufferTests.swift b/Tests/AudioTeeCoreTests/AudioBufferTests.swift index d2153d1..2268e12 100644 --- a/Tests/AudioTeeCoreTests/AudioBufferTests.swift +++ b/Tests/AudioTeeCoreTests/AudioBufferTests.swift @@ -44,6 +44,15 @@ final class AudioBufferTests: XCTestCase { } } + /// 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() { @@ -55,10 +64,10 @@ final class AudioBufferTests: XCTestCase { let data = makeData(byte: 0xAB, count: chunkSize) appendData(data, to: buffer) - let packets = buffer.processChunks() - XCTAssertEqual(packets.count, 1) - XCTAssertEqual(packets[0].data.count, chunkSize) - XCTAssertEqual(packets[0].data, data) + let chunks = collectChunks(from: buffer) + XCTAssertEqual(chunks.count, 1) + XCTAssertEqual(chunks[0].count, chunkSize) + XCTAssertEqual(chunks[0], data) } func testMultipleChunksExtracted() { @@ -69,11 +78,11 @@ final class AudioBufferTests: XCTestCase { // Append 2.5 chunks worth appendData(makeData(byte: 0x01, count: chunkSize * 2 + chunkSize / 2), to: buffer) - let packets = buffer.processChunks() + let chunks = collectChunks(from: buffer) // 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) + XCTAssertEqual(chunks.count, 2) + XCTAssertEqual(chunks[0].count, chunkSize) + XCTAssertEqual(chunks[1].count, chunkSize) } func testInsufficientDataReturnsNoChunks() { @@ -84,8 +93,8 @@ final class AudioBufferTests: XCTestCase { // Append less than one chunk appendData(makeData(byte: 0xFF, count: chunkSize - 1), to: buffer) - let packets = buffer.processChunks() - XCTAssertEqual(packets.count, 0) + let chunks = collectChunks(from: buffer) + XCTAssertEqual(chunks.count, 0) } // MARK: - Wrap-around @@ -103,7 +112,7 @@ final class AudioBufferTests: XCTestCase { for _ in 0..<33 { appendData(makeData(byte: 0x00, count: chunkSize), to: buffer) } - let drained = buffer.processChunks() + let drained = collectChunks(from: buffer) XCTAssertEqual(drained.count, 33) // Next write of 4800 bytes starts at 158400. 158400 + 4800 = 163200 > 160000. @@ -117,9 +126,9 @@ final class AudioBufferTests: XCTestCase { XCTAssertEqual(wrappingData.count, chunkSize) appendData(wrappingData, to: buffer) - let packets = buffer.processChunks() - XCTAssertEqual(packets.count, 1) - XCTAssertEqual(packets[0].data, wrappingData) + let chunks = collectChunks(from: buffer) + XCTAssertEqual(chunks.count, 1) + XCTAssertEqual(chunks[0], wrappingData) } func testWrapAroundRead() { @@ -133,7 +142,7 @@ final class AudioBufferTests: XCTestCase { for _ in 0..<33 { appendData(makeData(byte: 0x00, count: chunkSize), to: buffer) } - _ = buffer.processChunks() + _ = collectChunks(from: buffer) // Write one chunk starting at 158400. The write itself wraps (tested above), // but crucially the READ will also wrap: readIndex = 158400, @@ -145,9 +154,9 @@ final class AudioBufferTests: XCTestCase { crossBoundaryData.append(makeData(byte: 0xDD, count: 3200)) appendData(crossBoundaryData, to: buffer) - let packets = buffer.processChunks() - XCTAssertEqual(packets.count, 1) - XCTAssertEqual(packets[0].data, crossBoundaryData) + let chunks = collectChunks(from: buffer) + XCTAssertEqual(chunks.count, 1) + XCTAssertEqual(chunks[0], crossBoundaryData) } // MARK: - Overflow guard @@ -164,13 +173,13 @@ final class AudioBufferTests: XCTestCase { appendData(makeData(byte: 0x02, count: 100), to: buffer) // Drain and verify we only got the original data - let packets = buffer.processChunks() - let totalBytes = packets.reduce(0) { $0 + $1.data.count } + let chunks = collectChunks(from: buffer) + let totalBytes = chunks.reduce(0) { $0 + $1.count } XCTAssertEqual(totalBytes, maxBuffer) // Every byte should be 0x01, not 0x02 - for packet in packets { - XCTAssertTrue(packet.data.allSatisfy { $0 == 0x01 }) + for chunk in chunks { + XCTAssertTrue(chunk.allSatisfy { $0 == 0x01 }) } } @@ -187,26 +196,24 @@ final class AudioBufferTests: XCTestCase { appendData(makeData(byte: UInt8(i), count: callbackSize), to: buffer) } - let packets = buffer.processChunks() - XCTAssertEqual(packets.count, 1) - XCTAssertEqual(packets[0].data.count, chunkSize) + 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 = packets[0].data.subdata(in: (i * callbackSize)..<((i + 1) * callbackSize)) + let slice = chunks[0].subdata(in: (i * callbackSize)..<((i + 1) * callbackSize)) XCTAssertTrue(slice.allSatisfy { $0 == UInt8(i) }) } } - // MARK: - Packet metadata + // MARK: - Chunk size - func testChunkDurationIsCorrect() { + func testBytesPerChunkIsCorrect() { let format = makeFormat() let buffer = AudioBuffer(format: format, chunkDuration: 0.1) - appendData(makeData(byte: 0x00, count: 3200), to: buffer) - let packets = buffer.processChunks() - - XCTAssertEqual(packets[0].duration, 0.1, accuracy: 0.001) + // 16kHz * 0.1s * 2 bytes/frame = 3200 + XCTAssertEqual(buffer.bytesPerChunk, 3200) } } diff --git a/Tests/AudioTeeCoreTests/AudioPacketTests.swift b/Tests/AudioTeeCoreTests/AudioPacketTests.swift deleted file mode 100644 index e93b388..0000000 --- a/Tests/AudioTeeCoreTests/AudioPacketTests.swift +++ /dev/null @@ -1,31 +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) - } -}