* 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>
69 lines
2.6 KiB
Python
69 lines
2.6 KiB
Python
"""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"]
|