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 ---
+77
View File
@@ -483,6 +483,7 @@ class TestColdStartWarmup:
src.write_bytes(b"x")
monkeypatch.setattr(stream.paths, "local_media_path", lambda db, fp: src)
monkeypatch.setattr(stream.sysconfig, "get_int", lambda db, k: 2)
monkeypatch.setattr(stream.sysconfig, "get_bool", lambda db, k: False)
monkeypatch.setattr(stream.subprocess, "Popen", lambda *a, **k: _FakeProc())
return tmp_path
@@ -554,6 +555,12 @@ class TestTranscodeStartSession:
"get_str",
lambda db, k: {"plex_transcode_preset": "slow"}.get(k, "auto"),
)
# Loudness normalisation (item 14) is off unless a test turns it on via `stream._loudnorm_on`.
monkeypatch.setattr(
stream.sysconfig,
"get_bool",
lambda db, k: {"plex_audio_loudnorm": getattr(stream, "_loudnorm_on", False)}[k],
)
monkeypatch.setattr(stream, "wait_for", lambda path, timeout=20.0, proc=None: True)
_CapturingProc.calls = []
monkeypatch.setattr(stream.subprocess, "Popen", _CapturingProc)
@@ -868,3 +875,73 @@ class TestWaitForProcFastFail:
seg = tmp_path / "seg_0.ts"
seg.write_bytes(b"")
assert stream.wait_for(seg, timeout=30.0, proc=self._DeadProc()) is False
class TestAudioLoudnorm:
"""`plex_audio_loudnorm` (bug hunt 2026-09, item 14): the HLS path always downmixes 5.1 to stereo
AAC and never normalised it, so a cinema mix arrives with whispered dialogue and deafening action.
The knob is OFF by default — it makes a film ~11 dB louder and narrows its dynamic range, which is
a taste call. Measured on a real 5.1 AC3 film: I -27.6 -> -16.6 LUFS, LRA 13.3 -> 9.8 LU.
Reuses TestTranscodeStartSession's capturing fixture: what matters is the argv ffmpeg is actually
spawned with, not `_ffmpeg_cmd` called with literals."""
capturing_env = TestTranscodeStartSession.capturing_env
@staticmethod
def _spawn(monkeypatch, *, on: bool, multi: bool = False):
monkeypatch.setattr(stream, "_loudnorm_on", on, raising=False)
monkeypatch.setattr(
stream, "_select_encode", lambda db, item, transcode, src: (False, False)
)
if multi:
monkeypatch.setattr(stream, "_probe_audio_langs", lambda src: ["hun", "eng"])
s = stream.start_session(db=None, item=_FakeItem(), start_s=0.0, multi=multi)
assert s is not None
(cmd, *_), _ = _CapturingProc.calls[0]
return s, cmd
def test_the_knob_off_leaves_the_audio_exactly_as_it_was(self, capturing_env, monkeypatch):
_, cmd = self._spawn(monkeypatch, on=False)
assert "-af" not in cmd
assert cmd[cmd.index("-c:a") + 1] == "aac" and cmd[cmd.index("-ac") + 1] == "2"
def test_the_knob_on_normalises_AFTER_the_downmix(self, capturing_env, monkeypatch):
# Order is the whole point: measuring 5.1 and folding it down afterwards would normalise
# something other than what the viewer hears.
_, cmd = self._spawn(monkeypatch, on=True)
af = cmd[cmd.index("-af") + 1]
stages = af.split(",")
assert stages[0] == "aformat=channel_layouts=stereo"
assert stages[1].startswith("loudnorm=")
# loudnorm runs its own 192 kHz resampler internally; the AAC encoder needs 48 kHz back.
assert stages[-1] == "aresample=48000"
def test_every_audio_rendition_of_a_multi_audio_file_is_normalised(
self, capturing_env, monkeypatch
):
# A multi-audio session emits one AAC output per language. `-af` without a stream index
# covers all of them — a per-stream filter would have normalised only the first.
_, cmd = self._spawn(monkeypatch, on=True, multi=True)
assert cmd.count("-map") == 3 # video + two audio tracks
assert cmd.count("-af") == 1
assert "loudnorm=" in cmd[cmd.index("-af") + 1]
def test_the_session_directory_records_the_setting(self, capturing_env, monkeypatch):
# The knob changes the AUDIO THE SEGMENTS CONTAIN, so it belongs in the directory name with
# the other parameters that do. Otherwise a session left from before the flip is reused.
off, _ = self._spawn(monkeypatch, on=False)
_CapturingProc.calls = []
on, _ = self._spawn(monkeypatch, on=True)
assert off.dir.name != on.dir.name
assert on.dir.name.endswith("-ln_+0.00")
@pytest.mark.parametrize("on", [False, True])
def test_the_orphan_sweep_still_recognises_its_own_directory(
self, capturing_env, monkeypatch, on
):
# The tag is appended with a DASH, not an underscore, precisely so `_SESSION_DIR_RE` (the only
# thing keeping the sweep off a shared scratch path) still matches. A session directory the
# sweep no longer recognises is one that never gets cleaned up.
s, _ = self._spawn(monkeypatch, on=on)
assert _SESSION_DIR_RE.match(s.dir.name)
+4
View File
@@ -183,6 +183,10 @@
"plex_transcode_max_height": {
"label": "Software max height",
"hint": "Cap the software transcode's output height in pixels (e.g. 1080; 0 = keep source) so a tall source stays affordable to re-encode. The GPU path keeps source resolution."
},
"plex_audio_loudnorm": {
"label": "Normalize audio loudness",
"hint": "EBU R128 loudness normalization of the stereo downmix, for films whose dialogue is a whisper and whose action is deafening. Applies to remux/transcode playback only (a direct-played file is served untouched). Measured on a 5.1 film: −27.6 → −16.6 LUFS with the loud/quiet range narrowed — everything gets about 11 dB louder, so turn your volume down after switching it on. Takes effect on the next playback."
}
},
"save": "Save",
+4
View File
@@ -183,6 +183,10 @@
"plex_transcode_max_height": {
"label": "Szoftveres max. magasság",
"hint": "A szoftveres transcode kimeneti magasságának korlátja képpontban (pl. 1080; 0 = forrás megtartása), hogy egy magas forrás újrakódolása megfizethető maradjon. A GPU-út a forrásfelbontást tartja."
},
"plex_audio_loudnorm": {
"label": "Hangerő normalizálása",
"hint": "A sztereó lekeverés EBU R128 szerinti hangerő-normalizálása — azokhoz a filmekhez, ahol a párbeszéd suttogás, az akció meg fülsiketítő. Csak remux/transcode lejátszásnál él (a direktben játszott fájl érintetlen marad). Egy 5.1-es filmen mérve: −27,6 → −16,6 LUFS, szűkebb halk/hangos különbséggel — minden kb. 11 dB-lel hangosabb lesz, szóval bekapcsolás után vedd lejjebb a hangerőt. A következő lejátszástól érvényes."
}
},
"save": "Mentés",