diff --git a/backend/app/config.py b/backend/app/config.py index 62acd26e..ad8d13ea 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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 diff --git a/backend/app/plex/stream.py b/backend/app/plex/stream.py index e3053176..3a18f9d3 100644 --- a/backend/app/plex/stream.py +++ b/backend/app/plex/stream.py @@ -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, diff --git a/backend/app/sysconfig.py b/backend/app/sysconfig.py index 1fa7802f..5ade8ac2 100644 --- a/backend/app/sysconfig.py +++ b/backend/app/sysconfig.py @@ -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 --- diff --git a/backend/tests/test_plex_stream.py b/backend/tests/test_plex_stream.py index 39be017f..5c354d9b 100644 --- a/backend/tests/test_plex_stream.py +++ b/backend/tests/test_plex_stream.py @@ -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) diff --git a/frontend/src/i18n/locales/en/config.json b/frontend/src/i18n/locales/en/config.json index 0bed3713..60d56bcf 100644 --- a/frontend/src/i18n/locales/en/config.json +++ b/frontend/src/i18n/locales/en/config.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/config.json b/frontend/src/i18n/locales/hu/config.json index a87a0def..a1a172ef 100644 --- a/frontend/src/i18n/locales/hu/config.json +++ b/frontend/src/i18n/locales/hu/config.json @@ -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",