diff --git a/backend/api/routers/audiobook.py b/backend/api/routers/audiobook.py index ca2844af..757bdd05 100644 --- a/backend/api/routers/audiobook.py +++ b/backend/api/routers/audiobook.py @@ -30,6 +30,7 @@ from services.audiobook import ( synthesize_chapter, ) from services.longform_render import ( + LOUDNESS_PRESETS, build_concat_list, build_ffmetadata, build_render_cmd, @@ -445,11 +446,24 @@ async def _render_longform_sse( ext = "mp3" if (fmt or "").lower() == "mp3" else "m4b" out_name = f"{job_type}_{job_id}.{ext}" out_path = os.path.join(OUTPUTS_DIR, out_name) + + # Two-pass loudness master (#28): for a known preset, measure the + # concatenated program first, then feed the measured values back into the + # single mux encode. `measured is None` (skip OR any failure) → the mux + # falls back to single-pass. Gated identically to the pure builders + # (.lower(), no strip), so off/None/unknown/whitespace skip cleanly. + measured = None + norm = (loudness or "").lower() + if norm in LOUDNESS_PRESETS: + yield _emit({"type": "mastering", "preset": norm}) + from services.loudness import measure_loudness + measured = await measure_loudness(ffmpeg, concat_path, norm, job_id=job_id) + await run_ffmpeg( build_render_cmd( ffmpeg, concat_path, meta_path, out_path, fmt=ext, bitrate=bitrate, cover_path=_safe_cover_path(cover_path), - loudness=loudness, + loudness=loudness, measured=measured, ), job_id=job_id, ) @@ -460,9 +474,19 @@ async def _render_longform_sse( except Exception: pass # best-effort job history total_s = sum(d for _, d in chapters_meta) / 1000.0 - yield _emit({"type": "done", "output": out_name, - "chapters": len(chapter_files), "duration_s": round(total_s, 2), - "cached_chapters": cached_n, "failed_chapters": failed}) + done = {"type": "done", "output": out_name, + "chapters": len(chapter_files), "duration_s": round(total_s, 2), + "cached_chapters": cached_n, "failed_chapters": failed} + # Loudness verdict only when a preset was requested — off/None paths keep + # the exact legacy `done` shape (additive, old clients unaffected). + if norm in LOUDNESS_PRESETS: + p = LOUDNESS_PRESETS[norm] + done["loudness"] = { + "preset": norm, "target_i": p.i, "target_tp": p.tp, + "two_pass": measured is not None, + "measured_i": measured.input_i if measured else None, + } + yield _emit(done) except Exception as e: # surface, don't 500 the stream logger.exception("[%s] longform render failed", job_id) if job_store is not None: diff --git a/backend/services/loudness.py b/backend/services/loudness.py new file mode 100644 index 00000000..5f3726fe --- /dev/null +++ b/backend/services/loudness.py @@ -0,0 +1,68 @@ +"""Two-pass loudnorm measure orchestrator (#28). + +The impure half of the two-pass ACX/podcast master: run ffmpeg's measure pass +over the concatenated chapters and parse the printed loudnorm JSON. The pure +builders/parser live in :mod:`services.longform_render`; this only drives ffmpeg. + +Contract: **never raises.** Every failure (skip / non-zero rc / timeout / spawn +error / empty or unparseable stderr / silent program) is caught, logged at +WARNING, and converted to ``None`` so the caller falls back to single-pass. A +slow or broken measure must degrade the master, never abort the render. +""" +from __future__ import annotations + +import logging +from typing import Optional + +from services.longform_render import ( + MeasuredLoudness, + build_loudnorm_measure_cmd, + build_loudnorm_measure_filter, + parse_loudnorm_measure, +) + +logger = logging.getLogger("omnivoice.loudness") + + +async def measure_loudness( + ffmpeg: str, + concat_list_path: str, + preset: str, + *, + job_id: str, +) -> Optional[MeasuredLoudness]: + """Measure the concatenated program's loudness for ``preset`` (acx/podcast), + or ``None`` for off/unknown or on ANY failure (→ single-pass fallback). + + Only the ffmpeg rc and a short static message are logged — never the raw + stderr (it can carry the concat path under OUTPUTS_DIR), keeping the log + local-first / path-safe. + """ + filt = build_loudnorm_measure_filter(preset) + if filt is None: + return None # off / unknown — a normal skip, not an error (no log) + cmd = build_loudnorm_measure_cmd(ffmpeg, concat_list_path, filt) + from services.ffmpeg_utils import run_ffmpeg # lazy → patchable at source + try: + # asyncio.TimeoutError is a subclass of Exception (Py≥3.11) — caught + # here so a slow measure degrades to single-pass instead of killing the + # whole render via the caller's outer except. + rc, _out, err = await run_ffmpeg(cmd, capture=True, job_id=job_id) + except Exception as exc: + logger.warning("loudness measure pass did not run (%s) — single-pass fallback", + type(exc).__name__) + return None + if rc != 0: + logger.warning("loudness measure pass exited rc=%s — single-pass fallback", rc) + return None + try: + stderr_text = err.decode("utf-8", "replace") if isinstance(err, (bytes, bytearray)) else (err or "") + except Exception: + return None + measured = parse_loudnorm_measure(stderr_text) + if measured is None: + logger.warning("loudness measure output unparseable — single-pass fallback") + return measured + + +__all__ = ["measure_loudness"] diff --git a/tests/test_longform_e2e.py b/tests/test_longform_e2e.py index 8cfa1a33..f3775eeb 100644 --- a/tests/test_longform_e2e.py +++ b/tests/test_longform_e2e.py @@ -167,3 +167,26 @@ def test_no_ffmpeg_errors_before_synth(tmp_path, monkeypatch): events = _collect_events(_plan(("One", "hi")), monkeypatch, out) assert events[-1]["type"] == "error" and "ffmpeg" in events[-1]["error"] assert not list(p for p in out.iterdir() if p.is_file()) + + +def test_acx_emits_mastering_event_and_loudness_block(tmp_path, monkeypatch): + """#28: a known loudness preset fires a `mastering` event and adds a + `loudness` block to `done` (two-pass on real signal; silent stub → fallback, + two_pass False — either way the wiring + event shape are exercised).""" + out = tmp_path / "outputs" + out.mkdir() + events = _collect_events(_plan(("One", "hi")), monkeypatch, out, fmt="m4b", loudness="acx") + types = [e["type"] for e in events] + assert "mastering" in types + done = events[-1] + assert done["type"] == "done" + assert done["loudness"]["preset"] == "acx" + assert "two_pass" in done["loudness"] + + +def test_off_path_emits_no_loudness_block(tmp_path, monkeypatch): + out = tmp_path / "outputs" + out.mkdir() + events = _collect_events(_plan(("One", "hi")), monkeypatch, out, fmt="m4b") # no loudness + assert "mastering" not in [e["type"] for e in events] + assert "loudness" not in events[-1] # legacy done shape preserved diff --git a/tests/test_loudness.py b/tests/test_loudness.py new file mode 100644 index 00000000..36ba456e --- /dev/null +++ b/tests/test_loudness.py @@ -0,0 +1,96 @@ +"""Orchestrator tests for the two-pass loudness measure (#28 slice 2). + +Drives services.loudness.measure_loudness with a stubbed run_ffmpeg (no real +ffmpeg, no torch) — asserts the never-raises / single-pass-fallback contract +across the full failure matrix. +""" +from __future__ import annotations + +import asyncio + +import pytest + +from services.loudness import measure_loudness + +_JSON = """[Parsed_loudnorm_0 @ 0x55] +{ + "input_i" : "-21.75", "input_tp" : "-18.06", "input_lra" : "0.00", + "input_thresh" : "-31.75", "target_offset" : "0.05" +} +[out#0/null @ 0x66] size=N/A +""" + + +def _run(coro): + return asyncio.run(coro) + + +def _stub(monkeypatch, *, rc=0, err=b"", raises=None, spy=None): + async def fake(cmd, *a, **kw): + if spy is not None: + spy["cmd"] = cmd + spy["job_id"] = kw.get("job_id") + spy["called"] = True + if raises is not None: + raise raises + return (rc, b"", err) + if spy is not None: + spy["called"] = False + monkeypatch.setattr("services.ffmpeg_utils.run_ffmpeg", fake) + + +def test_happy_parses_fixture(monkeypatch): + _stub(monkeypatch, rc=0, err=_JSON.encode()) + m = _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j1")) + assert m is not None and m.input_i == -21.75 and m.target_offset == 0.05 + + +@pytest.mark.parametrize("preset", ["off", "none", "bogus", " acx ", "", None]) +def test_skips_without_spawning(monkeypatch, preset): + spy = {} + _stub(monkeypatch, rc=0, err=_JSON.encode(), spy=spy) + assert _run(measure_loudness("ffmpeg", "c.txt", preset, job_id="j")) is None + assert spy["called"] is False # never ran ffmpeg for a non-preset + + +def test_nonzero_rc_returns_none(monkeypatch): + _stub(monkeypatch, rc=1, err=_JSON.encode()) + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + + +def test_rc_none_returns_none(monkeypatch): + _stub(monkeypatch, rc=None, err=_JSON.encode()) + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + + +def test_timeout_does_not_propagate(monkeypatch): + _stub(monkeypatch, raises=asyncio.TimeoutError()) + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + + +def test_oserror_returns_none(monkeypatch): + _stub(monkeypatch, raises=OSError("spawn failed")) + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + + +def test_empty_and_unparseable_stderr_return_none(monkeypatch): + _stub(monkeypatch, rc=0, err=b"") + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + _stub(monkeypatch, rc=0, err=b"garbage no json {trunc") + assert _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) is None + + +def test_non_utf8_stderr_still_parses(monkeypatch): + # cp1252 byte + the ASCII JSON block → decode('replace') keeps the JSON. + _stub(monkeypatch, rc=0, err=b"\xff broken byte\n" + _JSON.encode()) + m = _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="j")) + assert m is not None and m.input_i == -21.75 + + +def test_forwards_job_id_and_uses_measure_argv(monkeypatch): + spy = {} + _stub(monkeypatch, rc=0, err=_JSON.encode(), spy=spy) + _run(measure_loudness("ffmpeg", "c.txt", "acx", job_id="job-xyz")) + assert spy["job_id"] == "job-xyz" + assert spy["cmd"][:5] == ["ffmpeg", "-y", "-hide_banner", "-loglevel", "info"] + assert spy["cmd"][-3:] == ["-f", "null", "-"]