From 1b537eb3957721c07b59b1907c3fbd2470291fb0 Mon Sep 17 00:00:00 2001 From: Nick Payne Date: Thu, 10 Jul 2025 13:43:44 +0100 Subject: [PATCH 1/3] use ring buffer to avoid memory leak --- Sources/CLI/AudioTee.swift | 2 +- Sources/Core/AudioBuffer.swift | 79 ++++++++++++++++++++++++------- Sources/Core/AudioRecorder.swift | 1 + Sources/Core/AudioTeeErrors.swift | 1 + 4 files changed, 64 insertions(+), 19 deletions(-) diff --git a/Sources/CLI/AudioTee.swift b/Sources/CLI/AudioTee.swift index a4fc6f2..51da1d7 100644 --- a/Sources/CLI/AudioTee.swift +++ b/Sources/CLI/AudioTee.swift @@ -200,7 +200,7 @@ struct AudioTee { // Helper for stderr output var standardError = FileHandle.standardError -extension FileHandle: @retroactive TextOutputStream { +extension FileHandle: TextOutputStream { public func write(_ string: String) { let data = Data(string.utf8) self.write(data) diff --git a/Sources/Core/AudioBuffer.swift b/Sources/Core/AudioBuffer.swift index a73c366..5c09f23 100644 --- a/Sources/Core/AudioBuffer.swift +++ b/Sources/Core/AudioBuffer.swift @@ -2,17 +2,48 @@ import CoreAudio import Foundation public class AudioBuffer { - private var buffer = Data() - private let targetChunkDuration: Double - private let streamFormat: AudioStreamBasicDescription + private var buffer: [UInt8] + private var writeIndex: Int = 0 + private var readIndex: Int = 0 + private var availableBytes: Int = 0 + private let maxBufferSize: Int + + // Pre-calculated values for efficiency + private let bytesPerChunk: Int + private let chunkDuration: Double public init(format: AudioStreamBasicDescription, chunkDuration: Double = 0.2) { - self.streamFormat = format - self.targetChunkDuration = chunkDuration + + // 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 + let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame + self.maxBufferSize = bytesPerSecond * 10 + + // Pre-allocate ring buffer + self.buffer = Array(repeating: 0, count: maxBufferSize) } public func append(_ data: Data) { - buffer.append(data) + guard availableBytes + data.count <= maxBufferSize else { + Logger.error("Audio buffer overflow", context: [ + "requested": String(data.count), + "available": String(maxBufferSize - availableBytes) + ]) + return + } + + // Simple, clean, fast enough + for byte in data { + buffer[writeIndex] = byte + writeIndex = (writeIndex + 1) % maxBufferSize + } + + availableBytes += data.count } public func processChunks() -> [AudioPacket] { @@ -26,36 +57,48 @@ public class AudioBuffer { } public func flushRemaining() -> AudioPacket? { - guard !buffer.isEmpty else { return nil } + guard availableBytes > 0 else { return nil } + + // Create Data from remaining bytes + var remainingData = Data(capacity: availableBytes) + for _ in 0.. AudioPacket? { - let bytesPerFrame = Int(streamFormat.mBytesPerFrame) - let samplesPerChunk = Int(streamFormat.mSampleRate * targetChunkDuration) - let bytesPerChunk = samplesPerChunk * bytesPerFrame + // Check if we have enough data for a complete chunk + guard availableBytes >= bytesPerChunk else { return nil } - guard buffer.count >= bytesPerChunk else { return nil } - - let chunkData = buffer.prefix(bytesPerChunk) + // Extract chunk data - bounds-checked but still efficient + var chunkData = Data(capacity: bytesPerChunk) + + for _ in 0.. Date: Thu, 10 Jul 2025 20:43:17 +0100 Subject: [PATCH 2/3] get rid of O(n) ops on hot audio packet path --- Sources/Core/AudioBuffer.swift | 68 ++++++++++++++++---------------- Sources/Core/AudioRecorder.swift | 6 +-- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/Sources/Core/AudioBuffer.swift b/Sources/Core/AudioBuffer.swift index 5c09f23..4d4bfc7 100644 --- a/Sources/Core/AudioBuffer.swift +++ b/Sources/Core/AudioBuffer.swift @@ -8,7 +8,6 @@ public class AudioBuffer { private var availableBytes: Int = 0 private let maxBufferSize: Int - // Pre-calculated values for efficiency private let bytesPerChunk: Int private let chunkDuration: Double @@ -24,7 +23,7 @@ public class AudioBuffer { let bytesPerSecond = Int(format.mSampleRate) * bytesPerFrame self.maxBufferSize = bytesPerSecond * 10 - // Pre-allocate ring buffer + // Pre-allocated ring buffer self.buffer = Array(repeating: 0, count: maxBufferSize) } @@ -37,10 +36,25 @@ public class AudioBuffer { return } - // Simple, clean, fast enough - for byte in data { - buffer[writeIndex] = byte - writeIndex = (writeIndex + 1) % maxBufferSize + 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? { - guard availableBytes > 0 else { return nil } - - // Create Data from remaining bytes - var remainingData = Data(capacity: availableBytes) - for _ in 0.. AudioPacket? { // Check if we have enough data for a complete chunk guard availableBytes >= bytesPerChunk else { return nil } - // Extract chunk data - bounds-checked but still efficient var chunkData = Data(capacity: bytesPerChunk) - for _ in 0.. Date: Sat, 12 Jul 2025 13:45:25 +0100 Subject: [PATCH 3/3] no need for buffer overflow error --- Sources/Core/AudioTeeErrors.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Sources/Core/AudioTeeErrors.swift b/Sources/Core/AudioTeeErrors.swift index b960d2d..0accec5 100644 --- a/Sources/Core/AudioTeeErrors.swift +++ b/Sources/Core/AudioTeeErrors.swift @@ -8,7 +8,6 @@ public enum AudioTeeError: Error { case aggregateDeviceCreationFailed(OSStatus) case tapAssignmentFailed(OSStatus) case pidTranslationFailed([Int32]) - case bufferOverflow(requested: Int, available: Int) } // MARK: - Audio Format Conversion Errors