feat(plex): an off-by-default loudness normalisation toggle for playback

Bug hunt 2026-09, item 14 — "how does the multichannel downmix work, and is it
normalised?" It isn't: `plex/stream.py` has always encoded `-c:a aac -ac 2 -b:a
192k`, i.e. fixed stereo with ffmpeg's default downmix matrix and no loudness
handling at all, which is why a film mastered for a cinema arrives with whispered
dialogue and deafening action.

New admin knob `plex_audio_loudnorm` (Configuration → Plex), DEFAULT OFF: it makes
a film markedly louder and narrows its dynamic range, which is a taste call rather
than a fix. It sits at the end of the Plex group, after the software-transcode
knobs — it is an audio concern, not one of the libx264 ones.

  -af aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000

Three things about that chain are load-bearing and each has a test:

  * the downmix is FORCED FIRST rather than left to `-ac 2` — measuring 5.1 and
    folding it down afterwards would normalise something other than what is heard;
  * the trailing resample is not decoration: `loudnorm` runs its own resampler at
    192 kHz internally, which the AAC encoder will not take;
  * one `-af`, no stream index, so EVERY audio rendition of a multi-audio file is
    normalised — not just the first.

The knob is also part of the session directory name (`…_multi-ln_+0.00`), because
it changes the audio the segments contain, and a session left over from before the
admin flipped it must not be mistaken for one that honours it. The suffix is
appended with a DASH: `_SESSION_DIR_RE` treats the tag as one underscore-free
field, and that regex is the only thing keeping the orphan sweep off what may be a
shared scratch path. A test pins both halves — the name changes, and the sweep
still recognises it.

Measured end-to-end on the app's own HLS segments (a real 5.1 AC3 file, same item,
same 160 s offset, same ten segments): OFF -35.2 LUFS, ON -17.2 LUFS. On a 60 s
bench: I -27.6 → -16.6 LUFS with LRA 13.3 → 9.8 LU. A/V sync is untouched — under
this pipeline's own `-copyts` the audio start PTS is identical with and without the
filter, so the 3 s lookahead desyncs nothing.

Direct-played files are served untouched and stay so; a 5.1 mix is always `remux`,
so the reported case is fully covered.
This commit is contained in:
2026-09-10 20:02:06 +02:00
parent ed4ab4e13c
commit 5a42d76e8a
6 changed files with 130 additions and 3 deletions
+5
View File
@@ -271,6 +271,11 @@ class Settings(BaseSettings):
# trades quality for size (lower = better + bigger). `max_height` caps the SOFTWARE transcode's output
# height (0 = keep source) so a CPU re-encode of a tall source stays affordable — the GPU path always
# encodes at source resolution.
# Loudness normalisation of the downmixed stereo audio on the HLS path (EBU R128 `loudnorm`).
# OFF by default: it makes a film ~11 dB louder and narrows its dynamic range, which is a taste
# call, not a fix. Only affects files that go through remux/transcode — a direct-played file is
# served untouched, but those are browser-native stereo anyway, not the 5.1 mixes this is for.
plex_audio_loudnorm: bool = False
plex_transcode_preset: str = "veryfast"
plex_transcode_crf: int = 20
plex_transcode_max_height: int = 1080
+38 -3
View File
@@ -271,6 +271,30 @@ def _hwaccel_in_opts(hw: bool, tonemap: bool = False) -> list[str]:
return ["-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", _VAAPI_DEVICE]
# Audio is ALWAYS re-encoded on the HLS path: the source is typically AC3/DTS 5.1, which no browser
# plays, so it is downmixed to stereo AAC. `-ac 2` uses ffmpeg's standard downmix matrix; nothing is
# normalised, which is why a film mastered for a cinema arrives with whispered dialogue and deafening
# action (bug hunt 2026-09, item 14).
_AUDIO_ENC = ["-c:a", "aac", "-ac", "2", "-b:a", "192k"]
# Optional loudness normalisation (admin knob `plex_audio_loudnorm`, default OFF). EBU R128 single-pass
# `loudnorm` — two-pass is impossible here, since a seek-restart session must start emitting segments
# immediately and cannot analyse the file first.
#
# The downmix is FORCED FIRST (`aformat=channel_layouts=stereo`) rather than left to `-ac 2`: measuring
# 5.1 and then folding it down would normalise something other than what the viewer hears. `loudnorm`
# runs its own resampler at 192 kHz internally, so the chain resamples back to 48 kHz for the AAC
# encoder. Measured on a real 5.1 AC3 film (Westworld S1E6, 60 s from 15:00): I -27.6 → -16.6 LUFS,
# LRA 13.3 → 9.8 LU — the quiet/loud gap narrows, and everything gets ~11 dB louder, so expect to turn
# the volume down after switching this on. The filter's 3 s lookahead does NOT desync A/V: measured
# under this pipeline's own `-copyts`, the audio start PTS is identical with and without it.
_LOUDNORM_AF = "aformat=channel_layouts=stereo,loudnorm=I=-16:TP=-1.5:LRA=11,aresample=48000"
def _aenc_opts(loudnorm: bool) -> list[str]:
"""Audio encoder args for one output, with the normalisation filter when the admin enabled it."""
return [*_AUDIO_ENC, *(["-af", _LOUDNORM_AF] if loudnorm else [])]
def _venc_opts(
transcode: bool,
hw: bool,
@@ -446,6 +470,7 @@ def _ffmpeg_cmd(
preset: str = "veryfast",
crf: int = 20,
max_height: int = 0,
loudnorm: bool = False,
) -> list[str]:
# For a transcode session the video is re-encoded (VAAPI or libx264) instead of stream-copied; the
# hwaccel decode options go before -i, the encoder args replace `-c:v copy` below. Everything else is
@@ -473,7 +498,8 @@ def _ffmpeg_cmd(
args += ["-map", "0:v:0"]
for i in range(len(audio_langs)):
args += ["-map", f"{ai}:a:{i}?"]
args += ["-sn", *venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
# One `-af` covers EVERY audio output of this file, so each rendition is normalised.
args += ["-sn", *venc, *_aenc_opts(loudnorm)]
var = ["v:0,agroup:aud"]
for i, lang in enumerate(audio_langs):
entry = f"a:{i},agroup:aud,name:a{i}"
@@ -494,7 +520,7 @@ def _ffmpeg_cmd(
else:
ao = audio_ord if audio_ord is not None else 0
args += ["-map", "0:v:0", "-map", f"{ai}:a:{ao}?", "-sn"]
args += [*venc, "-c:a", "aac", "-ac", "2", "-b:a", "192k"]
args += [*venc, *_aenc_opts(loudnorm)]
args += _HLS_TAIL + [
"-hls_segment_filename",
str(out_dir / "seg_%d.ts"),
@@ -579,11 +605,19 @@ def start_session(
src = paths.local_media_path(db, item.file_path)
if src is None:
return None
# Read once, here: the knob has to be identical for the directory name and the ffmpeg argv, and a
# session respawn (the HW->SW fallback) must not pick up a mid-playback config change.
loudnorm = sysconfig.get_bool(db, "plex_audio_loudnorm")
key = item.rating_key
start_s = max(0.0, float(start_s))
audio_langs = _probe_audio_langs(src) if multi else []
is_multi = len(audio_langs) > 1 # only worth a master playlist when there really are ≥2 tracks
tag = "multi" if is_multi else str(audio_ord)
# Loudness normalisation changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory
# name beside the other parameters that do (offset, audio track, A/V offset): a session left over
# from before the admin flipped the knob must not be mistaken for one that honours it. Appended to
# the tag with a DASH — `_SESSION_DIR_RE` treats the tag as one underscore-free field, and that
# regex is the only thing keeping the orphan sweep off a shared scratch path (see its comment).
tag = ("multi" if is_multi else str(audio_ord)) + ("-ln" if loudnorm else "")
directory = _HLS_ROOT / f"{key}_{int(start_s)}_{tag}_{aoff:+.2f}"
def _seg(i: int) -> Path:
@@ -632,6 +666,7 @@ def start_session(
preset=preset,
crf=crf,
max_height=max_height,
loudnorm=loudnorm,
),
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
+2
View File
@@ -167,6 +167,8 @@ SPECS: tuple[ConfigSpec, ...] = (
ConfigSpec(
"plex_transcode_max_height", "int", "plex", "plex_transcode_max_height", min=0, max=4320
),
# Audio loudness normalisation on the HLS path (remux + transcode alike, not direct play).
ConfigSpec("plex_audio_loudnorm", "bool", "plex", "plex_audio_loudnorm"),
# NOTE: plex_watch_sync_interval_min is a config.py default read by the watch-sync scheduler job
# (scheduler.py); it is intentionally NOT a runtime admin knob, so it stays out of SPECS.
# --- TMDB enrichment (E5 S2; optional) — the API key is the only secret ---