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
+67 -4
View File
@@ -39,7 +39,7 @@ uv sync
uv run ujm
```
`uv run ujm` is the only supported entry point — it boots the GUI.
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
### Logging flags
```bash
@@ -52,10 +52,71 @@ Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font
selector. The directory is gitignored to avoid bundling licensed font data.
See [CJK_FONTS.md](CJK_FONTS.md) for details.
## Web UI
The same analysis backend with a browser front end, on branch `feature/web-ui`.
No PyQt5, no matplotlib: `metrics.py` was already split into a backend-neutral
`compute` and a view-aware `build_spec`, so the web UI reuses the analysis code
unchanged and reimplements only the drawing.
```bash
uv sync --extra web
uv run ujm-web # http://127.0.0.1:8000
uv run ujm-web --host 0.0.0.0 --port 8080 --open
```
Everything the desktop app does works the same way: all seven metrics, the
overlay, lin/log toggles, absolute/relative time, and draggable reference lines
(kept per metric, as on the desktop).
**Uploaded audio is never written to the host.** The browser copies each file
into Origin Private File System — its own sandboxed storage — and streams the
bytes to the server for one request at a time. The server spools each body to a
temp file, decodes, computes, and unlinks it in a `finally` before the response
is sent. There is no upload directory, no cache of audio, and no session state
keyed on a file path: `file_id` is the SHA-256 of the audio, so re-sending the
same master for a second metric reuses the first decode.
Being precise about the guarantee: the bytes do have to reach the server, because
`librosa` decodes from a path and the analysis is CPU-bound numpy/scipy.
*Running this does not accumulate the user's masters on the host* — nothing
persists between requests. It is not a claim that audio is never on the machine
mid-request.
The front end is served entirely from `static/` with uPlot vendored locally, so
nothing is fetched from a CDN, and the server binds loopback by default.
### Verifying it
With a server running:
```bash
python3 scripts/webapi_smoke.py # 61 assertions, needs ffmpeg for the fixtures
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
```
The suite drives the real HTTP surface with real audio and checks the contract
the browser depends on — payload shapes per metric, physics cross-checked
against an independent `librosa` computation, view toggles that must not alter
data, error paths, and that no spool file survives a request.
### Web architecture
| Module | Responsibility |
| --- | --- |
| `web_session.py` | Headless analysis session: content-addressed track cache, metric cache, overlay |
| `metrics_json.py` | `PlotSpec` → JSON (the renderer seam; the desktop renderer is the other consumer) |
| `webapp.py` | FastAPI routes, raw-body analysis endpoints, static mount, `ujm-web` entry point |
| `static/audio-store.js` | OPFS audio store, content hashing, streamed upload |
| `static/app.js` | Web renderer: same `PlotSpec` drawn with uPlot + canvas |
## Dependencies
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
through `uv.lock`. Python 3.10+.
The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
(`uv sync --extra web`).
## Architecture
| Module | Responsibility |
@@ -74,6 +135,8 @@ through `uv.lock`. Python 3.10+.
### Adding a metric
Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
heavy part, runs on the worker thread) and `render(data, file_path) -> Figure`
(cheap, runs on the GUI thread). Register the instance in the `METRICS` dict at
the bottom of the file — it shows up in the dropdown automatically.
heavy part, runs on the worker thread) and `build_spec(data, view) -> PlotSpec`
(cheap, view-aware, reruns on every scale toggle without recomputation). Register
the instance in the `METRICS` dict at the bottom of the file — it shows up in the
desktop dropdown and in the web UI's metric list automatically, because both
renderers consume the same `PlotSpec`.