import XCTest
import Accelerate
@testable import Physarum
final class AudioAnalyzerTests: XCTestCase {
private func sine(freq: Double, sampleRate: Double, n: Int) -> [Float] {
(0..<n).map { Float(sin(2.0 * Double.pi * freq * Double($0) / sampleRate)) }
}
/// Tuning with the balance adaptation off — these tests exercise the aggregator alone.
private func rawTuning() -> AudioTuning {
var t = AudioTuning()
t.adaptiveBalance = false
return t
}
// Constant magnitudes: RMS equals that magnitude, so the dB mapping is unchanged.
// -20 dBFS → 10^(-20/20) = 0.1 ; -90 dBFS → 10^(-90/20) ≈ 3.1623e-5
func testBandLevelMapsDbFloorCeilTo01() {
let fft = 1024, sr = 48000.0
let ceil = [Float](repeating: 0.1, count: fft / 2)
let floor = [Float](repeating: 3.1623e-5, count: fft / 2)
XCTAssertEqual(bandLevel(mags: ceil, sampleRate: sr, fftSize: fft, range: (20, 250)), 1.0, accuracy: 1e-3)
XCTAssertEqual(bandLevel(mags: floor, sampleRate: sr, fftSize: fft, range: (20, 250)), 0.0, accuracy: 1e-3)
}
// Guards the vDSP_ctoz stride. With the old stride of 1, ctoz walked overlapping
// pairs and every frequency landed on half its bin index — the whole axis squashed
// by two. These three tones sit deep inside their bands under a correct mapping but
// fall into the band below when halved (8000→4000, 2000→1000, 300→150).
func testToneLandsOnItsOwnFrequency() {
let sr = 48000.0, n = 1024
// Well below full scale: a 0 dBFS sine saturates several bands at the -20 dBFS
// ceiling, which would mask the very ordering this test is about.
func bands(_ freq: Double) -> AudioBands {
let s = sine(freq: freq, sampleRate: sr, n: n).map { $0 * 0.05 }
return AudioAnalyzer(fftSize: n, smoothing: 0)
.analyze(samples: s, sampleRate: sr, tuning: rawTuning())
}
let high = bands(8000)
XCTAssertGreaterThan(high.high, high.mid, "8 kHz belongs to high, not mid")
let mid = bands(2000)
XCTAssertGreaterThan(mid.mid, mid.high)
XCTAssertGreaterThan(mid.mid, mid.bass)
// 500 Hz sits ~11 bins up, comfortably clear of the bass edge at bin 5 — but
// halved it would land right on that edge.
let lowMid = bands(500)
XCTAssertGreaterThan(lowMid.mid, lowMid.bass, "500 Hz is well above the 250 Hz bass edge")
}
// The regression this whole round exists for. Measured 2026-06-08 with the old
// arithmetic dB mean: an 8 kHz sine read mid=0.69 > high=0.58 — a tone squarely
// inside the high band (4–16 kHz) came out louder in mid, because the same energy
// peak is diluted across 256 bins in high but only 80 in mid.
func testEightKilohertzSineReadsHighestInHighBand() {
let sr = 48000.0, n = 1024
let analyzer = AudioAnalyzer(fftSize: n, smoothing: 0)
let b = analyzer.analyze(samples: sine(freq: 8000, sampleRate: sr, n: n),
sampleRate: sr, tuning: rawTuning())
XCTAssertGreaterThan(b.high, b.mid, "8 kHz must dominate the high band")
XCTAssertGreaterThan(b.high, b.bass)
}
// The other half of the aggregator fix: a narrow energy peak inside a wide band must
// move the reading far, not a little. Worked example from the design doc §1 — 50 of
// 256 high-band bins at -40 dBFS against a -80 dBFS floor. The old dB mean spanned
// 0.143→0.254 (0.11 of the range); RMS spans roughly 0.143→0.613 (0.47).
func testNarrowPeakInWideBandWidensTheSpan() {
let fft = 1024, sr = 48000.0
let binHz = sr / Double(fft)
let loBin = Int((4000.0 / binHz).rounded(.down))
let quiet = [Float](repeating: 1e-4, count: fft / 2) // -80 dBFS
var loud = quiet
for i in loBin..<(loBin + 50) { loud[i] = 1e-2 } // -40 dBFS
let vQuiet = bandLevel(mags: quiet, sampleRate: sr, fftSize: fft, range: (4000, 16000))
let vLoud = bandLevel(mags: loud, sampleRate: sr, fftSize: fft, range: (4000, 16000))
XCTAssertEqual(vQuiet, 0.143, accuracy: 0.02)
XCTAssertEqual(vLoud, 0.613, accuracy: 0.02)
XCTAssertGreaterThan(vLoud - vQuiet, 0.4, "the usable span must be far wider than the old 0.11")
}
func testLowSineReadsHighestInBassBand() {
let sr = 48000.0, n = 1024
let analyzer = AudioAnalyzer(fftSize: n, smoothing: 0)
let b = analyzer.analyze(samples: sine(freq: 120, sampleRate: sr, n: n),
sampleRate: sr, tuning: rawTuning())
XCTAssertGreaterThan(b.bass, b.mid)
XCTAssertGreaterThan(b.mid, b.high)
}
func testSineEnergyLandsInExpectedBand() {
let sr = 48000.0, n = 1024
let analyzer = AudioAnalyzer(fftSize: n, smoothing: 0)
let low = analyzer.analyze(samples: sine(freq: 120, sampleRate: sr, n: n),
sampleRate: sr, tuning: rawTuning())
XCTAssertGreaterThan(low.bass, low.mid)
XCTAssertGreaterThan(low.bass, low.high)
let high = analyzer.analyze(samples: sine(freq: 8000, sampleRate: sr, n: n),
sampleRate: sr, tuning: rawTuning())
XCTAssertGreaterThan(high.high, high.bass)
}
func testSilenceIsNearZeroAndClamped() {
let sr = 48000.0, n = 1024
let analyzer = AudioAnalyzer(fftSize: n, smoothing: 0)
let b = analyzer.analyze(samples: [Float](repeating: 0, count: n),
sampleRate: sr, tuning: rawTuning())
for v in [b.bass, b.mid, b.high] {
XCTAssertGreaterThanOrEqual(v, 0); XCTAssertLessThanOrEqual(v, 1)
XCTAssertLessThan(v, 0.2, "silence should map near the dB floor")
}
}
// The invariant that keeps musical dynamics intact: loudness must stay the raw
// overall level. Feeding normalised bands into it would lift quiet passages to
// drop level via the loudness response.
func testLoudnessStaysUnnormalised() {
let sr = 48000.0, n = 1024
var t = AudioTuning()
t.adaptiveBalance = true
let quiet = sine(freq: 120, sampleRate: sr, n: n).map { $0 * 0.02 }
let loud = sine(freq: 120, sampleRate: sr, n: n)
let a = AudioAnalyzer(fftSize: n, smoothing: 0)
let bQuiet = a.analyze(samples: quiet, sampleRate: sr, tuning: t)
let b = AudioAnalyzer(fftSize: n, smoothing: 0)
let bLoud = b.analyze(samples: loud, sampleRate: sr, tuning: t)
XCTAssertGreaterThan(bLoud.loudness, bQuiet.loudness,
"loudness must still separate quiet from loud")
}
// The A/B switch must leave no residue: with the adaptation off, feeding the same
// signal over and over yields a constant reading (no follower state creeps in),
// whereas with it on the value provably moves as the followers bracket the band.
func testAdaptiveBalanceOffKeepsReadingConstant() {
let sr = 48000.0, n = 1024
let s = sine(freq: 8000, sampleRate: sr, n: n).map { $0 * 0.05 }
var off = AudioTuning(); off.adaptiveBalance = false
let a = AudioAnalyzer(fftSize: n, smoothing: 0)
let first = a.analyze(samples: s, sampleRate: sr, tuning: off).high
for _ in 0..<200 { _ = a.analyze(samples: s, sampleRate: sr, tuning: off) }
let last = a.analyze(samples: s, sampleRate: sr, tuning: off).high
XCTAssertEqual(first, last, accuracy: 1e-6, "raw path must be stateless in level")
// Same loud→quiet sequence through both paths. A constant level would prove
// nothing: with level == peak the normalised value sits at 1.0 until the span
// finally collapses below minSpan (that slow fade is covered in
// BandNormalizerTests). A level change is what the adaptation reshapes.
var on = AudioTuning(); on.adaptiveBalance = true
let loud = sine(freq: 8000, sampleRate: sr, n: n).map { $0 * 0.5 }
let quiet = sine(freq: 8000, sampleRate: sr, n: n).map { $0 * 0.05 }
func run(_ tuning: AudioTuning) -> Float {
let an = AudioAnalyzer(fftSize: n, smoothing: 0)
for _ in 0..<100 { _ = an.analyze(samples: loud, sampleRate: sr, tuning: tuning) }
return an.analyze(samples: quiet, sampleRate: sr, tuning: tuning).high
}
XCTAssertNotEqual(run(on), run(off), accuracy: 1e-4,
"the adaptation must reshape a level change")
}
// loudness = raw overall level: > 0 for a tone, ~0 for silence.
func testLoudnessReflectsOverallLevel() {
let sr = 48000.0, n = 1024
let tone = AudioAnalyzer(fftSize: n, smoothing: 0)
.analyze(samples: sine(freq: 120, sampleRate: sr, n: n), sampleRate: sr, tuning: rawTuning())
XCTAssertGreaterThan(tone.loudness, 0.0)
let silent = AudioAnalyzer(fftSize: n, smoothing: 0)
.analyze(samples: [Float](repeating: 0, count: n), sampleRate: sr, tuning: rawTuning())
XCTAssertLessThan(silent.loudness, 0.2)
}
}