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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user