npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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.

npm npm downloads zero dependencies EBU Tech 3341 types license

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-loudness

30-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 dBTP

Add --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.blocksTotal tells you why.
  • Silence is -Infinity, not a magic -60. The CLI's --json emits null for non-finite values, because JSON.stringify would 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 / encodeWav

Full 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 sanity

Regenerating 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.mjs

MIT © 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).