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
+67 -30
View File
@@ -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<UInt8>.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..<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.advanced(by: writeIndex).copyMemory(from: source, byteCount: firstChunkSize)
buffer.copyMemory(from: source.advanced(by: firstChunkSize), byteCount: secondChunkSize)
buffer.replaceSubrange(writeIndex..<maxBufferSize, with: sourceBytes.prefix(firstChunkSize))
buffer.replaceSubrange(0..<secondChunkSize, with: sourceBytes.suffix(secondChunkSize))
writeIndex = 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] {
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..<readIndex + bytesPerChunk])
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
chunkData.append(contentsOf: buffer[readIndex..<maxBufferSize])
chunkData.append(contentsOf: buffer[0..<secondChunkSize])
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
}
@@ -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,46 +68,91 @@ public class AudioFormatConverter {
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 {
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,
@@ -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
}
@@ -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()
@@ -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)
}