Commit b06cba91 by PLN (Algolia)

Merge remote-tracking branch 'origin/master'

parents 29ce99d3 328bca59
...@@ -33,6 +33,15 @@ docker build -t fourier:latest armada/api # ~1 GB image, light engine only ...@@ -33,6 +33,15 @@ docker build -t fourier:latest armada/api # ~1 GB image, light engine only
(Or build on the dev box and `docker save fourier:latest | ssh erable docker load`.) (Or build on the dev box and `docker save fourier:latest | ssh erable docker load`.)
> **Base-image drift (bit us 2026-07-23).** A clean rebuild re-pulls
> `python:3.12-slim`, which now tracks Debian **trixie** — and its apt layer
> fails in erable's old Docker (`APT::Update::Post-Invoke … Sub-process returned
> an error`). Two fixes: **pin the base** to a working digest
> (`FROM python:3.12-slim@sha256:…`) so rebuilds are reproducible — the proper
> fix; or, for a **source-only** change (no dep change), skip the base entirely
> and overlay onto the running image:
> `printf 'FROM fourier:latest\nCOPY . /app\n' | docker build -t fourier:latest -f - .`
## 2. State volume ## 2. State volume
One writable dir at `/data` holds the content-addressed **cache** (transient, One writable dir at `/data` holds the content-addressed **cache** (transient,
...@@ -67,18 +76,29 @@ MKL_NUM_THREADS=1 ...@@ -67,18 +76,29 @@ MKL_NUM_THREADS=1
## 4. Run ## 4. Run
Use the launcher — it bakes in every flag idempotently (rm + run), including the
**memory cap** that a hand-typed `docker run` used to omit:
```bash ```bash
docker run -d --name fourier --restart unless-stopped \ deploy/fourier.sh up # (re)launches BOTH the API and the worker container
--security-opt seccomp=unconfined \ deploy/fourier.sh ps # status + effective mem caps
--env-file ~/.config/fourier/fourier.env \
-v /home/pln/srv/fourier/data:/data \
-p 127.0.0.1:9780:9780 \
fourier:latest
``` ```
`-p 127.0.0.1:9780:9780` is the whole security model: loopback-only means the It launches two containers off the same image: **`fourier`** (uvicorn, the HTTP
gateway is the *only* way in, so the injected `X-Tenant`/`X-Scopes` headers can be API) and **`fourier-worker`** (`python -m worker`, the heavy-engine job runner).
trusted. SRE wraps this in a keep-alive systemd unit. Both get `--restart unless-stopped`, `--security-opt seccomp=unconfined`, the
`--env-file`, the `/data` volume, and `--memory` (default `3g` each — override
via `FOURIER_MEM` / `FOURIER_API_MEM` / `FOURIER_WORKER_MEM`).
**Why the cap matters:** erable is a shared ~7.7 GB box (Postgres + the platform
live there too). Uncapped, a runaway separate/sources job could OOM its
neighbours. `docker update --memory` only survives a *restart*, not a rm+run
redeploy — the script is the durable home for the cap, so redeploys keep it.
The API publishes `-p 127.0.0.1:9780:9780`; loopback-only is the whole security
model — the gateway is the *only* way in, so the injected `X-Tenant`/`X-Scopes`
headers can be trusted. uvicorn binds `0.0.0.0` *inside* the container (the
loopback restriction is the host-side `-p`). The worker publishes no ports.
### Two erable gotchas (verified at first deploy, 2026-06-28) ### Two erable gotchas (verified at first deploy, 2026-06-28)
......
...@@ -21,18 +21,27 @@ import db ...@@ -21,18 +21,27 @@ import db
# ── addressing ────────────────────────────────────────────────────────────── # ── addressing ──────────────────────────────────────────────────────────────
def content_id(audio_path: str | Path) -> str: def content_id(audio_path: str | Path) -> str:
"""sha256 of decoded PCM (+ samplerate). Falls back to raw file bytes if the """sha256 of decoded PCM (+ samplerate). Falls back to raw file bytes if the
format can't be decoded here (still dedupes identical files).""" format can't be decoded here (still dedupes identical files).
STREAMED (#7): hashes the decoded PCM block-by-block instead of reading the
whole file into RAM — the concatenated float32 bytes are identical to the old
whole-array hash, so existing cache ids stay valid. Bounded memory either way."""
try: try:
import numpy as np import numpy as np
import soundfile as sf import soundfile as sf
y, sr = sf.read(str(audio_path), dtype="float32", always_2d=False)
h = hashlib.sha256() h = hashlib.sha256()
h.update(np.ascontiguousarray(y).tobytes()) with sf.SoundFile(str(audio_path)) as f:
sr = f.samplerate
for block in f.blocks(blocksize=1 << 18, dtype="float32", always_2d=False):
h.update(np.ascontiguousarray(block).tobytes())
h.update(str(sr).encode()) h.update(str(sr).encode())
return "cid_" + h.hexdigest()[:32] return "cid_" + h.hexdigest()[:32]
except Exception: except Exception:
raw = Path(audio_path).read_bytes() h = hashlib.sha256()
return "raw_" + hashlib.sha256(raw).hexdigest()[:32] with open(audio_path, "rb") as fp:
for chunk in iter(lambda: fp.read(1 << 20), b""):
h.update(chunk)
return "raw_" + h.hexdigest()[:32]
def params_hash(params: dict | None) -> str: def params_hash(params: dict | None) -> str:
......
...@@ -42,7 +42,11 @@ DB_PATH = Path(os.environ.get("FOURIER_DB", HERE / "_cache" / "fourier.db")) ...@@ -42,7 +42,11 @@ DB_PATH = Path(os.environ.get("FOURIER_DB", HERE / "_cache" / "fourier.db"))
DEV_TOKEN = os.environ.get("FOURIER_DEV_TOKEN", "") DEV_TOKEN = os.environ.get("FOURIER_DEV_TOKEN", "")
# ── limits ───────────────────────────────────────────────────────────────────── # ── limits ─────────────────────────────────────────────────────────────────────
MAX_UPLOAD_MB = int(os.environ.get("FOURIER_MAX_UPLOAD_MB", "40")) # Match the gateway's client_max_body_size (100 MB) so the app never rejects a
# body nginx let through. Safe to accept large uploads now: ingest + hashing +
# the full-track engines all stream in bounded windows (#7), so peak memory is
# O(window), not O(file). The gateway's friendly 413 fires first above this.
MAX_UPLOAD_MB = int(os.environ.get("FOURIER_MAX_UPLOAD_MB", "100"))
# ── artifact store (#27) — local disk on erable; freebox deferred ─────────────── # ── artifact store (#27) — local disk on erable; freebox deferred ───────────────
# Big binaries (fetched sources, stems, loops) live content-addressed under here, # Big binaries (fetched sources, stems, loops) live content-addressed under here,
......
# Fourier job worker (#25) — systemd --user unit. # Fourier job worker (#25) — systemd --user unit.
# #
# SUPERSEDED on erable (2026-07): the worker now runs CONTAINERIZED off the same
# fourier:latest image, launched by `deploy/fourier.sh up` alongside the API (see
# DEPLOY.md §4). This host-venv unit remains as the paved-road alternative for a
# deploy host that runs the worker outside Docker; on erable it is NOT installed.
#
# The worker is a SEPARATE long-lived process from the API container: it claims # The worker is a SEPARATE long-lived process from the API container: it claims
# pending jobs and runs the heavy engines (separate/sources/loops). Runs as a # pending jobs and runs the heavy engines (separate/sources/loops). Runs as a
# systemd --user service with lingering on, so it survives logout and starts at # systemd --user service with lingering on, so it survives logout and starts at
......
#!/usr/bin/env bash
# Idempotent launcher for the Fourier (audio sub-API) containers on erable.
#
# Replaces the hand-typed `docker run` from DEPLOY.md §4 so that redeploys
# reproduce ALL the flags — most importantly the memory cap, which a bare
# `docker run` used to omit (a runaway job could then OOM the DB/platform on
# the shared 7.7 GB box). `docker update` only survives a restart, NOT a
# rm+run redeploy; this script is the durable home for the cap.
#
# Usage:
# deploy/fourier.sh up # (re)launch both containers (default)
# deploy/fourier.sh restart # same as up (rm + run is our restart)
# deploy/fourier.sh down # stop + remove both
# deploy/fourier.sh ps # show status + effective mem caps
#
# Everything is overridable by env; defaults match the live 2026-07 config.
set -euo pipefail
IMAGE="${FOURIER_IMAGE:-fourier:latest}"
DATA_DIR="${FOURIER_DATA_DIR:-$HOME/srv/fourier/data}"
ENV_FILE="${FOURIER_ENV_FILE:-$HOME/.config/fourier/fourier.env}"
BIND="${FOURIER_BIND:-127.0.0.1}" # loopback-only: the gateway is the only way in
PORT="${FOURIER_PORT:-9780}"
# Memory caps — the whole point of this script existing.
# The box has ~7.7 GB total shared with Postgres + the platform; keep the two
# audio containers bounded so a heavy job can't OOM its neighbours. Worker runs
# the heavy engines (separate/sources), API is CPU-light — but we cap both at
# 3g to match the live config. Override per-container if you retune.
MEM="${FOURIER_MEM:-3g}"
API_MEM="${FOURIER_API_MEM:-$MEM}"
WORKER_MEM="${FOURIER_WORKER_MEM:-$MEM}"
# Flags shared by both containers (see DEPLOY.md §4 for the why of each).
common_flags=(
--restart unless-stopped
--security-opt seccomp=unconfined # kernel 4.9 / clone3 — REQUIRED, see DEPLOY.md gotcha #1
--env-file "$ENV_FILE"
-v "$DATA_DIR:/data"
)
preflight() {
[[ -f "$ENV_FILE" ]] || { echo "!! env file missing: $ENV_FILE (see DEPLOY.md §3)" >&2; exit 1; }
mkdir -p "$DATA_DIR"
# Guard the shared box: refuse to launch if the disk is already tight.
local pct; pct=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if (( pct >= 90 )); then
echo "!! root fs at ${pct}% — refusing to launch (free space first; see disk-monitor)" >&2
exit 1
fi
}
down() {
docker rm -f fourier fourier-worker 2>/dev/null || true
}
up() {
preflight
down
# API: serves HTTP on loopback; uvicorn binds 0.0.0.0 INSIDE (loopback is host-side -p).
docker run -d --name fourier "${common_flags[@]}" \
--memory "$API_MEM" \
-p "$BIND:$PORT:$PORT" \
"$IMAGE" \
uvicorn app:app --host 0.0.0.0 --port "$PORT"
# Worker: no ports; claims jobs and runs the heavy engines.
docker run -d --name fourier-worker "${common_flags[@]}" \
--memory "$WORKER_MEM" \
"$IMAGE" \
python -m worker
echo "launched fourier (mem=$API_MEM) + fourier-worker (mem=$WORKER_MEM)"
ps
}
ps() {
docker ps --filter name=fourier --format 'table {{.Names}}\t{{.Status}}'
for c in fourier fourier-worker; do
printf '%s mem-cap: ' "$c"
docker inspect -f '{{.HostConfig.Memory}} bytes' "$c" 2>/dev/null || echo '(not running)'
done
}
case "${1:-up}" in
up|restart) up ;;
down) down; echo "removed fourier + fourier-worker" ;;
ps|status) ps ;;
*) echo "usage: $0 {up|restart|down|ps}" >&2; exit 2 ;;
esac
...@@ -5,11 +5,20 @@ The mastering numbers PLN + hexa actually gate on (reference_postprod_master: ...@@ -5,11 +5,20 @@ The mastering numbers PLN + hexa actually gate on (reference_postprod_master:
integrated loudness; true-peak is a cheap 4× oversample (catches inter-sample integrated loudness; true-peak is a cheap 4× oversample (catches inter-sample
peaks a raw sample-peak misses). Returns gain-to-target so a caller knows exactly peaks a raw sample-peak misses). Returns gain-to-target so a caller knows exactly
how much to push for each delivery target. how much to push for each delivery target.
FULL-TRACK (#7): streams the WHOLE track in bounded windows. Peak / true-peak /
RMS / crest are EXACT (running max / accumulated sum-of-squares). Integrated LUFS
is energy-weighted across per-window meters — pyloudnorm's gating can't be merged
exactly across chunks, so this is a close APPROXIMATION, flagged `approximate:true`
with `lufs_method:"chunked"`. For a steady master the error is well under ~0.5 LU.
""" """
import numpy as np import numpy as np
from . import streaming
_EPS = 1e-12 _EPS = 1e-12
TARGETS = {"streaming": -14.0, "club": -9.0} # LUFS, reference_postprod_master TARGETS = {"streaming": -14.0, "club": -9.0} # LUFS, reference_postprod_master
_METER_MIN_S = 0.4 # pyloudnorm needs ≥ one 400 ms gated block
def _db(x): def _db(x):
...@@ -26,37 +35,65 @@ def _true_peak_dbfs(mono, sr): ...@@ -26,37 +35,65 @@ def _true_peak_dbfs(mono, sr):
return _db(np.max(np.abs(mono))) return _db(np.max(np.abs(mono)))
def loudness(audio_path, max_seconds=180.0) -> dict: def loudness(audio_path, win_s=streaming.DEFAULT_WIN_S) -> dict:
"""Integrated LUFS, sample/true peak (dBFS), crest, + gain-to-target.""" """Integrated LUFS (chunked-approx), sample/true peak (dBFS, exact), crest,
import soundfile as sf + gain-to-target, over the whole track."""
data, sr = sf.read(str(audio_path), dtype="float64") meta = streaming.probe(audio_path)
if data.shape[0] < sr // 2: # pyloudnorm needs ≥ ~0.4 s for a gated block sr, channels = meta["sr"], meta["channels"]
if meta["frames"] and meta["frames"] < sr // 2:
raise ValueError("clip too short for integrated loudness (need ≥ 0.5 s)") raise ValueError("clip too short for integrated loudness (need ≥ 0.5 s)")
if max_seconds:
data = data[: int(sr * max_seconds)]
mono = data if data.ndim == 1 else data.mean(axis=1)
rms_db = _db(np.sqrt(np.mean(mono ** 2) + _EPS))
peak_db = _db(np.max(np.abs(mono)))
tp_db = _true_peak_dbfs(mono, sr)
lufs = None
try: try:
import pyloudnorm as pyln import pyloudnorm as pyln
lufs = float(pyln.Meter(sr).integrated_loudness(data)) meter = pyln.Meter(sr)
except Exception:
meter = None
sumsq = 0.0
n = 0
peak = _EPS
tp = _EPS
energy_sum = 0.0 # Σ dur_i · 10^(LUFS_i/10) — linear loudness, duration-weighted
dur_sum = 0.0
for block, sr, _ in streaming.windows(audio_path, win_s, mono=False, dtype="float64"):
if not len(block):
continue
mono = block if block.ndim == 1 else block.mean(axis=1)
sumsq += float(np.sum(mono ** 2))
n += mono.size
peak = max(peak, float(np.max(np.abs(mono))))
tp = max(tp, 10 ** (_true_peak_dbfs(mono, sr) / 20.0))
if meter is not None and len(block) >= int(_METER_MIN_S * sr):
try:
lw = float(meter.integrated_loudness(block))
except Exception: except Exception:
pass lw = float("-inf")
if np.isfinite(lw): # silence-gated windows → skip
w = len(block) / sr
energy_sum += w * (10 ** (lw / 10.0))
dur_sum += w
if n < sr // 2:
raise ValueError("clip too short for integrated loudness (need ≥ 0.5 s)")
gain = ({k: round(t - lufs, 2) for k, t in TARGETS.items()} if lufs is not None rms_db = _db(np.sqrt(sumsq / max(n, 1) + _EPS))
and np.isfinite(lufs) else None) peak_db = _db(peak)
tp_db = _db(tp)
lufs = 10.0 * np.log10(energy_sum / dur_sum) if dur_sum > 0 else None
gain = ({k: round(t - lufs, 2) for k, t in TARGETS.items()}
if lufs is not None and np.isfinite(lufs) else None)
return { return {
"lufs_integrated": round(lufs, 2) if lufs is not None and np.isfinite(lufs) else None, "lufs_integrated": round(lufs, 2) if lufs is not None and np.isfinite(lufs) else None,
"lufs_method": "chunked",
"approximate": True, # energy-weighted across windows, not a single gated pass
"peak_dbfs": round(peak_db, 2), "peak_dbfs": round(peak_db, 2),
"true_peak_dbfs": round(tp_db, 2), "true_peak_dbfs": round(tp_db, 2),
"rms_dbfs": round(rms_db, 2), "rms_dbfs": round(rms_db, 2),
"crest_db": round(peak_db - rms_db, 2), "crest_db": round(peak_db - rms_db, 2),
"gain_to_target_db": gain, # add this many dB to hit each LUFS target "gain_to_target_db": gain, # add this many dB to hit each LUFS target
"channels": 1 if data.ndim == 1 else data.shape[1], "channels": int(channels),
"sr": int(sr), "sr": int(sr),
"duration_s": round(meta["duration_s"], 3),
"engine": "light", "engine": "light",
"full_track": True,
} }
"""Bounded-memory streaming primitives for full-track analysis. CPU, NO torch.
Producers upload whole tracks, not 60 s clips. Rather than 413-reject a big file
(or buffer the whole thing in RAM), the signal/loudness engines decode it in
fixed time windows and aggregate as they go — so peak memory is O(one window),
not O(file), and a large upload can't OOM the shared erable box.
soundfile (libsndfile 1.2) streams WAV/FLAC/OGG/MP3 with exact frame counts, so
we read at NATIVE sample rate and let each engine pass `sr` to librosa — no
per-block resampling (which would add seam artifacts at window boundaries).
"""
import numpy as np
# 30 s windows: at 96 kHz stereo float64 that is ~44 MB resident — comfortably
# under the container's 3 GB cap, with margin for the engine's own working set.
DEFAULT_WIN_S = 30.0
def probe(path) -> dict:
"""Header-only metadata (no decode): frames, sr, channels, duration_s.
`frames <= 0` means the container can't frame-count this file (rare); callers
fall back to a bounded whole-file decode."""
import soundfile as sf
i = sf.info(str(path))
return {
"frames": int(i.frames),
"sr": int(i.samplerate),
"channels": int(i.channels),
"duration_s": (i.frames / i.samplerate) if i.samplerate else 0.0,
"format": i.format,
}
def windows(path, win_s=DEFAULT_WIN_S, *, mono=True, dtype="float32"):
"""Yield (block, sr, start_frame) over the WHOLE file in `win_s` windows.
`mono=True` downmixes channels (mean); `mono=False` yields 2-D (n, channels).
Non-overlapping and contiguous: start_frame of window k is the running sum of
prior window lengths, so callers can map a block's samples to absolute time.
Bounded memory: only one window is resident at a time."""
import soundfile as sf
with sf.SoundFile(str(path)) as f:
sr = f.samplerate
wf = max(1, int(win_s * sr))
start = 0
while True:
blk = f.read(frames=wf, dtype=dtype, always_2d=not mono)
if len(blk) == 0:
return
block = (blk if blk.ndim == 1 else blk.mean(axis=1)) if mono else blk
yield block, sr, start
start += len(block)
"""Full-track streaming (#7) — the guarantees that make big uploads safe.
Proves the two things unit tests on 2 s clips can't: (1) the signal/loudness
engines now analyse the WHOLE track, past the old 60 s / 180 s head cap, and
(2) streaming didn't change the numbers — content_id stays byte-identical to the
old whole-file hash (so the deployed cache stays warm), and exact reductions
(waveform min/max) equal a single-pass computation.
cd armada/api && python -m pytest tests/test_fulltrack.py -q
"""
import hashlib
import tempfile
from pathlib import Path
import numpy as np
import soundfile as sf
import cache
from engines import loudness, signal, streaming
SR = 22050
def _click_track(bpm=120, secs=70.0, sr=SR):
"""A long pulse train (>60 s) so a head-only analysis would visibly truncate."""
n = int(sr * secs)
y = np.zeros(n, dtype="float32")
step = int(sr * 60 / bpm)
for i in range(0, n, step):
y[i:i + 200] += np.hanning(min(200, n - i)).astype("float32")
return y
def _write(y, sr=SR, suffix=".wav"):
p = Path(tempfile.mktemp(suffix=suffix))
sf.write(str(p), y, sr)
return str(p)
# ── (1) full-track coverage: past the old 60 s / 180 s head cap ─────────────────
def test_onsets_cover_whole_track_not_just_head():
p = _write(_click_track(secs=70.0))
r = signal.onsets(p)
assert r["full_track"] is True
assert r["duration_s"] >= 69.0 # whole track, not capped at 60
assert max(r["onsets"]) > 60.0 # hits found beyond the old head window
assert r["n_onsets"] >= 120 # ~120 bpm × 70 s ≈ 140 pulses
def test_waveform_spans_whole_track():
p = _write(_click_track(secs=70.0))
r = signal.waveform(p, bins=100)
assert r["full_track"] is True and r["bins"] == 100
assert r["duration_s"] >= 69.0
# pulses recur throughout → energy present in the last bins, not only the first
assert r["rms"][-1] > 0.0 and r["rms"][90] > 0.0
def test_loudness_full_track_exact_peak_and_flagged_approx():
y = _click_track(secs=70.0) * 0.5
r = loudness.loudness(_write(y))
assert r["full_track"] is True and r["approximate"] is True
assert r["lufs_method"] == "chunked" and r["lufs_integrated"] is not None
# peak is an EXACT running max over the stream
assert abs(r["peak_dbfs"] - 20 * np.log10(float(np.max(np.abs(y))))) < 0.1
assert r["duration_s"] >= 69.0
# ── (2) streaming didn't change the numbers ─────────────────────────────────────
def test_content_id_streaming_equals_whole_file_hash():
"""The streamed content_id must equal the OLD whole-array hash, or every
deployed cache entry silently invalidates on deploy."""
y = (0.2 * np.sin(2 * np.pi * 220 * np.linspace(0, 5, 5 * SR, endpoint=False))).astype("float32")
p = _write(y)
# reproduce the pre-#7 computation: sha256(whole_pcm.tobytes() + str(sr))
data, sr = sf.read(p, dtype="float32", always_2d=False)
h = hashlib.sha256()
h.update(np.ascontiguousarray(data).tobytes())
h.update(str(sr).encode())
expected = "cid_" + h.hexdigest()[:32]
assert cache.content_id(p) == expected
def test_waveform_reduction_is_exact_vs_single_pass():
"""Streamed per-bin min/max must equal a single-pass numpy computation."""
y = (0.4 * np.sin(2 * np.pi * 110 * np.linspace(0, 8, 8 * SR, endpoint=False))).astype("float32")
p = _write(y)
bins = 64
r = signal.waveform(p, bins=bins)
edges = np.linspace(0, len(y), bins + 1).astype(int)
for i in range(bins):
seg = y[edges[i]:edges[i + 1]]
assert abs(r["peaks"][i][0] - round(float(seg.min()), 4)) < 1e-3
assert abs(r["peaks"][i][1] - round(float(seg.max()), 4)) < 1e-3
def test_streaming_windows_cover_all_samples_once():
"""The window iterator must yield every sample exactly once, contiguously."""
y = np.arange(int(2.5 * SR), dtype="float32")
p = _write(y)
seen = 0
last_end = 0
for block, sr, start in streaming.windows(p, win_s=1.0, mono=True):
assert start == last_end # contiguous, no gap/overlap
seen += len(block)
last_end = start + len(block)
assert seen == len(y)
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment