blackboxanalytics commited on
Commit
474be08
·
1 Parent(s): 5eef7f2

Make the hero spectrum dance with the actual audio (Web Audio AnalyserNode)

Browse files

The 48 hero EQ bars used to freeze the moment the finished track played - the
CSS only energized them off the play button's label. Now they react to the real
audio: when any CODA player starts we tap its element with a MediaElementSource
+ AnalyserNode and paint each bar from getByteFrequencyData() every frame.

- Wrap HTMLMediaElement.play/pause so we capture WaveSurfer's DETACHED <audio>
element (no DOM parent, so querySelector/closest/capture listeners miss it);
the wrap runs in the click stack so the AudioContext resumes under a gesture.
- Mirrored mapping: center bars track the lows, edges the highs, scaled within
each bar's existing height so the centered silhouette is preserved.
- On pause/stop the bars glide back to idle and hand control to the CSS breathing
animation; reduced-motion and wiring failures fall back to the CSS-only look.

Also folds in the working-tree intro/head fixes: the 2016 story is baked on with
an inline onclick close fallback, and head=HEAD_SCRIPT moves to launch() so the
front-end JS module actually injects on the Space (Gradio 6.16.0 ignores head=
on gr.Blocks()).

Files changed (1) hide show
  1. app.py +206 -16
app.py CHANGED
@@ -1013,6 +1013,21 @@ footer{ display:none !important; }
1013
  #coda-eq.coda-eq-playing span{ animation:none !important; transform:none !important; }
1014
  }
1015
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1016
  /* ===== FEATURE B — SEAM REVEAL (gold-accented, part of the MASTER reveal) ===== */
1017
  #coda-seam-overlay{
1018
  position:absolute; inset:0; pointer-events:none; z-index:4; display:none;
@@ -1359,14 +1374,24 @@ AMBIENT = ("<div class='coda-aurora'></div>"
1359
  "<div class='coda-glow'></div>"
1360
  f"<div class='coda-particles'>{_parts}</div>")
1361
 
1362
- # Feature C — cinematic onboarding overlay. Server-rendered, position:fixed,
1363
- # opacity:0/pointer-events:none by default (so a no-JS visitor goes straight to
1364
- # the tool and is never locked out). The CODA_INIT_JS controller relocates it to
1365
- # <body>, plays the 4-beat Track0000 story, and hard-removes it on dismiss.
 
 
 
 
 
 
 
 
 
 
1366
  INTRO_OVERLAY = (
1367
- "<div id='coda-intro-overlay' role='dialog' aria-modal='true' "
1368
- "aria-label='CODA — the story'>"
1369
- "<div id='coda-intro-skip' role='button' tabindex='0'>Skip</div>"
1370
  "<div id='coda-intro-card'>"
1371
  "<div class='coda-intro-beat b1'><p class='coda-intro-year'>2016</p></div>"
1372
  "<div class='coda-intro-beat b2'><p class='coda-intro-line'>A song, recorded "
@@ -1376,7 +1401,7 @@ INTRO_OVERLAY = (
1376
  "<div id='coda-intro-eq'>" + ("<span></span>" * 12) + "</div></div>"
1377
  "<div class='coda-intro-beat b4'>"
1378
  "<p class='coda-intro-fin'>Ten years later, it gets finished.</p>"
1379
- "<button id='coda-intro-enter' type='button'>Enter CODA</button>"
1380
  "</div></div></div>")
1381
 
1382
  # the only JS, and purely cosmetic: a soft light that follows the cursor. Runs
@@ -1473,6 +1498,11 @@ CODA_INIT_JS = """
1473
  },
1474
  pauseOthers: function (keep) {
1475
  try {
 
 
 
 
 
1476
  var all = doc.querySelectorAll('.coda-player audio, .coda-drop audio');
1477
  for (var i = 0; i < all.length; i++) {
1478
  if (all[i] !== keep && !all[i].paused) { try { all[i].pause(); } catch (e) {} }
@@ -1517,6 +1547,159 @@ CODA_INIT_JS = """
1517
  W.heroSync = sync;
1518
  }
