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