diff --git a/CLAUDE.md b/CLAUDE.md index f512c45e..8cc97408 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -245,6 +245,8 @@ GEMINI_API_KEY="your_key_here" # read by server.py at runtime, not in SONIC_ANALYZER_ADMIN_KEY="optional" # if set, DELETE /api/analysis-runs/{run_id} accepts an X-Admin-Key header that bypasses ownership for operator-level purge. Unset by default; admin path is closed. ASA_ENABLE_MT3="0" # set to "1" on the legacy analyze.py CLI path to run the optional MT3 polyphonic transcription pass. Canonical staged API uses the run-level form field `mt3_mode='enabled'` instead. Heavy deps in apps/backend/requirements-mt3.txt. ASA_SAMPLE_SYNTH_BACKEND="auto" # Phase 3 audition-sample synth backend: `auto` (default, prefers FluidSynth and falls back to symusic), `symusic`, or `fluidsynth` to pin one. See apps/backend/sample_synthesis.py. +ASA_LOUDNESS_BACKEND="essentia" # Phase 1 loudness source for the LUFS scalars: `essentia` (default, authoritative) or `wasm` to override lufsIntegrated/lufsRange/lufsMomentaryMax/lufsShortTermMax with the asa-dsp (loudness-spectro-wasm) reading via the native measure-cli binary. truePeak + lufsCurve stay Essentia; degrades back to Essentia if measure-cli is unbuilt. Default-off experiment. See apps/backend/loudness_backend.py. +ASA_MEASURE_CLI="" # optional absolute path to the measure-cli binary used by ASA_LOUDNESS_BACKEND=wasm; defaults to packages/loudness-spectro-wasm/target/release/measure-cli. ``` Phase 2 is gated by `VITE_ENABLE_PHASE2_GEMINI`. `GEMINI_API_KEY` is backend-only. `SONIC_ANALYZER_ADMIN_KEY` is backend-only and never exposed to clients. diff --git a/apps/backend/analyze.py b/apps/backend/analyze.py index 6463cd1f..cfbc1c9f 100644 --- a/apps/backend/analyze.py +++ b/apps/backend/analyze.py @@ -97,6 +97,7 @@ analyze_duration_and_sr, analyze_time_signature, ) +from loudness_backend import apply_loudness_backend # noqa: E402 from analyze_structure import ( # noqa: E402 STRUCTURE_FRAME_SIZE, STRUCTURE_HOP_SIZE, @@ -1587,7 +1588,12 @@ def main(): # K-weighting coefficients. `sr` here is the source rate returned by # load_stereo above; thread it through so Essentia's filter is correct. if stereo is not None: - result.update(analyze_loudness(stereo, sample_rate=sr)) + loudness = analyze_loudness(stereo, sample_rate=sr) + # WS3b: optionally override the LUFS scalars with the asa-dsp (WASM core) + # reading when ASA_LOUDNESS_BACKEND=wasm. No-op by default; degrades back + # to Essentia on any failure. truePeak + lufsCurve stay Essentia. + loudness = apply_loudness_backend(loudness, stereo, sr) + result.update(loudness) else: result["lufsIntegrated"] = None result["lufsRange"] = None diff --git a/apps/backend/loudness_backend.py b/apps/backend/loudness_backend.py new file mode 100644 index 00000000..d93def0a --- /dev/null +++ b/apps/backend/loudness_backend.py @@ -0,0 +1,192 @@ +"""Selectable Phase 1 loudness backend (WS3b, default-off experiment). + +ASA's authoritative loudness is Essentia's ``LoudnessEBUR128`` (``analyze_core``). +This module lets the integrated/range/momentary-max/short-term-max **LUFS +scalars** optionally come from the ``loudness-spectro-wasm`` core (``asa-dsp``, +an openmeters-derived BS.1770 implementation) instead, selected at runtime by +``ASA_LOUDNESS_BACKEND``: + +* ``essentia`` (default) — unchanged; this module is a no-op. +* ``wasm`` — override the four LUFS scalars with ``asa-dsp``'s reading. + +Scope and rationale: + +* **LUFS scalars only.** The WS3a parity harness validated integrated LUFS at + ±0.1 LU; that is the EBU R128 loudness the swap is honest about. ``truePeak`` + stays on Essentia (asa-dsp's true peak diverges on broadband content — see the + #129 parity report), and ``lufsCurve`` stays on Essentia (the asa-dsp CLI + emits scalars, not the per-frame curves). So this is a scalar override, not a + full engine replacement. +* **Subprocess, not pyo3.** We invoke the already-built native ``measure-cli`` + binary (source-identical to the WASM core) on a temp WAV. This sidesteps the + workspace's ``panic = "abort"`` profile (which blocks pyo3) and the need for a + separate Cargo workspace, and — being pure Python on this side — cannot red + the ``loudness-wasm`` CI job. The backend has already decoded the audio, so a + temp WAV makes the swap format-agnostic (FLAC/MP3 in, WAV to the CLI). +* **Default stays Essentia.** Per PURPOSE.md invariant #1, the authoritative + value does not change until real-program parity is proven; the flip is the + owner's call. Any failure here degrades gracefully back to Essentia. + +The canonical loudness contract (field names, units, rounding) is unchanged. +""" + +from __future__ import annotations + +import json +import math +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +import numpy as np + +# The four LUFS scalar fields this backend may override. lufsCurve is +# deliberately excluded (Essentia-only; the CLI does not emit per-frame curves). +_OVERRIDABLE_FIELDS = ( + "lufsIntegrated", + "lufsRange", + "lufsMomentaryMax", + "lufsShortTermMax", +) + +# loudness_backend.py lives at apps/backend/, so the repo root is parents[2] +# (parents[0]=apps/backend, [1]=apps, [2]=repo root). +_REPO_ROOT = Path(__file__).resolve().parents[2] +_DEFAULT_MEASURE_CLI = ( + _REPO_ROOT / "packages" / "loudness-spectro-wasm" / "target" / "release" / "measure-cli" +) + + +def loudness_backend_name() -> str: + """Resolve the selected loudness backend. Unknown values fall back to essentia.""" + name = (os.environ.get("ASA_LOUDNESS_BACKEND") or "essentia").strip().lower() + return name if name in {"essentia", "wasm"} else "essentia" + + +def _measure_cli_path() -> Path | None: + """Locate the measure-cli binary (env override, then the repo build dir).""" + override = os.environ.get("ASA_MEASURE_CLI") + if override: + candidate = Path(override) + return candidate if candidate.exists() else None + return _DEFAULT_MEASURE_CLI if _DEFAULT_MEASURE_CLI.exists() else None + + +def _round1(value: Any) -> float | None: + if isinstance(value, (int, float)) and math.isfinite(float(value)): + return round(float(value), 1) + return None + + +def measure_loudness_via_cli(stereo: np.ndarray, sample_rate: int) -> dict[str, Any] | None: + """Run asa-dsp's measure-cli on the audio and return the LUFS scalars. + + Returns ``None`` (and warns on stderr) on any failure — missing binary, + non-zero exit, unparseable output, or a null integrated reading — so the + caller can fall back to Essentia. The diagnostics contract (stderr only) is + preserved; this never writes to stdout. + """ + cli = _measure_cli_path() + if cli is None: + print( + "[warn] ASA_LOUDNESS_BACKEND=wasm but measure-cli is not built " + f"(looked at {_DEFAULT_MEASURE_CLI} or $ASA_MEASURE_CLI); " + "falling back to Essentia loudness.", + file=sys.stderr, + ) + return None + + tmp_path: str | None = None + try: + import soundfile as sf # lazy: the essentia path needs no WAV writer + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as handle: + tmp_path = handle.name + # Native-rate float WAV; measure-cli decodes via hound at this rate + # (no resampling — that would break ±0.1 LU EBU conformance). + sf.write(tmp_path, np.asarray(stereo), int(sample_rate), subtype="FLOAT") + + proc = subprocess.run( + [str(cli), tmp_path], + capture_output=True, + text=True, + timeout=120, + check=False, + ) + if proc.returncode != 0: + print( + f"[warn] measure-cli exited {proc.returncode}: " + f"{proc.stderr.strip()[:200]}; falling back to Essentia loudness.", + file=sys.stderr, + ) + return None + + payload = json.loads(proc.stdout.strip()) + integrated = _round1(payload.get("integrated")) + if integrated is None: + # Null integrated (no block passed the gates / silence) — don't + # override Essentia with a null; let Essentia's reading stand. + print( + "[warn] measure-cli returned null integrated loudness; " + "falling back to Essentia loudness.", + file=sys.stderr, + ) + return None + + return { + "lufsIntegrated": integrated, + "lufsRange": _round1(payload.get("lra")), + "lufsMomentaryMax": _round1(payload.get("momentaryMax")), + "lufsShortTermMax": _round1(payload.get("shortTermMax")), + } + except Exception as exc: # noqa: BLE001 - degrade to Essentia, never crash analysis + print( + f"[warn] measure-cli loudness failed ({exc}); falling back to Essentia loudness.", + file=sys.stderr, + ) + return None + finally: + if tmp_path is not None: + try: + os.unlink(tmp_path) + except OSError: + pass + + +def apply_loudness_backend( + loudness: dict[str, Any], + stereo: np.ndarray | None, + sample_rate: int, +) -> dict[str, Any]: + """Override the LUFS scalars with the WASM backend when selected. + + No-op for the default ``essentia`` backend or when audio is unavailable. + On any WASM failure the Essentia ``loudness`` dict is returned unchanged, so + the analysis never degrades below the authoritative path. ``lufsCurve`` and + every non-LUFS field pass through untouched. + """ + if loudness_backend_name() != "wasm" or stereo is None: + return loudness + + cli_result = measure_loudness_via_cli(stereo, sample_rate) + if cli_result is None: + return loudness + + merged = dict(loudness) + for field in _OVERRIDABLE_FIELDS: + # Only override when the CLI gave a real value: integrated can be + # non-null while lra is undefined for short content, and we must not + # clobber a valid Essentia reading with None. + value = cli_result.get(field) + if value is not None: + merged[field] = value + print( + "[info] loudness backend: WASM (asa-dsp) overrode LUFS scalars " + f"(integrated {merged.get('lufsIntegrated')} LUFS); " + "truePeak and lufsCurve remain Essentia.", + file=sys.stderr, + ) + return merged diff --git a/apps/backend/tests/test_loudness_backend.py b/apps/backend/tests/test_loudness_backend.py new file mode 100644 index 00000000..6bcfd0f7 --- /dev/null +++ b/apps/backend/tests/test_loudness_backend.py @@ -0,0 +1,135 @@ +"""Tests for the selectable loudness backend (WS3b). + +The WASM path shells out to the native measure-cli binary, which isn't built in +the Python CI job — so these mock the subprocess and assert the +select/override/fall-back logic in pure Python. +""" + +import os +import unittest +from pathlib import Path +from unittest import mock + +import numpy as np + +import loudness_backend as lb + + +_FAKE_CLI = Path("/fake/measure-cli") + +# A representative Essentia loudness block (what analyze_loudness returns). +ESSENTIA_LOUDNESS = { + "lufsIntegrated": -9.3, + "lufsRange": 5.2, + "lufsMomentaryMax": -7.1, + "lufsShortTermMax": -8.0, + "lufsCurve": {"shortTerm": [-12.0, -10.0], "momentary": [-11.0, -9.0]}, +} + + +def _cli_proc(stdout: str, returncode: int = 0, stderr: str = "") -> mock.Mock: + return mock.Mock(returncode=returncode, stdout=stdout, stderr=stderr) + + +class LoudnessBackendNameTests(unittest.TestCase): + def test_default_is_essentia(self) -> None: + with mock.patch.dict(os.environ, {}, clear=False): + os.environ.pop("ASA_LOUDNESS_BACKEND", None) + self.assertEqual(lb.loudness_backend_name(), "essentia") + + def test_unknown_value_falls_back_to_essentia(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "bogus"}): + self.assertEqual(lb.loudness_backend_name(), "essentia") + + def test_wasm_is_recognized_case_insensitively(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "WASM"}): + self.assertEqual(lb.loudness_backend_name(), "wasm") + + def test_default_measure_cli_path_is_under_repo_packages(self) -> None: + # Guards the parents[] index: the package lives at /packages/..., + # NOT /apps/packages/... An off-by-one would silently degrade + # ASA_LOUDNESS_BACKEND=wasm to Essentia by default. + package_dir = lb._DEFAULT_MEASURE_CLI.parents[2] + self.assertEqual(package_dir.name, "loudness-spectro-wasm") + self.assertTrue(package_dir.is_dir(), f"{package_dir} should exist") + + +class ApplyLoudnessBackendTests(unittest.TestCase): + def setUp(self) -> None: + # 0.1 s of silence at 48 kHz, stereo. Real array so the (mocked) WASM + # path can write a temp WAV without special-casing. + self.stereo = np.zeros((4800, 2), dtype=np.float32) + + def test_essentia_default_is_a_noop(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "essentia"}): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + def test_none_stereo_is_a_noop_even_under_wasm(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), None, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + def test_wasm_overrides_lufs_scalars_keeps_curve(self) -> None: + cli_json = ( + '{"integrated":-14.71,"momentaryMax":-12.53,' + '"shortTermMax":-13.2,"truePeak":-1.0,"lra":6.04}' + ) + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=_FAKE_CLI), \ + mock.patch.object(lb.subprocess, "run", return_value=_cli_proc(cli_json)): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + + # The four LUFS scalars are replaced (rounded to the 1-dp contract)... + self.assertEqual(out["lufsIntegrated"], -14.7) + self.assertEqual(out["lufsRange"], 6.0) + self.assertEqual(out["lufsMomentaryMax"], -12.5) + self.assertEqual(out["lufsShortTermMax"], -13.2) + # ...while the per-frame curve (Essentia-only) passes through untouched. + self.assertEqual(out["lufsCurve"], ESSENTIA_LOUDNESS["lufsCurve"]) + + def test_wasm_keeps_essentia_value_when_cli_field_is_null(self) -> None: + # Valid integrated but null lra (undefined for short content): the CLI's + # None must not overwrite Essentia's valid LRA reading. + cli_json = ( + '{"integrated":-14.71,"momentaryMax":-12.5,' + '"shortTermMax":-13.2,"truePeak":-1.0,"lra":null}' + ) + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=_FAKE_CLI), \ + mock.patch.object(lb.subprocess, "run", return_value=_cli_proc(cli_json)): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out["lufsIntegrated"], -14.7) # overridden + self.assertEqual(out["lufsRange"], ESSENTIA_LOUDNESS["lufsRange"]) # preserved + + def test_wasm_missing_binary_falls_back_to_essentia(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=None): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + def test_wasm_cli_nonzero_exit_falls_back(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=_FAKE_CLI), \ + mock.patch.object(lb.subprocess, "run", return_value=_cli_proc("", returncode=1, stderr="boom")): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + def test_wasm_null_integrated_falls_back(self) -> None: + cli_json = '{"integrated":null,"momentaryMax":null,"shortTermMax":null,"truePeak":null,"lra":null}' + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=_FAKE_CLI), \ + mock.patch.object(lb.subprocess, "run", return_value=_cli_proc(cli_json)): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + def test_wasm_unparseable_output_falls_back(self) -> None: + with mock.patch.dict(os.environ, {"ASA_LOUDNESS_BACKEND": "wasm"}), \ + mock.patch.object(lb, "_measure_cli_path", return_value=_FAKE_CLI), \ + mock.patch.object(lb.subprocess, "run", return_value=_cli_proc("not json")): + out = lb.apply_loudness_backend(dict(ESSENTIA_LOUDNESS), self.stereo, 48_000) + self.assertEqual(out, ESSENTIA_LOUDNESS) + + +if __name__ == "__main__": + unittest.main()