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).
This commit is contained in:
@@ -20,11 +20,11 @@ Developed with Claude Code assistance.
|
||||
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
|
||||
- **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
|
||||
- **Embedded matplotlib canvas** with auto-regenerated plots on font change
|
||||
- **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](CLAUDE.md) for the full development roadmap. Near-term:
|
||||
@@ -35,12 +35,17 @@ dynamic range (DR meter), plot-style controls, interactive axis controls.
|
||||
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
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
|
||||
```bash
|
||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
||||
@@ -65,6 +70,9 @@ 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).
|
||||
@@ -86,19 +94,57 @@ 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:
|
||||
|
||||
```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.
|
||||
|
||||
### Verifying it
|
||||
|
||||
With a server running:
|
||||
|
||||
```bash
|
||||
python3 scripts/webapi_smoke.py # 61 assertions, needs ffmpeg for the fixtures
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -111,11 +157,12 @@ data, error paths, and that no spool file survives a request.
|
||||
| `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+.
|
||||
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
|
||||
`pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
|
||||
|
||||
The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
|
||||
(`uv sync --extra web`).
|
||||
Optional extras:
|
||||
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
|
||||
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -123,12 +170,13 @@ The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
|
||||
| --- | --- |
|
||||
| `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 |
|
||||
| `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` | 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 |
|
||||
| `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) |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user