Document the in-browser web UI and its parity tests

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
This commit is contained in:
2026-09-14 10:16:52 +09:00
parent da4231ad36
commit 6ae4d5e8e3
2 changed files with 115 additions and 140 deletions
+71 -88
View File
@@ -39,12 +39,11 @@ uv sync --extra desktop
uv run ujm
```
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser version).
`uv sync` on its own installs only the analysis dependencies (librosa, numpy,
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
`desktop` extra, kept separate because nothing on the web path imports it. Add
`--all-extras` to get both front ends.
`desktop` extra.
### Logging flags
```bash
@@ -59,102 +58,81 @@ 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.
A browser version that runs entirely inside the tab, in `web/` (branch
`feature/web-js`). There is no Python backend: decoding and all seven metrics are
JavaScript ports of `master_core.py` / `metrics.py`, computed in a Web Worker and
drawn from the same `PlotSpec`-shaped payloads the desktop renderer uses.
```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
```
A server install needs no Qt at all, which is also what keeps the container
image small.
**Audio never leaves the browser.** A dropped file is read into memory, decoded,
reduced to the mono signal the metrics use and analysed — nothing is uploaded,
cached or written to storage, and closing the tab discards it. This is enforced,
not just promised: the page is served with `Content-Security-Policy: connect-src
'none'`, so neither the page nor its worker is permitted to make any network
request.
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).
overlay, lin/log toggles, absolute/relative time and draggable reference lines
(kept per metric), plus the metrics' own reference lines (−14 LUFS, integrated
loudness, 0 dBTP…). Analysis runs at the file's native sample rate.
**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.
Serve `web/` with any static file server, over `http://` or `https://` — no
secure context is needed. (ES modules and workers don't load from `file://`, so
opening `index.html` directly won't work.)
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.
```bash
docker compose up -d --build # nginx + the CSP, http://localhost:8080
python3 -m http.server -d web 8080 # quick local try (no CSP header)
```
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.
### How the port matches the Python
- **Decoding** (`web/decode.js`): WAV and AIFF (PCM 8/16/24/32-bit, float 32/64,
WAVE_FORMAT_EXTENSIBLE) are parsed directly with libsndfile's sample scaling,
so the signal is bit-identical to `librosa.load`. Other formats (FLAC, MP3, Ogg
Vorbis/Opus, AAC) use the browser's decoder through an `OfflineAudioContext`
running at the sample rate read from the file header, so nothing is resampled.
MP3 artist/title come from ID3 tags, as on the desktop.
- **Metrics** (`web/dsp.js`): the same algorithms and numerics — pyloudnorm's
K-weighting through scipy's `lfilter` recurrence, BS.1770 gating and EBU LRA,
`resample_poly`'s 81-tap Kaiser filter for true peak, `librosa.stft` framing and
`amplitude_to_db` for the spectrogram. Running sums and window maxima are
single streaming passes, so no signal-length intermediate arrays are allocated.
### Parity tests
`tests/parity` checks the port against the Python on synthetic fixtures that
cover what the metric code branches on — shorter than each window, silence,
passages below the relative gate, 96 kHz, every PCM layout, lossy formats:
```bash
uv run --no-project --with numpy --with soundfile \
python tests/parity/fixtures.py /tmp/ujm-parity
uv run --no-project --with librosa --with pyloudnorm --with scipy --with mutagen \
python tests/parity/reference.py /tmp/ujm-ref /tmp/ujm-parity/*
node tests/parity/parity.mjs /tmp/ujm-ref
```
`reference.py` saves the exact mono signal `metrics.py` analysed, and
`parity.mjs` runs `web/dsp.js` on that same signal and compares every output
field. The float64 paths (loudness, crest factor, PSR) agree to ~1e-12, the
float32 paths (true peak, spectrogram dB, RMS) to ~1e-6.
### Deploying with Docker
A `docker compose` template is provided:
```bash
docker compose up --build # build and start
open http://localhost:8000
```
The image is `web` extra only — no Qt, no display server — and runs as an
unprivileged user (uid 10001). It is built multi-stage so the compiler toolchain
that scipy/numba need never reaches the runtime layer; the runtime image installs
the built wheel, so the same image works from a checkout or a release tarball.
What the template assumes, and why:
- **There is no audio volume.** Not an oversight: the app keeps no audio, so
there is nothing to persist. `read_only: true` with a `/tmp` tmpfs means
request spools are memory-backed and never touch a disk at all.
- **Loopback publishing.** The app binds `0.0.0.0` inside the container (the CLI
default of `127.0.0.1` would be unreachable from the host), but compose
publishes `127.0.0.1:8000:8000`. This tool has no authentication, so it should
not appear on the LAN by accident. Change to `"8000:8000"` or put it behind a
reverse proxy to expose it deliberately.
- **CPU limits, not just memory.** Analysis is CPU-bound numpy/scipy, so the BLAS
thread pools are pinned (`OMP_NUM_THREADS` etc.) to stop overlapping requests
from oversubscribing. Raise them if you raise `cpus`.
- `cap_drop: [ALL]` and `no-new-privileges` — nothing in the image needs
capabilities.
For a read-only root filesystem with no writable layer at all, the defaults
already give you that.
### Verifying it
With a server running:
```bash
python3 scripts/make_fixtures.py # synthesise test audio
python3 scripts/webapi_smoke.py # 61 assertions
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
```
`make_fixtures.py` generates the audio the suite needs (`sine440.wav`,
`pinknoise.wav`) with numpy alone, so no network and no ffmpeg are required — a
tagged MP3 is added only if ffmpeg happens to be present, and the tag assertions
skip cleanly when it is not. 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.
The image is nginx (unprivileged, read-only root filesystem) serving `web/` with
`deploy/nginx.conf`, which sets the CSP and turns access logging off. There is
nothing to persist, upload or log on the server side, so there are no volumes.
### 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 |
| `web/decode.js` | File → mono Float32Array at native rate (direct WAV/AIFF, browser decoder otherwise); ID3 tags |
| `web/dsp.js` | Ports of every `Metric.compute`; pure functions (also run under Node for the parity tests) |
| `web/metrics.js` | Metric registry + `build_spec` ports producing plot payloads; relative-time transform |
| `web/analysis-worker.js` | Web Worker holding each track's signal and computing one metric per request |
| `web/app.js` | Track list, overlay, result cache, foreground/prefetch job queue, uPlot + canvas renderer |
| `deploy/nginx.conf` | Static serving, CSP (`connect-src 'none'`), no access log |
## Dependencies
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
@@ -162,7 +140,9 @@ Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
Optional extras:
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
The web UI has no dependencies beyond uPlot, vendored in `web/vendor/`. The parity
tests need Node 18+ plus the Python analysis dependencies.
## Architecture
@@ -186,5 +166,8 @@ Subclass `Metric` in `metrics.py`, implement `compute(audio_file) -> data` (the
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`.
desktop dropdown automatically.
For the web UI, port `compute` to `web/dsp.js` (add it to `COMPUTE`) and
`build_spec` to `web/metrics.js` (add it to `METRICS`), then add its fields to the
tolerance table in `tests/parity/parity.mjs` so the parity run checks it.