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`
|
2026-06-07 00:06:50 +09:00
|
|
|
|
- **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
|
2026-06-07 00:06:50 +09:00
|
|
|
|
- **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:
|
2026-06-07 00:06:50 +09:00
|
|
|
|
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
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-14 01:44:48 +09:00
|
|
|
|
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
|
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, kept separate because nothing on the web path imports it. Add
|
|
|
|
|
|
`--all-extras` to get both front ends.
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
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
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-14 01:57:00 +09:00
|
|
|
|
A server install needs no Qt at all, which is also what keeps the container
|
|
|
|
|
|
image small.
|
|
|
|
|
|
|
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, 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.
|
|
|
|
|
|
|
2026-09-14 01:57:00 +09:00
|
|
|
|
### Deploying with Docker
|
|
|
|
|
|
|
|
|
|
|
|
A `docker compose` template is provided:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
|
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.
|
|
|
|
|
|
|
2026-09-14 01:44:48 +09:00
|
|
|
|
### Verifying it
|
|
|
|
|
|
|
|
|
|
|
|
With a server running:
|
|
|
|
|
|
|
|
|
|
|
|
```bash
|
2026-09-14 01:57:00 +09:00
|
|
|
|
python3 scripts/make_fixtures.py # synthesise test audio
|
|
|
|
|
|
python3 scripts/webapi_smoke.py # 61 assertions
|
2026-09-14 01:44:48 +09:00
|
|
|
|
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-09-14 01:57:00 +09:00
|
|
|
|
`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.
|
2026-09-14 01:44:48 +09:00
|
|
|
|
|
|
|
|
|
|
### 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 |
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
|
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 |
|
2026-05-30 00:42:45 +09:00
|
|
|
|
| `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 |
|
2026-06-07 00:06:50 +09:00
|
|
|
|
| `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) |
|
2026-05-30 00:42:45 +09:00
|
|
|
|
|
|
|
|
|
|
### 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 and in the web UI's metric list automatically, because both
|
|
|
|
|
|
renderers consume the same `PlotSpec`.
|