2026-08-18 19:51:44 +02:00
|
|
|
// 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'
|
2026-08-18 21:02:16 +02:00
|
|
|
let baseVolume = 0.5 // authored scene volume (0-1); mute overrides to 0
|
2026-08-18 19:51:44 +02:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 20:24:06 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-22 19:49:44 +02:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-18 19:51:44 +02:00
|
|
|
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()
|
2026-08-18 21:02:16 +02:00
|
|
|
if (promise) promise.then(() => { pending = false; fade(baseVolume) }).catch(() => { pending = true })
|
2026-08-18 19:51:44 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const audio = {
|
|
|
|
|
// A null url inherits whatever is already playing; a new url crossfades to it.
|
2026-08-18 21:02:16 +02:00
|
|
|
// 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 }
|
2026-08-18 19:51:44 +02:00
|
|
|
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)
|
2026-08-18 21:02:16 +02:00
|
|
|
else if (currentUrl) { if (music && music.paused) startMusic(); else fade(baseVolume) }
|
2026-08-18 19:51:44 +02:00
|
|
|
return muted
|
|
|
|
|
},
|
|
|
|
|
isMuted() { return muted },
|
|
|
|
|
sfx(kind: Sfx) {
|
|
|
|
|
if (muted) return
|
|
|
|
|
const context = audioContext()
|
2026-08-18 20:13:51 +02:00
|
|
|
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()
|
2026-08-18 19:51:44 +02:00
|
|
|
const osc = context.createOscillator(), gain = context.createGain()
|
2026-08-18 20:13:51 +02:00
|
|
|
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
|
2026-08-18 19:51:44 +02:00
|
|
|
gain.gain.setValueAtTime(0.0001, now)
|
2026-08-18 20:13:51 +02:00
|
|
|
gain.gain.exponentialRampToValueAtTime(0.16, now + 0.012)
|
|
|
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.16)
|
2026-08-18 19:51:44 +02:00
|
|
|
osc.connect(gain).connect(context.destination)
|
2026-08-18 20:13:51 +02:00
|
|
|
osc.start(now); osc.stop(now + 0.18)
|
2026-08-18 19:51:44 +02:00
|
|
|
},
|
2026-08-18 20:24:06 +02:00
|
|
|
// 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)
|
|
|
|
|
},
|
2026-08-22 19:49:44 +02:00
|
|
|
// 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 */ } } }
|
|
|
|
|
},
|
2026-08-18 19:51:44 +02:00
|
|
|
// 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())
|