File size: 16,410 Bytes
c25f760
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
"""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)