irene a1fed2cd71 Add docker compose deployment template
Multi-stage image built from the `web` extra only, so it carries no Qt: the
whole web path (webapp, web_session, metrics_json, and the shared analysis
modules) imports no Qt, pyqtgraph or matplotlib canvas. The build stage has the
compiler scipy/numba need; the runtime layer installs the built wheel, so the
same image works from a checkout or a release tarball.

Compose declares no audio volume on purpose. The app keeps no audio, so there is
nothing to persist: `read_only` plus a /tmp tmpfs means request spools are
memory-backed and never reach a disk. Publishing is loopback-only because the
tool has no authentication, and BLAS thread pools are pinned because analysis is
CPU-bound.

Fix three packaging bugs the build surfaced, each of which broke the image:

- `pip install --extra web` is not valid for a source tree; uv wants `.[web]`
  and failed with "Requesting extras requires a pylock.toml".
- The wheel shipped no static assets at all. `package-data` keys match
  *packages*, and the app modules are top-level `py-modules`, so the `"*"` key
  matched nothing - verified by inspecting the built wheel: 14 .py files, zero
  HTML/JS/CSS. An installed `ujm-web` would have 404'd every asset. `static/` is
  now declared a package (hence its packaging-shim `__init__.py`) so the assets
  install next to the modules, which is where `webapp.py` looks for them.
- The Dockerfile's curated COPY list omitted modules that `py-modules` requires,
  failing the build with "file <module>.py not found". It copies the checkout,
  filtered by .dockerignore.

Move PyQt5/pyqtgraph into a `desktop` extra so the web install is genuinely
Qt-free. Verified: `find` for Qt in a web-only venv now returns nothing, and the
desktop path still resolves and imports.

Add scripts/make_fixtures.py so the smoke suite has no network or ffmpeg
dependency (the tagged MP3 is optional and its assertions skip when absent), and
make the suite tolerate that. Verified against the *installed package* running
the container's own command line, from a directory with no source tree:
61/61 passing.

Also correct README claims that had gone stale: BPM (removed), the matplotlib
canvas (replaced by pyqtgraph), and font_control_widget.py (deleted).
2026-09-14 01:57:00 +09:00
2026-05-28 14:05:34 +09:00
2026-05-28 14:05:34 +09:00
2026-09-14 01:57:00 +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 front end).

uv sync on its own installs only the analysis dependencies (librosa, numpy, scipy, mutagen, pyloudnorm). The GUI stack — PyQt5, pyqtgraph — is the desktop extra, kept separate because nothing on the web path imports it. Add --all-extras to get both front ends.

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

The same analysis backend with a browser front end, on branch feature/web-ui. No PyQt5, no matplotlib: metrics.py was already split into a backend-neutral compute and a view-aware build_spec, so the web UI reuses the analysis code unchanged and reimplements only the drawing.

uv sync --extra web
uv run ujm-web                          # http://127.0.0.1:8000
uv run ujm-web --host 0.0.0.0 --port 8080 --open

A server install needs no Qt at all, which is also what keeps the container image small.

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, as on the desktop).

Uploaded audio is never written to the host. The browser copies each file into Origin Private File System — its own sandboxed storage — and streams the bytes to the server for one request at a time. The server spools each body to a temp file, decodes, computes, and unlinks it in a finally before the response is sent. There is no upload directory, no cache of audio, and no session state keyed on a file path: file_id is the SHA-256 of the audio, so re-sending the same master for a second metric reuses the first decode.

Being precise about the guarantee: the bytes do have to reach the server, because librosa decodes from a path and the analysis is CPU-bound numpy/scipy. Running this does not accumulate the user's masters on the host — nothing persists between requests. It is not a claim that audio is never on the machine mid-request.

The front end is served entirely from static/ with uPlot vendored locally, so nothing is fetched from a CDN, and the server binds loopback by default.

Deploying with Docker

A docker compose template is provided:

docker compose up --build        # build and start
open http://localhost:8000

The image is web extra only — no Qt, no display server — and runs as an unprivileged user (uid 10001). It is built multi-stage so the compiler toolchain that scipy/numba need never reaches the runtime layer; the runtime image installs the built wheel, so the same image works from a checkout or a release tarball.

What the template assumes, and why:

  • There is no audio volume. Not an oversight: the app keeps no audio, so there is nothing to persist. read_only: true with a /tmp tmpfs means request spools are memory-backed and never touch a disk at all.
  • Loopback publishing. The app binds 0.0.0.0 inside the container (the CLI default of 127.0.0.1 would be unreachable from the host), but compose publishes 127.0.0.1:8000:8000. This tool has no authentication, so it should not appear on the LAN by accident. Change to "8000:8000" or put it behind a reverse proxy to expose it deliberately.
  • CPU limits, not just memory. Analysis is CPU-bound numpy/scipy, so the BLAS thread pools are pinned (OMP_NUM_THREADS etc.) to stop overlapping requests from oversubscribing. Raise them if you raise cpus.
  • cap_drop: [ALL] and no-new-privileges — nothing in the image needs capabilities.

For a read-only root filesystem with no writable layer at all, the defaults already give you that.

Verifying it

With a server running:

python3 scripts/make_fixtures.py                    # synthesise test audio
python3 scripts/webapi_smoke.py                     # 61 assertions
python3 scripts/webapi_smoke.py http://host:port /path/to/audio

make_fixtures.py generates the audio the suite needs (sine440.wav, pinknoise.wav) with numpy alone, so no network and no ffmpeg are required — a tagged MP3 is added only if ffmpeg happens to be present, and the tag assertions skip cleanly when it is not. The suite drives the real HTTP surface with real audio and checks the contract the browser depends on — payload shapes per metric, physics cross-checked against an independent librosa computation, view toggles that must not alter data, error paths, and that no spool file survives a request.

Web architecture

Module Responsibility
web_session.py Headless analysis session: content-addressed track cache, metric cache, overlay
metrics_json.py PlotSpec → JSON (the renderer seam; the desktop renderer is the other consumer)
webapp.py FastAPI routes, raw-body analysis endpoints, static mount, ujm-web entry point
static/audio-store.js OPFS audio store, content hashing, streamed upload
static/app.js Web renderer: same PlotSpec drawn with uPlot + canvas

Dependencies

Core (always installed): librosa, numpy, matplotlib, mutagen, pyloudnorm — pinned through uv.lock. Python 3.10+.

Optional extras:

  • desktop — PyQt5, pyqtgraph (the GUI)
  • web — fastapi, uvicorn, python-multipart (the browser front end)

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 and in the web UI's metric list automatically, because both renderers consume the same PlotSpec.

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