Files
uj-mastering-master/scripts/webapi_smoke.py
T
irene a1fed2cd71 Add docker compose deployment template
Multi-stage image built from the `web` extra only, so it carries no Qt: the
whole web path (webapp, web_session, metrics_json, and the shared analysis
modules) imports no Qt, pyqtgraph or matplotlib canvas. The build stage has the
compiler scipy/numba need; the runtime layer installs the built wheel, so the
same image works from a checkout or a release tarball.

Compose declares no audio volume on purpose. The app keeps no audio, so there is
nothing to persist: `read_only` plus a /tmp tmpfs means request spools are
memory-backed and never reach a disk. Publishing is loopback-only because the
tool has no authentication, and BLAS thread pools are pinned because analysis is
CPU-bound.

Fix three packaging bugs the build surfaced, each of which broke the image:

- `pip install --extra web` is not valid for a source tree; uv wants `.[web]`
  and failed with "Requesting extras requires a pylock.toml".
- The wheel shipped no static assets at all. `package-data` keys match
  *packages*, and the app modules are top-level `py-modules`, so the `"*"` key
  matched nothing - verified by inspecting the built wheel: 14 .py files, zero
  HTML/JS/CSS. An installed `ujm-web` would have 404'd every asset. `static/` is
  now declared a package (hence its packaging-shim `__init__.py`) so the assets
  install next to the modules, which is where `webapp.py` looks for them.
- The Dockerfile's curated COPY list omitted modules that `py-modules` requires,
  failing the build with "file <module>.py not found". It copies the checkout,
  filtered by .dockerignore.

Move PyQt5/pyqtgraph into a `desktop` extra so the web install is genuinely
Qt-free. Verified: `find` for Qt in a web-only venv now returns nothing, and the
desktop path still resolves and imports.

Add scripts/make_fixtures.py so the smoke suite has no network or ffmpeg
dependency (the tagged MP3 is optional and its assertions skip when absent), and
make the suite tolerate that. Verified against the *installed package* running
the container's own command line, from a directory with no source tree:
61/61 passing.

Also correct README claims that had gone stale: BPM (removed), the matplotlib
canvas (replaced by pyqtgraph), and font_control_widget.py (deleted).
2026-09-14 01:57:00 +09:00

356 lines
16 KiB
Python

