Normalizing Audio Loudness with FFmpeg loudnorm
Run loudnorm twice: a first pass with print_format=json to measure the upload’s integrated loudness, loudness range, true peak and threshold, then a second pass that feeds those five measured values back with linear=true, so FFmpeg applies one constant gain to reach your target (−16 LUFS for streaming and podcasts, −14 for music platforms) with the true peak capped at −1.5 dBTP.
Users upload audio recorded at every level imaginable: a whispering voice memo at −38 LUFS, a clipped phone recording of a concert at −6, a podcast episode mastered properly at −16. Played back to back in the same feed, the listener rides the volume control between every item. Normalising at processing time fixes that once, for every player. This page belongs to audio processing pipelines in media processing and delivery pipelines. It runs before the encode step in transcoding audio to Opus and AAC, because normalising after lossy encoding stacks two generations of loss.
When to use this approach
- Uploads are played in sequence — a feed, a playlist, a course with many lessons — where level jumps between items are the main complaint.
- You want a measurable standard (LUFS, per ITU-R BS.1770) rather than peak normalisation, which makes quiet-but-spiky recordings no louder at all.
- You process files, not live streams. Two-pass measurement needs the whole file; live audio needs the single-pass dynamic mode and accepts its artefacts.
Prerequisites
- FFmpeg 5.0 or newer (
ffmpeg -filters | grep loudnormlists the filter). - Node 20+ to orchestrate the two passes; the code uses
node:child_processonly. - A target specification: integrated loudness (
I), true peak ceiling (TP) and loudness range (LRA). Defaults below are −16 LUFS, −1.5 dBTP, LRA 11. - The upload already verified as decodable audio — see validating video uploads with ffprobe, which covers audio-only containers too.
What LUFS measures and why peaks are the wrong target
Peak normalisation scales a file so its loudest sample hits a ceiling. A recording with one hand clap and otherwise quiet speech ends up exactly as quiet as before, because the clap already touched the ceiling. Loudness normalisation measures perceived loudness over the whole programme — K-weighted to mimic the ear’s sensitivity, gated to ignore silence — and scales that to a target. Two files at −16 LUFS sound equally loud even if their peaks differ by 10 dB.
Implementation
import { spawn } from "node:child_process";
export interface LoudnessTarget { I: number; TP: number; LRA: number }
export const STREAMING: LoudnessTarget = { I: -16, TP: -1.5, LRA: 11 };
interface Measured {
input_i: string; input_tp: string; input_lra: string; input_thresh: string; target_offset: string;
}
function ffmpeg(args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const p = spawn("ffmpeg", ["-hide_banner", "-nostats", ...args], { stdio: ["ignore", "ignore", "pipe"] });
let err = "";
p.stderr.on("data", (d: Buffer) => { err += d.toString(); });
p.on("error", reject);
p.on("close", (code) => (code === 0 ? resolve(err) : reject(new Error(`ffmpeg ${code}: ${err.slice(-2000)}`))));
});
}
/** Pass 1: measure. loudnorm prints a JSON block to stderr at the end of the run. */
export async function measure(input: string, t: LoudnessTarget = STREAMING): Promise<Measured> {
const stderr = await ffmpeg([
"-i", input, "-vn",
"-af", `loudnorm=I=${t.I}:TP=${t.TP}:LRA=${t.LRA}:print_format=json`,
"-f", "null", "-",
]);
const json = stderr.slice(stderr.lastIndexOf("{"), stderr.lastIndexOf("}") + 1);
return JSON.parse(json) as Measured;
}
/** Pass 2: apply one linear gain computed from the measurement. */
export async function normalize(
input: string, output: string, t: LoudnessTarget = STREAMING,
): Promise<{ measured: Measured; mode: "linear" | "dynamic" }> {
const m = await measure(input, t);
// Silence or near-silence: integrated loudness is -inf or below the gate. Do nothing.
if (!Number.isFinite(Number(m.input_i)) || Number(m.input_i) < -70) {
throw new Error(`input is effectively silent (I=${m.input_i})`);
}
const filter = [
`loudnorm=I=${t.I}:TP=${t.TP}:LRA=${t.LRA}`,
`measured_I=${m.input_i}`,
`measured_TP=${m.input_tp}`,
`measured_LRA=${m.input_lra}`,
`measured_thresh=${m.input_thresh}`,
`offset=${m.target_offset}`,
"linear=true",
"print_format=json",
].join(":");
const stderr = await ffmpeg([
"-y", "-i", input, "-vn",
"-af", filter,
"-ar", "48000", // loudnorm upsamples to 192 kHz internally; bring it back
"-c:a", "pcm_s24le", // lossless intermediate; encode to Opus/AAC afterwards
output,
]);
// loudnorm silently falls back to dynamic mode when linear gain would breach TP.
const out = JSON.parse(stderr.slice(stderr.lastIndexOf("{"), stderr.lastIndexOf("}") + 1));
const mode = String(out.normalization_type).toLowerCase() === "linear" ? "linear" : "dynamic";
return { measured: m, mode };
}
// Usage: node --experimental-strip-types loudnorm.ts in.m4a out.wav
if (process.argv[2] && process.argv[3]) {
const r = await normalize(process.argv[2], process.argv[3]);
console.log(JSON.stringify({ I: r.measured.input_i, TP: r.measured.input_tp, mode: r.mode }));
}
Line-by-line on the parameters that matter
- Two passes with
measured_*fed back. Without measurements,loudnormruns in dynamic mode: it adjusts gain continuously through the file, like a slow compressor. That changes the dynamics of music and makes speech pump. With all four measurements plusoffsetandlinear=true, it can compute one static gain for the whole file — the file sounds exactly the same, just louder or quieter. TP=-1.5. True peak is measured on an oversampled signal, catching inter-sample peaks that a plain sample-peak meter misses. Lossy encoders add overshoot; a ceiling of −1.5 dBTP leaves room so the AAC or Opus encode does not clip on playback. Use −2 if you encode at low bitrates.LRA=11. Target loudness range. It matters only when loudnorm has to fall back to dynamic mode. Raise it (up to 20) for music with wide dynamics to reduce the amount of compression applied.-ar 48000on output. loudnorm resamples to 192 kHz internally for true-peak detection and outputs at that rate unless told otherwise. Forgetting this quadruples the size of your intermediate file and makes the next encoder do an extra resample.pcm_s24leintermediate. Normalisation and encoding are separate steps so the gain is applied to lossless samples. Encoding directly in pass two is possible but couples two concerns and makes retrying the encode require re-running the measurement.- Reading
normalization_type. If the requested gain would push the true peak aboveTP, loudnorm cannot stay linear and switches to dynamic mode without an error. Log the mode: a recording that lands in dynamic mode is quiet with loud transients, and you may prefer to accept a lower integrated loudness (target −18) for it rather than compress it.
When linear mode is impossible
Linear gain is possible only when the measured true peak plus the needed gain stays under the ceiling. A quiet recording with sharp transients — a voice memo with a door slam — needs a lot of gain, and the slam would clip.
The fallback in the caption is a good policy for speech: compute the largest target that keeps linear mode (measured_I + (TP − measured_TP)), and if it is within 3 LU of your standard target, use it. Listeners will not notice −18 next to −16, but they will notice compression artefacts on a voice.
Configuration gotchas
Parsed_loudnorm … Input Integrated: -inf. The file is silent or under the −70 LUFS absolute gate. Passing measured_I=-inf to pass two produces NaN gain and a silent or corrupt output. Detect it after pass one — the code throws — and either skip normalisation or reject the upload.
Output sample rate is 192000 Hz. You did not set -ar. Every downstream tool now resamples, and a 60-minute WAV is 4 GB. Always force the output rate.
loudnorm reports normalization_type: dynamic for loud files. This happens when the measured LRA is larger than the target LRA even if gain is negative. Raise LRA to the measured value (capped at 20) for that file, or accept dynamic mode for music with extreme range.
Multichannel audio sounds wrong after normalising. loudnorm measures and gains all channels together, which is correct for stereo and 5.1 programme. But a two-channel file with different speakers on each channel (a phone call recording) needs pan to separate the channels first and normalise each, or the quiet side stays quiet.
Choosing a target
Verification
# Measure the normalised output: I within ±0.5 of target, TP at or under the ceiling.
ffmpeg -hide_banner -nostats -i out.wav -af loudnorm=print_format=json -f null - 2>&1 \
| sed -n '/{/,/}/p' | grep -E '"input_(i|tp)"'
# "input_i" : "-16.02",
# "input_tp" : "-1.53",
# Confirm the sample rate came back down to 48 kHz.
ffprobe -v error -show_entries stream=sample_rate -of csv=p=0 out.wav
# 48000
In a test suite, run normalize over fixtures covering a quiet memo, a hot concert clip, a well-mastered podcast and a silent file, and assert on the returned mode and on the pass-one measurement of each output.
Frequently Asked Questions
Should I normalise, or just store the measured loudness and let players adjust?
Storing the measurement and applying gain at playback (as ReplayGain and Apple’s Sound Check do) preserves the original file, but it only works in players you control that honour the metadata. For uploads rendered in browsers, social embeds and downloaded files, baking the gain in is the only way every listener gets consistent levels.
Does normalising damage audio quality?
A linear gain change on a 24-bit intermediate is transparent; it is the same as moving a fader. Quality risks come from dynamic mode (audible compression) and from encoding after normalising with too little true-peak headroom (clipping). Linear mode plus a −1.5 dBTP ceiling avoids both.
How long does two-pass take?
Both passes decode the whole file, so roughly twice real-time-divided-by-decode-speed: a 60-minute podcast takes around 20–40 seconds on one core. It is small next to transcription, as in generating captions with Whisper after upload.