Spaces:
Running
Running
Download src/loudness.js from cloudronin/astara: direct link, hf CLI and curl.
- Browser
- Download file 3.34 kB
-
https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/loudness.js
- Command line
-
hf download hf://spaces/cloudronin/astara/src/loudness.js
-
curl -L -o loudness.js https://huggingface.co/spaces/cloudronin/astara/resolve/main/src/loudness.js
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; | |
| } | |