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
+44 -52
View File
@@ -11,6 +11,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- **Modular GUI Architecture**: Complete PyQt5 interface with drag-and-drop and file dialog support
- **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
- **Threading & Logging**: Robust background processing with detailed logging system
- **Web UI** (`web/`): the same metrics ported to in-browser JavaScript — a static
site; audio never leaves the tab
### Technical stack
- **Audio Processing**: librosa, numpy
@@ -109,49 +111,41 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
gone with the matplotlib render path. If wanted back, it belongs in the
renderer, applied uniformly to every metric — not per-metric
#### `web_session.py`
- Server-side equivalent of `AnalysisResultsManager`, without Qt: owns the
per-track metric cache and the overlay set, and mirrors the desktop's
compute-off-the-request-thread behaviour
- **No audio is stored.** Tracks are keyed by `file_id` = SHA-256 of the audio,
and the session keeps only analysis results and metadata. There is no upload
directory and no cache of bytes
- Because the key is a content hash, re-sending the same master for a second
metric reuses the first decode — the client only re-uploads what the server has
never seen (checked via `metrics_cached`)
#### `web/` (browser version — no backend)
- A static site: decoding and every metric run in the visitor's tab; nothing is
uploaded or stored. Served with `Content-Security-Policy: connect-src 'none'`
(`deploy/nginx.conf`), so the page and its worker cannot make network requests
- `decode.js` — `AudioFile.__init__` counterpart. WAV/AIFF PCM & float are parsed
directly with libsndfile scaling (bit-identical to `librosa.load`; Chromium's
own WAV decoder scales positive int16 by 1/32767). Other formats go through
`decodeAudioData` on an `OfflineAudioContext` at the header-sniffed native rate
(no resampling). ID3 artist/title for MP3. Downmix = float32 channel mean
- `dsp.js` — ports of every `Metric.compute`: pure functions over the mono
Float32Array (run in the worker and under Node). Same numerics as the Python:
sequential float64 prefix sums sampled only at window edges (no N-length
arrays), pyloudnorm K-weighting via scipy `lfilter`'s DF2T recurrence (streamed),
`resample_poly(y, 4, 1)`'s 81-tap Kaiser(5) filter + monotonic-deque window max,
`librosa.stft` centred framing + `amplitude_to_db(ref=max, top_db=80)`.
Loudness (short-term, integrated, LRA) is one memoised pass shared by LUFS and PSR
- `metrics.js` — registry + `build_spec` ports producing the plot payload
(`{axes, hlines, annotations, segments, series, heatmap}`), plus `applyRelativeX`
- `analysis-worker.js` — Web Worker: holds each track's mono signal, computes one
metric per request, transfers typed arrays back
- `app.js` — `MainWindow` + `AnalysisResultsManager` + renderer: track list,
overlay set, per-metric result cache, job queue (foreground jobs jump ahead of
background prefetch), uPlot for curves/bands + canvas spectrogram (log axis
drawn from linear rows), draggable reference lines per metric, metric hlines
- `vendor/` — uPlot 1.6.32, vendored (the CSP only allows same-origin scripts)
- Needs an HTTP server (module scripts and workers don't load from `file://`),
but no secure context — plain `http://` on a LAN works
#### `webapp.py`
- FastAPI routes + static mount + the `ujm-web` entry point (binds loopback by
default; `--host`/`--port`/`--open` to override)
- Analysis endpoints take the audio as a **raw request body**, not a multipart
upload: spool to a temp file, `SESSION.observe(...)`, unlink in `finally`. The
bytes exist on disk only for the duration of that one request
- `POST /api/analyse` (analyse + plot data), `POST /api/file` (register without
plotting), `DELETE /api/file`, `POST /api/overlay`, `GET /api/plot`,
`GET /api/metadata`, `GET /api/metrics`, `GET /api/session`
#### `metrics_json.py`
- The renderer seam: `PlotSpec`/metric data → JSON, so a non-pyqtgraph renderer
consumes the identical spec (curves, bands, hlines, heatmap, axis specs) that
the desktop widget draws. `hlines` carry the integrated LUFS reference line —
it is not an annotation
#### `static/` (web front end)
- `audio-store.js` — OPFS store. Copies each picked/dropped `File` into Origin
Private File System (browser-sandboxed, not the host's filesystem), hashes it
with WebCrypto in chunks while streaming (never loads a whole master into
memory), and re-sends bytes to the server per metric
- `app.js` — the web renderer, an ES module importing the store. Draws the same
`PlotSpec` the desktop does: uPlot for curves/bands, a plain canvas for the
spectrogram heatmap (a canvas can map rows through an arbitrary function, so it
draws native linear rows and maps them, unlike pyqtgraph's affine-only
`ImageItem`). Mirrors the desktop's per-metric colour cycle, lin/log toggles,
absolute/relative time, and draggable reference lines
- `vendor/` — uPlot 1.6.32 (IIFE + CSS), vendored so nothing is fetched from a
CDN at runtime
- Boot rehydrates: the server still has the last session's analysis, so it
re-offers audio only for metric/track combinations the server reports as
uncached, and reports unavailable combinations rather than faking them
#### `tests/parity/`
- `fixtures.py` synthesises edge-case audio (short / silent / below the relative
gate, 96 kHz, every PCM layout, lossy formats via ffmpeg); `reference.py` runs
`metrics.py` and saves the exact mono signal it analysed; `parity.mjs` runs
`web/dsp.js` on that signal and compares every field (float64 paths ~1e-12,
float32 paths ~1e-6)
- Any change to `metrics.py` numerics must be mirrored in `web/dsp.js` and pass this
#### `master_core.py`
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection
@@ -269,7 +263,8 @@ A custom mastering toolkit that provides metrics to evaluate audio masterings th
- matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
- mutagen: Audio metadata extraction
- PyQt5: GUI framework
- fastapi / uvicorn: web UI (optional extra; the desktop app does not need them)
- Web UI: no Python dependencies; uPlot is vendored in `web/vendor/`. The parity
tests need Node 18+
### Architecture considerations
- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
@@ -294,19 +289,16 @@ uv run ujm # launch the GUI
```
### Running the web UI
Branch `feature/web-ui`. Same backend, browser front end.
Branch `feature/web-js`. Static files in `web/`; everything runs in the browser.
```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 # reachable from another machine
docker compose up -d --build # nginx + CSP, http://localhost:8080
python3 -m http.server -d web 8080 # quick local try (no CSP header)
```
Uploaded audio is held in the browser's Origin Private File System and streamed
to the server per request; the server spools each body to a temp file and unlinks
it before responding. Nothing is written to the host beyond that transient spool.
Verify with `python3 scripts/webapi_smoke.py` against a running server (needs
`ffmpeg` for the fixtures).
Audio is decoded and analysed in the tab and never leaves it (CSP
`connect-src 'none'`). Check the port against `metrics.py` with the parity tests
(`tests/parity`, see README).
Optional flags (handled by `logger_setup.parse_log_args`):
```bash