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

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

16 января 2026 г.

Скрипт на основе FFmpeg устанавливается в MacOS как набор команд в папке ~/bin: lufs14, lufs16 и lufs19. Он позволяет нормализовать финальные аудиофайлы подкаста или видео без сторонних программ: достаточно вызвать нужную команду с путём к файлу. Внутри выполняются два прохода FFmpeg: первый анализирует громкость и динамику, второй применяет точные параметры для достижения целевых уровней −14 LUFS (громче, под ролики и соцсети), −16 LUFS (стандарт стерео-подкастов) или −19 LUFS (моно). В результате получается готовый по уровню и балансу MP3-файл, оптимизированный под разные сценарии публикации.

Требования  Ссылка на этот раздел

  • MacOS с Homebrew
  • python3 в системе (обычно уже есть на macOS)

Установка FFmpeg  Ссылка на этот раздел

brew install ffmpeg
ffmpeg -version

Настройка: команды в ~/bin  Ссылка на этот раздел

Создай папку для личных команд (если её ещё нет):

mkdir -p ~/bin

Создай скрипт ~/bin/lufs и вставь в него код:

ZSH
cat > ~/bin/lufs <<'ZSH'
#!/bin/zsh
set -euo pipefail

# Defaults
BITRATE="192k"
SR="44100"
LRA="11"
TP="-1.5"

# Command can be invoked as lufs14/lufs16/lufs19 via symlink
cmd="$(basename "$0")"

target=""
force_mono=0

case "$cmd" in
  lufs14) target="-14"; force_mono=0 ;;
  lufs16) target="-16"; force_mono=0 ;;
  lufs19) target="-19"; force_mono=1 ;;
  lufs)
    if [[ "$#" -lt 2 ]]; then
      echo "Usage: lufs <-14|-16|-19> <input-file> [--mono]"
      exit 1
    fi
    target="$1"; shift
    ;;
  *)
    echo "Unknown command name: $cmd"
    exit 1
    ;;
esac

in="${1:-}"
opt="${2:-}"

if [[ -z "${in}" ]]; then
  echo "No input file."
  echo "Example: lufs16 \"episode.wav\""
  exit 1
fi

# Support file:// paths
in="${in#file://}"

if [[ ! -f "$in" ]]; then
  echo "Not a file: $in"
  exit 1
fi

if [[ "${opt:-}" == "--mono" ]]; then
  force_mono=1
fi

command -v ffmpeg >/dev/null 2>&1 || { echo "Need ffmpeg"; exit 1; }
command -v python3 >/dev/null 2>&1 || { echo "Need python3"; exit 1; }

dir="$(dirname "$in")"
base="$(basename "$in")"; base="${base%.*}"

suffix="${target#-}LUFS_${BITRATE}"
out="${dir}/${base}_${suffix}.mp3"
if [[ "$force_mono" -eq 1 ]]; then
  out="${dir}/${base}_${suffix}_mono.mp3"
fi

echo "Pass 1: measure loudness..."
if [[ "$force_mono" -eq 1 ]]; then
  pass="$(ffmpeg -hide_banner -nostats -y -vn -i "$in" \
    -af "aformat=channel_layouts=mono,loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
    -f null - 2>&1)"
else
  pass="$(ffmpeg -hide_banner -nostats -y -vn -i "$in" \
    -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:print_format=json" \
    -f null - 2>&1)"
fi

json_block="$(echo "$pass" | sed -n '/^{/,$p' | sed -n '1,/^}/p')"

read -r I TPm LRAi THRESH OFFSET < <(python3 - <<'PY'
import sys, json, re
s = sys.stdin.read()
m = re.search(r'^\{.*\}\s*$', s, re.S | re.M)
if not m:
    raise SystemExit(2)
d = json.loads(m.group(0))
keys = ["input_i","input_tp","input_lra","input_thresh","target_offset"]
print(" ".join(str(d.get(k,"")) for k in keys))
PY
<<< "$json_block")

if [[ -z "${I:-}" || -z "${TPm:-}" || -z "${LRAi:-}" || -z "${THRESH:-}" || -z "${OFFSET:-}" ]]; then
  echo "Failed to parse loudnorm metrics."
  echo "$pass"
  exit 1
fi

echo "Pass 2: normalize to ${target} LUFS..."
if [[ "$force_mono" -eq 1 ]]; then
  ffmpeg -hide_banner -y -vn -i "$in" \
    -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}" \
    -ar "${SR}" -ac 1 -c:a libmp3lame -b:a "${BITRATE}" "$out"
else
  ffmpeg -hide_banner -y -vn -i "$in" \
    -af "loudnorm=I=${target}:LRA=${LRA}:TP=${TP}:measured_I=${I}:measured_TP=${TPm}:measured_LRA=${LRAi}:measured_thresh=${THRESH}:offset=${OFFSET}" \
    -ar "${SR}" -c:a libmp3lame -b:a "${BITRATE}" "$out"
fi

echo "Done: $out"
ZSH

Сделай файл исполняемым:

chmod +x ~/bin/lufs

Создай команды lufs14, lufs16 и lufs19 через симлинки:

ln -sf ~/bin/lufs ~/bin/lufs14
ln -sf ~/bin/lufs ~/bin/lufs16
ln -sf ~/bin/lufs ~/bin/lufs19

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

Если ~/bin ещё не добавлен в PATH, открой ~/.zshrc:

nano ~/.zshrc

Добавь в конец:

export PATH="$HOME/bin:$PATH"

Применить изменения:

source ~/.zshrc

Проверь, что команды доступны:

type lufs14
type lufs16
type lufs19

Использование  Ссылка на этот раздел

Громкий стерео-микс под ролики / соцсети  Ссылка на этот раздел

lufs14 /путь/к/файлу.wav

Результат: <имя>_14LUFS_192k.mp3

Финальный стерео-микс выпуска подкаста  Ссылка на этот раздел

lufs16 /путь/к/файлу.wav

Результат: <имя>_16LUFS_192k.mp3

Моно-запись голоса  Ссылка на этот раздел

lufs19 /путь/к/файлу.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 Телеграм