Initial Commit
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user