Zum Inhalt

apple/PhysarumApp/Sources/Audio/BandNormalizer.swift

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