1/**2 * Small Web Audio facade for the game shell.3 *4 * Audio files are fetched as soon as the module is created, but the5 * AudioContext is created only from a user gesture. This keeps browsers from6 * rejecting the context while still having decoded buffers ready for play.7 */89const DEFAULT_CLIPS = Object.freeze({10 move: ['move-0.ogg', 'move-1.ogg', 'move-2.ogg', 'move-3.ogg', 'move-4.ogg'],11 tick: ['undo-0.ogg'],12 push: ['push-0.ogg', 'push-1.ogg', 'push-2.ogg', 'push-3.ogg', 'push-4.ogg'],13 blockedStop: ['blocked-stop-0.ogg', 'blocked-stop-1.ogg', 'blocked-stop-2.ogg', 'blocked-stop-3.ogg', 'blocked-stop-4.ogg'],14 blockedBounds: ['blocked-bounds-0.ogg', 'blocked-bounds-1.ogg', 'blocked-bounds-2.ogg', 'blocked-bounds-3.ogg', 'blocked-bounds-4.ogg'],15 ruleFormed: ['rule-formed-0.ogg', 'rule-formed-1.ogg', 'rule-formed-2.ogg', 'rule-formed-3.ogg'],16 ruleBroken: ['rule-broken-0.ogg', 'rule-broken-1.ogg', 'rule-broken-2.ogg', 'rule-broken-3.ogg'],17 transform: ['transform-0.ogg', 'transform-1.ogg'],18 sink: ['sink-0.ogg', 'sink-1.ogg', 'sink-2.ogg', 'sink-3.ogg'],19 defeat: ['defeat-0.ogg', 'defeat-1.ogg', 'defeat-2.ogg', 'defeat-3.ogg', 'defeat-4.ogg'],20 win: ['win.ogg'],21 undo: ['undo-0.ogg', 'undo-1.ogg', 'undo-2.ogg'],22 undoEmpty: ['undo-empty-0.ogg', 'undo-empty-1.ogg', 'undo-empty-2.ogg', 'undo-empty-3.ogg'],23 restart: ['restart-0.ogg', 'restart-1.ogg', 'restart-2.ogg', 'restart-3.ogg', 'restart-4.ogg'],24 select: ['select.ogg'],25 confirm: ['confirm.ogg'],26 back: ['back.ogg'],27 scene: ['scene.ogg'],28 music: ['music-flowing-rocks.ogg'],29});3031const ALIASES = Object.freeze({32 rule: 'ruleFormed',33 blocked: 'blockedStop',34 bounds: 'blockedBounds',35 undoEmpty: 'undoEmpty',36});3738const DEFAULT_VOLUMES = Object.freeze({39 move: 0.18,40 tick: 0.10,41 push: 0.22,42 blockedStop: 0.2,43 blockedBounds: 0.2,44 ruleFormed: 0.24,45 ruleBroken: 0.2,46 transform: 0.24,47 sink: 0.22,48 defeat: 0.24,49 win: 0.3,50 undo: 0.16,51 undoEmpty: 0.12,52 restart: 0.18,53 select: 0.16,54 confirm: 0.22,55 back: 0.16,56 scene: 0.2,57});5859function readEnabled(storage, key) {60 try {61 return storage?.getItem(key) !== '0';62 } catch {63 return true;64 }65}6667function writeEnabled(storage, key, enabled) {68 try {69 if (enabled) storage?.removeItem(key);70 else storage?.setItem(key, '0');71 } catch {72 // Storage may be unavailable in private browsing or embedded previews.73 }74}7576function readOptIn(storage, key) {77 try {78 return storage?.getItem(key) === '1';79 } catch {80 return false;81 }82}8384function choose(list, random = Math.random) {85 return list[Math.floor(random() * list.length)] || list[0];86}8788function availableStorage() {89 try { return globalThis.localStorage; } catch { return null; }90}9192export { DEFAULT_CLIPS };9394export function createAudioSystem({95 basePath = '/audio/',96 storage = availableStorage(),97 storageKey = 'miau.sound.enabled',98 musicStorageKey = 'miau.music.enabled',99 clips = DEFAULT_CLIPS,100 AudioContextCtor = globalThis.AudioContext || globalThis.webkitAudioContext,101 fetchImpl = globalThis.fetch,102 random = Math.random,103} = {}) {104 let enabled = readEnabled(storage, storageKey);105 let musicEnabled = readOptIn(storage, musicStorageKey);106 let context = null;107 let masterGain = null;108 let musicGain = null;109 let musicSource = null;110 let unlocking = null;111 let listenersTarget = null;112 let removeGestureListeners = null;113 let disposed = false;114 const rawBuffers = new Map();115 const buffers = new Map();116 const loadErrors = new Map();117118 const preload = fetchImpl119 ? Promise.all(Object.entries(clips).flatMap(([name, files]) => files.map(async file => {120 try {121 const response = await fetchImpl(`${basePath.replace(/\/$/, '')}/${file}`);122 if (!response.ok) throw new Error(`Audio request failed (${response.status})`);123 rawBuffers.set(file, await response.arrayBuffer());124 } catch (error) {125 loadErrors.set(name, error);126 }127 }))).then(() => rawBuffers)128 : Promise.resolve(rawBuffers);129130 function setMasterGain() {131 if (masterGain && context) masterGain.gain.setTargetAtTime(enabled ? 1 : 0, context.currentTime, 0.015);132 }133134 async function unlock() {135 if (disposed || !AudioContextCtor) return false;136 if (unlocking) return unlocking;137 unlocking = (async () => {138 context ||= new AudioContextCtor();139 masterGain ||= context.createGain();140 masterGain.connect(context.destination);141 musicGain ||= context.createGain();142 musicGain.gain.value = 1;143 musicGain.connect(masterGain);144 setMasterGain();145 if (context.state === 'suspended') await context.resume();146 await preload;147 for (const [name, files] of Object.entries(clips)) {148 const decoded = [];149 for (const file of files) {150 if (buffers.has(file)) {151 decoded.push(buffers.get(file));152 continue;153 }154 const source = rawBuffers.get(file);155 if (!source) continue;156 try {157 const buffer = await context.decodeAudioData(source.slice(0));158 buffers.set(file, buffer);159 decoded.push(buffer);160 } catch (error) {161 loadErrors.set(name, error);162 }163 }164 if (decoded.length) buffers.set(name, decoded);165 }166 return true;167 })().catch(() => false).finally(() => { unlocking = null; });168 return unlocking;169 }170171 function installGestureListeners(target = globalThis.document || globalThis) {172 if (listenersTarget || !target?.addEventListener) return () => {};173 listenersTarget = target;174 const onGesture = () => {175 void unlock().then(() => {176 if (musicEnabled) playMusic();177 if (!listenersTarget) return;178 for (const event of ['pointerdown', 'touchstart', 'keydown']) listenersTarget.removeEventListener(event, onGesture, true);179 listenersTarget = null;180 removeGestureListeners = null;181 });182 };183 for (const event of ['pointerdown', 'touchstart', 'keydown']) target.addEventListener(event, onGesture, { capture: true, passive: true });184 removeGestureListeners = () => {185 if (listenersTarget !== target) return;186 for (const event of ['pointerdown', 'touchstart', 'keydown']) target.removeEventListener(event, onGesture, true);187 listenersTarget = null;188 removeGestureListeners = null;189 };190 return removeGestureListeners;191 }192193 function play(name, { volume = DEFAULT_VOLUMES[name] ?? 0.2, rate = 1, loop = false, offset = 0 } = {}) {194 const resolved = ALIASES[name] || name;195 if (!enabled || disposed) return false;196 if (!context || !masterGain) {197 void unlock().then(() => play(name, { volume, rate, loop, offset }));198 return false;199 }200 const available = buffers.get(resolved);201 if (!available?.length) return false;202 const source = context.createBufferSource();203 const gain = context.createGain();204 source.buffer = choose(available, random);205 source.playbackRate.value = rate;206 source.loop = loop;207 gain.gain.value = volume;208 source.connect(gain).connect(masterGain);209 source.start(0, offset);210 return true;211 }212213 function mute(value = true) {214 enabled = !value;215 writeEnabled(storage, storageKey, enabled);216 setMasterGain();217 return !enabled;218 }219220 function toggleMute() {221 return mute(enabled);222 }223224 function stopMusic() {225 if (!musicSource) return false;226 try { musicSource.stop(); } catch { /* already stopped */ }227 musicSource.disconnect?.();228 musicSource = null;229 return true;230 }231232 function playMusic(name = 'music', { volume = 0.126, rate = 1, offset = 0 } = {}) {233 if (!musicEnabled || disposed) return false;234 const resolved = ALIASES[name] || name;235 if (!context || !musicGain) {236 void unlock().then(() => playMusic(name, { volume, rate, offset }));237 return false;238 }239 const available = buffers.get(resolved);240 if (!available?.length) return false;241 stopMusic();242 const source = context.createBufferSource();243 source.buffer = choose(available, random);244 source.playbackRate.value = rate;245 source.loop = true;246 const gain = context.createGain();247 gain.gain.value = volume;248 source.connect(gain).connect(musicGain);249 source.start(0, offset);250 musicSource = source;251 return true;252 }253254 function setMusicEnabled(value, { track = 'music' } = {}) {255 musicEnabled = Boolean(value);256 try {257 if (musicEnabled) storage?.setItem(musicStorageKey, '1');258 else storage?.removeItem(musicStorageKey);259 } catch {260 // Storage may be unavailable in private browsing or embedded previews.261 }262 if (musicEnabled) {263 if (context) void unlock().then(() => playMusic(track));264 } else stopMusic();265 return musicEnabled;266 }267268 function dispose() {269 disposed = true;270 stopMusic();271 removeGestureListeners?.();272 return context?.close?.();273 }274275 return {276 preload,277 unlock,278 installGestureListeners,279 play,280 playMusic,281 stopMusic,282 mute,283 toggleMute,284 isMuted: () => !enabled,285 isMusicEnabled: () => musicEnabled,286 setMusicEnabled,287 getStatus: () => ({ enabled, muted: !enabled, unlocked: Boolean(context), ready: buffers.size > 0, errors: [...loadErrors.keys()] }),288 dispose,289 };290}