/** * Web Audio engine for HUD sound effects. * * Uses AudioBufferSourceNode for low-latency, overlapping playback (HTMLAudio * can't overlap the same source and stalls when triggered rapidly). Adds a * small pitch jitter on each play so repeated sounds don't feel robotic — the * trick games use to keep keypress chirps from grating. */ import { logError } from "@/lib/errors"; type EngineOptions = { /** Master volume 0..1 applied on top of per-call volume. */ masterVolume: number; /** When false, all play() calls are no-ops. */ enabled: boolean; }; type PlayOptions = { /** 0..1, multiplied with master volume. */ volume?: number; /** ±fraction of playbackRate jitter; 0.05 = ±5%. */ pitchVariance?: number; /** Skip if the same sound was played within this many ms. Prevents audible doubling on rapid triggers. */ throttleMs?: number; }; class SoundEffectsEngine { private ctx: AudioContext | null = null; private masterGain: GainNode | null = null; private limiter: DynamicsCompressorNode | null = null; private buffers = new Map(); private loading = new Map>(); private lastPlayedAt = new Map(); private options: EngineOptions = { masterVolume: 0.02, enabled: true }; setOptions(next: Partial) { this.options = { ...this.options, ...next }; if (this.masterGain) { this.masterGain.gain.value = this.options.masterVolume; } } /** Lazy AudioContext init. Browsers (and Electron in some configs) start it suspended until a user gesture. */ private getContext(): AudioContext | null { if (this.ctx) return this.ctx; try { const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }) .webkitAudioContext; if (!Ctor) return null; this.ctx = new Ctor(); this.masterGain = this.ctx.createGain(); this.masterGain.gain.value = this.options.masterVolume; // Brickwall limiter: caps peaks regardless of system volume so the // click stays controlled when the OS volume is cranked. Threshold sets // the ceiling; high ratio + fast attack make it a hard limiter. this.limiter = this.ctx.createDynamicsCompressor(); this.limiter.threshold.value = -24; // dB ceiling for peaks this.limiter.knee.value = 0; this.limiter.ratio.value = 20; this.limiter.attack.value = 0.001; this.limiter.release.value = 0.08; this.masterGain.connect(this.limiter); this.limiter.connect(this.ctx.destination); return this.ctx; } catch (err) { logError(err, { scope: "soundEffects.createContext" }); return null; } } /** * Preload a sound by name from a URL. Safe to call multiple times — caches. * Missing/failed loads resolve to null and the sound silently no-ops on play. */ preload(name: string, url: string): Promise { if (this.buffers.has(name)) { return Promise.resolve(this.buffers.get(name)!); } const existing = this.loading.get(name); if (existing) return existing; const ctx = this.getContext(); if (!ctx) return Promise.resolve(null); const promise = fetch(url) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status} loading ${url}`); return res.arrayBuffer(); }) .then((data) => ctx.decodeAudioData(data)) .then((buffer) => { this.buffers.set(name, buffer); return buffer; }) .catch((err) => { logError(err, { scope: "soundEffects.preload", name }); return null; }); this.loading.set(name, promise); return promise; } play(name: string, opts: PlayOptions = {}) { if (!this.options.enabled) return; const buffer = this.buffers.get(name); if (!buffer) return; // silently skip if not loaded const ctx = this.ctx; const master = this.masterGain; if (!ctx || !master) return; // Resume suspended context (autoplay policies). Resume is async but // start(0) is queued correctly once the context resumes. if (ctx.state === "suspended") { void ctx.resume().catch((err) => logError(err, { scope: "soundEffects.resume" }), ); } const throttleMs = opts.throttleMs ?? 15; const now = ctx.currentTime * 1000; const last = this.lastPlayedAt.get(name) ?? -Infinity; if (now - last < throttleMs) return; this.lastPlayedAt.set(name, now); const source = ctx.createBufferSource(); source.buffer = buffer; const variance = opts.pitchVariance ?? 0.04; if (variance > 0) { // Centered around 1.0; e.g. variance 0.04 → 0.96..1.04 const jitter = 1 + (Math.random() * 2 - 1) * variance; source.playbackRate.value = jitter; } const gain = ctx.createGain(); gain.gain.value = opts.volume ?? 1; source.connect(gain).connect(master); source.start(0); } } export const soundEffects = new SoundEffectsEngine(); export type { PlayOptions };