Sathya-UM commited on
Commit
c25f760
·
verified ·
1 Parent(s): 18867b1

Deploy headless recognizer API (Docker)

Browse files
.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: Recognizer Api
3
- emoji: 📈
4
  colorFrom: yellow
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )