// Playback level. The synthesized signal is a sound pressure in pascals; the // gain maps it to full scale either at a fixed calibration (absolute, 1 Pa // at 0 dBFS plus a user gain), by its peak, or by its loudness (RMS of the // sustained part). A soft clipper above 0.9 of full scale keeps the output // bounded whatever the gain. export type LevelMode = "absolute" | "loudness" | "peak"; const KNEE = 0.9; const PEAK_TARGET = 10 ** (-1 / 20); // -1 dBFS const LOUDNESS_TARGET = 10 ** (-20 / 20); // -20 dBFS RMS const LOUDNESS_SKIP_S = 0.05; function peakAbs(samples: Float32Array): number { let max = 0; for (let k = 0; k < samples.length; k++) { const a = Math.abs(samples[k]); if (a > max) max = a; } return max; } /** * Gain that maps the pressure signal [Pa] to full scale: absolute = 1 Pa at * 0 dBFS shifted by gainDb; peak = normalize the peak to -1 dBFS; loudness = * normalize the RMS of the signal after the first 50 ms to -20 dBFS. A silent * signal gets unit gain in the normalizing modes (there is nothing to scale). */ export function levelGain(samples: Float32Array, fs: number, mode: LevelMode, gainDb: number): number { if (mode === "absolute") return 10 ** (gainDb / 20); if (mode === "peak") { const max = peakAbs(samples); return max > 0 ? PEAK_TARGET / max : 1; } const start = Math.min(samples.length, Math.round(LOUDNESS_SKIP_S * fs)); const count = samples.length - start; if (count <= 0) return 1; let sum = 0; for (let k = start; k < samples.length; k++) sum += samples[k] * samples[k]; const rms = Math.sqrt(sum / count); return rms > 0 ? LOUDNESS_TARGET / rms : 1; } /** * Applies the gain and a soft clipper and returns a new array. Below the * knee (0.9 of full scale) the clipper is the identity; above it the excess * is squashed with a tanh so that the output stays within (-1, 1) and the * slope is continuous at the knee. */ export function applyLevel(samples: Float32Array, gain: number): Float32Array { const out = new Float32Array(samples.length); const room = 1 - KNEE; for (let k = 0; k < samples.length; k++) { const y = samples[k] * gain; const a = Math.abs(y); if (a <= KNEE) out[k] = y; else { const c = KNEE + room * Math.tanh((a - KNEE) / room); out[k] = y < 0 ? -c : c; } } return out; } /** Peak level in dBFS of a signal (-Infinity when silent). */ export function peakDbfs(samples: Float32Array): number { const max = peakAbs(samples); return max > 0 ? 20 * Math.log10(max) : -Infinity; } /** Peak sound pressure level in dB SPL re 20 uPa of a pressure signal [Pa]. */ export function peakSpl(samples: Float32Array): number { const max = peakAbs(samples); return max > 0 ? 20 * Math.log10(max / 20e-6) : -Infinity; }