mikkeli 5420975f8e Version web assets by content so caches can't serve stale scripts
Cloudflare forces browser caching of .js/.css to max-age=14400 regardless
of the origin's no-cache, while the page itself stays uncached. After a
deploy, browsers paired the new index.html with the old app.js, which
failed on upload with "Cannot set properties of null (setting
'innerHTML')".

The image build now copies every asset into v/<content hash>/ and rewrites
index.html's references; nginx serves v/ as immutable and the page as
no-cache, so each deploy changes every asset URL. Security headers move to
a snippet included per location, since nginx drops server-level add_header
in locations that set their own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015UXA39Sh9coAx4FhRkd8R2
2026-09-15 17:15:01 +09:00
2026-05-28 14:05:34 +09:00
2026-05-28 14:05:34 +09:00
2026-09-14 10:16:52 +09:00

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 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 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:

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.

S
Description
Utility providing metrics to evaluate masterings.
Readme Apache-2.0 1.1 MiB
Languages
Python 100%