Ben Blaker Claude Opus 4.8 (1M context) commited on
Commit
fc31f3c
·
unverified ·
1 Parent(s): c949a43

feat(audio): replace synth bg music with the Cipher2 track (#28)

Browse files

Swap 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>

Files changed (3) hide show
  1. app.py +10 -2
  2. static/Cipher2.mp3 +3 -0
  3. static/game.js +34 -58
app.py CHANGED
@@ -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
- # Everything the board needs to render and switch levels client-side: the
17
- # play order, the boot board, and each level's client-facing slice.
 
 
 
 
 
 
 
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 = """
static/Cipher2.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8a98a1b6929404b538dd8b02c6f967a1635448730dcd20bd832fcee4d362cf54
3
+ size 3703244
static/game.js CHANGED
@@ -937,69 +937,45 @@
937
  },
938
  };
939
 
940
- // ---- background music: a gentle generative pentatonic doodle ----
941
- // Same "no samples" spirit as the sfx a soft melody noodles around a
942
- // C-major pentatonic (so nothing ever clashes) over a quiet I–vi–IV–V bass.
943
- // A lookahead scheduler clocks notes off ac.currentTime so it never drifts.
944
-
945
- // C-major pentatonic, C4 up to G5 the melody random-walks this ladder.
946
- const PENTA = [261.63, 293.66, 329.63, 392.0, 440.0, 523.25, 587.33, 659.25, 783.99];
947
- const BASS = [130.81, 110.0, 87.31, 98.0]; // one root per bar: C, A, F, G
948
- const MUSIC_BPM = 84;
949
- const STEP_DUR = 60 / MUSIC_BPM / 2; // eighth-note grid
950
- const STEPS = 16; // loop length 4 bars of 4 eighths
951
-
952
- // one warm, gently-enveloped voice through a lowpass: no clicks, no glare
953
- function musicNote(freq, at, dur, vol, type) {
954
- if (!musicGain) return;
955
- const osc = ac.createOscillator();
956
- const g = ac.createGain();
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 (musicTimer || !audio()) return;
996
- musicNextTime = ac.currentTime + 0.1;
997
- musicStep = 0;
998
- musicTimer = setInterval(scheduleMusic, 25);
 
 
 
 
 
 
 
999
  }
1000
  function stopMusic() {
1001
- if (musicTimer) clearInterval(musicTimer);
1002
- musicTimer = null;
 
 
 
 
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 ----