Files
uj-mastering-master/README.md
T

174 lines
8.4 KiB
Markdown
Raw Normal View History

2024-04-10 20:14:02 +09:00
# uj-mastering-master
2026-05-28 14:05:34 +09:00
Custom mastering toolkit providing visual metrics for evaluating audio masterings.
2025-08-22 00:26:27 +09:00
Developed with Claude Code assistance.
## Features
2026-05-28 14:05:34 +09:00
### 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
2026-09-14 01:57:00 +09:00
- **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
2026-09-14 01:57:00 +09:00
- **pyqtgraph renderer**: one persistent `PlotItem` reused across replots, with
per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis)
2025-08-22 00:26:27 +09:00
2026-05-28 14:05:34 +09:00
### Roadmap
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
dynamic range (DR meter), plot-style controls, interactive axis controls.
2025-08-22 00:26:27 +09:00
2026-05-28 14:05:34 +09:00
## Quick start
2025-08-22 00:26:27 +09:00
2026-05-28 14:05:34 +09:00
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
2025-08-22 00:26:27 +09:00
```bash
2026-09-14 01:57:00 +09:00
uv sync --extra desktop
2026-05-28 14:05:34 +09:00
uv run ujm
2025-08-22 00:26:27 +09:00
```
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser version).
2026-05-28 14:05:34 +09:00
2026-09-14 01:57:00 +09:00
`uv sync` on its own installs only the analysis dependencies (librosa, numpy,
scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the
`desktop` extra.
2026-09-14 01:57:00 +09:00
2026-05-28 14:05:34 +09:00
### Logging flags
2025-08-22 00:26:27 +09:00
```bash
2026-05-28 14:05:34 +09:00
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
uv run ujm --log-file # also write audio_analysis.log
2025-08-22 00:26:27 +09:00
```
2026-05-28 14:05:34 +09:00
### 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.
2025-08-22 00:26:27 +09:00
2026-09-14 01:44:48 +09:00
## 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.
2026-09-14 01:44:48 +09:00
**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.
2026-09-14 01:57:00 +09:00
2026-09-14 01:44:48 +09:00
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.
2026-09-14 01:44:48 +09:00
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.)
2026-09-14 01:57:00 +09:00
```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)
2026-09-14 01:57:00 +09:00
```
### How the port matches the Python
2026-09-14 01:57:00 +09:00
- **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.
2026-09-14 01:57:00 +09:00
### Parity tests
2026-09-14 01:57:00 +09:00
`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:
2026-09-14 01:44:48 +09:00
```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
2026-09-14 01:44:48 +09:00
```
`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.
2026-09-14 01:44:48 +09:00
### 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 |
2026-09-14 01:44:48 +09:00
2026-05-28 14:05:34 +09:00
## Dependencies
2026-09-14 01:57:00 +09:00
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
`pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
2025-08-22 00:26:27 +09:00
2026-09-14 01:57:00 +09:00
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.
2026-09-14 01:44:48 +09:00
2026-05-28 14:05:34 +09:00
## Architecture
2025-08-22 00:26:27 +09:00
2026-05-28 14:05:34 +09:00
| Module | Responsibility |
| --- | --- |
| `main.py` | `MainWindow` + the `ujm` entry point |
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
2026-09-14 01:57:00 +09:00
| `master_core.py` | `AudioFile`: native-rate librosa loading, rolling RMS |
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
2026-09-14 01:57:00 +09:00
| `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 |
2026-05-28 14:05:34 +09:00
| `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
2026-09-14 01:44:48 +09:00
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.