ffmpegaudiovideo-processing

Normalize audio loudness across a video library

·Javid Jamae·9 min read
Normalize audio loudness across a video library

You have 300 videos in a library and no two of them sit at the same volume. Some clips blow out headphones, others are a whisper, and viewers spend the whole session riding the volume slider. Running every file through the same filter didn't fix it.

Quick answer: To normalize audio loudness across a video library, measure each file with FFmpeg's loudnorm filter in analysis mode (print_format=json), then run a second pass that feeds those measured values back in with a fixed target such as I=-14:TP=-1:LRA=11. Single-pass loudnorm is a live estimator and routinely lands a full LU off target; two-pass is what makes files match each other. Batch it with a shell loop for a few dozen files, or send each file as one API call when the library runs into the hundreds.

Conventional wisdom says inconsistent audio means inconsistent levels, so you normalize the peaks and move on. Most tools produce exactly that, and the library still sounds uneven. It's not that your levels are wrong. It's that peak normalization measures the wrong thing, and the fix people reach for next, one pass of loudnorm, doesn't hit the number it prints.

Peak normalization isn't loudness normalization

Peak normalization finds the loudest sample in a file and scales the whole thing so that sample lands at 0 dBFS or -1 dBFS. That's a measurement of one instant. A soft interview with one door slam and a dense music bed with no transients can have identical peaks and a 12 LU difference in perceived loudness.

EBU R128 measures loudness the way ears do: K-weighted, gated, averaged over the program. The unit is LUFS (identical in scale to LKFS, the term ATSC uses). Two gates run during the measurement, an absolute gate at -70 LUFS and a relative gate 10 LU below the ungated average, so silence and room tone don't drag the number down.

That gating is why a library normalized by peak stays uneven and a library normalized by integrated loudness doesn't. If you're already stripping dead air in your pipeline, note that trimming silence changes the integrated measurement, so measure after the trim, not before.

Measure what your library actually sounds like

Before you normalize anything, get the numbers. This decodes audio only and writes nothing:

ffmpeg -nostats -i input.mp4 -af ebur128=peak=true -f null -

The summary at the end gives Integrated loudness (I), Loudness range (LRA), and True peak. Run it across a sample of 20 files and you'll usually find a 10 to 15 LU spread. That spread is the complaint.

The two-pass loudnorm recipe

Two-pass loudnorm is the accurate way to normalize audio loudness in FFmpeg because the first pass measures the whole program and the second applies a known, fixed gain instead of guessing in real time.

Pass 1: measure

ffmpeg -hide_banner -i input.mp4 \
  -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json \
  -f null - 2>&1 | tail -n 12

You get a JSON block on stderr:

{
  "input_i" : "-21.53",
  "input_tp" : "-4.31",
  "input_lra" : "9.20",
  "input_thresh" : "-32.24",
  "target_offset" : "-0.14"
}

Pass 2: apply

Feed all five values back in and set linear=true so the gain is applied as a single offset rather than a moving compressor:

ffmpeg -i input.mp4 \
  -af loudnorm=I=-14:TP=-1:LRA=11:measured_I=-21.53:measured_LRA=9.20:measured_TP=-4.31:measured_thresh=-32.24:offset=-0.14:linear=true \
  -c:v copy -c:a aac -b:a 192k -ar 48000 \
  output.mp4

-c:v copy matters. There is no reason to re-encode video to change audio, and skipping it turns a 30-second job into a 6-minute one on a 10-minute 1080p file.

Pick the target for where the video lands

One target for the whole library is only right if the whole library goes to one place. These are the numbers people actually deliver against:

DestinationIntegrated targetTrue peak ceiling
YouTube, Spotify (observed playback normalization)-14 LUFS-1 dBTP
Apple Podcasts (published spec)-16 LUFS-1 dBTP
EBU R128 broadcast-23 LUFS-1 dBTP
ATSC A/85 (US broadcast)-24 LKFS-2 dBTP
Netflix delivery-27 LKFS, dialog-gated-2 dBTP

Two honest caveats. YouTube and Spotify don't publish a spec they commit to; -14 LUFS is what their playback normalization converges on and what mastering engineers target. And Netflix measures with dialog gating, which loudnorm does not do, so a -27 LKFS Netflix deliverable is not something plain loudnorm produces.

For a social library, -14 LUFS with TP=-1 is the safe default. Going louder buys nothing because the platform turns you back down, and you keep the limiting damage.

Batching it, and where the loop falls over

For a few dozen files, a shell loop with jq is fine:

