/** Short dubstep/techno hover blips — Web Audio, no asset files. */ export const HOVER_SFX_STORAGE_KEY = 'aetherforge-hover-sfx'; export const HOVER_SFX_VOLUME_KEY = 'aetherforge-hover-volume'; export function loadHoverEnabled(): boolean { try { const v = localStorage.getItem(HOVER_SFX_STORAGE_KEY); return v === null ? true : v === '1'; } catch { return true; } } export function loadHoverVolume(): number { try { const v = localStorage.getItem(HOVER_SFX_VOLUME_KEY); if (v === null) return 0.25; const n = parseFloat(v); return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.25; } catch { return 0.25; } } function persistHoverEnabled(enabled: boolean) { try { localStorage.setItem(HOVER_SFX_STORAGE_KEY, enabled ? '1' : '0'); } catch { /* ignore */ } } function persistHoverVolume(volume: number) { try { localStorage.setItem(HOVER_SFX_VOLUME_KEY, String(volume)); } catch { /* ignore */ } } type HoverVariant = 'wub' | 'blip' | 'stab'; export class HoverSfxEngine { private ctx: AudioContext | null = null; private enabled = loadHoverEnabled(); private volume = loadHoverVolume(); private unlocked = false; private lastPlayAt = 0; private debounceMs = 140; isEnabled() { return this.enabled; } getVolume() { return this.volume; } setEnabled(enabled: boolean) { this.enabled = enabled; persistHoverEnabled(enabled); } setVolume(volume: number) { this.volume = Math.min(1, Math.max(0, volume)); persistHoverVolume(this.volume); } setDebounceMs(ms: number) { this.debounceMs = Math.max(80, ms); } unlock() { if (this.unlocked) return; try { const Ctx = typeof window !== 'undefined' ? window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext : undefined; if (!Ctx) return; if (!this.ctx) this.ctx = new Ctx(); if (this.ctx.state === 'suspended') void this.ctx.resume(); this.unlocked = true; } catch { /* ignore */ } } /** Respects main SFX mute via `sfxEnabled` from caller. */ play(sfxEnabled = true) { if (!sfxEnabled || !this.enabled) return; const now = Date.now(); if (now - this.lastPlayAt < this.debounceMs) return; this.lastPlayAt = now; this.unlock(); try { const Ctx = window.AudioContext || (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; if (!Ctx) return; if (!this.ctx) this.ctx = new Ctx(); const ctx = this.ctx; if (ctx.state === 'suspended') void ctx.resume(); const variant = pickVariant(); this.scheduleVariant(ctx, variant); } catch { /* Audio blocked */ } } preview(sfxEnabled = true) { this.lastPlayAt = 0; this.play(sfxEnabled); } private scheduleVariant(ctx: AudioContext, variant: HoverVariant) { const master = ctx.createGain(); master.gain.value = this.volume; master.connect(ctx.destination); const t0 = ctx.currentTime; switch (variant) { case 'wub': this.scheduleWub(ctx, master, t0); break; case 'blip': this.scheduleBlip(ctx, master, t0); break; case 'stab': this.scheduleStab(ctx, master, t0); break; } } private scheduleWub(ctx: AudioContext, dest: GainNode, t0: number) { const osc = ctx.createOscillator(); const g = ctx.createGain(); const filter = ctx.createBiquadFilter(); filter.type = 'lowpass'; filter.frequency.setValueAtTime(420, t0); filter.frequency.exponentialRampToValueAtTime(90, t0 + 0.1); osc.type = 'sawtooth'; osc.frequency.setValueAtTime(95, t0); osc.frequency.exponentialRampToValueAtTime(42, t0 + 0.09); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(0.09, t0 + 0.012); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.11); osc.connect(filter); filter.connect(g); g.connect(dest); osc.start(t0); osc.stop(t0 + 0.13); } private scheduleBlip(ctx: AudioContext, dest: GainNode, t0: number) { const freq = 280 + Math.random() * 180; const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = 'square'; osc.frequency.setValueAtTime(freq, t0); osc.frequency.exponentialRampToValueAtTime(freq * 1.4, t0 + 0.04); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(0.05, t0 + 0.006); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.055); osc.connect(g); g.connect(dest); osc.start(t0); osc.stop(t0 + 0.07); } private scheduleStab(ctx: AudioContext, dest: GainNode, t0: number) { const osc = ctx.createOscillator(); const g = ctx.createGain(); osc.type = 'triangle'; osc.frequency.setValueAtTime(62, t0); osc.frequency.setValueAtTime(48, t0 + 0.03); g.gain.setValueAtTime(0.0001, t0); g.gain.exponentialRampToValueAtTime(0.07, t0 + 0.01); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.08); osc.connect(g); g.connect(dest); osc.start(t0); osc.stop(t0 + 0.1); } } function pickVariant(): HoverVariant { const r = Math.random(); if (r < 0.45) return 'wub'; if (r < 0.8) return 'blip'; return 'stab'; } export const hoverSfxEngine = new HoverSfxEngine();