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
+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)