Spaces:
Sleeping
Sleeping
Deploy headless recognizer API (Docker)
Browse files- .gitattributes +1 -0
- Dockerfile +18 -0
- README.md +16 -4
- api.py +105 -0
- apps/__init__.py +6 -0
- apps/contribute.py +80 -0
- apps/identify.py +317 -0
- apps/usage_log.py +68 -0
- apps/verify.py +24 -0
- assets/favicon.svg +11 -0
- assets/logo.svg +25 -0
- models/raaga_xgb.calib.json +12 -0
- models/raaga_xgb.classes.json +1 -0
- models/raaga_xgb.json +3 -0
- raaga_guide.json +591 -0
- raaga_id/__init__.py +12 -0
- raaga_id/calibrate.py +98 -0
- raaga_id/config.py +85 -0
- raaga_id/data.py +214 -0
- raaga_id/evaluate.py +52 -0
- raaga_id/features.py +288 -0
- raaga_id/learn.py +159 -0
- raaga_id/model.py +95 -0
- raaga_id/pitch_extract.py +82 -0
- raaga_id/train.py +60 -0
- raaga_profiles.json +562 -0
- raagas.json +68 -0
- requirements-api.txt +11 -0
- schema.py +47 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
models/raaga_xgb.json filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Headless recognizer API as a Hugging Face Docker Space (or any container host).
|
| 2 |
+
# The assembled build (space/assemble.sh output, or the repo) provides raaga_id/, models/, api.py.
|
| 3 |
+
FROM python:3.10-slim
|
| 4 |
+
|
| 5 |
+
# ffmpeg: decode webm/opus/m4a the browser sends. libsndfile: soundfile backend.
|
| 6 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 7 |
+
ffmpeg libsndfile1 build-essential \
|
| 8 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
COPY requirements-api.txt .
|
| 12 |
+
RUN pip install --no-cache-dir -r requirements-api.txt
|
| 13 |
+
|
| 14 |
+
COPY . .
|
| 15 |
+
ENV MODEL_PATH=/app/models/raaga_xgb.json
|
| 16 |
+
# HF Spaces route to $PORT (7860). Warm the model on the first request, not at build.
|
| 17 |
+
EXPOSE 7860
|
| 18 |
+
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,22 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: yellow
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: twelveswaras recognizer API
|
| 3 |
+
emoji: 🎧
|
| 4 |
colorFrom: yellow
|
| 5 |
+
colorTo: red
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# twelveswaras recognizer API (headless)
|
| 12 |
+
|
| 13 |
+
The decoupled inference backend. No UI: it exposes JSON only. The user-facing wheel lives
|
| 14 |
+
first-party on twelveswaras.com (Cloudflare Pages) and calls this through a Cloudflare Worker.
|
| 15 |
+
|
| 16 |
+
- `GET /health` -> `{ "status": "ok", "raagas": 40 }`
|
| 17 |
+
- `POST /identify` (multipart `audio`) -> `{ top3, tonic_hz, heard_seconds, no_prediction, swara_activation[12] }`
|
| 18 |
+
|
| 19 |
+
This Space is a **Docker** SDK space (not Gradio). It builds `space/Dockerfile`, which runs
|
| 20 |
+
`uvicorn api:app` on port 7860. To deploy, assemble the build (raaga_id/, models/, api.py,
|
| 21 |
+
Dockerfile, requirements-api.txt) and push to a Space whose README has the front-matter above.
|
| 22 |
+
See `cloudflare/DEPLOY.md`.
|
api.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Headless inference API for the twelveswaras recognizer (the decoupled backend).
|
| 2 |
+
|
| 3 |
+
The recognizer used to be a Gradio app that rendered its own UI. In the decoupled architecture
|
| 4 |
+
the UI lives first-party on the site (Cloudflare Pages) and this is a pure JSON API. No UI, no
|
| 5 |
+
branding: the front end renders everything.
|
| 6 |
+
|
| 7 |
+
POST /identify multipart: audio=<file> ->
|
| 8 |
+
{
|
| 9 |
+
"no_prediction": false,
|
| 10 |
+
"tonic_hz": 147.0,
|
| 11 |
+
"heard_seconds": 42.3,
|
| 12 |
+
"top3": [{"raaga": "Kalyāṇi", "confidence": 0.91}, ...],
|
| 13 |
+
"swara_activation": [12 floats] # tonic-normalized pitch-class mass, folded to the 12
|
| 14 |
+
# swara positions (Sa first) — drives the wheel glow
|
| 15 |
+
}
|
| 16 |
+
GET /health -> {"status": "ok", "raagas": 40}
|
| 17 |
+
|
| 18 |
+
Runs the EXACT production pipeline (essentia predominant pitch + compiam tonic -> windowed TDMS
|
| 19 |
+
-> XGBoost). Serves on port 7860 for a Hugging Face Docker Space, but is a plain FastAPI app
|
| 20 |
+
runnable anywhere. Requires the numpy<2 inference stack (see requirements-api.txt).
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import io
|
| 25 |
+
import os
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
from fastapi import FastAPI, File, Form, UploadFile
|
| 29 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
+
from fastapi.responses import JSONResponse
|
| 31 |
+
|
| 32 |
+
from raaga_id import pitch_extract
|
| 33 |
+
from raaga_id.config import MODELS_DIR, TOP_K
|
| 34 |
+
from raaga_id.model import RaagaXGB
|
| 35 |
+
|
| 36 |
+
MODEL_PATH = os.environ.get("MODEL_PATH", str(MODELS_DIR / "raaga_xgb.json"))
|
| 37 |
+
SWARAS = 12
|
| 38 |
+
|
| 39 |
+
app = FastAPI(title="twelveswaras recognizer API", docs_url="/docs", redoc_url=None)
|
| 40 |
+
# CORS is permissive here because the public entry point is the Cloudflare Worker (same-origin to
|
| 41 |
+
# the browser); direct browser calls are allowed too for local dev.
|
| 42 |
+
app.add_middleware(
|
| 43 |
+
CORSMiddleware, allow_origins=["*"],
|
| 44 |
+
allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"],
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
_model = None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def model() -> RaagaXGB:
|
| 51 |
+
global _model
|
| 52 |
+
if _model is None:
|
| 53 |
+
_model = RaagaXGB.load(MODEL_PATH)
|
| 54 |
+
pitch_extract.warmup() # pay the essentia/compiam import cost once, at first call
|
| 55 |
+
return _model
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def fold_swaras(pcd) -> list[float]:
|
| 59 |
+
"""Fold the tonic-normalized pitch-class distribution (PCD_BINS bins over the octave) into the
|
| 60 |
+
12 swara positions, Sa first. This is the real data the wheel lights up with."""
|
| 61 |
+
if pcd is None:
|
| 62 |
+
return [0.0] * SWARAS
|
| 63 |
+
a = np.asarray(pcd, dtype=float)
|
| 64 |
+
if a.size < SWARAS or a.sum() <= 0:
|
| 65 |
+
return [0.0] * SWARAS
|
| 66 |
+
per = a.size // SWARAS
|
| 67 |
+
out = [float(a[i * per:(i + 1) * per].sum()) for i in range(SWARAS)]
|
| 68 |
+
s = sum(out) or 1.0
|
| 69 |
+
return [round(v / s, 4) for v in out]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.get("/health")
|
| 73 |
+
def health():
|
| 74 |
+
return {"status": "ok", "raagas": len(model().classes)}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@app.post("/identify")
|
| 78 |
+
async def identify(audio: UploadFile = File(...), contribute: str = Form("no")):
|
| 79 |
+
import librosa
|
| 80 |
+
|
| 81 |
+
m = model()
|
| 82 |
+
raw = await audio.read()
|
| 83 |
+
try:
|
| 84 |
+
y, sr = librosa.load(io.BytesIO(raw), sr=None, mono=True)
|
| 85 |
+
except Exception as e: # noqa: BLE001
|
| 86 |
+
return JSONResponse({"error": "could not decode audio", "detail": str(e)[:200]}, status_code=400)
|
| 87 |
+
|
| 88 |
+
windows, tonic, heard, pcd = pitch_extract.audio_to_features(y, sr)
|
| 89 |
+
if not windows:
|
| 90 |
+
return {
|
| 91 |
+
"no_prediction": True,
|
| 92 |
+
"tonic_hz": round(float(tonic), 1) if tonic else None,
|
| 93 |
+
"heard_seconds": round(float(heard or 0), 1),
|
| 94 |
+
"top3": [],
|
| 95 |
+
"swara_activation": fold_swaras(pcd),
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
preds = m.aggregate_top_k(np.vstack(windows), k=TOP_K)
|
| 99 |
+
return {
|
| 100 |
+
"no_prediction": False,
|
| 101 |
+
"tonic_hz": round(float(tonic), 1),
|
| 102 |
+
"heard_seconds": round(float(heard), 1),
|
| 103 |
+
"top3": [{"raaga": p.raaga, "confidence": round(float(p.confidence), 4)} for p in preds],
|
| 104 |
+
"swara_activation": fold_swaras(pcd),
|
| 105 |
+
}
|
apps/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Serving layer — Gradio apps for the HF Space (PRD §6.6, §17).
|
| 2 |
+
|
| 3 |
+
identify.py -> v0: mic/upload -> top-3 raagas.
|
| 4 |
+
contribute.py -> v1: predict -> confirm/correct -> writeback to the commons.
|
| 5 |
+
verify.py -> v1: community verification queue.
|
| 6 |
+
"""
|
apps/contribute.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v1 contribution loop — predict -> confirm/correct -> writeback (PRD §18.3, §13).
|
| 2 |
+
|
| 3 |
+
Anonymous by default (D12): no login, dedup via audio_sha256, optional handle for
|
| 4 |
+
attribution only. Rights gate (§12): nothing is saved unless the contributor
|
| 5 |
+
affirms it's their own performance / they have the right to share it.
|
| 6 |
+
|
| 7 |
+
Writes {id}.flac + {id}.json into incoming/ of the dataset repo; the consolidate
|
| 8 |
+
cron promotes verified clips into shards (pipeline/consolidate.py).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import datetime as dt
|
| 13 |
+
import hashlib
|
| 14 |
+
import io
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
import uuid
|
| 18 |
+
|
| 19 |
+
# Neutral org (D19). The Space holds an HF write token scoped to this dataset repo.
|
| 20 |
+
DATASET_REPO = "twelveswaras/twelveswaras-commons"
|
| 21 |
+
CONSENT_VERSION = "2026-07-01-v1"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def utcnow() -> str:
|
| 25 |
+
return dt.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def pseudonym(handle: str) -> str:
|
| 29 |
+
# Optional, never PII (D12). Random per-clip id if no handle given.
|
| 30 |
+
return "u_" + hashlib.sha256(handle.encode()).hexdigest()[:8]
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def submit(audio, chosen_raaga, is_own, license_, handle, instrument, form, ctx) -> str:
|
| 34 |
+
if not is_own:
|
| 35 |
+
return "❌ Not saved — you must confirm this is your own performance / you may share it."
|
| 36 |
+
|
| 37 |
+
import soundfile as sf
|
| 38 |
+
from huggingface_hub import CommitOperationAdd, HfApi
|
| 39 |
+
|
| 40 |
+
api = HfApi(token=os.environ["HF_WRITE_TOKEN"]) # Space secret, scoped to the dataset repo
|
| 41 |
+
sr, wav = audio
|
| 42 |
+
buf = io.BytesIO()
|
| 43 |
+
sf.write(buf, wav, sr, format="FLAC")
|
| 44 |
+
raw = buf.getvalue()
|
| 45 |
+
rid = uuid.uuid4().hex[:8]
|
| 46 |
+
meta = {
|
| 47 |
+
"id": rid,
|
| 48 |
+
"audio_file": f"{rid}.flac",
|
| 49 |
+
"audio_sha256": hashlib.sha256(raw).hexdigest(),
|
| 50 |
+
"raaga": chosen_raaga,
|
| 51 |
+
"tradition": "carnatic",
|
| 52 |
+
"contributor_id": pseudonym(handle or rid),
|
| 53 |
+
"is_own_performance": True,
|
| 54 |
+
"license": license_,
|
| 55 |
+
"consent_version": CONSENT_VERSION,
|
| 56 |
+
"created_at": utcnow(),
|
| 57 |
+
"label_source": "model_confirmed" if chosen_raaga == ctx.get("pred") else "contributor_declared",
|
| 58 |
+
"model_prediction": ctx.get("pred"),
|
| 59 |
+
"model_confidence": float(ctx.get("conf", 0.0)),
|
| 60 |
+
"verification_status": "unverified",
|
| 61 |
+
"split": "pending",
|
| 62 |
+
"votes_agree": 0,
|
| 63 |
+
"votes_disagree": 0,
|
| 64 |
+
"instrument": instrument,
|
| 65 |
+
"form": form,
|
| 66 |
+
}
|
| 67 |
+
api.create_commit(
|
| 68 |
+
repo_id=DATASET_REPO,
|
| 69 |
+
repo_type="dataset",
|
| 70 |
+
commit_message=f"contrib {rid} ({chosen_raaga})",
|
| 71 |
+
operations=[
|
| 72 |
+
CommitOperationAdd(f"incoming/{rid}.flac", raw),
|
| 73 |
+
CommitOperationAdd(f"incoming/{rid}.json", json.dumps(meta, ensure_ascii=False).encode()),
|
| 74 |
+
],
|
| 75 |
+
)
|
| 76 |
+
return f"✅ Saved as {chosen_raaga} — thank you! It enters the verification queue."
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# TODO(v1): assemble the Gradio Blocks UI (predict button + confirm/correct form).
|
| 80 |
+
# The identify half already lives in apps/identify.py; this file owns the writeback.
|
apps/identify.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v0 "Shazam for raagas" — mic/upload -> top-3 (PRD build-order step 7).
|
| 2 |
+
|
| 3 |
+
python -m apps.identify # launches the Gradio app (needs the inference env)
|
| 4 |
+
|
| 5 |
+
Runs the PCD path (essentia pitch + tonic -> pooled model), shows top-3 as confidence
|
| 6 |
+
bars + the estimated Sa + recognition time. Styled to the shared urbanmorph design
|
| 7 |
+
system (dark #0a0a0a canvas, system-ui, hairline borders) with twelveswaras' amber hue.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from raaga_id import pitch_extract
|
| 17 |
+
from raaga_id.config import MODELS_DIR, TOP_K
|
| 18 |
+
from raaga_id.model import RaagaXGB
|
| 19 |
+
|
| 20 |
+
from apps import usage_log
|
| 21 |
+
|
| 22 |
+
ASSETS = Path(__file__).resolve().parent.parent / "assets"
|
| 23 |
+
MODEL_PATH = MODELS_DIR / "raaga_xgb.json"
|
| 24 |
+
|
| 25 |
+
# Inline logo tile (12-bar pitch-class histogram = the twelve swaras), amber gradient.
|
| 26 |
+
_TILE = """
|
| 27 |
+
<svg width="46" height="46" viewBox="0 0 256 256" style="flex:0 0 auto">
|
| 28 |
+
<defs><linearGradient id="tsl" x1="0" y1="0" x2="0" y2="1">
|
| 29 |
+
<stop offset="0" stop-color="#f59e0b"/><stop offset="1" stop-color="#b45309"/></linearGradient></defs>
|
| 30 |
+
<rect width="256" height="256" rx="56" fill="url(#tsl)"/>
|
| 31 |
+
<g fill="#fff">
|
| 32 |
+
<rect x="36" y="140" width="10" height="60" rx="5"/><rect x="52" y="105" width="10" height="95" rx="5"/>
|
| 33 |
+
<rect x="68" y="145" width="10" height="55" rx="5"/><rect x="84" y="80" width="10" height="120" rx="5"/>
|
| 34 |
+
<rect x="100" y="120" width="10" height="80" rx="5"/><rect x="116" y="95" width="10" height="105" rx="5"/>
|
| 35 |
+
<rect x="132" y="64" width="10" height="136" rx="5"/><rect x="148" y="130" width="10" height="70" rx="5"/>
|
| 36 |
+
<rect x="164" y="90" width="10" height="110" rx="5"/><rect x="180" y="115" width="10" height="85" rx="5"/>
|
| 37 |
+
<rect x="196" y="100" width="10" height="100" rx="5"/><rect x="212" y="135" width="10" height="65" rx="5"/>
|
| 38 |
+
</g></svg>
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
TITLE_HTML = f"""
|
| 42 |
+
<div id="ts-title" style="display:flex; align-items:center; justify-content:center; gap:.65rem; margin:.4rem 0 .2rem">
|
| 43 |
+
{_TILE}
|
| 44 |
+
<div style="text-align:left; line-height:1.05">
|
| 45 |
+
<div style="font-size:clamp(1.4rem,6.5vw,2.1rem); font-weight:800; letter-spacing:-1px"><span style="color:#ededed">twelve</span><span style="color:#f59e0b">swaras</span></div>
|
| 46 |
+
<div style="font-size:clamp(.72rem,3.2vw,.92rem); color:#9ca3af; letter-spacing:.2px">identify the raaga</div>
|
| 47 |
+
</div>
|
| 48 |
+
</div>
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
FOOTER_HTML = """
|
| 52 |
+
<div id="ts-footer">a non-commercial, open-source public good · Carnatic first · CC-BY data commons</div>
|
| 53 |
+
"""
|
| 54 |
+
|
| 55 |
+
CSS = """
|
| 56 |
+
.gradio-container { max-width: 640px !important; margin: 0 auto !important; }
|
| 57 |
+
footer { display: none !important; }
|
| 58 |
+
#ts-footer { text-align:center; color:#9ca3af; opacity:.7; font-size:.78rem; margin:1rem 0 .3rem; }
|
| 59 |
+
/* privacy reassurance shown right above the recorder (visible in the embed too) */
|
| 60 |
+
#ts-privacy { text-align:center; color:#9ca3af; font-size:.82rem; margin:.1rem 0 .3rem; }
|
| 61 |
+
#ts-privacy p { margin:0; }
|
| 62 |
+
/* confidence bars in the brand amber */
|
| 63 |
+
.gradio-container .label span.text + div, .gradio-container .fill { background: #f59e0b !important; }
|
| 64 |
+
/* audio player: keep the seek bar from covering the 0:00 / total time read-outs */
|
| 65 |
+
.gradio-container .timestamps { position: relative; z-index: 3; margin-top: 4px; }
|
| 66 |
+
.gradio-container .timestamps time { background: #0a0a0a; padding: 0 3px; border-radius: 3px; }
|
| 67 |
+
/* embed mode (loaded in the twelveswaras.com iframe via ?embed=1): hide the app's own logo,
|
| 68 |
+
footer, and the drone tip (the page already carries all three) so it fits without scrolling */
|
| 69 |
+
body.embed #ts-title, body.embed #ts-footer, body.embed #ts-drone { display: none !important; }
|
| 70 |
+
body.embed .gap, body.embed .contain { gap: 10px !important; }
|
| 71 |
+
body.embed .gradio-container { padding-top: 2px !important; padding-bottom: 0 !important;
|
| 72 |
+
overflow: hidden !important; max-width: 100% !important; } /* fill page width; no bottom slack
|
| 73 |
+
so the auto-resized frame ends right at the content (640px cap is for standalone) */
|
| 74 |
+
/* breathing room around the status line so Gradio's progress bar doesn't crowd the "Listening…" text */
|
| 75 |
+
#ts-status { margin-top: 10px !important; }
|
| 76 |
+
#ts-status p { padding-top: 6px !important; }
|
| 77 |
+
/* frame is sized to content -> the iframe itself never scrolls; the page does. Kills Gradio's
|
| 78 |
+
always-on scrollbar track. (overflow only — no height changes, which would blank the app.) */
|
| 79 |
+
html:has(body.embed), body.embed { overflow: hidden !important; }
|
| 80 |
+
"""
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _theme():
|
| 84 |
+
import gradio as gr
|
| 85 |
+
|
| 86 |
+
return gr.themes.Base(
|
| 87 |
+
primary_hue=gr.themes.colors.amber,
|
| 88 |
+
secondary_hue=gr.themes.colors.amber,
|
| 89 |
+
neutral_hue=gr.themes.colors.neutral,
|
| 90 |
+
font=["system-ui", "ui-sans-serif", "-apple-system", "Segoe UI", "sans-serif"],
|
| 91 |
+
font_mono=["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
|
| 92 |
+
).set(
|
| 93 |
+
body_background_fill="#0b0a08", # match the twelveswaras.com page ground (no seam)
|
| 94 |
+
body_text_color="#ededed",
|
| 95 |
+
body_text_color_subdued="#9ca3af",
|
| 96 |
+
background_fill_primary="#15151a",
|
| 97 |
+
background_fill_secondary="#1a1a1f",
|
| 98 |
+
block_background_fill="#15151a",
|
| 99 |
+
block_border_color="#262626",
|
| 100 |
+
block_border_width="1px",
|
| 101 |
+
block_radius="12px",
|
| 102 |
+
block_label_background_fill="#1a1a1f",
|
| 103 |
+
block_label_text_color="#fbbf24",
|
| 104 |
+
border_color_primary="#262626",
|
| 105 |
+
input_background_fill="#1a1a1f",
|
| 106 |
+
button_primary_background_fill="#d97706",
|
| 107 |
+
button_primary_background_fill_hover="#b45309",
|
| 108 |
+
button_primary_text_color="#ffffff",
|
| 109 |
+
button_primary_border_color="#d97706",
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _load_model() -> RaagaXGB:
|
| 114 |
+
if not MODEL_PATH.exists():
|
| 115 |
+
raise SystemExit(f"no model at {MODEL_PATH} — run `python -m raaga_id.train` first.")
|
| 116 |
+
return RaagaXGB.load(MODEL_PATH)
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _learn_plot(raaga, user_profile):
|
| 120 |
+
"""A dark/amber bar chart of which of the seven swaras the raaga rests on vs the user's
|
| 121 |
+
clip — the 'how to hear this raaga' visual, in the notes a beginner knows."""
|
| 122 |
+
import matplotlib
|
| 123 |
+
matplotlib.use("Agg")
|
| 124 |
+
import matplotlib.pyplot as plt
|
| 125 |
+
|
| 126 |
+
from raaga_id import learn
|
| 127 |
+
from raaga_id.features import to_swaras7
|
| 128 |
+
|
| 129 |
+
names, user7 = to_swaras7(user_profile)
|
| 130 |
+
x = np.arange(len(names))
|
| 131 |
+
fig, ax = plt.subplots(figsize=(6, 2.4))
|
| 132 |
+
fig.patch.set_facecolor("#0a0a0a")
|
| 133 |
+
ax.set_facecolor("#0a0a0a")
|
| 134 |
+
ref = learn.reference_profile(raaga)
|
| 135 |
+
if ref is not None:
|
| 136 |
+
ax.bar(x - 0.2, to_swaras7(ref)[1], width=0.4, color="#f59e0b", label=raaga)
|
| 137 |
+
ax.bar(x + 0.2, user7, width=0.4, color="#6b7280", label="your clip")
|
| 138 |
+
ax.set_xticks(x)
|
| 139 |
+
ax.set_xticklabels(names, color="#ededed", fontsize=9)
|
| 140 |
+
ax.tick_params(axis="x", length=0)
|
| 141 |
+
ax.set_yticks([])
|
| 142 |
+
for spine in ax.spines.values():
|
| 143 |
+
spine.set_visible(False)
|
| 144 |
+
ax.legend(facecolor="#15151a", edgecolor="#262626", labelcolor="#ededed", fontsize=8, loc="upper right")
|
| 145 |
+
fig.tight_layout()
|
| 146 |
+
return fig
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _mmss(seconds: float) -> str:
|
| 150 |
+
"""Whole-second duration as m:ss (e.g. 90 -> '1:30'), for the 'heard 0:00-…' label."""
|
| 151 |
+
s = int(round(seconds))
|
| 152 |
+
return f"{s // 60}:{s % 60:02d}"
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def identify(audio, model: RaagaXGB):
|
| 156 |
+
"""audio = (sample_rate, np.ndarray) from Gradio. A GENERATOR: it yields a "listening"
|
| 157 |
+
state first (so the app is visibly working before it answers — D24), then the final
|
| 158 |
+
(labels, info, plot, learn_md). The info line names the segment it actually heard."""
|
| 159 |
+
if audio is None:
|
| 160 |
+
yield {}, "Upload or record ~10 s+ of melody. A clear line with a drone works best.", None, ""
|
| 161 |
+
return
|
| 162 |
+
# Show that we're listening BEFORE the ~3.5 s of pitch+tonic extraction, and clear any
|
| 163 |
+
# previous result, so the answer never appears to precede the analysis.
|
| 164 |
+
yield {}, "🎧 **Listening…** finding the tonic (Sa) and tracing the swaras.", None, ""
|
| 165 |
+
|
| 166 |
+
sr, wav = audio
|
| 167 |
+
t0 = time.perf_counter()
|
| 168 |
+
windows, tonic, heard, display_pcd = pitch_extract.audio_to_features(wav, sr)
|
| 169 |
+
if not windows:
|
| 170 |
+
usage_log.record(no_prediction=True, tonic_hz=tonic, heard_seconds=heard)
|
| 171 |
+
yield {}, "🤔 Couldn't find a clear melody + tonic. Try a longer, cleaner clip with a drone.", None, ""
|
| 172 |
+
return
|
| 173 |
+
X = np.vstack(windows)
|
| 174 |
+
preds = model.aggregate_top_k(X, k=TOP_K)
|
| 175 |
+
elapsed = time.perf_counter() - t0
|
| 176 |
+
print(f"[identify] {preds[0].raaga} ({preds[0].confidence:.0%}) · Sa≈{tonic:.0f}Hz · "
|
| 177 |
+
f"heard {_mmss(heard)} · {elapsed:.1f}s", flush=True)
|
| 178 |
+
usage_log.record(top1=preds[0].raaga, confidence=preds[0].confidence,
|
| 179 |
+
top3=[{"raaga": p.raaga, "conf": round(float(p.confidence), 3)} for p in preds],
|
| 180 |
+
tonic_hz=tonic, heard_seconds=heard, elapsed_s=elapsed)
|
| 181 |
+
|
| 182 |
+
from raaga_id.calibrate import confidence_state
|
| 183 |
+
labels = {p.raaga: float(p.confidence) for p in preds}
|
| 184 |
+
state, note = confidence_state(preds) # calibrated top-2 -> "confident" / "close" / "unsure"
|
| 185 |
+
info = (f"**Sa ≈ {tonic:.0f} Hz** · heard **0:00–{_mmss(heard)}** · "
|
| 186 |
+
f"recognized in **{elapsed:.1f} s** · {note}")
|
| 187 |
+
|
| 188 |
+
from raaga_id import learn
|
| 189 |
+
from raaga_id.features import pcd_to_swaras
|
| 190 |
+
top = preds[0].raaga
|
| 191 |
+
user_profile = pcd_to_swaras(display_pcd) # human-readable swaras from the PCD, not the TDMS surface
|
| 192 |
+
learn_md = learn.summary_md(top, user_profile)
|
| 193 |
+
# On a close call, lead the learner panel with how to tell the top two apart (D29 Explorer).
|
| 194 |
+
if state == "close":
|
| 195 |
+
cmp = learn.comparison_md(preds[0].raaga, preds[1].raaga)
|
| 196 |
+
if cmp:
|
| 197 |
+
learn_md = cmp + "\n\n---\n\n" + learn_md
|
| 198 |
+
yield labels, info, _learn_plot(top, user_profile), learn_md
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
# When the recognizer is loaded inside the twelveswaras.com page (i.e. in an iframe), hide its
|
| 202 |
+
# own logo/footer/drone-tip so it reads as part of the page. Injected in <head> so it always
|
| 203 |
+
# runs; detects embedding by iframe (window.self !== window.top) — no query-param dependency —
|
| 204 |
+
# and hides elements DIRECTLY by id, re-applying on a few timers because Gradio renders its
|
| 205 |
+
# components asynchronously after first paint. Standalone (hf.space direct) keeps full branding.
|
| 206 |
+
EMBED_HEAD = """
|
| 207 |
+
<script>
|
| 208 |
+
(function () {
|
| 209 |
+
function embedded() { try { return window.self !== window.top; } catch (e) { return true; } }
|
| 210 |
+
function hideChrome() {
|
| 211 |
+
if (!embedded()) return;
|
| 212 |
+
document.body.classList.add('embed');
|
| 213 |
+
// The frame is sized to content, so the recognizer never needs to scroll itself — the PAGE
|
| 214 |
+
// scrolls. Hide the iframe's own overflow so Gradio's always-on scrollbar track disappears.
|
| 215 |
+
// (Only overflow — no height/min-height changes, which would collapse the layout.)
|
| 216 |
+
document.documentElement.style.overflow = 'hidden';
|
| 217 |
+
document.body.style.overflow = 'hidden';
|
| 218 |
+
['ts-title', 'ts-drone', 'ts-footer'].forEach(function (id) {
|
| 219 |
+
var el = document.getElementById(id);
|
| 220 |
+
if (el) { el.style.display = 'none'; }
|
| 221 |
+
});
|
| 222 |
+
}
|
| 223 |
+
// Tell the parent page our content height so it can grow the iframe to fit — the PAGE scrolls,
|
| 224 |
+
// the frame never gets its own scrollbar. Fires on load, timers, window resize, and (via
|
| 225 |
+
// ResizeObserver) whenever the content changes — a result appears, the accordion opens, etc.
|
| 226 |
+
var lastH = 0;
|
| 227 |
+
function reportHeight() {
|
| 228 |
+
if (!embedded()) return;
|
| 229 |
+
// Gradio stretches every CONTAINER to fill the viewport (= the frame height), so measuring any
|
| 230 |
+
// of them loops the auto-resize to infinity ("grows like a worm"). The #ts-end sentinel is a
|
| 231 |
+
// plain marker that flows right after the last component, so its bottom is the TRUE content
|
| 232 |
+
// height and can't stretch. absolute = rect.bottom + scrollY. +10px breathing room.
|
| 233 |
+
var end = document.getElementById('ts-end');
|
| 234 |
+
if (!end) return;
|
| 235 |
+
var h = Math.ceil(end.getBoundingClientRect().bottom + window.scrollY) + 10;
|
| 236 |
+
if (h > 0 && h !== lastH) {
|
| 237 |
+
lastH = h;
|
| 238 |
+
try { window.parent.postMessage({ twelveswaras_height: h }, '*'); } catch (er) {}
|
| 239 |
+
}
|
| 240 |
+
}
|
| 241 |
+
function hideDeadMic() {
|
| 242 |
+
// Gradio's audio device <select> shows a misleading "No microphone found" before mic permission
|
| 243 |
+
// is granted, even though recording works fine on the default device. Hide that control while it
|
| 244 |
+
// shows the dead label, and restore ONLY the ones we hid once a real device name appears.
|
| 245 |
+
var L = 'No microphone found';
|
| 246 |
+
document.querySelectorAll('select, button').forEach(function (el) {
|
| 247 |
+
var t = (el.textContent || '').replace(/\\s+/g, ' ').trim();
|
| 248 |
+
if (t === L) { el.style.display = 'none'; el.dataset.tsDeadmic = '1'; }
|
| 249 |
+
else if (el.dataset.tsDeadmic === '1') { el.style.display = ''; delete el.dataset.tsDeadmic; }
|
| 250 |
+
});
|
| 251 |
+
}
|
| 252 |
+
function tick() { hideChrome(); hideDeadMic(); reportHeight(); }
|
| 253 |
+
if (document.readyState !== 'loading') tick();
|
| 254 |
+
document.addEventListener('DOMContentLoaded', tick);
|
| 255 |
+
[150, 400, 900, 1800].forEach(function (t) { setTimeout(tick, t); });
|
| 256 |
+
window.addEventListener('resize', reportHeight);
|
| 257 |
+
// Gradio's body is pinned to 100vh, so opening the accordion / getting a result overflows it
|
| 258 |
+
// WITHOUT changing its size — ResizeObserver never fires. So poll the sentinel (deduped, cheap).
|
| 259 |
+
setInterval(tick, 300);
|
| 260 |
+
})();
|
| 261 |
+
</script>
|
| 262 |
+
"""
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def build_ui():
|
| 266 |
+
import gradio as gr
|
| 267 |
+
|
| 268 |
+
model = _load_model()
|
| 269 |
+
pitch_extract.warmup() # pay the essentia/compiam import cost once, up front
|
| 270 |
+
|
| 271 |
+
with gr.Blocks(title="twelveswaras", theme=_theme(), css=CSS, head=EMBED_HEAD) as demo:
|
| 272 |
+
gr.HTML(TITLE_HTML)
|
| 273 |
+
gr.Markdown("🔒 Your recording is **never stored**. It is analyzed to find the raaga, then discarded.",
|
| 274 |
+
elem_id="ts-privacy")
|
| 275 |
+
# buttons=["download"] drops Gradio's built-in "share": it re-uploads the raw clip to HF's
|
| 276 |
+
# MIME-restricted uploader (rejects m4a/aac/flac/…) and shares the *input*, not the result
|
| 277 |
+
# — confusing + flaky. A real "share this raga" is an Explorer feature (D29). Keep download.
|
| 278 |
+
audio = gr.Audio(sources=["microphone", "upload"], type="numpy", autoplay=True,
|
| 279 |
+
label="Upload or record ~15–30 s", buttons=["download"])
|
| 280 |
+
gr.Markdown("🎚️ **For best accuracy, include a tanpura / shruti-box drone.** A live "
|
| 281 |
+
"concert always has one — the tonic (Sa) is found from it, so solo voice "
|
| 282 |
+
"without a drone is unreliable.", elem_id="ts-drone")
|
| 283 |
+
result = gr.Label(num_top_classes=TOP_K, label="Raaga")
|
| 284 |
+
info = gr.Markdown("_Recognition runs automatically when you upload or finish recording._",
|
| 285 |
+
elem_id="ts-status")
|
| 286 |
+
with gr.Accordion("🎓 How to hear this raaga", open=False):
|
| 287 |
+
learn_plot = gr.Plot(label="Typical shape from recordings (gold) vs your clip (grey)")
|
| 288 |
+
learn_md = gr.Markdown()
|
| 289 |
+
gr.HTML(FOOTER_HTML)
|
| 290 |
+
# Sentinel at the very end of the content. Gradio stretches every CONTAINER to fill the
|
| 291 |
+
# viewport (so measuring any of them loops the auto-resize), but this plain marker just
|
| 292 |
+
# flows after the last component — its position IS the true content height.
|
| 293 |
+
gr.HTML('<div id="ts-end" style="height:1px"></div>')
|
| 294 |
+
|
| 295 |
+
outs = [result, info, learn_plot, learn_md]
|
| 296 |
+
|
| 297 |
+
def on_audio(a): # generator fn so Gradio streams "listening…" then the result
|
| 298 |
+
yield from identify(a, model)
|
| 299 |
+
|
| 300 |
+
# No button: auto-identify when a file is uploaded or a recording stops.
|
| 301 |
+
# show_progress="hidden": the generator already yields a "🎧 Listening…" status, so Gradio's
|
| 302 |
+
# per-output progress spinners are redundant AND duplicate on mobile — each of the 4 outputs
|
| 303 |
+
# renders its own "N.Ns" eta, and on a narrow layout one floats over the caption text.
|
| 304 |
+
audio.upload(on_audio, audio, outs, show_progress="hidden")
|
| 305 |
+
audio.stop_recording(on_audio, audio, outs, show_progress="hidden")
|
| 306 |
+
|
| 307 |
+
# Clearing the audio (Gradio's ✕) must also reset the result/Sa/panels below — otherwise
|
| 308 |
+
# the previous clip's raaga lingers under an empty input.
|
| 309 |
+
def clear_panels():
|
| 310 |
+
return {}, "_Recognition runs automatically when you upload or finish recording._", None, ""
|
| 311 |
+
audio.clear(clear_panels, None, outs)
|
| 312 |
+
return demo
|
| 313 |
+
|
| 314 |
+
|
| 315 |
+
if __name__ == "__main__":
|
| 316 |
+
favicon = ASSETS / "favicon.svg"
|
| 317 |
+
build_ui().launch(favicon_path=str(favicon) if favicon.exists() else None)
|
apps/usage_log.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Aggregate, privacy-preserving usage logging.
|
| 2 |
+
|
| 3 |
+
Logs the RESULT of each identification (predicted raaga + confidence + tonic + whether a
|
| 4 |
+
prediction was made) to a private Hugging Face Dataset. It NEVER logs the audio, an IP, or any
|
| 5 |
+
user identifier — just anonymous per-identification result metadata, so we can see how much the
|
| 6 |
+
app is used and what it's recognising in the wild (the passive real-world signal).
|
| 7 |
+
|
| 8 |
+
Robustness:
|
| 9 |
+
- Disabled unless HF_TOKEN is set (a write token, as a Space secret). Without it the app runs
|
| 10 |
+
exactly as before, just without logging.
|
| 11 |
+
- Every call is wrapped so a logging failure can NEVER break recognition.
|
| 12 |
+
- Uses huggingface_hub.CommitScheduler: appends locally, flushes to the dataset every few minutes
|
| 13 |
+
(one commit per identification would be far too many).
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
import os
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
|
| 21 |
+
DATASET = "twelveswaras/usage-logs"
|
| 22 |
+
LOG_DIR = Path("usage_logs")
|
| 23 |
+
LOG_FILE = LOG_DIR / "identifications.jsonl"
|
| 24 |
+
|
| 25 |
+
_scheduler = None
|
| 26 |
+
_tried = False
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _scheduler_or_none():
|
| 30 |
+
global _scheduler, _tried
|
| 31 |
+
if _scheduler is not None or _tried:
|
| 32 |
+
return _scheduler
|
| 33 |
+
_tried = True
|
| 34 |
+
if not os.environ.get("HF_TOKEN"):
|
| 35 |
+
return None # logging off; app still works
|
| 36 |
+
try:
|
| 37 |
+
from huggingface_hub import CommitScheduler
|
| 38 |
+
LOG_DIR.mkdir(exist_ok=True)
|
| 39 |
+
_scheduler = CommitScheduler(repo_id=DATASET, repo_type="dataset", private=True,
|
| 40 |
+
folder_path=str(LOG_DIR), path_in_repo="data", every=5)
|
| 41 |
+
except Exception: # noqa: BLE001 (bad token / no access -> stay disabled)
|
| 42 |
+
_scheduler = None
|
| 43 |
+
return _scheduler
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def record(*, top1=None, confidence=None, top3=None, tonic_hz=None,
|
| 47 |
+
heard_seconds=None, no_prediction=False, elapsed_s=None) -> None:
|
| 48 |
+
"""Append one identification's RESULT metadata. No audio, no PII. Never raises."""
|
| 49 |
+
try:
|
| 50 |
+
sch = _scheduler_or_none()
|
| 51 |
+
if sch is None:
|
| 52 |
+
return
|
| 53 |
+
from datetime import datetime, timezone
|
| 54 |
+
row = {
|
| 55 |
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
| 56 |
+
"top1": top1,
|
| 57 |
+
"confidence": round(float(confidence), 3) if confidence is not None else None,
|
| 58 |
+
"top3": top3,
|
| 59 |
+
"tonic_hz": round(float(tonic_hz)) if tonic_hz else None,
|
| 60 |
+
"heard_s": round(float(heard_seconds), 1) if heard_seconds is not None else None,
|
| 61 |
+
"no_prediction": bool(no_prediction),
|
| 62 |
+
"elapsed_s": round(float(elapsed_s), 2) if elapsed_s is not None else None,
|
| 63 |
+
}
|
| 64 |
+
with sch.lock:
|
| 65 |
+
with open(LOG_FILE, "a", encoding="utf-8") as fh:
|
| 66 |
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
| 67 |
+
except Exception: # noqa: BLE001 (logging must never break recognition)
|
| 68 |
+
pass
|
apps/verify.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v1 community verification queue (PRD §14, D13).
|
| 2 |
+
|
| 3 |
+
Serves unverified clips from incoming/, collects agree/disagree votes, and applies
|
| 4 |
+
the promotion rule: >=3 agree AND >=80% agreement -> train; disagreement -> disputed
|
| 5 |
+
-> expert queue (Sathya + a small trusted circle). Thresholds live in config.py and
|
| 6 |
+
are configurable.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from raaga_id.config import PROMOTE_MIN_AGREEMENT, PROMOTE_MIN_VOTES
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def decide(votes_agree: int, votes_disagree: int) -> str:
|
| 14 |
+
"""Return the verification_status for a clip given its current votes (D13)."""
|
| 15 |
+
total = votes_agree + votes_disagree
|
| 16 |
+
if total < PROMOTE_MIN_VOTES:
|
| 17 |
+
return "unverified"
|
| 18 |
+
if votes_agree / total >= PROMOTE_MIN_AGREEMENT:
|
| 19 |
+
return "verified"
|
| 20 |
+
return "disputed"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# TODO(v1): Gradio queue UI — play a clip, show the declared raaga, agree/disagree;
|
| 24 |
+
# persist votes back to incoming/{id}.json; hand `disputed` to the expert queue.
|
assets/favicon.svg
ADDED
|
|
assets/logo.svg
ADDED
|
|
models/raaga_xgb.calib.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"temperature": 0.50304316348938,
|
| 3 |
+
"fit": {
|
| 4 |
+
"method": "temperature",
|
| 5 |
+
"k": 4,
|
| 6 |
+
"n_tracks": 567,
|
| 7 |
+
"nll_before": 0.9527,
|
| 8 |
+
"nll_after": 0.6602,
|
| 9 |
+
"ece_before": 0.347,
|
| 10 |
+
"ece_after": 0.0741
|
| 11 |
+
}
|
| 12 |
+
}
|
models/raaga_xgb.classes.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
["A\u1e6d\u0101na", "Beh\u0101g", "Bhairavi", "Bilahari", "B\u0113ga\u1e0da", "Dhany\u0101si", "D\u0113vag\u0101ndh\u0101ri", "Gau\u1e37a", "Harik\u0101mbh\u014dji", "Huss\u0113n\u012b", "Kaly\u0101\u1e47i", "Kam\u0101s", "Karaharapriya", "K\u0101mavardani", "K\u0101na\u1e0da", "K\u0101pi", "K\u0101\u1e41bh\u014dji", "K\u0113d\u0101ragau\u1e37a", "Madhyam\u0101vati", "Mukh\u0101ri", "M\u0101y\u0101m\u0101\u1e37avagau\u1e37a", "M\u014dhana\u1e41", "N\u0101\u1e6da", "N\u0101\u1e6dakurinji", "P\u016brv\u012bka\u1e37y\u0101\u1e47i", "R\u012btigau\u1e37a", "Sah\u0101n\u0101", "Sencuru\u1e6d\u1e6di", "Sindhubhairavi", "Sura\u1e6di", "S\u0101ma", "S\u0101v\u0113ri", "T\u014d\u1e0di", "Var\u0101\u1e37i", "Yadukula k\u0101\u1e41b\u014dji", "\u0100nandabhairavi", "\u015aankar\u0101bhara\u1e47a\u1e41", "\u015ar\u012b", "\u015ar\u012branjani", "\u1e62anmukhapriya"]
|
models/raaga_xgb.json
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5a25af1edf468ae6b64210950e020f3158b0737afa0b3b17255c7440ac6873c6
|
| 3 |
+
size 19030603
|
raaga_guide.json
ADDED
|
@@ -0,0 +1,591 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Aṭāna": {
|
| 3 |
+
"arohana": "",
|
| 4 |
+
"avarohana": "",
|
| 5 |
+
"pakad": "",
|
| 6 |
+
"listen_for": "",
|
| 7 |
+
"vs": "",
|
| 8 |
+
"swaras": [
|
| 9 |
+
"S",
|
| 10 |
+
"R2",
|
| 11 |
+
"G3",
|
| 12 |
+
"M1",
|
| 13 |
+
"P",
|
| 14 |
+
"D2",
|
| 15 |
+
"N2",
|
| 16 |
+
"N3"
|
| 17 |
+
]
|
| 18 |
+
},
|
| 19 |
+
"Behāg": {
|
| 20 |
+
"arohana": "",
|
| 21 |
+
"avarohana": "",
|
| 22 |
+
"pakad": "",
|
| 23 |
+
"listen_for": "",
|
| 24 |
+
"vs": "",
|
| 25 |
+
"swaras": []
|
| 26 |
+
},
|
| 27 |
+
"Bhairavi": {
|
| 28 |
+
"arohana": "",
|
| 29 |
+
"avarohana": "",
|
| 30 |
+
"pakad": "",
|
| 31 |
+
"listen_for": "",
|
| 32 |
+
"vs": "",
|
| 33 |
+
"swaras": [
|
| 34 |
+
"S",
|
| 35 |
+
"R2",
|
| 36 |
+
"G2",
|
| 37 |
+
"M1",
|
| 38 |
+
"P",
|
| 39 |
+
"D1",
|
| 40 |
+
"D2",
|
| 41 |
+
"N2"
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
"Bilahari": {
|
| 45 |
+
"arohana": "",
|
| 46 |
+
"avarohana": "",
|
| 47 |
+
"pakad": "",
|
| 48 |
+
"listen_for": "",
|
| 49 |
+
"vs": "",
|
| 50 |
+
"swaras": [
|
| 51 |
+
"S",
|
| 52 |
+
"R2",
|
| 53 |
+
"G3",
|
| 54 |
+
"M1",
|
| 55 |
+
"P",
|
| 56 |
+
"D2",
|
| 57 |
+
"N3"
|
| 58 |
+
]
|
| 59 |
+
},
|
| 60 |
+
"Bēgaḍa": {
|
| 61 |
+
"arohana": "",
|
| 62 |
+
"avarohana": "",
|
| 63 |
+
"pakad": "",
|
| 64 |
+
"listen_for": "",
|
| 65 |
+
"vs": "",
|
| 66 |
+
"swaras": [
|
| 67 |
+
"S",
|
| 68 |
+
"R2",
|
| 69 |
+
"G3",
|
| 70 |
+
"M1",
|
| 71 |
+
"P",
|
| 72 |
+
"D2",
|
| 73 |
+
"N2",
|
| 74 |
+
"N3"
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
"Dhanyāsi": {
|
| 78 |
+
"arohana": "",
|
| 79 |
+
"avarohana": "",
|
| 80 |
+
"pakad": "",
|
| 81 |
+
"listen_for": "",
|
| 82 |
+
"vs": "",
|
| 83 |
+
"swaras": [
|
| 84 |
+
"S",
|
| 85 |
+
"R1",
|
| 86 |
+
"G2",
|
| 87 |
+
"M1",
|
| 88 |
+
"P",
|
| 89 |
+
"D1",
|
| 90 |
+
"N2"
|
| 91 |
+
]
|
| 92 |
+
},
|
| 93 |
+
"Dēvagāndhāri": {
|
| 94 |
+
"arohana": "",
|
| 95 |
+
"avarohana": "",
|
| 96 |
+
"pakad": "",
|
| 97 |
+
"listen_for": "",
|
| 98 |
+
"vs": "",
|
| 99 |
+
"swaras": [
|
| 100 |
+
"S",
|
| 101 |
+
"R2",
|
| 102 |
+
"G3",
|
| 103 |
+
"M1",
|
| 104 |
+
"P",
|
| 105 |
+
"D2",
|
| 106 |
+
"N3"
|
| 107 |
+
]
|
| 108 |
+
},
|
| 109 |
+
"Gauḷa": {
|
| 110 |
+
"arohana": "",
|
| 111 |
+
"avarohana": "",
|
| 112 |
+
"pakad": "",
|
| 113 |
+
"listen_for": "",
|
| 114 |
+
"vs": "",
|
| 115 |
+
"swaras": [
|
| 116 |
+
"S",
|
| 117 |
+
"R1",
|
| 118 |
+
"G3",
|
| 119 |
+
"M1",
|
| 120 |
+
"P",
|
| 121 |
+
"N3"
|
| 122 |
+
]
|
| 123 |
+
},
|
| 124 |
+
"Harikāmbhōji": {
|
| 125 |
+
"arohana": "",
|
| 126 |
+
"avarohana": "",
|
| 127 |
+
"pakad": "",
|
| 128 |
+
"listen_for": "",
|
| 129 |
+
"vs": "",
|
| 130 |
+
"swaras": [
|
| 131 |
+
"S",
|
| 132 |
+
"R2",
|
| 133 |
+
"G3",
|
| 134 |
+
"M1",
|
| 135 |
+
"P",
|
| 136 |
+
"D2",
|
| 137 |
+
"N2"
|
| 138 |
+
]
|
| 139 |
+
},
|
| 140 |
+
"Hussēnī": {
|
| 141 |
+
"arohana": "",
|
| 142 |
+
"avarohana": "",
|
| 143 |
+
"pakad": "",
|
| 144 |
+
"listen_for": "",
|
| 145 |
+
"vs": "",
|
| 146 |
+
"swaras": [
|
| 147 |
+
"S",
|
| 148 |
+
"R2",
|
| 149 |
+
"G2",
|
| 150 |
+
"M1",
|
| 151 |
+
"P",
|
| 152 |
+
"D2",
|
| 153 |
+
"N2"
|
| 154 |
+
]
|
| 155 |
+
},
|
| 156 |
+
"Kalyāṇi": {
|
| 157 |
+
"arohana": "",
|
| 158 |
+
"avarohana": "",
|
| 159 |
+
"pakad": "",
|
| 160 |
+
"listen_for": "",
|
| 161 |
+
"vs": "",
|
| 162 |
+
"swaras": [
|
| 163 |
+
"S",
|
| 164 |
+
"R2",
|
| 165 |
+
"G3",
|
| 166 |
+
"M2",
|
| 167 |
+
"P",
|
| 168 |
+
"D2",
|
| 169 |
+
"N3"
|
| 170 |
+
]
|
| 171 |
+
},
|
| 172 |
+
"Kamās": {
|
| 173 |
+
"arohana": "",
|
| 174 |
+
"avarohana": "",
|
| 175 |
+
"pakad": "",
|
| 176 |
+
"listen_for": "",
|
| 177 |
+
"vs": "",
|
| 178 |
+
"swaras": [
|
| 179 |
+
"S",
|
| 180 |
+
"R2",
|
| 181 |
+
"G3",
|
| 182 |
+
"M1",
|
| 183 |
+
"P",
|
| 184 |
+
"D2",
|
| 185 |
+
"N2"
|
| 186 |
+
]
|
| 187 |
+
},
|
| 188 |
+
"Karaharapriya": {
|
| 189 |
+
"arohana": "",
|
| 190 |
+
"avarohana": "",
|
| 191 |
+
"pakad": "",
|
| 192 |
+
"listen_for": "",
|
| 193 |
+
"vs": "",
|
| 194 |
+
"swaras": [
|
| 195 |
+
"S",
|
| 196 |
+
"R2",
|
| 197 |
+
"G2",
|
| 198 |
+
"M1",
|
| 199 |
+
"P",
|
| 200 |
+
"D2",
|
| 201 |
+
"N2"
|
| 202 |
+
]
|
| 203 |
+
},
|
| 204 |
+
"Kāmavardani": {
|
| 205 |
+
"arohana": "",
|
| 206 |
+
"avarohana": "",
|
| 207 |
+
"pakad": "",
|
| 208 |
+
"listen_for": "",
|
| 209 |
+
"vs": "",
|
| 210 |
+
"swaras": [
|
| 211 |
+
"S",
|
| 212 |
+
"R1",
|
| 213 |
+
"G3",
|
| 214 |
+
"M2",
|
| 215 |
+
"P",
|
| 216 |
+
"D1",
|
| 217 |
+
"N3"
|
| 218 |
+
]
|
| 219 |
+
},
|
| 220 |
+
"Kānaḍa": {
|
| 221 |
+
"arohana": "",
|
| 222 |
+
"avarohana": "",
|
| 223 |
+
"pakad": "",
|
| 224 |
+
"listen_for": "",
|
| 225 |
+
"vs": "",
|
| 226 |
+
"swaras": [
|
| 227 |
+
"S",
|
| 228 |
+
"R2",
|
| 229 |
+
"G2",
|
| 230 |
+
"M1",
|
| 231 |
+
"P",
|
| 232 |
+
"D2",
|
| 233 |
+
"N2"
|
| 234 |
+
]
|
| 235 |
+
},
|
| 236 |
+
"Kāpi": {
|
| 237 |
+
"arohana": "",
|
| 238 |
+
"avarohana": "",
|
| 239 |
+
"pakad": "",
|
| 240 |
+
"listen_for": "",
|
| 241 |
+
"vs": "",
|
| 242 |
+
"swaras": []
|
| 243 |
+
},
|
| 244 |
+
"Kāṁbhōji": {
|
| 245 |
+
"arohana": "",
|
| 246 |
+
"avarohana": "",
|
| 247 |
+
"pakad": "",
|
| 248 |
+
"listen_for": "",
|
| 249 |
+
"vs": "",
|
| 250 |
+
"swaras": [
|
| 251 |
+
"S",
|
| 252 |
+
"R2",
|
| 253 |
+
"G3",
|
| 254 |
+
"M1",
|
| 255 |
+
"P",
|
| 256 |
+
"D2",
|
| 257 |
+
"N2"
|
| 258 |
+
]
|
| 259 |
+
},
|
| 260 |
+
"Kēdāragauḷa": {
|
| 261 |
+
"arohana": "",
|
| 262 |
+
"avarohana": "",
|
| 263 |
+
"pakad": "",
|
| 264 |
+
"listen_for": "",
|
| 265 |
+
"vs": "",
|
| 266 |
+
"swaras": [
|
| 267 |
+
"S",
|
| 268 |
+
"R2",
|
| 269 |
+
"G3",
|
| 270 |
+
"M1",
|
| 271 |
+
"P",
|
| 272 |
+
"D2",
|
| 273 |
+
"N2"
|
| 274 |
+
]
|
| 275 |
+
},
|
| 276 |
+
"Madhyamāvati": {
|
| 277 |
+
"arohana": "",
|
| 278 |
+
"avarohana": "",
|
| 279 |
+
"pakad": "",
|
| 280 |
+
"listen_for": "",
|
| 281 |
+
"vs": "",
|
| 282 |
+
"swaras": [
|
| 283 |
+
"S",
|
| 284 |
+
"R2",
|
| 285 |
+
"M1",
|
| 286 |
+
"P",
|
| 287 |
+
"N2"
|
| 288 |
+
]
|
| 289 |
+
},
|
| 290 |
+
"Mukhāri": {
|
| 291 |
+
"arohana": "",
|
| 292 |
+
"avarohana": "",
|
| 293 |
+
"pakad": "",
|
| 294 |
+
"listen_for": "",
|
| 295 |
+
"vs": "",
|
| 296 |
+
"swaras": [
|
| 297 |
+
"S",
|
| 298 |
+
"R2",
|
| 299 |
+
"G2",
|
| 300 |
+
"M1",
|
| 301 |
+
"P",
|
| 302 |
+
"D1",
|
| 303 |
+
"D2",
|
| 304 |
+
"N2"
|
| 305 |
+
]
|
| 306 |
+
},
|
| 307 |
+
"Māyāmāḷavagauḷa": {
|
| 308 |
+
"arohana": "",
|
| 309 |
+
"avarohana": "",
|
| 310 |
+
"pakad": "",
|
| 311 |
+
"listen_for": "",
|
| 312 |
+
"vs": "",
|
| 313 |
+
"swaras": [
|
| 314 |
+
"S",
|
| 315 |
+
"R1",
|
| 316 |
+
"G3",
|
| 317 |
+
"M1",
|
| 318 |
+
"P",
|
| 319 |
+
"D1",
|
| 320 |
+
"N3"
|
| 321 |
+
]
|
| 322 |
+
},
|
| 323 |
+
"Mōhanaṁ": {
|
| 324 |
+
"arohana": "",
|
| 325 |
+
"avarohana": "",
|
| 326 |
+
"pakad": "",
|
| 327 |
+
"listen_for": "",
|
| 328 |
+
"vs": "",
|
| 329 |
+
"swaras": [
|
| 330 |
+
"S",
|
| 331 |
+
"R2",
|
| 332 |
+
"G3",
|
| 333 |
+
"P",
|
| 334 |
+
"D2"
|
| 335 |
+
]
|
| 336 |
+
},
|
| 337 |
+
"Nāṭa": {
|
| 338 |
+
"arohana": "",
|
| 339 |
+
"avarohana": "",
|
| 340 |
+
"pakad": "",
|
| 341 |
+
"listen_for": "",
|
| 342 |
+
"vs": "",
|
| 343 |
+
"swaras": []
|
| 344 |
+
},
|
| 345 |
+
"Nāṭakurinji": {
|
| 346 |
+
"arohana": "",
|
| 347 |
+
"avarohana": "",
|
| 348 |
+
"pakad": "",
|
| 349 |
+
"listen_for": "",
|
| 350 |
+
"vs": "",
|
| 351 |
+
"swaras": [
|
| 352 |
+
"S",
|
| 353 |
+
"R2",
|
| 354 |
+
"G3",
|
| 355 |
+
"M1",
|
| 356 |
+
"P",
|
| 357 |
+
"D2",
|
| 358 |
+
"N2"
|
| 359 |
+
]
|
| 360 |
+
},
|
| 361 |
+
"Pūrvīkaḷyāṇi": {
|
| 362 |
+
"arohana": "",
|
| 363 |
+
"avarohana": "",
|
| 364 |
+
"pakad": "",
|
| 365 |
+
"listen_for": "",
|
| 366 |
+
"vs": "",
|
| 367 |
+
"swaras": [
|
| 368 |
+
"S",
|
| 369 |
+
"R1",
|
| 370 |
+
"G3",
|
| 371 |
+
"M2",
|
| 372 |
+
"P",
|
| 373 |
+
"D2",
|
| 374 |
+
"N3"
|
| 375 |
+
]
|
| 376 |
+
},
|
| 377 |
+
"Rītigauḷa": {
|
| 378 |
+
"arohana": "",
|
| 379 |
+
"avarohana": "",
|
| 380 |
+
"pakad": "",
|
| 381 |
+
"listen_for": "",
|
| 382 |
+
"vs": "",
|
| 383 |
+
"swaras": [
|
| 384 |
+
"S",
|
| 385 |
+
"R2",
|
| 386 |
+
"G2",
|
| 387 |
+
"M1",
|
| 388 |
+
"P",
|
| 389 |
+
"D2",
|
| 390 |
+
"N2"
|
| 391 |
+
]
|
| 392 |
+
},
|
| 393 |
+
"Sahānā": {
|
| 394 |
+
"arohana": "",
|
| 395 |
+
"avarohana": "",
|
| 396 |
+
"pakad": "",
|
| 397 |
+
"listen_for": "",
|
| 398 |
+
"vs": "",
|
| 399 |
+
"swaras": [
|
| 400 |
+
"S",
|
| 401 |
+
"R2",
|
| 402 |
+
"G3",
|
| 403 |
+
"M1",
|
| 404 |
+
"P",
|
| 405 |
+
"D2",
|
| 406 |
+
"N2"
|
| 407 |
+
]
|
| 408 |
+
},
|
| 409 |
+
"Sencuruṭṭi": {
|
| 410 |
+
"arohana": "",
|
| 411 |
+
"avarohana": "",
|
| 412 |
+
"pakad": "",
|
| 413 |
+
"listen_for": "",
|
| 414 |
+
"vs": "",
|
| 415 |
+
"swaras": [
|
| 416 |
+
"S",
|
| 417 |
+
"R2",
|
| 418 |
+
"G3",
|
| 419 |
+
"M1",
|
| 420 |
+
"P",
|
| 421 |
+
"D2",
|
| 422 |
+
"N2"
|
| 423 |
+
]
|
| 424 |
+
},
|
| 425 |
+
"Sindhubhairavi": {
|
| 426 |
+
"arohana": "",
|
| 427 |
+
"avarohana": "",
|
| 428 |
+
"pakad": "",
|
| 429 |
+
"listen_for": "",
|
| 430 |
+
"vs": "",
|
| 431 |
+
"swaras": []
|
| 432 |
+
},
|
| 433 |
+
"Suraṭi": {
|
| 434 |
+
"arohana": "",
|
| 435 |
+
"avarohana": "",
|
| 436 |
+
"pakad": "",
|
| 437 |
+
"listen_for": "",
|
| 438 |
+
"vs": "",
|
| 439 |
+
"swaras": [
|
| 440 |
+
"S",
|
| 441 |
+
"R2",
|
| 442 |
+
"G3",
|
| 443 |
+
"M1",
|
| 444 |
+
"P",
|
| 445 |
+
"D2",
|
| 446 |
+
"N2"
|
| 447 |
+
]
|
| 448 |
+
},
|
| 449 |
+
"Sāma": {
|
| 450 |
+
"arohana": "",
|
| 451 |
+
"avarohana": "",
|
| 452 |
+
"pakad": "",
|
| 453 |
+
"listen_for": "",
|
| 454 |
+
"vs": "",
|
| 455 |
+
"swaras": [
|
| 456 |
+
"S",
|
| 457 |
+
"R2",
|
| 458 |
+
"G3",
|
| 459 |
+
"M1",
|
| 460 |
+
"P",
|
| 461 |
+
"D2"
|
| 462 |
+
]
|
| 463 |
+
},
|
| 464 |
+
"Sāvēri": {
|
| 465 |
+
"arohana": "",
|
| 466 |
+
"avarohana": "",
|
| 467 |
+
"pakad": "",
|
| 468 |
+
"listen_for": "",
|
| 469 |
+
"vs": "",
|
| 470 |
+
"swaras": [
|
| 471 |
+
"S",
|
| 472 |
+
"R1",
|
| 473 |
+
"G3",
|
| 474 |
+
"M1",
|
| 475 |
+
"P",
|
| 476 |
+
"D1",
|
| 477 |
+
"N3"
|
| 478 |
+
]
|
| 479 |
+
},
|
| 480 |
+
"Tōḍi": {
|
| 481 |
+
"arohana": "",
|
| 482 |
+
"avarohana": "",
|
| 483 |
+
"pakad": "",
|
| 484 |
+
"listen_for": "",
|
| 485 |
+
"vs": "",
|
| 486 |
+
"swaras": [
|
| 487 |
+
"S",
|
| 488 |
+
"R1",
|
| 489 |
+
"G2",
|
| 490 |
+
"M1",
|
| 491 |
+
"P",
|
| 492 |
+
"D1",
|
| 493 |
+
"N2"
|
| 494 |
+
]
|
| 495 |
+
},
|
| 496 |
+
"Varāḷi": {
|
| 497 |
+
"arohana": "",
|
| 498 |
+
"avarohana": "",
|
| 499 |
+
"pakad": "",
|
| 500 |
+
"listen_for": "",
|
| 501 |
+
"vs": "",
|
| 502 |
+
"swaras": []
|
| 503 |
+
},
|
| 504 |
+
"Yadukula kāṁbōji": {
|
| 505 |
+
"arohana": "",
|
| 506 |
+
"avarohana": "",
|
| 507 |
+
"pakad": "",
|
| 508 |
+
"listen_for": "",
|
| 509 |
+
"vs": "",
|
| 510 |
+
"swaras": [
|
| 511 |
+
"S",
|
| 512 |
+
"R2",
|
| 513 |
+
"G3",
|
| 514 |
+
"M1",
|
| 515 |
+
"P",
|
| 516 |
+
"D2",
|
| 517 |
+
"N2"
|
| 518 |
+
]
|
| 519 |
+
},
|
| 520 |
+
"Ānandabhairavi": {
|
| 521 |
+
"arohana": "",
|
| 522 |
+
"avarohana": "",
|
| 523 |
+
"pakad": "",
|
| 524 |
+
"listen_for": "",
|
| 525 |
+
"vs": "",
|
| 526 |
+
"swaras": []
|
| 527 |
+
},
|
| 528 |
+
"Śankarābharaṇaṁ": {
|
| 529 |
+
"arohana": "",
|
| 530 |
+
"avarohana": "",
|
| 531 |
+
"pakad": "",
|
| 532 |
+
"listen_for": "",
|
| 533 |
+
"vs": "",
|
| 534 |
+
"swaras": [
|
| 535 |
+
"S",
|
| 536 |
+
"R2",
|
| 537 |
+
"G3",
|
| 538 |
+
"M1",
|
| 539 |
+
"P",
|
| 540 |
+
"D2",
|
| 541 |
+
"N3"
|
| 542 |
+
]
|
| 543 |
+
},
|
| 544 |
+
"Śrī": {
|
| 545 |
+
"arohana": "",
|
| 546 |
+
"avarohana": "",
|
| 547 |
+
"pakad": "",
|
| 548 |
+
"listen_for": "",
|
| 549 |
+
"vs": "",
|
| 550 |
+
"swaras": [
|
| 551 |
+
"S",
|
| 552 |
+
"R2",
|
| 553 |
+
"G2",
|
| 554 |
+
"M1",
|
| 555 |
+
"P",
|
| 556 |
+
"D2",
|
| 557 |
+
"N2"
|
| 558 |
+
]
|
| 559 |
+
},
|
| 560 |
+
"Śrīranjani": {
|
| 561 |
+
"arohana": "",
|
| 562 |
+
"avarohana": "",
|
| 563 |
+
"pakad": "",
|
| 564 |
+
"listen_for": "",
|
| 565 |
+
"vs": "",
|
| 566 |
+
"swaras": [
|
| 567 |
+
"S",
|
| 568 |
+
"R2",
|
| 569 |
+
"G2",
|
| 570 |
+
"M1",
|
| 571 |
+
"D2",
|
| 572 |
+
"N2"
|
| 573 |
+
]
|
| 574 |
+
},
|
| 575 |
+
"Ṣanmukhapriya": {
|
| 576 |
+
"arohana": "",
|
| 577 |
+
"avarohana": "",
|
| 578 |
+
"pakad": "",
|
| 579 |
+
"listen_for": "",
|
| 580 |
+
"vs": "",
|
| 581 |
+
"swaras": [
|
| 582 |
+
"S",
|
| 583 |
+
"R2",
|
| 584 |
+
"G2",
|
| 585 |
+
"M2",
|
| 586 |
+
"P",
|
| 587 |
+
"D1",
|
| 588 |
+
"N2"
|
| 589 |
+
]
|
| 590 |
+
}
|
| 591 |
+
}
|
raaga_id/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""twelveswaras — raaga identification pipeline.
|
| 2 |
+
|
| 3 |
+
An open "Shazam for raagas": infer the raaga of any Carnatic performance, paired
|
| 4 |
+
with a CC-BY-4.0 data commons. Non-commercial, open-source only (PRD D1).
|
| 5 |
+
|
| 6 |
+
Pipeline order (PRD §6.7):
|
| 7 |
+
audio -> [tonic ID + predominant-melody] -> normalize to Sa
|
| 8 |
+
-> features (swara histogram / TDMS / mel-CQT) -> model -> top-3.
|
| 9 |
+
|
| 10 |
+
The tonic step (PRD D5, essentia/compIAM) is Phase 2 — Phase 1 is a librosa-only
|
| 11 |
+
floor so we get a working baseline without native-lib pain on Apple Silicon.
|
| 12 |
+
"""
|
raaga_id/calibrate.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Probability calibration + confidence state (D25).
|
| 2 |
+
|
| 3 |
+
The shown %s are the mean of XGBoost softmax over a clip's windows — a *relative* ranking,
|
| 4 |
+
not a true likelihood. Two fixes live here:
|
| 5 |
+
|
| 6 |
+
1. **Temperature scaling** — one scalar T (fit offline on leak-free out-of-fold predictions,
|
| 7 |
+
`tools/calibrate.py`) reshapes the probabilities so the number means what it says, WITHOUT
|
| 8 |
+
changing which raaga wins (argmax-preserving). Stored in a `<model>.calib.json` sidecar.
|
| 9 |
+
2. **Confidence state** — turns a flat top-2 into an honest "close call — X vs Y" instead of a
|
| 10 |
+
falsely-precise single answer.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import json
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
from .config import CLOSE_MARGIN, LOW_CONFIDENCE
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def apply_temperature(proba, temperature: float):
|
| 23 |
+
"""Temperature-scale a probability vector (or a batch; classes = last axis).
|
| 24 |
+
|
| 25 |
+
q_i ∝ p_i**(1/T). T>1 flattens (less confident), T<1 sharpens, T=1 is identity. Monotone,
|
| 26 |
+
so the argmax (winning raaga) never changes — only the spread of the confidences does.
|
| 27 |
+
"""
|
| 28 |
+
p = np.asarray(proba, dtype=float)
|
| 29 |
+
q = np.power(np.clip(p, 0.0, None), 1.0 / temperature)
|
| 30 |
+
s = q.sum(axis=-1, keepdims=True)
|
| 31 |
+
s = np.where(s == 0.0, 1.0, s)
|
| 32 |
+
return q / s
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def nll(P, y_idx, eps: float = 1e-12) -> float:
|
| 36 |
+
"""Mean negative log-likelihood of the true class — the thing temperature scaling minimizes."""
|
| 37 |
+
P = np.atleast_2d(np.asarray(P, dtype=float))
|
| 38 |
+
y = np.asarray(y_idx)
|
| 39 |
+
picked = P[np.arange(len(y)), y]
|
| 40 |
+
return float(-np.mean(np.log(np.clip(picked, eps, 1.0))))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def ece(P, y_idx, n_bins: int = 10) -> float:
|
| 44 |
+
"""Expected calibration error: |confidence − accuracy| averaged over confidence bins.
|
| 45 |
+
0 = perfectly calibrated (a bin of "70% sure" predictions is right 70% of the time)."""
|
| 46 |
+
P = np.atleast_2d(np.asarray(P, dtype=float))
|
| 47 |
+
y = np.asarray(y_idx)
|
| 48 |
+
conf = P.max(axis=1)
|
| 49 |
+
correct = (P.argmax(axis=1) == y).astype(float)
|
| 50 |
+
edges = np.linspace(0.0, 1.0, n_bins + 1)
|
| 51 |
+
e = 0.0
|
| 52 |
+
for b in range(n_bins):
|
| 53 |
+
m = (conf > edges[b]) & (conf <= edges[b + 1])
|
| 54 |
+
if m.any():
|
| 55 |
+
e += m.mean() * abs(conf[m].mean() - correct[m].mean())
|
| 56 |
+
return float(e)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def fit_temperature(P, y_idx, lo: float = 0.2, hi: float = 6.0, n: int = 60) -> float:
|
| 60 |
+
"""Return the temperature over [lo, hi] (geometric grid) that minimizes NLL. Grid search —
|
| 61 |
+
no scipy dependency, and T needs no great precision."""
|
| 62 |
+
P = np.atleast_2d(np.asarray(P, dtype=float))
|
| 63 |
+
grid = np.geomspace(lo, hi, n)
|
| 64 |
+
losses = [nll(apply_temperature(P, T), y_idx) for T in grid]
|
| 65 |
+
return float(grid[int(np.argmin(losses))])
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def confidence_state(preds, low: float = LOW_CONFIDENCE, close_margin: float = CLOSE_MARGIN):
|
| 69 |
+
"""Map a decoded top-k list to (state, note). `unsure` when the top score is below `low`;
|
| 70 |
+
else `close` when the top two are within `close_margin`; else `confident`."""
|
| 71 |
+
p1 = preds[0].confidence
|
| 72 |
+
p2 = preds[1].confidence if len(preds) > 1 else 0.0
|
| 73 |
+
if p1 < low:
|
| 74 |
+
return "unsure", "🤔 not sure — melody or tonic unclear; try a longer, cleaner clip with a drone"
|
| 75 |
+
if p1 - p2 < close_margin:
|
| 76 |
+
return "close", f"🤔 close call — {preds[0].raaga} vs {preds[1].raaga}"
|
| 77 |
+
return "confident", "✓ confident"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# --- sidecar persistence: temperature lives next to the model as <model>.calib.json ---
|
| 81 |
+
def temperature_path(model_path) -> Path:
|
| 82 |
+
return Path(model_path).with_suffix(".calib.json")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def load_temperature(model_path, default: float = 1.0) -> float:
|
| 86 |
+
p = temperature_path(model_path)
|
| 87 |
+
if p.exists():
|
| 88 |
+
return float(json.loads(p.read_text()).get("temperature", default))
|
| 89 |
+
return default
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def save_temperature(model_path, temperature: float, extra: dict | None = None) -> Path:
|
| 93 |
+
p = temperature_path(model_path)
|
| 94 |
+
payload = {"temperature": float(temperature)}
|
| 95 |
+
if extra:
|
| 96 |
+
payload.update(extra)
|
| 97 |
+
p.write_text(json.dumps(payload, indent=2))
|
| 98 |
+
return p
|
raaga_id/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared constants, grounded in the PRD decisions log."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import os
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
# Repo layout (PRD §17). data/ and models/ are gitignored; benchmark/ is tracked.
|
| 8 |
+
# Point the corpus at an external SSD without editing code: export TWELVESWARAS_DATA=/Volumes/....
|
| 9 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 10 |
+
DATA_DIR = Path(os.environ.get("TWELVESWARAS_DATA", ROOT / "data"))
|
| 11 |
+
MODELS_DIR = ROOT / "models"
|
| 12 |
+
BENCHMARK_DIR = ROOT / "benchmark"
|
| 13 |
+
RAAGAS_PATH = ROOT / "raagas.json"
|
| 14 |
+
|
| 15 |
+
# Audio (PRD §6.7 + D7).
|
| 16 |
+
SAMPLE_RATE = 16_000 # 16 kHz mono throughout
|
| 17 |
+
CLIP_SECONDS = 10.0 # D7: 10 s analysis window
|
| 18 |
+
MIN_CLIP_SECONDS = 5.0 # D7: accept >=5 s with a warning
|
| 19 |
+
HOP_SECONDS = 5.0 # stride when aggregating predictions across a long clip
|
| 20 |
+
|
| 21 |
+
# Output UX (D6).
|
| 22 |
+
TOP_K = 3 # always show top-3 + confidence
|
| 23 |
+
# Below this averaged top-1 probability -> "not sure". Calibrated to the v0 floor:
|
| 24 |
+
# real clips average ~0.28-0.60 for the top class, near-random/percussion ~0.08-0.12
|
| 25 |
+
# (uniform = 1/12 = 0.083), so 0.15 shows top-3 for real music and gates only noise.
|
| 26 |
+
LOW_CONFIDENCE = 0.15
|
| 27 |
+
# When the top two raagas are within this (calibrated) margin, call it a "close call — X vs Y"
|
| 28 |
+
# rather than a confident single answer (D25). Allied raagas (Mōhanaṁ/Bilahari/Bēgaḍa …) share
|
| 29 |
+
# a pitch-class profile, so an honest close-call is common and correct.
|
| 30 |
+
CLOSE_MARGIN = 0.06
|
| 31 |
+
INFER_MAX_WINDOWS = 60 # analyse ~first 10 min of a long upload (matches training)
|
| 32 |
+
INFER_SECONDS = 90 # cap raw-audio analysed at inference — tonic salience is ~1min/2min
|
| 33 |
+
PCD_BINS = 120 # pitch-class-distribution resolution (10-cent bins); the display feature
|
| 34 |
+
|
| 35 |
+
# Production model feature = windowed Time-Delayed Melody Surface (D28). The gate benchmark
|
| 36 |
+
# (tools/tdms_benchmark) put TDMS-30s at top1 0.866 / top3 0.954 vs windowed-PCD 0.780 / 0.926,
|
| 37 |
+
# and the allied triple at 0.881 vs 0.714 — gamaka/movement is what the static PCD threw away.
|
| 38 |
+
TDMS_BINS = 48 # surface is TDMS_BINS x TDMS_BINS (10-cent-ish, 25-cent bins over an octave)
|
| 39 |
+
TDMS_DELAY = 0.3 # seconds; the (pitch(t), pitch(t+delay)) lag that exposes gamaka
|
| 40 |
+
TDMS_WINDOW_S = 30.0 # 30 s windows — dense enough to fill the surface (10 s was too sparse)
|
| 41 |
+
TDMS_HOP_S = 30.0
|
| 42 |
+
TDMS_MAX_WINDOWS = 20 # cap windows/track in training (20 x 30 s = 600 s, matches the gate)
|
| 43 |
+
# Junk gate (D6/D8): drop windows whose predominant-melody pitch is voiced less than this fraction
|
| 44 |
+
# of the time — i.e. percussion solos, speech, applause, long silences, where there's no stable
|
| 45 |
+
# melody for Melodia to track. Real melody is voiced most of the window; junk is mostly unvoiced.
|
| 46 |
+
MIN_VOICED_FRAC = 0.5
|
| 47 |
+
|
| 48 |
+
# Verification (D13). Configurable; these are the v0 defaults.
|
| 49 |
+
PROMOTE_MIN_VOTES = 3
|
| 50 |
+
PROMOTE_MIN_AGREEMENT = 0.80
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def load_raagas() -> dict:
|
| 54 |
+
"""Return the controlled vocabulary from raagas.json (canonical + aliases)."""
|
| 55 |
+
import json
|
| 56 |
+
|
| 57 |
+
with open(RAAGAS_PATH, encoding="utf-8") as fh:
|
| 58 |
+
return json.load(fh)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def fold_raaga(name: str) -> str:
|
| 62 |
+
"""Normalize a raaga name for matching: strip diacritics + case + separators.
|
| 63 |
+
|
| 64 |
+
Saraga uses diacritics (Mōhanaṁ, Tōḍi, Śudda sāvēri); our vocab/aliases are ASCII.
|
| 65 |
+
NFKD-decompose, drop combining marks, lowercase, keep only alphanumerics so
|
| 66 |
+
'Mōhanaṁ' and 'Mohanam' fold to the same key.
|
| 67 |
+
"""
|
| 68 |
+
import unicodedata
|
| 69 |
+
|
| 70 |
+
decomposed = unicodedata.normalize("NFKD", name)
|
| 71 |
+
stripped = "".join(c for c in decomposed if not unicodedata.combining(c))
|
| 72 |
+
return "".join(c for c in stripped.lower() if c.isalnum())
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def canonical_raaga(name: str, vocab: dict | None = None) -> str:
|
| 76 |
+
"""Map a raw raaga label to its canonical form via the alias table (diacritic-insensitive)."""
|
| 77 |
+
vocab = vocab or load_raagas()
|
| 78 |
+
key = fold_raaga(name)
|
| 79 |
+
for canon in vocab["canonical"]:
|
| 80 |
+
if key == fold_raaga(canon):
|
| 81 |
+
return canon
|
| 82 |
+
for canon, aliases in vocab.get("aliases", {}).items():
|
| 83 |
+
if key in {fold_raaga(a) for a in aliases}:
|
| 84 |
+
return canon
|
| 85 |
+
return name # unknown -> pass through (surfaces as an out-of-vocab label)
|
raaga_id/data.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data loading — Saraga Carnatic via mirdata (PRD §6.3, D10).
|
| 2 |
+
|
| 3 |
+
Seed corpus for v0 is Saraga (D10). The multi-GB download is deliberately NOT run
|
| 4 |
+
at import time; call `download_saraga()` explicitly (held until storage is
|
| 5 |
+
confirmed). Everything here degrades to a clear error if the data is absent.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
import random
|
| 11 |
+
from collections import defaultdict
|
| 12 |
+
from dataclasses import dataclass
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from .config import BENCHMARK_DIR, DATA_DIR, canonical_raaga, fold_raaga, load_raagas
|
| 16 |
+
|
| 17 |
+
FROZEN_TEST_PATH = BENCHMARK_DIR / "test_track_ids.json"
|
| 18 |
+
|
| 19 |
+
SARAGA_DATASET = "saraga_carnatic"
|
| 20 |
+
SARAGA_HOME = DATA_DIR / "saraga_carnatic"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class Clip:
|
| 25 |
+
"""One labelled audio example: a path + its canonical raaga."""
|
| 26 |
+
track_id: str
|
| 27 |
+
audio_path: Path
|
| 28 |
+
raaga: str # canonical (raagas.json)
|
| 29 |
+
tradition: str = "carnatic"
|
| 30 |
+
tonic_hz: float | None = None # Saraga ctonic annotation (precise Sa), if present
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class PitchClip:
|
| 35 |
+
"""A labelled predominant-melody pitch track + its tonic — the PCD feature source.
|
| 36 |
+
Dataset-agnostic so Saraga and IAMRRD pool through the same path."""
|
| 37 |
+
dataset: str
|
| 38 |
+
track_id: str
|
| 39 |
+
raaga: str
|
| 40 |
+
tonic_hz: float
|
| 41 |
+
times: "object" # np.ndarray of frame times (s)
|
| 42 |
+
freqs: "object" # np.ndarray of f0 (Hz), 0/NaN where unvoiced
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _dataset(download: bool = False):
|
| 46 |
+
import mirdata
|
| 47 |
+
|
| 48 |
+
ds = mirdata.initialize(SARAGA_DATASET, data_home=str(SARAGA_HOME))
|
| 49 |
+
if download:
|
| 50 |
+
ds.download()
|
| 51 |
+
return ds
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def download_saraga() -> Path:
|
| 55 |
+
"""Download Saraga Carnatic into data/ (a few GB). Run once, on demand."""
|
| 56 |
+
SARAGA_HOME.mkdir(parents=True, exist_ok=True)
|
| 57 |
+
_dataset(download=True)
|
| 58 |
+
return SARAGA_HOME
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def iter_clips(only_vocab: bool = True):
|
| 62 |
+
"""Yield Clip(track_id, audio_path, raaga) for every Saraga track with a raaga.
|
| 63 |
+
|
| 64 |
+
only_vocab=True keeps just the v0 controlled-vocabulary raagas (raagas.json).
|
| 65 |
+
"""
|
| 66 |
+
ds = _dataset(download=False)
|
| 67 |
+
vocab = load_raagas()
|
| 68 |
+
keep = {r.lower().replace(" ", "") for r in vocab["canonical"]}
|
| 69 |
+
|
| 70 |
+
for track_id, track in ds.load_tracks().items():
|
| 71 |
+
raw = _raaga_of(track)
|
| 72 |
+
if not raw:
|
| 73 |
+
continue
|
| 74 |
+
canon = canonical_raaga(raw, vocab)
|
| 75 |
+
if only_vocab and canon.lower().replace(" ", "") not in keep:
|
| 76 |
+
continue
|
| 77 |
+
audio_path = getattr(track, "audio_path", None)
|
| 78 |
+
if not audio_path or not Path(audio_path).exists():
|
| 79 |
+
continue
|
| 80 |
+
yield Clip(track_id=track_id, audio_path=Path(audio_path), raaga=canon,
|
| 81 |
+
tonic_hz=_tonic_of(track))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def load_frozen_test() -> set[str] | None:
|
| 85 |
+
"""The frozen held-out track ids, or None if no benchmark is frozen yet."""
|
| 86 |
+
if FROZEN_TEST_PATH.exists():
|
| 87 |
+
return set(json.loads(FROZEN_TEST_PATH.read_text()))
|
| 88 |
+
return None
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def freeze_test(test_ids: set[str]) -> Path:
|
| 92 |
+
"""Write the benchmark test split ONCE. Refuses to overwrite an existing freeze
|
| 93 |
+
(build-order step 6: a frozen set stays frozen so scores stay comparable)."""
|
| 94 |
+
if FROZEN_TEST_PATH.exists():
|
| 95 |
+
raise FileExistsError(f"{FROZEN_TEST_PATH} already frozen — delete it to re-split.")
|
| 96 |
+
FROZEN_TEST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
| 97 |
+
FROZEN_TEST_PATH.write_text(json.dumps(sorted(test_ids), indent=2) + "\n")
|
| 98 |
+
return FROZEN_TEST_PATH
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def split_by_track(clips: list[Clip], test_frac: float = 0.25, seed: int = 0) -> tuple[set[str], set[str]]:
|
| 102 |
+
"""Return (train_ids, test_ids), split BY TRACK so windows never leak across the
|
| 103 |
+
split, stratified per raaga. Honors an existing frozen test set; otherwise draws a
|
| 104 |
+
deterministic split and freezes it.
|
| 105 |
+
"""
|
| 106 |
+
all_ids = {c.track_id for c in clips}
|
| 107 |
+
frozen = load_frozen_test()
|
| 108 |
+
if frozen is not None:
|
| 109 |
+
test = frozen & all_ids
|
| 110 |
+
return all_ids - test, test
|
| 111 |
+
|
| 112 |
+
by_raaga: dict[str, list[str]] = defaultdict(list)
|
| 113 |
+
for c in clips:
|
| 114 |
+
by_raaga[c.raaga].append(c.track_id)
|
| 115 |
+
|
| 116 |
+
rng = random.Random(seed)
|
| 117 |
+
test: set[str] = set()
|
| 118 |
+
for raaga, ids in by_raaga.items():
|
| 119 |
+
ids = sorted(ids)
|
| 120 |
+
rng.shuffle(ids)
|
| 121 |
+
n_test = int(len(ids) * test_frac)
|
| 122 |
+
if len(ids) >= 2: # keep >=1 in each side when possible
|
| 123 |
+
n_test = max(1, n_test)
|
| 124 |
+
test.update(ids[:n_test])
|
| 125 |
+
|
| 126 |
+
freeze_test(test)
|
| 127 |
+
return all_ids - test, test
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _raga_name(val) -> str | None:
|
| 131 |
+
"""Extract a raaga name from the various shapes mirdata returns (str / dict / list)."""
|
| 132 |
+
if isinstance(val, str) and val.strip():
|
| 133 |
+
return val
|
| 134 |
+
if isinstance(val, dict):
|
| 135 |
+
return val.get("name")
|
| 136 |
+
if isinstance(val, (list, tuple)) and val:
|
| 137 |
+
first = val[0]
|
| 138 |
+
return first.get("name") if isinstance(first, dict) else str(first)
|
| 139 |
+
return None
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _raaga_of(track) -> str | None:
|
| 143 |
+
"""Raaga label across schemas: IAMRRD's ``track.raga`` (name string) or Saraga's
|
| 144 |
+
``track.metadata["raaga"]`` (list of dicts with a ``name``)."""
|
| 145 |
+
try:
|
| 146 |
+
name = _raga_name(getattr(track, "raga", None)) # IAMRRD (compmusic_raga)
|
| 147 |
+
except Exception: # noqa: BLE001
|
| 148 |
+
name = None
|
| 149 |
+
if name:
|
| 150 |
+
return name
|
| 151 |
+
try:
|
| 152 |
+
meta = track.metadata # Saraga
|
| 153 |
+
except Exception: # noqa: BLE001 — missing/corrupt per-track metadata json
|
| 154 |
+
return None
|
| 155 |
+
return _raga_name(meta.get("raaga")) if meta else None
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _tradition_of(track, default: str = "carnatic") -> str:
|
| 159 |
+
"""Track tradition (carnatic/hindustani). IAMRRD sets ``track.tradition``; Saraga
|
| 160 |
+
Carnatic has none, so it defaults to carnatic."""
|
| 161 |
+
try:
|
| 162 |
+
t = getattr(track, "tradition", None)
|
| 163 |
+
except Exception: # noqa: BLE001
|
| 164 |
+
t = None
|
| 165 |
+
return t.lower() if isinstance(t, str) and t else default
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _tonic_of(track) -> float | None:
|
| 169 |
+
"""Saraga's ctonic annotation (tonic in Hz), if present. mirdata exposes it as
|
| 170 |
+
``track.tonic`` (loads the .ctonic file) — the precise Sa for tonic normalization."""
|
| 171 |
+
try:
|
| 172 |
+
t = track.tonic
|
| 173 |
+
except Exception: # noqa: BLE001 — missing/unreadable ctonic file
|
| 174 |
+
return None
|
| 175 |
+
return float(t) if isinstance(t, (int, float)) and t > 0 else None
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _pitch_of(track):
|
| 179 |
+
"""(times, freqs) from a track's predominant-melody pitch annotation, or None.
|
| 180 |
+
Handles Saraga (``track.pitch``) and IAMRRD (``pitch`` / ``pitch_post_processed``)."""
|
| 181 |
+
for attr in ("pitch", "pitch_post_processed"):
|
| 182 |
+
try:
|
| 183 |
+
p = getattr(track, attr, None)
|
| 184 |
+
except Exception: # noqa: BLE001 — missing/unreadable pitch file
|
| 185 |
+
continue
|
| 186 |
+
if p is not None and getattr(p, "frequencies", None) is not None:
|
| 187 |
+
return p.times, p.frequencies
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def iter_pitch_clips(only_vocab: bool = True, datasets=("saraga_carnatic",), tradition="carnatic"):
|
| 192 |
+
"""Yield PitchClip across datasets — a labelled pitch track + tonic per recording.
|
| 193 |
+
Filters to one tradition (Hindustani shares raaga names like Bhairavi/Todi but they
|
| 194 |
+
are different ragas). Skips tracks missing a raaga, tonic, or pitch annotation."""
|
| 195 |
+
import mirdata
|
| 196 |
+
|
| 197 |
+
vocab = load_raagas()
|
| 198 |
+
keep = {fold_raaga(r) for r in vocab["canonical"]}
|
| 199 |
+
for name in datasets:
|
| 200 |
+
ds = mirdata.initialize(name, data_home=str(DATA_DIR / name))
|
| 201 |
+
for track_id, track in ds.load_tracks().items():
|
| 202 |
+
if tradition and _tradition_of(track) != tradition:
|
| 203 |
+
continue
|
| 204 |
+
raw = _raaga_of(track)
|
| 205 |
+
if not raw:
|
| 206 |
+
continue
|
| 207 |
+
canon = canonical_raaga(raw, vocab)
|
| 208 |
+
if only_vocab and fold_raaga(canon) not in keep:
|
| 209 |
+
continue
|
| 210 |
+
tonic = _tonic_of(track)
|
| 211 |
+
pitch = _pitch_of(track)
|
| 212 |
+
if not tonic or pitch is None:
|
| 213 |
+
continue
|
| 214 |
+
yield PitchClip(name, track_id, canon, tonic, pitch[0], pitch[1])
|
raaga_id/evaluate.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Evaluation against the frozen benchmark (PRD §15, build-order step 6).
|
| 2 |
+
|
| 3 |
+
python -m raaga_id.evaluate --model models/raaga_xgb.json
|
| 4 |
+
|
| 5 |
+
Scores per TRACK on the frozen test split: PCD-window each held-out recording, average
|
| 6 |
+
the window probabilities (D7), take top-k, compare to the true raaga. Top-3 is the
|
| 7 |
+
headline (the product shows top-3, D6). The frozen benchmark stays the original Saraga
|
| 8 |
+
test tracks so numbers compare across features/data.
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from . import data, features
|
| 17 |
+
from .config import TOP_K
|
| 18 |
+
from .model import RaagaXGB
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def main() -> None:
|
| 22 |
+
ap = argparse.ArgumentParser(description="Score the raaga model on the frozen benchmark.")
|
| 23 |
+
ap.add_argument("--model", required=True)
|
| 24 |
+
ap.add_argument("--datasets", nargs="+", default=["saraga_carnatic"])
|
| 25 |
+
ap.add_argument("--max-windows", type=int, default=None)
|
| 26 |
+
args = ap.parse_args()
|
| 27 |
+
|
| 28 |
+
model = RaagaXGB.load(args.model)
|
| 29 |
+
frozen = data.load_frozen_test()
|
| 30 |
+
if frozen is None:
|
| 31 |
+
raise SystemExit("No frozen benchmark yet — run `python -m raaga_id.train` first "
|
| 32 |
+
"(it freezes benchmark/test_track_ids.json).")
|
| 33 |
+
|
| 34 |
+
top1 = top3 = n = 0
|
| 35 |
+
for pc in data.iter_pitch_clips(only_vocab=True, datasets=tuple(args.datasets)):
|
| 36 |
+
if pc.track_id not in frozen:
|
| 37 |
+
continue
|
| 38 |
+
wins = features.model_windows(pc.times, pc.freqs, pc.tonic_hz, max_windows=args.max_windows)
|
| 39 |
+
if not wins:
|
| 40 |
+
continue
|
| 41 |
+
names = [p.raaga for p in model.aggregate_top_k(np.vstack(wins), k=TOP_K)]
|
| 42 |
+
n += 1
|
| 43 |
+
top1 += int(names[0] == pc.raaga)
|
| 44 |
+
top3 += int(pc.raaga in names)
|
| 45 |
+
|
| 46 |
+
if n == 0:
|
| 47 |
+
raise SystemExit("no evaluation tracks matched the frozen split — check the data.")
|
| 48 |
+
print(f"n={n} tracks top1={top1 / n:.3f} top{TOP_K}={top3 / n:.3f}")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
if __name__ == "__main__":
|
| 52 |
+
main()
|
raaga_id/features.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Feature extraction.
|
| 2 |
+
|
| 3 |
+
Modeling ladder (D16): tonic-normalized pitch-class/swara histogram + XGBoost floor
|
| 4 |
+
-> TDMS -> CNN on mel/CQT. The floor (D16 step 1) is a librosa chroma histogram rolled
|
| 5 |
+
so the tonic (Sa) sits at bin 0 (D5). The tonic comes from Saraga's ctonic annotation
|
| 6 |
+
when available (precise), else a drone-argmax estimate; essentia TonicIndianArtMusic is
|
| 7 |
+
the inference-time upgrade for unlabelled clips (Phase 2).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from .config import CLIP_SECONDS, MIN_CLIP_SECONDS, MIN_VOICED_FRAC, SAMPLE_RATE
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _voiced_fraction(seg) -> float:
|
| 17 |
+
"""Fraction of a window's predominant-melody frames that carry a pitch (f0>0). Low for
|
| 18 |
+
percussion/speech/applause/silence (the junk gate, D6/D8); high for real melody."""
|
| 19 |
+
seg = np.asarray(seg, dtype=float)
|
| 20 |
+
return float((seg > 0).mean()) if seg.size else 0.0
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def load_audio(path: str, sr: int = SAMPLE_RATE, duration: float | None = None) -> np.ndarray:
|
| 24 |
+
"""Decode any format to mono float32 at `sr` (ffmpeg handles mp3/m4a).
|
| 25 |
+
|
| 26 |
+
`duration` caps the decode length — Saraga tracks run 20-30 min, so decoding the
|
| 27 |
+
whole file to use only the first N windows wastes most of the work.
|
| 28 |
+
"""
|
| 29 |
+
import librosa
|
| 30 |
+
|
| 31 |
+
y, _ = librosa.load(path, sr=sr, mono=True, duration=duration)
|
| 32 |
+
return y.astype(np.float32)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def estimate_tonic_pc(y: np.ndarray, sr: int = SAMPLE_RATE) -> int:
|
| 36 |
+
"""Cheap tonic (Sa) estimate: the most energetic pitch class over the clip.
|
| 37 |
+
|
| 38 |
+
Carnatic performances carry a continuous tanpura drone on Sa, so the summed
|
| 39 |
+
chromagram peaks at the tonic. This is the librosa-only stand-in for the precise
|
| 40 |
+
essentia `TonicIndianArtMusic` used in Phase 2 (D5).
|
| 41 |
+
"""
|
| 42 |
+
import librosa
|
| 43 |
+
|
| 44 |
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
| 45 |
+
return int(chroma.mean(axis=1).argmax())
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def tonic_pc_from_hz(hz: float) -> int:
|
| 49 |
+
"""Map a tonic frequency (Hz) to its chroma pitch class (0-11). Used with a
|
| 50 |
+
precise tonic — Saraga's ctonic annotation, or essentia at inference."""
|
| 51 |
+
import librosa
|
| 52 |
+
|
| 53 |
+
return int(round(librosa.hz_to_midi(hz))) % 12
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# 12 semitone positions relative to Sa (common Carnatic labels for the swara-sthanas).
|
| 57 |
+
SWARA_LABELS = ["S", "R1", "R2", "G2", "G3", "M1", "M2", "P", "D1", "D2", "N2", "N3"]
|
| 58 |
+
|
| 59 |
+
# The seven swaras every learner knows. For the "how to hear this raaga" panel we fold the 12
|
| 60 |
+
# chromatic positions down to these names (sa ri ga ma pa da ni) — the beginner's vocabulary,
|
| 61 |
+
# not the R1/R2/G2/G3 sthana subscripts. Each name groups its sthana variants.
|
| 62 |
+
SWARA7 = ["Sa", "Ri", "Ga", "Ma", "Pa", "Da", "Ni"]
|
| 63 |
+
_SWARA7_GROUPS = [[0], [1, 2], [3, 4], [5, 6], [7], [8, 9], [10, 11]] # indices into SWARA_LABELS
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def to_swaras7(profile):
|
| 67 |
+
"""Fold a 12-position swara profile to the seven named swaras (SWARA7), summing each swara's
|
| 68 |
+
sthana variants. Returns (names, values). The friendly display unit for new learners."""
|
| 69 |
+
p = np.asarray(profile, dtype=float)
|
| 70 |
+
vals = np.array([p[g].sum() for g in _SWARA7_GROUPS])
|
| 71 |
+
return SWARA7, vals
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def pcd_to_swaras(pcd) -> np.ndarray:
|
| 75 |
+
"""Fold a fine PCD (n_bins, a multiple of 12) to a 12-position swara profile
|
| 76 |
+
(one bin per semitone), normalized — the human-readable raaga fingerprint."""
|
| 77 |
+
a = np.asarray(pcd, dtype=float)
|
| 78 |
+
a = a.reshape(12, a.size // 12).sum(axis=1)
|
| 79 |
+
return a / (a.sum() + 1e-9)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def pitch_class_histogram(f0_hz, tonic_hz: float, n_bins: int = 120) -> np.ndarray:
|
| 83 |
+
"""Tonic-normalized pitch-class distribution (PCD) from a predominant-melody pitch
|
| 84 |
+
track — the classic raaga fingerprint (D16). Each voiced f0 becomes cents relative
|
| 85 |
+
to Sa, folded to one octave, binned and normalized; unvoiced frames (f0<=0) are
|
| 86 |
+
ignored. Works on any (pitch, tonic) source, so Saraga and IAMRRD pool cleanly.
|
| 87 |
+
Returns zeros when nothing is voiced.
|
| 88 |
+
"""
|
| 89 |
+
f0 = np.asarray(f0_hz, dtype=float)
|
| 90 |
+
voiced = f0[f0 > 0]
|
| 91 |
+
if voiced.size == 0:
|
| 92 |
+
return np.zeros(n_bins, dtype=np.float32)
|
| 93 |
+
cents = 1200.0 * np.log2(voiced / tonic_hz)
|
| 94 |
+
bins = np.mod((np.mod(cents, 1200.0) / (1200.0 / n_bins)).astype(int), n_bins)
|
| 95 |
+
hist = np.bincount(bins, minlength=n_bins).astype(np.float32)
|
| 96 |
+
return hist / hist.sum()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def tdms(times, f0_hz, tonic_hz: float, delay: float = 0.3, n_bins: int = 48) -> np.ndarray:
|
| 100 |
+
"""Time-Delayed Melody Surface (Gulati et al., ISMIR 2016) — the gamaka feature (refines
|
| 101 |
+
D16). A 2-D histogram of (pitch(t), pitch(t+delay)) over the tonic-normalized, octave-folded
|
| 102 |
+
melody: a held note sits on the diagonal, while gamaka/transitions smear off-diagonal in a
|
| 103 |
+
raaga-characteristic way. This keeps the *movement* the 1-D PCD discards — the information
|
| 104 |
+
that should separate allied raagas (Mōhanaṁ/Bilahari/Bēgaḍa) which share a scale.
|
| 105 |
+
|
| 106 |
+
Returns a flat (n_bins*n_bins,) surface normalized to sum 1, or zeros when too few voiced
|
| 107 |
+
pairs. `delay` is in seconds; the frame hop is inferred from `times`.
|
| 108 |
+
"""
|
| 109 |
+
times = np.asarray(times, dtype=float)
|
| 110 |
+
f0 = np.asarray(f0_hz, dtype=float)
|
| 111 |
+
zeros = np.zeros(n_bins * n_bins, dtype=np.float32)
|
| 112 |
+
if times.size < 2:
|
| 113 |
+
return zeros
|
| 114 |
+
voiced = f0 > 0
|
| 115 |
+
cents = np.zeros_like(f0)
|
| 116 |
+
cents[voiced] = np.mod(1200.0 * np.log2(f0[voiced] / tonic_hz), 1200.0)
|
| 117 |
+
bins = np.mod((cents / (1200.0 / n_bins)).astype(int), n_bins)
|
| 118 |
+
|
| 119 |
+
hop = float(np.median(np.diff(times)))
|
| 120 |
+
d = max(1, int(round(delay / hop)))
|
| 121 |
+
if d >= bins.size:
|
| 122 |
+
return zeros
|
| 123 |
+
a, b = bins[:-d], bins[d:]
|
| 124 |
+
both = voiced[:-d] & voiced[d:] # count a pair only if both endpoints are voiced
|
| 125 |
+
a, b = a[both], b[both]
|
| 126 |
+
if a.size == 0:
|
| 127 |
+
return zeros
|
| 128 |
+
surf = np.zeros((n_bins, n_bins), dtype=np.float64)
|
| 129 |
+
np.add.at(surf, (a, b), 1.0)
|
| 130 |
+
return (surf / surf.sum()).astype(np.float32).ravel()
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def tdms_windows(
|
| 134 |
+
times,
|
| 135 |
+
f0_hz,
|
| 136 |
+
tonic_hz: float,
|
| 137 |
+
window_s: float = CLIP_SECONDS,
|
| 138 |
+
hop_s: float = CLIP_SECONDS,
|
| 139 |
+
max_windows: int | None = None,
|
| 140 |
+
delay: float = 0.3,
|
| 141 |
+
n_bins: int = 48,
|
| 142 |
+
min_voiced: float = MIN_VOICED_FRAC,
|
| 143 |
+
) -> list[np.ndarray]:
|
| 144 |
+
"""Slide a window over a predominant-melody pitch track -> one TDMS surface per window
|
| 145 |
+
(the delay-surface analog of pitch_windows, D28). Slices identically to pitch_windows so
|
| 146 |
+
surfaces aggregate exactly like PCD windows. Windows voiced less than `min_voiced` of the
|
| 147 |
+
time are dropped (junk gate, D6/D8 — percussion/speech/silence, no stable melody to track).
|
| 148 |
+
"""
|
| 149 |
+
times = np.asarray(times, dtype=float)
|
| 150 |
+
f0 = np.asarray(f0_hz, dtype=float)
|
| 151 |
+
if times.size == 0 or times[-1] < MIN_CLIP_SECONDS:
|
| 152 |
+
return []
|
| 153 |
+
end = float(times[-1])
|
| 154 |
+
out: list[np.ndarray] = []
|
| 155 |
+
start = 0.0
|
| 156 |
+
while start < end:
|
| 157 |
+
if min(start + window_s, end) - start < MIN_CLIP_SECONDS:
|
| 158 |
+
break
|
| 159 |
+
mask = (times >= start) & (times < start + window_s)
|
| 160 |
+
if _voiced_fraction(f0[mask]) >= min_voiced:
|
| 161 |
+
surf = tdms(times[mask], f0[mask], tonic_hz, delay=delay, n_bins=n_bins)
|
| 162 |
+
if surf.sum() > 0:
|
| 163 |
+
out.append(surf)
|
| 164 |
+
if max_windows and len(out) >= max_windows:
|
| 165 |
+
break
|
| 166 |
+
start += hop_s
|
| 167 |
+
return out
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def model_windows(times, f0_hz, tonic_hz: float, max_windows: int | None = None,
|
| 171 |
+
hop_s: float | None = None) -> list[np.ndarray]:
|
| 172 |
+
"""THE production model feature (D28): windowed TDMS at the config's winning settings.
|
| 173 |
+
train / evaluate / calibrate / inference all call this one function, so the feature can
|
| 174 |
+
never drift between how the model is trained and how it's served. `hop_s` overrides only the
|
| 175 |
+
stride — inference passes a smaller hop so a short clip yields more windows to average (each
|
| 176 |
+
window is still classified independently, so overlap is safe and doesn't change the feature).
|
| 177 |
+
"""
|
| 178 |
+
from .config import TDMS_BINS, TDMS_DELAY, TDMS_HOP_S, TDMS_MAX_WINDOWS, TDMS_WINDOW_S
|
| 179 |
+
|
| 180 |
+
return tdms_windows(times, f0_hz, tonic_hz, window_s=TDMS_WINDOW_S, hop_s=hop_s or TDMS_HOP_S,
|
| 181 |
+
max_windows=max_windows or TDMS_MAX_WINDOWS, delay=TDMS_DELAY, n_bins=TDMS_BINS)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def frame_vector(y: np.ndarray, sr: int = SAMPLE_RATE, tonic_pc: int = 0) -> np.ndarray:
|
| 185 |
+
"""Tonic-relative pitch-class descriptor for one window (D16 step 1).
|
| 186 |
+
|
| 187 |
+
Raaga lives in the pitch classes *relative to Sa*, so we roll the chromagram so
|
| 188 |
+
the tonic pitch class -> bin 0, then summarize as a normalized 12-bin pitch-class
|
| 189 |
+
histogram (the raaga fingerprint) + per-bin spread. Timbre features (MFCC,
|
| 190 |
+
spectral shape) are deliberately EXCLUDED: they encode recording/instrument
|
| 191 |
+
identity and make the model memorize tracks instead of learning raaga.
|
| 192 |
+
"""
|
| 193 |
+
import librosa
|
| 194 |
+
|
| 195 |
+
if y.size == 0:
|
| 196 |
+
raise ValueError("empty audio")
|
| 197 |
+
|
| 198 |
+
chroma = librosa.feature.chroma_cqt(y=y, sr=sr)
|
| 199 |
+
chroma = np.roll(chroma, -tonic_pc, axis=0) # tonic -> bin 0
|
| 200 |
+
hist = chroma.mean(axis=1)
|
| 201 |
+
hist = hist / (hist.sum() + 1e-8) # pitch-class distribution
|
| 202 |
+
return np.concatenate([hist, chroma.std(axis=1)]).astype(np.float32) # 24-dim
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def window_vectors(
|
| 206 |
+
y: np.ndarray,
|
| 207 |
+
sr: int = SAMPLE_RATE,
|
| 208 |
+
window_s: float = CLIP_SECONDS,
|
| 209 |
+
hop_s: float = CLIP_SECONDS,
|
| 210 |
+
max_windows: int | None = None,
|
| 211 |
+
tonic_hz: float | None = None,
|
| 212 |
+
) -> list[np.ndarray]:
|
| 213 |
+
"""Slide a `window_s` window over a (long) clip -> one frame vector per window.
|
| 214 |
+
|
| 215 |
+
Saraga tracks are full concert recordings, so one vector per track would be a
|
| 216 |
+
single over-smoothed sample. Windowing (D7: 10 s) turns each recording into many
|
| 217 |
+
training examples. Tail segments shorter than MIN_CLIP_SECONDS are dropped;
|
| 218 |
+
hop_s == window_s gives non-overlapping windows (the training default).
|
| 219 |
+
"""
|
| 220 |
+
win = int(round(window_s * sr))
|
| 221 |
+
hop = max(1, int(round(hop_s * sr)))
|
| 222 |
+
min_len = int(round(MIN_CLIP_SECONDS * sr))
|
| 223 |
+
if len(y) < min_len:
|
| 224 |
+
return []
|
| 225 |
+
if max_windows: # only look at the span we'll use (fast tonic estimate on long uploads)
|
| 226 |
+
y = y[: win + hop * (max_windows - 1)]
|
| 227 |
+
# Prefer a precise tonic (Saraga ctonic) when given; else the drone-argmax heuristic.
|
| 228 |
+
tonic_pc = tonic_pc_from_hz(tonic_hz) if tonic_hz else estimate_tonic_pc(y, sr)
|
| 229 |
+
out: list[np.ndarray] = []
|
| 230 |
+
start = 0
|
| 231 |
+
while start < len(y):
|
| 232 |
+
seg = y[start : start + win]
|
| 233 |
+
if len(seg) < min_len:
|
| 234 |
+
break
|
| 235 |
+
out.append(frame_vector(seg, sr, tonic_pc))
|
| 236 |
+
if max_windows and len(out) >= max_windows:
|
| 237 |
+
break
|
| 238 |
+
start += hop
|
| 239 |
+
return out
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def pitch_windows(
|
| 243 |
+
times,
|
| 244 |
+
f0_hz,
|
| 245 |
+
tonic_hz: float,
|
| 246 |
+
window_s: float = CLIP_SECONDS,
|
| 247 |
+
hop_s: float = CLIP_SECONDS,
|
| 248 |
+
max_windows: int | None = None,
|
| 249 |
+
n_bins: int = 120,
|
| 250 |
+
min_voiced: float = MIN_VOICED_FRAC,
|
| 251 |
+
) -> list[np.ndarray]:
|
| 252 |
+
"""Slide a window over a predominant-melody pitch track -> one tonic-normalized PCD
|
| 253 |
+
per window (the pitch-track analog of window_vectors, D7). Windows that are voiced less
|
| 254 |
+
than `min_voiced` of the time are dropped (junk gate, D6/D8 — percussion/speech/silence);
|
| 255 |
+
a trailing window >= MIN_CLIP_SECONDS is kept. Works on any (pitch, tonic) source.
|
| 256 |
+
"""
|
| 257 |
+
times = np.asarray(times, dtype=float)
|
| 258 |
+
f0 = np.asarray(f0_hz, dtype=float)
|
| 259 |
+
if times.size == 0 or times[-1] < MIN_CLIP_SECONDS:
|
| 260 |
+
return []
|
| 261 |
+
end = float(times[-1])
|
| 262 |
+
out: list[np.ndarray] = []
|
| 263 |
+
start = 0.0
|
| 264 |
+
while start < end:
|
| 265 |
+
if min(start + window_s, end) - start < MIN_CLIP_SECONDS:
|
| 266 |
+
break
|
| 267 |
+
seg = f0[(times >= start) & (times < start + window_s)]
|
| 268 |
+
if _voiced_fraction(seg) >= min_voiced:
|
| 269 |
+
hist = pitch_class_histogram(seg, tonic_hz, n_bins)
|
| 270 |
+
if hist.sum() > 0:
|
| 271 |
+
out.append(hist)
|
| 272 |
+
if max_windows and len(out) >= max_windows:
|
| 273 |
+
break
|
| 274 |
+
start += hop_s
|
| 275 |
+
return out
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def extract_windows(
|
| 279 |
+
path: str,
|
| 280 |
+
sr: int = SAMPLE_RATE,
|
| 281 |
+
max_windows: int | None = None,
|
| 282 |
+
tonic_hz: float | None = None,
|
| 283 |
+
) -> list[np.ndarray]:
|
| 284 |
+
"""Load a (long) recording and return one frame vector per 10 s window.
|
| 285 |
+
Pass tonic_hz (Saraga ctonic) for a precise tonic; otherwise it's estimated."""
|
| 286 |
+
duration = None if max_windows is None else max_windows * CLIP_SECONDS + 1.0
|
| 287 |
+
y = load_audio(path, sr, duration=duration)
|
| 288 |
+
return window_vectors(y, sr, max_windows=max_windows, tonic_hz=tonic_hz)
|
raaga_id/learn.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""'How to hear this raaga' — the data-derived learner layer (PRD §16.1).
|
| 2 |
+
|
| 3 |
+
Reference swara profiles are computed from the corpus (tools/build_raaga_profiles.py);
|
| 4 |
+
the summary is auto-generated from the pitch data (safe). The curated guide
|
| 5 |
+
(arohana/avarohana/pakad/contrasts) is EXPERT-vetted content loaded from
|
| 6 |
+
raaga_guide.json — never auto-generated here, because wrong musicology is worse than
|
| 7 |
+
none. All loaders degrade gracefully when the files are absent.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from functools import lru_cache
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from .config import ROOT
|
| 17 |
+
from .features import to_swaras7
|
| 18 |
+
|
| 19 |
+
PROFILES_PATH = ROOT / "raaga_profiles.json"
|
| 20 |
+
GUIDE_PATH = ROOT / "raaga_guide.json"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@lru_cache(maxsize=1)
|
| 24 |
+
def _profiles() -> dict:
|
| 25 |
+
return json.loads(PROFILES_PATH.read_text()) if PROFILES_PATH.exists() else {}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@lru_cache(maxsize=1)
|
| 29 |
+
def _guide() -> dict:
|
| 30 |
+
return json.loads(GUIDE_PATH.read_text()) if GUIDE_PATH.exists() else {}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def reference_profile(raaga: str):
|
| 34 |
+
"""The raaga's average 12-position swara profile, or None if not built yet."""
|
| 35 |
+
p = _profiles().get(raaga)
|
| 36 |
+
return np.asarray(p, dtype=float) if p else None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def top_swaras(profile, k: int = 4) -> list[str]:
|
| 40 |
+
"""The k swaras the raaga rests on most, by their everyday names (Sa Ri Ga Ma Pa Da Ni),
|
| 41 |
+
dropping ones it barely touches."""
|
| 42 |
+
names, vals = to_swaras7(profile)
|
| 43 |
+
order = np.argsort(vals)[::-1][:k]
|
| 44 |
+
return [names[i] for i in order if vals[i] > 0.03]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def guide(raaga: str) -> dict:
|
| 48 |
+
"""Curated per-raaga notes, empty fields dropped ({} until an expert fills them in)."""
|
| 49 |
+
return {k: v for k, v in _guide().get(raaga, {}).items() if v}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# --- Allied-raaga comparison (D29 Explorer): how to tell two close-call raagas apart, from their
|
| 53 |
+
# swara SETS (which of the 12 notes each uses — a documented fact). Seed sets live in
|
| 54 |
+
# raaga_guide.json["swaras"]; empty until seeded/expert-filled. Beginner-friendly note names below;
|
| 55 |
+
# a variety qualifier is only added when two raagas share a base note but differ in its variety.
|
| 56 |
+
from .features import SWARA_LABELS # noqa: E402
|
| 57 |
+
|
| 58 |
+
_BASE = {"S": "sa", "R1": "ri", "R2": "ri", "G2": "ga", "G3": "ga", "M1": "ma", "M2": "ma",
|
| 59 |
+
"P": "pa", "D1": "da", "D2": "da", "N2": "ni", "N3": "ni"}
|
| 60 |
+
_VARIETY = {"R1": "the lower ri", "R2": "ri", "G2": "the lower ga", "G3": "ga",
|
| 61 |
+
"M1": "the natural ma", "M2": "the sharp ma (prati-madhyama)", "D1": "the lower da",
|
| 62 |
+
"D2": "da", "N2": "ni", "N3": "the sharp ni"}
|
| 63 |
+
_SEED_OVERRIDE = None # tests inject swara sets here without touching the guide file
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def swaras(raaga: str):
|
| 67 |
+
"""The raaga's swara set (12-position labels), or None if not seeded/filled."""
|
| 68 |
+
if _SEED_OVERRIDE is not None and raaga in _SEED_OVERRIDE:
|
| 69 |
+
return _SEED_OVERRIDE[raaga]
|
| 70 |
+
s = _guide().get(raaga, {}).get("swaras")
|
| 71 |
+
return s or None
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def distinguish(a: str, b: str):
|
| 75 |
+
"""Set-difference of two raagas' swaras, or None if either lacks data. Returns which notes
|
| 76 |
+
each has that the other doesn't (ordered), and whether the sets are identical."""
|
| 77 |
+
sa, sb = swaras(a), swaras(b)
|
| 78 |
+
if not sa or not sb:
|
| 79 |
+
return None
|
| 80 |
+
order = {lab: i for i, lab in enumerate(SWARA_LABELS)}
|
| 81 |
+
A, B = set(sa), set(sb)
|
| 82 |
+
return {"a_only": sorted(A - B, key=lambda x: order.get(x, 99)),
|
| 83 |
+
"b_only": sorted(B - A, key=lambda x: order.get(x, 99)),
|
| 84 |
+
"same": A == B}
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
_ORDER7 = ["sa", "ri", "ga", "ma", "pa", "da", "ni"]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _join(names) -> str:
|
| 91 |
+
names = list(dict.fromkeys(names)) # dedupe, keep order
|
| 92 |
+
if len(names) <= 2:
|
| 93 |
+
return " and ".join(names)
|
| 94 |
+
return ", ".join(names[:-1]) + " and " + names[-1]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _variety_desc(labels) -> str:
|
| 98 |
+
"""Describe the varieties of ONE base note a raaga uses (e.g. both dhaivatas, or the sharp ni)."""
|
| 99 |
+
return f"both {_BASE[labels[0]]}s" if len(labels) >= 2 else _VARIETY[labels[0]]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def comparison_md(a: str, b: str) -> str:
|
| 103 |
+
"""A plain-language 'how to tell them apart' line for a close call, or '' if no data.
|
| 104 |
+
Compares base note by base note so it stays correct for bhashanga raagas that use *both*
|
| 105 |
+
varieties of a note (e.g. Bhairavi's two dhaivatas)."""
|
| 106 |
+
sa, sb = swaras(a), swaras(b)
|
| 107 |
+
if not sa or not sb:
|
| 108 |
+
return ""
|
| 109 |
+
A, B = set(sa), set(sb)
|
| 110 |
+
if A == B:
|
| 111 |
+
return (f"**Telling {a} from {b}:** they use the **same notes** — the difference is in the "
|
| 112 |
+
f"*gamaka* (how each note is shaken and slid) and the phrasing, not the scale. "
|
| 113 |
+
f"(Phrase guidance coming soon.)")
|
| 114 |
+
a_extra, b_extra, variety = [], [], []
|
| 115 |
+
for base in _ORDER7:
|
| 116 |
+
av = [x for x in A if _BASE[x] == base]
|
| 117 |
+
bv = [x for x in B if _BASE[x] == base]
|
| 118 |
+
if set(av) == set(bv):
|
| 119 |
+
continue
|
| 120 |
+
if av and not bv:
|
| 121 |
+
a_extra.append(base) # a has this note entirely, b doesn't
|
| 122 |
+
elif bv and not av:
|
| 123 |
+
b_extra.append(base)
|
| 124 |
+
else: # both have the note but in different varieties
|
| 125 |
+
variety.append((base, av, bv))
|
| 126 |
+
parts = [f"the **{base}** differs — {a} uses {_variety_desc(av)}, {b} uses {_variety_desc(bv)}"
|
| 127 |
+
for base, av, bv in variety]
|
| 128 |
+
if b_extra:
|
| 129 |
+
parts.append(f"{b} uses **{_join(b_extra)}**, which {a} leaves out")
|
| 130 |
+
if a_extra:
|
| 131 |
+
parts.append(f"{a} uses **{_join(a_extra)}**, which {b} leaves out")
|
| 132 |
+
return f"**Telling {a} from {b}:** " + "; ".join(parts) + ". Listen for those notes."
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def summary_md(raaga: str, user_profile=None) -> str:
|
| 136 |
+
"""A warm, plain-language 'how to hear this raaga' note for someone new to Carnatic music —
|
| 137 |
+
which of the seven swaras the raaga rests on, what their clip leaned on, and (when an expert
|
| 138 |
+
has filled it in) the raaga's ascent/descent and its telltale phrase."""
|
| 139 |
+
lines = [f"### How to hear **{raaga}**",
|
| 140 |
+
"Every raaga is a way of moving through the seven swaras — "
|
| 141 |
+
"**sa · ri · ga · ma · pa · da · ni**. Here's what to listen for."]
|
| 142 |
+
# NB: we do NOT auto-state which swaras the *raaga* uses — the data profiles are gamaka-
|
| 143 |
+
# smeared (ornaments glide pitch across neighbours), so that claim can be wrong. The raaga's
|
| 144 |
+
# actual notes belong to the expert guide below. We only describe the user's own clip.
|
| 145 |
+
if user_profile is not None:
|
| 146 |
+
lines.append(f"In your clip, the melody sat mostly on **{' · '.join(top_swaras(user_profile))}** — "
|
| 147 |
+
"the notes it kept returning to.")
|
| 148 |
+
|
| 149 |
+
g = guide(raaga)
|
| 150 |
+
if g:
|
| 151 |
+
for label, key in (("Going up", "arohana"), ("Coming down", "avarohana"),
|
| 152 |
+
("Its signature phrase", "pakad"), ("Listen for", "listen_for"),
|
| 153 |
+
("Easy to mix up with", "vs")):
|
| 154 |
+
if g.get(key):
|
| 155 |
+
lines.append(f"- **{label}:** {g[key]}")
|
| 156 |
+
else:
|
| 157 |
+
lines.append("_More on the way — how the raaga rises and falls, its signature phrase, "
|
| 158 |
+
"and the raagas it's easy to mix up with (written by musicians, not guessed)._")
|
| 159 |
+
return "\n\n".join(lines)
|
raaga_id/model.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Model: architecture + load/save (PRD §17).
|
| 2 |
+
|
| 3 |
+
Framework is PyTorch (D15). The v0 floor (D16 step 1) is an XGBoost classifier over
|
| 4 |
+
the fixed-length frame vector — cheap, strong, trains in minutes on CPU. The CNN /
|
| 5 |
+
TDMS / transformer rungs come later; `RaagaCNN` is a stub marking that seam.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
from dataclasses import dataclass
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
from .config import MODELS_DIR, TOP_K
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class Prediction:
|
| 20 |
+
raaga: str
|
| 21 |
+
confidence: float
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class RaagaXGB:
|
| 25 |
+
"""XGBoost floor over frame vectors. Wraps label<->index + top-k decode."""
|
| 26 |
+
|
| 27 |
+
def __init__(self, classes: list[str], booster=None, temperature: float = 1.0):
|
| 28 |
+
self.classes = list(classes)
|
| 29 |
+
self._booster = booster
|
| 30 |
+
self.temperature = temperature # calibration (D25); 1.0 = uncalibrated identity
|
| 31 |
+
|
| 32 |
+
def fit(self, X: np.ndarray, y_idx: np.ndarray, **kwargs):
|
| 33 |
+
from xgboost import XGBClassifier
|
| 34 |
+
|
| 35 |
+
self._booster = XGBClassifier(
|
| 36 |
+
objective="multi:softprob",
|
| 37 |
+
num_class=len(self.classes),
|
| 38 |
+
n_estimators=kwargs.pop("n_estimators", 400),
|
| 39 |
+
max_depth=kwargs.pop("max_depth", 6),
|
| 40 |
+
learning_rate=kwargs.pop("learning_rate", 0.1),
|
| 41 |
+
n_jobs=-1,
|
| 42 |
+
**kwargs,
|
| 43 |
+
)
|
| 44 |
+
self._booster.fit(X, y_idx)
|
| 45 |
+
return self
|
| 46 |
+
|
| 47 |
+
def predict_proba(self, X: np.ndarray) -> np.ndarray:
|
| 48 |
+
if self._booster is None:
|
| 49 |
+
raise RuntimeError("model is not trained/loaded")
|
| 50 |
+
return self._booster.predict_proba(X)
|
| 51 |
+
|
| 52 |
+
def top_k(self, x: np.ndarray, k: int = TOP_K) -> list[Prediction]:
|
| 53 |
+
"""Top-k raagas + confidence for one frame vector (D6: always top-3)."""
|
| 54 |
+
proba = self.predict_proba(x.reshape(1, -1))[0]
|
| 55 |
+
return self._decode(proba, k)
|
| 56 |
+
|
| 57 |
+
def aggregate_top_k(self, X: np.ndarray, k: int = TOP_K) -> list[Prediction]:
|
| 58 |
+
"""Top-k for a clip given its per-window vectors: mean the window
|
| 59 |
+
probabilities, then decode (D7: aggregate predictions across a long clip)."""
|
| 60 |
+
X = np.atleast_2d(X)
|
| 61 |
+
proba = self.predict_proba(X).mean(axis=0)
|
| 62 |
+
return self._decode(proba, k)
|
| 63 |
+
|
| 64 |
+
def _decode(self, proba: np.ndarray, k: int) -> list[Prediction]:
|
| 65 |
+
from .calibrate import apply_temperature
|
| 66 |
+
proba = apply_temperature(proba, self.temperature) # D25; argmax-preserving
|
| 67 |
+
order = np.argsort(proba)[::-1][:k]
|
| 68 |
+
return [Prediction(self.classes[i], float(proba[i])) for i in order]
|
| 69 |
+
|
| 70 |
+
def save(self, path: str | Path | None = None) -> Path:
|
| 71 |
+
path = Path(path) if path else MODELS_DIR / "raaga_xgb.json"
|
| 72 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
self._booster.save_model(str(path))
|
| 74 |
+
path.with_suffix(".classes.json").write_text(json.dumps(self.classes))
|
| 75 |
+
return path
|
| 76 |
+
|
| 77 |
+
@classmethod
|
| 78 |
+
def load(cls, path: str | Path) -> "RaagaXGB":
|
| 79 |
+
from xgboost import XGBClassifier
|
| 80 |
+
|
| 81 |
+
from .calibrate import load_temperature
|
| 82 |
+
|
| 83 |
+
path = Path(path)
|
| 84 |
+
booster = XGBClassifier()
|
| 85 |
+
booster.load_model(str(path))
|
| 86 |
+
classes = json.loads(path.with_suffix(".classes.json").read_text())
|
| 87 |
+
return cls(classes, booster, temperature=load_temperature(path))
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class RaagaCNN:
|
| 91 |
+
"""PHASE 2 SEAM (D16). CNN on tonic-normalized mel/CQT, in torch/Lightning.
|
| 92 |
+
Benchmark target: compIAM `DEEPSRGM`."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, *_, **__):
|
| 95 |
+
raise NotImplementedError("CNN rung — implement after the XGBoost floor lands")
|
raaga_id/pitch_extract.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Inference-time audio -> model feature windows.
|
| 2 |
+
|
| 3 |
+
Extracts the predominant-melody pitch (essentia PredominantPitchMelodia) and the tonic
|
| 4 |
+
(compiam TonicIndianMultiPitch = the Salamon/Gulati multipitch method, which exploits the
|
| 5 |
+
drone), then builds windowed TDMS (D28) via features.model_windows — the SAME feature the
|
| 6 |
+
model trains on. This is the audio->feature path for a RAW upload; the annotated-corpus path
|
| 7 |
+
(data.iter_pitch_clips) uses each dataset's own pitch+tonic instead.
|
| 8 |
+
|
| 9 |
+
REQUIRES the numpy<2 inference env (environment-inference.yml): essentia's compute is
|
| 10 |
+
broken under numpy 2.x. essentia/compiam are imported lazily so this module still imports
|
| 11 |
+
(harmlessly) in the numpy-2.x training env.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
from . import features
|
| 18 |
+
from .config import INFER_MAX_WINDOWS, INFER_SECONDS, MIN_CLIP_SECONDS, PCD_BINS, TDMS_HOP_S
|
| 19 |
+
|
| 20 |
+
TONIC_SR = 44100 # essentia's native rate for pitch + tonic
|
| 21 |
+
MELODIA_HOP = 128
|
| 22 |
+
|
| 23 |
+
_MELODIA = None
|
| 24 |
+
_TONIC = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _extractors():
|
| 28 |
+
"""Import + instantiate the essentia/compiam extractors ONCE, then reuse. Importing
|
| 29 |
+
compiam is the slow (~tens of s) one-time cost; instantiating and reusing the
|
| 30 |
+
algorithms keeps every identify ~3.5s. Call warmup() at startup to pay it upfront."""
|
| 31 |
+
global _MELODIA, _TONIC
|
| 32 |
+
if _MELODIA is None:
|
| 33 |
+
from compiam.melody.tonic_identification.tonic_multipitch import TonicIndianMultiPitch
|
| 34 |
+
from essentia.standard import PredominantPitchMelodia
|
| 35 |
+
|
| 36 |
+
_MELODIA = PredominantPitchMelodia(hopSize=MELODIA_HOP, sampleRate=TONIC_SR)
|
| 37 |
+
_TONIC = TonicIndianMultiPitch()
|
| 38 |
+
return _MELODIA, _TONIC
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def warmup() -> None:
|
| 42 |
+
"""Pre-load the extractors so the first identify isn't slow (call at demo startup)."""
|
| 43 |
+
_extractors()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def audio_to_features(audio, sr, max_seconds: float = INFER_SECONDS, max_windows: int = INFER_MAX_WINDOWS):
|
| 47 |
+
"""Raw mono audio -> (list of model-feature windows, tonic_hz, analysed_seconds, display_pcd).
|
| 48 |
+
|
| 49 |
+
The model windows are windowed TDMS (D28), IDENTICAL to training via features.model_windows.
|
| 50 |
+
display_pcd is the tonic-normalized pitch-class distribution over the whole analysed span —
|
| 51 |
+
used ONLY for the learner panel's human-readable swara fingerprint, never by the model.
|
| 52 |
+
analysed_seconds is how much audio (from 0:00) was actually fed to the model — capped at
|
| 53 |
+
max_seconds — so the UI can tell the user exactly what it heard (D24: legible recognition).
|
| 54 |
+
Returns ([], None, 0.0, None) when the tonic or pitch can't be estimated (no clear melody/drone).
|
| 55 |
+
"""
|
| 56 |
+
import librosa
|
| 57 |
+
|
| 58 |
+
melodia, tonic_algo = _extractors()
|
| 59 |
+
y = np.asarray(audio, dtype=np.float32)
|
| 60 |
+
if y.ndim > 1:
|
| 61 |
+
y = y.mean(axis=1).astype(np.float32)
|
| 62 |
+
if sr != TONIC_SR:
|
| 63 |
+
y = librosa.resample(y, orig_sr=sr, target_sr=TONIC_SR).astype(np.float32)
|
| 64 |
+
if max_seconds:
|
| 65 |
+
y = y[: int(max_seconds * TONIC_SR)]
|
| 66 |
+
if y.size < int(MIN_CLIP_SECONDS * TONIC_SR):
|
| 67 |
+
return [], None, 0.0, None
|
| 68 |
+
analysed_seconds = y.size / TONIC_SR
|
| 69 |
+
|
| 70 |
+
try:
|
| 71 |
+
tonic = float(tonic_algo.extract(y, input_sr=TONIC_SR))
|
| 72 |
+
except Exception: # noqa: BLE001 — essentia raises "No peak locations" on unclear audio
|
| 73 |
+
return [], None, 0.0, None
|
| 74 |
+
if not tonic or tonic <= 0:
|
| 75 |
+
return [], None, 0.0, None
|
| 76 |
+
|
| 77 |
+
f0, _ = melodia(y)
|
| 78 |
+
times = np.arange(len(f0)) * MELODIA_HOP / TONIC_SR
|
| 79 |
+
# Overlap windows at inference (hop < window) so a short clip still yields several to average.
|
| 80 |
+
windows = features.model_windows(times, f0, tonic, max_windows=max_windows, hop_s=TDMS_HOP_S / 2)
|
| 81 |
+
display_pcd = features.pitch_class_histogram(f0, tonic, n_bins=PCD_BINS)
|
| 82 |
+
return windows, tonic, analysed_seconds, display_pcd
|
raaga_id/train.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training entrypoint (PRD §17, build-order step 5).
|
| 2 |
+
|
| 3 |
+
python -m raaga_id.train [--datasets saraga_carnatic compmusic_raga]
|
| 4 |
+
|
| 5 |
+
Floor (D16): a tonic-normalized pitch-class distribution (PCD) over each recording's
|
| 6 |
+
predominant-melody pitch track, windowed (D7), classified by XGBoost. Split is BY
|
| 7 |
+
TRACK (frozen benchmark, no window leakage). Pools any datasets that expose pitch +
|
| 8 |
+
tonic (Saraga, IAMRRD).
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
from . import data, features
|
| 17 |
+
from .config import MODELS_DIR
|
| 18 |
+
from .model import RaagaXGB
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build_features(pclips, max_windows: int | None):
|
| 22 |
+
"""(X, y) of per-window model features (windowed TDMS, D28) labelled with track raaga."""
|
| 23 |
+
X, y = [], []
|
| 24 |
+
for pc in pclips:
|
| 25 |
+
wins = features.model_windows(pc.times, pc.freqs, pc.tonic_hz, max_windows=max_windows)
|
| 26 |
+
X.extend(wins)
|
| 27 |
+
y.extend([pc.raaga] * len(wins))
|
| 28 |
+
if not X:
|
| 29 |
+
raise SystemExit("No feature windows — is the dataset downloaded (pitch + tonic present)?")
|
| 30 |
+
return np.vstack(X), np.array(y)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def main() -> None:
|
| 34 |
+
ap = argparse.ArgumentParser(description="Train the raaga floor (windowed TDMS + XGBoost, D28).")
|
| 35 |
+
ap.add_argument("--out", default=str(MODELS_DIR / "raaga_xgb.json"))
|
| 36 |
+
ap.add_argument("--datasets", nargs="+", default=["saraga_carnatic"])
|
| 37 |
+
ap.add_argument("--max-windows", type=int, default=None, help="cap windows/track (default: config)")
|
| 38 |
+
ap.add_argument("--test-frac", type=float, default=0.25)
|
| 39 |
+
args = ap.parse_args()
|
| 40 |
+
|
| 41 |
+
pclips = list(data.iter_pitch_clips(only_vocab=True, datasets=tuple(args.datasets)))
|
| 42 |
+
if not pclips:
|
| 43 |
+
raise SystemExit("No labelled pitch clips — check datasets / raagas.json.")
|
| 44 |
+
|
| 45 |
+
train_ids, test_ids = data.split_by_track(pclips, test_frac=args.test_frac)
|
| 46 |
+
train_clips = [c for c in pclips if c.track_id in train_ids]
|
| 47 |
+
print(f"{len(pclips)} clips {args.datasets} -> {len(train_clips)} train / {len(test_ids)} test "
|
| 48 |
+
f"(frozen: {data.FROZEN_TEST_PATH})")
|
| 49 |
+
|
| 50 |
+
X, y = build_features(train_clips, args.max_windows)
|
| 51 |
+
classes = sorted(set(y))
|
| 52 |
+
idx = {c: i for i, c in enumerate(classes)}
|
| 53 |
+
print(f"training on {len(X)} TDMS windows ({X.shape[1]}d) across {len(classes)} raagas: {classes}")
|
| 54 |
+
model = RaagaXGB(classes).fit(X, np.array([idx[c] for c in y]))
|
| 55 |
+
path = model.save(args.out)
|
| 56 |
+
print(f"saved -> {path}\nnext: python -m raaga_id.evaluate --model {path}")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
if __name__ == "__main__":
|
| 60 |
+
main()
|
raaga_profiles.json
ADDED
|
@@ -0,0 +1,562 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"Suraṭi": [
|
| 3 |
+
0.1025,
|
| 4 |
+
0.0522,
|
| 5 |
+
0.0766,
|
| 6 |
+
0.0564,
|
| 7 |
+
0.0958,
|
| 8 |
+
0.0618,
|
| 9 |
+
0.0901,
|
| 10 |
+
0.1086,
|
| 11 |
+
0.0549,
|
| 12 |
+
0.1432,
|
| 13 |
+
0.073,
|
| 14 |
+
0.085
|
| 15 |
+
],
|
| 16 |
+
"Mōhanaṁ": [
|
| 17 |
+
0.1203,
|
| 18 |
+
0.0601,
|
| 19 |
+
0.0813,
|
| 20 |
+
0.132,
|
| 21 |
+
0.0673,
|
| 22 |
+
0.0254,
|
| 23 |
+
0.0832,
|
| 24 |
+
0.0966,
|
| 25 |
+
0.085,
|
| 26 |
+
0.0801,
|
| 27 |
+
0.0413,
|
| 28 |
+
0.1274
|
| 29 |
+
],
|
| 30 |
+
"Tōḍi": [
|
| 31 |
+
0.1896,
|
| 32 |
+
0.0846,
|
| 33 |
+
0.0536,
|
| 34 |
+
0.0359,
|
| 35 |
+
0.0745,
|
| 36 |
+
0.0641,
|
| 37 |
+
0.0638,
|
| 38 |
+
0.1383,
|
| 39 |
+
0.0753,
|
| 40 |
+
0.0561,
|
| 41 |
+
0.0466,
|
| 42 |
+
0.1174
|
| 43 |
+
],
|
| 44 |
+
"Kamās": [
|
| 45 |
+
0.1235,
|
| 46 |
+
0.0192,
|
| 47 |
+
0.0286,
|
| 48 |
+
0.0638,
|
| 49 |
+
0.098,
|
| 50 |
+
0.0415,
|
| 51 |
+
0.0559,
|
| 52 |
+
0.0908,
|
| 53 |
+
0.1025,
|
| 54 |
+
0.1915,
|
| 55 |
+
0.0742,
|
| 56 |
+
0.1104
|
| 57 |
+
],
|
| 58 |
+
"Bēgaḍa": [
|
| 59 |
+
0.1187,
|
| 60 |
+
0.0405,
|
| 61 |
+
0.0627,
|
| 62 |
+
0.137,
|
| 63 |
+
0.1023,
|
| 64 |
+
0.0351,
|
| 65 |
+
0.0818,
|
| 66 |
+
0.1246,
|
| 67 |
+
0.0629,
|
| 68 |
+
0.0783,
|
| 69 |
+
0.045,
|
| 70 |
+
0.1112
|
| 71 |
+
],
|
| 72 |
+
"Behāg": [
|
| 73 |
+
0.1082,
|
| 74 |
+
0.0361,
|
| 75 |
+
0.0431,
|
| 76 |
+
0.1159,
|
| 77 |
+
0.1326,
|
| 78 |
+
0.0608,
|
| 79 |
+
0.1094,
|
| 80 |
+
0.0902,
|
| 81 |
+
0.0408,
|
| 82 |
+
0.0506,
|
| 83 |
+
0.0559,
|
| 84 |
+
0.1564
|
| 85 |
+
],
|
| 86 |
+
"Bhairavi": [
|
| 87 |
+
0.1315,
|
| 88 |
+
0.0757,
|
| 89 |
+
0.1335,
|
| 90 |
+
0.0534,
|
| 91 |
+
0.0546,
|
| 92 |
+
0.0541,
|
| 93 |
+
0.0694,
|
| 94 |
+
0.1457,
|
| 95 |
+
0.0805,
|
| 96 |
+
0.068,
|
| 97 |
+
0.038,
|
| 98 |
+
0.0957
|
| 99 |
+
],
|
| 100 |
+
"Varāḷi": [
|
| 101 |
+
0.2178,
|
| 102 |
+
0.092,
|
| 103 |
+
0.0879,
|
| 104 |
+
0.0327,
|
| 105 |
+
0.0149,
|
| 106 |
+
0.0264,
|
| 107 |
+
0.1144,
|
| 108 |
+
0.1356,
|
| 109 |
+
0.0454,
|
| 110 |
+
0.02,
|
| 111 |
+
0.0285,
|
| 112 |
+
0.1843
|
| 113 |
+
],
|
| 114 |
+
"Harikāmbhōji": [
|
| 115 |
+
0.1086,
|
| 116 |
+
0.0398,
|
| 117 |
+
0.0666,
|
| 118 |
+
0.1091,
|
| 119 |
+
0.1079,
|
| 120 |
+
0.0413,
|
| 121 |
+
0.0531,
|
| 122 |
+
0.0869,
|
| 123 |
+
0.0998,
|
| 124 |
+
0.1328,
|
| 125 |
+
0.0544,
|
| 126 |
+
0.0997
|
| 127 |
+
],
|
| 128 |
+
"Śankarābharaṇaṁ": [
|
| 129 |
+
0.1398,
|
| 130 |
+
0.0326,
|
| 131 |
+
0.0576,
|
| 132 |
+
0.1566,
|
| 133 |
+
0.136,
|
| 134 |
+
0.0386,
|
| 135 |
+
0.0734,
|
| 136 |
+
0.1108,
|
| 137 |
+
0.033,
|
| 138 |
+
0.0374,
|
| 139 |
+
0.0384,
|
| 140 |
+
0.1456
|
| 141 |
+
],
|
| 142 |
+
"Kāpi": [
|
| 143 |
+
0.1276,
|
| 144 |
+
0.0631,
|
| 145 |
+
0.1313,
|
| 146 |
+
0.0832,
|
| 147 |
+
0.0938,
|
| 148 |
+
0.0574,
|
| 149 |
+
0.0621,
|
| 150 |
+
0.0983,
|
| 151 |
+
0.0381,
|
| 152 |
+
0.0426,
|
| 153 |
+
0.0524,
|
| 154 |
+
0.1501
|
| 155 |
+
],
|
| 156 |
+
"Pūrvīkaḷyāṇi": [
|
| 157 |
+
0.2013,
|
| 158 |
+
0.04,
|
| 159 |
+
0.0225,
|
| 160 |
+
0.0882,
|
| 161 |
+
0.07,
|
| 162 |
+
0.0522,
|
| 163 |
+
0.107,
|
| 164 |
+
0.1015,
|
| 165 |
+
0.0595,
|
| 166 |
+
0.071,
|
| 167 |
+
0.0416,
|
| 168 |
+
0.1452
|
| 169 |
+
],
|
| 170 |
+
"Sindhubhairavi": [
|
| 171 |
+
0.1399,
|
| 172 |
+
0.0619,
|
| 173 |
+
0.0986,
|
| 174 |
+
0.076,
|
| 175 |
+
0.0673,
|
| 176 |
+
0.0628,
|
| 177 |
+
0.0758,
|
| 178 |
+
0.1595,
|
| 179 |
+
0.0717,
|
| 180 |
+
0.0485,
|
| 181 |
+
0.0477,
|
| 182 |
+
0.0902
|
| 183 |
+
],
|
| 184 |
+
"Rītigauḷa": [
|
| 185 |
+
0.1219,
|
| 186 |
+
0.0605,
|
| 187 |
+
0.1125,
|
| 188 |
+
0.0679,
|
| 189 |
+
0.0962,
|
| 190 |
+
0.0759,
|
| 191 |
+
0.0336,
|
| 192 |
+
0.0474,
|
| 193 |
+
0.0622,
|
| 194 |
+
0.1308,
|
| 195 |
+
0.0691,
|
| 196 |
+
0.122
|
| 197 |
+
],
|
| 198 |
+
"Sāvēri": [
|
| 199 |
+
0.2051,
|
| 200 |
+
0.0495,
|
| 201 |
+
0.0243,
|
| 202 |
+
0.0498,
|
| 203 |
+
0.0784,
|
| 204 |
+
0.0507,
|
| 205 |
+
0.1077,
|
| 206 |
+
0.2043,
|
| 207 |
+
0.0658,
|
| 208 |
+
0.0262,
|
| 209 |
+
0.0238,
|
| 210 |
+
0.1143
|
| 211 |
+
],
|
| 212 |
+
"Sahānā": [
|
| 213 |
+
0.1089,
|
| 214 |
+
0.0545,
|
| 215 |
+
0.0852,
|
| 216 |
+
0.0832,
|
| 217 |
+
0.1427,
|
| 218 |
+
0.0619,
|
| 219 |
+
0.0688,
|
| 220 |
+
0.0971,
|
| 221 |
+
0.0871,
|
| 222 |
+
0.079,
|
| 223 |
+
0.0307,
|
| 224 |
+
0.1008
|
| 225 |
+
],
|
| 226 |
+
"Ṣanmukhapriya": [
|
| 227 |
+
0.1082,
|
| 228 |
+
0.0539,
|
| 229 |
+
0.1333,
|
| 230 |
+
0.0446,
|
| 231 |
+
0.0173,
|
| 232 |
+
0.0311,
|
| 233 |
+
0.1316,
|
| 234 |
+
0.1816,
|
| 235 |
+
0.0692,
|
| 236 |
+
0.068,
|
| 237 |
+
0.0734,
|
| 238 |
+
0.0877
|
| 239 |
+
],
|
| 240 |
+
"Kalyāṇi": [
|
| 241 |
+
0.1199,
|
| 242 |
+
0.0534,
|
| 243 |
+
0.0666,
|
| 244 |
+
0.0759,
|
| 245 |
+
0.0621,
|
| 246 |
+
0.0438,
|
| 247 |
+
0.1087,
|
| 248 |
+
0.0959,
|
| 249 |
+
0.0485,
|
| 250 |
+
0.0604,
|
| 251 |
+
0.076,
|
| 252 |
+
0.1888
|
| 253 |
+
],
|
| 254 |
+
"Hussēnī": [
|
| 255 |
+
0.1543,
|
| 256 |
+
0.0764,
|
| 257 |
+
0.1021,
|
| 258 |
+
0.0378,
|
| 259 |
+
0.0537,
|
| 260 |
+
0.0565,
|
| 261 |
+
0.1075,
|
| 262 |
+
0.1531,
|
| 263 |
+
0.0602,
|
| 264 |
+
0.0549,
|
| 265 |
+
0.0354,
|
| 266 |
+
0.1081
|
| 267 |
+
],
|
| 268 |
+
"Kāṁbhōji": [
|
| 269 |
+
0.1205,
|
| 270 |
+
0.0376,
|
| 271 |
+
0.0579,
|
| 272 |
+
0.1163,
|
| 273 |
+
0.1024,
|
| 274 |
+
0.0325,
|
| 275 |
+
0.0733,
|
| 276 |
+
0.0898,
|
| 277 |
+
0.089,
|
| 278 |
+
0.1171,
|
| 279 |
+
0.0486,
|
| 280 |
+
0.1151
|
| 281 |
+
],
|
| 282 |
+
"Śrīranjani": [
|
| 283 |
+
0.1133,
|
| 284 |
+
0.1038,
|
| 285 |
+
0.1254,
|
| 286 |
+
0.0505,
|
| 287 |
+
0.0754,
|
| 288 |
+
0.0742,
|
| 289 |
+
0.0271,
|
| 290 |
+
0.0388,
|
| 291 |
+
0.1121,
|
| 292 |
+
0.121,
|
| 293 |
+
0.0551,
|
| 294 |
+
0.1033
|
| 295 |
+
],
|
| 296 |
+
"Kēdāragauḷa": [
|
| 297 |
+
0.1412,
|
| 298 |
+
0.0778,
|
| 299 |
+
0.1213,
|
| 300 |
+
0.1178,
|
| 301 |
+
0.0742,
|
| 302 |
+
0.027,
|
| 303 |
+
0.0738,
|
| 304 |
+
0.1192,
|
| 305 |
+
0.0451,
|
| 306 |
+
0.0578,
|
| 307 |
+
0.0371,
|
| 308 |
+
0.1078
|
| 309 |
+
],
|
| 310 |
+
"Madhyamāvati": [
|
| 311 |
+
0.161,
|
| 312 |
+
0.087,
|
| 313 |
+
0.114,
|
| 314 |
+
0.0391,
|
| 315 |
+
0.0463,
|
| 316 |
+
0.0472,
|
| 317 |
+
0.0773,
|
| 318 |
+
0.1156,
|
| 319 |
+
0.0487,
|
| 320 |
+
0.0829,
|
| 321 |
+
0.0574,
|
| 322 |
+
0.1236
|
| 323 |
+
],
|
| 324 |
+
"Dēvagāndhāri": [
|
| 325 |
+
0.1192,
|
| 326 |
+
0.0557,
|
| 327 |
+
0.1105,
|
| 328 |
+
0.0839,
|
| 329 |
+
0.1056,
|
| 330 |
+
0.0434,
|
| 331 |
+
0.063,
|
| 332 |
+
0.0937,
|
| 333 |
+
0.0556,
|
| 334 |
+
0.0882,
|
| 335 |
+
0.059,
|
| 336 |
+
0.1222
|
| 337 |
+
],
|
| 338 |
+
"Gauḷa": [
|
| 339 |
+
0.2328,
|
| 340 |
+
0.0644,
|
| 341 |
+
0.0243,
|
| 342 |
+
0.0409,
|
| 343 |
+
0.1006,
|
| 344 |
+
0.0681,
|
| 345 |
+
0.0864,
|
| 346 |
+
0.0874,
|
| 347 |
+
0.0151,
|
| 348 |
+
0.0131,
|
| 349 |
+
0.0376,
|
| 350 |
+
0.2292
|
| 351 |
+
],
|
| 352 |
+
"Kānaḍa": [
|
| 353 |
+
0.1199,
|
| 354 |
+
0.0774,
|
| 355 |
+
0.098,
|
| 356 |
+
0.0653,
|
| 357 |
+
0.1002,
|
| 358 |
+
0.0622,
|
| 359 |
+
0.0567,
|
| 360 |
+
0.0895,
|
| 361 |
+
0.076,
|
| 362 |
+
0.0827,
|
| 363 |
+
0.0506,
|
| 364 |
+
0.1213
|
| 365 |
+
],
|
| 366 |
+
"Nāṭa": [
|
| 367 |
+
0.1459,
|
| 368 |
+
0.043,
|
| 369 |
+
0.0954,
|
| 370 |
+
0.1189,
|
| 371 |
+
0.1268,
|
| 372 |
+
0.0787,
|
| 373 |
+
0.0852,
|
| 374 |
+
0.0941,
|
| 375 |
+
0.0133,
|
| 376 |
+
0.0128,
|
| 377 |
+
0.0309,
|
| 378 |
+
0.155
|
| 379 |
+
],
|
| 380 |
+
"Karaharapriya": [
|
| 381 |
+
0.101,
|
| 382 |
+
0.0871,
|
| 383 |
+
0.1594,
|
| 384 |
+
0.0484,
|
| 385 |
+
0.061,
|
| 386 |
+
0.0432,
|
| 387 |
+
0.0505,
|
| 388 |
+
0.0757,
|
| 389 |
+
0.0962,
|
| 390 |
+
0.1455,
|
| 391 |
+
0.0429,
|
| 392 |
+
0.0891
|
| 393 |
+
],
|
| 394 |
+
"Dhanyāsi": [
|
| 395 |
+
0.1789,
|
| 396 |
+
0.0496,
|
| 397 |
+
0.0308,
|
| 398 |
+
0.0335,
|
| 399 |
+
0.0828,
|
| 400 |
+
0.0787,
|
| 401 |
+
0.1119,
|
| 402 |
+
0.1598,
|
| 403 |
+
0.0446,
|
| 404 |
+
0.0499,
|
| 405 |
+
0.0449,
|
| 406 |
+
0.1345
|
| 407 |
+
],
|
| 408 |
+
"Sāma": [
|
| 409 |
+
0.128,
|
| 410 |
+
0.0574,
|
| 411 |
+
0.108,
|
| 412 |
+
0.0976,
|
| 413 |
+
0.1445,
|
| 414 |
+
0.0674,
|
| 415 |
+
0.0688,
|
| 416 |
+
0.0837,
|
| 417 |
+
0.0438,
|
| 418 |
+
0.0671,
|
| 419 |
+
0.0301,
|
| 420 |
+
0.1037
|
| 421 |
+
],
|
| 422 |
+
"Śrī": [
|
| 423 |
+
0.153,
|
| 424 |
+
0.1087,
|
| 425 |
+
0.1445,
|
| 426 |
+
0.0409,
|
| 427 |
+
0.0473,
|
| 428 |
+
0.0477,
|
| 429 |
+
0.0774,
|
| 430 |
+
0.114,
|
| 431 |
+
0.0423,
|
| 432 |
+
0.0438,
|
| 433 |
+
0.0457,
|
| 434 |
+
0.1348
|
| 435 |
+
],
|
| 436 |
+
"Sencuruṭṭi": [
|
| 437 |
+
0.0798,
|
| 438 |
+
0.0289,
|
| 439 |
+
0.0345,
|
| 440 |
+
0.0209,
|
| 441 |
+
0.0744,
|
| 442 |
+
0.091,
|
| 443 |
+
0.0704,
|
| 444 |
+
0.0981,
|
| 445 |
+
0.1827,
|
| 446 |
+
0.1829,
|
| 447 |
+
0.0784,
|
| 448 |
+
0.0579
|
| 449 |
+
],
|
| 450 |
+
"Ānandabhairavi": [
|
| 451 |
+
0.1223,
|
| 452 |
+
0.0697,
|
| 453 |
+
0.1033,
|
| 454 |
+
0.0551,
|
| 455 |
+
0.0944,
|
| 456 |
+
0.0899,
|
| 457 |
+
0.1063,
|
| 458 |
+
0.1503,
|
| 459 |
+
0.0322,
|
| 460 |
+
0.0405,
|
| 461 |
+
0.0285,
|
| 462 |
+
0.1076
|
| 463 |
+
],
|
| 464 |
+
"Kāmavardani": [
|
| 465 |
+
0.1885,
|
| 466 |
+
0.0401,
|
| 467 |
+
0.0203,
|
| 468 |
+
0.0691,
|
| 469 |
+
0.051,
|
| 470 |
+
0.0437,
|
| 471 |
+
0.1499,
|
| 472 |
+
0.1655,
|
| 473 |
+
0.0517,
|
| 474 |
+
0.0204,
|
| 475 |
+
0.0335,
|
| 476 |
+
0.1662
|
| 477 |
+
],
|
| 478 |
+
"Māyāmāḷavagauḷa": [
|
| 479 |
+
0.1573,
|
| 480 |
+
0.0318,
|
| 481 |
+
0.0201,
|
| 482 |
+
0.0524,
|
| 483 |
+
0.1612,
|
| 484 |
+
0.0986,
|
| 485 |
+
0.091,
|
| 486 |
+
0.1727,
|
| 487 |
+
0.0356,
|
| 488 |
+
0.0157,
|
| 489 |
+
0.0227,
|
| 490 |
+
0.1409
|
| 491 |
+
],
|
| 492 |
+
"Aṭāna": [
|
| 493 |
+
0.1396,
|
| 494 |
+
0.0467,
|
| 495 |
+
0.05,
|
| 496 |
+
0.0269,
|
| 497 |
+
0.0373,
|
| 498 |
+
0.0407,
|
| 499 |
+
0.0936,
|
| 500 |
+
0.136,
|
| 501 |
+
0.092,
|
| 502 |
+
0.1176,
|
| 503 |
+
0.0633,
|
| 504 |
+
0.1563
|
| 505 |
+
],
|
| 506 |
+
"Mukhāri": [
|
| 507 |
+
0.1328,
|
| 508 |
+
0.0861,
|
| 509 |
+
0.1355,
|
| 510 |
+
0.036,
|
| 511 |
+
0.0529,
|
| 512 |
+
0.0542,
|
| 513 |
+
0.0893,
|
| 514 |
+
0.1576,
|
| 515 |
+
0.0672,
|
| 516 |
+
0.0553,
|
| 517 |
+
0.0285,
|
| 518 |
+
0.1046
|
| 519 |
+
],
|
| 520 |
+
"Yadukula kāṁbōji": [
|
| 521 |
+
0.1217,
|
| 522 |
+
0.0542,
|
| 523 |
+
0.0837,
|
| 524 |
+
0.1527,
|
| 525 |
+
0.0987,
|
| 526 |
+
0.0372,
|
| 527 |
+
0.0841,
|
| 528 |
+
0.1176,
|
| 529 |
+
0.0578,
|
| 530 |
+
0.0664,
|
| 531 |
+
0.0328,
|
| 532 |
+
0.0931
|
| 533 |
+
],
|
| 534 |
+
"Nāṭakurinji": [
|
| 535 |
+
0.1571,
|
| 536 |
+
0.0305,
|
| 537 |
+
0.0377,
|
| 538 |
+
0.0777,
|
| 539 |
+
0.1308,
|
| 540 |
+
0.0541,
|
| 541 |
+
0.0188,
|
| 542 |
+
0.0299,
|
| 543 |
+
0.1264,
|
| 544 |
+
0.1488,
|
| 545 |
+
0.0538,
|
| 546 |
+
0.1343
|
| 547 |
+
],
|
| 548 |
+
"Bilahari": [
|
| 549 |
+
0.1193,
|
| 550 |
+
0.05,
|
| 551 |
+
0.0703,
|
| 552 |
+
0.1366,
|
| 553 |
+
0.0977,
|
| 554 |
+
0.0262,
|
| 555 |
+
0.0726,
|
| 556 |
+
0.0906,
|
| 557 |
+
0.067,
|
| 558 |
+
0.0811,
|
| 559 |
+
0.0531,
|
| 560 |
+
0.1355
|
| 561 |
+
]
|
| 562 |
+
}
|
raagas.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_note": "40-raaga Carnatic set: every raaga in IAMRRD (compmusic_raga / Gulati CMD), deduped by diacritic fold. Saraga pools in where raagas overlap. Regenerate with scratchpad/build_raagas40.py.",
|
| 3 |
+
"tradition": "carnatic",
|
| 4 |
+
"canonical": [
|
| 5 |
+
"Aṭāna",
|
| 6 |
+
"Behāg",
|
| 7 |
+
"Bhairavi",
|
| 8 |
+
"Bilahari",
|
| 9 |
+
"Bēgaḍa",
|
| 10 |
+
"Dhanyāsi",
|
| 11 |
+
"Dēvagāndhāri",
|
| 12 |
+
"Gauḷa",
|
| 13 |
+
"Harikāmbhōji",
|
| 14 |
+
"Hussēnī",
|
| 15 |
+
"Kalyāṇi",
|
| 16 |
+
"Kamās",
|
| 17 |
+
"Karaharapriya",
|
| 18 |
+
"Kāmavardani",
|
| 19 |
+
"Kānaḍa",
|
| 20 |
+
"Kāpi",
|
| 21 |
+
"Kāṁbhōji",
|
| 22 |
+
"Kēdāragauḷa",
|
| 23 |
+
"Madhyamāvati",
|
| 24 |
+
"Mukhāri",
|
| 25 |
+
"Māyāmāḷavagauḷa",
|
| 26 |
+
"Mōhanaṁ",
|
| 27 |
+
"Nāṭa",
|
| 28 |
+
"Nāṭakurinji",
|
| 29 |
+
"Pūrvīkaḷyāṇi",
|
| 30 |
+
"Rītigauḷa",
|
| 31 |
+
"Sahānā",
|
| 32 |
+
"Sencuruṭṭi",
|
| 33 |
+
"Sindhubhairavi",
|
| 34 |
+
"Suraṭi",
|
| 35 |
+
"Sāma",
|
| 36 |
+
"Sāvēri",
|
| 37 |
+
"Tōḍi",
|
| 38 |
+
"Varāḷi",
|
| 39 |
+
"Yadukula kāṁbōji",
|
| 40 |
+
"Ānandabhairavi",
|
| 41 |
+
"Śankarābharaṇaṁ",
|
| 42 |
+
"Śrī",
|
| 43 |
+
"Śrīranjani",
|
| 44 |
+
"Ṣanmukhapriya"
|
| 45 |
+
],
|
| 46 |
+
"aliases": {
|
| 47 |
+
"Shankarabharanam": [
|
| 48 |
+
"Dheerasankarabharanam",
|
| 49 |
+
"Sankarabharanam",
|
| 50 |
+
"Shankarabharana"
|
| 51 |
+
],
|
| 52 |
+
"Kalyani": [
|
| 53 |
+
"Mechakalyani",
|
| 54 |
+
"Yaman"
|
| 55 |
+
],
|
| 56 |
+
"Kamboji": [
|
| 57 |
+
"Kambhoji",
|
| 58 |
+
"Khamboji"
|
| 59 |
+
],
|
| 60 |
+
"Mayamalavagowla": [
|
| 61 |
+
"Mayamalavagoula",
|
| 62 |
+
"Malahari"
|
| 63 |
+
],
|
| 64 |
+
"Hindolam": [
|
| 65 |
+
"Malkauns"
|
| 66 |
+
]
|
| 67 |
+
}
|
| 68 |
+
}
|
requirements-api.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Headless inference API deps (numpy<2 for essentia's C-array bridge). Same core as the Gradio
|
| 2 |
+
# Space, minus gradio, plus fastapi/uvicorn/python-multipart.
|
| 3 |
+
numpy<2
|
| 4 |
+
essentia
|
| 5 |
+
compiam
|
| 6 |
+
librosa
|
| 7 |
+
soundfile
|
| 8 |
+
xgboost
|
| 9 |
+
fastapi
|
| 10 |
+
uvicorn[standard]
|
| 11 |
+
python-multipart
|
schema.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The data-commons schema (PRD §18.1).
|
| 2 |
+
|
| 3 |
+
A `datasets.Features` spec is defined even though v0 has no contributions yet — it
|
| 4 |
+
future-proofs the commons so the v1 contribute/verify loop writes into a stable
|
| 5 |
+
shape. Dataset license: CC-BY-4.0 (D9), kept separate from Saraga-derived artifacts.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datasets import Audio, Features, Sequence, Value
|
| 10 |
+
|
| 11 |
+
FEATURES = Features(
|
| 12 |
+
{
|
| 13 |
+
"id": Value("string"),
|
| 14 |
+
"audio": Audio(sampling_rate=16_000),
|
| 15 |
+
"audio_sha256": Value("string"), # dedup key (D12)
|
| 16 |
+
"raaga": Value("string"), # canonical vocab (raagas.json)
|
| 17 |
+
"tradition": Value("string"), # carnatic | hindustani (D3)
|
| 18 |
+
"contributor_id": Value("string"), # optional pseudonym, never PII (D12)
|
| 19 |
+
"is_own_performance": Value("bool"), # rights gate (§12)
|
| 20 |
+
"license": Value("string"),
|
| 21 |
+
"consent_version": Value("string"),
|
| 22 |
+
"created_at": Value("string"),
|
| 23 |
+
"label_source": Value("string"), # model_confirmed | contributor_declared | expert
|
| 24 |
+
"model_prediction": Value("string"),
|
| 25 |
+
"model_confidence": Value("float32"),
|
| 26 |
+
"verification_status": Value("string"), # unverified | verified | disputed (D13)
|
| 27 |
+
"split": Value("string"), # pending | train | test | disputed
|
| 28 |
+
"votes_agree": Value("int32"),
|
| 29 |
+
"votes_disagree": Value("int32"),
|
| 30 |
+
# nice-to-have
|
| 31 |
+
"tonic_hz": Value("float32"),
|
| 32 |
+
"tonic_source": Value("string"),
|
| 33 |
+
"instrument": Value("string"),
|
| 34 |
+
"voice_type": Value("string"),
|
| 35 |
+
"form": Value("string"),
|
| 36 |
+
"tala": Value("string"),
|
| 37 |
+
"annotations": Sequence(
|
| 38 |
+
{
|
| 39 |
+
"type": Value("string"), # phrase | tonic | swara_boundary
|
| 40 |
+
"start_s": Value("float32"),
|
| 41 |
+
"end_s": Value("float32"),
|
| 42 |
+
"value": Value("string"),
|
| 43 |
+
"annotator_id": Value("string"),
|
| 44 |
+
}
|
| 45 |
+
),
|
| 46 |
+
}
|
| 47 |
+
)
|