To normalize audio loudness with ffmpeg, run it through the loudnorm filter with a target: ffmpeg -i in.wav -af loudnorm=I=-14:TP=-1.5:LRA=11 out.wav. It measures loudness the EBU R128 way while it plays the file, and turns the audio up or down towards an integrated loudness (I) of -14 LUFS, keeps true peaks (TP) under -1.5 dBTP with a limiter, and aims for a loudness range (LRA) of at most 11 LU.

ffmpeg -i in.wav -af loudnorm=I=-14:TP=-1.5:LRA=11 out.wav

That is the one-pass form. It is quick and it is the one most answers give, but it does not know the whole file in advance, so it is guessing as it goes. The rest of this page measures how close it gets on four kinds of input, then does the same with the two-pass method, and shows where two passes still miss. Every number below came from running the commands on this page with ffmpeg 4.4.2, the version Ubuntu 22.04 ships, in an empty directory.

About -14. It is the default of the Shorts ffmpeg command builder on this site and the example target on this page. It is not presented here as a YouTube rule: no Google or YouTube page was opened for this post, so this page makes no claim about what YouTube does to loudness. Pick the target your platform or client documents; the method is the same.

First gotcha: one pass writes 192 kHz

Check the sample rate of the file that command wrote, then do it again with -ar 48000:

ffprobe -v error -show_entries stream=sample_rate -of csv=p=0 out.wav
ffmpeg -hide_banner -loglevel error -y -i in.wav -af loudnorm=I=-14:TP=-1.5:LRA=11 -ar 48000 out48.wav
ffprobe -v error -show_entries stream=sample_rate -of csv=p=0 out48.wav
192000
48000

The input was 48 kHz; the plain command wrote 192 kHz, four times the data for nothing. The ffmpeg filter documentation says why: in dynamic mode, “to accurately detect true peaks, the audio stream will be upsampled to 192 kHz”, and it tells you to use “the -ar option or aresample filter to explicitly set an output sample rate”. Every other command on this page adds -ar 48000.

Four test inputs you can rebuild

Real recordings vary too much to show what the filter does, so these are synthesized by ffmpeg itself with lavfi, no files to download:

# (a) speech-like: pink noise in 1.5 s bursts, 1 s of silence between, level drifting
ffmpeg -hide_banner -loglevel error -y \
  -f lavfi -i "anoisesrc=color=pink:amplitude=0.5:seed=1:sample_rate=48000:duration=20" \
  -af "volume='if(lt(mod(t,2.5),1.5),0.3+0.25*sin(2*PI*t/7),0)':eval=frame" speech.wav
# (b) music-like bed: a three-note chord with a slow 8 s swell, over quiet pink noise
ffmpeg -hide_banner -loglevel error -y \
  -f lavfi -i "aevalsrc='(0.8+0.2*sin(2*PI*t/8))*(0.08*sin(2*PI*220*t)+0.08*sin(2*PI*277.2*t)+0.08*sin(2*PI*329.6*t))':s=48000:d=20" \
  -f lavfi -i "anoisesrc=color=pink:amplitude=0.05:seed=2:sample_rate=48000:duration=20" \
  -filter_complex "amix=inputs=2:normalize=0" bed.wav
# (c) very quiet: the speech-like clip turned down 25 dB
ffmpeg -hide_banner -loglevel error -y -i speech.wav -af volume=-25dB quiet.wav
# (d) loud peak: the bed turned down 12 dB, plus one 5 ms full-scale blip at 10 s
ffmpeg -hide_banner -loglevel error -y -i bed.wav \
  -f lavfi -i "aevalsrc='0.99*between(t,10,10.005)*sin(2*PI*1000*t)':s=48000:d=20" \
  -filter_complex "[0:a]volume=-12dB[b];[b][1:a]amix=inputs=2:normalize=0" peak.wav

All four are 20 seconds, mono, 48 kHz. The fixed noise seeds make every run identical, which is what lets the test behind this page re-run them and expect the same numbers.

Measure: integrated loudness, true peak, loudness range

The ebur128 filter with peak=true prints a summary at the end of its log. This shell function keeps the three numbers that matter, so every result on this page is one line:

measure() {
  ffmpeg -hide_banner -nostats -i "$1" -af ebur128=peak=true -f null - 2>&1 |
    awk -v f="$1" '/Integrated loudness:/{s=1} s&&/ I:/{i=$2} s&&/LRA:/{l=$2} s&&/Peak:/{p=$2}
      END{print f, "I=" i, "TP=" p, "LRA=" l}'
}
for f in speech bed quiet peak; do measure $f.wav; done
speech.wav I=-29.6 TP=-12.4 LRA=9.8
bed.wav I=-22.5 TP=-11.4 LRA=3.2
quiet.wav I=-54.5 TP=-37.4 LRA=9.8
peak.wav I=-33.2 TP=0.0 LRA=7.6

