5df802a574
Reuses the analysis backend unchanged. metrics.py already split compute() from build_spec(), and plotspec.PlotSpec is backend-agnostic, so the web UI reimplements only the renderer: uPlot for curves/bands, a canvas for the spectrogram heatmap. No PyQt5, pyqtgraph or matplotlib on the web path. Uploaded audio is never persisted on the host. The browser copies each file into Origin Private File System (sandboxed browser storage) and streams the bytes to the server per request; the server spools each body to a temp file, decodes, computes, and unlinks it in a finally before responding. There is no upload directory, no cache of audio, and no session state keyed on a path: file_id is the SHA-256 of the audio, so re-sending a master for another metric reuses the first decode. Verified: a spool is observable mid-request and gone after the response. Being precise: the bytes do have to reach the server, since librosa decodes from a path and the analysis is CPU-bound numpy/scipy. The claim is that running this does not accumulate masters on the host, not that audio is never on the machine mid-request. New: metrics_json.py PlotSpec/metric data -> JSON (the renderer seam) web_session.py headless analysis session, content-addressed tracks webapp.py FastAPI routes, raw-body analysis, ujm-web entry point static/ browser front end + test suite scripts/webapi_smoke.py 61 end-to-end assertions against a running server Also fixes a bug the direct-uvicorn dev loop had hidden: the CLI advertises logger_setup's WARN level but passed it straight to uvicorn, which only knows 'warning' -- `ujm-web --log-level WARN` died with KeyError: 'warn'. Removes an earlier /api/collect debug route that served every source module over HTTP. pyproject: 'web' extra (fastapi, uvicorn, python-multipart), package-data for static/, Apache-2.0 licence.
143 lines
6.4 KiB
Markdown
143 lines
6.4 KiB
Markdown
# 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
|
||
- **BPM detection** via librosa
|
||
- **CJK-safe font system** with custom fonts loaded from `fonts/` (gitignored), system fallbacks, and a live font selector
|
||
- **Background analysis thread** so the UI stays responsive; metric switches
|
||
compute off the GUI thread and cache, so re-selecting a metric is instant
|
||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||
|
||
### Roadmap
|
||
See [CLAUDE.md](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](https://docs.astral.sh/uv/). With uv installed:
|
||
|
||
```bash
|
||
uv sync
|
||
uv run ujm
|
||
```
|
||
|
||
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
|
||
|
||
### Logging flags
|
||
```bash
|
||
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](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.
|
||
|
||
```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
|
||
```
|
||
|
||
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.
|
||
|
||
### Verifying it
|
||
|
||
With a server running:
|
||
|
||
```bash
|
||
python3 scripts/webapi_smoke.py # 61 assertions, needs ffmpeg for the fixtures
|
||
python3 scripts/webapi_smoke.py http://host:port /path/to/audio
|
||
```
|
||
|
||
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
|
||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
||
through `uv.lock`. Python 3.10+.
|
||
|
||
The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
|
||
(`uv sync --extra web`).
|
||
|
||
## 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, RMS rolling window, BPM |
|
||
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
|
||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
||
| `font_control_widget.py` | Font picker + size slider |
|
||
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
||
| `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`.
|