Ben Blaker Claude Opus 4.8 (1M context) commited on
Commit
30816a1
·
unverified ·
1 Parent(s): d6d25e1

feat(audio): per-board music with loudness-matched crossfade (#72)

Browse files

Give the bonus and critters boards their own background loops and crossfade
between tracks on every level transition.

- bonus → corporate-glitch, critters → farm; home keeps Cipher2.
- Each Level carries a music basename + a loudness trim (music_gain). The trims
(0.349, 0.365) match each track to Cipher2's -19.74 LUFS (measured via EBU
R128) so nothing jumps in volume across a transition.
- Music is now a per-track voice pool (one <audio> + gain node each) feeding the
shared music bus; loadLevel crossfades by ramping the outgoing track to 0 and
the incoming up to its trim. Mixer slider, mute, and the glitch-board low-pass
still level every track together.

Verified with Playwright against the live Web Audio graph: home→bonus→critters
each select the right track, crossfade (both tracks overlap mid-fade), settle on
the exact trim, and park the outgoing voice; farm decodes and plays
(HAVE_ENOUGH_DATA); no console errors.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

.gitattributes CHANGED
@@ -1,2 +1,4 @@
1
  static/Cipher2.mp3 filter=lfs diff=lfs merge=lfs -text
 
 
2
  docs/*.png filter=lfs diff=lfs merge=lfs -text
 
1
  static/Cipher2.mp3 filter=lfs diff=lfs merge=lfs -text
2
+ static/farm.mp3 filter=lfs diff=lfs merge=lfs -text
3
+ static/corporate-glitch.mp3 filter=lfs diff=lfs merge=lfs -text
4
  docs/*.png filter=lfs diff=lfs merge=lfs -text
app.py CHANGED
@@ -19,15 +19,31 @@ _SCRATCH = base64.b64encode((STATIC / "pencil-scratch.mp3").read_bytes()).decode
19
  # root so it still holds up inside the HF Spaces iframe.
20
  gr.set_static_paths(paths=[STATIC])
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  # Everything the board needs to render and switch levels client-side: the play
23
  # order, the boot board, each level's client-facing slice, the pen-scratch
24
- # sample (data URI), and the looping background-music URL (file route).
25
  GAME = {
26
- "levels": [LEVELS[lid].client_value() for lid in LEVEL_ORDER],
27
  "order": LEVEL_ORDER,
28
  "home": HOME_ID,
29
  "scratchAudio": "data:audio/mpeg;base64," + _SCRATCH,
30
- "music": "gradio_api/file=" + str(STATIC / "Cipher2.mp3"),
31
  }
32
 
33
  HEAD = """
 
19
  # root so it still holds up inside the HF Spaces iframe.
20
  gr.set_static_paths(paths=[STATIC])
21
 
22
+ # A static file streamed via the Gradio file route. game.js resolves the URL
23
+ # against the Gradio root so it still holds up inside the HF Spaces iframe.
24
+ def _file_route(name: str) -> str:
25
+ return "gradio_api/file=" + str(STATIC / name)
26
+
27
+
28
+ # Each board may name its own background loop (Level.music, a static/ basename);
29
+ # resolve those to file-route URLs here so the Level data stays path-agnostic.
30
+ # Boards without one play the default loop (Cipher2). game.js crossfades between
31
+ # tracks on transition; per-board music_gain keeps them at a matched loudness.
32
+ def _level_client(lid: str) -> dict:
33
+ cv = LEVELS[lid].client_value()
34
+ cv["music"] = _file_route(cv["music"]) if cv["music"] else ""
35
+ return cv
36
+
37
+
38
  # Everything the board needs to render and switch levels client-side: the play
39
  # order, the boot board, each level's client-facing slice, the pen-scratch
40
+ # sample (data URI), and the default looping background-music URL (file route).
41
  GAME = {
42
+ "levels": [_level_client(lid) for lid in LEVEL_ORDER],
43
  "order": LEVEL_ORDER,
44
  "home": HOME_ID,
45
  "scratchAudio": "data:audio/mpeg;base64," + _SCRATCH,
46
+ "music": _file_route("Cipher2.mp3"),
47
  }
48
 
49
  HEAD = """
levels/_base.py CHANGED
@@ -33,6 +33,12 @@ class Level:
33
  # negative; "?" becomes a shrink key and file tiles blow the context (see game.js)
34
  portal_to: str = "" # if set, this board's portal tiles are a cross-level
35
  # link to that level id (instead of an in-board teleport)
 
 
 
 
 
 
36
 
37
  def client_value(self) -> dict:
38
  """The subset shipped to the browser — no judge internals leave the server."""
@@ -46,4 +52,6 @@ class Level:
46
  "budget": self.budget,
47
  "glitch": self.glitch,
48
  "portal_to": self.portal_to,
 
 
49
  }
 
33
  # negative; "?" becomes a shrink key and file tiles blow the context (see game.js)
34
  portal_to: str = "" # if set, this board's portal tiles are a cross-level
35
  # link to that level id (instead of an in-board teleport)
36
+ music: str = "" # basename of this board's background loop under static/
37
+ # (e.g. "farm.mp3"); "" plays the default loop. game.js crossfades between
38
+ # boards' tracks on transition.
39
+ music_gain: float = 1.0 # linear trim applied to this track so every board
40
+ # sits at the same loudness as the default loop (measured by EBU R128 / LUFS);
41
+ # nothing jumps in volume across a transition.
42
 
43
  def client_value(self) -> dict:
44
  """The subset shipped to the browser — no judge internals leave the server."""
 
52
  "budget": self.budget,
53
  "glitch": self.glitch,
54
  "portal_to": self.portal_to,
55
+ "music": self.music,
56
+ "music_gain": self.music_gain,
57
  }
levels/animal.py CHANGED
@@ -27,4 +27,6 @@ LEVEL = Level(
27
  budget=12,
28
  order=2, # last: reached from the bonus board through the portal
29
  home=False,
 
 
30
  )
 
27
  budget=12,
28
  order=2, # last: reached from the bonus board through the portal
29
  home=False,
30
+ music="farm.mp3", # sweet-country-farm loop, fits the critters
31
+ music_gain=0.365, # trim to match Cipher2's loudness (track is ~9 LUFS hotter)
32
  )
levels/bonus.py CHANGED
@@ -37,4 +37,6 @@ LEVEL = Level(
37
  order=1, # second board: feelings → bonus → critters (via the portal)
38
  glitch=True,
39
  portal_to="animal", # the spiral whisks you to the critters board and back
 
 
40
  )
 
37
  order=1, # second board: feelings → bonus → critters (via the portal)
38
  glitch=True,
39
  portal_to="animal", # the spiral whisks you to the critters board and back
40
+ music="corporate-glitch.mp3", # the glitchy corporate sting for the finale
41
+ music_gain=0.349, # trim to match Cipher2's loudness (track is ~9 LUFS hotter)
42
  )
static/corporate-glitch.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4fca1fa8d43805539f3236f1fbd742bcabacb9169425970c344b7ea42659442d
3
+ size 4499748
static/farm.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:295bc128372ee9d4c612a457d8812f59b6772f627673833e997e74cb2c374d9b
3
+ size 6065423
static/game.js CHANGED
@@ -1566,45 +1566,103 @@
1566
  },
1567
  };
1568
 
1569
- // ---- background music: a looping track on the music bus ----
1570
- // A single recorded loop (Cipher2) plays under the game via an <audio>
1571
- // element wired into musicGain, so the mixer's music slider levels and mutes
1572
- // it exactly like before the bus, prefs, and gesture-unlock are unchanged.
1573
- let musicEl = null; // the <audio> element, created on first play
1574
- let musicSrc = null; // its MediaElementAudioSourceNode (only creatable once)
1575
- let musicPlay = Promise.resolve(); // the last play() promise, so a quick mute
1576
- // can wait it out instead of interrupting it (see stopMusic)
1577
-
1578
- // data.music is a "gradio_api/file=…" path. An <audio> already resolves it
1579
- // against the document, but inside the HF iframe the file route lives under
 
 
1580
  // the Gradio root — so prefer that root when the config exposes it, and fall
1581
  // back to document-relative (correct on localhost and direct embeds).
1582
- function musicUrl() {
1583
  const root = (window.gradio_config && window.gradio_config.root) || "";
1584
  const base = root.replace(/\/+$/, "");
1585
- return base ? base + "/" + data.music : data.music;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1586
  }
1587
 
1588
  function startMusic() {
1589
  if (!audio()) return;
1590
- if (!musicEl) {
1591
- musicEl = new Audio(musicUrl());
1592
- musicEl.loop = true;
1593
- musicEl.preload = "auto";
1594
- musicSrc = ac.createMediaElementSource(musicEl);
1595
- musicSrc.connect(musicGain);
1596
- }
1597
- // play() is gesture-gated; unlockAudio drives the first call from a real tap
1598
- musicPlay = musicEl.play() || Promise.resolve();
1599
- musicPlay.catch(() => {});
1600
  }
1601
  function stopMusic() {
1602
- if (!musicEl) return;
1603
- // Pausing while a play() is still pending throws AbortError and can wedge
1604
- // the element (a fast mute→unmute mid-load would then stay silent). So wait
1605
- // for the play to settle, then pause only if we still mean to be off —
1606
- // musicGain is already at 0, so nothing is audible in the meantime anyway.
1607
- musicPlay.then(() => { if (musicVol === 0 && musicEl) musicEl.pause(); }).catch(() => {});
 
1608
  }
1609
 
1610
  // ---- audio toggles: hand-drawn ♫ + speaker doodles in the top-right ----
@@ -3153,6 +3211,7 @@
3153
  // leaving the glitch board (e.g. onto critters) returns the doodle to size
3154
  if (!level.glitch && shrunk) { shrunk = false; charGroup.scale.set(1, 1, 1); }
3155
  setGlitchMode(!!level.glitch); // a glitch board inverts the whole screen
 
3156
  state = "hopping"; // block input until the poof lands (then -> idle)
3157
  buildTiles();
3158
  buildTargets();
 
1566
  },
1567
  };
1568
 
1569
+ // ---- background music: per-board loops, crossfaded on transition ----
1570
+ // Each board names its own loop (data.levels[].music bonus = corporate-glitch,
1571
+ // critters = farm) and a loudness trim (.music_gain); boards without one play
1572
+ // the default loop (data.music, Cipher2). Every track gets its own <audio> +
1573
+ // gain node ("voice") feeding the shared musicGain bus, so the mixer slider,
1574
+ // mute, and the glitch low-pass still level them all together. Switching boards
1575
+ // ramps the outgoing voice to 0 and the incoming up to its trim a crossfade.
1576
+ const CROSSFADE = 1.4; // seconds; rides under the board drop-in
1577
+ const voices = new Map(); // url -> { el, src, gain, play }
1578
+ let curUrl = null; // the track currently faded up (its <audio> is the one playing)
1579
+
1580
+ // data.music / level.music are "gradio_api/file=…" paths. An <audio> resolves
1581
+ // them against the document, but inside the HF iframe the file route lives under
1582
  // the Gradio root — so prefer that root when the config exposes it, and fall
1583
  // back to document-relative (correct on localhost and direct embeds).
1584
+ function musicUrl(path) {
1585
  const root = (window.gradio_config && window.gradio_config.root) || "";
1586
  const base = root.replace(/\/+$/, "");
1587
+ return base ? base + "/" + path : path;
1588
+ }
1589
+
1590
+ // the track + loudness trim for the active board (its own, else the default)
1591
+ function trackForLevel() {
1592
+ return { url: musicUrl(level.music || data.music), trim: level.music ? (level.music_gain || 1) : 1 };
1593
+ }
1594
+
1595
+ // a voice is lazily built per track; the MediaElementSource can only be made
1596
+ // once per element, so we cache by url and reuse on revisits.
1597
+ function voiceFor(url) {
1598
+ let v = voices.get(url);
1599
+ if (!v) {
1600
+ const el = new Audio(url);
1601
+ el.loop = true;
1602
+ el.preload = "auto";
1603
+ const src = ac.createMediaElementSource(el);
1604
+ const gain = ac.createGain();
1605
+ gain.gain.value = 0; // silent until faded in
1606
+ src.connect(gain).connect(musicGain);
1607
+ v = { el, src, gain, play: Promise.resolve() };
1608
+ voices.set(url, v);
1609
+ }
1610
+ return v;
1611
+ }
1612
+ function playVoice(v) {
1613
+ // play() is gesture-gated; unlockAudio drives the first call from a real tap
1614
+ v.play = v.el.play() || Promise.resolve();
1615
+ v.play.catch(() => {});
1616
+ }
1617
+
1618
+ // Crossfade to `url`: fade its voice up to `trim`, fade the current one down to
1619
+ // 0 and park it. Idempotent if `url` is already current. The bus gain (musicGain)
1620
+ // is multiplied on top, so a muted bus keeps the swap silent.
1621
+ function crossfadeTo(url, trim) {
1622
+ if (!audio() || url === curUrl) return;
1623
+ const prevUrl = curUrl;
1624
+ curUrl = url;
1625
+ const next = voiceFor(url);
1626
+ const t = ac.currentTime;
1627
+ if (musicVol > 0) playVoice(next); // gesture-gated; bus is 0 anyway when muted
1628
+ next.gain.gain.cancelScheduledValues(t);
1629
+ next.gain.gain.setValueAtTime(next.gain.gain.value, t);
1630
+ next.gain.gain.linearRampToValueAtTime(trim, t + CROSSFADE);
1631
+ const prev = prevUrl && voices.get(prevUrl);
1632
+ if (prev) {
1633
+ prev.gain.gain.cancelScheduledValues(t);
1634
+ prev.gain.gain.setValueAtTime(prev.gain.gain.value, t);
1635
+ prev.gain.gain.linearRampToValueAtTime(0, t + CROSSFADE);
1636
+ // park the element once it's silent — unless it became current again. Wait
1637
+ // out its play() first so the pause can't race a pending play (AbortError).
1638
+ gsap.delayedCall(CROSSFADE + 0.1, () => {
1639
+ if (curUrl !== prevUrl) prev.play.then(() => { if (curUrl !== prevUrl) prev.el.pause(); }).catch(() => {});
1640
+ });
1641
+ }
1642
+ }
1643
+
1644
+ // Switch to the active board's track. No-ops until the context exists (boot
1645
+ // runs before any gesture); unlockAudio starts the right track on first tap.
1646
+ function updateMusicTrack() {
1647
+ if (!ac) return;
1648
+ const { url, trim } = trackForLevel();
1649
+ crossfadeTo(url, trim);
1650
  }
1651
 
1652
  function startMusic() {
1653
  if (!audio()) return;
1654
+ const { url, trim } = trackForLevel();
1655
+ if (curUrl === url) playVoice(voiceFor(url)); // unmuting the current track
1656
+ else crossfadeTo(url, trim); // first play, or catch up after a muted swap
 
 
 
 
 
 
 
1657
  }
1658
  function stopMusic() {
1659
+ const v = curUrl && voices.get(curUrl);
1660
+ if (!v) return;
1661
+ // Pausing while a play() is still pending throws AbortError and can wedge the
1662
+ // element (a fast mute→unmute mid-load would then stay silent). So wait for the
1663
+ // play to settle, then pause only if we still mean to be off — the bus is
1664
+ // already at 0, so nothing is audible in the meantime anyway.
1665
+ v.play.then(() => { if (musicVol === 0) v.el.pause(); }).catch(() => {});
1666
  }
1667
 
1668
  // ---- audio toggles: hand-drawn ♫ + speaker doodles in the top-right ----
 
3211
  // leaving the glitch board (e.g. onto critters) returns the doodle to size
3212
  if (!level.glitch && shrunk) { shrunk = false; charGroup.scale.set(1, 1, 1); }
3213
  setGlitchMode(!!level.glitch); // a glitch board inverts the whole screen
3214
+ updateMusicTrack(); // crossfade to this board's loop (no-op before first gesture)
3215
  state = "hopping"; // block input until the poof lands (then -> idle)
3216
  buildTiles();
3217
  buildTargets();