Files
ytplayer/frontend/loudness.test.js
Jonathan Sykes ea9e11d054 Loudness levelling (EBU R128, -14 LUFS) measured in the browser with WebAssembly
- frontend/wasm/loudness.c → loudness-wasm.js (1.5 KB, base64-embedded; built by
  scripts/build-loudness-wasm.sh with clang or Zig's clang): K-weighting + 100 ms
  energy steps. loudness.js does the 400 ms gating (abs -70 LUFS, rel -10 LU)
  with an identical JS fallback. EBU Tech 3341 sine cases measure -23.0/-33.0
  on both engines; a real track reads -21.7 vs ffmpeg ebur128 -21.8.
- The device measures from audio it already has (saved copy or the server
  cache's m4a sidecar, songs up to 12 min, decoded natively at 22.05 kHz) and
  reports the number to /api/loudness, so each song is measured once for
  everyone; the server stores numbers only, no audio processing.
- The EQ graph gains a level stage (gain -> limiter) that eases each track
  toward -14 LUFS (-12..+9 dB). Settings -> Playback -> Level volume: on by
  default on Android/desktop, opt-in on iPhone (Web Audio stops sound when the
  screen locks there).
2026-10-03 07:44:44 +08:00

38 lines
1.6 KiB
JavaScript

'use strict';
const { test } = require('node:test');
const assert = require('node:assert');
globalThis.LOUDNESS_WASM_B64 = require('./loudness-wasm.js');
const L = require('./loudness');
const sine = (amp, freq, fs, secs) => { const a = new Float32Array(fs * secs); for (let i = 0; i < a.length; i++) a[i] = amp * Math.sin(2 * Math.PI * freq * i / fs); return a; };
const dbfs = (db) => Math.pow(10, db / 20);
test('EBU Tech 3341 case: 1 kHz stereo sine at -23 dBFS measures -23.0 LUFS (wasm and js)', () => {
for (const fs of [48000, 44100, 22050]) {
const s = sine(dbfs(-23), 1000, fs, 20);
const w = L.measureChannels([s, s], fs);
const j = L.measureChannels([s, s], fs, { prefer: 'js' });
assert.ok(Math.abs(w - -23) < 0.1, `wasm ${fs}: ${w}`);
assert.ok(Math.abs(j - w) < 1e-6, `js ${j} vs wasm ${w}`);
}
assert.strictEqual(L.engineName(), 'wasm');
});
test('EBU Tech 3341 case: -33 dBFS measures -33.0 LUFS', () => {
const s = sine(dbfs(-33), 1000, 48000, 20);
assert.ok(Math.abs(L.measureChannels([s, s], 48000) - -33) < 0.1);
});
test('gating ignores silence around the music', () => {
const fs = 48000, s = sine(dbfs(-23), 1000, fs, 10);
const padded = new Float32Array(fs * 30); padded.set(s, fs * 10);
// 20 s of silence would read about -27.8 ungated; only the partial 400 ms
// blocks at the tone's edges still count, which is correct R128 behaviour.
assert.ok(Math.abs(L.measureChannels([padded, padded], fs) - -23) < 0.2);
});
test('silence is null', () => {
const z = new Float32Array(48000 * 5);
assert.strictEqual(L.measureChannels([z, z], 48000), null);
});