Антон Малявский Антон Малявский

Нормализация громкости в FFmpeg (двухпроходный loudnorm)

4 марта 2026 г.

Что это?  Ссылка на этот раздел

Скрипт на основе 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 и вставь в него код:

ZSH
  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/lufs19

PATH: чтобы команды работали без полного пути  Ссылка на этот раздел

Если ~/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 прямо из терминала.

См. также  Ссылка на этот раздел

Есть что сказать? Напишите мне!
Комментировать по почте
Понравилось? Подпишитесь на меня!
RSS Телеграм