Нормализация громкости в 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 и вставь в него код:
1#!/bin/zsh
2set -euo pipefail
3
4# Defaults (можно переопределять через env, например: BITRATE=256k lufs16 file.wav)
5BITRATE="${BITRATE:-192k}"
6SR="${SR:-44100}"
7LRA="${LRA:-11}"
8TP="${TP:--1.5}"
9
10cmd="$(basename "$0")"
11
12target=""
13force_mono=0
14
15case "$cmd" in
16 lufs14) target="-14"; force_mono=0 ;;
17 lufs16) target="-16"; force_mono=0 ;;
18 lufs19) target="-19"; force_mono=1 ;;
19 lufs)
20 if [[ "$#" -lt 2 ]]; then
21 echo "Usage: lufs <-14|-16|-19> <input-file> [--mono]"
22 exit 1
23 fi
24 target="$1"; shift
25 ;;
26 *)
27 echo "Unknown command name: $cmd"
28 exit 1
29 ;;
30esac
31
32in="${1:-}"
33opt="${2:-}"
34
35if [[ -z "${in}" ]]; then
36 echo "No input file."
37 echo "Example: lufs16 \"episode.wav\""
38 exit 1
39fi
40
41in="${in#file://}"
42
43if [[ ! -f "$in" ]]; then
44 echo "Not a file: $in"
45 exit 1
46fi
47
48if [[ "${opt:-}" == "--mono" ]]; then
49 force_mono=1
50fi
51
52command -v ffmpeg >/dev/null 2>&1 || { echo "Need ffmpeg"; exit 1; }
53command -v python3 >/dev/null 2>&1 || { echo "Need python3"; exit 1; }
54
55dir="$(dirname "$in")"
56base="$(basename "$in")"; base="${base%.*}"
57
58suffix="${target#-}LUFS_${BITRATE}"
59out="${dir}/${base}_${suffix}.mp3"
60if [[ "$force_mono" -eq 1 ]]; then
61 out="${dir}/${base}_${suffix}_mono.mp3"
62fi
63
64# Temp log for pass 1 (оставляем, если KEEP_LOG=1)
65log="$(mktemp -t loudnorm_pass1.XXXXXX)"
66cleanup() {
67 if [[ "${KEEP_LOG:-0}" != "1" ]]; then
68 rm -f "$log" 2>/dev/null || true
69 fi
70}
71trap cleanup EXIT
72
73echo "Input: $in"
74echo "Target: ${target} LUFS | LRA=${LRA} | TP=${TP} | SR=${SR} | BITRATE=${BITRATE} | mono=${force_mono}"
75echo
76
77echo "Pass 1: measure loudness (this can take as long as the audio itself)..."
78if [[ "$force_mono" -eq 1 ]]; then
79 ffmpeg -hide_banner -v info -stats -y -vn -i "$in" \
80 -af "aformat=channel_layouts=mono,loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
81 -f null - 2> >(tee "$log" >&2)
82else
83 ffmpeg -hide_banner -v info -stats -y -vn -i "$in" \
84 -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
85 -f null - 2> >(tee "$log" >&2)
86fi
87
88metrics="$(python3 - "$log" <<'PY'
89import sys, json, re
90
91path = sys.argv[1]
92txt = open(path, "r", encoding="utf-8", errors="replace").read()
93
94# Ищем блок JSON от loudnorm по наличию ключа input_i.
95# Берём ближайшую "{" выше строки с input_i и закрывающую "}" ниже.
96lines = txt.splitlines()
97idx = None
98for i in range(len(lines)-1, -1, -1):
99 if '"input_i"' in lines[i]:
100 idx = i
101 break
102
103if idx is None:
104 sys.stderr.write("Could not find loudnorm JSON (no input_i in log).\n")
105 sys.exit(2)
106
107start = None
108for i in range(idx, -1, -1):
109 if re.match(r'^\s*\{\s*$', lines[i]) or re.match(r'^\s*\{', lines[i]):
110 start = i
111 break
112
113if start is None:
114 sys.stderr.write("Could not locate start of JSON block.\n")
115 sys.exit(2)
116
117end = None
118for i in range(idx, len(lines)):
119 if re.match(r'^\s*\}\s*$', lines[i]) or lines[i].strip().endswith('}'):
120 end = i
121 break
122
123if end is None:
124 sys.stderr.write("Could not locate end of JSON block.\n")
125 sys.exit(2)
126
127block = "\n".join(lines[start:end+1]).strip()
128
129# Иногда после "}" может быть мусор в той же строке, чистим аккуратно
130m = re.search(r'\{.*\}', block, flags=re.S)
131if not m:
132 sys.stderr.write("JSON braces not found in extracted block.\n")
133 sys.exit(2)
134
135data = json.loads(m.group(0))
136keys = ["input_i", "input_tp", "input_lra", "input_thresh", "target_offset"]
137vals = [str(data.get(k, "")) for k in keys]
138
139if any(v == "" for v in vals):
140 sys.stderr.write("Missing some loudnorm metrics in JSON.\n")
141 sys.exit(2)
142
143print(" ".join(vals))
144PY
145)" || {
146 echo
147 echo "Failed to parse loudnorm metrics from pass 1."
148 echo "Log saved at: $log"
149 echo "Tip: run KEEP_LOG=1 lufs16 \"file.wav\" and open the log."
150 exit 1
151}
152
153IFS=' ' read -r I TPm LRAi THRESH OFFSET <<< "$metrics"
154
155echo
156echo "Measured (pass 1):"
157echo " measured_I=$I"
158echo " measured_TP=$TPm"
159echo " measured_LRA=$LRAi"
160echo " measured_thresh=$THRESH"
161echo " offset=$OFFSET"
162echo
163
164echo "Pass 2: normalize to ${target} LUFS..."
165if [[ "$force_mono" -eq 1 ]]; then
166 ffmpeg -hide_banner -v info -stats -y -vn -i "$in" \
167 -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}" \
168 -ar "${SR}" -ac 1 -c:a libmp3lame -b:a "${BITRATE}" "$out"
169else
170 ffmpeg -hide_banner -v info -stats -y -vn -i "$in" \
171 -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:measured_I=${I}:measured_TP=${TPm}:measured_LRA=${LRAi}:measured_thresh=${THRESH}:offset=${OFFSET}" \
172 -ar "${SR}" -c:a libmp3lame -b:a "${BITRATE}" "$out"
173fi
174
175echo
176echo "Done: $out"Сделай файл исполняемым:
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 прямо из терминала.