diff --git a/Sources/AudioTeeCLI/AudioTee.swift b/Sources/AudioTeeCLI/AudioTee.swift index 4835d5a..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,7 +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)") @@ -63,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) @@ -141,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 10ef775..7b5c781 100644 --- a/Sources/AudioTeeCore/Core/AudioBuffer.swift +++ b/Sources/AudioTeeCore/Core/AudioBuffer.swift @@ -1,105 +1,121 @@ 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 + /// 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, 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) + + self.linearizationBuffer = UnsafeMutableRawPointer.allocate( + byteCount: bytesPerChunk, + alignment: MemoryLayout.alignment + ) } - public func append(_ data: Data) { - guard availableBytes + data.count <= maxBufferSize else { + deinit { + buffer.deallocate() + 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", 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 - - // 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] = [] - - while let packet = nextChunk() { - packets.append(packet) - } - - 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 { - // one copy needed - chunkData.append(contentsOf: buffer[readIndex.. 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 + + 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 + } } } diff --git a/Sources/AudioTeeCore/Core/AudioFormatConverter.swift b/Sources/AudioTeeCore/Core/AudioFormatConverter.swift index f8f7bae..5a52284 100644 --- a/Sources/AudioTeeCore/Core/AudioFormatConverter.swift +++ b/Sources/AudioTeeCore/Core/AudioFormatConverter.swift @@ -2,12 +2,22 @@ import AVFoundation import CoreAudio 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 { private let avConverter: AVAudioConverter private let sourceFormat: 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) throws { @@ -58,51 +68,96 @@ public class AudioFormatConverter { return targetFormat.streamDescription.pointee } - public func transform(_ packet: AudioPacket) -> AudioPacket { - let inputData = packet.data + /// Returns pre-allocated input and output buffers sized for the given + /// 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 - let inputFrameCount = - inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) - let outputFrameCount = Int( - 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) + } - // Create input buffer + // Allocate new buffers (first call, or unexpected capacity increase) guard - let inputBuffer = AVAudioPCMBuffer( - pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount)) + let inputBuf = AVAudioPCMBuffer( + pcmFormat: sourceFormat, frameCapacity: inputFrameCount) else { AudioTeeLogging.logger.error("Failed to create input buffer") - return packet + return nil } - // Copy input data to buffer - inputData.withUnsafeBytes { bytes in - let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData! - dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count) - } - inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount) - - // Create output buffer guard - let outputBuffer = AVAudioPCMBuffer( - pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount)) + let outputBuf = AVAudioPCMBuffer( + pcmFormat: targetFormat, frameCapacity: outputFrameCount) else { AudioTeeLogging.logger.error("Failed to create output buffer") - return packet + return nil } - // Perform conversion - simpler approach + // 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! + dest.copyMemory(from: source, byteCount: count) + inputBuffer.frameLength = inputFrameCount + + // 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? 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 } - // Check if conversion produced output (regardless of status code) guard outputBuffer.frameLength > 0 else { AudioTeeLogging.logger.error( "Audio conversion produced no output", @@ -112,20 +167,13 @@ public class AudioFormatConverter { "input_frames": String(inputBuffer.frameLength), "output_capacity": String(outputBuffer.frameCapacity), ]) - return packet + return false } - // Extract converted data - 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, - 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/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/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 ade0e6e..6d8dce2 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() @@ -129,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/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/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 new file mode 100644 index 0000000..2268e12 --- /dev/null +++ b/Tests/AudioTeeCoreTests/AudioBufferTests.swift @@ -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) + } +} diff --git a/Tests/AudioTeeCoreTests/AudioPacketTests.swift b/Tests/AudioTeeCoreTests/AudioPacketTests.swift deleted file mode 100644 index 35898ac..0000000 --- a/Tests/AudioTeeCoreTests/AudioPacketTests.swift +++ /dev/null @@ -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) - } -} \ No newline at end of file