// Playback level: absolute (fixed pascal-to-full-scale mapping with a user gain), // loudness normalization, or peak normalization. A soft clipper bounds the output. export type LevelMode = "absolute" | "loudness" | "peak"; /** Linear gain applied to the pressure signal [Pa] for the given mode. */ export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): number { if (samples.length === 0) return 0; if (mode === "absolute") { // 0 dBFS corresponds to 1 Pa at 0 dB gain. return 10 ** (gainDb / 20); } if (mode === "peak") { let max = 0; for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k])); return max > 0 ? peak / max : 0; } // Loudness: scale the RMS of the sustained part (after the first 50 ms) to -16 dBFS. const start = Math.min(samples.length - 1, Math.round(0.05 * fs)); let sum = 0; for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k]; const rms = Math.sqrt(sum / Math.max(1, samples.length - start)); return rms > 0 ? 10 ** (-16 / 20) / rms : 0; } /** Peak level [dBFS] the signal would reach before clipping. */ export function peakDbfs(samples: Float32Array, gain: number): number { let max = 0; for (let k = 0; k < samples.length; k++) max = Math.max(max, Math.abs(samples[k])); return max > 0 ? 20 * Math.log10(max * gain) : -Infinity; } /** Returns a copy of `samples` scaled for playback or export, soft-clipped to [-peak, peak]. */ export function normalize(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number, peak = 0.95): Float32Array { const out = new Float32Array(samples.length); const g = levelGain(samples, fs, mode, gainDb, peak); if (mode === "peak") { for (let k = 0; k < samples.length; k++) out[k] = samples[k] * g; return out; } for (let k = 0; k < samples.length; k++) out[k] = peak * Math.tanh((samples[k] * g) / peak); return out; }