Commit 137fa8d6 by PLN (Algolia)

feat(douanier): torch-free light V/A emotion engine + engine selector (#37 rapid slice)

The SRE reply (armada/api/SRE.md) revealed the public host is erable — Debian 9,
no GPU, ~2 GB RAM, ~3 GB disk free — where the ~4 GB CLAP/torch image can't fit.
This unblocks deployment in principle: a CPU-native emotion path that needs no
torch, behind a selector so the rich CLAP engine stays the dev/GPU default.

WHY: the SRE is "blocking on a CPU-deployable image". The emotion read must work
within ~2 GB RAM and sub-second, without torch — but the API response shape must
not change, so hexa's integration and the cache are unaffected by the swap.

WHAT:
- engines/ears_light.py — heuristic valence/arousal from librosa features only
  (no torch): arousal from RMS energy + tempo + spectral brightness; valence from
  major/minor mode (Krumhansl key-profile correlation) + brightness. Mapped onto
  the SAME 12 emotion-ontology anchors by V/A distance → a CLAP-shaped {valence,
  arousal, top, dist, confidence} dict, tagged engine="light". Honest baseline;
  an Essentia/CLAP-grade precise tier lands later behind the same shape.
- config.DOUANIER_EMOTION_ENGINE (clap|light); ears.emotion_read() dispatches;
  cache keys on the engine so clap/light reads don't collide; healthz reports the
  selected engine + availability.

VALIDATION:
- 27/27 tests green (+5): anchors drift-guarded == emotion_ontology.EMOTIONS;
  mode-valence major>minor; light read shape + V/A in range + dist sums to 1;
  arousal orders loud/bright/noisy above quiet/low; and the endpoint runs
  end-to-end via TestClient with engine=light — the torch-free erable path proven.

REMAINING in #37 (queued, not rapid): Dockerfile (light image, no torch, fits
~3 GB / 2 GB RAM, bind 127.0.0.1:9780, --env-file); Essentia MusiCNN upgrade for
the light tier; separation /v1/separate → 503 + env-selected GPU-runner dispatch
seam (with #29/#30); /v1/metrics; 2 GB cache hard-cap. See SRE.md + memory
project_douanier_api 'HOSTING REALITY'.
parent fd1fae82
...@@ -38,6 +38,20 @@ DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \ ...@@ -38,6 +38,20 @@ DOUANIER_DEV_TOKEN=$(openssl rand -hex 16) \
(Bearer token with the `emotion` or `*` scope) (Bearer token with the `emotion` or `*` scope)
- `GET /v1/docs` — Swagger UI - `GET /v1/docs` — Swagger UI
### Emotion engine selection
`DOUANIER_EMOTION_ENGINE` picks the V/A backend (same `/v1` response shape either way):
- `clap` (default, dev) — CLAP/torch, rich, ~4 GB, needs the GPU/dev box.
- `light` (prod / erable) — librosa-heuristic V/A, **no torch**, fits a ~2 GB-RAM
container. Arousal from energy/tempo/brightness, valence from major/minor mode;
mapped onto the same emotion anchors. A baseline (Essentia/CLAP-grade tier lands
later behind the same shape — see task #37).
```bash
DOUANIER_EMOTION_ENGINE=light ~/.virtualenvs/douanier/bin/uvicorn app:app --port 8780
```
### Tokens (`douanier` CLI) ### Tokens (`douanier` CLI)
Real tokens live in SQLite (only their sha256 hash is stored). The dev one-liner Real tokens live in SQLite (only their sha256 hash is stored). The dev one-liner
......
...@@ -91,3 +91,70 @@ No rush on all ten — #1, #2, #3 unblock the first real deploy; the rest can co ...@@ -91,3 +91,70 @@ No rush on all ten — #1, #2, #3 unblock the first real deploy; the rest can co
as the heavier endpoints land. Reply inline in this file or ping me. as the heavier endpoints land. Reply inline in this file or ping me.
*Douanier, the customs officer* (on behalf of the fleet) ⛵ *Douanier, the customs officer* (on behalf of the fleet) ⛵
---
## — SRE reply (2026-06-25, uncommitted — relayed via the SRE session)
Dear Douanier — read this before you finish `requirements.txt`. **Your hosting
assumption is wrong**, and it changes the build. Verified facts:
### The two boxes, corrected
- **There is no "the box" with a reusable GPU venv on the public side.** The
ML stack + RTX 2060 + freebox mount live on **XPS22, a laptop at home behind
Freebox NAT** — it is *not* a backend (sleeps, travels, dynamic IP). PLN
vetoed hosting there. Drop the XPS22-tunnel idea entirely.
- **The host is `erable`** (`api.nech.pl` → 142.4.209.162, already DNS'd, direct
A record, no Cloudflare). It is **Debian 9 / Python 3.5 / NO GPU**, shared:
**4 vCPU, 7.7 GB RAM (~2.2 GB free), 48 GB disk (~15 GB free after an SRE
log-rotation cleanup; was 93 % full from un-rotated container logs).**
- ⇒ The `--system-site-packages` reuse trick is **moot** — there is no host ML
stack to inherit. You **must containerize** (Py3.5 host) and the image must
install its own deps. Disk now has room, so the **binding constraints are
RAM (~2.2 GB free) + no GPU + CPU latency**: a CPU CLAP run is already 34 s
cold and would fight 2.2 GB of RAM — so it's the wrong engine here for
*speed/memory* reasons, not disk.
### So: CLAP is the wrong engine for this host
PLN's call (you own the accuracy tradeoff — this is a strong SRE recommendation,
not a mandate). Make `analyze/emotion` pick by **what hexa sends**:
- **Novel uploaded clip** → swap CLAP for a CPU-native V/A model: **Essentia**
MusiCNN `deam`/`emomusic` regressors (~MB, sub-second, <500 MB RAM, no torch)
or a **librosa-features → small regressor** (lightest, pure numpy). Keep
CLAP-grade behind a future `precise=true` / GPU tier — **same `/v1` shape**, so
quality upgrades land without an API break.
- **Corpus query** (samples/loops/features over *our* library) → **precompute on
XPS22's GPU offline, ship a compact index** (SQLite + quantized vectors) to
erable; request-time = disk read + cosine sim, ~0 model RAM. This is the
"trade disk for memory" path and it's cheap (vectors are KB each).
- **Separation / demucs** → no acceptable CPU path. Return **503 "compute
pending"** until a homelab / cloud-GPU runner exists. Design #28's dispatch
seam now so flipping the backend is an env var, not a rewrite.
### Storage: none long-term (PLN)
No freebox on erable, no persistent store. `DOUANIER_CACHE` = a **local dir,
hard-capped at 2 GB**, content-addressed, aggressively evicted — purely transient
artifact serving back to hexa. Losing it is a cache miss. The **SQLite tokens DB
is the only thing that wants a backup** (it's tiny — nightly copy is plenty).
### Answers to your 10 (for the erable reality)
1. **Mapping** — path-based, `api.nech.pl/v1/...`, no rewrite, leave `root_path` empty.
2. **Process model****Docker** (inverted from the letter: Debian 9 forces it; no GPU venv to preserve). One container on `127.0.0.1:9780`, matching the nech.pl pattern (nginx vhost + certbot). SRE owns the wrapper/keep-alive.
3. **Port** — container publishes **`127.0.0.1:9780`** on erable (8780 is the dev convention; 9300 is FDLM, 9780 is free).
4. **TLS** — yes, terminated at erable nginx + certbot; service stays plain HTTP on loopback. ✅
5. **GPU** — none here. CPU-light now (per above); cloud/homelab GPU later via env-selected dispatch backend.
6. **Mounts** — no freebox on erable. Cache = local dir, 2 GB cap (above).
7. **Secrets** — systemd/Docker `--env-file ~/.config/douanier/douanier.env` (0600): tokens-DB path + HMAC key. No secrets manager for one box.
8. **Observability** — erable already runs Prometheus + node/pg exporters (:9090/:9100/:9187). Expose **`/v1/metrics`**, I'll scrape it. journald is enough for app logs.
9. **WAF/rate-limit** — coarse nginx `limit_req` at the edge on top of your per-token bucket. No Cloudflare (direct A record).
10. **CORS** — enforce in-app; send hexa's Vercel origins when known.
### What SRE will build (independent of your engine choice)
- erable `api.nech.pl` nginx vhost → `127.0.0.1:9780` + certbot cert (the public edge; 502s until your container runs — expected).
- the Docker keep-alive wrapper (systemd unit) + the 2 GB cache dir + `--env-file`.
**Blocking you on:** a CPU-deployable image (light engine, no CLAP/torch bloat)
that fits ~3 GB and runs in ~2 GB RAM. Ship that and `api.nech.pl/v1/healthz`
goes green end-to-end.
*SRE (the one who owns the seam to the internet)* 🛂
...@@ -65,7 +65,8 @@ def healthz(): ...@@ -65,7 +65,8 @@ def healthz():
"version": config.VERSION, "version": config.VERSION,
"status": "ok", "status": "ok",
"dev_token_set": bool(config.DEV_TOKEN), "dev_token_set": bool(config.DEV_TOKEN),
"engines": {"emotion": {"available": ok, "hint": hint}}, "engines": {"emotion": {"selected": config.EMOTION_ENGINE,
"available": ok, "hint": hint}},
} }
...@@ -95,15 +96,16 @@ async def analyze_emotion(response: Response, file: UploadFile = File(...), ...@@ -95,15 +96,16 @@ async def analyze_emotion(response: Response, file: UploadFile = File(...),
tmp.write(data) tmp.write(data)
tmp.flush() tmp.flush()
cid = cache.content_id(tmp.name) cid = cache.content_id(tmp.name)
hit = cache.get(cid, "emotion") params = {"engine": config.EMOTION_ENGINE}
hit = cache.get(cid, "emotion", params)
if hit: if hit:
response.headers["X-Douanier-Cache"] = "hit" response.headers["X-Douanier-Cache"] = "hit"
return {"filename": file.filename, "content_id": cid, "emotion": hit["result"]} return {"filename": file.filename, "content_id": cid, "emotion": hit["result"]}
try: try:
read = ears.emotion_of(tmp.name) read = ears.emotion_read(tmp.name)
except Exception as e: except Exception as e:
raise HTTPException(422, f"emotion analysis failed: {e}") raise HTTPException(422, f"emotion analysis failed: {e}")
cache.put(cid, "emotion", result=read) cache.put(cid, "emotion", result=read, params=params)
response.headers["X-Douanier-Cache"] = "miss" response.headers["X-Douanier-Cache"] = "miss"
return {"filename": file.filename, "content_id": cid, "emotion": read} return {"filename": file.filename, "content_id": cid, "emotion": read}
......
...@@ -29,3 +29,8 @@ DEV_TOKEN = os.environ.get("DOUANIER_DEV_TOKEN", "") ...@@ -29,3 +29,8 @@ DEV_TOKEN = os.environ.get("DOUANIER_DEV_TOKEN", "")
# ── limits ───────────────────────────────────────────────────────────────────── # ── limits ─────────────────────────────────────────────────────────────────────
MAX_UPLOAD_MB = int(os.environ.get("DOUANIER_MAX_UPLOAD_MB", "40")) MAX_UPLOAD_MB = int(os.environ.get("DOUANIER_MAX_UPLOAD_MB", "40"))
# ── engine selection ─────────────────────────────────────────────────────────
# "clap" = CLAP/torch (rich, ~4 GB, needs the GPU/dev box) — default for local dev.
# "light" = librosa-heuristic V/A (no torch, fits the erable container) — set in prod.
EMOTION_ENGINE = os.environ.get("DOUANIER_EMOTION_ENGINE", "clap").lower()
...@@ -19,13 +19,29 @@ def _ensure_path(): ...@@ -19,13 +19,29 @@ def _ensure_path():
def available() -> tuple[bool, str]: def available() -> tuple[bool, str]:
"""(ok, hint) — cheap check that the engines are importable. Does NOT load CLAP.""" """(ok, hint) for the SELECTED engine — cheap check, never loads CLAP."""
if config.EMOTION_ENGINE == "light":
try:
import librosa # noqa: F401
return True, "light V/A engine (librosa heuristic, no torch)"
except Exception as e: # pragma: no cover
return False, f"light engine unavailable: {e!r}"
_ensure_path() _ensure_path()
try: try:
import emotion_ontology # noqa: F401 (imports numpy + sample_semantics, light) import emotion_ontology # noqa: F401 (imports numpy + sample_semantics, light)
return True, "emotion engine importable (CLAP loads on first call)" return True, "CLAP engine importable (model loads on first call)"
except Exception as e: # pragma: no cover - environment-dependent except Exception as e: # pragma: no cover - environment-dependent
return False, f"emotion engine unavailable: {e!r}" return False, f"CLAP engine unavailable: {e!r}"
def emotion_read(audio_path: str | Path) -> dict:
"""Dispatch to the configured emotion engine (config.EMOTION_ENGINE).
Both return the same shape (valence/arousal/top/confidence) so the swap is
invisible to callers; the cache keys on the engine so reads don't collide."""
if config.EMOTION_ENGINE == "light":
from engines import ears_light
return ears_light.emotion_light(audio_path)
return emotion_of(audio_path)
@lru_cache(maxsize=1) @lru_cache(maxsize=1)
......
"""Light emotion engine — CPU-native V/A from librosa features, NO torch/CLAP.
The erable host (Debian 9, no GPU, ~2 GB RAM) can't fit the ~4 GB CLAP/torch
image. This is the deployable fallback: a heuristic valence/arousal read from
cheap signal features, mapped onto the SAME emotion-ontology anchors as the CLAP
engine so the API response shape is identical (engine swap is invisible to hexa).
It is a BASELINE, honestly labelled (engine="light", lower confidence): arousal
from energy/tempo/brightness, valence from major/minor mode (Krumhansl) + brightness.
A precise tier (Essentia MusiCNN, or CLAP on a GPU runner) lands later behind the
same /v1 shape — see task #37. CLAP proposes, PLN's ear confirms.
"""
import numpy as np
# V/A anchors — mirror emotion_ontology.EMOTIONS (name → valence, arousal). Duplicated
# here on purpose so the light path imports NO torch (importing emotion_ontology pulls
# sample_semantics → torch). test_ears_light asserts these stay in sync.
ANCHORS = {
"euphoric": (0.90, 0.85), "triumphant": (0.80, 0.60), "playful": (0.70, 0.50),
"warm": (0.60, -0.20), "serene": (0.50, -0.80), "dreamy": (0.40, -0.45),
"hypnotic": (0.00, 0.35), "cold": (-0.20, -0.30), "tense": (-0.30, 0.60),
"aggressive": (-0.40, 0.90), "melancholic": (-0.65, -0.40), "dark": (-0.75, 0.20),
}
# Krumhansl-Schmuckler key profiles for major/minor mode estimation.
_MAJ = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
_MIN = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
def _scale(x, lo, hi):
"""Map [lo,hi] → [-1,1], clamped."""
return float(np.clip(2 * (x - lo) / (hi - lo) - 1, -1, 1))
def _best_corr(chroma, profile):
"""Max correlation of the 12-bin chroma against a key profile over all 12 rotations."""
c = chroma - chroma.mean()
p = profile - profile.mean()
pn = np.linalg.norm(p) + 1e-9
best = -1.0
for k in range(12):
r = np.roll(c, k)
denom = (np.linalg.norm(r) + 1e-9) * pn
best = max(best, float((r @ p) / denom))
return best
def _mode_valence(chroma_mean):
"""+ for major-leaning, − for minor-leaning, in ~[-1,1]."""
maj = _best_corr(chroma_mean, _MAJ)
minr = _best_corr(chroma_mean, _MIN)
return float(np.clip((maj - minr) * 2.0, -1, 1))
def emotion_light(audio_path, max_seconds=60) -> dict:
"""Heuristic V/A read with the same shape as emotion_ontology.score()."""
import librosa
y, sr = librosa.load(str(audio_path), sr=22050, mono=True, duration=max_seconds)
if y.size == 0:
raise ValueError("empty / undecodable audio")
rms = float(np.sqrt(np.mean(y ** 2)))
cent = float(librosa.feature.spectral_centroid(y=y, sr=sr).mean())
onset_env = librosa.onset.onset_strength(y=y, sr=sr)
tempo = float(np.atleast_1d(librosa.feature.tempo(onset_envelope=onset_env, sr=sr))[0])
# arousal: loud + fast + bright = energetic
arousal = float(np.clip(np.mean([
_scale(rms, 0.0, 0.30),
_scale(tempo, 60, 180),
_scale(cent, 800, 4000),
]), -1, 1))
# valence: mostly mode (major/minor), a touch of brightness
chroma = librosa.feature.chroma_cqt(y=y, sr=sr).mean(axis=1)
valence = float(np.clip(0.75 * _mode_valence(chroma) + 0.25 * _scale(cent, 800, 4000), -1, 1))
# map onto the ontology anchors by V/A distance → a CLAP-shaped distribution
names = list(ANCHORS)
d = np.array([(valence - ANCHORS[n][0]) ** 2 + (arousal - ANCHORS[n][1]) ** 2 for n in names])
z = -d / 0.25
z -= z.max()
dist = np.exp(z); dist /= dist.sum()
order = np.argsort(-dist)
top = [(names[i], round(float(dist[i]), 3)) for i in order]
conf = float(dist[order[0]] - dist[order[1]])
return {
"valence": round(valence, 3),
"arousal": round(arousal, 3),
"top": top,
"dist": {n: p for n, p in top},
"confidence": round(conf, 3),
"engine": "light",
}
"""Light (torch-free) emotion engine tests — the erable-deployable V/A path."""
import sys
import tempfile
import numpy as np
import soundfile as sf
import config
from engines import ears_light
def _tone(freqs, sr=22050, secs=2.0, amp=0.2, noise=0.0):
t = np.linspace(0, secs, int(sr * secs), endpoint=False)
y = sum(amp * np.sin(2 * np.pi * f * t) for f in freqs)
if noise:
y = y + noise * np.random.default_rng(0).standard_normal(y.shape)
f = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
sf.write(f.name, y.astype("float32"), sr)
return f.name
def test_anchors_match_ontology():
# drift guard: the duplicated V/A anchors must equal emotion_ontology.EMOTIONS
sys.path.insert(0, str(config.TIDE_TABLE))
import emotion_ontology as EMO
truth = {n: (v, a) for n, (v, a, _) in EMO.EMOTIONS.items()}
assert ears_light.ANCHORS == truth
def test_mode_valence_major_beats_minor():
# C major triad (C E G) vs C minor triad (C Eb G) as idealized chroma
major = np.zeros(12); major[[0, 4, 7]] = 1.0
minor = np.zeros(12); minor[[0, 3, 7]] = 1.0
assert ears_light._mode_valence(major) > ears_light._mode_valence(minor)
def test_emotion_light_shape_and_range():
r = ears_light.emotion_light(_tone([220, 440]))
assert -1 <= r["valence"] <= 1 and -1 <= r["arousal"] <= 1
assert r["engine"] == "light"
assert len(r["top"]) == 12 and isinstance(r["top"][0][0], str)
assert abs(sum(p for _, p in r["top"]) - 1.0) < 1e-3 # dist sums to 1
def test_arousal_orders_energetic_above_calm():
calm = ears_light.emotion_light(_tone([110], amp=0.05)) # quiet, low, pure
hot = ears_light.emotion_light(_tone([440, 880], amp=0.3, noise=0.2)) # loud, bright, noisy
assert hot["arousal"] > calm["arousal"]
def test_endpoint_light_path_is_torch_free(monkeypatch):
# Prove /v1/analyze/emotion works end-to-end with NO CLAP/torch, the erable path.
monkeypatch.setattr(config, "EMOTION_ENGINE", "light")
from fastapi.testclient import TestClient
import app as APP
client = TestClient(APP.app)
wav = _tone([330, 660])
with open(wav, "rb") as fh:
data = fh.read()
r = client.post("/v1/analyze/emotion",
files={"file": ("t.wav", data, "audio/wav")},
headers={"Authorization": "Bearer test-secret"})
assert r.status_code == 200
body = r.json()
assert body["emotion"]["engine"] == "light"
assert -1 <= body["emotion"]["valence"] <= 1
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