Files
suaveplan/packages/media/audio/audio-analysis-loudness/src/algorithms/loudness-range.ts
2026-05-27 23:47:53 +01:00

94 lines
3.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Loudness range (LRA, in LU) per EBU Tech 3342.
*
* Inputs are short-term loudness samples — values of K-weighted, channel-
* summed loudness measured over 3 s windows stepped by 1 s. The algorithm
* applies an absolute gate at −70 LUFS, then a relative gate 20 LU below the
* gated mean (wider than the integrated-loudness relative gate of 10 LU —
* this is intentional in Tech 3342), and returns the 95th-minus-10th
* percentile spread of the surviving distribution.
*/
import {
ABSOLUTE_GATE_LUFS,
energyToLufs,
LUFS_SILENCE_FLOOR,
} from "./lufs.js";
/** EBU Tech 3342 relative-gate offset. */
export const LRA_RELATIVE_GATE_OFFSET_LU = -20;
export interface LoudnessRangeResult {
/** LRA in LU — 95th percentile minus 10th percentile. */
readonly lra: number;
/** Lower percentile (10th). */
readonly low: number;
/** Upper percentile (95th). */
readonly high: number;
/** Number of short-term blocks that survived both gates. */
readonly gatedCount: number;
}
/**
* Compute loudness range from a flat array of short-term LUFS values. If the
* input contains fewer than one full short-term window (i.e. is empty), the
* caller should pass `[]` and the function reports `lra: 0` so consumers do
* not have to special-case very short documents.
*/
export function loudnessRange(
shortTermLufs: readonly number[]
): LoudnessRangeResult {
if (shortTermLufs.length === 0)
return {
lra: 0,
low: LUFS_SILENCE_FLOOR,
high: LUFS_SILENCE_FLOOR,
gatedCount: 0,
};
// Absolute gate.
const aboveAbs: number[] = [];
for (const v of shortTermLufs) if (v > ABSOLUTE_GATE_LUFS) aboveAbs.push(v);
if (aboveAbs.length === 0)
return {
lra: 0,
low: LUFS_SILENCE_FLOOR,
high: LUFS_SILENCE_FLOOR,
gatedCount: 0,
};
// Ungated mean of absolute-gate survivors (mean of linear power → LUFS).
let powerSum = 0;
for (const v of aboveAbs) powerSum += 10 ** ((v + 0.691) / 10);
const meanPower = powerSum / aboveAbs.length;
const meanLufs = energyToLufs(meanPower);
const relativeGate = meanLufs + LRA_RELATIVE_GATE_OFFSET_LU;
const gated: number[] = [];
for (const v of aboveAbs) if (v > relativeGate) gated.push(v);
// gated.length is always ≥ 1 here: at least the loudest survivor of the
// absolute gate must exceed (mean − 20 LU). The branch is asserted by
// construction; no explicit fallback path is needed.
gated.sort((a, b) => a - b);
const low = percentile(gated, 0.1);
const high = percentile(gated, 0.95);
return { lra: high - low, low, high, gatedCount: gated.length };
}
/**
* Linear-interpolated percentile of a sorted ascending array. EBU Tech 3342
* specifies linear interpolation between adjacent order statistics.
*/
export function percentile(sortedAsc: readonly number[], q: number): number {
const n = sortedAsc.length;
if (n === 0) return LUFS_SILENCE_FLOOR;
if (n === 1) return sortedAsc[0] as number;
const idx = q * (n - 1);
const lo = Math.floor(idx);
const hi = Math.ceil(idx);
const frac = idx - lo;
const a = sortedAsc[lo] as number;
const b = sortedAsc[hi] as number;
return a + (b - a) * frac;
}