I is integrated loudness in LUFS, TP is true peak (ebur128 labels it dBFS; with peak=true it is the oversampled true peak, so dBTP), and LRA is loudness range in LU. The speech-like clip and its quiet copy have the same 9.8 LU range, as they should: turning a file down moves its loudness, not its spread. The blip in (d) is 5 milliseconds long and barely moves the integrated loudness, but it puts the true peak at 0.0.

One pass

for f in speech bed quiet peak; do
  ffmpeg -hide_banner -loglevel error -y -i $f.wav \
    -af loudnorm=I=-14:TP=-1.5:LRA=11 -ar 48000 $f-1pass.wav
  measure $f-1pass.wav
done
speech-1pass.wav I=-15.6 TP=-1.4 LRA=7.3
bed-1pass.wav I=-14.2 TP=-3.0 LRA=3.2
quiet-1pass.wav I=-15.6 TP=-1.4 LRA=7.8
peak-1pass.wav I=-15.1 TP=-1.5 LRA=3.4

Two passes, with python3 parsing pass 1’s JSON

The two-pass method runs loudnorm once only to measure (print_format=json), then feeds the five numbers it printed back in as measured_I, measured_TP, measured_LRA, measured_thresh and offset, with linear=true. With all of those known, loudnorm can apply one fixed gain to the whole file, which is what “linear” means, instead of riding the level.

Most examples parse the JSON with jq. This machine has no jq, but it has python3, so the parsing is python3 here; it is in the standard library and present on most Linux boxes and macOS. Paste this function into your shell:

loudnorm2() {
  in="$1"; out="$2"; target="I=-14:TP=-1.5:LRA=11"
  # Pass 1: measure only. loudnorm prints its JSON at the very end of stderr.
  stats=$(ffmpeg -hide_banner -nostats -i "$in" \
    -af "loudnorm=$target:print_format=json" -f null - 2>&1)
  # Pull the five values out with python3 (this machine has no jq).
  measured=$(printf '%s' "$stats" | python3 -c '
import json, sys
s = sys.stdin.read()
m = json.loads(s[s.rindex("{"):])
print("measured_I={input_i}:measured_TP={input_tp}:measured_LRA={input_lra}"
      ":measured_thresh={input_thresh}:offset={target_offset}".format(**m))')
  echo "$in $measured"
  # Pass 2: apply them, linear if loudnorm can; print the mode it really used.
  ffmpeg -hide_banner -nostats -y -i "$in" \
    -af "loudnorm=$target:$measured:linear=true:print_format=json" \
    -ar 48000 "$out" 2>&1 |
    python3 -c 'import json, sys; s = sys.stdin.read()
print(sys.argv[1], json.loads(s[s.rindex("{"):])["normalization_type"])' "$out"
}

The JSON is the last thing ffmpeg writes, and it is flat, so slicing from the last { is enough. The pass-2 JSON carries a field, normalization_type, that says whether loudnorm really ran linear or quietly fell back to dynamic; the function prints it, because linear=true is a request, not a guarantee. Usage is loudnorm2 in.wav out.wav. Run on the four inputs:

for f in speech bed quiet peak; do
  loudnorm2 $f.wav $f-2pass.wav
  measure $f-2pass.wav
done
speech.wav measured_I=-29.95:measured_TP=-12.39:measured_LRA=9.80:measured_thresh=-41.09:offset=1.60
speech-2pass.wav dynamic
speech-2pass.wav I=-15.3 TP=-1.4 LRA=5.8
bed.wav measured_I=-22.36:measured_TP=-11.45:measured_LRA=3.10:measured_thresh=-32.36:offset=0.18
bed-2pass.wav linear
bed-2pass.wav I=-14.1 TP=-3.1 LRA=3.2
quiet.wav measured_I=-54.92:measured_TP=-37.39:measured_LRA=9.80:measured_thresh=-65.46:offset=1.61
quiet-2pass.wav dynamic
quiet-2pass.wav I=-15.4 TP=-1.4 LRA=6.2
peak.wav measured_I=-33.25:measured_TP=0.03:measured_LRA=7.60:measured_thresh=-43.25:offset=1.10
peak-2pass.wav dynamic
peak-2pass.wav I=-14.0 TP=-1.5 LRA=3.4

The measured table

Target I=-14:TP=-1.5:LRA=11. Each cell is integrated loudness (LUFS) / true peak (dBTP) / loudness range (LU), from ebur128=peak=true on the file.

InputBeforeOne passTwo passesPass 2 ran
(a) speech-like, with silences-29.6 / -12.4 / 9.8-15.6 / -1.4 / 7.3-15.3 / -1.4 / 5.8dynamic
(b) steady music-like bed-22.5 / -11.4 / 3.2-14.2 / -3.0 / 3.2-14.1 / -3.1 / 3.2linear
(c) very quiet-54.5 / -37.4 / 9.8-15.6 / -1.4 / 7.8-15.4 / -1.4 / 6.2dynamic
(d) one loud peak-33.2 / 0.0 / 7.6-15.1 / -1.5 / 3.4-14.0 / -1.5 / 3.4dynamic

What it shows, as measured:

  • One pass undershot on every input, most on the dynamic ones. The speech-like clip came out 1.6 LU short of -14, the quiet clip 1.6 LU short, the clip with the peak 1.1 LU short, and the steady bed only 0.2 LU short. None overshot.
  • Two passes only fixed it where linear mode was allowed, or where the limiter had little to do. The bed ran linear and landed 0.1 LU from the target. The clip with the peak ran dynamic but hit -14.0 exactly. The speech-like and quiet clips also ran dynamic, and were still 1.3 and 1.4 LU short.
  • The true-peak limit held to within 0.1 dB. The target was -1.5; ebur128 read -1.4 on the dynamic speech-like and quiet outputs, so the limiter let them through 0.1 dB over, and -1.5 on the clip with the peak.
  • Dynamic mode squeezed the loudness range even though it was already under 11. The speech-like clip’s 9.8 LU became 7.3 after one pass and 5.8 after two. If you want the dynamics of a voice kept, that is a reason to want linear mode.

Why two passes fell back to dynamic

The filter documentation gives the conditions for linear: “Target LRA shouldn’t be lower than source LRA and the change in integrated loudness shouldn’t result in a true peak which exceeds the target TP. If any of these conditions aren’t met, normalization mode will revert to dynamic.”

Do that arithmetic on the speech-like clip’s pass-1 numbers. Reaching -14 from -29.95 takes 15.95 dB of gain. Its true peak of -12.39 would rise with it to 3.56 dBTP, far over the -1.5 limit, so a single fixed gain is not allowed and loudnorm rides the level instead. The quiet clip is the same story at a bigger gain, and the clip with the 0.03 dBTP blip has no headroom at all. The bed needed only 8.36 dB, which puts its peak at -3.09 dBTP, under the limit; it ran linear, and ebur128 read the result at -3.1, as predicted. In linear mode the offset you pass is not used: the 4.4.2 source sets the gain to target minus measured_I.

There is one more trap, and it is not in the documentation. A perfectly steady signal has a loudness range of 0, and loudnorm reads measured_LRA=0 as “not given”, so it will not run linear on it:

ffmpeg -hide_banner -loglevel error -y \
  -f lavfi -i "aevalsrc='0.08*sin(2*PI*220*t)+0.08*sin(2*PI*277.2*t)+0.08*sin(2*PI*329.6*t)':s=48000:d=20" \
  chord.wav
measure chord.wav
loudnorm2 chord.wav chord-2pass.wav
measure chord-2pass.wav
chord.wav I=-21.0 TP=-12.4 LRA=0.0
chord.wav measured_I=-21.05:measured_TP=-12.43:measured_LRA=0.00:measured_thresh=-31.05:offset=-0.04
chord-2pass.wav dynamic
chord-2pass.wav I=-14.0 TP=-5.4 LRA=0.0

The gain it needed, 7.05 dB, was well within the peak limit, and it still ran dynamic. In the source of af_loudnorm.c at 4.4.2, linear mode requires s->measured_lra != 0 alongside the other checks, and the same test is in the current master. Here the dynamic result still hit -14.0, so it did no harm; it is worth knowing when normalization_type surprises you on a test tone or a drone.

loudnorm vs dynaudnorm

They are not two ways of doing the same thing. loudnorm targets a loudness standard: integrated loudness, true peak and range, in LUFS. dynaudnorm, per the filter documentation, “applies a certain amount of gain to the input audio in order to bring its peak magnitude to a target level”, re-adjusted frame by frame (500 ms frames by default, smoothed over a 31-frame Gaussian window) so that quiet and loud sections are each brought up towards the same peak of 0.95. It has no LUFS target at all. One measured row, with dynaudnorm at its defaults on the speech-like clip:

ffmpeg -hide_banner -loglevel error -y -i speech.wav -af dynaudnorm -ar 48000 speech-dynaudnorm.wav
measure speech-dynaudnorm.wav
speech-dynaudnorm.wav I=-24.0 TP=-4.8 LRA=15.1
Speech-like clipI (LUFS)TP (dBTP)LRA (LU)
Before-29.6-12.49.8
loudnorm, two passes-15.3-1.45.8
dynaudnorm, defaults-24.0-4.815.1

dynaudnorm made it louder, but to -24.0, a number nobody asked for, and on this input it widened the loudness range from 9.8 to 15.1 LU rather than evening it out. The documentation’s own description of it as evening out quiet and loud sections did not show up in that LRA figure here; I did not dig into why. Use loudnorm when the job is “hit a loudness target”; reach for dynaudnorm when a recording has quiet and loud passages you want brought closer, and measure the result.

Normalize the audio inside a video without re-encoding the picture

Map the video through with -c:v copy and run loudnorm on the audio only. The audio has to be re-encoded (a filter needs decoded samples), the picture does not:

# A 10 s 720x1280 test clip with the speech-like track as its sound.
# -threads 1 is only there so the video hash below is identical on every run.
ffmpeg -hide_banner -loglevel error -y \
  -f lavfi -i "testsrc2=size=720x1280:rate=30:duration=10" -i speech.wav \
  -t 10 -c:v libx264 -threads 1 -crf 23 -pix_fmt yuv420p -c:a aac -b:a 192k clip.mp4
# Normalize the sound, copy the picture as it is.
ffmpeg -hide_banner -loglevel error -y -i clip.mp4 -c:v copy \
  -af loudnorm=I=-14:TP=-1.5:LRA=11 -ar 48000 -c:a aac -b:a 192k clip-norm.mp4
for f in clip.mp4 clip-norm.mp4; do
  ffprobe -v error -select_streams v:0 -count_frames \
    -show_entries stream=codec_name,nb_read_frames,bit_rate -of csv=p=0 "$f"
  ffmpeg -loglevel error -i "$f" -map 0:v -c copy -f md5 -
  measure "$f"
done
h264,3149084,300
MD5=270615f27d6f95aaccd509529cb16823
clip.mp4 I=-28.8 TP=-13.6 LRA=13.1
h264,3149084,300
MD5=270615f27d6f95aaccd509529cb16823
clip-norm.mp4 I=-14.9 TP=-1.3 LRA=8.2

The ffprobe line is codec, bit rate in bits per second, and frame count: h264, 3149084, 300 frames, before and after. The MD5 line hashes every compressed video packet, and it is identical, so the picture was not touched at all. The sound went from -28.8 to -14.9 LUFS in one pass. Note the true peak: -1.3 dBTP, over the -1.5 limit by 0.2 dB, because the limiter worked on the samples and the AAC encoder added its own overshoot after it. If you need a hard ceiling in a lossy file, set TP a little lower than the ceiling you need.

Where the docs and ffmpeg 4.4.2 differ

The option names and defaults above are from ffmpeg-filters.html#loudnorm and agree with ffmpeg -h filter=loudnorm on 4.4.2 (defaults I -24, TP -2, LRA 7, linear true), with two differences:

  • LRA range. Today’s documentation says LRA accepts 1.0 - 50.0. The 4.4.2 binary says “from 1 to 20” and rejects anything above.
  • stats_file. Today’s documentation lists a stats_file option that writes the stats to a file. 4.4.2 has no such option, which is why the script above reads the JSON from stderr; on a newer ffmpeg you could write it to a file instead.

What this test does not tell you

  • Pink-noise bursts are not a voice, and a chord is not a mix. They stand in for “dynamic, with pauses” and “steady”, which is what decides how loudnorm behaves. A real narration will land at its own distance from the target; measure yours with the measure function.
  • Only ffmpeg 4.4.2 was tested. The numbers were produced on that version only. Other builds may round or limit differently.
  • 20-second clips. Integrated loudness and LRA settle better on longer material, and one-pass loudnorm has more to go on in a longer file.

Where this came from

The YouTube Shorts automation on a Raspberry Pi write-up sets the voice, sound effects and music by gain and never states a final loudness. The Shorts ffmpeg command builder adds one-pass loudnorm=I=-14:TP=-1.5:LRA=11 with -ar 48000 to the command it writes, and says it does not check that loudnorm hits exactly -14 LUFS. This page is that check: on dynamic material one pass came out 1.1 to 1.6 LU short, and the two-pass function above is the way to get closer, as long as you read what normalization_type says it did.

← Back to all posts

Follow new posts by RSS: paste dankdev.com/feed.xml into any feed reader. It is a plain XML file — no signup, no email address, no account. Posts land there the same day they go up, and there is nothing to unsubscribe from later.

Back to the top