From a5f8643648589f6a82215489fef5ef11170a9e8e Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 13:41:16 +1000 Subject: [PATCH 1/2] feat(console): persist music across pages, per-theme tunes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Music state now survives navigation and console close: - persist `music`/`musicVolume` to localStorage, restore on load (falls back to first user gesture when autoplay is blocked) - closing the console no longer stops playback; `music stop` does Tunes extracted to assets/js/tunes.js, keyed by theme. Engine reads the active theme's tune and swaps live on `theme set`: - default (light/dark/auto): original C-major triangle, unchanged - dracula: A-minor square lead, slower — moody but chill - valentine: F-major sine lead — soft and sweet console.html concats tunes.js + console.js so window.TUNES exists before the engine reads it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RbMGFzZfHUfbhid95FjBKb --- assets/js/console.js | 52 ++++++++++++++++++++++++++--------- assets/js/tunes.js | 36 ++++++++++++++++++++++++ layouts/partials/console.html | 5 +++- 3 files changed, 79 insertions(+), 14 deletions(-) create mode 100644 assets/js/tunes.js diff --git a/assets/js/console.js b/assets/js/console.js index 19ada56..f69149e 100644 --- a/assets/js/console.js +++ b/assets/js/console.js @@ -152,14 +152,12 @@ log.appendChild(el); }; - // ---- 8-bit soundtrack: calm C-major pentatonic, WebAudio, no assets ---- - // 32 eighth-notes. midi note, 0 = rest. Sparse melody that rings. + // ---- 8-bit soundtrack: WebAudio, no assets. Tunes live in tunes.js ---- + // (window.TUNES), keyed by theme; the engine just plays the active one. + var TUNES = (typeof window !== "undefined" && window.TUNES) || {}; var chiptune = (function () { - var lead = [72, 0, 76, 0, 79, 0, 76, 0, 74, 0, 77, 0, 81, 0, 79, 0, - 72, 0, 76, 0, 84, 0, 79, 0, 81, 0, 79, 0, 76, 0, 74, 0]; - var bass = [48, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, - 41, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0]; - var spn = 60 / 84 / 2; // seconds per eighth-note at a gentle 84bpm + var tune = TUNES.default || { bpm: 84, wave: "triangle", lead: [0], bass: [0] }; + var spn = 60 / tune.bpm / 2; // seconds per eighth-note var ctx, master, timer, nextTime, step, playing = false, vol = 0.5; var freq = function (m) { return 440 * Math.pow(2, (m - 69) / 12); }; var voice = function (m, t, dur, type, peak) { // peak: per-note gain (distinct from master vol) @@ -173,13 +171,19 @@ }; var tick = function () { while (nextTime < ctx.currentTime + 0.12) { - voice(lead[step], nextTime, spn * 2.2, "triangle", 0.14); // let it ring - voice(bass[step], nextTime, spn * 7, "sine", 0.12); // slow pad - step = (step + 1) % lead.length; + voice(tune.lead[step], nextTime, spn * 2.2, tune.wave || "triangle", 0.14); // let it ring + voice(tune.bass[step], nextTime, spn * 7, "sine", 0.12); // slow pad + step = (step + 1) % tune.lead.length; nextTime += spn; } }; return { + // Swap the soundtrack to match a theme (light/dark/auto → default). + setTune: function (name) { + var t = TUNES[name] || TUNES.default; + if (!t || t === tune) return; + tune = t; spn = 60 / tune.bpm / 2; step = 0; + }, // Resolves once audio is actually running; rejects if the browser blocks it. on: function () { if (playing) return Promise.resolve(); @@ -198,9 +202,29 @@ }; })(); + // Restore music across page navigations. Autoplay policy blocks a fresh + // load with no user gesture, so if immediate resume is refused we start on + // the first interaction. ponytail: relies on stored pref, not tab session. + (function () { + var v = localStorage.getItem("musicVolume"); + if (v != null) chiptune.setVolume(+v); + if (localStorage.getItem("music") !== "on") return; + chiptune.setTune(document.documentElement.dataset.theme); + var arm = function () { + document.removeEventListener("pointerdown", arm); + document.removeEventListener("keydown", arm); + chiptune.on().then(function () {}, function () {}); + }; + chiptune.on().then(function () {}, function () { + document.addEventListener("pointerdown", arm); + document.addEventListener("keydown", arm); + }); + })(); + var applyTheme = function (mode) { if (mode === "auto") { localStorage.removeItem("theme"); delete document.documentElement.dataset.theme; } else { localStorage.setItem("theme", mode); document.documentElement.dataset.theme = mode; } + chiptune.setTune(mode); // swap soundtrack live if music is playing }; var open = function () { @@ -209,7 +233,7 @@ input.focus(); }; var close = function () { - chiptune.off(); + // Music keeps playing when closed — stop it with `music stop`. root.hidden = true; root.classList.remove("console--docked"); root.style.left = root.style.top = root.style.right = root.style.bottom = ""; @@ -242,13 +266,15 @@ res.lines.forEach(function (l) { write(l); }); if (res.theme) applyTheme(res.theme); if (res.music === "on") { + localStorage.setItem("music", "on"); + chiptune.setTune(document.documentElement.dataset.theme); chiptune.on().then( function () { write({ text: "♪ music on" }); log.scrollTop = log.scrollHeight; }, function () { write({ text: "music blocked by the browser — interact with the page and retry." }); log.scrollTop = log.scrollHeight; } ); } - if (res.music === "off") { chiptune.off(); write({ text: "music off" }); } - if (res.volume != null) chiptune.setVolume(res.volume); + if (res.music === "off") { localStorage.removeItem("music"); chiptune.off(); write({ text: "music off" }); } + if (res.volume != null) { localStorage.setItem("musicVolume", res.volume); chiptune.setVolume(res.volume); } input.value = ""; histIdx = cmdHistory.length; // reset recall to the (empty) current line log.scrollTop = log.scrollHeight; diff --git a/assets/js/tunes.js b/assets/js/tunes.js new file mode 100644 index 0000000..94363a7 --- /dev/null +++ b/assets/js/tunes.js @@ -0,0 +1,36 @@ +// Per-theme 8-bit soundtracks for the console. The player engine lives in +// console.js; this is just the note data. Each tune: { bpm, wave?, lead[], +// bass[] } — MIDI notes, 0 = rest, 32 eighth-notes per loop. `wave` is the lead +// oscillator (bass is always a soft sine). Keyed by theme name; `default` +// covers light/dark/auto, and any theme without an entry falls back to it. +// Kept chill everywhere: slow tempo, sparse notes, lots of rests so it rings. +(function (root) { + var TUNES = { + // Calm C-major pentatonic — the original. Bright and neutral. + default: { + bpm: 84, wave: "triangle", + lead: [72, 0, 76, 0, 79, 0, 76, 0, 74, 0, 77, 0, 81, 0, 79, 0, + 72, 0, 76, 0, 84, 0, 79, 0, 81, 0, 79, 0, 76, 0, 74, 0], + bass: [48, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, + 41, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0] + }, + // A natural minor, slower and hollow (square lead) — moody but unhurried. + dracula: { + bpm: 72, wave: "square", + lead: [69, 0, 72, 0, 76, 0, 72, 0, 74, 0, 72, 0, 69, 0, 67, 0, + 81, 0, 79, 0, 76, 0, 72, 0, 74, 0, 72, 0, 69, 0, 69, 0], + bass: [45, 0, 0, 0, 0, 0, 0, 0, 41, 0, 0, 0, 0, 0, 0, 0, + 38, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, 0, 0, 0] + }, + // F major, warm and sweet (soft sine lead) — a gentle, romantic lilt. + valentine: { + bpm: 80, wave: "sine", + lead: [72, 0, 74, 0, 77, 0, 76, 0, 74, 0, 72, 0, 69, 0, 72, 0, + 77, 0, 81, 0, 79, 0, 77, 0, 76, 0, 74, 0, 72, 0, 72, 0], + bass: [41, 0, 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 0, + 34, 0, 0, 0, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0] + } + }; + if (typeof module !== "undefined" && module.exports) module.exports = TUNES; + else root.TUNES = TUNES; +})(typeof window !== "undefined" ? window : this); diff --git a/layouts/partials/console.html b/layouts/partials/console.html index 7c8ae76..3abe9e4 100644 --- a/layouts/partials/console.html +++ b/layouts/partials/console.html @@ -21,5 +21,8 @@ -{{ $js := resources.Get "js/console.js" | resources.Minify | resources.Fingerprint }} +{{/* tunes.js sets window.TUNES; console.js reads it — concat keeps that order. */}} +{{ $tunes := resources.Get "js/tunes.js" }} +{{ $console := resources.Get "js/console.js" }} +{{ $js := slice $tunes $console | resources.Concat "js/console.bundle.js" | resources.Minify | resources.Fingerprint }} From 90ffdf6326c2abb3c199ac3c8460a5db31f8b311 Mon Sep 17 00:00:00 2001 From: James Jiang Date: Tue, 28 Jul 2026 13:50:45 +1000 Subject: [PATCH 2/2] feat(console): paper tune, longer resolving loops, fix play state - add a distinct `paper` soundtrack (D-major folk, plagal turnaround) - extend dracula/valentine to 8-bar phrases that resolve to the tonic so the loop wraps seamlessly - fix music not playing: the load-time restore called on() with no user gesture, leaving resume() pending while `playing` stayed true, which wedged every later `music play`. Restore now arms a one-time gesture listener instead; on() clears any stale interval. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01RbMGFzZfHUfbhid95FjBKb --- assets/js/console.js | 11 +++++------ assets/js/tunes.js | 37 ++++++++++++++++++++++++++++++------- 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/assets/js/console.js b/assets/js/console.js index f69149e..5cb147e 100644 --- a/assets/js/console.js +++ b/assets/js/console.js @@ -194,7 +194,7 @@ playing = true; step = 0; return Promise.resolve(ctx.resume()).then(function () { nextTime = ctx.currentTime + 0.05; - tick(); timer = setInterval(tick, 25); + clearInterval(timer); tick(); timer = setInterval(tick, 25); }, function (err) { playing = false; throw err; }); }, off: function () { playing = false; clearInterval(timer); }, @@ -203,7 +203,8 @@ })(); // Restore music across page navigations. Autoplay policy blocks a fresh - // load with no user gesture, so if immediate resume is refused we start on + // load with no user gesture, so we never call on() here (a gesture-less + // resume() stays pending and would wedge `playing`); instead we resume on // the first interaction. ponytail: relies on stored pref, not tab session. (function () { var v = localStorage.getItem("musicVolume"); @@ -215,10 +216,8 @@ document.removeEventListener("keydown", arm); chiptune.on().then(function () {}, function () {}); }; - chiptune.on().then(function () {}, function () { - document.addEventListener("pointerdown", arm); - document.addEventListener("keydown", arm); - }); + document.addEventListener("pointerdown", arm); + document.addEventListener("keydown", arm); })(); var applyTheme = function (mode) { diff --git a/assets/js/tunes.js b/assets/js/tunes.js index 94363a7..de1c15b 100644 --- a/assets/js/tunes.js +++ b/assets/js/tunes.js @@ -1,12 +1,14 @@ // Per-theme 8-bit soundtracks for the console. The player engine lives in // console.js; this is just the note data. Each tune: { bpm, wave?, lead[], -// bass[] } — MIDI notes, 0 = rest, 32 eighth-notes per loop. `wave` is the lead +// bass[] } — MIDI notes, 0 = rest, one bar = 8 eighth-notes. `wave` is the lead // oscillator (bass is always a soft sine). Keyed by theme name; `default` // covers light/dark/auto, and any theme without an entry falls back to it. -// Kept chill everywhere: slow tempo, sparse notes, lots of rests so it rings. +// The engine loops the arrays forever, so each phrase ends back on its tonic +// (bass on the dominant/sub-dominant) to make the wrap seamless. lead and bass +// must be the same length. Kept chill: slow tempo, sparse notes, lots of rests. (function (root) { var TUNES = { - // Calm C-major pentatonic — the original. Bright and neutral. + // Calm C-major pentatonic — the original 4-bar loop. Bright and neutral. default: { bpm: 84, wave: "triangle", lead: [72, 0, 76, 0, 79, 0, 76, 0, 74, 0, 77, 0, 81, 0, 79, 0, @@ -14,21 +16,42 @@ bass: [48, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, 41, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0] }, - // A natural minor, slower and hollow (square lead) — moody but unhurried. + // A natural minor, hollow square lead, slow — moody 8-bar loop, i–VI–iv–v. dracula: { bpm: 72, wave: "square", lead: [69, 0, 72, 0, 76, 0, 72, 0, 74, 0, 72, 0, 69, 0, 67, 0, - 81, 0, 79, 0, 76, 0, 72, 0, 74, 0, 72, 0, 69, 0, 69, 0], + 81, 0, 79, 0, 76, 0, 72, 0, 74, 0, 72, 0, 71, 0, 69, 0, + 76, 0, 79, 0, 81, 0, 84, 0, 83, 0, 81, 0, 79, 0, 76, 0, + 77, 0, 76, 0, 74, 0, 72, 0, 71, 0, 69, 0, 69, 0, 0, 0], bass: [45, 0, 0, 0, 0, 0, 0, 0, 41, 0, 0, 0, 0, 0, 0, 0, + 38, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, 0, 0, 0, + 45, 0, 0, 0, 0, 0, 0, 0, 41, 0, 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 0, 40, 0, 0, 0, 0, 0, 0, 0] }, - // F major, warm and sweet (soft sine lead) — a gentle, romantic lilt. + // F major, soft sine lead — a sweet, romantic 8-bar loop, I–vi–IV–V. valentine: { bpm: 80, wave: "sine", lead: [72, 0, 74, 0, 77, 0, 76, 0, 74, 0, 72, 0, 69, 0, 72, 0, - 77, 0, 81, 0, 79, 0, 77, 0, 76, 0, 74, 0, 72, 0, 72, 0], + 77, 0, 81, 0, 79, 0, 77, 0, 76, 0, 74, 0, 72, 0, 69, 0, + 70, 0, 69, 0, 67, 0, 65, 0, 67, 0, 69, 0, 72, 0, 74, 0, + 77, 0, 76, 0, 74, 0, 72, 0, 69, 0, 67, 0, 65, 0, 0, 0], bass: [41, 0, 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 0, + 34, 0, 0, 0, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0, + 41, 0, 0, 0, 0, 0, 0, 0, 38, 0, 0, 0, 0, 0, 0, 0, 34, 0, 0, 0, 0, 0, 0, 0, 36, 0, 0, 0, 0, 0, 0, 0] + }, + // D major, warm triangle lead — a cozy folk 8-bar loop, I–V–vi–IV with a + // plagal (IV→I) turnaround. Ink-on-cream, unhurried. + paper: { + bpm: 76, wave: "triangle", + lead: [74, 0, 78, 0, 81, 0, 78, 0, 76, 0, 74, 0, 71, 0, 74, 0, + 81, 0, 83, 0, 86, 0, 83, 0, 81, 0, 78, 0, 76, 0, 74, 0, + 79, 0, 78, 0, 76, 0, 74, 0, 76, 0, 78, 0, 81, 0, 83, 0, + 81, 0, 79, 0, 78, 0, 76, 0, 78, 0, 76, 0, 74, 0, 0, 0], + bass: [38, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, + 47, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0, + 38, 0, 0, 0, 0, 0, 0, 0, 45, 0, 0, 0, 0, 0, 0, 0, + 47, 0, 0, 0, 0, 0, 0, 0, 43, 0, 0, 0, 0, 0, 0, 0] } }; if (typeof module !== "undefined" && module.exports) module.exports = TUNES;