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 <noreply@anthropic.com>
This commit is contained in:
Nick Payne
2026-03-07 07:14:16 +00:00
parent 65c2d58c82
commit a1eb465142
9 changed files with 122 additions and 174 deletions
+36 -47
View File
@@ -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<UInt8>.alignment
)
buffer.initializeMemory(as: UInt8.self, repeating: 0, count: maxBufferSize)
self.linearizationBuffer = UnsafeMutableRawPointer.allocate(
byteCount: bytesPerChunk,
alignment: MemoryLayout<UInt8>.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
)
}
}
@@ -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(
@@ -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
}
}
+11 -4
View File
@@ -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)
}
}
}
@@ -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()