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
+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.