lantern / web /audio.js
visproj's picture
feat: sound events for synced recording; README results
89dfb1c verified
Raw History Blame Contribute Delete
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).
"use strict";
(() => {
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; } };
})();