Skip to content
Merged
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
26 changes: 16 additions & 10 deletions Packages/ContinuityCore/Sources/ContinuityCore/LoudnessMeter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,32 @@ public enum LoudnessMeter {
var highPass = Biquad.highPass(f0: 38.13547087602444, q: 0.5003270373238773,
sampleRate: sampleRate)

var weighted = [Double](repeating: 0, count: samples.count)
for i in 0..<samples.count {
weighted[i] = highPass.process(shelf.process(Double(samples[i])))
}

// 400 ms blocks, 100 ms hop (75% overlap).
let block = Int(0.4 * sampleRate)
let hop = Int(0.1 * sampleRate)
guard block > 0, hop > 0, weighted.count >= block else { return nil }
guard block > 0, hop > 0, samples.count >= block else { return nil }

// Single streaming pass: weighted samples land in a block-sized ring instead of a
// full-length buffer (which cost ~127 MB on a 6-minute track — a jetsam-feeding spike
// while analysis runs during playback). Each block's mean-square is summed from the
// ring in logical order, so the arithmetic — order included — matches the old
// whole-buffer implementation exactly.
var ring = [Double](repeating: 0, count: block)
var blockLoudness: [Double] = []
var blockMeanSquare: [Double] = []
var start = 0
while start + block <= weighted.count {
for i in 0..<samples.count {
ring[i % block] = highPass.process(shelf.process(Double(samples[i])))
let filled = i + 1
guard filled >= block, (filled - block) % hop == 0 else { continue }
let start = filled - block
var sum = 0.0
for i in start..<(start + block) { sum += weighted[i] * weighted[i] }
for j in start..<filled {
let w = ring[j % block]
sum += w * w
}
let ms = sum / Double(block)
blockMeanSquare.append(ms)
blockLoudness.append(-0.691 + 10 * log10(max(ms, .leastNormalMagnitude)))
start += hop
}

// Absolute gate: drop blocks below −70 LUFS.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,57 @@ final class LoudnessMeterTests: XCTestCase {
XCTAssertEqual(LoudnessMeter.makeupGainDB(measuredLUFS: -30, targetLUFS: -14), 6, accuracy: 1e-9) // boost cap
XCTAssertEqual(LoudnessMeter.makeupGainDB(measuredLUFS: -1, targetLUFS: -14), -12, accuracy: 1e-9) // cut cap
}

/// The streaming ring-buffer implementation must reproduce the straightforward
/// whole-buffer measurement bit-for-bit — including at sample rates where the 400 ms
/// block is NOT an exact multiple of the 100 ms hop (integer truncation, e.g. 44056 Hz).
func testStreamingMatchesReferenceImplementation() {
for rate in [44_100.0, 48_000.0, 44_056.0] {
let n = Int(3.7 * rate) // non-whole block count exercises the tail handling
let samples = (0..<n).map { i -> Float in
// Deterministic multi-tone with a quiet stretch so both gates engage.
let t = Double(i) / rate
let quiet = t > 2.5 ? 0.001 : 1.0
return Float(quiet * (0.08 * sin(2 * .pi * 997 * t) + 0.04 * sin(2 * .pi * 210 * t)))
}
let streamed = LoudnessMeter.integratedLUFS(samples: samples, sampleRate: rate)
let reference = Self.referenceLUFS(samples: samples, sampleRate: rate)
XCTAssertEqual(streamed, reference, "sample rate \(rate)")
}
}

/// The pre-optimization implementation: full-length weighted buffer, then blocks.
private static func referenceLUFS(samples: [Float], sampleRate: Double) -> Double? {
guard sampleRate > 0, !samples.isEmpty else { return nil }
var shelf = LoudnessMeter.Biquad.highShelf(f0: 1681.974450955533, gainDB: 3.999843853973347,
q: 0.7071752369554196, sampleRate: sampleRate)
var highPass = LoudnessMeter.Biquad.highPass(f0: 38.13547087602444, q: 0.5003270373238773,
sampleRate: sampleRate)
var weighted = [Double](repeating: 0, count: samples.count)
for i in 0..<samples.count {
weighted[i] = highPass.process(shelf.process(Double(samples[i])))
}
let block = Int(0.4 * sampleRate)
let hop = Int(0.1 * sampleRate)
guard block > 0, hop > 0, weighted.count >= block else { return nil }
var blockLoudness: [Double] = []
var blockMeanSquare: [Double] = []
var start = 0
while start + block <= weighted.count {
var sum = 0.0
for i in start..<(start + block) { sum += weighted[i] * weighted[i] }
let ms = sum / Double(block)
blockMeanSquare.append(ms)
blockLoudness.append(-0.691 + 10 * log10(max(ms, .leastNormalMagnitude)))
start += hop
}
var gated = zip(blockMeanSquare, blockLoudness).filter { $0.1 > -70 }
guard !gated.isEmpty else { return nil }
let meanMS = gated.reduce(0) { $0 + $1.0 } / Double(gated.count)
let relativeThreshold = -0.691 + 10 * log10(meanMS) - 10
gated = gated.filter { $0.1 > relativeThreshold }
guard !gated.isEmpty else { return nil }
let finalMS = gated.reduce(0) { $0 + $1.0 } / Double(gated.count)
return -0.691 + 10 * log10(finalMS)
}
}
Loading