recognizer-api / api.py
Sathya-UM's picture
fix: decode compressed audio via temp file + ffmpeg
cdaf104 verified
Raw
History Blame Contribute Delete
4.34 kB
"""Headless inference API for the twelveswaras recognizer (the decoupled backend).
The recognizer used to be a Gradio app that rendered its own UI. In the decoupled architecture
the UI lives first-party on the site (Cloudflare Pages) and this is a pure JSON API. No UI, no
branding: the front end renders everything.
POST /identify multipart: audio=<file> ->
{
"no_prediction": false,
"tonic_hz": 147.0,
"heard_seconds": 42.3,
"top3": [{"raaga": "Kalyāṇi", "confidence": 0.91}, ...],
"swara_activation": [12 floats] # tonic-normalized pitch-class mass, folded to the 12
# swara positions (Sa first) — drives the wheel glow
}
GET /health -> {"status": "ok", "raagas": 40}
Runs the EXACT production pipeline (essentia predominant pitch + compiam tonic -> windowed TDMS
-> XGBoost). Serves on port 7860 for a Hugging Face Docker Space, but is a plain FastAPI app
runnable anywhere. Requires the numpy<2 inference stack (see requirements-api.txt).
"""
from __future__ import annotations
import os
import tempfile
import numpy as np
from fastapi import FastAPI, File, Form, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from raaga_id import pitch_extract
from raaga_id.config import MODELS_DIR, TOP_K
from raaga_id.model import RaagaXGB
MODEL_PATH = os.environ.get("MODEL_PATH", str(MODELS_DIR / "raaga_xgb.json"))
SWARAS = 12
app = FastAPI(title="twelveswaras recognizer API", docs_url="/docs", redoc_url=None)
# CORS is permissive here because the public entry point is the Cloudflare Worker (same-origin to
# the browser); direct browser calls are allowed too for local dev.
app.add_middleware(
CORSMiddleware, allow_origins=["*"],
allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"],
)
_model = None
def model() -> RaagaXGB:
global _model
if _model is None:
_model = RaagaXGB.load(MODEL_PATH)
pitch_extract.warmup() # pay the essentia/compiam import cost once, at first call
return _model
def fold_swaras(pcd) -> list[float]:
"""Fold the tonic-normalized pitch-class distribution (PCD_BINS bins over the octave) into the
12 swara positions, Sa first. This is the real data the wheel lights up with."""
if pcd is None:
return [0.0] * SWARAS
a = np.asarray(pcd, dtype=float)
if a.size < SWARAS or a.sum() <= 0:
return [0.0] * SWARAS
per = a.size // SWARAS
out = [float(a[i * per:(i + 1) * per].sum()) for i in range(SWARAS)]
s = sum(out) or 1.0
return [round(v / s, 4) for v in out]
@app.get("/health")
def health():
return {"status": "ok", "raagas": len(model().classes)}
@app.post("/identify")
async def identify(audio: UploadFile = File(...), contribute: str = Form("no")):
import librosa
m = model()
raw = await audio.read()
# Write to a temp file so librosa can fall back to ffmpeg (audioread) for compressed formats
# (webm/opus from the browser, m4a/mp3 from uploads); soundfile-on-BytesIO can't read those.
suffix = os.path.splitext(audio.filename or "")[1] or ".webm"
tmp = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)
try:
tmp.write(raw); tmp.flush(); tmp.close()
y, sr = librosa.load(tmp.name, sr=None, mono=True)
except Exception as e: # noqa: BLE001
return JSONResponse({"error": "could not decode audio", "detail": str(e)[:200]}, status_code=400)
finally:
try:
os.unlink(tmp.name)
except OSError:
pass
windows, tonic, heard, pcd = pitch_extract.audio_to_features(y, sr)
if not windows:
return {
"no_prediction": True,
"tonic_hz": round(float(tonic), 1) if tonic else None,
"heard_seconds": round(float(heard or 0), 1),
"top3": [],
"swara_activation": fold_swaras(pcd),
}
preds = m.aggregate_top_k(np.vstack(windows), k=TOP_K)
return {
"no_prediction": False,
"tonic_hz": round(float(tonic), 1),
"heard_seconds": round(float(heard), 1),
"top3": [{"raaga": p.raaga, "confidence": round(float(p.confidence), 4)} for p in preds],
"swara_activation": fold_swaras(pcd),
}