Zum Inhalt

apple/PhysarumApp/Tests/BandNormalizerTests.swift

import XCTest
@testable import Physarum

final class BandNormalizerTests: XCTestCase {
    private let hop: Float = 1.0 / 47.0   // ≈ one 1024-sample analysis at 48 kHz

    /// Feed a level repeatedly and return the last normalised value.
    private func settle(_ n: inout BandNormalizer, level: Float,
                        seconds: Float, tuning: AudioTuning = AudioTuning()) -> Float {
        var out: Float = 0
        for _ in 0..<Int(seconds / hop) { out = n.normalize(level, hop: hop, tuning: tuning) }
        return out
    }

    // A band oscillating between two levels must use most of the 0..1 range once the
    // followers have bracketed it — that is the whole point of the adaptation.
    func testAlternatingLevelSpansMostOfTheRange() {
        var n = BandNormalizer(peakFloor: 0.02)
        let t = AudioTuning()
        var lo: Float = 1, hi: Float = 0
        for i in 0..<Int(20 / hop) {
            let level: Float = (i / 20) % 2 == 0 ? 0.3 : 0.8
            let v = n.normalize(level, hop: hop, tuning: t)
            if i > Int(10 / hop) { lo = min(lo, v); hi = max(hi, v) }
        }
        XCTAssertGreaterThan(hi, 0.8, "loud phase should reach near the top")
        XCTAssertLessThan(lo, 0.2, "quiet phase should reach near the bottom")
    }

    // A band that never rises above the gate stays silent: adaptation must never turn
    // noise into music (design doc §3.3).
    func testBandBelowGateStaysSilent() {
        var n = BandNormalizer(peakFloor: 0.02)
        let v = settle(&n, level: 0.03, seconds: 20)
        XCTAssertEqual(v, 0, accuracy: 1e-6)
    }

    // Documented consequence of the valley follower: a perfectly constant tone is
    // bracketed away and fades out. Real music never does this; ambient drones can.
    // The escape hatch is a very large valleyTau (effectively peak-only).
    func testConstantToneFadesOut() {
        var n = BandNormalizer(peakFloor: 0.02)
        let v = settle(&n, level: 0.6, seconds: 60)
        XCTAssertLessThan(v, 0.1, "constant level collapses the span")

        var slow = BandNormalizer(peakFloor: 0.02)
        var t = AudioTuning()
        t.valleyTau = 10_000            // effectively peak-only
        let held = settle(&slow, level: 0.6, seconds: 60, tuning: t)
        XCTAssertGreaterThan(held, 0.8, "with a frozen valley the tone stays deflected")
    }

    // minSpan must keep the division bounded even when peak and valley coincide.
    func testMinSpanBoundsTheOutput() {
        var n = BandNormalizer(peakFloor: 0.02)
        var t = AudioTuning()
        t.minSpan = 0.08
        let v = settle(&n, level: 0.5, seconds: 60, tuning: t)
        XCTAssertFalse(v.isNaN)
        XCTAssertTrue((0...1).contains(v))
    }

    func testOutputIsAlwaysClamped() {
        var n = BandNormalizer(peakFloor: 0.02)
        let t = AudioTuning()
        for level in stride(from: Float(0), through: 1.5, by: 0.05) {
            let v = n.normalize(level, hop: hop, tuning: t)
            XCTAssertTrue((0...1).contains(v), "level \(level) produced \(v)")
        }
    }
}