6ae4d5e8e3
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
174 lines
8.4 KiB
Markdown
174 lines
8.4 KiB
Markdown
# 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 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.
|
||
|
||
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.
|
||
|
||
### 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.
|