# uj-mastering-master Custom mastering toolkit providing visual metrics for evaluating audio masterings. Developed with Claude Code assistance. ## Features ### Current - **PyQt5 GUI**: drag-and-drop or file-dialog ingest of `.mp3`, `.wav`, `.flac` - **Switchable metrics** via a dropdown, all sharing one analysis cache: - **RMS Power** — 10 s rolling window with adaptive colour scale - **Waveform** — min/max envelope, fixed ±1.1 scale - **LUFS** — BS.1770 short-term (3 s) + integrated + loudness range (LRA) - **Crest Factor** — peak-to-RMS spread over time - **PSR** — peak-to-short-term-loudness ratio ("is it still breathing?") - **True Peak** — 4× oversampled dBTP, catches inter-sample peaks - **Spectrogram** — log-frequency STFT power heatmap over time - **Always-labelled axis extremes**: every plot forces its exact min/max onto the ticks, so you can read the true range even on a log axis (e.g. the spectrogram's 22 kHz top, which otherwise falls between decade ticks) - **Native sample rate**: audio is loaded without resampling, so the full band (up to the file's own nyquist, e.g. ~22 kHz for 44.1 kHz files) is analysed - **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored) and system fallbacks; the UI is pinned to M PLUS 1 Code @ 10pt - **Background analysis thread** so the UI stays responsive; metric switches compute off the GUI thread and cache, so re-selecting a metric is instant - **pyqtgraph renderer**: one persistent `PlotItem` reused across replots, with per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis) ### Roadmap See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term: dynamic range (DR meter), plot-style controls, interactive axis controls. ## Quick start This project uses [uv](https://docs.astral.sh/uv/). With uv installed: ```bash uv sync --extra desktop uv run ujm ``` `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. ### Logging flags ```bash uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE uv run ujm --log-file # also write audio_analysis.log ``` ### Fonts Drop `.ttf` / `.otf` / `.ttc` files into `fonts/` to get them in the font selector. The directory is gitignored to avoid bundling licensed font data. See [CJK_FONTS.md](CJK_FONTS.md) for details. ## Web UI 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. **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, an in-plot legend, wheel zoom around the cursor (Ctrl: time only, Shift: values only, or scroll over an axis), live drag-to-pan and double-click to reset, and draggable reference lines (kept per metric) alongside the metrics' own guides (−14 LUFS, integrated loudness, 0 dBTP…). The metadata panel and file list show integrated loudness, LRA and max true peak. Analysis runs at the file's native sample rate. 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.) ```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) ``` ### 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 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. Assets are served from a content-hashed `v//` directory (cached indefinitely) while the page itself is always revalidated, so a deploy can never leave a browser running old scripts against a new page — even behind a CDN that overrides cache headers. ### Web architecture | Module | Responsibility | | --- | --- | | `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`, `pyloudnorm` — pinned through `uv.lock`. Python 3.10+. Optional extras: - `desktop` — `PyQt5`, `pyqtgraph` (the GUI) The web UI has no dependencies beyond uPlot, vendored in `web/vendor/`. The parity tests need Node 18+ plus the Python analysis dependencies. ## Architecture | Module | Responsibility | | --- | --- | | `main.py` | `MainWindow` + the `ujm` entry point | | `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache | | `master_core.py` | `AudioFile`: native-rate librosa loading, rolling RMS | | `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) | | `audio_visualization_widget.py` | pyqtgraph `PlotItem` host; renders `PlotSpec` | | `plotspec.py` | Renderer-neutral plot description (`Curve`/`Band`/`HLine`/`Heatmap`) | | `font_manager.py` | Custom + system CJK font discovery, Qt font config | | `plot_control_widget.py` | Metric selector, log-frequency toggle, time-axis mode | | `ref_line_widget.py` | Reference-line control; values kept per metric, in its own units | | `logger_setup.py` | CLI log-level parsing + custom TRACE level | | `setup_fonts.py` | Diagnostic utility (run standalone) | ### Adding a metric 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 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.