#!/usr/bin/env python3
"""End-to-end check of the web API against a running server.
Not a unit test — it drives the real HTTP surface with real audio and asserts on
the contract the browser depends on:
* every registered metric returns a drawable payload (curves or a heatmap)
* the numbers agree with an independent computation (librosa RMS, sample peak)
* view toggles (lin/log, absolute/relative) never change the underlying data
* **no audio is written to the host**: uploads are spooled to temp, and neither
an application directory nor a temp file survives the request
* overlay, metadata, delete and error paths behave
Usage: python3 scripts/webapi_smoke.py [base_url] [audio_dir]
"""
from __future__ import annotations
import glob
import hashlib
import json
import math
import os
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8137"
AUDIO = Path(sys.argv[2] if len(sys.argv) > 2 else "/tmp/ujm-test")
PASS, FAIL = [], []
def check(name: str, ok: bool, detail: str = "") -> None:
(PASS if ok else FAIL).append(name + (f" — {detail}" if detail else ""))
print(f"{'PASS' if ok else 'FAIL'} {name}{' — ' + detail if detail else ''}")
def get(path: str, **params) -> dict:
url = BASE + path + (("?" + urllib.parse.urlencode(params)) if params else "")
with urllib.request.urlopen(url, timeout=600) as res:
return json.loads(res.read())
def post(path: str, payload=None, **params) -> dict:
url = BASE + path + (("?" + urllib.parse.urlencode(params)) if params else "")
data = json.dumps(payload).encode() if payload is not None else b""
req = urllib.request.Request(url, data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=600) as res:
return json.loads(res.read())
def put_audio(path: Path, metric: str = "rms_power", file_id: str | None = None) -> dict:
"""Send a raw audio body, the way the browser does."""
data = path.read_bytes()
qs = {"name": path.name, "metric": metric}
if file_id:
qs["file_id"] = file_id
req = urllib.request.Request(
f"{BASE}/api/analyse?" + urllib.parse.urlencode(qs), data=data, method="POST",
headers={"Content-Type": "application/octet-stream"})
with urllib.request.urlopen(req, timeout=900) as res:
return json.loads(res.read())
def expect_http_error(fn, code: int, name: str) -> None:
try:
fn()
except urllib.error.HTTPError as exc:
check(name, exc.code == code, f"HTTP {exc.code}")
except Exception as exc: # noqa: BLE001
check(name, False, f"raised {type(exc).__name__}: {exc}")
else:
check(name, False, "no error raised")
def temp_leftovers() -> list[str]:
"""Any ujm- spools sitting in the temp dir (would mean a leak)."""
return glob.glob(os.path.join(tempfile.gettempdir(), "ujm-*"))
def main() -> int:
files = sorted(AUDIO.glob("*.wav")) + sorted(AUDIO.glob("*.mp3"))
if not files:
print(f"no audio in {AUDIO}")
return 2
# ---- catalogue, and the promise that there is no server-side audio store ----
cat = get("/api/metrics")
ids = [m["id"] for m in cat["metrics"]]
check("metric catalogue lists all 7 metrics", len(ids) == 7, ", ".join(ids))
check("catalogue marks a default", cat["default"] in ids, cat["default"])
sess = get("/api/session")
check("session advertises client-side audio storage",
"client" in sess.get("audio_storage", ""), sess.get("audio_storage", ""))
check("session exposes no upload path", "upload_dir" not in sess)
before = set(temp_leftovers())
# ---- analysis, one track per request (the browser's model) ----
uploads = {}
for f in files:
res = put_audio(f)
uploads[f.name] = res
check(f"analyse {f.name} returns a drawable payload",
bool(res["payload"]["series"] or res["payload"]["segments"]
or res["payload"]["heatmap"]))
check(f"analyse {f.name} returns a content-hash id",
res["file"]["file_id"] == hashlib.sha256(f.read_bytes()).hexdigest()[:32],
res["file"]["file_id"][:12])
sine = next(r for n, r in uploads.items() if "sine440" in n)
noise = next(r for n, r in uploads.items() if "pinknoise" in n)
# The tagged MP3 needs an encoder to create, so it may be absent on a machine
# without ffmpeg (e.g. the container image). Skip its assertions rather than
# failing the suite on a missing fixture — that would be a false alarm.
tagged = next((r for r in uploads.values()
if r["file"]["song_name"].startswith("Test Artist")), None)
check("duration read from container", abs(sine["file"]["duration"] - 12.0) < 0.05,
f"{sine['file']['duration']}s")
check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100)
if tagged is None:
print("SKIP ID3 tag assertions — no tagged MP3 fixture (needs ffmpeg)")
else:
check("ID3 tags resolve to a song name",
tagged["file"]["song_name"] == "Test Artist - Test Title",
tagged["file"]["song_name"])
check("CJK-containing filename handled",
tagged["file"]["name"].endswith(".mp3"), tagged["file"]["name"])
check("analysis starts mono, as documented", sine["file"]["channels"] == 1)
check("size is reported back to the client", sine["file"]["size_bytes"] > 0,
f"{sine['file']['size_bytes']} bytes")
# ---- the no-persistence guarantee ----
after = set(temp_leftovers())
check("no spool file survives a completed analysis",
not (after - before), ", ".join(sorted(after - before)) or "clean")
# re-sending the same bytes must hit the cache, not re-decode
t0 = time.perf_counter()
again = put_audio(Path(AUDIO / "sine440.wav"), metric="waveform")
warm = time.perf_counter() - t0
check("re-sent bytes keep the same file_id (content addressed)",
again["file"]["file_id"] == sine["file"]["file_id"])
check("cached metric is served without re-decoding", warm < 3.0, f"{warm:.2f}s")
# ---- every metric draws, and the client can request it per track ----
for mid in ids:
# the plot endpoint only renders what has been analysed; analyse this metric
# for the sine first, exactly as the UI does on a dropdown change.
put_audio(Path(AUDIO / "sine440.wav"), metric=mid)
# restrict to the sine so per-metric shape is unambiguous
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
plot = get("/api/plot", metric=mid, x_mode="absolute")
ds = plot["datasets"]
if not ds:
check(f"metric {mid} returns a dataset", False, "no datasets")
continue
payload = ds[0]["payload"]
has_drawable = bool(payload["series"] or payload["segments"] or payload["heatmap"])
x_len = len(payload["series"][0]["x"]) if payload["series"] else (
len(payload["segments"][0]["x"]) if payload["segments"] else
(len(payload["heatmap"]["x"]) if payload["heatmap"] else 0))
check(f"metric {mid} draws", has_drawable and x_len > 1,
f"{len(payload['series'])} curves, {len(payload['segments'])} bands, "
f"{'heatmap' if payload['heatmap'] else 'no heatmap'}, x={x_len}")
if payload["heatmap"]:
hm = payload["heatmap"]
# TARGET_COLUMNS is a ceiling, not a floor: the hop is whichever is
# larger, N_FFT//4 (1024 samples) or len(y)/TARGET_COLUMNS.
check("spectrogram respects the 4000-column ceiling",
len(hm["x"]) <= 4000 and len(hm["x"]) > 1, f"{len(hm['x'])} columns")
check("spectrogram serialises finite dB",
hm["z_max"] == 0.0 and hm["z_min"] == -80.0,
f"z range {hm['z_min']}..{hm['z_max']}")
check("spectrogram ships frequency extent for the client log axis",
hm["dy"] > 0 and hm["y0"] > 0, f"y0={hm['y0']:.2f} dy={hm['dy']:.3f}")
# ---- physics sanity: independent recomputation ----
import librosa
import numpy as np
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
put_audio(Path(AUDIO / "sine440.wav"), metric="rms_power")
y, sr = librosa.load(str(AUDIO / "sine440.wav"), sr=None)
y = librosa.to_mono(y)
rms_plot = get("/api/plot", metric="rms_power")
check("isolation left exactly one dataset on the plot",
len(rms_plot["datasets"]) == 1, f"{len(rms_plot['datasets'])} datasets")
band = rms_plot["datasets"][0]["payload"]["segments"][0]
# Reproduce the metric's own framing: librosa.feature.rms at 10 s / 2 s.
expected = librosa.feature.rms(y=y, frame_length=10 * sr, hop_length=2 * sr).ravel()
reported = np.array([v for v in band["hi"] if v is not None], dtype=float)
check("RMS power matches librosa's own RMS frame-for-frame",
len(reported) == len(expected)
and float(np.max(np.abs(reported - expected))) < 1e-4,
f"{len(reported)} frames, max Δ {float(np.max(np.abs(reported - expected))):.2e}")
put_audio(Path(AUDIO / "sine440.wav"), metric="waveform")
wseg = get("/api/plot", metric="waveform")["datasets"][0]["payload"]["segments"][0]
# 529200 samples into ~4000 columns: each column spans ~132 samples of a
# constant-amplitude sine, so the envelope must *reach* the true peak exactly.
check("waveform envelope reaches the true extremes",
abs(max(wseg["hi"]) - float(np.max(y))) < 1e-6
and abs(min(wseg["lo"]) - float(np.min(y))) < 1e-6,
f"hi {max(wseg['hi']):.6f} vs peak {float(np.max(y)):.6f}")
put_audio(Path(AUDIO / "sine440.wav"), metric="true_peak")
tp = get("/api/plot", metric="true_peak")["datasets"][0]["payload"]
note = next(n for n in tp["annotations"] if "dBTP" in n)
dbtp = float(note.replace("Max:", "").replace("dBTP", "").strip())
expected_dbtp = 20 * math.log10(float(np.max(np.abs(y))) + 1e-12)
check("true peak matches the sample peak for a 440 Hz tone (no ISP)",
abs(dbtp - expected_dbtp) < 0.5,
f"api {dbtp:.2f} dBTP vs {expected_dbtp:.2f} dBFS")
put_audio(Path(AUDIO / "sine440.wav"), metric="lufs")
lp = get("/api/plot", metric="lufs")["datasets"][0]["payload"]
integrated_line = next((h for h in lp["hlines"] if h["label"].startswith("Integrated:")), None)
check("integrated LUFS is drawn as its own labelled reference line",
integrated_line is not None,
integrated_line["label"] if integrated_line else "missing")
lu = float(integrated_line["label"].split(":")[1].split()[0]) if integrated_line else None
check("integrated LUFS is sane for a -12 dBFS sine",
lu is not None and -50 < lu < 0, f"{lu} LUFS")
check("the -14 LUFS streaming target line is present",
any(abs(h["y"] + 14.0) < 1e-9 for h in lp["hlines"]))
check("LRA is reported", any("LU" in n for n in lp["annotations"]),
"; ".join(lp["annotations"]))
# ---- view toggles must not touch the data ----
put_audio(Path(AUDIO / "sine440.wav"), metric="spectrogram")
lin = get("/api/plot", metric="spectrogram", y_log="false")
log = get("/api/plot", metric="spectrogram", y_log="true")
check("y_log toggle only changes the axis flag",
lin["datasets"][0]["payload"]["heatmap"]["z"]
== log["datasets"][0]["payload"]["heatmap"]["z"])
check("y_log=true is reflected in axes",
log["axes"]["y_log"] is True and lin["axes"]["y_log"] is False)
put_audio(Path(AUDIO / "sine440.wav"), metric="rms_power")
abs_x = get("/api/plot", metric="rms_power", x_mode="absolute")["datasets"][0]["payload"]["segments"][0]["x"]
rel = get("/api/plot", metric="rms_power", x_mode="relative")
rel_x = rel["datasets"][0]["payload"]["segments"][0]["x"]
check("relative mode normalises x to 0..100",
abs(rel_x[0]) < 1e-6 and abs(rel_x[-1] - 100.0) < 1e-6,
f"{rel_x[0]:.4f} .. {rel_x[-1]:.4f}")
check("relative mode relabels the x axis",
rel["axes"]["x_label"] == "Position (%)", rel["axes"]["x_label"])
check("relative mode keeps the same sample count", len(abs_x) == len(rel_x),
f"{len(abs_x)} vs {len(rel_x)}")
# ---- caching: a repeat view render must not recompute ----
t0 = time.perf_counter()
first = get("/api/plot", metric="spectrogram")
t_cold = time.perf_counter() - t0
t0 = time.perf_counter()
second = get("/api/plot", metric="spectrogram")
t_warm = time.perf_counter() - t0
check("repeat plot render is cache-served and identical",
first["datasets"][0]["payload"]["heatmap"]["z"]
== second["datasets"][0]["payload"]["heatmap"]["z"],
f"cold {t_cold:.2f}s vs warm {t_warm:.2f}s")
# ---- overlay ----
all_ids = [r["file"]["file_id"] for r in uploads.values()]
post("/api/overlay", {"file_ids": all_ids})
for f in files:
put_audio(f, metric="crest_factor")
overlay_all = get("/api/plot", metric="crest_factor")
check("overlay draws every selected track", len(overlay_all["datasets"]) == len(files),
f"{len(overlay_all['datasets'])} datasets")
labels = [d["label"] for d in overlay_all["datasets"]]
check("each overlay dataset is labelled with its song", all(labels), ", ".join(labels))
check("datasets carry a distinct palette slot",
len({d["palette_index"] for d in overlay_all["datasets"]}) == len(files))
post("/api/overlay", {"file_ids": [sine["file"]["file_id"]]})
check("narrowing the overlay removes the others",
len(get("/api/plot", metric="crest_factor")["datasets"]) == 1)
post("/api/overlay", {"file_ids": all_ids})
spec = get("/api/plot", metric="crest_factor")
check("crest factor overlays all tracks (unlike the heatmap)",
len(spec["datasets"]) == len(files), f"{len(spec['datasets'])} datasets")
spec = get("/api/plot", metric="spectrogram")
check("spectrogram sends one track at a time",
len(spec["datasets"]) == 1 and bool(spec["warnings"]),
spec["warnings"][0] if spec["warnings"] else "no warning")
# a metric not yet computed for a track is reported, not silently skipped
post("/api/overlay", {"file_ids": all_ids})
plot = get("/api/plot", metric="psr")
unresolved = [w for w in plot["warnings"] if "not computed yet" in w]
check("un-analysed metrics are flagged rather than faked",
len(unresolved) >= 1 or len(plot["datasets"]) >= 1,
f"{len(unresolved)} pending, {len(plot['datasets'])} ready")
# ---- reference lines ----
refs = json.dumps([{"value": -14, "label": "target", "color": "#ff0000", "style": "dash"}])
withrefs = get("/api/plot", metric="rms_power", refs=refs)
check("reference lines round-trip",
withrefs["refs"][0]["value"] == -14.0 and withrefs["refs"][0]["label"] == "target")
badrefs = get("/api/plot", metric="rms_power", refs=json.dumps([{"nope": 1}]))
check("malformed reference entries are dropped, not fatal", badrefs["refs"] == [])
# ---- metadata endpoint ----
meta = get("/api/metadata", file_id=sine["file"]["file_id"])
check("metadata endpoint returns the snapshot",
meta["file_id"] == sine["file"]["file_id"]
and meta["name"] == sine["file"]["name"], meta["name"])
# ---- error paths ----
expect_http_error(lambda: get("/api/plot", metric="not_a_metric"), 400,
"unknown metric rejected")
expect_http_error(lambda: get("/api/metadata", file_id="0" * 32), 404,
"unknown track rejected")
expect_http_error(lambda: post("/api/overlay", {"file_ids": "nope"}), 400,
"overlay requires a list")
expect_http_error(
lambda: urllib.request.urlopen(urllib.request.Request(
f"{BASE}/api/analyse?name=evil.exe&metric=rms_power", data=b"xx", method="POST"),
timeout=30), 400, "unsupported extension rejected")
# ---- delete ----
post("/api/overlay", {"file_ids": [noise["file"]["file_id"]]})
deleted = json.loads(urllib.request.urlopen(urllib.request.Request(
f"{BASE}/api/file?" + urllib.parse.urlencode({"file_id": noise["file"]["file_id"]}),
method="DELETE"), timeout=60).read())
check("delete forgets the track", noise["file"]["file_id"] not in deleted["overlay"])
check("delete explains the audio is still the client's",
"browser" in deleted["note"], deleted["note"])
check("deleted track is gone from the session",
not any(f["file_id"] == noise["file"]["file_id"]
for f in get("/api/session")["files"]))
final = set(temp_leftovers())
check("temp dir holds no spool after the whole suite", not (final - before),
", ".join(sorted(final - before)) or "clean")
print(f"\n{len(PASS)} passed, {len(FAIL)} failed")
for f in FAIL:
print(" FAILED:", f)
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())