Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
192 changes: 192 additions & 0 deletions apps/backend/loudness_backend.py
Original file line number Diff line number Diff line change
@@ -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
135 changes: 135 additions & 0 deletions apps/backend/tests/test_loudness_backend.py
Original file line number Diff line number Diff line change
@@ -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 <repo>/packages/...,
# NOT <repo>/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()