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

  1. FFmpeg 5.0 or newer (ffmpeg -filters | grep loudnorm lists the filter).
  2. Node 20+ to orchestrate the two passes; the code uses node:child_process only.
  3. A target specification: integrated loudness (I), true peak ceiling (TP) and loudness range (LRA). Defaults below are −16 LUFS, −1.5 dBTP, LRA 11.
  4. 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.

Peak normalisation versus loudness normalisation on three uploads Three uploads measured at minus 31, minus 9 and minus 19 LUFS. After peak normalisation to 0 dBFS they sit at minus 29, minus 9 and minus 14 LUFS, still far apart. After loudness normalisation all three sit at minus 16 LUFS. Integrated loudness after each strategy (LUFS) as uploaded peak-normalised loudnorm −16 −16 target −31 −9 −19 −29 −9 −14 −16 −16 −16 Peak normalisation leaves a 20 dB spread between items; loudness normalisation removes it. Higher on the chart = louder. Voice memo (amber), clipped concert (orange), podcast (gold).
Only the loudness measurement matches what a listener hears, so only it can make consecutive items sound equally loud.

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, loudnorm runs 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 plus offset and linear=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 48000 on 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_s24le intermediate. 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 above TP, 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.

Headroom check that decides linear versus dynamic mode A voice memo measures minus 31 LUFS with a true peak of minus 6 dBTP. Reaching minus 16 LUFS needs plus 15 dB of gain, which would put the peak at plus 9 dBTP, above the minus 1.5 ceiling, so loudnorm falls back to dynamic mode. A podcast at minus 19 LUFS with a minus 4 peak needs plus 3 dB, reaching minus 1 dBTP, which is also over, but lowering the target to minus 17 keeps it linear. gain needed + measured peak ≤ ceiling ? upload measured I / TP gain peak after result voice memo −31 / −6.0 +15 dB +9.0 dBTP dynamic podcast −19 / −4.0 +3 dB −1.0 dBTP dynamic concert clip −9 / −0.1 −7 dB −7.1 dBTP linear Turning a file DOWN is always linear. Turning a spiky file UP is where dynamic mode appears. Fallback option: retry with I = measured I + (TP − measured TP) − 0.1 to stay linear at a lower target.
The headroom arithmetic is known after pass one, so you can choose the fallback yourself instead of letting loudnorm choose it silently.

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

Common integrated loudness targets on one scale A horizontal scale from minus 26 to minus 12 LUFS marks broadcast EBU R128 at minus 23, Apple Podcasts at minus 16, general web streaming at minus 16, and music streaming platforms at minus 14. Where common targets sit (LUFS) −26 −22 −18 −14 EBU R128 −23 podcasts / web −16 music streaming −14 Pick −16 for mixed user uploads played on phones and laptops; −23 only if you feed broadcast.
Web and podcast playback converged on about −16 LUFS; broadcast standards are quieter because TV has more headroom to spare.

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.