diff --git a/Sources/OpenFaderCore/AudioRenderLoop.swift b/Sources/OpenFaderCore/AudioRenderLoop.swift index 2234aa5..c915b82 100644 --- a/Sources/OpenFaderCore/AudioRenderLoop.swift +++ b/Sources/OpenFaderCore/AudioRenderLoop.swift @@ -299,8 +299,15 @@ public protocol RenderLoopManaging: Sendable { } final class RenderedSampleBuffer: @unchecked Sendable { + /// Minimum number of consumed samples before compaction is worthwhile. + /// Compacting shifts the remaining samples to the front of storage, so we + /// only pay that cost once enough dead space (and at least half the array, + /// see `compactStorageIfNeeded()`) has accumulated. + private static let compactionThreshold = 4096 + private let lock = NSLock() private var samples: [Float] = [] + private var readOffset = 0 private let maximumSampleCount: Int init(maximumSampleCount: Int = 48_000 * 8) { @@ -309,7 +316,7 @@ final class RenderedSampleBuffer: @unchecked Sendable { var sampleCount: Int { lock.withLock { - samples.count + samples.count - readOffset } } @@ -320,8 +327,14 @@ final class RenderedSampleBuffer: @unchecked Sendable { lock.withLock { samples.append(contentsOf: nextSamples) - if samples.count > maximumSampleCount { - samples.removeFirst(samples.count - maximumSampleCount) + let overflowCount = (samples.count - readOffset) - maximumSampleCount + if overflowCount > 0 { + readOffset += overflowCount + } + compactStorageIfNeeded() + if readOffset == samples.count { + samples.removeAll(keepingCapacity: true) + readOffset = 0 } } @@ -334,21 +347,40 @@ final class RenderedSampleBuffer: @unchecked Sendable { } return lock.withLock { - let readCount = min(requestedSampleCount, samples.count) + let readCount = min(requestedSampleCount, samples.count - readOffset) guard readCount > 0 else { return [] } - let nextSamples = Array(samples.prefix(readCount)) - samples.removeFirst(readCount) + let readEnd = readOffset + readCount + let nextSamples = Array(samples[readOffset.. 0, + readOffset >= Self.compactionThreshold, + readOffset * 2 >= samples.count else { + return } + + samples.removeFirst(readOffset) + readOffset = 0 } } diff --git a/Tests/OpenFaderCoreTests/RenderedSampleBufferTests.swift b/Tests/OpenFaderCoreTests/RenderedSampleBufferTests.swift new file mode 100644 index 0000000..697154a --- /dev/null +++ b/Tests/OpenFaderCoreTests/RenderedSampleBufferTests.swift @@ -0,0 +1,27 @@ +import Testing +@testable import OpenFaderCore + +@Test +func renderedSampleBufferDequeuesInOrder() { + let buffer = RenderedSampleBuffer(maximumSampleCount: 8) + + #expect(buffer.enqueue([1, 2, 3, 4]) == 4) + #expect(buffer.sampleCount == 4) + #expect(buffer.dequeue(sampleCount: 2) == [1, 2]) + #expect(buffer.sampleCount == 2) + #expect(buffer.dequeue(sampleCount: 4) == [3, 4]) + #expect(buffer.sampleCount == 0) +} + +@Test +func renderedSampleBufferDropsOldestSamplesAtCapacity() { + let buffer = RenderedSampleBuffer(maximumSampleCount: 6) + + #expect(buffer.enqueue([1, 2, 3, 4]) == 4) + #expect(buffer.dequeue(sampleCount: 2) == [1, 2]) + #expect(buffer.enqueue([5, 6, 7, 8, 9]) == 5) + + #expect(buffer.sampleCount == 6) + #expect(buffer.dequeue(sampleCount: 10) == [4, 5, 6, 7, 8, 9]) + #expect(buffer.sampleCount == 0) +}