85975d6cc3
- 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>
206 lines
7.6 KiB
Swift
206 lines
7.6 KiB
Swift
import AVFoundation
|
|
import CoreAudio
|
|
import Foundation
|
|
|
|
/// 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
|
|
{
|
|
var mutableSourceFormat = sourceFormat
|
|
var mutableTargetFormat = targetFormat
|
|
|
|
guard let sourceAVFormat = AVAudioFormat(streamDescription: &mutableSourceFormat),
|
|
let targetAVFormat = AVAudioFormat(streamDescription: &mutableTargetFormat)
|
|
else {
|
|
throw AudioConverterError.invalidFormat
|
|
}
|
|
|
|
guard let converter = AVAudioConverter(from: sourceAVFormat, to: targetAVFormat) else {
|
|
throw AudioConverterError.creationFailed
|
|
}
|
|
|
|
self.sourceFormat = sourceAVFormat
|
|
self.targetFormat = targetAVFormat
|
|
self.avConverter = converter
|
|
|
|
AudioTeeLogging.logger.debug(
|
|
"Audio converter created",
|
|
context: [
|
|
"source_sample_rate": String(sourceAVFormat.sampleRate),
|
|
"target_sample_rate": String(targetAVFormat.sampleRate),
|
|
"source_channels": String(sourceAVFormat.channelCount),
|
|
"target_channels": String(targetAVFormat.channelCount),
|
|
])
|
|
|
|
// Warn about upsampling once during initialization
|
|
if targetAVFormat.sampleRate > sourceAVFormat.sampleRate {
|
|
AudioTeeLogging.logger.info(
|
|
"Upsampling audio - this doesn't add frequency content above the original Nyquist limit",
|
|
context: [
|
|
"source_rate": String(sourceAVFormat.sampleRate),
|
|
"target_rate": String(targetAVFormat.sampleRate),
|
|
])
|
|
}
|
|
}
|
|
|
|
/// The source format this converter reads from.
|
|
public var sourceFormatDescription: AudioStreamBasicDescription {
|
|
return sourceFormat.streamDescription.pointee
|
|
}
|
|
|
|
/// The target format this converter produces.
|
|
public var targetFormatDescription: AudioStreamBasicDescription {
|
|
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 count from the input data size
|
|
let bytesPerFrame = Int(sourceFormat.streamDescription.pointee.mBytesPerFrame)
|
|
let inputFrameCount = AVAudioFrameCount(inputData.count / bytesPerFrame)
|
|
|
|
// Get or create pre-allocated buffers
|
|
guard let (inputBuffer, outputBuffer) = getBuffers(inputFrameCount: inputFrameCount) else {
|
|
return packet
|
|
}
|
|
|
|
// 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 = inputFrameCount
|
|
|
|
// 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
|
|
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",
|
|
context: [
|
|
"status": String(describing: status),
|
|
"error": String(describing: error),
|
|
"input_frames": String(inputBuffer.frameLength),
|
|
"output_capacity": String(outputBuffer.frameCapacity),
|
|
])
|
|
return packet
|
|
}
|
|
|
|
// 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
|
|
)
|
|
}
|
|
|
|
public static func toSampleRate(
|
|
_ sampleRate: Double, from sourceFormat: AudioStreamBasicDescription
|
|
) throws -> AudioFormatConverter {
|
|
var targetFormat = AudioStreamBasicDescription()
|
|
targetFormat.mSampleRate = sampleRate
|
|
targetFormat.mFormatID = kAudioFormatLinearPCM
|
|
targetFormat.mFormatFlags = kAudioFormatFlagIsPacked | kAudioFormatFlagIsSignedInteger
|
|
targetFormat.mFramesPerPacket = 1
|
|
targetFormat.mBitsPerChannel = 16
|
|
targetFormat.mChannelsPerFrame = sourceFormat.mChannelsPerFrame
|
|
targetFormat.mBytesPerFrame =
|
|
(targetFormat.mBitsPerChannel / 8) * sourceFormat.mChannelsPerFrame
|
|
targetFormat.mBytesPerPacket = targetFormat.mFramesPerPacket * targetFormat.mBytesPerFrame
|
|
|
|
return try AudioFormatConverter(sourceFormat: sourceFormat, targetFormat: targetFormat)
|
|
}
|
|
|
|
public static func isValidSampleRate(_ sampleRate: Double) -> Bool {
|
|
return [8000, 16000, 22050, 24000, 32000, 44100, 48000].contains(sampleRate)
|
|
}
|
|
}
|