* 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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
4a75d694e6
commit
d20c24e1e1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user