Download web/audio.js from visproj/lantern: direct link, hf CLI and curl.
- Browser
- Download file 5.02 kB
-
https://huggingface.co/spaces/visproj/lantern/resolve/main/web/audio.js
- Command line
-
hf download hf://spaces/visproj/lantern/web/audio.js
-
curl -L -o audio.js https://huggingface.co/spaces/visproj/lantern/resolve/main/web/audio.js
5.02 kB
| // Lantern sound: dungeon ambience plus effects tied to game events. Silent until the | |
| // visitor turns it on. Assets are CC0 (see /static/assets/audio/CREDITS.txt). | |
| ; | |
| (() => { | |
| const DIR = "/static/assets/audio/"; | |
| const SFX = { | |
| step: ["footstep00", "footstep01", "footstep02", "footstep03", "footstep04"], | |
| pickup: ["metalClick"], | |
| unlock: ["metalLatch"], | |
| door: ["doorOpen_1"], | |
| write: ["bookFlip1"], | |
| win: ["handleCoins"], | |
| fail: ["creak1"], | |
| }; | |
| const STEP_MS = 222; // matches the 3D hero's walking speed (4.5 cells per second) | |
| const MUSIC_VOLUME = 0.55; | |
| let enabled = false; | |
| let ctx = null; | |
| const buffers = {}; | |
| const music = new Audio(DIR + "dungeon-ambience.mp3"); | |
| music.loop = true; | |
| music.preload = "none"; | |
| music.volume = 0; | |
| function storedPreference() { | |
| try { return localStorage.getItem("lantern-sound") === "on"; } catch { return false; } | |
| } | |
| function storePreference(on) { | |
| try { localStorage.setItem("lantern-sound", on ? "on" : "off"); } catch { /* private mode */ } | |
| } | |
| async function loadBuffers() { | |
| const names = [...new Set(Object.values(SFX).flat())]; | |
| await Promise.all(names.map(async (n) => { | |
| try { | |
| const res = await fetch(`${DIR}${n}.mp3`); | |
| buffers[n] = await ctx.decodeAudioData(await res.arrayBuffer()); | |
| } catch { /* a missing effect should never break the page */ } | |
| })); | |
| } | |
| function fadeMusic(target, ms = 1200) { | |
| const start = music.volume, t0 = performance.now(); | |
| const step = (now) => { | |
| const k = Math.min(1, (now - t0) / ms); | |
| music.volume = start + (target - start) * k; | |
| if (k < 1) requestAnimationFrame(step); | |
| else if (target === 0) music.pause(); | |
| }; | |
| requestAnimationFrame(step); | |
| } | |
| // Announce each sound so a screen recorder can rebuild the soundtrack in sync | |
| // (browser video capture has no audio). Harmless when nobody listens. | |
| function announce(kind, delayMs = 0, gain = 1) { | |
| window.dispatchEvent(new CustomEvent("lantern-sound", { detail: { kind, delayMs, gain } })); | |
| } | |
| function play(kind, delayMs = 0, gain = 0.6) { | |
| if (!enabled || !ctx) return; | |
| announce(kind, delayMs, gain); | |
| const names = SFX[kind]; | |
| const buf = buffers[names[Math.floor(Math.random() * names.length)]]; | |
| if (!buf) return; | |
| const src = ctx.createBufferSource(); | |
| const g = ctx.createGain(); | |
| g.gain.value = gain; | |
| src.buffer = buf; | |
| src.playbackRate.value = 0.94 + Math.random() * 0.12; // keep repeated steps from sounding mechanical | |
| src.connect(g).connect(ctx.destination); | |
| src.start(ctx.currentTime + delayMs / 1000); | |
| } | |
| async function setEnabled(on) { | |
| enabled = on; | |
| storePreference(on); | |
| const btn = document.getElementById("sound"); | |
| if (btn) { | |
| btn.setAttribute("aria-pressed", String(on)); | |
| btn.textContent = on ? "Sound on" : "Sound off"; | |
| } | |
| if (on) { | |
| if (!ctx) { | |
| ctx = new (window.AudioContext || window.webkitAudioContext)(); | |
| loadBuffers(); | |
| } | |
| if (ctx.state === "suspended") await ctx.resume(); | |
| music.play().then(() => { fadeMusic(MUSIC_VOLUME); announce("music", 0, MUSIC_VOLUME); }).catch(() => {}); | |
| } else { | |
| fadeMusic(0, 400); | |
| } | |
| } | |
| /** Sounds for one Beam call, played as the hero walks its route. */ | |
| function turn(turnEvent) { | |
| if (!enabled) return; | |
| let steps = 0; | |
| for (const e of turnEvent.executed) { | |
| if (e.tool === "write_notes") play("write", 0, 0.5); | |
| if (e.tool === "act") steps += (e.path || []).length; | |
| } | |
| for (let i = 0; i < steps; i++) play("step", i * STEP_MS, 0.35); | |
| const msg = (turnEvent.frame.message || "").toLowerCase(); | |
| const after = steps * STEP_MS; | |
| if (msg.includes("unlock")) { play("unlock", after, 0.7); play("door", after + 350, 0.6); } | |
| else if (/^[a-z] - /.test(msg)) play("pickup", after, 0.7); // inventory line: "g - a key ..." | |
| } | |
| function end(endEvent) { | |
| if (endEvent.outcome === "success") play("win", 300, 0.7); | |
| else if (endEvent.outcome !== "stopped") play("fail", 300, 0.6); | |
| } | |
| function lessons() { play("write", 0, 0.5); } | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const btn = document.getElementById("sound"); | |
| if (!btn) return; | |
| btn.addEventListener("click", () => setEnabled(!enabled)); | |
| // Browsers only allow audio after a user gesture, so a remembered "on" waits for one. | |
| if (storedPreference()) { | |
| btn.textContent = "Sound on (click to start)"; | |
| const resume = (e) => { | |
| window.removeEventListener("pointerdown", resume); | |
| if (e.target.closest("#sound")) return; // the button's own click handler turns sound on | |
| setEnabled(true); | |
| }; | |
| window.addEventListener("pointerdown", resume); | |
| } | |
| }); | |
| window.LanternAudio = { turn, end, lessons, get enabled() { return enabled; } }; | |
| })(); | |