Zum Inhalt

apple/PhysarumApp/Sources/Audio/AudioAnalyzer.swift

import Foundation
import Accelerate

private let BASS_HZ: (Double, Double) = (20, 250)
private let MID_HZ: (Double, Double)  = (250, 4000)
private let HIGH_HZ: (Double, Double) = (4000, 16000)
private let DB_FLOOR: Float = -90
private let DB_CEIL: Float  = -20

/// RMS energy across the FFT-bin range for `range` Hz, mapped to 0..1.
///
/// Deliberately NOT the arithmetic mean of per-bin dB (which the web `bandLevel` still
/// uses): because dB is logarithmic, every bin sitting at the noise floor drags that mean
/// down hard even though it carries no energy. Wide bands are diluted by their own empty
/// bins — an 8 kHz sine used to read louder in mid (80 bins) than in high (256 bins).
/// Averaging energy linearly and converting once afterwards fixes the band assignment
/// and restores the dynamic range. `mags` is a half-spectrum (fftSize/2 bins).
func bandLevel(mags: [Float], sampleRate: Double, fftSize: Int, range: (Double, Double)) -> Float {
    let binHz = sampleRate / Double(fftSize)
    let loBin = Int((range.0 / binHz).rounded(.down))
    let hiBin = Int((range.1 / binHz).rounded(.up))
    let lo = max(0, loBin), hi = min(mags.count - 1, hiBin)
    guard hi >= lo else { return 0 }
    var energy: Float = 0
    for i in lo...hi { energy += mags[i] * mags[i] }
    let rms = sqrt(energy / Float(hi - lo + 1))
    let v = (20 * log10(max(rms, 1e-9)) - DB_FLOOR) / (DB_CEIL - DB_FLOOR)
    return min(1, max(0, v))
}

/// Stateful FFT analyzer: time-domain Float samples → 3 normalized bands.
/// Hann window → real FFT (vDSP) → magnitude → dB → bandLevel → per-band EMA.
final class AudioAnalyzer {
    private let fftSize: Int
    private let halfN: Int
    private let fftSetup: FFTSetup
    private let window: [Float]
    private let smoothing: Float
    private var smoothed = AudioBands.zero

    // MARK: – Transient state
    private struct BandT { var baseline: Float = 0; var env: Float = 0; var peak: Float = 0.02 }
    private var tBass = BandT(), tMid = BandT(), tHigh = BandT()
    private var prevMags: [Float]?

    // MARK: – Per-band balance adaptation
    private var nBass = BandNormalizer()
    private var nMid  = BandNormalizer()
    private var nHigh = BandNormalizer()
    private static let peakFloor: Float = 0.02

    private func bandFlux(_ mags: [Float], _ prev: [Float], sampleRate: Double, range: (Double, Double)) -> Float {
        let binHz = sampleRate / Double(fftSize)
        let lo = max(0, Int((range.0 / binHz).rounded(.down)))
        let hi = min(mags.count - 1, Int((range.1 / binHz).rounded(.up)))
        guard hi >= lo else { return 0 }
        var s: Float = 0
        for i in lo...hi { s += max(0, mags[i] - prev[i]) }
        return s
    }

    private func transientValue(_ flux: Float, _ st: inout BandT, baselineCoef: Float, releaseCoef: Float, peakCoef: Float) -> Float {
        st.baseline += baselineCoef * (flux - st.baseline)
        let dev = max(0, flux - st.baseline)
        st.env = dev > st.env ? dev : releaseCoef * st.env
        st.peak = max(st.env, peakCoef * st.peak)
        return min(1, max(0, st.env / max(st.peak, Self.peakFloor)))
    }

    init(fftSize: Int = 1024, smoothing: Float = 0.8) {
        self.fftSize  = fftSize
        self.halfN    = fftSize / 2
        let log2n = vDSP_Length(log2(Float(fftSize)))
        self.fftSetup = vDSP_create_fftsetup(log2n, FFTRadix(kFFTRadix2))!
        // Hann window (normalised)
        var win = [Float](repeating: 0, count: fftSize)
        vDSP_hann_window(&win, vDSP_Length(fftSize), Int32(vDSP_HANN_NORM))
        self.window   = win
        self.smoothing = smoothing
    }

    deinit {
        vDSP_destroy_fftsetup(fftSetup)
    }

