feat(audio): replace synth bg music with the Cipher2 track (#28)
Browse filesSwap the procedural C-pentatonic synth loop for a recorded track,
downsampled to 128 kbps (~3.7 MB). It's too big to inline as a data URI
like the pen-scratch SFX (that would bloat every page load), so it
streams from static/ via gr.set_static_paths and the file route, with
the URL resolved against the Gradio root so it holds up inside the HF
Spaces iframe.
A looping <audio> element feeds the existing musicGain bus, so the
in-game mixer (slider, mute, localStorage persistence, first-gesture
unlock) drives it unchanged. The pause-on-mute is deferred until the
pending play() settles, so a fast mute->unmute mid-load can't wedge the
element.
Verified locally (Gradio 6.18, Playwright): the file route serves
audio/mpeg with range support, the track plays and loops in real time
through the bus, and mute/unmute -- including rapid toggles during load
-- behaves correctly, with no console errors. HF-iframe streaming still
needs a real deploy to confirm.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- app.py +10 -2
- static/Cipher2.mp3 +3 -0
- static/game.js +34 -58
|
@@ -13,13 +13,21 @@ STATIC = pathlib.Path(__file__).parent / "static"
|
|
| 13 |
# a Web Audio buffer; see sfx.scratch in game.js.
|
| 14 |
_SCRATCH = base64.b64encode((STATIC / "pencil-scratch.mp3").read_bytes()).decode()
|
| 15 |
|
| 16 |
-
#
|
| 17 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
GAME = {
|
| 19 |
"levels": [LEVELS[lid].client_value() for lid in LEVEL_ORDER],
|
| 20 |
"order": LEVEL_ORDER,
|
| 21 |
"home": HOME_ID,
|
| 22 |
"scratchAudio": "data:audio/mpeg;base64," + _SCRATCH,
|
|
|
|
| 23 |
}
|
| 24 |
|
| 25 |
HEAD = """
|
|
|
|
| 13 |
# a Web Audio buffer; see sfx.scratch in game.js.
|
| 14 |
_SCRATCH = base64.b64encode((STATIC / "pencil-scratch.mp3").read_bytes()).decode()
|
| 15 |
|
| 16 |
+
# The background-music track is far too big to inline like the scratch above
|
| 17 |
+
# (a multi-MB loop would bloat every page load), so it streams from the static
|
| 18 |
+
# dir via the file route instead. game.js resolves the URL against the Gradio
|
| 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 = """
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8a98a1b6929404b538dd8b02c6f967a1635448730dcd20bd832fcee4d362cf54
|
| 3 |
+
size 3703244
|
|
@@ -937,69 +937,45 @@
|
|
| 937 |
},
|
| 938 |
};
|
| 939 |
|
| 940 |
-
// ---- background music: a
|
| 941 |
-
//
|
| 942 |
-
//
|
| 943 |
-
//
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
//
|
| 953 |
-
function
|
| 954 |
-
|
| 955 |
-
const
|
| 956 |
-
|
| 957 |
-
const lp = ac.createBiquadFilter();
|
| 958 |
-
lp.type = "lowpass";
|
| 959 |
-
lp.frequency.value = 2000;
|
| 960 |
-
osc.type = type || "triangle";
|
| 961 |
-
osc.frequency.value = freq;
|
| 962 |
-
g.gain.setValueAtTime(0, at);
|
| 963 |
-
g.gain.linearRampToValueAtTime(vol, at + 0.03);
|
| 964 |
-
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
|
| 965 |
-
osc.connect(lp).connect(g).connect(musicGain);
|
| 966 |
-
osc.start(at);
|
| 967 |
-
osc.stop(at + dur + 0.05);
|
| 968 |
-
}
|
| 969 |
-
|
| 970 |
-
let musicTimer = null;
|
| 971 |
-
let musicStep = 0;
|
| 972 |
-
let musicNextTime = 0;
|
| 973 |
-
let melodyIdx = 4; // start mid-ladder (A4)
|
| 974 |
-
|
| 975 |
-
// Schedule everything that falls inside the ~120ms lookahead, then advance.
|
| 976 |
-
function scheduleMusic() {
|
| 977 |
-
if (!ac) return;
|
| 978 |
-
while (musicNextTime < ac.currentTime + 0.12) {
|
| 979 |
-
const bar = Math.floor(musicStep / 4) % BASS.length;
|
| 980 |
-
if (musicStep % 4 === 0) musicNote(BASS[bar], musicNextTime, 0.9, 0.32, "sine"); // bass on the downbeat
|
| 981 |
-
// melody: a smooth random walk with rests for a relaxed, sketchy feel
|
| 982 |
-
if (Math.random() < 0.62) {
|
| 983 |
-
const stride = (Math.floor(Math.random() * 3) - 1) * (Math.random() < 0.3 ? 2 : 1);
|
| 984 |
-
melodyIdx = Math.max(0, Math.min(PENTA.length - 1, melodyIdx + stride));
|
| 985 |
-
const long = Math.random() < 0.25;
|
| 986 |
-
musicNote(PENTA[melodyIdx], musicNextTime, STEP_DUR * (long ? 1.8 : 0.9), 0.17);
|
| 987 |
-
}
|
| 988 |
-
if (Math.random() < 0.05) musicNote(PENTA[PENTA.length - 1] * 2, musicNextTime, 0.3, 0.07); // rare high sparkle
|
| 989 |
-
musicNextTime += STEP_DUR;
|
| 990 |
-
musicStep = (musicStep + 1) % STEPS;
|
| 991 |
-
}
|
| 992 |
}
|
| 993 |
|
| 994 |
function startMusic() {
|
| 995 |
-
if (
|
| 996 |
-
|
| 997 |
-
|
| 998 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 999 |
}
|
| 1000 |
function stopMusic() {
|
| 1001 |
-
if (
|
| 1002 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1003 |
}
|
| 1004 |
|
| 1005 |
// ---- audio toggles: hand-drawn ♫ + speaker doodles in the top-right ----
|
|
|
|
| 937 |
},
|
| 938 |
};
|
| 939 |
|
| 940 |
+
// ---- background music: a looping track on the music bus ----
|
| 941 |
+
// A single recorded loop (Cipher2) plays under the game via an <audio>
|
| 942 |
+
// element wired into musicGain, so the mixer's music slider levels and mutes
|
| 943 |
+
// it exactly like before — the bus, prefs, and gesture-unlock are unchanged.
|
| 944 |
+
let musicEl = null; // the <audio> element, created on first play
|
| 945 |
+
let musicSrc = null; // its MediaElementAudioSourceNode (only creatable once)
|
| 946 |
+
let musicPlay = Promise.resolve(); // the last play() promise, so a quick mute
|
| 947 |
+
// can wait it out instead of interrupting it (see stopMusic)
|
| 948 |
+
|
| 949 |
+
// data.music is a "gradio_api/file=…" path. An <audio> already resolves it
|
| 950 |
+
// against the document, but inside the HF iframe the file route lives under
|
| 951 |
+
// the Gradio root — so prefer that root when the config exposes it, and fall
|
| 952 |
+
// back to document-relative (correct on localhost and direct embeds).
|
| 953 |
+
function musicUrl() {
|
| 954 |
+
const root = (window.gradio_config && window.gradio_config.root) || "";
|
| 955 |
+
const base = root.replace(/\/+$/, "");
|
| 956 |
+
return base ? base + "/" + data.music : data.music;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 957 |
}
|
| 958 |
|
| 959 |
function startMusic() {
|
| 960 |
+
if (!audio()) return;
|
| 961 |
+
if (!musicEl) {
|
| 962 |
+
musicEl = new Audio(musicUrl());
|
| 963 |
+
musicEl.loop = true;
|
| 964 |
+
musicEl.preload = "auto";
|
| 965 |
+
musicSrc = ac.createMediaElementSource(musicEl);
|
| 966 |
+
musicSrc.connect(musicGain);
|
| 967 |
+
}
|
| 968 |
+
// play() is gesture-gated; unlockAudio drives the first call from a real tap
|
| 969 |
+
musicPlay = musicEl.play() || Promise.resolve();
|
| 970 |
+
musicPlay.catch(() => {});
|
| 971 |
}
|
| 972 |
function stopMusic() {
|
| 973 |
+
if (!musicEl) return;
|
| 974 |
+
// Pausing while a play() is still pending throws AbortError and can wedge
|
| 975 |
+
// the element (a fast mute→unmute mid-load would then stay silent). So wait
|
| 976 |
+
// for the play to settle, then pause only if we still mean to be off —
|
| 977 |
+
// musicGain is already at 0, so nothing is audible in the meantime anyway.
|
| 978 |
+
musicPlay.then(() => { if (musicVol === 0 && musicEl) musicEl.pause(); }).catch(() => {});
|
| 979 |
}
|
| 980 |
|
| 981 |
// ---- audio toggles: hand-drawn ♫ + speaker doodles in the top-right ----
|