Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 28 additions & 16 deletions FineTune/Audio/Engine/ProcessTapController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1264,6 +1264,33 @@ final class ProcessTapController: ProcessTapControlling {
primaryResources.destroy()
}

/// Returns the peak magnitude across every channel of every input buffer,
/// plus the frame count of the first non-empty buffer. RT-safe: no allocations.
@inline(__always)
nonisolated static func inputPeak(
_ inputBuffers: UnsafeMutableAudioBufferListPointer
) -> (peak: Float, frames: Int) {
var maxPeak: Float = 0
var frames = 0

for inputBuffer in inputBuffers {
guard let inputData = inputBuffer.mData else { continue }
let inputSamples = inputData.assumingMemoryBound(to: Float.self)
let channels = max(1, Int(inputBuffer.mNumberChannels))
let sampleCount = Int(inputBuffer.mDataByteSize) / MemoryLayout<Float>.size
if frames == 0 {
frames = sampleCount / channels
}

for index in 0..<sampleCount {
let magnitude = abs(inputSamples[index])
if magnitude > maxPeak { maxPeak = magnitude }
}
}

return (min(maxPeak, 1), frames)
}

/// Advance the output-gate state machine for one buffer and return the multiplier
/// to apply to this buffer's output. Pure function; no class state. RT-safe:
/// no allocations, no locks, no Foundation. One `cos` per buffer (not per sample).
Expand Down Expand Up @@ -1525,22 +1552,7 @@ final class ProcessTapController: ProcessTapControlling {
}

// Track peak level for VU meter
var maxPeak: Float = 0.0
var totalSamplesThisBuffer: Int = 0
for inputBuffer in inputBuffers {
guard let inputData = inputBuffer.mData else { continue }
let inputSamples = inputData.assumingMemoryBound(to: Float.self)
let channels = max(1, Int(inputBuffer.mNumberChannels))
let sampleCount = Int(inputBuffer.mDataByteSize) / MemoryLayout<Float>.size
if totalSamplesThisBuffer == 0 {
totalSamplesThisBuffer = sampleCount / channels
}
for i in stride(from: 0, to: sampleCount, by: channels) {
let absSample = abs(inputSamples[i])
if absSample > maxPeak { maxPeak = absSample }
}
}
let rawPeak = min(maxPeak, 1.0)
let (rawPeak, totalSamplesThisBuffer) = Self.inputPeak(inputBuffers)

if isPrimary {
_peakLevel = _peakLevel + levelSmoothingFactor * (rawPeak - _peakLevel)
Expand Down
27 changes: 27 additions & 0 deletions FineTuneTests/ProcessingPipelineTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,33 @@ private func loadRepositorySource(_ relativePath: String) throws -> String {
return try String(contentsOf: url, encoding: .utf8)
}

// MARK: - Input Peak Tests

@Suite("ProcessTapController — Input Peak")
struct InputPeakTests {
@Test("Right-only stereo input is not read as silence")
func rightOnlyStereo() {
let input = TestABL(buffers: [(channels: 2, frames: 256)])
let samples = input.data(at: 0)
for frame in 0..<256 {
samples[frame * 2 + 1] = 0.5
}

let result = ProcessTapController.inputPeak(input.bufferList)

#expect(result.peak == 0.5)
#expect(result.frames == 256)
}

@Test("Peak is clamped to unity")
func clampsPeak() {
let input = TestABL(buffers: [(channels: 2, frames: 4)])
fill(input, bufferIndex: 0, value: -3)

#expect(ProcessTapController.inputPeak(input.bufferList).peak == 1)
}
}

// MARK: - Buffer Mapping Tests

@Suite("ProcessTapController — Buffer Mapping")
Expand Down