1519
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1520
  /* ===== FEATURE B — BEFORE/AFTER SEAM REVEAL =====
1521
  * The before/after split (YOUR PART | CODA + the join marker) is now rendered
1522
  * SERVER-SIDE as a self-contained ribbon (see _seam_html in app.py) with the
@@ -1578,6 +1761,9 @@ CODA_INIT_JS = """
1578
  host.addEventListener('transitionend', finish, { once: true });
1579
  setTimeout(finish, 750);
1580
  }
 
 
 
1581
  if (enter) enter.addEventListener('click', dismiss);
1582
  if (skip) skip.addEventListener('click', dismiss);
1583
  host.addEventListener('click', function (ev) { if (ev.target === host) dismiss(); });
@@ -1661,7 +1847,7 @@ HEAD_SCRIPT = (
1661
  "</script>"
1662
  )
1663
 
1664
- with gr.Blocks(title="CODA", head=HEAD_SCRIPT) as app:
1665
  # cinematic onboarding overlay (position:fixed; the JS relocates it to <body>
1666
  # and never lets it block the tool). First child so it can never affect layout.
1667
  gr.HTML(INTRO_OVERLAY)
@@ -1796,14 +1982,18 @@ with gr.Blocks(title="CODA", head=HEAD_SCRIPT) as app:
1796
 
1797
 
1798
  if __name__ == "__main__":
1799
- # Gradio 6 moved theme/css to launch(); pass them here (and they remain on
1800
- # Blocks above) so the dark DAW theme applies however the Space serves it.
 
 
 
 
 
1801
  # queue: one heavy job at a time (single GPU), others wait rather than
1802
  # contend. show_error surfaces a real error in the UI instead of a silently
1803
- # stuck spinner the failure mode we just chased on the deployed Space.
1804
- # allowed_paths: on HF Spaces, Gradio 6 refuses to serve the bundled demo
1805
- # clip (gradio_api/file=…/examples/… 403) unless its directory is
1806
- # explicitly allowed, which broke the "Try the demo" button on the Space.
1807
  app.queue(default_concurrency_limit=1, max_size=10).launch(
1808
- theme=THEME, css=CSS, show_error=True,
1809
  allowed_paths=[os.path.join(os.path.dirname(__file__), "examples")])
 
1013
  #coda-eq.coda-eq-playing span{ animation:none !important; transform:none !important; }
1014
  }
1015
 
1016
+ /* ===== FEATURE A (LIVE) — real audio-reactive bars =====
1017
+ When a Web Audio AnalyserNode is driving the field, the JS adds .coda-eq-live
1018
+ and writes each bar's scaleY inline from getByteFrequencyData(). A *running*
1019
+ CSS animation would override those inline transforms, so we kill it here. This
1020
+ rule sits AFTER .coda-eq-playing so its equal-specificity !important wins by
1021
+ source order. On stop the JS removes the class and the CSS breathing resumes. */
1022
+ #coda-eq.coda-eq-live span{
1023
+ animation: none !important;
1024
+ transition: transform .06s linear, box-shadow .2s ease;
1025
+ box-shadow: 0 0 16px rgba(111,224,245,.7), 0 0 6px rgba(169,139,255,.55);
1026
+ }
1027
+ @media (prefers-reduced-motion: reduce){
1028
+ #coda-eq.coda-eq-live span{ transition:none !important; }
1029
+ }
1030
+
1031
  /* ===== FEATURE B — SEAM REVEAL (gold-accented, part of the MASTER reveal) ===== */
1032
  #coda-seam-overlay{
1033
  position:absolute; inset:0; pointer-events:none; z-index:4; display:none;
 
1374
  "<div class='coda-glow'></div>"
1375
  f"<div class='coda-particles'>{_parts}</div>")
1376
 
