Commit 8c2be606 by PLN (Algolia)

feat(douanier): low-hanging endpoints — /onsets, /waveform, /analyze (#40)

Three cheap, torch-free, cached building blocks — the no-model tier, plus a
convenience composite. The image now serves 8 engines (emotion/features/samples/
grade/onsets/waveform/analyze + separate-503).

- POST /onsets   → onset hit times (s) + tempo + onset rate. Rhythmic hits for
  visual sync / slicing. scope `onsets`.
- POST /waveform → render-ready waveform: per-bin [min,max] in [-1,1] + a 0..1
  RMS energy envelope (?bins= ≤4000). For hexa's audio-reactive visuals. scope `waveform`.
- POST /analyze  → emotion + features + sample role in ONE cached call (fewer
  round-trips for hexa); each is the same engine the dedicated routes use. scope `analyze`.

engines/signal.py is self-contained librosa (onsets uses feature.tempo, the
0.11-correct path). Validated in the torch-free container (/onsets tempo 107.7
n=41; /waveform bins honored, peaks in range; /analyze returns all three blocks).
50/50 tests (added test_signal.py: engine + endpoint + cache + scope-gate + torch-free).
parent cd3caa7c
......@@ -56,6 +56,9 @@ Endpoints (internal root paths shown; public = `/audio/v1` + path):
- `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 /grade` — loop/one-shot → the Foundry's mechanical quality grade (0..1 + S/A/B/C/D tier, per-rule sub-scores + flags); `?role=` hint (scope `grade`)
- `POST /onsets` — audio → onset hit times + tempo + onset rate (rhythmic sync / slicing) (scope `onsets`)
- `POST /waveform` — audio → render-ready `[min,max]` peaks + 0..1 RMS envelope; `?bins=` ≤4000 (scope `waveform`)
- `POST /analyze` — one call → emotion + features + sample role (fewer round-trips) (scope `analyze`)
- `POST /separate` — stem separation; **503 `compute_unavailable`** until a GPU runner (scope `separate`)
All three analyze routes are **torch-free** (librosa) and **content-addressed
......
......@@ -18,7 +18,7 @@ from fastapi.middleware.cors import CORSMiddleware
import auth
import cache
import config
from engines import ears, feats
from engines import ears, feats, signal
from engines import grade as grade_eng # VENDORED copy of the Foundry grader (see engines/grade.py)
app = FastAPI(
......@@ -103,6 +103,9 @@ def healthz():
"features": {"available": ok, "hint": "light feature stack (librosa, no torch)"},
"samples": {"available": ok, "hint": "measured sample EDA + role (librosa, no torch)"},
"grade": {"available": True, "hint": "Foundry mechanical loop grader (no torch)"},
"onsets": {"available": ok, "hint": "onset times + tempo (librosa, no torch)"},
"waveform": {"available": ok, "hint": "render-ready peak/RMS envelope (no torch)"},
"analyze": {"available": ok, "hint": "composite emotion+features+sample in one call"},
"separate": {"available": False, "hint": "no CPU path on erable — GPU runner pending"},
},
}
......@@ -213,6 +216,43 @@ async def analyze_samples(response: Response, file: UploadFile = File(...), name
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/onsets")
async def onsets(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("onsets"))):
"""Upload audio → onset hit times (s) + tempo + onset rate. The cheapest tier
(no model): rhythmic hits for visual sync / slicing. Torch-free, cached."""
data = await _read_upload(file)
cid, result = _cached("onsets", {"v": 1}, data, file.filename, signal.onsets, response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/waveform")
async def waveform(response: Response, file: UploadFile = File(...), bins: int = 800,
_p: auth.Principal = Depends(require_scope("waveform"))):
"""Upload audio → render-ready waveform: per-bin [min,max] + a 0..1 RMS energy
envelope (`?bins=`, ≤4000). For audio-reactive visuals. Torch-free, cached."""
data = await _read_upload(file)
cid, result = _cached("waveform", {"bins": bins, "v": 1}, data, file.filename,
lambda p: signal.waveform(p, bins=bins), response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/analyze")
async def analyze_all(response: Response, file: UploadFile = File(...),
_p: auth.Principal = Depends(require_scope("analyze"))):
"""One call → emotion + features + sample role for a clip (fewer round-trips
for hexa). Each is the same engine the dedicated routes use. Cached as a unit."""
data = await _read_upload(file)
params = {"engine": config.EMOTION_ENGINE, "v": 1}
def _compute(p):
return {"emotion": ears.emotion_read(p),
"features": feats.features(p, rhythm=True)["features"],
"sample": feats.sample_profile(p)}
cid, result = _cached("analyze", params, data, file.filename, _compute, response)
return {"filename": file.filename, "content_id": cid, **result}
@v1.post("/grade")
async def grade(response: Response, file: UploadFile = File(...), role: str = "",
_p: auth.Principal = Depends(require_scope("grade"))):
......
"""Light signal building blocks — onsets + waveform peaks. CPU, NO torch.
The cheapest analysis tier: thin librosa wrappers that need no model at all.
Especially useful for hexa (a visuals platform): /waveform gives a render-ready
peak/energy envelope, /onsets gives rhythmic hit times for visual sync + slicing.
Self-contained like ears_light/feats (the container has only armada/api/).
"""
import numpy as np
SR = 22050
MAX_S = 60.0
def _load(audio_path, max_seconds):
import librosa
y, _ = librosa.load(str(audio_path), sr=SR, mono=True, duration=max_seconds)
if y.size < SR // 20:
raise ValueError("clip too short / undecodable (need ≥ 50 ms of audio)")
return y
def onsets(audio_path, max_seconds=MAX_S) -> dict:
"""Onset hit times (s) + tempo + onset rate. Drift-tolerant tempo via the
onset envelope (librosa 0.11 feature.tempo, not the removed rhythm.tempo)."""
import librosa
y = _load(audio_path, max_seconds)
onset_env = librosa.onset.onset_strength(y=y, sr=SR)
times = librosa.onset.onset_detect(onset_envelope=onset_env, sr=SR, units="time")
tempo = float(np.atleast_1d(librosa.feature.tempo(onset_envelope=onset_env, sr=SR))[0])
dur = len(y) / SR
return {
"tempo_bpm": round(tempo, 2),
"onsets": [round(float(t), 4) for t in times],
"n_onsets": int(len(times)),
"onset_rate": round(len(times) / dur, 3) if dur else 0.0,
"duration_s": round(dur, 3),
"sr": SR,
"engine": "light",
}
def waveform(audio_path, bins=800, max_seconds=MAX_S) -> dict:
"""Downsampled waveform for rendering: per-bin [min, max] in [-1,1] (draws the
classic waveform) + a 0..1 RMS energy envelope (audio-reactive intensity)."""
y = _load(audio_path, max_seconds)
bins = max(1, min(int(bins), 4000))
edges = np.linspace(0, len(y), bins + 1).astype(int)
peaks, rms = [], []
for i in range(bins):
seg = y[edges[i]:edges[i + 1]]
if seg.size == 0:
peaks.append([0.0, 0.0]); rms.append(0.0); continue
peaks.append([round(float(seg.min()), 4), round(float(seg.max()), 4)])
rms.append(float(np.sqrt(np.mean(seg ** 2))))
norm = max(rms) or 1.0
return {
"bins": bins,
"peaks": peaks,
"rms": [round(r / norm, 4) for r in rms], # 0..1, peak-normalized
"duration_s": round(len(y) / SR, 3),
"sr": SR,
"engine": "light",
}
"""Signal building blocks + their endpoints (#40) — onsets, waveform, analyze.
Torch-free, real DSP on synth clips. conftest sets paths + the dev fallback.
cd armada/api && ~/.virtualenvs/douanier/bin/python -m pytest tests/test_signal.py -q
"""
import io
import sys
import tempfile
from pathlib import Path
import numpy as np
import soundfile as sf
from fastapi.testclient import TestClient
import app as APP
from engines import ears, signal
client = TestClient(APP.app)
SR = 22050
def _click_track(bpm=120, secs=2.0):
"""A simple pulse train so onset detection has real hits to find."""
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 _wav_bytes(y, sr=SR):
buf = io.BytesIO()
sf.write(buf, y, sr, format="WAV")
return buf.getvalue()
# ── engine ──────────────────────────────────────────────────────────────────
def test_onsets_finds_pulses():
p = Path(tempfile.mktemp(suffix=".wav"))
sf.write(str(p), _click_track(bpm=120), SR)
r = signal.onsets(str(p))
assert r["n_onsets"] >= 3 and r["tempo_bpm"] > 0
assert all(0 <= t <= r["duration_s"] + 0.1 for t in r["onsets"])
assert r["engine"] == "light"
def test_waveform_shape_and_range():
p = Path(tempfile.mktemp(suffix=".wav"))
t = np.linspace(0, 1.5, int(SR * 1.5), endpoint=False)
sf.write(str(p), (0.5 * np.sin(2 * np.pi * 220 * t)).astype("float32"), SR)
r = signal.waveform(str(p), bins=256)
assert r["bins"] == 256 and len(r["peaks"]) == 256 and len(r["rms"]) == 256
assert all(-1.0 <= lo <= hi <= 1.0 for lo, hi in r["peaks"])
assert max(r["rms"]) <= 1.0 and min(r["rms"]) >= 0.0
def test_signal_pulls_no_torch():
p = Path(tempfile.mktemp(suffix=".wav"))
sf.write(str(p), _click_track(), SR)
signal.onsets(str(p)); signal.waveform(str(p), bins=64)
assert "torch" not in sys.modules
# ── endpoints ─────────────────────────────────────────────────────────────────
GW = lambda scope: {"X-Tenant": "hexa", "X-Scopes": scope}
def test_onsets_endpoint_and_cache():
files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
r1 = client.post("/onsets", files=files, headers=GW("onsets"))
assert r1.status_code == 200 and r1.headers["X-Douanier-Cache"] == "miss"
assert "onsets" in r1.json() and r1.json()["n_onsets"] >= 3
r2 = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
headers=GW("onsets"))
assert r2.headers["X-Douanier-Cache"] == "hit"
def test_waveform_endpoint():
files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
r = client.post("/waveform?bins=128", files=files, headers=GW("waveform"))
assert r.status_code == 200
body = r.json()
assert body["bins"] == 128 and len(body["peaks"]) == 128
def test_analyze_composite(monkeypatch):
# stub the (clap) emotion engine; light features/sample run for real
monkeypatch.setattr(ears, "emotion_read",
lambda p: {"valence": 0.1, "arousal": 0.2, "engine": "light"})
files = {"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")}
r = client.post("/analyze", files=files, headers=GW("analyze"))
assert r.status_code == 200
body = r.json()
assert "emotion" in body and "features" in body and "sample" in body
assert "spectral_centroid" in body["features"]
def test_signal_scope_enforced():
r = client.post("/onsets", files={"file": ("c.wav", _wav_bytes(_click_track()), "audio/wav")},
headers=GW("features"))
assert r.status_code == 403
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