From a1fed2cd712cc9a50a34715cce8bdef5fb2dbc75 Mon Sep 17 00:00:00 2001 From: Irene Date: Mon, 14 Sep 2026 01:57:00 +0900 Subject: [PATCH] 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 .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). --- .dockerignore | 29 +++++++++++++ Dockerfile | 88 ++++++++++++++++++++++++++++++++++++++++ README.md | 84 ++++++++++++++++++++++++++++++-------- docker-compose.yml | 88 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 62 ++++++++++++++++++++-------- scripts/make_fixtures.py | 84 ++++++++++++++++++++++++++++++++++++++ scripts/webapi_smoke.py | 19 ++++++--- static/__init__.py | 11 +++++ uv.lock | 14 ++++--- 9 files changed, 432 insertions(+), 47 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 scripts/make_fixtures.py create mode 100644 static/__init__.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc01ab6 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5f6e30 --- /dev/null +++ b/Dockerfile @@ -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 .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"] diff --git a/README.md b/README.md index fe0d1c9..3f41d34 100644 --- a/README.md +++ b/README.md @@ -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) | diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..86856e4 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 74ff9cb..65214f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,12 +6,27 @@ build-backend = "setuptools.build_meta" name = "uj-mastering-master" version = "0.1.0" 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 = [ "librosa", "numpy", - "matplotlib", + "matplotlib", # librosa dependency, and colormaps for the colormap helper "mutagen", "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", # 5.15.2 is the only pyqt5-qt5 release with a Windows wheel; later # versions are Linux/macOS only. @@ -19,19 +34,27 @@ dependencies = [ "pyqtgraph>=0.14.0", ] -license = {text = "Apache-2.0"} - -[project.optional-dependencies] -# Web front end (the `feature/web-ui` branch). Installed with: +# Web front end. Installed with: # uv sync --extra web # 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 = [ "fastapi>=0.110", "uvicorn[standard]>=0.27", "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] ujm = "main:main" ujm-web = "webapp:main" @@ -54,16 +77,21 @@ py-modules = [ "web_session", "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] -# Vendored browser assets: uPlot (curves), no CDN at runtime. `webapp.py` -# resolves them relative to `__file__`, so on an installed wheel they must sit -# next to the modules — see `[tool.setuptools.data-files]` for the sdist path. -"*" = ["static/*"] - -[tool.setuptools.data-files] -# The web assets live next to the *modules*, not in site-packages/static, so a -# wheel install needs them copied into the package directory. `MANIFEST.in` -# keeps them in the sdist; running from a checkout (the documented path) needs -# neither. -"lib/python-static" = ["static/*"] +# Vendored browser assets: uPlot (curves), no CDN at runtime. Keyed on the +# `static` package (declared above) rather than `"*"`: a `"*"` key matches +# *packages*, and there are none besides `static` — the app modules are +# top-level `py-modules`, which is why the original form shipped nothing. +# +# `**/*` rather than `*` so the glob reaches `static/vendor/`, where the uPlot +# bundle lives. +static = ["*", "**/*"] diff --git a/scripts/make_fixtures.py b/scripts/make_fixtures.py new file mode 100644 index 0000000..efabe4a --- /dev/null +++ b/scripts/make_fixtures.py @@ -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()) diff --git a/scripts/webapi_smoke.py b/scripts/webapi_smoke.py index d10fa35..54a0d11 100644 --- a/scripts/webapi_smoke.py +++ b/scripts/webapi_smoke.py @@ -116,16 +116,23 @@ def main() -> int: 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) - 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, f"{sine['file']['duration']}s") check("native sample rate preserved (sr=None)", sine["file"]["sample_rate"] == 44100) - check("ID3 tags resolve to a song 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"]) + if tagged is None: + print("SKIP ID3 tag assertions — no tagged MP3 fixture (needs ffmpeg)") + else: + check("ID3 tags resolve to a song 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("size is reported back to the client", sine["file"]["size_bytes"] > 0, f"{sine['file']['size_bytes']} bytes") diff --git a/static/__init__.py b/static/__init__.py new file mode 100644 index 0000000..cafcf15 --- /dev/null +++ b/static/__init__.py @@ -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. +""" diff --git a/uv.lock b/uv.lock index 7f2e5f2..6bfc20a 100644 --- a/uv.lock +++ b/uv.lock @@ -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.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyloudnorm" }, +] + +[package.optional-dependencies] +desktop = [ { name = "pyqt5" }, { name = "pyqt5-qt5", marker = "sys_platform == 'win32'" }, { name = "pyqtgraph" }, ] - -[package.optional-dependencies] web = [ { name = "fastapi" }, { name = "python-multipart" }, @@ -2066,13 +2068,13 @@ requires-dist = [ { name = "mutagen" }, { name = "numpy" }, { name = "pyloudnorm" }, - { name = "pyqt5", specifier = ">=5.15.10" }, - { name = "pyqt5-qt5", marker = "sys_platform == 'win32'", specifier = "==5.15.2" }, - { name = "pyqtgraph", specifier = ">=0.14.0" }, + { name = "pyqt5", marker = "extra == 'desktop'", specifier = ">=5.15.10" }, + { name = "pyqt5-qt5", marker = "sys_platform == 'win32' and extra == 'desktop'", specifier = "==5.15.2" }, + { name = "pyqtgraph", marker = "extra == 'desktop'", specifier = ">=0.14.0" }, { name = "python-multipart", marker = "extra == 'web'", specifier = ">=0.0.9" }, { name = "uvicorn", extras = ["standard"], marker = "extra == 'web'", specifier = ">=0.27" }, ] -provides-extras = ["web"] +provides-extras = ["desktop", "web"] [[package]] name = "urllib3"