1377
+ # Feature C — cinematic onboarding overlay. Tony wants the 2016 story to play on
1378
+ # EVERY page load. It is rendered with `coda-intro-on` BAKED IN, so the CSS beats
1379
+ # start immediately on load WITHOUT waiting on (or even needing) JS bulletproof
1380
+ # against the head-injection issue that made it vanish on the Space. The
1381
+ # CODA_INIT_JS controller (when present) relocates it to <body>, locks scroll,
1382
+ # wires Esc / auto-advance / replay, and hard-removes it on dismiss. Skip / Enter
1383
+ # carry an INLINE onclick fallback so the overlay is always dismissable even if
1384
+ # the JS module never loads (no lock-out).
1385
+ _INTRO_CLOSE = (
1386
+ "(window.__codaCloseIntro||function(){"
1387
+ "var o=document.getElementById('coda-intro-overlay');"
1388
+ "if(o){o.style.opacity=0;o.style.pointerEvents='none';"
1389
+ "setTimeout(function(){if(o)o.remove();},620);}"
1390
+ "document.body.classList.remove('coda-intro-lock');})()")
1391
  INTRO_OVERLAY = (
1392
+ "<div id='coda-intro-overlay' class='coda-intro-on' role='dialog' "
1393
+ "aria-modal='true' aria-label='CODA — the story'>"
1394
+ f'<div id="coda-intro-skip" role="button" tabindex="0" onclick="{_INTRO_CLOSE}">Skip</div>'
1395
  "<div id='coda-intro-card'>"
1396
  "<div class='coda-intro-beat b1'><p class='coda-intro-year'>2016</p></div>"
1397
  "<div class='coda-intro-beat b2'><p class='coda-intro-line'>A song, recorded "
 
1401
  "<div id='coda-intro-eq'>" + ("<span></span>" * 12) + "</div></div>"
1402
  "<div class='coda-intro-beat b4'>"
1403
  "<p class='coda-intro-fin'>Ten years later, it gets finished.</p>"
1404
+ f'<button id="coda-intro-enter" type="button" onclick="{_INTRO_CLOSE}">Enter CODA</button>'
1405
  "</div></div></div>")
1406
 
1407
  # the only JS, and purely cosmetic: a soft light that follows the cursor. Runs
 
1498
  },
