// Lightweight game audio: one looping "scene music" track plus synthesized one-shot // SFX. Zero dependencies. Browsers block autoplay until a user gesture, so music/SFX // are primed on the first pointer interaction. type Sfx = 'advance' | 'choice' | 'sting' let baseVolume = 0.5 // authored scene volume (0-1); mute overrides to 0 let ctx: AudioContext | null = null function audioContext() { if (!ctx) { const AC = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (AC) ctx = new AC() } return ctx } let noiseBuffer: AudioBuffer | null = null function noise(context: AudioContext) { if (!noiseBuffer) { const length = Math.floor(context.sampleRate * 0.05) noiseBuffer = context.createBuffer(1, length, context.sampleRate) const data = noiseBuffer.getChannelData(0) for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1 } return noiseBuffer } let markerNoiseBuffer:AudioBuffer | null=null function markerNoise(context:AudioContext) { if (!markerNoiseBuffer) { const length=Math.floor(context.sampleRate * .31) markerNoiseBuffer=context.createBuffer(1,length,context.sampleRate) const data=markerNoiseBuffer.getChannelData(0) let previous=0 for (let index=0;index < length;index++) { const white=Math.random() * 2 - 1 previous=previous * .66 + white * .34 data[index]=previous * (.72 + Math.sin(index / 37) * .18) } } return markerNoiseBuffer } const music = typeof Audio !== 'undefined' ? new Audio() : null if (music) music.loop = true let currentUrl: string | null = null let muted = false let pending = false let fadeTimer: number | undefined function fade(target: number, done?: () => void) { if (!music) return window.clearInterval(fadeTimer) const step = (target - music.volume) / 12 || (target > music.volume ? 0.1 : -0.1) fadeTimer = window.setInterval(() => { if (!music) return const next = music.volume + step if ((step >= 0 && next >= target) || (step <= 0 && next <= target)) { music.volume = target; window.clearInterval(fadeTimer); done?.() } else music.volume = Math.max(0, Math.min(1, next)) }, 40) } function startMusic() { if (!music || !currentUrl || muted) return const promise = music.play() if (promise) promise.then(() => { pending = false; fade(baseVolume) }).catch(() => { pending = true }) } export const audio = { // A null url inherits whatever is already playing; a new url crossfades to it. // The same url with a new volume just adjusts the level (no restart). setMusic(url: string | null, volume?: number) { if (!music) return if (volume !== undefined) baseVolume = Math.max(0, Math.min(1, volume)) if (url === currentUrl) { if (volume !== undefined && currentUrl && !muted) fade(baseVolume); return } currentUrl = url if (!url) { fade(0, () => music.pause()); pending = false; return } music.src = url music.volume = 0 startMusic() }, toggleMute() { muted = !muted if (muted) fade(0) else if (currentUrl) { if (music && music.paused) startMusic(); else fade(baseVolume) } return muted }, isMuted() { return muted }, sfx(kind: Sfx) { if (muted) return const context = audioContext() if (!context) return // Called from a click, so we can unlock the context right here if it's suspended. if (context.state === 'suspended') void context.resume() const osc = context.createOscillator(), gain = context.createGain() osc.type = 'triangle' const now = context.currentTime + 0.01 const base = kind === 'choice' ? 500 : kind === 'sting' ? 260 : 420 osc.frequency.setValueAtTime(base, now) if (kind === 'choice') osc.frequency.exponentialRampToValueAtTime(base * 1.5, now + 0.09) // a little up-chirp gain.gain.setValueAtTime(0.0001, now) gain.gain.exponentialRampToValueAtTime(0.16, now + 0.012) gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16) osc.connect(gain).connect(context.destination) osc.start(now); osc.stop(now + 0.18) }, // A dry typewriter key-clack, for the text-reveal clatter. Short filtered noise // burst with slight pitch jitter so successive keys differ. type() { if (muted) return const context = audioContext() if (!context) return if (context.state === 'suspended') void context.resume() const src = context.createBufferSource(); src.buffer = noise(context) const filter = context.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1500 + Math.random() * 900; filter.Q.value = 0.9 const gain = context.createGain() const now = context.currentTime gain.gain.setValueAtTime(0.06, now) gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.028) src.connect(filter).connect(gain).connect(context.destination) src.start(now); src.stop(now + 0.04) }, // A restrained felt-tip-on-paper scratch. The caller supplies the handwriting // duration so the sound ends with the incremental letter reveal. sharpie(durationMs:number) { if (muted) return undefined const context=audioContext() if (!context) return undefined if (context.state === 'suspended') void context.resume() const duration=Math.max(.12,Math.min(3,durationMs / 1000)) const source=context.createBufferSource();source.buffer=markerNoise(context);source.loop=true const filter=context.createBiquadFilter();filter.type='bandpass';filter.frequency.value=1180;filter.Q.value=.62 const gain=context.createGain() const now=context.currentTime + .012,end=now + duration gain.gain.setValueAtTime(.0001,now) gain.gain.linearRampToValueAtTime(.032,now + .035) for (let at=now + .055;at < end - .04;at += .045) gain.gain.setValueAtTime(.018 + Math.random() * .026,at) gain.gain.exponentialRampToValueAtTime(.0001,end) source.connect(filter).connect(gain).connect(context.destination) let ended=false source.onended=() => { ended=true } source.start(now);source.stop(end + .02) return () => { if (!ended) { try { source.stop() } catch { /* already stopped */ } } } }, // Resume the context and retry pending music on a user gesture. resume() { void audioContext()?.resume?.() if (pending) startMusic() }, } if (typeof window !== 'undefined') window.addEventListener('pointerdown', () => audio.resume())