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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user