Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
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
PlotItemreused across replots, with per-axis zoom modifiers (Ctrl+scroll for time only, Shift+scroll for the value axis)
Roadmap
See 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. With uv installed:
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
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 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.)
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 tolibrosa.load. Other formats (FLAC, MP3, Ogg Vorbis/Opus, AAC) use the browser's decoder through anOfflineAudioContextrunning 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'slfilterrecurrence, BS.1770 gating and EBU LRA,resample_poly's 81-tap Kaiser filter for true peak,librosa.stftframing andamplitude_to_dbfor 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:
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.