astara / src /loudness.js
system's picture
system HF Staff
cbe9c42: Every paper is read aloud, by whoever wrote it
7b6d5a9 verified
Raw History Blame Contribute Delete
3.34 kB
// How loud a recording is, measured the way broadcast measures it.
//
// THE VOICES CAME IN 14 DB APART. The smith at -34.8 LUFS, the baker at -20.7:
// played as recorded, one letter is five times louder than the next and the
// quiet ones vanish under the torch. So every voice is brought to one level
// when it loads, from its own samples, and a file dropped in later is levelled
// the same way without anybody writing a number down.
//
// NOT A PLAIN AVERAGE. Gated RMS agreed with a real loudness meter to within a
// decibel for ten of the eleven voices and missed the well house keeper by
// three: that voice is breathy and bright, and the ear hears high frequencies
// louder than an average counts them. ITU-R BS.1770 exists for exactly that --
// a K-weighting filter shaped like the ear, then a gate that ignores the
// silences -- so this is BS.1770-4 integrated loudness, checked against
// ffmpeg's ebur128 on every voice in the game.
/** Biquad coefficients for the two K-weighting stages, at any sample rate. */
function kWeighting(fs) {
// Stage 1: the head as an acoustic shelf, +4 dB above ~1.7 kHz.
let K = Math.tan(Math.PI * 1681.974450955533 / fs);
const Q1 = 0.7071752369554196;
const Vh = 10 ** (3.999843853973347 / 20);
const Vb = Vh ** 0.4996667741545416;
let a0 = 1 + K / Q1 + K * K;
const shelf = {
b: [(Vh + Vb * K / Q1 + K * K) / a0, 2 * (K * K - Vh) / a0, (Vh - Vb * K / Q1 + K * K) / a0],
a: [2 * (K * K - 1) / a0, (1 - K / Q1 + K * K) / a0],
};
// Stage 2: the RLB high-pass, which stops rumble counting as loudness.
K = Math.tan(Math.PI * 38.13547087602444 / fs);
const Q2 = 0.5003270373238773;
a0 = 1 + K / Q2 + K * K;
const highpass = {
b: [1, -2, 1],
a: [2 * (K * K - 1) / a0, (1 - K / Q2 + K * K) / a0],
};
return [shelf, highpass];
}
function filter(x, { b, a }) {
const y = new Float32Array(x.length);
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (let i = 0; i < x.length; i++) {
const v = b[0] * x[i] + b[1] * x1 + b[2] * x2 - a[0] * y1 - a[1] * y2;
x2 = x1; x1 = x[i]; y2 = y1; y1 = v;
y[i] = v;
}
return y;
}
/**
* Integrated loudness in LUFS of one mono channel, BS.1770-4: K-weight,
* 400ms blocks overlapping by 75%, an absolute gate at -70 LUFS and a relative
* gate 10 LU under the result. -Infinity for silence.
*/
export function loudness(samples, sampleRate) {
const [shelf, highpass] = kWeighting(sampleRate);
const y = filter(filter(samples, shelf), highpass);
const block = Math.round(0.4 * sampleRate);
const hop = Math.round(0.1 * sampleRate);
const powers = [];
for (let i = 0; i + block <= y.length; i += hop) {
let e = 0;
for (let j = i; j < i + block; j++) e += y[j] * y[j];
powers.push(e / block);
}
const lufs = (p) => -0.691 + 10 * Math.log10(p);
const mean = (list) => list.reduce((s, p) => s + p, 0) / list.length;
const loud = powers.filter((p) => lufs(p) > -70);
if (!loud.length) return -Infinity;
const gate = lufs(mean(loud)) - 10;
const kept = loud.filter((p) => lufs(p) > gate);
return lufs(mean(kept));
}
/** The largest absolute sample, for keeping a level change clear of clipping. */
export function peak(samples) {
let m = 0;
for (let i = 0; i < samples.length; i++) {
const v = Math.abs(samples[i]);
if (v > m) m = v;
}
return m;
}