feat: Adding audio player + music to play

This commit is contained in:
gauvainboiche
2026-10-25 22:35:53 +01:00
parent 7a525d084a
commit dd4714f499
39 changed files with 850 additions and 81 deletions
+99 -44
View File
@@ -1,24 +1,63 @@
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft
// (slow attacks, low levels, filtered noise) so it stays in the background of the desk.
// Sound effects, synthesised with the Web Audio API: no audio files. Everything is soft: slow
// attacks, long fades, no deep bass, and a short room reverb under it all so sounds melt away
// instead of stopping. Each sound belongs to a mixer group the player can turn down on its own.
// Browsers keep audio locked until the first click or key press; unlock() resumes it.
export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
// Mixer groups, in the order the mixer shows them.
export const MIX_GROUPS = ["stamp", "paper", "page", "sign", "telegram", "alert", "chime", "tick", "countdown", "click", "ending"];
const GROUP_OF = {
stampYes: "stamp", stampNo: "stamp", paper: "paper", page: "page", sign: "sign", telegram: "telegram",
alert: "alert", chime: "chime", tick: "tick", tock: "tick", clockTick: "countdown", clockTock: "countdown",
click: "click", fall: "ending", fanfare: "ending",
};
export function createAudio({ enabled = () => true, volume = () => 0.6, mix = () => ({}) } = {}) {
let ctx = null;
let master = null;
let noiseBuf = null;
let out = null; // where the sound being built sends its notes (a gain node per play)
function context() {
if (!ctx) {
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch {
return null;
}
master = ctx.createGain();
master.connect(ctx.destination);
if (ctx) return ctx;
const AC = globalThis.AudioContext ?? globalThis.webkitAudioContext;
if (!AC) return null;
try {
ctx = new AC();
} catch {
return null;
}
// master -> no deep bass -> no harsh treble -> gentle limiter -> speakers, plus a soft room send
master = ctx.createGain();
const lowCut = ctx.createBiquadFilter();
lowCut.type = "highpass";
lowCut.frequency.value = 140;
const highCut = ctx.createBiquadFilter();
highCut.type = "lowpass";
highCut.frequency.value = 4800;
highCut.Q.value = 0.4;
const limiter = ctx.createDynamicsCompressor();
limiter.threshold.value = -22;
limiter.knee.value = 24;
limiter.ratio.value = 6;
limiter.attack.value = 0.01;
limiter.release.value = 0.35;
master.connect(lowCut).connect(highCut).connect(limiter).connect(ctx.destination);
const room = ctx.createConvolver();
const len = Math.floor(ctx.sampleRate * 1.5);
const ir = ctx.createBuffer(2, len, ctx.sampleRate);
for (let ch = 0; ch < 2; ch++) {
const d = ir.getChannelData(ch);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, 2.6);
}
room.buffer = ir;
const wet = ctx.createGain();
wet.gain.value = 0.28;
const roomTone = ctx.createBiquadFilter();
roomTone.type = "lowpass";
roomTone.frequency.value = 2600;
highCut.connect(wet).connect(room).connect(roomTone).connect(limiter);
return ctx;
}
@@ -28,8 +67,8 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
}
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, unlock, { passive: true });
// One note: a sine (or other) wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.2, gain = 0.2, attack = 0.008 }) {
// One note: a wave with an attack and an exponential fade.
function tone(c, at, { freq, to, type = "sine", dur = 0.3, gain = 0.1, attack = 0.02 }) {
const o = c.createOscillator();
const g = c.createGain();
o.type = type;
@@ -38,13 +77,13 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
o.connect(g).connect(master);
o.connect(g).connect(out);
o.start(at);
o.stop(at + dur + 0.05);
}
// Filtered noise: paper, pages, thumps and clicks.
function noise(c, at, { dur = 0.15, gain = 0.1, type = "bandpass", freq = 2000, to, q = 0.8, attack = 0.02 }) {
// Filtered noise: paper, pages, soft impacts and ticks.
function noise(c, at, { dur = 0.2, gain = 0.06, type = "bandpass", freq = 2000, to, q = 0.6, attack = 0.03 }) {
noiseBuf ??= (() => {
const b = c.createBuffer(1, c.sampleRate, c.sampleRate);
const d = b.getChannelData(0);
@@ -63,55 +102,66 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
g.gain.setValueAtTime(0.0001, at);
g.gain.linearRampToValueAtTime(gain, at + attack);
g.gain.exponentialRampToValueAtTime(0.0001, at + dur);
src.connect(f).connect(g).connect(master);
src.connect(f).connect(g).connect(out);
src.start(at);
src.stop(at + dur + 0.05);
}
const SOUNDS = {
// A padded "thup" rather than a thud: mid-range, slow to start, with a soft paper tail.
stampYes: (c, t) => {
tone(c, t, { freq: 150, to: 55, dur: 0.16, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 500, dur: 0.07, gain: 0.25, attack: 0.004 });
noise(c, t, { freq: 2200, dur: 0.04, gain: 0.07, attack: 0.004 });
tone(c, t, { freq: 230, to: 130, dur: 0.32, gain: 0.2, attack: 0.016 });
noise(c, t, { type: "lowpass", freq: 1100, dur: 0.18, gain: 0.09, attack: 0.014, q: 0.4 });
noise(c, t + 0.02, { freq: 2000, to: 1400, dur: 0.32, gain: 0.025, attack: 0.03 });
},
stampNo: (c, t) => {
tone(c, t, { freq: 118, to: 44, dur: 0.18, gain: 0.5 });
noise(c, t, { type: "lowpass", freq: 420, dur: 0.08, gain: 0.28, attack: 0.004 });
noise(c, t, { freq: 1800, dur: 0.04, gain: 0.07, attack: 0.004 });
tone(c, t, { freq: 190, to: 108, dur: 0.34, gain: 0.2, attack: 0.016 });
noise(c, t, { type: "lowpass", freq: 900, dur: 0.2, gain: 0.09, attack: 0.014, q: 0.4 });
noise(c, t + 0.02, { freq: 1700, to: 1200, dur: 0.32, gain: 0.025, attack: 0.03 });
},
paper: (c, t) => noise(c, t, { freq: 2600, to: 1200, dur: 0.17, gain: 0.1, attack: 0.04 }),
paper: (c, t) => noise(c, t, { freq: 2400, to: 1100, dur: 0.36, gain: 0.075, attack: 0.09 }),
page: (c, t) => {
noise(c, t, { freq: 500, to: 3200, dur: 0.24, gain: 0.11, attack: 0.05 });
noise(c, t + 0.2, { freq: 2400, dur: 0.06, gain: 0.05, attack: 0.01 });
noise(c, t, { freq: 450, to: 2800, dur: 0.52, gain: 0.085, attack: 0.13 });
noise(c, t + 0.4, { freq: 2200, dur: 0.18, gain: 0.035, attack: 0.03 });
},
sign: (c, t) => {
noise(c, t, { freq: 3400, to: 2600, dur: 0.2, gain: 0.05, q: 2, attack: 0.05 });
tone(c, t + 0.22, { freq: 140, to: 60, dur: 0.14, gain: 0.3 });
noise(c, t, { freq: 3200, to: 2400, dur: 0.42, gain: 0.04, q: 2, attack: 0.11 });
tone(c, t + 0.44, { freq: 200, to: 125, dur: 0.28, gain: 0.15, attack: 0.02 });
noise(c, t + 0.44, { type: "lowpass", freq: 1000, dur: 0.14, gain: 0.06, attack: 0.012, q: 0.4 });
},
telegram: (c, t) => {
for (let i = 0; i < 4; i++) noise(c, t + i * 0.07, { type: "highpass", freq: 3200, dur: 0.02, gain: 0.1, attack: 0.002 });
tone(c, t + 0.34, { freq: 1320, dur: 0.7, gain: 0.1, attack: 0.01 });
tone(c, t + 0.34, { freq: 1980, dur: 0.45, gain: 0.04, attack: 0.01 });
for (let i = 0; i < 4; i++) noise(c, t + i * 0.09, { type: "highpass", freq: 3200, dur: 0.045, gain: 0.05, attack: 0.007 });
tone(c, t + 0.44, { freq: 1320, dur: 1.6, gain: 0.07, attack: 0.04 });
tone(c, t + 0.44, { freq: 1980, dur: 1.1, gain: 0.022, attack: 0.04 });
},
alert: (c, t) => {
tone(c, t, { freq: 233, dur: 0.4, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t + 0.24, { freq: 175, dur: 0.55, gain: 0.2, type: "triangle", attack: 0.03 });
tone(c, t, { freq: 233, dur: 0.95, gain: 0.12, type: "triangle", attack: 0.16 });
tone(c, t + 0.32, { freq: 175, dur: 1.25, gain: 0.12, type: "triangle", attack: 0.16 });
},
chime: (c, t) => {
tone(c, t, { freq: 659, dur: 0.7, gain: 0.12, attack: 0.02 });
tone(c, t + 0.16, { freq: 880, dur: 0.9, gain: 0.1, attack: 0.02 });
tone(c, t, { freq: 659, dur: 1.4, gain: 0.08, attack: 0.06 });
tone(c, t + 0.22, { freq: 880, dur: 1.8, gain: 0.065, attack: 0.06 });
},
tick: (c, t) => tone(c, t, { freq: 1000, to: 850, dur: 0.07, gain: 0.02, attack: 0.007 }),
tock: (c, t) => tone(c, t, { freq: 760, to: 650, dur: 0.07, gain: 0.02, attack: 0.007 }),
// The countdown on the day before a new proposal: a small wooden clock.
clockTick: (c, t) => {
tone(c, t, { freq: 1500, to: 1150, dur: 0.1, gain: 0.05, attack: 0.005 });
noise(c, t, { freq: 2800, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
},
clockTock: (c, t) => {
tone(c, t, { freq: 1150, to: 880, dur: 0.1, gain: 0.05, attack: 0.005 });
noise(c, t, { freq: 2200, q: 4, dur: 0.06, gain: 0.025, attack: 0.005 });
},
tick: (c, t) => tone(c, t, { freq: 1100, to: 900, dur: 0.035, gain: 0.03, attack: 0.002 }),
tock: (c, t) => tone(c, t, { freq: 820, to: 700, dur: 0.035, gain: 0.03, attack: 0.002 }),
click: (c, t) => {
tone(c, t, { freq: 900, to: 600, dur: 0.04, gain: 0.06, attack: 0.002 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.015, gain: 0.04, attack: 0.002 });
tone(c, t, { freq: 800, to: 600, dur: 0.09, gain: 0.045, attack: 0.007 });
noise(c, t, { type: "highpass", freq: 3000, dur: 0.035, gain: 0.02, attack: 0.005 });
},
fall: (c, t) => {
[330, 247, 196, 147].forEach((f, i) => tone(c, t + i * 0.45, { freq: f, dur: 0.9, gain: 0.14, attack: 0.04, type: "triangle" }));
[262, 220, 196, 165].forEach((f, i) => tone(c, t + i * 0.6, { freq: f, dur: 1.9, gain: 0.09, attack: 0.16 }));
},
fanfare: (c, t) => {
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.14, { freq: f, dur: 1.1, gain: 0.09, attack: 0.03 }));
[392, 494, 587, 784].forEach((f, i) => tone(c, t + i * 0.2, { freq: f, dur: 1.9, gain: 0.07, attack: 0.07 }));
},
};
@@ -124,7 +174,12 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
c.resume?.().catch?.(() => {});
return; // still locked: nothing to hear yet
}
const level = Math.max(0, Math.min(1, mix()[GROUP_OF[name]] ?? 1));
if (level < 0.005) return;
master.gain.value = Math.max(0, Math.min(1, volume()));
out = c.createGain();
out.gain.value = level;
out.connect(master);
try {
make(c, c.currentTime + 0.005);
} catch {
@@ -132,5 +187,5 @@ export function createAudio({ enabled = () => true, volume = () => 0.6 } = {}) {
}
}
return { play, unlock, names: Object.keys(SOUNDS) };
return { play, unlock, names: Object.keys(SOUNDS), groupOf: (name) => GROUP_OF[name] };
}
+33 -5
View File
@@ -8,6 +8,7 @@ import { encodeSave, decodeSave, migrate } from "../save/codec.js";
import { KEYS, SLOT_COUNT, readSave, writeSave } from "../save/storage.js";
import { $, esc } from "./desk.js";
import { renderMarkdown } from "./markdown.js";
import { MIX_GROUPS } from "./audio.js";
const ARM_MS = 4000; // how long a "Sure?" button stays armed
@@ -221,9 +222,7 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
<input type="checkbox" id="setting-sound" ${s.sound ? "checked" : ""}>
<span><b>${t("settings.sound")}</b><small>${t("settings.soundHint")}</small></span>
</label>
<label class="menu__field">${t("settings.volume")}
<input type="range" id="setting-volume" min="0" max="1" step="0.05" value="${s.volume}" ${s.sound ? "" : "disabled"}>
</label>
<div class="menu__list">${button("mixer", t("settings.mixer"))}</div>
${storage.persistent ? "" : `<p class="menu__msg menu__msg--error">${t("settings.storage")}</p>`}
${back()}`;
}
@@ -253,7 +252,26 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
return `<div class="rules" tabindex="0" aria-label="${t("rules.title")}">${html}</div>${back()}`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules };
// One slider per sound group, plus the overall volume and the music. Levels apply as you drag.
function mixer() {
const s = getSettings();
const rows = [["master", s.volume], ["music", s.mix.music], ...MIX_GROUPS.map((g) => [g, s.mix[g]])];
return `
<h3>${t("mixer.title")}</h3>
<p class="menu__sub">${t("mixer.hint")}</p>
<div class="mixer">
${rows.map(([key, v]) => `
<label class="mixer__row ${key === "master" ? "mixer__row--master" : ""}">
<span>${t("mixer." + key)}</span>
<input type="range" min="0" max="1" step="0.05" value="${v}" data-mix="${key}" aria-label="${t("mixer." + key)}">
<output>${Math.round(v * 100)}%</output>
</label>`).join("")}
</div>
<div class="menu__list">${button("mixerReset", t("mixer.reset"))}</div>
<footer class="menu__foot">${button("settings", t("menu.back"))}</footer>`;
}
const SCREENS = { home, new: newGame, load, save, export: exportScreen, import: importScreen, settings, credits, rules, mixer };
function render() {
root.innerHTML = `
@@ -324,6 +342,8 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
import: () => go("import"),
settings: () => go("settings"),
rules: () => go("rules"),
mixer: () => go("mixer"),
mixerReset: () => { handlers.onSettings({ resetMix: true }); render(); },
credits: () => go("credits"),
back: () => go("home"),
resume: () => handlers.onResume(),
@@ -362,10 +382,18 @@ export function createMenu(root, { content, storage, getState, getSettings, hand
ACTIONS[action](arg);
});
root.addEventListener("input", (e) => {
const key = e.target.dataset?.mix;
if (!key) return;
const v = Number(e.target.value);
e.target.parentElement.querySelector("output").textContent = `${Math.round(v * 100)}%`;
handlers.onSettings(key === "master" ? { volume: v } : { mix: { [key]: v } });
});
root.addEventListener("change", async (e) => {
if (e.target.dataset?.mix) handlers.onPreview?.(e.target.dataset.mix);
if (e.target.id === "setting-slow") handlers.onSettings({ slow: e.target.checked });
if (e.target.id === "setting-sound") { handlers.onSettings({ sound: e.target.checked }); render(); }
if (e.target.id === "setting-volume") handlers.onSettings({ volume: Number(e.target.value) });
if (e.target.id === "import-file" && e.target.files[0]) {
$(root, "#import-text").value = await e.target.files[0].text();
}
+162
View File
@@ -0,0 +1,162 @@
// Background music: plays the .mp3 files of the music folder (listed by the server at music/index.json)
// in random order, like a shuffled tape. No DOM here: the walkman panel drives it and listens to it.
//
// States: "stopped" (nothing loaded), "paused" (track loaded, silent), "playing".
// Browsers refuse to start audio before the first click or key press; start() then waits for it.
export const prettyTitle = (file) => {
const base = file.replace(/\.mp3$/i, "");
return base.replace(/^\d+[\s._-]*/, "").replace(/_+/g, " ").replace(/\s+/g, " ").trim() || base;
};
function shuffled(list) {
const a = [...list];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
export function createMusic({ getVolume = () => 0.3, onUserChoice = () => {} } = {}) {
const el = new Audio();
el.preload = "none";
let tracks = [];
let bag = []; // tracks still to come in this shuffle
let history = []; // tracks already played, for "previous"
let current = null;
let state = "stopped";
let waitingForGesture = false;
let failures = 0;
let listener = () => {};
const notify = () => listener(info());
const info = () => ({ available: tracks.length > 0, state, title: current ? prettyTitle(current) : "", waitingForGesture });
// The next track of the shuffle, never the one that just played (unless it is the only one).
function pick() {
if (!bag.length) {
bag = shuffled(tracks);
if (bag.length > 1 && bag[0] === current) bag.push(bag.shift());
}
return bag.shift();
}
function setTrack(name) {
current = name;
el.src = `music/${encodeURIComponent(name)}`;
}
function applyVolume() {
el.volume = Math.max(0, Math.min(1, getVolume()));
}
async function begin() {
applyVolume();
try {
await el.play();
state = "playing";
waitingForGesture = false;
} catch (err) {
// Blocked until the player touches the page, or the file could not be read.
if (err?.name === "NotAllowedError") waitingForGesture = true;
state = current ? "paused" : "stopped";
}
notify();
}
el.addEventListener("playing", () => { failures = 0; });
el.addEventListener("ended", () => next());
el.addEventListener("error", () => {
// A broken or unreadable file: try another tape, and give up when none plays.
failures++;
if (failures > tracks.length) {
state = "stopped";
notify();
return;
}
if (current) history.pop();
skip();
});
function skip() {
if (!tracks.length) return;
setTrack(pick());
void begin();
}
async function load() {
try {
const r = await fetch("music/index.json", { cache: "no-store" });
const data = r.ok ? await r.json() : {};
tracks = Array.isArray(data.tracks) ? data.tracks.filter((n) => typeof n === "string") : [];
} catch {
tracks = [];
}
notify();
return tracks.length;
}
function play() {
if (!tracks.length) return;
if (state === "paused" && current) {
void begin();
} else {
if (current) history.push(current);
skip();
}
onUserChoice(true);
}
function pause() {
if (state !== "playing") return;
el.pause();
state = "paused";
waitingForGesture = false;
onUserChoice(false);
notify();
}
function stop() {
el.pause();
el.currentTime = 0;
state = "stopped";
current = null;
waitingForGesture = false;
onUserChoice(false);
notify();
}
function next() {
if (!tracks.length) return;
if (current) history.push(current);
skip();
onUserChoice(true);
}
// Back to the start of the track, or to the one before when it has barely begun.
function prev() {
if (!tracks.length) return;
if (state === "stopped" || el.currentTime > 3 || !history.length) {
if (current) el.currentTime = 0;
else skip();
} else {
setTrack(history.pop());
void begin();
}
onUserChoice(true);
}
// Called on the first click or key press: starts the music if the player wants it.
function unlockedByGesture(wanted) {
if (wanted && state === "stopped" && tracks.length) skip();
else if (waitingForGesture && current) void begin();
}
return {
load, play, pause, stop, next, prev, applyVolume, unlockedByGesture,
info,
setListener: (fn) => { listener = fn; fn(info()); },
};
}
+75
View File
@@ -0,0 +1,75 @@
// A small cassette player on the desk: a cassette window with two reels that turn while the music plays,
// the track title on the cassette label, and five vintage keys (previous, play, pause, stop, next).
import { t } from "../i18n/i18n.js";
import { $ } from "./desk.js";
const KEYS = [
["prev", `<path d="M12 1v14L3 8zM22 1v14l-9-7z"/>`],
["play", `<path d="M7 1v14l11-7z"/>`],
["pause", `<path d="M6 1h4v14H6zM14 1h4v14h-4z"/>`],
["stop", `<path d="M6 2h12v12H6z"/>`],
["next", `<path d="M2 1v14l9-7zM12 1v14l9-7z"/>`],
];
const reel = (side) => `
<svg class="walkman__reel walkman__reel--${side}" viewBox="0 0 40 40" aria-hidden="true" focusable="false">
<circle class="walkman__tape" cx="20" cy="20" r="17"/>
<g class="walkman__spokes">${[0, 60, 120].map((a) => `<line x1="20" y1="9" x2="20" y2="31" transform="rotate(${a} 20 20)"/>`).join("")}</g>
<circle class="walkman__hub" cx="20" cy="20" r="6"/>
</svg>`;
export function createWalkman(root, { music, onPress = () => {} }) {
root.innerHTML = `
<div class="walkman__body">
<div class="walkman__badge"><b>VALDOR</b><span>${t("walkman.model")}</span><i class="walkman__led" aria-hidden="true"></i></div>
<div class="walkman__window">
${reel("left")}
<div class="walkman__label"><span class="walkman__title" role="status"></span></div>
${reel("right")}
</div>
<div class="walkman__keys" role="group" aria-label="${t("walkman.title")}">
${KEYS.map(([act, icon]) => `
<button class="walkman__key walkman__key--${act}" data-act="${act}" aria-label="${t("walkman." + act)}" title="${t("walkman." + act)}">
<svg viewBox="0 0 24 16" aria-hidden="true" focusable="false">${icon}</svg>
</button>`).join("")}
</div>
</div>`;
const title = $(root, ".walkman__title");
const label = $(root, ".walkman__label");
root.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b || b.disabled) return;
onPress();
music[b.dataset.act]();
});
function update() {
const m = music.info();
const playing = m.state === "playing";
root.classList.toggle("is-playing", playing);
root.classList.toggle("is-empty", !m.available);
root.title = m.available ? "" : t("walkman.hint");
let text = t("walkman.empty");
if (m.available) text = m.waitingForGesture ? t("walkman.tap") : m.state === "stopped" ? t("walkman.stopped") : m.title;
if (title.textContent !== text) {
title.textContent = text;
title.classList.remove("is-scroll");
// a long title scrolls across the label while the tape runs
requestAnimationFrame(() => title.classList.toggle("is-scroll", title.scrollWidth > label.clientWidth + 2));
}
for (const b of root.querySelectorAll("button[data-act]")) {
b.disabled = !m.available;
if (b.dataset.act === "play") b.setAttribute("aria-pressed", String(playing));
if (b.dataset.act === "pause") b.setAttribute("aria-pressed", String(m.state === "paused"));
if (b.dataset.act === "stop") b.setAttribute("aria-pressed", String(m.state === "stopped"));
}
}
music.setListener(update);
return { update };
}