import Foundation
/// Per-band balance adaptation. Normalises one band's level onto its own running
/// peak/valley span, so a `depth` value means the same in every band regardless of how
/// many FFT bins the band happens to cover.
///
/// Pure and deterministic: no clock, no AVFoundation. The caller passes the hop duration,
/// so the time constants hold regardless of buffer size and sample rate — the same
/// approach the transient path already uses.
///
/// Design: _SDD/2026-07-30-audio-adaptive-band-balance-design.md §3.3
struct BandNormalizer {
private var peak: Float
private var valley: Float = 0
init(peakFloor: Float = 0.02) {
peak = peakFloor
}
/// Advance the followers with `level` and return the normalised 0..1 value.
/// `hop` is the duration in seconds of audio this analysis covered.
mutating func normalize(_ level: Float, hop: Float, tuning: AudioTuning) -> Float {
let peakDecay = exp(-hop / max(tuning.peakTau, 1e-3))
let valleyRise = 1 - exp(-hop / max(tuning.valleyTau, 1e-3))
let floorLevel = tuning.peakFloor
// Fast attack, slow release — anchored at peakFloor so silence cannot collapse
// the span to zero and make the next sound explode.
peak = max(level, floorLevel + (peak - floorLevel) * peakDecay)
// Mirror image: drops immediately, creeps up slowly. The min() also guarantees
// valley <= level, so the numerator below is never negative.
valley = min(level, valley + (peak - valley) * valleyRise)
let span = max(peak - valley, tuning.minSpan)
let norm = min(1, max(0, (level - valley) / span))
// Absolute gate: a band whose running peak stays low is faded out rather than
// amplified. Soft ramp so an entering bass does not click.
let presence = smoothstep(tuning.bandGateLo, tuning.bandGateHi, peak)
return norm * presence
}
}