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:
@@ -0,0 +1,29 @@
|
|||||||
|
# Build context hygiene. The image needs the source modules and static/, but
|
||||||
|
# nothing else — a fat context here means slow builds and a bigger surface for
|
||||||
|
# accidentally baking in local state (audio, caches, .env, ssh keys).
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.venv
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Tests and tooling are not needed to run the service.
|
||||||
|
tests/
|
||||||
|
scripts/
|
||||||
|
.claude/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Local state that must never enter an image.
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
fonts/
|
||||||
|
*.ttf
|
||||||
|
*.otf
|
||||||
|
*.ttc
|
||||||
|
audio_analysis.log
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
# uj-mastering-master — web UI
|
||||||
|
#
|
||||||
|
# Multi-stage on purpose: the build stage compiles wheels (librosa pulls in
|
||||||
|
# scipy, numba and llvmlite, all of which have C extensions), and none of that
|
||||||
|
# toolchain belongs in the runtime image.
|
||||||
|
#
|
||||||
|
# The runtime image installs the *installed package*, not a source tree, so the
|
||||||
|
# same image works whether you built it from a checkout or a release tarball.
|
||||||
|
#
|
||||||
|
# Note on audio: the web UI stores uploaded audio in the browser (OPFS) and
|
||||||
|
# spools each request body to /tmp only for the duration of that request. There
|
||||||
|
# is no volume for audio, because the app never keeps any — see README.
|
||||||
|
#
|
||||||
|
# Note on the desktop app: this image is the *web* front end only. PyQt5 and
|
||||||
|
# pyqtgraph are deliberately not installed — there is no display in a container.
|
||||||
|
|
||||||
|
# ---- build -----------------------------------------------------------------
|
||||||
|
FROM python:3.11-slim AS build
|
||||||
|
|
||||||
|
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
UV_LINK_MODE=copy
|
||||||
|
|
||||||
|
# numba/llvmlite and scipy need a compiler; libsndfile is what soundfile
|
||||||
|
# (librosa's decoder) links against at runtime, but the wheel needs it at build
|
||||||
|
# time to be detected.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
build-essential \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
# Copy the whole checkout minus .dockerignore. Deliberately not a hand-picked
|
||||||
|
# file list: `py-modules` in pyproject.toml enumerates every module the build
|
||||||
|
# backend must find, and a curated COPY that omits any of them fails the build
|
||||||
|
# with "file <module>.py not found". The exclusions that matter (venv, caches,
|
||||||
|
# .git, local state) are in .dockerignore, which is the right place for them.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build a wheel, then install it into a self-contained prefix to copy forward.
|
||||||
|
# `"uj-mastering-master[web]"` (not `--extra web`) is the syntax uv wants when
|
||||||
|
# installing a source tree — `--extra` needs a lockfile/setup.py it does not
|
||||||
|
# have here and fails with "Requesting extras requires a pylock.toml...".
|
||||||
|
RUN uv venv /opt/venv \
|
||||||
|
&& uv pip install --python /opt/venv/bin/python --no-cache \
|
||||||
|
".[web]"
|
||||||
|
|
||||||
|
# ---- runtime ---------------------------------------------------------------
|
||||||
|
FROM python:3.11-slim AS runtime
|
||||||
|
|
||||||
|
# libsndfile1 is the only runtime system dependency: librosa decodes through
|
||||||
|
# soundfile, which dlopens it. Everything else is a self-contained wheel.
|
||||||
|
# (FFmpeg is *not* needed — it only ever generated test fixtures.)
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
libsndfile1 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Non-root, with a home it can actually write to: librosa/numba want a cache
|
||||||
|
# dir, and the app spools request bodies under /tmp.
|
||||||
|
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin ujm
|
||||||
|
|
||||||
|
COPY --from=build /opt/venv /opt/venv
|
||||||
|
|
||||||
|
ENV PATH=/opt/venv/bin:$PATH \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
HOME=/home/ujm \
|
||||||
|
NUMBA_CACHE_DIR=/tmp/numba-cache \
|
||||||
|
MPLCONFIGDIR=/tmp/matplotlib
|
||||||
|
|
||||||
|
# The app's temporary spools land here. Declared so a read-only root filesystem
|
||||||
|
# can still be used (`read_only: true` + `tmpfs: /tmp` in compose).
|
||||||
|
VOLUME ["/tmp"]
|
||||||
|
|
||||||
|
USER ujm
|
||||||
|
WORKDIR /home/ujm
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Baked-in healthcheck: hit the API rather than the page, so it fails when the
|
||||||
|
# *backend* is broken and not merely when a static asset is missing.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
|
||||||
|
CMD ["python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
|
||||||
|
|
||||||
|
# 0.0.0.0, not the CLI default of 127.0.0.1: loopback inside a container is
|
||||||
|
# unreachable from the host, which is the whole point of publishing a port.
|
||||||
|
CMD ["ujm-web", "--host", "0.0.0.0", "--port", "8000", "--log-level", "INFO"]
|
||||||
@@ -20,11 +20,11 @@ Developed with Claude Code assistance.
|
|||||||
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
|
spectrogram's 22 kHz top, which otherwise falls between decade ticks)
|
||||||
- **Native sample rate**: audio is loaded without resampling, so the full band
|
- **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
|
(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) and system fallbacks; the UI is pinned to M PLUS 1 Code @ 10pt
|
||||||
- **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
|
- **Background analysis thread** so the UI stays responsive; metric switches
|
||||||
compute off the GUI thread and cache, so re-selecting a metric is instant
|
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
|
### Roadmap
|
||||||
See [CLAUDE.md](CLAUDE.md) for the full development roadmap. Near-term:
|
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:
|
This project uses [uv](https://docs.astral.sh/uv/). With uv installed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv sync
|
uv sync --extra desktop
|
||||||
uv run ujm
|
uv run ujm
|
||||||
```
|
```
|
||||||
|
|
||||||
`uv run ujm` boots the desktop GUI (see *Web UI* for the browser front end).
|
`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
|
### Logging flags
|
||||||
```bash
|
```bash
|
||||||
uv run ujm --log-level DEBUG # ERROR | WARN | INFO | DEBUG | TRACE
|
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
|
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
|
Everything the desktop app does works the same way: all seven metrics, the
|
||||||
overlay, lin/log toggles, absolute/relative time, and draggable reference lines
|
overlay, lin/log toggles, absolute/relative time, and draggable reference lines
|
||||||
(kept per metric, as on the desktop).
|
(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
|
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.
|
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
|
### Verifying it
|
||||||
|
|
||||||
With a server running:
|
With a server running:
|
||||||
|
|
||||||
```bash
|
```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
|
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
|
`make_fixtures.py` generates the audio the suite needs (`sine440.wav`,
|
||||||
the browser depends on — payload shapes per metric, physics cross-checked
|
`pinknoise.wav`) with numpy alone, so no network and no ffmpeg are required — a
|
||||||
against an independent `librosa` computation, view toggles that must not alter
|
tagged MP3 is added only if ffmpeg happens to be present, and the tag assertions
|
||||||
data, error paths, and that no spool file survives a request.
|
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
|
### 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 |
|
| `static/app.js` | Web renderer: same `PlotSpec` drawn with uPlot + canvas |
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
`librosa`, `numpy`, `matplotlib`, `mutagen`, `pyloudnorm`, `PyQt5` — all pinned
|
Core (always installed): `librosa`, `numpy`, `matplotlib`, `mutagen`,
|
||||||
through `uv.lock`. Python 3.10+.
|
`pyloudnorm` — pinned through `uv.lock`. Python 3.10+.
|
||||||
|
|
||||||
The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
|
Optional extras:
|
||||||
(`uv sync --extra web`).
|
- `desktop` — `PyQt5`, `pyqtgraph` (the GUI)
|
||||||
|
- `web` — `fastapi`, `uvicorn`, `python-multipart` (the browser front end)
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -123,12 +170,13 @@ The web UI additionally needs `fastapi`, `uvicorn` and `python-multipart`
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `main.py` | `MainWindow` + the `ujm` entry point |
|
| `main.py` | `MainWindow` + the `ujm` entry point |
|
||||||
| `analysis_results_manager.py` | Background `QThread` worker, result + metric-data cache |
|
| `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) |
|
| `metrics.py` | Pluggable `Metric` ABC + registry (RMS, Waveform, LUFS, Crest, PSR, True Peak, Spectrogram) |
|
||||||
| `audio_visualization_widget.py` | Embedded `FigureCanvasQTAgg` host |
|
| `audio_visualization_widget.py` | pyqtgraph `PlotItem` host; renders `PlotSpec` |
|
||||||
| `font_manager.py` | Custom + system CJK font discovery, matplotlib/Qt config |
|
| `plotspec.py` | Renderer-neutral plot description (`Curve`/`Band`/`HLine`/`Heatmap`) |
|
||||||
| `font_control_widget.py` | Font picker + size slider |
|
| `font_manager.py` | Custom + system CJK font discovery, Qt font config |
|
||||||
| `plot_control_widget.py` | Metric selector + refresh-plot button |
|
| `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 |
|
| `logger_setup.py` | CLI log-level parsing + custom TRACE level |
|
||||||
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
| `setup_fonts.py` | Diagnostic utility (run standalone) |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# uj-mastering-master — web UI, container deployment template.
|
||||||
|
#
|
||||||
|
# docker compose up --build # build and start
|
||||||
|
# docker compose logs -f # watch startup
|
||||||
|
# open http://localhost:8000
|
||||||
|
#
|
||||||
|
# There is deliberately no audio volume. Uploaded audio lives in the browser
|
||||||
|
# (Origin Private File System) and each request body is spooled to /tmp only
|
||||||
|
# while that request is being decoded. Nothing about a user's masters is
|
||||||
|
# persisted here — read-only storage is not just possible, it is the intent.
|
||||||
|
|
||||||
|
services:
|
||||||
|
ujm-web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
# Pin the build to this branch's source; override to build from a
|
||||||
|
# release tag instead:
|
||||||
|
# docker compose build --build-arg ...
|
||||||
|
image: uj-mastering-master-web:local
|
||||||
|
container_name: ujm-web
|
||||||
|
|
||||||
|
# Loopback-only by default. The app binds 0.0.0.0 *inside* the container,
|
||||||
|
# but publishing to 127.0.0.1 means it is only reachable from this host —
|
||||||
|
# an analysis tool with no authentication should not appear on the LAN by
|
||||||
|
# accident. Change to "8000:8000" (or put it behind a reverse proxy) to
|
||||||
|
# expose it deliberately.
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:8000:8000"
|
||||||
|
|
||||||
|
environment:
|
||||||
|
# Analysis is CPU-bound numpy/scipy; keep BLAS from oversubscribing when
|
||||||
|
# several requests overlap. Raise these if you raise `cpus` below.
|
||||||
|
OMP_NUM_THREADS: "4"
|
||||||
|
OPENBLAS_NUM_THREADS: "4"
|
||||||
|
MKL_NUM_THREADS: "4"
|
||||||
|
NUMEXPR_NUM_THREADS: "4"
|
||||||
|
# Writable scratch for numba/matplotlib caches and request spools.
|
||||||
|
NUMBA_CACHE_DIR: /tmp/numba-cache
|
||||||
|
MPLCONFIGDIR: /tmp/matplotlib
|
||||||
|
TMPDIR: /tmp
|
||||||
|
|
||||||
|
# The container filesystem can be read-only: the app writes nothing it
|
||||||
|
# needs to keep. /tmp is a tmpfs (memory-backed) so spooled request bodies
|
||||||
|
# never touch a disk at all.
|
||||||
|
read_only: true
|
||||||
|
tmpfs:
|
||||||
|
- /tmp:size=1g,mode=1777
|
||||||
|
|
||||||
|
# Analysis needs headroom: a long master at native sample rate is heavy in
|
||||||
|
# RAM. Raise the limit for large files; the tmpfs above is also backed by
|
||||||
|
# memory, so --memory must comfortably exceed it.
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: "4.0"
|
||||||
|
memory: 4g
|
||||||
|
|
||||||
|
# Run as the image's unprivileged user (uid 10001). Nothing here needs root.
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
cap_drop:
|
||||||
|
- ALL
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 10s
|
||||||
|
|
||||||
|
# The image ships a HEALTHCHECK; this is the same check expressed at the
|
||||||
|
# orchestration layer, so `docker compose ps` shows real health.
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/metrics', timeout=4).status == 200 else 1)"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
start_period: 20s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
# No volumes are declared, on purpose. If you want to run the API smoke test
|
||||||
|
# inside the image, do it ad hoc rather than mounting a source tree:
|
||||||
|
#
|
||||||
|
# docker compose exec ujm-web python - <<'PY'
|
||||||
|
# ... # or see `make smoke` in the README
|
||||||
|
# PY
|
||||||
+45
-17
@@ -6,12 +6,27 @@ build-backend = "setuptools.build_meta"
|
|||||||
name = "uj-mastering-master"
|
name = "uj-mastering-master"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|
||||||
|
# Only what the *analysis* needs. The GUI stack lives in the `desktop` extra
|
||||||
|
# below, because nothing on the web path touches it: `webapp.py`,
|
||||||
|
# `web_session.py`, `metrics_json.py`, `master_core.py`, `metrics.py` and
|
||||||
|
# `plotspec.py` import no Qt, no pyqtgraph and no matplotlib canvas. Keeping
|
||||||
|
# PyQt5 here would force a `--headless`-style install to drag in a full Qt
|
||||||
|
# stack it never loads.
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"librosa",
|
"librosa",
|
||||||
"numpy",
|
"numpy",
|
||||||
"matplotlib",
|
"matplotlib", # librosa dependency, and colormaps for the colormap helper
|
||||||
"mutagen",
|
"mutagen",
|
||||||
"pyloudnorm",
|
"pyloudnorm",
|
||||||
|
]
|
||||||
|
|
||||||
|
license = {text = "Apache-2.0"}
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
# Desktop GUI. Installed with:
|
||||||
|
# uv sync --extra desktop (or `uv sync --all-extras`)
|
||||||
|
desktop = [
|
||||||
"PyQt5>=5.15.10",
|
"PyQt5>=5.15.10",
|
||||||
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
# 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later
|
||||||
# versions are Linux/macOS only.
|
# versions are Linux/macOS only.
|
||||||
@@ -19,19 +34,27 @@ dependencies = [
|
|||||||
"pyqtgraph>=0.14.0",
|
"pyqtgraph>=0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
license = {text = "Apache-2.0"}
|
# Web front end. Installed with:
|
||||||
|
|
||||||
[project.optional-dependencies]
|
|
||||||
# Web front end (the `feature/web-ui` branch). Installed with:
|
|
||||||
# uv sync --extra web
|
# uv sync --extra web
|
||||||
# Deliberately separate from the desktop path: a server install of the web UI
|
# Deliberately separate from the desktop path: a server install of the web UI
|
||||||
# needs no Qt/wheel stack, and a desktop install needs no ASGI stack.
|
# needs no Qt/wheel stack. Which is what makes the container image small and
|
||||||
|
# lets it run without a display server at all.
|
||||||
web = [
|
web = [
|
||||||
"fastapi>=0.110",
|
"fastapi>=0.110",
|
||||||
"uvicorn[standard]>=0.27",
|
"uvicorn[standard]>=0.27",
|
||||||
"python-multipart>=0.0.9", # multipart upload parsing for FastAPI
|
"python-multipart>=0.0.9", # multipart upload parsing for FastAPI
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
# Note: `default-groups` cannot name an entry from `[project.optional-dependencies]`
|
||||||
|
# — it only accepts `[dependency-groups]` names, and errors with "Default group
|
||||||
|
# `desktop` ... is not defined in the project's `dependency-groups` table".
|
||||||
|
# Extras must be requested explicitly, which is why the README spells out both:
|
||||||
|
# uv sync # analysis + nothing else
|
||||||
|
# uv sync --extra desktop # + Qt/pyqtgraph (the GUI; `uv run ujm`)
|
||||||
|
# uv sync --extra web # + fastapi/uvicorn (`uv run ujm-web`)
|
||||||
|
# uv sync --all-extras # both
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
ujm = "main:main"
|
ujm = "main:main"
|
||||||
ujm-web = "webapp:main"
|
ujm-web = "webapp:main"
|
||||||
@@ -54,16 +77,21 @@ py-modules = [
|
|||||||
"web_session",
|
"web_session",
|
||||||
"metrics_json",
|
"metrics_json",
|
||||||
]
|
]
|
||||||
|
# `static/` is declared a *package* so `package-data` has something to attach
|
||||||
|
# to. `package-data` keys match packages, and top-level `py-modules` are not
|
||||||
|
# packages — so the earlier `"*" = [...]` form silently matched nothing and the
|
||||||
|
# built wheel contained no assets at all (verified: 14 .py files, zero HTML/
|
||||||
|
# JS/CSS). An installed `ujm-web` therefore 404'd every asset. With this entry
|
||||||
|
# the assets install to `site-packages/static/`, which is exactly where
|
||||||
|
# `webapp.py` looks (`Path(__file__).parent / "static"`).
|
||||||
|
packages = ["static"]
|
||||||
|
|
||||||
[tool.setuptools.package-data]
|
[tool.setuptools.package-data]
|
||||||
# Vendored browser assets: uPlot (curves), no CDN at runtime. `webapp.py`
|
# Vendored browser assets: uPlot (curves), no CDN at runtime. Keyed on the
|
||||||
# resolves them relative to `__file__`, so on an installed wheel they must sit
|
# `static` package (declared above) rather than `"*"`: a `"*"` key matches
|
||||||
# next to the modules — see `[tool.setuptools.data-files]` for the sdist path.
|
# *packages*, and there are none besides `static` — the app modules are
|
||||||
"*" = ["static/*"]
|
# top-level `py-modules`, which is why the original form shipped nothing.
|
||||||
|
#
|
||||||
[tool.setuptools.data-files]
|
# `**/*` rather than `*` so the glob reaches `static/vendor/`, where the uPlot
|
||||||
# The web assets live next to the *modules*, not in site-packages/static, so a
|
# bundle lives.
|
||||||
# wheel install needs them copied into the package directory. `MANIFEST.in`
|
static = ["*", "**/*"]
|
||||||
# keeps them in the sdist; running from a checkout (the documented path) needs
|
|
||||||
# neither.
|
|
||||||
"lib/python-static" = ["static/*"]
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the audio fixtures `webapi_smoke.py` expects.
|
||||||
|
|
||||||
|
Synthesised rather than downloaded so the smoke test has no network dependency
|
||||||
|
and no external tools — `soundfile` and `numpy` come in with `librosa`, so this
|
||||||
|
runs in the container image as-is (no ffmpeg needed).
|
||||||
|
|
||||||
|
sine440.wav 12 s, 440 Hz, stereo, -12 dBFS — a known-tone reference
|
||||||
|
pinknoise.wav 8 s, pink noise, stereo — broadband, exercises the STFT
|
||||||
|
tagged.mp3 a tagged file — only if ffmpeg is available
|
||||||
|
|
||||||
|
Usage: python3 scripts/make_fixtures.py [output_dir] (default /tmp/ujm-test)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import soundfile as sf
|
||||||
|
|
||||||
|
SR = 44100
|
||||||
|
|
||||||
|
|
||||||
|
def sine(path: Path, seconds: float = 12.0, hz: float = 440.0,
|
||||||
|
dbfs: float = -12.0) -> None:
|
||||||
|
"""A tone at a known level. Peak is exactly 10**(dbfs/20) before encoding."""
|
||||||
|
t = np.arange(int(seconds * SR)) / SR
|
||||||
|
mono = (10 ** (dbfs / 20)) * np.sin(2 * np.pi * hz * t)
|
||||||
|
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
|
||||||
|
|
||||||
|
|
||||||
|
def pink(path: Path, seconds: float = 8.0, peak: float = 0.25) -> None:
|
||||||
|
"""Pink noise via 1/sqrt(f) shaping, normalised to a known peak."""
|
||||||
|
rng = np.random.default_rng(7)
|
||||||
|
n = int(seconds * SR)
|
||||||
|
spectrum = np.fft.rfft(rng.standard_normal(n))
|
||||||
|
freqs = np.fft.rfftfreq(n, 1 / SR)
|
||||||
|
freqs[0] = freqs[1] # avoid dividing by zero at DC
|
||||||
|
shaped = np.fft.irfft(spectrum / np.sqrt(freqs), n)
|
||||||
|
mono = peak * shaped / np.max(np.abs(shaped))
|
||||||
|
sf.write(path, np.stack([mono, mono], axis=1), SR, subtype="PCM_16")
|
||||||
|
|
||||||
|
|
||||||
|
def tagged_mp3(path: Path) -> bool:
|
||||||
|
"""An MP3 with ID3 tags, so the tag-reading path is exercised.
|
||||||
|
|
||||||
|
Genuinely optional: MP3 encoding is the one thing numpy cannot do, so this
|
||||||
|
needs ffmpeg and is skipped when it is absent. The suite's tag assertions
|
||||||
|
adapt — see `webapi_smoke.py`.
|
||||||
|
"""
|
||||||
|
if shutil.which("ffmpeg") is None:
|
||||||
|
return False
|
||||||
|
subprocess.run(
|
||||||
|
["ffmpeg", "-y", "-loglevel", "error",
|
||||||
|
"-f", "lavfi", "-i", "sine=frequency=330:duration=6:sample_rate=44100",
|
||||||
|
"-metadata", "artist=Test Artist", "-metadata", "title=Test Title",
|
||||||
|
"-codec:a", "libmp3lame", "-q:a", "2", str(path)],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
out = Path(sys.argv[1] if len(sys.argv) > 1 else "/tmp/ujm-test")
|
||||||
|
out.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
sine(out / "sine440.wav")
|
||||||
|
pink(out / "pinknoise.wav")
|
||||||
|
mp3 = tagged_mp3(out / "テスト.mp3")
|
||||||
|
|
||||||
|
for f in sorted(out.iterdir()):
|
||||||
|
print(f" {f.name} {f.stat().st_size / 1024:.0f} KiB")
|
||||||
|
if not mp3:
|
||||||
|
print("\nnote: no ffmpeg, so no tagged MP3 fixture was written "
|
||||||
|
"(tag assertions will be skipped)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+13
-6
@@ -116,16 +116,23 @@ def main() -> int:
|
|||||||
|
|
||||||
sine = next(r for n, r in uploads.items() if "sine440" in n)
|
sine = next(r for n, r in uploads.items() if "sine440" in n)
|
||||||
noise = next(r for n, r in uploads.items() if "pinknoise" in n)
|
noise = next(r for n, r in uploads.items() if "pinknoise" in n)
|
||||||
tagged = next(r for n, r in uploads.items() if r["file"]["song_name"].startswith("Test Artist"))
|
# The tagged MP3 needs an encoder to create, so it may be absent on a machine
|
||||||
|
# without ffmpeg (e.g. the container image). Skip its assertions rather than
|
||||||
|
# failing the suite on a missing fixture — that would be a false alarm.
|
||||||
|
tagged = next((r for r in uploads.values()
|
||||||
|
if r["file"]["song_name"].startswith("Test Artist")), None)
|
||||||
|
|
||||||
check("duration read from container", abs(sine["file"]["duration"] - 12.0) < 0.05,
|
check("duration read from container", abs(sine["file"]["duration"] - 12.0) < 0.05,
|
||||||
f"{sine['file']['duration']}s")
|
f"{sine['file']['duration']}s")
|
||||||
check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100)
|
check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100)
|
||||||
check("ID3 tags resolve to a song name",
|
if tagged is None:
|
||||||
tagged["file"]["song_name"] == "Test Artist - Test Title",
|
print("SKIP ID3 tag assertions — no tagged MP3 fixture (needs ffmpeg)")
|
||||||
tagged["file"]["song_name"])
|
else:
|
||||||
check("CJK-containing filename handled",
|
check("ID3 tags resolve to a song name",
|
||||||
tagged["file"]["name"].endswith(".mp3"), tagged["file"]["name"])
|
tagged["file"]["song_name"] == "Test Artist - Test Title",
|
||||||
|
tagged["file"]["song_name"])
|
||||||
|
check("CJK-containing filename handled",
|
||||||
|
tagged["file"]["name"].endswith(".mp3"), tagged["file"]["name"])
|
||||||
check("analysis starts mono, as documented", sine["file"]["channels"] == 1)
|
check("analysis starts mono, as documented", sine["file"]["channels"] == 1)
|
||||||
check("size is reported back to the client", sine["file"]["size_bytes"] > 0,
|
check("size is reported back to the client", sine["file"]["size_bytes"] > 0,
|
||||||
f"{sine['file']['size_bytes']} bytes")
|
f"{sine['file']['size_bytes']} bytes")
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Packaging shim — not an importable Python module.
|
||||||
|
|
||||||
|
`static/` holds the web UI's browser assets (HTML, JS, CSS, vendored uPlot).
|
||||||
|
It is declared as a setuptools *package* purely so `[tool.setuptools.package-data]`
|
||||||
|
has a package to attach the assets to. Without this file the directory is not a
|
||||||
|
package, `package-data` matches nothing, and the built wheel ships without any
|
||||||
|
assets at all — leaving an installed `ujm-web` serving 404s for every file.
|
||||||
|
|
||||||
|
Nothing imports it. `webapp.py` reads the directory from disk via
|
||||||
|
`Path(__file__).parent / "static"`, so the empty module has no runtime role.
|
||||||
|
"""
|
||||||
@@ -2046,12 +2046,14 @@ dependencies = [
|
|||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
{ name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||||
{ name = "pyloudnorm" },
|
{ name = "pyloudnorm" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
desktop = [
|
||||||
{ name = "pyqt5" },
|
{ name = "pyqt5" },
|
||||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'" },
|
||||||
{ name = "pyqtgraph" },
|
{ name = "pyqtgraph" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.optional-dependencies]
|
|
||||||
web = [
|
web = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
{ name = "python-multipart" },
|
{ name = "python-multipart" },
|
||||||
@@ -2066,13 +2068,13 @@ requires-dist = [
|
|||||||
{ name = "mutagen" },
|
{ name = "mutagen" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "pyloudnorm" },
|
{ name = "pyloudnorm" },
|
||||||
{ name = "pyqt5", specifier = ">=5.15.10" },
|
{ name = "pyqt5", marker = "extra == 'desktop'", specifier = ">=5.15.10" },
|
||||||
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" },
|
{ name = "pyqt5-qt5", marker = "sys_platform == 'win32' and extra == 'desktop'", specifier = "==5.15.2" },
|
||||||
{ name = "pyqtgraph", specifier = ">=0.14.0" },
|
{ name = "pyqtgraph", marker = "extra == 'desktop'", specifier = ">=0.14.0" },
|
||||||
{ name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" },
|
{ name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" },
|
||||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.27" },
|
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.27" },
|
||||||
]
|
]
|
||||||
provides-extras = ["web"]
|
provides-extras = ["desktop", "web"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
|
|||||||
Reference in New Issue
Block a user