bravoh-loudness
v0.1.0
Published
ITU-R BS.1770-4 / EBU R128 loudness meter in pure TypeScript. Integrated LUFS, loudness range, momentary & short-term, true peak. Zero dependencies, streaming or one-shot, browser + Node + CLI.
Maintainers
Readme
bravoh-loudness
ITU-R BS.1770-4 / EBU R128 loudness measurement — integrated LUFS, loudness range, momentary & short-term, true peak — in pure TypeScript with no runtime dependencies.
Every audio product eventually needs the same number: how loud is this, really? Not sample peak — that says nothing about perceived level. Not RMS — that ignores how hearing is weighted. The answer the entire industry standardised on is BS.1770's gated LUFS, and in JavaScript your options have been a WebAssembly wrapper around a C library, or rolling your own and hoping.
bravoh-loudness is the meter we needed at BRAVOH, where every track an artist uploads gets measured before anything else touches it. It is one TypeScript package with zero runtime dependencies — it runs in a browser, in Node, on a worker thread or from the shell — and it is checked against three independent references on every test run.
npm install bravoh-loudness30-second quickstart
In the browser — decode with the platform, measure with us:
import { analyzeLoudness, planNormalization } from "bravoh-loudness";
const buffer = await new AudioContext().decodeAudioData(await file.arrayBuffer());
const channels = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c));
const result = analyzeLoudness(channels, { sampleRateHz: buffer.sampleRate });
// → { integratedLufs: -14.2, loudnessRangeLu: 6.1, truePeakDbtp: -1.0, … }
const plan = planNormalization(result, "streaming");
// → "apply +0.8 dB — lands on -14 LUFS at -0.2 dBTP"In Node — straight from a WAV file:
import { readFileSync } from "node:fs";
import { loudnessFromWav } from "bravoh-loudness";
const result = loudnessFromWav(readFileSync("track.wav"));
console.log(result.integratedLufs, result.truePeakDbtp);From the shell — no install needed:
$ npx bravoh-loudness master.wav --target streaming
master.wav
────────────
integrated -14.0 LUFS
range (LRA) 7.7 LU
short-term max -11.6 LUFS
momentary max -11.4 LUFS
true peak -4.02 dBTP
sample peak -4.03 dBFS
crest 10.0 dB
duration 212.44 s · 44100 Hz · 2 ch
vs Streaming (music) (-14 LUFS / -1 dBTP)
-36 ·····················▓········ -6
apply +0.0 dB — lands on -14 LUFS at -4.0 dBTPAdd --check and it becomes a delivery gate: exit code 1 when the file misses its target, so it drops straight into CI.
$ npx bravoh-loudness episode.wav --target podcast --check --tolerance 0.5 || echo "fix the master"What you get
| Measurement | Notes | |---|---| | Integrated LUFS | Gated per BS.1770-4: −70 LUFS absolute, then −10 LU relative | | Loudness range (LRA) | EBU Tech 3342, 3 s windows at 10 Hz, −20 LU relative gate | | Momentary / short-term | 400 ms and 3 s windows, live values and running maxima | | True peak (dBTP) | Polyphase oversampling, per channel and overall | | Sample peak (dBFS) | The number your DAW shows, for comparison | | Crest factor | True peak above integrated loudness | | Gating report | Relative gate, blocks used vs total — so a reading is explainable |
Everything arrives as one self-describing receipt you can store, diff and version:
{
"version": "loudness.v1",
"integratedLufs": -23.4972,
"loudnessRangeLu": 1.9876,
"momentaryMaxLufs": -20.3662,
"shortTermMaxLufs": -23.52,
"truePeakDbtp": -11.8072,
"samplePeakDbfs": -11.8162,
"crestDb": 11.69,
"channelTruePeaksDbtp": [-11.8072],
"sampleRateHz": 44100,
"channels": 1,
"frameCount": 352800,
"durationMs": 8000,
"gating": {
"absoluteGateLufs": -70,
"relativeGateLufs": -35.0939,
"blocksUsed": 48,
"blocksTotal": 77
}
}(That is test/fixtures/programme-dynamic.wav, verbatim — npx bravoh-loudness <file> --json prints it.)
Verified against three references, on every test run
A loudness meter that hasn't been checked against something is a plausible-looking number generator. This one is pinned down three ways, and all of it runs in npm test:
1. The EBU conformance suite, generated from the standards. EBU Tech 3341 and 3342 define programmes a conforming meter must land on within a stated tolerance. They are tones and level steps, so we generate them rather than downloading WAVs — the whole suite runs in-process, at any sample rate, with nothing committed to the repo. src/ebu.ts exports them, because they are useful to anyone testing a meter:
import { COMPLIANCE_CASES, analyzeLoudness } from "bravoh-loudness";
for (const testCase of COMPLIANCE_CASES) {
const programme = testCase.build(); // e.g. −36 dBFS 10 s, −23 dBFS 60 s, −36 dBFS 10 s
const result = analyzeLoudness(programme.channels, { sampleRateHz: programme.sampleRateHz });
// testCase.expected → { integratedLufs: -23, toleranceLu: 0.1 }
}2. libebur128, via ffmpeg — the C implementation most of the industry actually runs.
3. pyloudnorm — the Python meter BRAVOH's own production DSP service uses, so the JS answer and the server answer are the same answer.
scripts/verify-references.mjs measures every case with all three and writes test/golden/reference-parity.json. The committed golden lets npm test re-verify the agreement on machines that have neither tool installed; CI re-derives it against a freshly installed ffmpeg so the claim can't go stale.
Measured deltas across the full suite (ours − reference):
| | integrated LUFS | loudness range | true peak | |---|---|---|---| | vs ffmpeg / libebur128 | ≤ 0.02 LU | ≤ 0.02 LU | ≤ 0.05 dB | | vs pyloudnorm | ≤ 0.05 LU | — (doesn't compute it) | — (doesn't compute it) | | vs EBU Tech 3341/3342 expected | ≤ 0.03 LU (tolerance ±0.1) | exact (tolerance ±1.0) | — |
ffmpeg prints one decimal place, which is the resolution floor for those comparisons.
The true-peak path gets an independent check that needs no reference implementation at all: a tone at fs/4 with 45° of phase never lands on its own crest, so every sample sits exactly 3.01 dB below the analog peak. The file says −4.01 dBFS. The waveform reaches −1 dBTP. We read −0.998.
The bug that is easy to write and hard to see
The oversampler is where a true-peak meter quietly goes wrong. Ours did, for an afternoon.
Build the polyphase interpolator the obvious way — a windowed sinc centred at (taps − 1) / 2 — and the filter's group delay lands on a fractional number of input samples. Every interpolated point is then offset from where you think it is, so the oversampled grid never quite sits on the waveform's crests. A half-Nyquist tone reads 0.17 dB light, and no amount of extra taps fixes it, because taps were never the problem.
Centre the sinc on a tap index that is an exact multiple of the oversampling factor instead: the delay becomes a whole number of samples, branch 0 comes out as a pure impulse, and the same tone reads to three decimal places. The test that catches it asserts branch 0 is an impulse — a property, not a number.
Streaming, not just files
The meter is incremental. Feed it whatever your audio graph hands you, in whatever sizes; the reading is identical to the one-shot call to nine decimal places (there's a test).
import { LoudnessMeter } from "bravoh-loudness";
const meter = new LoudnessMeter({ sampleRateHz: 48_000, channels: 2 });
processor.onaudioprocess = (event) => {
const input = event.inputBuffer;
meter.push([input.getChannelData(0), input.getChannelData(1)]);
display.textContent = `M ${meter.momentaryLufs.toFixed(1)} S ${meter.shortTermLufs.toFixed(1)} I ${meter.integratedLufs.toFixed(1)}`;
};State costs a handful of numbers per 100 ms of programme, not a copy of the audio — about 100 000 numbers, under a megabyte, for an hour of stereo. pushInterleaved() takes L R L R … directly.
Normalisation that accounts for the ceiling
Multiplying until the loudness matches is how a −14 LUFS master arrives at the encoder clipping. planNormalization returns both numbers — what the loudness asks for, and what the true-peak ceiling actually allows:
const plan = planNormalization({ integratedLufs: -18, truePeakDbtp: -2 }, "streaming");
// {
// loudnessGainDb: 4, // what -14 LUFS wants
// gainDb: 1, // what -1 dBTP permits
// peakLimited: true,
// limiterGainReductionDb: 3, // what a limiter still has to absorb
// summary: "apply +1.0 dB to reach -1 dBTP; +3.0 dB more needs a limiter to hit -14 LUFS"
// }Built-in targets: ebu-r128 (−23 / −1), atsc-a85 (−24 / −2), streaming (−14 / −1), apple-music (−16 / −1), podcast (−16 / −1). Broadcast figures come from published standards; the streaming figures are platform conventions that move, so each target carries a source string and you can pass your own object instead of a name.
Channels
BS.1770 sums channel energy — it does not average it. The same programme in mono reads 3.01 LU below its stereo version, surrounds are weighted +1.5 dB, and the LFE is excluded entirely.
analyzeLoudness(channels, { sampleRateHz: 48_000, layout: "5.1" });
// or, for anything unusual:
analyzeLoudness(channels, {
sampleRateHz: 48_000,
channelRoles: ["left", "right", "centre", "lfe", "leftSurround", "rightSurround"],
});Mono, stereo, 5.1 and 7.1 are inferred from the channel count. Anything else should pass channelRoles — guessing is how a surround stem ends up weighted like a front one.
Where we differ, on purpose
Nothing here is hidden in a footnote:
- Programmes shorter than ~4 s report
loudnessRangeLu: 0. Loudness range is defined over 3 s windows; with fewer than two of them there is no range to report. ffmpeg extrapolates from partial windows and will print a number (on our 3 s fixture it says 20 LU). We'd rather say nothing than say something unfounded —gating.blocksTotaltells you why. - Silence is
-Infinity, not a magic-60. The CLI's--jsonemitsnullfor non-finite values, becauseJSON.stringifywould do it silently otherwise. - 20 kHz tones read up to ~0.22 dB light at the default 4× oversampling. That ceiling is the grid, not the filter — every 4× meter has it, BS.1770-4 allows ±0.4 dB, and
{ truePeak: { oversampleFactor: 8 } }halves it. Below 15 kHz we're within 0.03 dB of the analytic answer. - We're ~0.04 LU above pyloudnorm and ~0.01 LU from libebur128. Both are inside every stated tolerance; we simply sit closer to the C reference.
Speed
A 3-minute stereo master at 48 kHz, on an M4 Max, Node 22:
| | time | vs realtime |
|---|---|---|
| full measurement incl. true peak | 1.08 s | 167× |
| loudness only (truePeak: false) | 0.11 s | 1646× |
Peak detection dominates, so chunks that can't beat the running peak are skipped without convolving — bounded by the filter's L1 norm, which makes the shortcut exact rather than a guess. Memory stays flat at a few MB regardless of length.
API
// one-shot
analyzeLoudness(channels, options): LoudnessResult
loudnessFromWav(bytes, options?): LoudnessResult
// streaming
new LoudnessMeter(options)
.push(channels) / .pushInterleaved(samples)
.momentaryLufs / .shortTermLufs / .integratedLufs / .loudnessRangeLu
.truePeakDbtp / .samplePeakDbfs / .channelTruePeaksDbtp
.result() / .reset()
// delivery
planNormalization(result, target): NormalizationPlan
planGainFactor(plan): number
applyGain(channels, gainDb): void
TARGETS
// building blocks, exported because they're useful on their own
designKWeighting(sampleRateHz) / kWeightingMagnitudeDb(hz, sampleRateHz)
TruePeakDetector / designPolyphaseInterpolator(factor, tapsPerPhase)
COMPLIANCE_CASES / levelSteppedSine / steadySine
decodeWav / encodeWavFull types in src/types.ts. ESM and CJS, .d.ts included, Node 18+, no polyfills.
What this is not
- Not a decoder. WAV in (8/16/24/32-bit PCM, 32/64-bit float,
WAVE_FORMAT_EXTENSIBLE) is included because it makes the CLI work. For MP3/AAC/OGG, decode with the platform and hand us the PCM — keeping codecs out is what keeps the dependency count at zero. - Not a limiter. It tells you the gain and what a limiter would have to absorb; it doesn't process audio.
- Not a replacement for your DAW's meter. It's the same standard, in the place where you can automate it.
Contributing
Issues and PRs welcome — the good first issues are real work, not busywork. Before pushing:
./gates.sh # secret scan, typecheck, tests, build, CLI smoke, pack sanityRegenerating the reference golden needs ffmpeg, and optionally a Python env with pyloudnorm:
npm run build
PYLOUDNORM_PYTHON=/path/to/venv/bin/python node scripts/verify-references.mjsMIT © BRAVOH. Part of BRAVOH open source — the instruments are yours: bravoh-daw (Rust DAW project-file parsers) · bravoh-wire (typed WebSocket frames for streaming AI chat) · bravoh-peaks (waveform peaks, zero deps).
