optimise hot-path audio pipeline: zero-copy ring buffer, pre-allocated converter buffers

- Replace Swift Array<UInt8> ring buffer with UnsafeMutableRawPointer to
  eliminate COW ref-count checks on every write/read
- Add append(from:count:) to copy directly from Core Audio buffer pointer
  into the ring buffer, removing the per-callback Data heap allocation
- Pre-allocate AVAudioPCMBuffer pair in AudioFormatConverter and reuse
  across transform() calls (lazy init, capacity-checked)
- Fix float-to-int truncation in output frame count calculation (ceil)
- Add comprehensive AudioBuffer test suite (12 tests) including proper
  wrap-around coverage for both append and read paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nick Payne
2026-03-06 21:32:42 +00:00
parent 1cd2e83060
commit 85975d6cc3
8 changed files with 423 additions and 73 deletions
+2 -1
View File
@@ -45,7 +45,8 @@ struct AudioTee {
name: "exclude-processes", help: "Process IDs to exclude (space-separated)") name: "exclude-processes", help: "Process IDs to exclude (space-separated)")
parser.addFlag(name: "mute", help: "Mute processes being tapped") parser.addFlag(name: "mute", help: "Mute processes being tapped")
parser.addFlag(name: "stereo", help: "Records in stereo") parser.addFlag(name: "stereo", help: "Records in stereo")
parser.addFlag(name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)") parser.addFlag(
name: "flush", help: "Flush stdout after each audio chunk (reduces latency when piping)")
parser.addOption( parser.addOption(
name: "sample-rate", name: "sample-rate",
help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)") help: "Target sample rate (8000, 16000, 22050, 24000, 32000, 44100, 48000)")
+67 -30
View File
@@ -1,8 +1,15 @@
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Ring buffer for accumulating raw audio data and extracting fixed-size chunks.
///
/// Uses a raw heap-allocated pointer rather than Swift Array to avoid
/// copy-on-write reference-count checks on every mutation. This buffer
/// lives on the real-time audio IO thread and is never shared, so COW
/// semantics are pure overhead.
public class AudioBuffer { public class AudioBuffer {
private var buffer: [UInt8] /// Raw heap-allocated ring buffer backing store.
private let buffer: UnsafeMutableRawPointer
private var writeIndex: Int = 0 private var writeIndex: Int = 0
private var readIndex: Int = 0 private var readIndex: Int = 0
private var availableBytes: Int = 0 private var availableBytes: Int = 0
@@ -12,56 +19,80 @@ public class AudioBuffer {
private let chunkDuration: Double private let chunkDuration: Double
public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) {
// Pre-calculate chunk parameters // Pre-calculate chunk parameters
let bytesPerFrame = Int(format.mBytesPerFrame) let bytesPerFrame = Int(format.mBytesPerFrame)
let samplesPerChunk = Int(format.mSampleRate * chunkDuration) let samplesPerChunk = Int(format.mSampleRate * chunkDuration)
self.bytesPerChunk = samplesPerChunk * bytesPerFrame self.bytesPerChunk = samplesPerChunk * bytesPerFrame
self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate self.chunkDuration = Double(samplesPerChunk) / format.mSampleRate
// Calculate max buffer size to hold ~10 seconds of audio, way more than the maximum we allow // Calculate max buffer size to hold ~10 seconds of audio (safety limit)
let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame
self.maxBufferSize = bytesPerSecond * 10 self.maxBufferSize = bytesPerSecond * 10
// Pre-allocated ring buffer // Allocate raw memory. We use UnsafeMutableRawPointer instead of [UInt8]
self.buffer = Array(repeating: 0, count: maxBufferSize) // to eliminate Swift Array's COW ref-count check on every write/read.
self.buffer = UnsafeMutableRawPointer.allocate(
byteCount: maxBufferSize,
alignment: MemoryLayout<UInt8>.alignment
)
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
} }
public func append(_ data: Data) { deinit {
guard availableBytes + data.count <= maxBufferSize else { 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( AudioTeeLogging.logger.error(
"Audio buffer overflow", "Audio buffer overflow",
context: [ context: [
"requested": String(data.count), "requested": String(count),
"available": String(maxBufferSize - availableBytes), "available": String(maxBufferSize - availableBytes),
]) ])
return return
} }
data.withUnsafeBytes { bytes in if writeIndex + count <= maxBufferSize {
let sourceBytes = bytes.bindMemory(to: UInt8.self) // Single contiguous write no wrap-around needed
let dataSize = sourceBytes.count buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: count)
writeIndex = (writeIndex + count) % maxBufferSize
} 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) buffer.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
if writeIndex + dataSize <= maxBufferSize { buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
// only one write needed
buffer.replaceSubrange(writeIndex..<writeIndex + dataSize, with: sourceBytes)
writeIndex = (writeIndex + dataSize) % maxBufferSize
} else {
// two writes needed due to wrap-around
let firstChunkSize = maxBufferSize - writeIndex
let secondChunkSize = dataSize - firstChunkSize
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize)) writeIndex = secondChunkSize
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = secondChunkSize
}
} }
availableBytes += data.count 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] { public func processChunks() -> [AudioPacket] {
var packets: [AudioPacket] = [] var packets: [AudioPacket] = []
@@ -76,20 +107,26 @@ public class AudioBuffer {
// Check if we have enough data for a complete chunk // Check if we have enough data for a complete chunk
guard availableBytes >= bytesPerChunk else { return nil } 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) // Check if we can copy in one block (no wrap-around)
if readIndex + bytesPerChunk <= maxBufferSize { if readIndex + bytesPerChunk <= maxBufferSize {
// one copy needed // one copy needed
chunkData.append(contentsOf: buffer[readIndex..<readIndex + bytesPerChunk]) chunkData = Data(bytes: buffer.advanced(by: readIndex), count: bytesPerChunk)
readIndex = (readIndex + bytesPerChunk) % maxBufferSize readIndex = (readIndex + bytesPerChunk) % maxBufferSize
} else { } else {
// two copies needed due to wrap-around // two copies needed due to wrap-around
let firstChunkSize = maxBufferSize - readIndex let firstChunkSize = maxBufferSize - readIndex
let secondChunkSize = bytesPerChunk - firstChunkSize let secondChunkSize = bytesPerChunk - firstChunkSize
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize]) var assembled = Data(capacity: bytesPerChunk)
chunkData.append(contentsOf: buffer[0..<secondChunkSize]) 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 readIndex = secondChunkSize
} }
@@ -2,12 +2,22 @@ import AVFoundation
import CoreAudio import CoreAudio
import Foundation import Foundation
/// Simple audio format converter using AVFoundation /// Audio format converter using AVFoundation's AVAudioConverter.
///
/// Pre-allocates input/output buffers on first use and reuses them across
/// transform() calls. This eliminates two AVAudioPCMBuffer heap allocations
/// per chunk significant when chunks are small (50ms = 20 calls/sec).
public class AudioFormatConverter { public class AudioFormatConverter {
private let avConverter: AVAudioConverter private let avConverter: AVAudioConverter
private let sourceFormat: AVAudioFormat private let sourceFormat: AVAudioFormat
private let targetFormat: AVAudioFormat private let targetFormat: AVAudioFormat
/// Pre-allocated buffers reused across transform() calls. Lazily created
/// on first transform() since we need the actual input frame count to
/// size them correctly.
private var cachedInputBuffer: AVAudioPCMBuffer?
private var cachedOutputBuffer: AVAudioPCMBuffer?
public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription) public init(sourceFormat: AudioStreamBasicDescription, targetFormat: AudioStreamBasicDescription)
throws throws
{ {
@@ -58,46 +68,91 @@ public class AudioFormatConverter {
return targetFormat.streamDescription.pointee return targetFormat.streamDescription.pointee
} }
/// 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))
)
// 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 { public func transform(_ packet: AudioPacket) -> AudioPacket {
let inputData = packet.data let inputData = packet.data
// Calculate frame counts // Calculate frame count from the input data size
let inputFrameCount = let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
inputData.count / Int(sourceFormat.streamDescription.pointee.mBytesPerFrame) let inputFrameCount = AVAudioFrameCount(inputData.count / bytesPerFrame)
let outputFrameCount = Int(
Double(inputFrameCount) * (targetFormat.sampleRate / sourceFormat.sampleRate))
// Create input buffer // Get or create pre-allocated buffers
guard guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
let inputBuffer = AVAudioPCMBuffer(
pcmFormat: sourceFormat, frameCapacity: AVAudioFrameCount(inputFrameCount))
else {
AudioTeeLogging.logger.error("Failed to create input buffer")
return packet return packet
} }
// Copy input data to buffer // Copy input data into the reusable input buffer
inputData.withUnsafeBytes { bytes in inputData.withUnsafeBytes { bytes in
let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData! let dest = inputBuffer.audioBufferList.pointee.mBuffers.mData!
dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count) dest.copyMemory(from: bytes.baseAddress!, byteCount: inputData.count)
} }
inputBuffer.frameLength = AVAudioFrameCount(inputFrameCount) inputBuffer.frameLength = inputFrameCount
// Create output buffer // Perform conversion the block-based API lets AVAudioConverter pull
guard // input data as needed. We do NOT call avConverter.reset() between
let outputBuffer = AVAudioPCMBuffer( // calls because the resampler maintains internal state for continuity
pcmFormat: targetFormat, frameCapacity: AVAudioFrameCount(outputFrameCount)) // across chunks (avoiding discontinuity artifacts).
else {
AudioTeeLogging.logger.error("Failed to create output buffer")
return packet
}
// Perform conversion - simpler approach
var error: NSError? var error: NSError?
let status = avConverter.convert(to: outputBuffer, error: &error) { let status = avConverter.convert(to: outputBuffer, error: &error) {
requestedPackets, outStatus in requestedPackets, outStatus in
// Always provide our input buffer and let converter manage it
outStatus.pointee = .haveData outStatus.pointee = .haveData
return inputBuffer return inputBuffer
} }
@@ -115,12 +170,11 @@ public class AudioFormatConverter {
return packet return packet
} }
// Extract converted data // Extract converted data from the reusable output buffer
let outputData = Data( let outputData = Data(
bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!, bytes: outputBuffer.audioBufferList.pointee.mBuffers.mData!,
count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame)) count: Int(outputBuffer.frameLength * targetFormat.streamDescription.pointee.mBytesPerFrame))
// Return new packet with converted audio (keeping original metadata for simplicity)
return AudioPacket( return AudioPacket(
timestamp: packet.timestamp, timestamp: packet.timestamp,
duration: packet.duration, duration: packet.duration,
@@ -3,7 +3,8 @@ import CoreAudio
import Foundation import Foundation
public class AudioFormatManager { 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 // First, wait for the device to become alive/ready
let deviceReadyTimeout = 2.0 // 2 seconds max wait let deviceReadyTimeout = 2.0 // 2 seconds max wait
let pollInterval = 0.1 // 100ms poll interval let pollInterval = 0.1 // 100ms poll interval
@@ -49,7 +50,8 @@ public class AudioFormatManager {
deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat) deviceID, &propertyAddress, 0, nil, &propertySize, &streamFormat)
if status == noErr { 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 return streamFormat
} }
@@ -36,7 +36,8 @@ public class AudioRecorder {
if let targetSampleRate = convertToSampleRate { if let targetSampleRate = convertToSampleRate {
// Validate sample rate // Validate sample rate
guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else { guard AudioFormatConverter.isValidSampleRate(targetSampleRate) else {
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.converter = nil
self.finalFormat = sourceFormat self.finalFormat = sourceFormat
return return
@@ -108,14 +109,16 @@ public class AudioRecorder {
let bufferList = inputData.pointee let bufferList = inputData.pointee
let firstBuffer = bufferList.mBuffers let firstBuffer = bufferList.mBuffers
guard firstBuffer.mData != nil && firstBuffer.mDataByteSize > 0 else { guard let sourcePointer = firstBuffer.mData, firstBuffer.mDataByteSize > 0 else {
AudioTeeLogging.logger.error("Received empty audio buffer") AudioTeeLogging.logger.error("Received empty audio buffer")
return noErr return noErr
} }
// Append raw audio data to buffer // Copy directly from the Core Audio buffer into our ring buffer.
let audioData = Data(bytes: firstBuffer.mData!, count: Int(firstBuffer.mDataByteSize)) // This avoids creating an intermediate Data object (heap alloc + memcpy)
audioBuffer?.append(audioData) // on every IO callback (~10ms). The pointer is valid for the duration
// of this callback, so this is safe.
audioBuffer?.append(from: sourcePointer, count: Int(firstBuffer.mDataByteSize))
processAudioBuffer() processAudioBuffer()
@@ -8,7 +8,7 @@ public class AudioTapManager {
private var deviceID: AudioObjectID? private var deviceID: AudioObjectID?
public init() {} public init() {}
deinit { deinit {
AudioTeeLogging.logger.debug("Cleaning up audio tap manager") AudioTeeLogging.logger.debug("Cleaning up audio tap manager")
@@ -76,7 +76,8 @@ public class AudioTapManager {
AudioTeeLogging.logger.debug( AudioTeeLogging.logger.debug(
"AudioHardwareCreateProcessTap completed", context: ["status": String(status)]) "AudioHardwareCreateProcessTap completed", context: ["status": String(status)])
guard status == kAudioHardwareNoError else { guard status == kAudioHardwareNoError else {
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) throw AudioTeeError.tapCreationFailed(status)
} }
@@ -115,7 +116,8 @@ public class AudioTapManager {
let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID) let status = AudioHardwareCreateAggregateDevice(description as CFDictionary, &deviceID)
guard status == kAudioHardwareNoError else { guard status == kAudioHardwareNoError else {
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) throw AudioTeeError.aggregateDeviceCreationFailed(status)
} }
@@ -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)
}
}
@@ -1,4 +1,5 @@
import XCTest import XCTest
@testable import AudioTeeCore @testable import AudioTeeCore
final class AudioPacketTests: XCTestCase { final class AudioPacketTests: XCTestCase {
@@ -6,25 +7,25 @@ final class AudioPacketTests: XCTestCase {
let timestamp = Date() let timestamp = Date()
let duration = 1.0 let duration = 1.0
let data = Data([0x01, 0x02, 0x03, 0x04]) let data = Data([0x01, 0x02, 0x03, 0x04])
let packet = AudioPacket( let packet = AudioPacket(
timestamp: timestamp, timestamp: timestamp,
duration: duration, duration: duration,
data: data data: data
) )
XCTAssertEqual(packet.timestamp, timestamp) XCTAssertEqual(packet.timestamp, timestamp)
XCTAssertEqual(packet.duration, duration) XCTAssertEqual(packet.duration, duration)
XCTAssertEqual(packet.data, data) XCTAssertEqual(packet.data, data)
} }
func testPacketDataSize() { func testPacketDataSize() {
let packet = AudioPacket( let packet = AudioPacket(
timestamp: Date(), timestamp: Date(),
duration: 0.5, duration: 0.5,
data: Data(repeating: 0xFF, count: 1024) data: Data(repeating: 0xFF, count: 1024)
) )
XCTAssertEqual(packet.data.count, 1024) XCTAssertEqual(packet.data.count, 1024)
} }
} }