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 - **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 - **Font Management**: CJK-capable, fixed UI font (M PLUS 1 Code @ 10pt) with system fallback
- **Threading & Logging**: Robust background processing with detailed logging system - **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 ### Technical stack
- **Audio Processing**: librosa, numpy - **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 gone with the matplotlib render path. If wanted back, it belongs in the
renderer, applied uniformly to every metric — not per-metric renderer, applied uniformly to every metric — not per-metric
#### `web_session.py` #### `web/` (browser version — no backend)
- Server-side equivalent of `AnalysisResultsManager`, without Qt: owns the - A static site: decoding and every metric run in the visitor's tab; nothing is
per-track metric cache and the overlay set, and mirrors the desktop's uploaded or stored. Served with `Content-Security-Policy: connect-src 'none'`
compute-off-the-request-thread behaviour (`deploy/nginx.conf`), so the page and its worker cannot make network requests
- **No audio is stored.** Tracks are keyed by `file_id` = SHA-256 of the audio, - `decode.js` — `AudioFile.__init__` counterpart. WAV/AIFF PCM & float are parsed
and the session keeps only analysis results and metadata. There is no upload directly with libsndfile scaling (bit-identical to `librosa.load`; Chromium's
directory and no cache of bytes own WAV decoder scales positive int16 by 1/32767). Other formats go through
- Because the key is a content hash, re-sending the same master for a second `decodeAudioData` on an `OfflineAudioContext` at the header-sniffed native rate
metric reuses the first decode — the client only re-uploads what the server has (no resampling). ID3 artist/title for MP3. Downmix = float32 channel mean
never seen (checked via `metrics_cached`) - `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` #### `tests/parity/`
- FastAPI routes + static mount + the `ujm-web` entry point (binds loopback by - `fixtures.py` synthesises edge-case audio (short / silent / below the relative
default; `--host`/`--port`/`--open` to override) gate, 96 kHz, every PCM layout, lossy formats via ffmpeg); `reference.py` runs
- Analysis endpoints take the audio as a **raw request body**, not a multipart `metrics.py` and saves the exact mono signal it analysed; `parity.mjs` runs
upload: spool to a temp file, `SESSION.observe(...)`, unlink in `finally`. The `web/dsp.js` on that signal and compares every field (float64 paths ~1e-12,
bytes exist on disk only for the duration of that one request float32 paths ~1e-6)
- `POST /api/analyse` (analyse + plot data), `POST /api/file` (register without - Any change to `metrics.py` numerics must be mirrored in `web/dsp.js` and pass this
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
#### `master_core.py` #### `master_core.py`
- Defines the `AudioFile` class: librosa loading, rolling RMS power. BPM detection - 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 - matplotlib: Colormaps only (consumed by pyqtgraph) + librosa dependency
- mutagen: Audio metadata extraction - mutagen: Audio metadata extraction
- PyQt5: GUI framework - 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 ### Architecture considerations
- Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral - Three-stage split: `metrics.compute` (heavy, worker thread, backend-neutral
@@ -294,19 +289,16 @@ uv run ujm # launch the GUI
``` ```
### Running the web UI ### 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 ```bash
uv sync --extra web docker compose up -d --build # nginx + CSP, http://localhost:8080
uv run ujm-web # http://127.0.0.1:8000 python3 -m http.server -d web 8080 # quick local try (no CSP header)
uv run ujm-web --host 0.0.0.0 --port 8080 # reachable from another machine
``` ```
Uploaded audio is held in the browser's Origin Private File System and streamed Audio is decoded and analysed in the tab and never leaves it (CSP
to the server per request; the server spools each body to a temp file and unlinks `connect-src 'none'`). Check the port against `metrics.py` with the parity tests
it before responding. Nothing is written to the host beyond that transient spool. (`tests/parity`, see README).
Verify with `python3 scripts/webapi_smoke.py` against a running server (needs
`ffmpeg` for the fixtures).
Optional flags (handled by `logger_setup.parse_log_args`): Optional flags (handled by `logger_setup.parse_log_args`):
```bash ```bash
+71 -88
View File
@@ -39,12 +39,11 @@ uv sync --extra desktop
uv run ujm 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, `uv sync` on its own installs only the analysis dependencies (librosa, numpy,
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
`desktop` extra, kept separate because nothing on the web path imports it. Add `desktop` extra.
`--all-extras` to get both front ends.
### Logging flags ### Logging flags
```bash ```bash
@@ -59,102 +58,81 @@ See [CJK_FONTS.md](CJK_FONTS.md) for details.
## Web UI ## Web UI
The same analysis backend with a browser front end, on branch `feature/web-ui`. A browser version that runs entirely inside the tab, in `web/` (branch
No PyQt5, no matplotlib: `metrics.py` was already split into a backend-neutral `feature/web-js`). There is no Python backend: decoding and all seven metrics are
`compute` and a view-aware `build_spec`, so the web UI reuses the analysis code JavaScript ports of `master_core.py` / `metrics.py`, computed in a Web Worker and
unchanged and reimplements only the drawing. drawn from the same `PlotSpec`-shaped payloads the desktop renderer uses.
```bash **Audio never leaves the browser.** A dropped file is read into memory, decoded,
uv sync --extra web reduced to the mono signal the metrics use and analysed — nothing is uploaded,
uv run ujm-web # http://127.0.0.1:8000 cached or written to storage, and closing the tab discards it. This is enforced,
uv run ujm-web --host 0.0.0.0 --port 8080 --open 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.
A server install needs no Qt at all, which is also what keeps the container
image small.
Everything the desktop app does works the same way: all seven metrics, the Everything the desktop app does works the same way: all seven metrics, the
overlay, lin/log toggles, absolute/relative time, and draggable reference lines overlay, lin/log toggles, absolute/relative time and draggable reference lines
(kept per metric, as on the desktop). (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 Serve `web/` with any static file server, over `http://` or `https://` — no
into Origin Private File System — its own sandboxed storage — and streams the secure context is needed. (ES modules and workers don't load from `file://`, so
bytes to the server for one request at a time. The server spools each body to a opening `index.html` directly won't work.)
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 ```bash
`librosa` decodes from a path and the analysis is CPU-bound numpy/scipy. docker compose up -d --build # nginx + the CSP, http://localhost:8080
*Running this does not accumulate the user's masters on the host* — nothing python3 -m http.server -d web 8080 # quick local try (no CSP header)
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 ### How the port matches the Python
nothing is fetched from a CDN, and the server binds loopback by default.
- **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 ### Deploying with Docker
A `docker compose` template is provided: 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
```bash nothing to persist, upload or log on the server side, so there are no volumes.
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.
### Web architecture ### Web architecture
| Module | Responsibility | | Module | Responsibility |
| --- | --- | | --- | --- |
| `web_session.py` | Headless analysis session: content-addressed track cache, metric cache, overlay | | `web/decode.js` | File → mono Float32Array at native rate (direct WAV/AIFF, browser decoder otherwise); ID3 tags |
| `metrics_json.py` | `PlotSpec` → JSON (the renderer seam; the desktop renderer is the other consumer) | | `web/dsp.js` | Ports of every `Metric.compute`; pure functions (also run under Node for the parity tests) |
| `webapp.py` | FastAPI routes, raw-body analysis endpoints, static mount, `ujm-web` entry point | | `web/metrics.js` | Metric registry + `build_spec` ports producing plot payloads; relative-time transform |
| `static/audio-store.js` | OPFS audio store, content hashing, streamed upload | | `web/analysis-worker.js` | Web Worker holding each track's signal and computing one metric per request |
| `static/app.js` | Web renderer: same `PlotSpec` drawn with uPlot + canvas | | `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 ## Dependencies
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`, Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
@@ -162,7 +140,9 @@ Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
Optional extras: Optional extras:
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI) - `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 ## 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` heavy part, runs on the worker thread) and `build_spec(data, view) -> PlotSpec`
(cheap, view-aware, reruns on every scale toggle without recomputation). Register (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 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 desktop dropdown automatically.
renderers consume the same `PlotSpec`.
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.