recognizer-api / apps /identify.py
Sathya-UM's picture
Deploy headless recognizer API (Docker)
c25f760 verified
Raw
History Blame Contribute Delete
16.4 kB
"""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 = """
<svg width="46" height="46" viewBox="0 0 256 256" style="flex:0 0 auto">
<defs><linearGradient id="tsl" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#f59e0b"/><stop offset="1" stop-color="#b45309"/></linearGradient></defs>
<rect width="256" height="256" rx="56" fill="url(#tsl)"/>
<g fill="#fff">
<rect x="36" y="140" width="10" height="60" rx="5"/><rect x="52" y="105" width="10" height="95" rx="5"/>
<rect x="68" y="145" width="10" height="55" rx="5"/><rect x="84" y="80" width="10" height="120" rx="5"/>
<rect x="100" y="120" width="10" height="80" rx="5"/><rect x="116" y="95" width="10" height="105" rx="5"/>
<rect x="132" y="64" width="10" height="136" rx="5"/><rect x="148" y="130" width="10" height="70" rx="5"/>
<rect x="164" y="90" width="10" height="110" rx="5"/><rect x="180" y="115" width="10" height="85" rx="5"/>
<rect x="196" y="100" width="10" height="100" rx="5"/><rect x="212" y="135" width="10" height="65" rx="5"/>
</g></svg>
"""
TITLE_HTML = f"""
<div id="ts-title" style="display:flex; align-items:center; justify-content:center; gap:.65rem; margin:.4rem 0 .2rem">
{_TILE}
<div style="text-align:left; line-height:1.05">
<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>
<div style="font-size:clamp(.72rem,3.2vw,.92rem); color:#9ca3af; letter-spacing:.2px">identify the raaga</div>
</div>
</div>
"""
FOOTER_HTML = """
<div id="ts-footer">a non-commercial, open-source public good · Carnatic first · CC-BY data commons</div>
"""
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 <head> 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 = """
<script>
(function () {
function embedded() { try { return window.self !== window.top; } catch (e) { return true; } }
function hideChrome() {
if (!embedded()) return;
document.body.classList.add('embed');
// The frame is sized to content, so the recognizer never needs to scroll itself — the PAGE
// scrolls. Hide the iframe's own overflow so Gradio's always-on scrollbar track disappears.
// (Only overflow — no height/min-height changes, which would collapse the layout.)
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
['ts-title', 'ts-drone', 'ts-footer'].forEach(function (id) {
var el = document.getElementById(id);
if (el) { el.style.display = 'none'; }
});
}
// Tell the parent page our content height so it can grow the iframe to fit — the PAGE scrolls,
// the frame never gets its own scrollbar. Fires on load, timers, window resize, and (via
// ResizeObserver) whenever the content changes — a result appears, the accordion opens, etc.
var lastH = 0;
function reportHeight() {
if (!embedded()) return;
// Gradio stretches every CONTAINER to fill the viewport (= the frame height), so measuring any
// of them loops the auto-resize to infinity ("grows like a worm"). The #ts-end sentinel is a
// plain marker that flows right after the last component, so its bottom is the TRUE content
// height and can't stretch. absolute = rect.bottom + scrollY. +10px breathing room.
var end = document.getElementById('ts-end');
if (!end) return;
var h = Math.ceil(end.getBoundingClientRect().bottom + window.scrollY) + 10;
if (h > 0 && h !== lastH) {
lastH = h;
try { window.parent.postMessage({ twelveswaras_height: h }, '*'); } catch (er) {}
}
}
function hideDeadMic() {
// Gradio's audio device <select> shows a misleading "No microphone found" before mic permission
// is granted, even though recording works fine on the default device. Hide that control while it
// shows the dead label, and restore ONLY the ones we hid once a real device name appears.
var L = 'No microphone found';
document.querySelectorAll('select, button').forEach(function (el) {
var t = (el.textContent || '').replace(/\\s+/g, ' ').trim();
if (t === L) { el.style.display = 'none'; el.dataset.tsDeadmic = '1'; }
else if (el.dataset.tsDeadmic === '1') { el.style.display = ''; delete el.dataset.tsDeadmic; }
});
}
function tick() { hideChrome(); hideDeadMic(); reportHeight(); }
if (document.readyState !== 'loading') tick();
document.addEventListener('DOMContentLoaded', tick);
[150, 400, 900, 1800].forEach(function (t) { setTimeout(tick, t); });
window.addEventListener('resize', reportHeight);
// Gradio's body is pinned to 100vh, so opening the accordion / getting a result overflows it
// WITHOUT changing its size — ResizeObserver never fires. So poll the sentinel (deduped, cheap).
setInterval(tick, 300);
})();
</script>
"""
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('<div id="ts-end" style="height:1px"></div>')
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)