Нормализация громкости в FFmpeg (двухпроходный loudnorm)
Что это?
Скрипт на основе FFmpeg устанавливается в MacOS как набор команд в папке ~/bin: lufs14, lufs16 и lufs19. Он позволяет нормализовать финальные аудиофайлы подкаста или видео без сторонних программ: достаточно вызвать нужную команду с путём к файлу. Внутри выполняются два прохода FFmpeg: первый анализирует громкость и динамику, второй применяет точные параметры для достижения целевых уровней −14 LUFS (громче, под ролики и соцсети), −16 LUFS (стандарт стерео-подкастов) или −19 LUFS (моно). В результате получается готовый по уровню и балансу MP3-файл, оптимизированный под разные сценарии публикации.
Требования
- MacOS с Homebrew
- python3 в системе (обычно уже есть на macOS)
Установка FFmpeg
1brew install ffmpeg
2ffmpeg -versionНастройка: команды в ~/bin
Создай папку для личных команд (если её ещё нет):
1mkdir -p ~/binСоздай скрипт ~/bin/lufs и вставь в него код:
1cat > ~/bin/lufs <<'ZSH'
2#!/bin/zsh
3set -euo pipefail
4
5# Defaults
6BITRATE="192k"
7SR="44100"
8LRA="11"
9TP="-1.5"
10
11# Command can be invoked as lufs14/lufs16/lufs19 via symlink
12cmd="$(basename "$0")"
13
14target=""
15force_mono=0
16
17case "$cmd" in
18 lufs14) target="-14"; force_mono=0 ;;
19 lufs16) target="-16"; force_mono=0 ;;
20 lufs19) target="-19"; force_mono=1 ;;
21 lufs)
22 if [[ "$#" -lt 2 ]]; then
23 echo "Usage: lufs <-14|-16|-19> <input-file> [--mono]"
24 exit 1
25 fi
26 target="$1"; shift
27 ;;
28 *)
29 echo "Unknown command name: $cmd"
30 exit 1
31 ;;
32esac
33
34in="${1:-}"
35opt="${2:-}"
36
37if [[ -z "${in}" ]]; then
38 echo "No input file."
39 echo "Example: lufs16 \"episode.wav\""
40 exit 1
41fi
42
43# Support file:// paths
44in="${in#file://}"
45
46if [[ ! -f "$in" ]]; then
47 echo "Not a file: $in"
48 exit 1
49fi
50
51if [[ "${opt:-}" == "--mono" ]]; then
52 force_mono=1
53fi
54
55command -v ffmpeg >/dev/null 2>&1 || { echo "Need ffmpeg"; exit 1; }
56command -v python3 >/dev/null 2>&1 || { echo "Need python3"; exit 1; }
57
58dir="$(dirname "$in")"
59base="$(basename "$in")"; base="${base%.*}"
60
61suffix="${target#-}LUFS_${BITRATE}"
62out="${dir}/${base}_${suffix}.mp3"
63if [[ "$force_mono" -eq 1 ]]; then
64 out="${dir}/${base}_${suffix}_mono.mp3"
65fi
66
67echo "Pass 1: measure loudness..."
68if [[ "$force_mono" -eq 1 ]]; then
69 pass="$(ffmpeg -hide_banner -nostats -y -vn -i "$in" \
70 -af "aformat=channel_layouts=mono,loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
71 -f null - 2>&1)"
72else
73 pass="$(ffmpeg -hide_banner -nostats -y -vn -i "$in" \
74 -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
75 -f null - 2>&1)"
76fi
77
78json_block="$(echo "$pass" | sed -n '/^{/,$p' | sed -n '1,/^}/p')"
79
80read -r I TPm LRAi THRESH OFFSET < <(python3 - <<'PY'
81import sys, json, re
82s = sys.stdin.read()
83m = re.search(r'^\{.*\}\s*$', s, re.S | re.M)
84if not m:
85 raise SystemExit(2)
86d = json.loads(m.group(0))
87keys = ["input_i","input_tp","input_lra","input_thresh","target_offset"]
88print(" ".join(str(d.get(k,"")) for k in keys))
89PY
90<<< "$json_block")
91
92if [[ -z "${I:-}" || -z "${TPm:-}" || -z "${LRAi:-}" || -z "${THRESH:-}" || -z "${OFFSET:-}" ]]; then
93 echo "Failed to parse loudnorm metrics."
94 echo "$pass"
95 exit 1
96fi
97
98echo "Pass 2: normalize to ${target} LUFS..."
99if [[ "$force_mono" -eq 1 ]]; then
100 ffmpeg -hide_banner -y -vn -i "$in" \
101 -af "aformat=channel_layouts=mono,loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:measured_I=${I}:measured_TP=${TPm}:measured_LRA=${LRAi}:measured_thresh=${THRESH}:offset=${OFFSET}" \
102 -ar "${SR}" -ac 1 -c:a libmp3lame -b:a "${BITRATE}" "$out"
103else
104 ffmpeg -hide_banner -y -vn -i "$in" \
105 -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:measured_I=${I}:measured_TP=${TPm}:measured_LRA=${LRAi}:measured_thresh=${THRESH}:offset=${OFFSET}" \
106 -ar "${SR}" -c:a libmp3lame -b:a "${BITRATE}" "$out"
107fi
108
109echo "Done: $out"
110ZSHСделай файл исполняемым:
1chmod +x ~/bin/lufsСоздай команды lufs14, lufs16 и lufs19 через симлинки:
1ln -sf ~/bin/lufs ~/bin/lufs14
2ln -sf ~/bin/lufs ~/bin/lufs16
3ln -sf ~/bin/lufs ~/bin/lufs19PATH: чтобы команды работали без полного пути
Если ~/bin ещё не добавлен в PATH, открой ~/.zshrc:
1nano ~/.zshrcДобавь в конец:
1export PATH="$HOME/bin:$PATH"Применить изменения:
1source ~/.zshrcПроверь, что команды доступны:
1type lufs14
2type lufs16
3type lufs19Использование
Громкий стерео-микс под ролики / соцсети
1lufs14 /путь/к/файлу.wavРезультат: <имя>_14LUFS_192k.mp3
Финальный стерео-микс выпуска подкаста
1lufs16 /путь/к/файлу.wavРезультат: <имя>_16LUFS_192k.mp3
Моно-запись голоса
1lufs19 /путь/к/файлу.wavРезультат: <имя>_19LUFS_192k_mono.mp3
Примечания
- -14 LUFS: чуть громче, удобно для шортсов, соцсетей и более плотного звука.
- -16 LUFS: стандартный уровень для стерео-подкастов и плееров.
- -19 LUFS: эквивалентная целевая громкость для моно.
- Двухпроходный режим (сначала измерение, потом применение) даёт более точную нормализацию.
- Битрейт фиксирован: CBR 192 kbps MP3 (libmp3lame). При необходимости можно поменять параметр
BITRATE="192k"в скрипте.
Диагностика
- «Failed to parse loudnorm metrics.»: проверь, что вывод первого прохода содержит JSON-блок (в логе будут поля
input_i,target_offsetи т.п.). - Если команда «не находится», проверь
export PATH="$HOME/bin:$PATH"и выполниsource ~/.zshrc. - Если скрипт запускается только как
~/bin/lufs16, значит~/binещё не в PATH.
Этого достаточно, чтобы стабильно приводить выпуски к целевым LUFS прямо из терминала.