Zum Inhalt

apple/PhysarumApp/Tests/AudioAnalyzerTests.swift

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)
    }
}