Commit 9eb26236 by PLN (Algolia)

feat(douanier): modular ears building blocks — /features + /analyze/samples (#38)

Two new CPU-native, torch-free, cached endpoints on the audio sub-API — the
"interesting value points" for hexa beyond emotion, each the same shape/pattern
as /analyze/emotion (upload → content-address → cache → compute).

- POST /features  → the ~35-dim audio feature stack (spectral moments, MFCCs,
  chroma/key, envelope/attack-decay, + rhythm/tempo). scope `features`.
- POST /analyze/samples → per-sample EDA + role (percs|bass|melodic|tops|atmos)
  decided by the MEASURED spectrum (centroid + band energy), never the name; an
  optional ?name= only disambiguates breaks/drums (feedback_mastering_eda). scope `samples`.

Engine: engines/feats.py is SELF-CONTAINED (vendored DSP), like ears_light —
the deployed container holds only armada/api/, not armada/tide-table/, so it can
NOT import sample_features/audio_lens at runtime. It mirrors their algorithms and
fixes the librosa-0.11 tempo bug (feature.tempo, not the removed
feature.rhythm.tempo that silently dropped tempo in the tide-table original).

Wiring: a shared _cached() helper now backs all three analyze routes (DRY);
healthz advertises the engines map; both routes are scope-gated via the gateway
X-Scopes. Validated in the torch-free container: /features 200 (41 features,
tempo 107.7, key=Amaj), /analyze/samples role-by-measurement, 403 scope gate,
X-Douanier-Cache hit on repeat. 40/40 tests (added test_feats.py + endpoint cases).
parent 722daded
...@@ -53,7 +53,12 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path): ...@@ -53,7 +53,12 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path):
- `GET /metrics` — Prometheus text for the erable scraper (unauthenticated) - `GET /metrics` — Prometheus text for the erable scraper (unauthenticated)
- `GET /me` — echo the caller's gateway identity (any authenticated tenant) - `GET /me` — echo the caller's gateway identity (any authenticated tenant)
- `POST /analyze/emotion``multipart` audio → `{valence, arousal, top…}` (scope `emotion`) - `POST /analyze/emotion``multipart` audio → `{valence, arousal, top…}` (scope `emotion`)
- `POST /features` — audio → the ~35-dim feature stack (spectral/MFCC/chroma/key/envelope + rhythm); `?rhythm=` (scope `features`)
- `POST /analyze/samples` — one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|tops|atmos); `?name=` only disambiguates breaks/drums (scope `samples`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`) - `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
All three analyze routes are **torch-free** (librosa) and **content-addressed
cached** (#26): the same clip recomputes once, then serves `X-Douanier-Cache: hit`.
- `GET /docs` — Swagger UI - `GET /docs` — Swagger UI
```bash ```bash
......
...@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware ...@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
import auth import auth
import cache import cache
import config import config
from engines import ears from engines import ears, feats
app = FastAPI( app = FastAPI(
title="Douanier — the `audio` sub-API of the nech.pl platform", title="Douanier — the `audio` sub-API of the nech.pl platform",
...@@ -99,6 +99,8 @@ def healthz(): ...@@ -99,6 +99,8 @@ def healthz():
"status": "ok", "status": "ok",
"engines": { "engines": {
"emotion": {"selected": config.EMOTION_ENGINE, "available": ok, "hint": hint}, "emotion": {"selected": config.EMOTION_ENGINE, "available": ok, "hint": hint},
"features": {"available": ok, "hint": "light feature stack (librosa, no torch)"},
"samples": {"available": ok, "hint": "measured sample EDA + role (librosa, no torch)"},
"separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"}, "separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
}, },
} }
...@@ -142,37 +144,71 @@ def whoami(principal: auth.Principal = Depends(require_scope(None))): ...@@ -142,37 +144,71 @@ def whoami(principal: auth.Principal = Depends(require_scope(None))):
"expires_at": principal.expires_at} "expires_at": principal.expires_at}
# ── #36 walking-skeleton endpoint: synchronous emotion read ───────────────────── # ── shared analyze flow: upload → content-address → cache → compute (#26) ───────
@v1.post("/analyze/emotion") async def _read_upload(file: UploadFile) -> bytes:
async def analyze_emotion(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("emotion"))):
"""Upload a short audio clip → valence/arousal + top emotions (CLAP).
Content-addressed cache (#26): the same audio returns instantly on a repeat
(X-Douanier-Cache: hit) instead of paying the ~CLAP compute again. Still
synchronous on a miss; #28 layers the async job queue under this.
"""
data = await file.read() data = await file.read()
limit = config.MAX_UPLOAD_MB * 1024 * 1024 limit = config.MAX_UPLOAD_MB * 1024 * 1024
if len(data) > limit: if len(data) > limit:
raise HTTPException(413, f"file too large (> {config.MAX_UPLOAD_MB} MB)") raise HTTPException(413, f"file too large (> {config.MAX_UPLOAD_MB} MB)")
suffix = Path(file.filename or "clip").suffix or ".wav" return data
def _cached(kind: str, params: dict, data: bytes, filename: str, compute, response: Response):
"""Content-address the bytes, serve a cache hit instantly (X-Douanier-Cache:
hit), else run `compute(path)` and cache it. Returns (content_id, result).
The same engine call is paid ONCE per (content_id, params) — the margin lever."""
suffix = Path(filename or "clip").suffix or ".wav"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp: with tempfile.NamedTemporaryFile(suffix=suffix, delete=True) as tmp:
tmp.write(data) tmp.write(data)
tmp.flush() tmp.flush()
cid = cache.content_id(tmp.name) cid = cache.content_id(tmp.name)
params = {"engine": config.EMOTION_ENGINE} hit = cache.get(cid, kind, params)
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 cid, hit["result"]
try: try:
read = ears.emotion_read(tmp.name) result = compute(tmp.name)
except Exception as e: except Exception as e:
raise HTTPException(422, f"emotion analysis failed: {e}") raise HTTPException(422, f"{kind} analysis failed: {e}")
cache.put(cid, "emotion", result=read, params=params) cache.put(cid, kind, result=result, params=params)
response.headers["X-Douanier-Cache"] = "miss" response.headers["X-Douanier-Cache"] = "miss"
return {"filename": file.filename, "content_id": cid, "emotion": read} return cid, result
@v1.post("/analyze/emotion")
async def analyze_emotion(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("emotion"))):
"""Upload a short audio clip → valence/arousal + top emotions. Cached (#26):
the same audio returns instantly on a repeat instead of recomputing."""
data = await _read_upload(file)
params = {"engine": config.EMOTION_ENGINE}
cid, result = _cached("emotion", params, data, file.filename, ears.emotion_read, response)
return {"filename": file.filename, "content_id": cid, "emotion": result}
@v1.post("/features")
async def features(response: Response, file: UploadFile = File(...), rhythm: bool = True,
_p: auth.Principal = Depends(require_scope("features"))):
"""Upload a clip → the ~35-dim audio feature stack (spectral moments, MFCCs,
chroma/key, envelope, + rhythm when `rhythm=true`). Torch-free, cached."""
data = await _read_upload(file)
params = {"engine": "light", "rhythm": rhythm}
cid, result = _cached("features", params, data, file.filename,
lambda p: feats.features(p, rhythm=rhythm), response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/analyze/samples")
async def analyze_samples(response: Response, file: UploadFile = File(...), name: str = "",
_p: auth.Principal = Depends(require_scope("samples"))):
"""Upload a one-shot/loop → per-sample EDA + MEASURED role (percs|bass|melodic|
tops|atmos) from the spectrum, never the name. Optional `name` only
disambiguates breaks/drums. Torch-free, cached."""
data = await _read_upload(file)
params = {"engine": "light", "name": name}
cid, result = _cached("samples", params, data, file.filename,
lambda p: feats.sample_profile(p, name=name), response)
return {"filename": file.filename, "content_id": cid, **result}
# ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ── # ── #37 separation seam — no CPU path on erable; env-selected GPU backend later ──
......
"""Light feature + sample-analysis engine tests (#38) — torch-free, real DSP.
Synth clips with known properties so the asserts are about MEASUREMENT, not a
fixture: a low sine reads bass register, a bright noise reads high register, the
feature stack carries the L0 floor + a correct tempo. conftest sets up paths.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/test_feats.py -q
"""
import sys
import tempfile
from pathlib import Path
import numpy as np
import soundfile as sf
from engines import feats
SR = 22050
def _write(y):
p = Path(tempfile.mktemp(suffix=".wav"))
sf.write(str(p), y.astype("float32"), SR)
return str(p)
def _sine(freqs, secs=2.0):
t = np.linspace(0, secs, int(SR * secs), endpoint=False)
y = sum(np.sin(2 * np.pi * f * t) for f in freqs) / len(freqs)
return _write(0.3 * y)
def _noise(secs=2.0, highpass=False):
rng = np.random.default_rng(0)
y = rng.standard_normal(int(SR * secs))
if highpass: # crude high-emphasis → bright, high centroid
y = np.diff(y, prepend=0.0)
return _write(0.2 * y / (np.abs(y).max() + 1e-9))
def test_features_has_L0_floor_and_tempo():
r = feats.features(_sine([220, 277, 330]), rhythm=True)
f = r["features"]
# the robust L0 tier must always be present
for k in feats.FEATURE_TIERS["L0"]:
assert k in f, f"missing L0 feature {k}"
assert r["engine"] == "light" and r["sr"] == SR
# tempo came through the librosa 0.11 path (feature.tempo, not rhythm.tempo)
assert "tempo_bpm" in f and f["tempo_bpm"] > 0
def test_features_detects_major_key():
f = feats.features(_sine([220, 277.18, 329.63]), rhythm=False)["features"]
assert f.get("key") == "A" and f.get("key_mode") == "maj"
def test_sample_role_is_measured_not_named():
# a 60 Hz sine is bass by SPECTRUM even if we (mis)name it "lead"
low = feats.sample_profile(_sine([60]), name="lead_synth")
assert low["role"] == "bass" and low["centroid"] < 180
# a bright noise sits in the high register
hi = feats.sample_profile(_noise(highpass=True), name="")
assert hi["role"] in {"melodic", "atmos"} and hi["centroid"] > low["centroid"]
def test_sample_bands_sum_to_100():
sp = feats.sample_profile(_sine([220, 440]), name="")
total = sum(sp["profile"]["bands"].values())
assert abs(total - 100.0) < 0.5
assert "time_activity" in sp and 0.0 <= sp["time_activity"] <= 1.0
def test_engine_pulls_no_torch():
feats.features(_sine([330]), rhythm=False)
feats.sample_profile(_sine([330]), name="")
assert "torch" not in sys.modules # the light path must never import torch
...@@ -98,6 +98,46 @@ def test_emotion_too_large(monkeypatch): ...@@ -98,6 +98,46 @@ def test_emotion_too_large(monkeypatch):
assert r.status_code == 413 assert r.status_code == 413
def _wav_bytes(freqs=(220, 277, 330), secs=1.5):
import io
import numpy as np
import soundfile as sf
t = np.linspace(0, secs, int(22050 * secs), endpoint=False)
y = (0.3 * sum(np.sin(2 * np.pi * f * t) for f in freqs) / len(freqs)).astype("float32")
buf = io.BytesIO()
sf.write(buf, y, 22050, format="WAV")
return buf.getvalue()
def test_features_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(), "audio/wav")}
r1 = client.post("/features", files=files, headers={"X-Tenant": "hexa", "X-Scopes": "features"})
assert r1.status_code == 200
assert r1.headers["X-Douanier-Cache"] == "miss"
body = r1.json()
assert "features" in body and body["engine"] == "light"
assert "spectral_centroid" in body["features"]
r2 = client.post("/features", files={"file": ("c.wav", _wav_bytes(), "audio/wav")},
headers={"X-Tenant": "hexa", "X-Scopes": "features"})
assert r2.headers["X-Douanier-Cache"] == "hit"
def test_features_scope_enforced():
r = client.post("/features", files={"file": ("c.wav", b"RIFF....", "audio/wav")},
headers={"X-Tenant": "hexa", "X-Scopes": "emotion"})
assert r.status_code == 403
def test_samples_endpoint_measures_role():
files = {"file": ("c.wav", _wav_bytes(freqs=(60,)), "audio/wav")}
r = client.post("/analyze/samples?name=lead", files=files,
headers={"X-Tenant": "hexa", "X-Scopes": "samples"})
assert r.status_code == 200
body = r.json()
# 60 Hz sine → bass by measurement, despite the "lead" name hint
assert body["role"] == "bass" and body["engine"] == "light"
def test_separate_is_503_until_gpu(): def test_separate_is_503_until_gpu():
r = client.post("/separate", headers={"X-Tenant": "hexa", "X-Scopes": "separate"}) r = client.post("/separate", headers={"X-Tenant": "hexa", "X-Scopes": "separate"})
assert r.status_code == 503 assert r.status_code == 503
......
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