Spaces:
Sleeping
feat(audio): per-board music with loudness-matched crossfade (#72)
Browse filesGive 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 +2 -0
- app.py +19 -3
- levels/_base.py +8 -0
- levels/animal.py +2 -0
- levels/bonus.py +2 -0
- static/corporate-glitch.mp3 +3 -0
- static/farm.mp3 +3 -0
- static/game.js +88 -29
|
@@ -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
|
|
@@ -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": [
|
| 27 |
"order": LEVEL_ORDER,
|
| 28 |
"home": HOME_ID,
|
| 29 |
"scratchAudio": "data:audio/mpeg;base64," + _SCRATCH,
|
| 30 |
-
"music":
|
| 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 = """
|
|
@@ -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 |
}
|
|
@@ -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 |
)
|
|
@@ -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 |
)
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:4fca1fa8d43805539f3236f1fbd742bcabacb9169425970c344b7ea42659442d
|
| 3 |
+
size 4499748
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:295bc128372ee9d4c612a457d8812f59b6772f627673833e997e74cb2c374d9b
|
| 3 |
+
size 6065423
|
|
@@ -1566,45 +1566,103 @@
|
|
| 1566 |
},
|
| 1567 |
};
|
| 1568 |
|
| 1569 |
-
// ---- background music:
|
| 1570 |
-
//
|
| 1571 |
-
//
|
| 1572 |
-
//
|
| 1573 |
-
|
| 1574 |
-
|
| 1575 |
-
|
| 1576 |
-
|
| 1577 |
-
|
| 1578 |
-
//
|
| 1579 |
-
|
|
|
|
|
|
|
| 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 + "/" +
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1586 |
}
|
| 1587 |
|
| 1588 |
function startMusic() {
|
| 1589 |
if (!audio()) return;
|
| 1590 |
-
|
| 1591 |
-
|
| 1592 |
-
|
| 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 |
-
|
| 1603 |
-
|
| 1604 |
-
//
|
| 1605 |
-
//
|
| 1606 |
-
//
|
| 1607 |
-
|
|
|
|
| 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();
|