Add web UI: same backend, browser front end

Reuses the analysis backend unchanged. metrics.py already split compute()
from build_spec(), and plotspec.PlotSpec is backend-agnostic, so the web UI
reimplements only the renderer: uPlot for curves/bands, a canvas for the
spectrogram heatmap. No PyQt5, pyqtgraph or matplotlib on the web path.

Uploaded audio is never persisted on the host. The browser copies each file
into Origin Private File System (sandboxed browser storage) and streams the
bytes to the server per request; the server spools each body to a temp file,
decodes, computes, and unlinks it in a finally before responding. There is no
upload directory, no cache of audio, and no session state keyed on a path:
file_id is the SHA-256 of the audio, so re-sending a master for another metric
reuses the first decode. Verified: a spool is observable mid-request and gone
after the response.

Being precise: the bytes do have to reach the server, since librosa decodes
from a path and the analysis is CPU-bound numpy/scipy. The claim is that
running this does not accumulate masters on the host, not that audio is never
on the machine mid-request.

New:
  metrics_json.py       PlotSpec/metric data -> JSON (the renderer seam)
  web_session.py        headless analysis session, content-addressed tracks
  webapp.py             FastAPI routes, raw-body analysis, ujm-web entry point
  static/               browser front end + test suite
  scripts/webapi_smoke.py  61 end-to-end assertions against a running server

Also fixes a bug the direct-uvicorn dev loop had hidden: the CLI advertises
logger_setup's WARN level but passed it straight to uvicorn, which only knows
'warning' -- `ujm-web --log-level WARN` died with KeyError: 'warn'.

Removes an earlier /api/collect debug route that served every source module
over HTTP.

pyproject: 'web' extra (fastapi, uvicorn, python-multipart), package-data for
static/, Apache-2.0 licence.
This commit is contained in:
2026-09-14 01:44:48 +09:00
parent 99d99b866f
commit 5df802a574
14 changed files with 4022 additions and 19 deletions
+451
View File
@@ -0,0 +1,451 @@
# SPDX-License-Identifier: Apache-2.0
"""
Web front end for the mastering toolkit.
Same backend as the desktop app — `master_core.AudioFile` for decode,
`metrics.METRICS[...].compute` for the numbers — with the PyQt5 layer replaced by
a JSON API and a browser canvas. `metrics.Metric` was already split into a
backend-neutral `compute` and a view-aware `build_spec`, so this costs no changes
to the analysis code; `metrics_json` is the new renderer seam.
Run it:
uv run ujm-web # http://127.0.0.1:8000
uv run ujm-web --host 0.0.0.0 --port 8080 --open
Design notes:
- **The browser holds the audio; the server holds only computed data.** The
client keeps every uploaded track in OPFS and PUTs the bytes for each analysis
request. The server spools the body to a temp file, decodes, computes, and
deletes it inside a `finally` — there is no upload directory and no audio file
survives a request. `web_session`'s docstring explains why the bytes still have
to reach this process (librosa decodes by path) and what that does and doesn't
guarantee. Concretely: hosting this does not accumulate the user's masters on
the host.
- **Nothing is keyed on a client-supplied path.** `file_id` is the SHA-256 of the
audio bytes, so there is no traversal surface and no filename collision; the
only place a client string reaches the spool is its extension, which is matched
against an allow-list.
- **Analysis runs in the event loop's threadpool, not in the loop.** Metric
computes are CPU-bound numpy/scipy calls that do release the GIL; a plain `def`
endpoint is dispatched to Starlette's threadpool, so a long spectrogram doesn't
block status requests.
- **Bodies are read as a stream.** `await request.stream()` is written straight to
the spool, so a 200 MB master never sits in RAM as one `bytes` object.
- **Nothing is served by a CDN.** The front end (uPlot for curves, a plain canvas
for the spectrogram) is vendored under `static/` and the server binds loopback
by default: mastering work-in-progress shouldn't leak out of the box.
- **`/api/plot` is a pure view render.** Lin/log, time mode and the reference-line
set are query parameters, so a view change costs a JSON re-serialisation from
the cache — never a recompute. That is the property that made the desktop app
feel fast; the web UI keeps it.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import logging
import tempfile
from pathlib import Path
from typing import Any, Optional
from fastapi import Body, FastAPI, HTTPException, Query, Request
from fastapi.concurrency import run_in_threadpool
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from logger_setup import setup_logging
from metrics import METRICS, DEFAULT_METRIC_ID
from metrics_json import serialize_ref_lines, serialize_spec, serialize_view
from plotspec import RefLineProps, ViewState, X_ABSOLUTE, X_RELATIVE, apply_x_mode
from web_session import AUDIO_SUFFIXES, SESSION, DEFAULT_METRIC, file_id_for
STATIC_DIR = Path(__file__).parent / "static"
logger = logging.getLogger(__name__)
app = FastAPI(
title="uj-mastering-master (web)",
description="Audio mastering analysis metrics over HTTP.",
version="0.1.0",
)
# ---- request helpers -------------------------------------------------------
def _query(request: Request, key: str) -> Optional[str]:
"""Read a query parameter.
FastAPI's `Query` only populates declared signature parameters, and these
endpoints take a raw body rather than a parameter list, so the two values the
client sends alongside the audio are pulled off the URL directly.
"""
value = request.query_params.get(key)
return value or None
def _suffix_or_400(name: str) -> str:
suffix = Path(name or "").suffix.lower()
if suffix not in AUDIO_SUFFIXES:
raise HTTPException(
status_code=400,
detail=f"Unsupported format '{suffix or '?'}' "
f"(expected one of {', '.join(sorted(AUDIO_SUFFIXES))})",
)
return suffix
def _require_file(file_id: str):
entry = SESSION.entry(file_id)
if entry is None:
raise HTTPException(status_code=404, detail="Track not analysed in this session")
return entry
def _metric_or_400(metric: Optional[str]) -> str:
uid = metric or DEFAULT_METRIC_ID
if uid not in METRICS:
raise HTTPException(status_code=400, detail=f"Unknown metric: {uid}")
return uid
# ---- catalogue & session ---------------------------------------------------
def _metric_catalogue() -> list[dict]:
return [
{
"id": m.id,
"display_name": m.display_name,
"default": m.id == DEFAULT_METRIC_ID,
}
for m in METRICS.values()
]
@app.get("/api/metrics")
def list_metrics():
return {"metrics": _metric_catalogue(), "default": DEFAULT_METRIC_ID}
@app.get("/api/session")
def session_state():
return {
"files": [f.metadata() for f in SESSION.files.values()],
"overlay": list(SESSION.overlay),
"default_metric": DEFAULT_METRIC_ID,
# Explicit, so the front end can state it in the UI and a reader of the API
# can confirm there is no audio store behind this server.
"audio_storage": "client (browser OPFS) — nothing persisted server-side",
}
# ---- analysis --------------------------------------------------------------
@app.post("/api/analyse")
async def analyse(
request: Request,
file_id: Optional[str] = Query(None, description="SHA-256 of the body; computed if absent"),
name: str = Query("upload", description="Original filename, for display only"),
metric: Optional[str] = Query(None),
):
"""Decode and measure one track, sent as a raw body.
Returns the snapshot plus the serialised metric payload in a single round trip:
the client has to send the bytes either way, so making it then ask again for the
plot would upload the same file twice.
Declared `async` so the body can be streamed to the spool with `await`; the
decode/compute that follows is a blocking call, so it is handed to the
threadpool explicitly to keep the event loop free.
"""
uid = _metric_or_400(metric)
suffix = _suffix_or_400(name)
display_name = Path(name).name
spool = tempfile.NamedTemporaryFile(prefix="ujm-", suffix=suffix, delete=False)
path = Path(spool.name)
try:
size = 0
with spool:
async for chunk in request.stream():
spool.write(chunk)
size += len(chunk)
if size == 0:
raise HTTPException(status_code=400, detail="Empty request body")
fid = file_id or await run_in_threadpool(_hash_file, path)
def _work() -> tuple[Any, Any]:
entry = SESSION.load(path, fid, display_name, size)
data = SESSION.compute_metric(path, fid, uid, display_name, size)
spec = METRICS[uid].build_spec(data, ViewState())
return entry, spec
entry, spec = await run_in_threadpool(_work)
finally:
# The guarantee this design makes: no audio outlives the request.
path.unlink(missing_ok=True)
payload = serialize_spec(spec)
return {
"file": entry.metadata(),
"metric": uid,
"metric_name": METRICS[uid].display_name,
"payload": payload,
"axes": payload["axes"],
"overlay": list(SESSION.overlay),
}
def _hash_file(path: Path) -> str:
"""SHA-256 of a spooled body, read in chunks."""
digest = hashlib.sha256()
with path.open("rb") as handle:
for block in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()[:32]
# ---- plotting --------------------------------------------------------------
@app.get("/api/metadata")
def metadata(file_id: str):
return _require_file(file_id).metadata()
@app.post("/api/file")
async def analyse_extra(request: Request, file_id: Optional[str] = Query(None),
name: str = Query("upload")):
"""Register a track without plotting it.
Used when the client wants the file on the list (duration, amplitudes, tags)
for a metric that is already drawn, so the plot endpoint doesn't need to
re-upload it.
"""
suffix = _suffix_or_400(name)
display_name = Path(name).name
spool = tempfile.NamedTemporaryFile(prefix="ujm-", suffix=suffix, delete=False)
path = Path(spool.name)
try:
size = 0
with spool:
async for chunk in request.stream():
spool.write(chunk)
size += len(chunk)
if size == 0:
raise HTTPException(status_code=400, detail="Empty request body")
fid = file_id or await run_in_threadpool(_hash_file, path)
entry = await run_in_threadpool(SESSION.observe, path, fid, display_name, size)
finally:
path.unlink(missing_ok=True)
return {"file": entry.metadata(), "overlay": list(SESSION.overlay)}
@app.post("/api/overlay")
def set_overlay(payload: dict = Body(...)):
ids = payload.get("file_ids")
if not isinstance(ids, list):
raise HTTPException(status_code=400, detail="'file_ids' must be a list")
return {"overlay": SESSION.set_overlay([str(i) for i in ids])}
@app.delete("/api/file")
def delete_file(file_id: str):
if not SESSION.drop(file_id):
raise HTTPException(status_code=404, detail="Track not analysed in this session")
return {"overlay": list(SESSION.overlay),
"note": "server forgot the analysis; the browser still holds the audio"}
@app.get("/api/plot")
def plot(
metric: str = Query(DEFAULT_METRIC),
y_log: Optional[bool] = Query(None),
x_log: Optional[bool] = Query(None),
x_mode: str = Query(X_ABSOLUTE),
refs: str = Query("", description="JSON list of reference lines"),
):
"""Draw the current overlay of `metric`, honouring view state.
Pure view render: every dataset here was already computed by `/api/analyse`, so
a lin/log flip or a time-mode change never touches numpy. Returns one
serialised dataset per drawn file plus the shared axis description, mirroring
the desktop `show_specs([(label, PlotSpec, colour), ...])`.
"""
if metric not in METRICS:
raise HTTPException(status_code=400, detail=f"Unknown metric: {metric}")
if x_mode not in (X_ABSOLUTE, X_RELATIVE):
raise HTTPException(status_code=400, detail=f"Unknown x_mode: {x_mode}")
view = ViewState(y_log=y_log, x_log=x_log, x_mode=x_mode)
ref_lines = _parse_refs(refs)
entries = SESSION.overlay_entries()
if not entries:
return {
"metric": metric, "metric_name": METRICS[metric].display_name,
"view": serialize_view(view), "refs": serialize_ref_lines(ref_lines),
"datasets": [], "axes": None, "warnings": [],
}
datasets, warnings = [], []
for index, entry in enumerate(entries):
data = entry.metric_data.get(metric)
if data is None:
# Never analysed this metric for this track: the client has the bytes and
# knows to send them. Say so rather than recomputing from nothing.
warnings.append(f"{entry.snapshot.song_name}: {METRICS[metric].display_name} "
f"not computed yet")
continue
try:
spec = METRICS[metric].build_spec(data, view)
except Exception as exc:
logger.exception("build_spec failed for %s", metric)
warnings.append(f"{entry.snapshot.song_name}: {exc}")
continue
if view.x_mode == X_RELATIVE:
spec = apply_x_mode(spec, view.x_mode)
datasets.append({
"file_id": entry.snapshot.file_id,
"label": entry.snapshot.song_name,
"index": index,
"payload": serialize_spec(spec),
# The client assigns colours; it keeps a song's colour stable across
# metric switches the same way the desktop palette-by-row does.
"palette_index": _palette_index(entry.snapshot.file_id),
})
# Heatmaps don't overlay: only the first drawn dataset is rendered, as on the
# desktop, so the browser isn't sent z-grids it will throw away.
if datasets and datasets[0]["payload"]["heatmap"] is not None and len(datasets) > 1:
warnings.append(
f"{METRICS[metric].display_name}: one track at a time — "
f"showing {datasets[0]['label']}"
)
datasets = datasets[:1]
return {
"metric": metric,
"metric_name": METRICS[metric].display_name,
"view": serialize_view(view),
"refs": serialize_ref_lines(ref_lines),
"datasets": datasets,
"axes": datasets[0]["payload"]["axes"] if datasets else None,
"warnings": warnings,
"recompute_free": True,
}
def _palette_index(file_id: str) -> int:
"""Stable palette slot for a track, by first-seen order in the session."""
order = list(SESSION.files.keys())
try:
return order.index(file_id)
except ValueError:
return 0
def _parse_refs(raw: str) -> list[RefLineProps]:
if not raw:
return []
try:
items = json.loads(raw)
except ValueError:
raise HTTPException(status_code=400, detail="refs must be JSON")
if not isinstance(items, list):
raise HTTPException(status_code=400, detail="refs must be a JSON list")
lines = []
for item in items:
if not isinstance(item, dict) or "value" not in item:
continue
try:
value = float(item["value"])
except (TypeError, ValueError):
continue
lines.append(RefLineProps(
value=value,
color=str(item.get("color", "#444444")),
style=str(item.get("style", "dash")),
label=str(item.get("label", "")),
))
return lines
# ---- front end -------------------------------------------------------------
@app.get("/")
def index():
index_file = STATIC_DIR / "index.html"
if not index_file.exists():
raise HTTPException(status_code=500, detail="Front end assets missing")
return FileResponse(index_file)
if STATIC_DIR.exists():
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
@app.exception_handler(Exception)
async def unhandled(request, exc): # pragma: no cover - safety net
logger.exception("Unhandled error on %s", request.url.path)
return JSONResponse(status_code=500, content={"detail": str(exc)})
# ---- entry point -----------------------------------------------------------
# Our CLI speaks `logger_setup`'s vocabulary (ERROR/WARN/INFO/DEBUG/TRACE);
# uvicorn only accepts its own names. Translate rather than passing through —
# `--log-level WARN` used to die with KeyError: 'warn'.
_UVICORN_LEVEL = {
"ERROR": "error",
"WARN": "warning",
"INFO": "info",
"DEBUG": "debug",
"TRACE": "debug",
}
def main() -> None:
import uvicorn
parser = argparse.ArgumentParser(
prog="ujm-web",
description="Mastering analysis toolkit — web front end",
)
parser.add_argument("--host", default="127.0.0.1",
help="interface to bind (default: %(default)s — loopback)")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--open", action="store_true",
help="open the UI in a browser on startup")
parser.add_argument("--reload", action="store_true", help="auto-reload on edits")
parser.add_argument("--log-level", "-l", default="INFO",
choices=["ERROR", "WARN", "INFO", "DEBUG", "TRACE"])
parser.add_argument("--log-file", action="store_true",
help="also write audio_analysis.log")
args = parser.parse_args()
setup_logging(level=args.log_level, log_to_file=args.log_file)
url = f"http://{args.host if args.host != '0.0.0.0' else '127.0.0.1'}:{args.port}"
logger.info("Web UI at %s", url)
logger.info("Audio is stored in the browser (OPFS); "
"uploads are spooled to temp and deleted after each request")
if args.open:
import threading
import webbrowser
threading.Timer(1.0, lambda: webbrowser.open(url)).start()
uvicorn.run("webapp:app", host=args.host, port=args.port, reload=args.reload,
log_level=_UVICORN_LEVEL[args.log_level])
if __name__ == "__main__":
main()