#!/usr/bin/env bash
for f in library/*.mp4; do
  json=$(ffmpeg -hide_banner -i "$f" \
    -af loudnorm=I=-14:TP=-1:LRA=11:print_format=json \
    -f null - 2>&1 | sed -n '/^{/,/^}/p')
  I=$(jq -r .input_i <<< "$json")
  LRA=$(jq -r .input_lra <<< "$json")
  TP=$(jq -r .input_tp <<< "$json")
  TH=$(jq -r .input_thresh <<< "$json")
  OFF=$(jq -r .target_offset <<< "$json")
  ffmpeg -hide_banner -y -i "$f" \
    -af "loudnorm=I=-14:TP=-1:LRA=11:measured_I=$I:measured_LRA=$LRA:measured_TP=$TP:measured_thresh=$TH:offset=$OFF:linear=true" \
    -c:v copy -c:a aac -b:a 192k -ar 48000 \
    "out/$(basename "$f")"
done

Two passes per file, serial. At roughly 25 seconds per 10-minute file, 500 files is about three and a half hours of a laptop you can't close, and one corrupt input kills the run at file 212 with Invalid data found when processing input and no record of what already succeeded.

That's the point where the mechanism should change instead of the loop getting better. FFmpeg Micro runs the same filter chain as a job: submit the file, poll or take a webhook, download the output. No FFmpeg install, no jq parsing, no box that has to stay awake, and the fan-out is however many jobs you submit at once. Because it's just an HTTP call, the queue can be a Google Sheet driving n8n, a Make scenario, a Zapier zap, or an MCP tool call from an agent. The full API reference is in the docs, and you can try a single file in the playground before wiring anything.

Common pitfalls

The 192 kHz surprise. loudnorm resamples internally and will happily hand you a 192 kHz output stream if you don't say otherwise. Always set -ar 48000 (or 44100) on the output. Files that suddenly quadrupled in audio size are this bug.

linear=true silently isn't. If the required gain would push true peak past your ceiling, loudnorm falls back to dynamic mode. Check normalization_type in the pass-2 summary. If it says dynamic when you asked for linear, your target is too loud for that source and you should lower I or raise TP.

Mono files read 3 LU quiet. A mono file played on a stereo system is perceptually louder than its R128 measurement suggests. Add dual_mono=true for mono sources or they'll come out consistently too hot.

Normalizing clips before you concatenate. If you normalize each clip to -14 and then stitch, every quiet clip gets pushed up, room tone included, and the assembled video breathes. Normalize the finished timeline instead. Same rule applies when you swap in a new audio track or concatenate clips programmatically: loudness is a property of the final program, not the parts.

LRA set too tight for music. LRA=11 suits talking-head and podcast content. Force a music-heavy piece into LRA=7 and the limiter flattens it. Leave LRA generous, or leave it out and let the source keep its range.

When not to do this

Skip loudness normalization when you're mastering music for release, since a mastering engineer's limiter decisions beat a filter's. Skip it when your deliverable is already spec-compliant, because normalizing a compliant file just adds a lossy re-encode. And if you need dialog-gated measurement for a Netflix or streaming-service deliverable, loudnorm alone won't get you there; that requires a Dolby-based dialog intelligence measurement, and dedicated broadcast QC tools or AWS Elemental MediaConvert's audio normalization modes are the right call.

FAQ

What LUFS should video be for YouTube?

Target -14 LUFS integrated with a -1 dBTP true peak ceiling. YouTube applies playback normalization, so anything louder gets turned down anyway and you keep whatever limiting damage you did to get there.

Does one pass of loudnorm work?

It works, but it doesn't hit the number. Single-pass loudnorm estimates as it goes and commonly lands within about 1 LU of target, which is enough drift to make files in the same library sound different from each other. Use two passes whenever consistency across files is the goal.

How do I check a video's loudness without changing it?

Run ffmpeg -nostats -i input.mp4 -af ebur128=peak=true -f null -. It decodes and reports integrated loudness, loudness range, and true peak, then discards the output. Nothing is written.

What's the difference between LUFS and LKFS?

Nothing in scale. LUFS is the EBU R128 term and LKFS is the ATSC A/85 term for the same K-weighted loudness measurement. A file at -23 LUFS is at -23 LKFS.

Can I normalize audio across a video library without installing FFmpeg?

Yes. Send each file to a video API as a job and let the service run the two-pass measurement and gain application, then download the normalized output. That's the same filter chain without the binary, the queue, or a machine that has to stay online.

If your library is past the point where a shell loop is worth babysitting, the two-pass recipe above runs as one API call per file on the FFmpeg API, with a free tier to test a handful of files first. Sign up free and normalize the noisiest 10 videos in your library before you commit to anything.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free