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