Commit 7f558a76 by PLN (Algolia)

feat(douanier): scaffold the Audio-Intelligence API + emotion walking skeleton (#22, #36)

Stand up Douanier — the ParVagues Audio-Intelligence API — as a new self-hosted
service in armada/api/, and prove the lean MVP vertical end-to-end before
investing in the auth/quota/jobs/cache machinery it'll later sit on.

WHY: hexa (Shipow's hydra-live-hexa Studio) wants to call our engines (stems,
loops, emotion, samples, features) live from his Vercel world. Rather than build
six foundational tasks before the first useful call, we ship a walking skeleton:
scaffold + ONE real synchronous endpoint + a stub token, to de-risk the three
seams that actually matter — engines import cleanly, the service runs, a client
can auth & call.

WHAT:
- FastAPI app (app.py) versioned at /v1: /v1/healthz (status + engine
  availability) and POST /v1/analyze/emotion (upload clip -> valence/arousal +
  top emotions), guarded by a hardcoded dev bearer that FAILS CLOSED (503 if no
  token configured, never accidentally unauthenticated).
- engines/ adapter layer with lazy heavy imports — ears.emotion_of() wraps the
  real tide-table seam: sample_semantics.embed_audios -> emotion_ontology.score.
  The route never touches the engine directly, so #28 can wrap it with
  cache+async later without changing the public shape.
- config.py (env-overridable paths/token/limits); requirements.txt documents the
  --system-site-packages venv trick (reuse the box's multi-GB torch/CLAP/demucs
  stack, add only FastAPI on top — Arch PEP-668 blocks system pip).
- SRE.md: a context letter to whoever maps the public api.nech.pl path — the 10
  hosting decisions that are theirs (path vs host routing, systemd vs Docker,
  ports, TLS, GPU dispatch, freebox mount, secrets, observability, WAF, CORS).
- README.md + smoke tests.

VALIDATION:
- 5/5 smoke tests green (healthz; auth fails-closed/opens; happy-path mocked;
  413 oversize) with no CLAP/torch needed.
- Real engine seam confirmed importable, and the FULL pipeline run end-to-end on
  a synthetic clip: real CLAP read, valence 0.154 / arousal 0.141, V/A in range,
  34.4s cold (model load — which is precisely why #28 warms CLAP once in the
  worker rather than per request).
parent d4c0ae6d
_cache/
__pycache__/
*.pyc
.pytest_cache/
# Douanier — ParVagues Audio-Intelligence API 🛂
The customs gate over the fleet's audio-intelligence engines: one self-hosted,
token-authed, async HTTP API that turns a URL or a clip into **stems, loops,
emotion reads, sample analysis, and features**. Named after Le Douanier Rousseau.
Productizes the [Foundry](../../tools/foundry/) + the tide-table "ears"
(`emotion_ontology`, `sample_semantics`, the feature stack) for **hexa** (Shipow)
and future seats. Full design + decisions: memory `project_douanier_api`, EPIC
task **#21**, and [`SRE.md`](./SRE.md) for the hosting handoff.
## Layout
```
armada/api/
app.py FastAPI app — /v1 router, /v1/healthz, /v1/analyze/emotion
config.py paths · dev token · limits (env-overridable)
engines/ thin adapters over the fleet libs (lazy heavy imports)
ears.py emotion read = sample_semantics embed → emotion_ontology.score
tests/ smoke tests (no CLAP/torch needed — engine call is mocked)
requirements.txt API-layer deps only; ML stack inherited from system venv
```
## Run (dev)
```bash
python3 -m venv --system-site-packages ~/.virtualenvs/douanier # once
~/.virtualenvs/douanier/bin/pip install -r requirements.txt # once
cd armada/api
DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \
~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
```
- `GET /v1/healthz` — status + engine availability
- `POST /v1/analyze/emotion``multipart` audio file → `{valence, arousal, top…}`
(Bearer `DOUANIER_DEV_TOKEN` required)
- `GET /v1/docs` — Swagger UI
```bash
curl -s -H "Authorization: Bearer $DOUANIER_DEV_TOKEN" \
-F file=@clip.wav http://127.0.0.1:8780/v1/analyze/emotion | jq
```
## Test
```bash
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
```
## Status
Walking skeleton (tasks #22 + #36): scaffold + synchronous emotion endpoint with
stub auth. Queued, building *under* this without changing the public shape:
SQLite auth/quota (#23/#24), async jobs + worker (#25), content-addressed cache
(#26), signed artifact URLs (#27), `/features`+`/samples` hardened (#28),
`/sources``/separate``/loops` heavy chain (#29–#31), OpenAPI client (#32),
ops/runbook (#34).
> **Why a venv with `--system-site-packages`?** The ears/Foundry engines need the
> multi-GB ML stack (torch/CLAP/librosa/demucs) already installed system-wide on
> the box; the venv reuses it and only adds FastAPI/uvicorn. Arch's PEP-668 lock
> is why we don't `pip install` into system Python.
# Dear SRE — re: hosting *Douanier* on `api.nech.pl` 🛂
A letter, because you own the seam between this little service and the public
internet, and I'd rather ask than assume. Here's what Douanier is, what state
it's in, and the handful of decisions that are yours, not mine.
## What it is (context)
**Douanier** (after Le Douanier Rousseau) is the ParVagues **Audio-Intelligence
API** — it productizes the fleet's engines (stem separation, loop/chop finding,
emotion analysis, sample analysis, the feature stack) behind one self-hosted,
token-authed, async HTTP API. First customer is **hexa** (Shipow's
hydra-live-hexa Studio), who'll call it from his Vercel functions; we want the
door open to selling more seats later.
Design decisions already locked (so you know the shape):
- **Self-hosted, all of it.** Compute + storage live on the box (CPU now, cloud
GPU later via the `parvagues@nech.pl` creds). No third-party blob/CDN — the
cost sketch said home egress beats cloud egress on big audio by ~20×.
- **Artifacts are a CACHE, not storage** — transient on the **freebox** mount,
content-addressed (sha256 of decoded audio), evictable, rebuildable. So:
losing the artifact cache is a cache-miss, not data loss. The **SQLite of
accounts/tokens** is the one thing that genuinely wants a backup.
- **Versioned at `/v1`.** Every route is emitted under `/v1/...` and I'll bump
the segment for breaking changes — so you can route by path prefix safely.
- **Runs in a venv** at `~/.virtualenvs/douanier`, created with
`--system-site-packages` so it reuses the already-installed ML stack
(torch/CLAP/numpy/librosa/demucs) and only adds FastAPI/uvicorn on top. This
matters for the containerization question below.
## What's running today
A walking skeleton (≈100 lines): `GET /v1/healthz` + a synchronous
`POST /v1/analyze/emotion` (upload a clip → valence/arousal + emotion tags),
guarded by a hardcoded dev bearer. Run locally:
```bash
cd armada/api
DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \
~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
# → http://127.0.0.1:8780/v1/healthz /v1/docs
```
The async jobs, SQLite auth/quota, content cache, signed artifact URLs, and the
heavy separation endpoints are designed and queued (tasks #23–#35) — they land
incrementally *under* this skeleton without changing its public shape.
## What I need from you (the actual questions)
1. **Public mapping.** Path-based (`api.nech.pl/v1/sound/...` → proxy forwards to
the service) or host-based (`sound.api.nech.pl`)? I emit `/v1/...`; tell me if
you'll strip/rewrite a prefix so I can set `root_path` accordingly.
2. **Process model.** My default is **systemd --user** units (`douanier-api` +
`douanier-worker`, linger on), mirroring the Bridge. Do you prefer that, or a
**Docker** container? ⚠️ Containerizing is awkward here: the venv leans on the
host's multi-GB GPU-coupled ML stack — a faithful image is huge and GPU-bound.
If you want Docker, let's talk about whether the heavy worker stays on the host.
3. **Port + bind.** What port should it bind on localhost (dev uses 8780)? Any
port registry on the box I should claim from?
4. **TLS.** I assume you terminate TLS at the proxy (Caddy/nginx/Traefik) and the
service stays plain HTTP on loopback. Correct?
5. **GPU dispatch.** CPU-only today. When we add separation, do GPU jobs run on
*this* box (RTX 2060) or a separate runner? How do you want me to target cloud
GPU later — env-selected backend, or a dispatch URL?
6. **Mounts.** Is the **freebox** reachable by the service user (path? always
mounted?) so I can point `DOUANIER_CACHE` at it? It'll grow to ~1–2 TB at
"success" scale; it's evictable, so a size cap is fine — any cap you want?
7. **Secrets.** How should I inject the bearer tokens' DB and the **HMAC signing
key** for artifact URLs — a systemd `EnvironmentFile`, a secrets manager,
something else? (Nothing secret is committed; tokens are minted via a CLI.)
8. **Observability.** journald enough, or do you want logs shipped somewhere? I
can expose `/v1/metrics` (Prometheus text) — want it? Liveness probe is
`GET /v1/healthz` (200 + engine availability).
9. **Edge rate-limit / WAF.** I do per-token quota in-app (SQLite token-bucket).
Do you also want a coarse IP rate-limit or WAF at the proxy? And is
`api.nech.pl` already DNS'd to the box / behind Cloudflare?
10. **CORS origins.** hexa calls cross-origin from Vercel. Want the allowlist
enforced at the proxy, in-app, or both? Send me his origins when known.
## What I'll give you (the contract)
- A stable `/v1` route surface + **OpenAPI** at `/v1/openapi.json` and Swagger at
`/v1/docs`.
- `GET /v1/healthz` for your liveness/readiness probes.
- A `DEPLOY.md` (task #34) with the exact run command, every env var, ports,
mounts, and the backup note (back up the SQLite, ignore the cache).
- **systemd unit files** for the API + worker (or a Dockerfile if you pick #2).
No rush on all ten — #1, #2, #3 unblock the first real deploy; the rest can come
as the heavier endpoints land. Reply inline in this file or ping me.
*Douanier, the customs officer* (on behalf of the fleet) ⛵
"""Douanier — ParVagues Audio-Intelligence API (the customs gate).
Walking skeleton (#22 scaffold + #36 vertical): a versioned FastAPI app with a
health check and ONE real, synchronous endpoint — /v1/analyze/emotion — guarded
by a hardcoded dev bearer. It proves the three risky seams end-to-end (engines
import · service runs · a client can auth & call) before we invest in the
SQLite auth/quota/jobs/cache machinery (#23–#27) that hardens it later.
run: cd armada/api && ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
"""
import secrets
import tempfile
from pathlib import Path
from fastapi import FastAPI, APIRouter, UploadFile, File, Header, HTTPException, Depends
from fastapi.middleware.cors import CORSMiddleware
import config
from engines import ears
app = FastAPI(
title="Douanier — ParVagues Audio-Intelligence API",
version="0.1.0",
description="Customs gate over the fleet's audio-intelligence engines. "
"Versioned at /v1; SRE maps the public api.nech.pl path in. See SRE.md.",
docs_url=f"/{config.VERSION}/docs",
openapi_url=f"/{config.VERSION}/openapi.json",
)
# hexa lives in Shipow's Vercel world → allow cross-origin calls. Tighten to his
# origins once known (kept open in dev so the walking skeleton is easy to hit).
app.add_middleware(
CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"],
)
v1 = APIRouter(prefix=f"/{config.VERSION}")
# ── stub auth (#36) — swapped for the SQLite token store in #23 ─────────────────
def require_token(authorization: str = Header(default="")) -> str:
"""Hardcoded bearer check. No DEV_TOKEN configured → 503 (fail closed, never
accidentally serve the engine unauthenticated)."""
if not config.DEV_TOKEN:
raise HTTPException(503, "auth not configured (set DOUANIER_DEV_TOKEN)")
scheme, _, token = authorization.partition(" ")
if scheme.lower() != "bearer" or not token:
raise HTTPException(401, "missing bearer token")
if not secrets.compare_digest(token, config.DEV_TOKEN):
raise HTTPException(401, "invalid token")
return token
# ── health ──────────────────────────────────────────────────────────────────
@v1.get("/healthz")
def healthz():
ok, hint = ears.available()
return {
"service": config.SERVICE,
"version": config.VERSION,
"status": "ok",
"auth_configured": bool(config.DEV_TOKEN),
"engines": {"emotion": {"available": ok, "hint": hint}},
}
# ── #36 walking-skeleton endpoint: synchronous emotion read ─────────────────────
@v1.post("/analyze/emotion")
async def analyze_emotion(file: UploadFile = File(...), _tok: str = Depends(require_token)):
"""Upload a short audio clip → valence/arousal + top emotions (CLAP).
Synchronous: no job queue, no cache yet (repeats recompute — fine for one
customer). The engine call is behind engines.ears so #28 can later wrap it
with cache + async without touching this route.
"""
data = await file.read()
limit = config.MAX_UPLOAD_MB * 1024 * 1024
if len(data) > limit:
raise HTTPException(413, f"file too large (> {config.MAX_UPLOAD_MB} MB)")
suffix = Path(file.filename or "clip").suffix or ".wav"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(data)
tmp.flush()
try:
read = ears.emotion_of(tmp.name)
except Exception as e:
raise HTTPException(422, f"emotion analysis failed: {e}")
return {"filename": file.filename, "emotion": read}
app.include_router(v1)
"""Douanier config — paths, dev token, version. Env-overridable, sane defaults.
Flat-module layout (run from this dir): the service imports `config` and
`engines.*` directly, matching the tide-table analyzers' sibling-import style.
"""
import os
from pathlib import Path
# ── identity ──────────────────────────────────────────────────────────────────
SERVICE = "douanier"
VERSION = "v1" # forced route prefix; SRE maps the public path in.
# ── repo-relative paths ─────────────────────────────────────────────────────────
HERE = Path(__file__).resolve().parent # armada/api
ARMADA = HERE.parent # armada
TIDE_TABLE = ARMADA / "tide-table" # the ears engines live here
FOUNDRY = ARMADA.parent / "tools" / "foundry" # the Foundry engines
# ── cache / data roots (transient — freebox is the SSOT later) ───────────────────
# Default to a local dir for dev; point CACHE_ROOT at the freebox mount in prod.
CACHE_ROOT = Path(os.environ.get("DOUANIER_CACHE", HERE / "_cache"))
DB_PATH = Path(os.environ.get("DOUANIER_DB", HERE / "_cache" / "douanier.db"))
# ── auth ─────────────────────────────────────────────────────────────────────
# WALKING-SKELETON stub (#36): one hardcoded bearer from the env, swapped for the
# SQLite token store in #23. No token set → the guarded routes return 503 (not
# wide open) so we never accidentally ship an unauthenticated endpoint.
DEV_TOKEN = os.environ.get("DOUANIER_DEV_TOKEN", "")
# ── limits ─────────────────────────────────────────────────────────────────────
MAX_UPLOAD_MB = int(os.environ.get("DOUANIER_MAX_UPLOAD_MB", "40"))
"""Engine adapters — thin seams over the fleet's audio-intelligence libraries.
Each adapter keeps import-time CHEAP (no torch/CLAP at module load); the heavy
deps load lazily on first real call, so the API process boots instantly and a
missing/unbuilt engine degrades to a clear error instead of a crash.
"""
"""Ears adapter — emotion read over the tide-table CLAP engines.
Seam: sample_semantics.embed_audios([path]) → a normalized CLAP audio embed,
then emotion_ontology.score(embed) → {valence, arousal, top, dist, confidence}.
Both live in armada/tide-table/ and import each other flat, so we put that dir on
sys.path lazily. CLAP/torch only load inside embed_audios (first call ≈ cold).
"""
import sys
from functools import lru_cache
from pathlib import Path
import config
def _ensure_path():
p = str(config.TIDE_TABLE)
if p not in sys.path:
sys.path.insert(0, p)
def available() -> tuple[bool, str]:
"""(ok, hint) — cheap check that the engines are importable. Does NOT load CLAP."""
_ensure_path()
try:
import emotion_ontology # noqa: F401 (imports numpy + sample_semantics, light)
return True, "emotion engine importable (CLAP loads on first call)"
except Exception as e: # pragma: no cover - environment-dependent
return False, f"emotion engine unavailable: {e!r}"
@lru_cache(maxsize=1)
def _engines():
"""Import + memoize the engine modules (CLAP still lazy within them)."""
_ensure_path()
import emotion_ontology as EMO
import sample_semantics as SEM
return EMO, SEM
def emotion_of(audio_path: str | Path) -> dict:
"""One audio file → emotion read. Heavy: triggers CLAP on first call.
Returns the emotion_ontology.score() dict (valence/arousal in [-1,1], top
emotions, confidence) — exactly the numbers the #90 hexa bundle uses, so the
API and the batch bundle agree by construction.
"""
EMO, SEM = _engines()
ae, _ = SEM.embed_audios([Path(audio_path)])
if not len(ae):
raise ValueError("could not decode audio for embedding")
return EMO.score(ae[0])
# Douanier API — the API layer only. Heavy audio deps (numpy, pydantic, torch,
# CLAP, librosa, demucs) are INHERITED from system site-packages via a
# --system-site-packages venv, so we don't reinstall the multi-GB ML stack:
#
# python3 -m venv --system-site-packages ~/.virtualenvs/douanier
# ~/.virtualenvs/douanier/bin/pip install -r requirements.txt
#
fastapi>=0.115
uvicorn[standard]>=0.30
python-multipart>=0.0.9 # UploadFile / multipart form parsing
"""Walking-skeleton smoke tests — no CLAP/torch required.
Cover the seams that must hold before anything else: the app boots, /v1/healthz
answers, and the stub auth fails closed / opens with the dev token. The actual
emotion inference is exercised separately (slow, needs CLAP) — here we only
assert the route is wired & guarded, by monkeypatching the engine call.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/ -q
"""
import os
import sys
from pathlib import Path
os.environ.setdefault("DOUANIER_DEV_TOKEN", "test-secret")
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from fastapi.testclient import TestClient # noqa: E402
import app as APP # noqa: E402
from engines import ears # noqa: E402
client = TestClient(APP.app)
AUTH = {"Authorization": "Bearer test-secret"}
def test_healthz_ok():
r = client.get("/v1/healthz")
assert r.status_code == 200
body = r.json()
assert body["service"] == "douanier" and body["version"] == "v1"
assert "emotion" in body["engines"]
assert body["auth_configured"] is True
def test_emotion_requires_token():
r = client.post("/v1/analyze/emotion", files={"file": ("c.wav", b"RIFF....", "audio/wav")})
assert r.status_code == 401
def test_emotion_rejects_bad_token():
r = client.post("/v1/analyze/emotion",
files={"file": ("c.wav", b"RIFF....", "audio/wav")},
headers={"Authorization": "Bearer nope"})
assert r.status_code == 401
def test_emotion_happy_path_mocked(monkeypatch):
# Stub the heavy CLAP call — we're testing the route/contract, not the model.
fake = {"valence": 0.42, "arousal": -0.10, "top": [["warm", 0.5]], "confidence": 0.2}
monkeypatch.setattr(ears, "emotion_of", lambda _p: fake)
r = client.post("/v1/analyze/emotion",
files={"file": ("c.wav", b"RIFF0123456789", "audio/wav")}, headers=AUTH)
assert r.status_code == 200
body = r.json()
assert body["filename"] == "c.wav"
assert body["emotion"]["valence"] == 0.42
assert -1 <= body["emotion"]["arousal"] <= 1
def test_emotion_too_large(monkeypatch):
monkeypatch.setattr(APP.config, "MAX_UPLOAD_MB", 0) # everything is too large
r = client.post("/v1/analyze/emotion",
files={"file": ("c.wav", b"x" * 1024, "audio/wav")}, headers=AUTH)
assert r.status_code == 413
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