5df802a574
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.
452 lines
16 KiB
Python
452 lines
16 KiB
Python
# 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()
|