"""v0 "Shazam for raagas" — mic/upload -> top-3 (PRD build-order step 7).
python -m apps.identify # launches the Gradio app (needs the inference env)
Runs the PCD path (essentia pitch + tonic -> pooled model), shows top-3 as confidence
bars + the estimated Sa + recognition time. Styled to the shared urbanmorph design
system (dark #0a0a0a canvas, system-ui, hairline borders) with twelveswaras' amber hue.
"""
from __future__ import annotations
import time
from pathlib import Path
import numpy as np
from raaga_id import pitch_extract
from raaga_id.config import MODELS_DIR, TOP_K
from raaga_id.model import RaagaXGB
from apps import usage_log
ASSETS = Path(__file__).resolve().parent.parent / "assets"
MODEL_PATH = MODELS_DIR / "raaga_xgb.json"
# Inline logo tile (12-bar pitch-class histogram = the twelve swaras), amber gradient.
_TILE = """
"""
TITLE_HTML = f"""
{_TILE}
twelveswaras
identify the raaga
"""
FOOTER_HTML = """
"""
CSS = """
.gradio-container { max-width: 640px !important; margin: 0 auto !important; }
footer { display: none !important; }
#ts-footer { text-align:center; color:#9ca3af; opacity:.7; font-size:.78rem; margin:1rem 0 .3rem; }
/* privacy reassurance shown right above the recorder (visible in the embed too) */
#ts-privacy { text-align:center; color:#9ca3af; font-size:.82rem; margin:.1rem 0 .3rem; }
#ts-privacy p { margin:0; }
/* confidence bars in the brand amber */
.gradio-container .label span.text + div, .gradio-container .fill { background: #f59e0b !important; }
/* audio player: keep the seek bar from covering the 0:00 / total time read-outs */
.gradio-container .timestamps { position: relative; z-index: 3; margin-top: 4px; }
.gradio-container .timestamps time { background: #0a0a0a; padding: 0 3px; border-radius: 3px; }
/* embed mode (loaded in the twelveswaras.com iframe via ?embed=1): hide the app's own logo,
footer, and the drone tip (the page already carries all three) so it fits without scrolling */
body.embed #ts-title, body.embed #ts-footer, body.embed #ts-drone { display: none !important; }
body.embed .gap, body.embed .contain { gap: 10px !important; }
body.embed .gradio-container { padding-top: 2px !important; padding-bottom: 0 !important;
overflow: hidden !important; max-width: 100% !important; } /* fill page width; no bottom slack
so the auto-resized frame ends right at the content (640px cap is for standalone) */
/* breathing room around the status line so Gradio's progress bar doesn't crowd the "Listening…" text */
#ts-status { margin-top: 10px !important; }
#ts-status p { padding-top: 6px !important; }
/* frame is sized to content -> the iframe itself never scrolls; the page does. Kills Gradio's
always-on scrollbar track. (overflow only — no height changes, which would blank the app.) */
html:has(body.embed), body.embed { overflow: hidden !important; }
"""
def _theme():
import gradio as gr
return gr.themes.Base(
primary_hue=gr.themes.colors.amber,
secondary_hue=gr.themes.colors.amber,
neutral_hue=gr.themes.colors.neutral,
font=["system-ui", "ui-sans-serif", "-apple-system", "Segoe UI", "sans-serif"],
font_mono=["ui-monospace", "SFMono-Regular", "Menlo", "monospace"],
).set(
body_background_fill="#0b0a08", # match the twelveswaras.com page ground (no seam)
body_text_color="#ededed",
body_text_color_subdued="#9ca3af",
background_fill_primary="#15151a",
background_fill_secondary="#1a1a1f",
block_background_fill="#15151a",
block_border_color="#262626",
block_border_width="1px",
block_radius="12px",
block_label_background_fill="#1a1a1f",
block_label_text_color="#fbbf24",
border_color_primary="#262626",
input_background_fill="#1a1a1f",
button_primary_background_fill="#d97706",
button_primary_background_fill_hover="#b45309",
button_primary_text_color="#ffffff",
button_primary_border_color="#d97706",
)
def _load_model() -> RaagaXGB:
if not MODEL_PATH.exists():
raise SystemExit(f"no model at {MODEL_PATH} — run `python -m raaga_id.train` first.")
return RaagaXGB.load(MODEL_PATH)
def _learn_plot(raaga, user_profile):
"""A dark/amber bar chart of which of the seven swaras the raaga rests on vs the user's
clip — the 'how to hear this raaga' visual, in the notes a beginner knows."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from raaga_id import learn
from raaga_id.features import to_swaras7
names, user7 = to_swaras7(user_profile)
x = np.arange(len(names))
fig, ax = plt.subplots(figsize=(6, 2.4))
fig.patch.set_facecolor("#0a0a0a")
ax.set_facecolor("#0a0a0a")
ref = learn.reference_profile(raaga)
if ref is not None:
ax.bar(x - 0.2, to_swaras7(ref)[1], width=0.4, color="#f59e0b", label=raaga)
ax.bar(x + 0.2, user7, width=0.4, color="#6b7280", label="your clip")
ax.set_xticks(x)
ax.set_xticklabels(names, color="#ededed", fontsize=9)
ax.tick_params(axis="x", length=0)
ax.set_yticks([])
for spine in ax.spines.values():
spine.set_visible(False)
ax.legend(facecolor="#15151a", edgecolor="#262626", labelcolor="#ededed", fontsize=8, loc="upper right")
fig.tight_layout()
return fig
def _mmss(seconds: float) -> str:
"""Whole-second duration as m:ss (e.g. 90 -> '1:30'), for the 'heard 0:00-…' label."""
s = int(round(seconds))
return f"{s // 60}:{s % 60:02d}"
def identify(audio, model: RaagaXGB):
"""audio = (sample_rate, np.ndarray) from Gradio. A GENERATOR: it yields a "listening"
state first (so the app is visibly working before it answers — D24), then the final
(labels, info, plot, learn_md). The info line names the segment it actually heard."""
if audio is None:
yield {}, "Upload or record ~10 s+ of melody. A clear line with a drone works best.", None, ""
return
# Show that we're listening BEFORE the ~3.5 s of pitch+tonic extraction, and clear any
# previous result, so the answer never appears to precede the analysis.
yield {}, "🎧 **Listening…** finding the tonic (Sa) and tracing the swaras.", None, ""
sr, wav = audio
t0 = time.perf_counter()
windows, tonic, heard, display_pcd = pitch_extract.audio_to_features(wav, sr)
if not windows:
usage_log.record(no_prediction=True, tonic_hz=tonic, heard_seconds=heard)
yield {}, "🤔 Couldn't find a clear melody + tonic. Try a longer, cleaner clip with a drone.", None, ""
return
X = np.vstack(windows)
preds = model.aggregate_top_k(X, k=TOP_K)
elapsed = time.perf_counter() - t0
print(f"[identify] {preds[0].raaga} ({preds[0].confidence:.0%}) · Sa≈{tonic:.0f}Hz · "
f"heard {_mmss(heard)} · {elapsed:.1f}s", flush=True)
usage_log.record(top1=preds[0].raaga, confidence=preds[0].confidence,
top3=[{"raaga": p.raaga, "conf": round(float(p.confidence), 3)} for p in preds],
tonic_hz=tonic, heard_seconds=heard, elapsed_s=elapsed)
from raaga_id.calibrate import confidence_state
labels = {p.raaga: float(p.confidence) for p in preds}
state, note = confidence_state(preds) # calibrated top-2 -> "confident" / "close" / "unsure"
info = (f"**Sa ≈ {tonic:.0f} Hz** · heard **0:00–{_mmss(heard)}** · "
f"recognized in **{elapsed:.1f} s** · {note}")
from raaga_id import learn
from raaga_id.features import pcd_to_swaras
top = preds[0].raaga
user_profile = pcd_to_swaras(display_pcd) # human-readable swaras from the PCD, not the TDMS surface
learn_md = learn.summary_md(top, user_profile)
# On a close call, lead the learner panel with how to tell the top two apart (D29 Explorer).
if state == "close":
cmp = learn.comparison_md(preds[0].raaga, preds[1].raaga)
if cmp:
learn_md = cmp + "\n\n---\n\n" + learn_md
yield labels, info, _learn_plot(top, user_profile), learn_md
# When the recognizer is loaded inside the twelveswaras.com page (i.e. in an iframe), hide its
# own logo/footer/drone-tip so it reads as part of the page. Injected in so it always
# runs; detects embedding by iframe (window.self !== window.top) — no query-param dependency —
# and hides elements DIRECTLY by id, re-applying on a few timers because Gradio renders its
# components asynchronously after first paint. Standalone (hf.space direct) keeps full branding.
EMBED_HEAD = """
"""
def build_ui():
import gradio as gr
model = _load_model()
pitch_extract.warmup() # pay the essentia/compiam import cost once, up front
with gr.Blocks(title="twelveswaras", theme=_theme(), css=CSS, head=EMBED_HEAD) as demo:
gr.HTML(TITLE_HTML)
gr.Markdown("🔒 Your recording is **never stored**. It is analyzed to find the raaga, then discarded.",
elem_id="ts-privacy")
# buttons=["download"] drops Gradio's built-in "share": it re-uploads the raw clip to HF's
# MIME-restricted uploader (rejects m4a/aac/flac/…) and shares the *input*, not the result
# — confusing + flaky. A real "share this raga" is an Explorer feature (D29). Keep download.
audio = gr.Audio(sources=["microphone", "upload"], type="numpy", autoplay=True,
label="Upload or record ~15–30 s", buttons=["download"])
gr.Markdown("🎚️ **For best accuracy, include a tanpura / shruti-box drone.** A live "
"concert always has one — the tonic (Sa) is found from it, so solo voice "
"without a drone is unreliable.", elem_id="ts-drone")
result = gr.Label(num_top_classes=TOP_K, label="Raaga")
info = gr.Markdown("_Recognition runs automatically when you upload or finish recording._",
elem_id="ts-status")
with gr.Accordion("🎓 How to hear this raaga", open=False):
learn_plot = gr.Plot(label="Typical shape from recordings (gold) vs your clip (grey)")
learn_md = gr.Markdown()
gr.HTML(FOOTER_HTML)
# Sentinel at the very end of the content. Gradio stretches every CONTAINER to fill the
# viewport (so measuring any of them loops the auto-resize), but this plain marker just
# flows after the last component — its position IS the true content height.
gr.HTML('')
outs = [result, info, learn_plot, learn_md]
def on_audio(a): # generator fn so Gradio streams "listening…" then the result
yield from identify(a, model)
# No button: auto-identify when a file is uploaded or a recording stops.
# show_progress="hidden": the generator already yields a "🎧 Listening…" status, so Gradio's
# per-output progress spinners are redundant AND duplicate on mobile — each of the 4 outputs
# renders its own "N.Ns" eta, and on a narrow layout one floats over the caption text.
audio.upload(on_audio, audio, outs, show_progress="hidden")
audio.stop_recording(on_audio, audio, outs, show_progress="hidden")
# Clearing the audio (Gradio's ✕) must also reset the result/Sa/panels below — otherwise
# the previous clip's raaga lingers under an empty input.
def clear_panels():
return {}, "_Recognition runs automatically when you upload or finish recording._", None, ""
audio.clear(clear_panels, None, outs)
return demo
if __name__ == "__main__":
favicon = ASSETS / "favicon.svg"
build_ui().launch(favicon_path=str(favicon) if favicon.exists() else None)