Files
uj-mastering-master/README.md
T

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