    func analyze(samples: [Float], sampleRate: Double, tuning: AudioTuning) -> AudioBands {
        // 1. Zero-pad / copy input, then apply Hann window
        var input = [Float](repeating: 0, count: fftSize)
        let count = min(samples.count, fftSize)
        input.replaceSubrange(0..<count, with: samples[0..<count])
        vDSP_vmul(input, 1, window, 1, &input, 1, vDSP_Length(fftSize))

        // 2. Pack real signal into split-complex via vDSP_ctoz.
        //    vDSP_ctoz reads the input as interleaved (re, im) pairs:
        //      realp[i] = input[2*i],  imagp[i] = input[2*i+1]
        //    Since `input` is purely real, imagp receives the odd-indexed
        //    time-domain samples — exactly the correct real-FFT pre-pack.
        //    The stride counts FLOATS, not DSPComplex elements, so densely packed
        //    input needs 2. This used to be 1, which made ctoz walk overlapping
        //    pairs: every frequency landed on half its bin index, squashing the whole
        //    axis by a factor of two (an 8 kHz tone measured as 4 kHz, i.e. right on
        //    the mid/high border, and the high band only ever saw 8–24 kHz).
        //    Verified empirically: with stride 2 an 8 kHz sine peaks at bin 171 of 512
        //    with 99.99% of the energy in the top 6 bins; with stride 1 it peaked at
        //    bin 85 plus a mirror ghost at 427. Guarded by
        //    AudioAnalyzerTests.testToneLandsOnItsOwnFrequency.
        var realp = [Float](repeating: 0, count: halfN)
        var imagp = [Float](repeating: 0, count: halfN)
        var magsOut = [Float](repeating: 0, count: halfN)

        realp.withUnsafeMutableBufferPointer { rp in
            imagp.withUnsafeMutableBufferPointer { ip in
                var split = DSPSplitComplex(realp: rp.baseAddress!, imagp: ip.baseAddress!)

                input.withUnsafeBytes { rawBytes in
                    let cPtr = rawBytes.baseAddress!.assumingMemoryBound(to: DSPComplex.self)
                    // stride 2 = one DSPComplex (two floats) per output element
                    vDSP_ctoz(cPtr, 2, &split, 1, vDSP_Length(halfN))
                }

                // 3. Forward FFT in-place (zrip result is scaled by 2×)
                let log2n = vDSP_Length(log2(Float(fftSize)))
                vDSP_fft_zrip(fftSetup, &split, 1, log2n, FFTDirection(FFT_FORWARD))

                // 4. Magnitudes, scaled by 1/fftSize (zrip doubles all bins; that plus the
                //    DFT normalisation is 1/N total). No dB array any more — both the band
                //    levels and the transient path work on these linear magnitudes.
                var mags = [Float](repeating: 0, count: halfN)
                vDSP_zvabs(&split, 1, &mags, 1, vDSP_Length(halfN))
                var scale = 1.0 / Float(fftSize)
                vDSP_vsmul(mags, 1, &scale, &magsOut, 1, vDSP_Length(halfN))
            }
        }

        // 5. Band levels → EMA smoothing
        let raw = AudioBands(
            bass: bandLevel(mags: magsOut, sampleRate: sampleRate, fftSize: fftSize, range: BASS_HZ),
            mid:  bandLevel(mags: magsOut, sampleRate: sampleRate, fftSize: fftSize, range: MID_HZ),
            high: bandLevel(mags: magsOut, sampleRate: sampleRate, fftSize: fftSize, range: HIGH_HZ))
        // NOTE: the web smooths linear FFT magnitudes (pre-dB) via AnalyserNode's
        // smoothingTimeConstant; we smooth the final clamped 0..1 band levels instead.
        // Deliberate — slightly softer near the rails, fine for a visualizer.
        // loudness = RAW overall level (max of un-smoothed bands) — drives the
        // loudness-response in evaluateAudioFactors. NOT per-band-normalized.
        let loudness = max(raw.bass, max(raw.mid, raw.high))
        let s = self.smoothing
        smoothed = AudioBands(
            bass: s * smoothed.bass + (1 - s) * raw.bass,
            mid:  s * smoothed.mid  + (1 - s) * raw.mid,
            high: s * smoothed.high + (1 - s) * raw.high,
            loudness: loudness)

        // hop = seconds of audio this analysis covered; drives every time constant below.
        let hop = Float(max(1, min(samples.count, fftSize))) / Float(sampleRate)

        // 6. Balance adaptation: each band onto its own running span, so a `depth` value
        //    means the same everywhere regardless of how many bins the band covers.
        //    `loudness` above stays raw on purpose — it carries the musical dynamics
        //    through the loudness response.
        if tuning.adaptiveBalance {
            smoothed.bass = nBass.normalize(smoothed.bass, hop: hop, tuning: tuning)
            smoothed.mid  = nMid.normalize(smoothed.mid,  hop: hop, tuning: tuning)
            smoothed.high = nHigh.normalize(smoothed.high, hop: hop, tuning: tuning)
        }

        // 7. Per-band spectral-flux transient. Runs on the raw magnitudes, so it is
        //    unaffected by the balance adaptation — it has its own peak normalisation.
        let baselineCoef = 1 - exp(-hop / 1.5)
        let releaseCoef  = exp(-hop / 0.25)
        let peakCoef     = exp(-hop / 3.0)
        if let prev = prevMags, prev.count == magsOut.count {
            smoothed.transientBass = transientValue(bandFlux(magsOut, prev, sampleRate: sampleRate, range: BASS_HZ), &tBass, baselineCoef: baselineCoef, releaseCoef: releaseCoef, peakCoef: peakCoef)
            smoothed.transientMid  = transientValue(bandFlux(magsOut, prev, sampleRate: sampleRate, range: MID_HZ),  &tMid,  baselineCoef: baselineCoef, releaseCoef: releaseCoef, peakCoef: peakCoef)
            smoothed.transientHigh = transientValue(bandFlux(magsOut, prev, sampleRate: sampleRate, range: HIGH_HZ), &tHigh, baselineCoef: baselineCoef, releaseCoef: releaseCoef, peakCoef: peakCoef)
        }
        prevMags = magsOut
        return smoothed
    }
}