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,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())
|
||||
Reference in New Issue
Block a user