Files
VoiceStudio/tests/test_loudness.py
T
Palash DebnathandClaude Opus 4.8 d20c24e1e1 feat(longform): two-pass loudnorm measure orchestrator + wiring (#28 slice 2) (#455)
* 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>
2026-06-14 16:03:28 +05:30

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", "-"]