1499
  pauseOthers: function (keep) {
1500
  try {
1501
+ /* the registered set covers WaveSurfer's DETACHED <audio> (which
1502
+ * querySelector can't reach); the query covers any in-DOM player. */
1503
+ this.playing.forEach(function (p) {
1504
+ if (p !== keep && !p.paused) { try { p.pause(); } catch (e) {} }
1505
+ });
1506
  var all = doc.querySelectorAll('.coda-player audio, .coda-drop audio');
1507
  for (var i = 0; i < all.length; i++) {
1508
  if (all[i] !== keep && !all[i].paused) { try { all[i].pause(); } catch (e) {} }
 
1547
  W.heroSync = sync;
1548
  }
1549
 
1550
+ /* ===== FEATURE A (LIVE) — AUDIO-REACTIVE HERO via Web Audio AnalyserNode =====
1551
+ * The CSS class above is the floor; THIS is the real thing Tony asked for.
1552
+ * When any CODA player starts, we tap its element with a MediaElementSource +
1553
+ * AnalyserNode (AUDIO.wire) and paint the 48 hero bars from getByteFrequency-
1554
+ * Data() every animation frame, so they dance with the actual music.
1555
+ *
1556
+ * The hard part: Gradio's WaveSurfer plays through a DETACHED <audio> element
1557
+ * (no DOM parent), so querySelector/closest/capture-phase listeners can't see
1558
+ * it. We solve that by wrapping HTMLMediaElement.prototype.play/pause — that
1559
+ * wrap fires with `this` = the real element the instant WaveSurfer plays it,
1560
+ * and runs inside the click call-stack so ensureCtx() can resume the
1561
+ * AudioContext under a valid user gesture (browsers require that).
1562
+ *
1563
+ * Bars are mirrored: center tracks the lows (bass pumps the middle), edges the
1564
+ * highs — a classic spectrum that keeps the hero's centered silhouette (the
1565
+ * per-bar inline height already encodes that shape; we only scale within it).
1566
+ * On pause/stop the bars decay smoothly to idle and control hands back to the
1567
+ * CSS breathing animation. If wiring ever fails (cross-origin taint, or a
1568
+ * WebAudio-backend Gradio build), we never add .coda-eq-live and the
1569
+ * CSS-energized look stays as a graceful fallback — nothing regresses. */
1570
+ if (!W.liveFFT) {
1571
+ W.liveFFT = true;
1572
+ var EQ = (W.eq = W.eq || {});
1573
+ EQ.idle = 0.16;
1574
+
1575
+ EQ.bars = function () {
1576
+ var eq = doc.getElementById('coda-eq');
1577
+ return eq ? eq.querySelectorAll('span') : [];
1578
+ };
1579
+
1580
+ /* bar i (0..n-1) -> a frequency bin. Distance from center picks the band
1581
+ * (center=low, edge=high), perceptually spread so the lows aren't crammed. */
1582
+ EQ.band = function (data, i, n, bins) {
1583
+ var half = n / 2;
1584
+ var d = Math.abs(i + 0.5 - half) / half; /* 0 center .. ~1 edge */
1585
+ var t = Math.pow(d, 1.35);
1586
+ var top = Math.max(4, Math.floor(bins * 0.80)); /* top bins are ~empty */
1587
+ var idx = 1 + Math.round(t * (top - 2));
1588
+ var a = data[idx];
1589
+ var b = data[idx + 1 < top ? idx + 1 : idx];
1590
+ return (a + b) / 510; /* /255/2 -> 0..1 */
1591
+ };
1592
+
1593
+ EQ.paint = function (data, bins) {
1594
+ var spans = EQ.bars(), n = spans.length;
1595
+ if (!n) return;
1596
+ if (!EQ.state || EQ.state.length !== n) {
1597
+ EQ.state = new Float32Array(n); EQ.state.fill(EQ.idle);
1598
+ }
1599
+ for (var i = 0; i < n; i++) {
1600
+ var amp = EQ.band(data, i, n, bins);
1601
+ var target = clamp(0.12 + amp * 1.18, 0.04, 1.14);
1602
+ var prev = EQ.state[i];
1603
+ var k = target > prev ? 0.55 : 0.20; /* snappy attack, soft release */
1604
+ var v = prev + (target - prev) * k;
1605
+ EQ.state[i] = v;
1606
+ spans[i].style.transform = 'scaleY(' + v.toFixed(3) + ')';
1607
+ }
1608
+ };
1609
+
1610
+ EQ.loop = function () {
1611
+ EQ.raf = requestAnimationFrame(EQ.loop);
1612
+ var el = AUDIO.findActive();
1613
+ if (!el) { /* small grace before settling */
1614
+ EQ.miss = (EQ.miss || 0) + 1;
1615
+ if (EQ.miss > 6) EQ.stop();
1616
+ return;
1617
+ }
1618
+ EQ.miss = 0;
1619
+ var g = AUDIO.graphs.get(el);
1620
+ if (!g || !g.analyser) return;
1621
+ g.analyser.getByteFrequencyData(g.data);
1622
+ EQ.paint(g.data, g.analyser.frequencyBinCount);
1623
+ };
1624
+
1625
+ EQ.start = function () {
1626
+ var eq = doc.getElementById('coda-eq');
1627
+ if (!eq || REDUCED) return;
1628
+ eq.classList.add('coda-eq-live');
1629
+ EQ.miss = 0;
1630
+ if (EQ.settleRaf) { cancelAnimationFrame(EQ.settleRaf); EQ.settleRaf = 0; }
1631
+ if (!EQ.raf) EQ.loop();
1632
+ };
1633
+
1634
+ EQ.stop = function () {
1635
+ if (EQ.raf) { cancelAnimationFrame(EQ.raf); EQ.raf = 0; }
1636
+ var spans = EQ.bars(), n = spans.length;
1637
+ var eq = doc.getElementById('coda-eq');
1638
+ if (!n) { if (eq) eq.classList.remove('coda-eq-live'); return; }
1639
+ if (!EQ.state || EQ.state.length !== n) {
1640
+ EQ.state = new Float32Array(n); EQ.state.fill(EQ.idle);
1641
+ }
1642
+ /* glide the bars down to idle, THEN hand back to the CSS breathing anim */
1643
+ function decay() {
1644
+ var moving = false;
1645
+ for (var i = 0; i < n; i++) {
1646
+ EQ.state[i] += (EQ.idle - EQ.state[i]) * 0.22;
1647
+ if (Math.abs(EQ.state[i] - EQ.idle) > 0.012) moving = true;
1648
+ spans[i].style.transform = 'scaleY(' + EQ.state[i].toFixed(3) + ')';
1649
+ }
1650
+ if (moving && !AUDIO.findActive()) {
1651
+ EQ.settleRaf = requestAnimationFrame(decay);
1652
+ } else {
1653
+ EQ.settleRaf = 0;
1654
+ if (eq) eq.classList.remove('coda-eq-live');
1655
+ for (var j = 0; j < n; j++) spans[j].style.transform = ''; /* CSS resumes */
1656
+ }
1657
+ }
1658
+ decay();
1659
+ };
1660
+
1661
+ AUDIO.register = function (el) {
1662
+ if (!el || el.tagName !== 'AUDIO') return;
1663
+ var g = this.wire(el); /* MediaElementSource + analyser (idempotent) */
1664
+ if (!g) return; /* taint / WebAudio backend -> CSS fallback */
1665
+ this.playing.add(el);
1666
+ this.pauseOthers(el);
1667
+ EQ.start();
1668
+ };
1669
+ AUDIO.unregister = function (el) {
1670
+ if (!el) return;
1671
+ this.playing['delete'](el);
1672
+ if (!this.findActive()) EQ.stop();
1673
+ };
1674
+
1675
+ /* 1) Wrap play/pause on the prototype: catches WaveSurfer's DETACHED element
1676
+ * (its play() runs in the click stack, so ensureCtx can resume the ctx).
1677
+ * Guarded so re-fired app.load / HEAD_SCRIPT polls never double-wrap. */
1678
+ try {
1679
+ var MP = window.HTMLMediaElement && HTMLMediaElement.prototype;
1680
+ if (MP && !MP.__codaPatched) {
1681
+ MP.__codaPatched = true;
1682
+ var _play = MP.play, _pause = MP.pause;
1683
+ MP.play = function () {
1684
+ try { AUDIO.register(this); } catch (e) {}
1685
+ return _play.apply(this, arguments);
1686
+ };
1687
+ MP.pause = function () {
1688
+ try { AUDIO.unregister(this); } catch (e) {}
1689
+ return _pause.apply(this, arguments);
1690
+ };
1691
+ }
1692
+ } catch (e) {}
1693
+
1694
+ /* 2) Belt-and-suspenders for any IN-DOM <audio> (media events don't bubble,
1695
+ * but capture still reaches them). 'ended' settles the field back to idle. */
1696
+ try {
1697
+ doc.addEventListener('play', function (e) { try { AUDIO.register(e.target); } catch (x) {} }, true);
1698
+ doc.addEventListener('pause', function (e) { try { AUDIO.unregister(e.target); } catch (x) {} }, true);
1699
+ doc.addEventListener('ended', function (e) { try { AUDIO.unregister(e.target); } catch (x) {} }, true);
1700
+ } catch (e) {}
1701
+ }
1702
+
1703
  /* ===== FEATURE B — BEFORE/AFTER SEAM REVEAL =====
1704
  * The before/after split (YOUR PART | CODA + the join marker) is now rendered
1705
  * SERVER-SIDE as a self-contained ribbon (see _seam_html in app.py) with the
 
1761
  host.addEventListener('transitionend', finish, { once: true });
1762
  setTimeout(finish, 750);
1763
  }
1764
+ /* expose the graceful close so the overlay's inline onclick fallback
1765
+ routes through it when the JS module is present (else it self-closes) */
1766
+ try { window.__codaCloseIntro = dismiss; } catch (e) {}
1767
  if (enter) enter.addEventListener('click', dismiss);
1768
  if (skip) skip.addEventListener('click', dismiss);
1769
  host.addEventListener('click', function (ev) { if (ev.target === host) dismiss(); });
 
1847
  "</script>"
1848
  )
1849
 
1850
+ with gr.Blocks(title="CODA") as app:
1851
  # cinematic onboarding overlay (position:fixed; the JS relocates it to <body>
1852
  # and never lets it block the tool). First child so it can never affect layout.
1853
  gr.HTML(INTRO_OVERLAY)
 
1982
 
1983
 
1984
  if __name__ == "__main__":
1985
+ # Gradio 6 moved theme/css/head to launch(). On the deployed Space (Gradio
1986
+ # 6.16.0) head= on gr.Blocks() is SILENTLY IGNORED, so the whole front-end
1987
+ # JS module (cursor glow + Feature A audio-reactive hero + Feature C intro)
1988
+ # never got injected — which is why the 2016 intro "disappeared" on the
1989
+ # Space. Passing head=HEAD_SCRIPT to launch() is the supported path and
1990
+ # injects it on 6.16.0+. (HEAD_SCRIPT itself is idempotent, so even if a
1991
+ # newer Gradio also honored Blocks(head=) the double-run would be harmless.)
1992
  # queue: one heavy job at a time (single GPU), others wait rather than
1993
  # contend. show_error surfaces a real error in the UI instead of a silently
1994
+ # stuck spinner. allowed_paths: on HF Spaces, Gradio 6 refuses to serve the
1995
+ # bundled demo clip (gradio_api/file=…/examples/… 403) unless its
1996
+ # directory is explicitly allowed, which broke "Try the demo" on the Space.
 
1997
  app.queue(default_concurrency_limit=1, max_size=10).launch(
1998
+ theme=THEME, css=CSS, head=HEAD_SCRIPT, show_error=True,
1999
  allowed_paths=[os.path.join(os.path.dirname(__file__), "examples")])