// src/audio/bands.ts
// Pure helpers for splitting an FFT magnitude spectrum into bass / mid / high
// energy levels. Kept free of the AudioContext / AnalyserNode dependency so it
// stays unit-testable in the node test environment.
export const BASS_HZ: readonly [number, number] = [20, 250];
export const MID_HZ: readonly [number, number] = [250, 4000];
export const HIGH_HZ: readonly [number, number] = [4000, 16000];
// Maps AnalyserNode dB values (typically −90 .. −20 dBFS for music at speaking
// volume) onto a 0..1 visual-reactivity range.
const DB_FLOOR = -90;
const DB_CEIL = -20;
function clamp01(v: number): number {
return v < 0 ? 0 : v > 1 ? 1 : v;
}
/** Average dB across the half-open FFT-bin range [loBin, hiBin], inclusive. */
function avgDb(freq: Float32Array, loBin: number, hiBin: number): number {
const lo = Math.max(0, Math.floor(loBin));
const hi = Math.min(freq.length - 1, Math.ceil(hiBin));
if (hi < lo) return DB_FLOOR;
let sum = 0;
for (let i = lo; i <= hi; i++) sum += freq[i] ?? DB_FLOOR;
return sum / (hi - lo + 1);
}
/**
* Computes the 0..1 reactivity level of one frequency band given a frequency
* spectrum (AnalyserNode getFloatFrequencyData output, dB) and the sample rate
* + FFT size that produced it.
*/
export function bandLevel(
freq: Float32Array,
sampleRate: number,
fftSize: number,
range: readonly [number, number],
): number {
const binHz = sampleRate / fftSize;
const lo = range[0] / binHz;
const hi = range[1] / binHz;
const db = avgDb(freq, lo, hi);
return clamp01((db - DB_FLOOR) / (DB_CEIL - DB_FLOOR));
}