* feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) Completes accurate ACX/podcast mastering end-to-end (builds on the pure builders from #28 slice 1). - `services/loudness.py` — `measure_loudness(ffmpeg, concat, preset, *, job_id)`: runs ffmpeg's measure pass, parses the loudnorm JSON → MeasuredLoudness. **Never raises** — skip / non-zero rc / rc None / asyncio.TimeoutError / spawn OSError / empty or unparseable stderr / silent program all WARN + return None → single-pass fallback (a slow/broken measure degrades the master, never aborts the render). Logs rc + a static message only, never the raw stderr (path-safe / local-first). UTF-8 decode with replacement (Windows-cp safe). - `_render_longform_sse` (audiobook.py): between the concat write and the mux, when `loudness` is a known preset (acx/podcast; same `.lower()`/no-strip gate as the builders) → emit a `mastering` event, measure, and pass `measured` into `build_render_cmd` (two-pass apply; `None` → single-pass). `done` gains a `loudness` block {preset, target_i, target_tp, two_pass, measured_i} ONLY for a requested preset — off/None paths keep the byte-identical legacy `done` shape. Both front doors (/audiobook + /longform/render) get it via the shared generator. Chapter cache key is deliberately untouched (loudness-agnostic → acx/off reuse the same cached WAVs; no re-render, no cache-layout break). Tests: `test_loudness.py` (14 — happy fixture, skip-without-spawn for off/ unknown/whitespace/None, non-zero/None rc, timeout-not-propagated, OSError, empty/unparseable stderr, non-UTF-8 stderr, job_id+argv forwarding) + 2 e2e cases (mastering event + done.loudness present for acx; absent for off). Orch tests run locally (stubbed run_ffmpeg, no torch); e2e on CI. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(loudness): lazy-import run_ffmpeg so the measure stub survives sys.modules purges test_loudness monkeypatched services.loudness.run_ffmpeg, but the route-shape fresh_app fixture purges services.* from sys.modules, so under the full-suite ordering the patch missed the re-imported module → real ffmpeg ran → 3 failures. Lazy-import run_ffmpeg inside measure_loudness and patch it at its source (services.ffmpeg_utils.run_ffmpeg) so the stub is always picked up at call time. Verified by running the purging suite + test_loudness together (31 pass). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
97 lines
3.3 KiB
Python
97 lines
3.3 KiB
Python
"""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", "-"]
|