// Synthesized UI sounds via the Web Audio API: zero network cost, no files. // Off by default; the visitor opts in with the toggle in the header. const STORAGE_KEY = "since3:sound" export const SOUND_CHANGE_EVENT = "since3:sound-change" export type SoundVoice = "hover" | "click" | "tick" | "on" | "off" | "confirm" | "whoosh" // Fallback when localStorage is blocked (private mode, strict cookie settings) let memoryEnabled = false export function isEnabled() { try { const stored = window.localStorage.getItem(STORAGE_KEY) return stored === null ? memoryEnabled : stored === "on" } catch { return memoryEnabled } } export function setEnabled(enabled: boolean) { memoryEnabled = enabled try { window.localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off") } catch { // Storage blocked: the in-memory value keeps working for this visit } window.dispatchEvent(new Event(SOUND_CHANGE_EVENT)) } /* ── Engine ─────────────────────────────────────────────────────────── */ type Engine = { ctx: AudioContext; master: GainNode; noise: AudioBuffer } let engine: Engine | null = null // Created lazily on first use. Hover is not a user activation, so `play` // checks activation before calling this — see there. function getEngine(): Engine { if (!engine) { const ctx = new AudioContext() // A gentle compressor glues overlapping sounds and prevents clipping const compressor = ctx.createDynamicsCompressor() compressor.threshold.value = -16 compressor.ratio.value = 4 const master = ctx.createGain() master.gain.value = 0.9 master.connect(compressor).connect(ctx.destination) const noise = ctx.createBuffer(1, ctx.sampleRate / 2, ctx.sampleRate) const data = noise.getChannelData(0) for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1 engine = { ctx, master, noise } } if (engine.ctx.state === "suspended") void engine.ctx.resume() return engine } // Slight random variation so repeated sounds never feel mechanical function vary(value: number, amount: number) { return value * (1 + (Math.random() * 2 - 1) * amount) } type ToneOptions = { freq: number endFreq?: number type?: OscillatorType start: number attack?: number decay: number peak: number } function tone({ ctx, master }: Engine, options: ToneOptions) { const { freq, endFreq, type = "sine", start, attack = 0.004, decay, peak } = options const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = type osc.frequency.setValueAtTime(freq, start) if (endFreq) osc.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay) gain.gain.setValueAtTime(0.0001, start) gain.gain.exponentialRampToValueAtTime(peak, start + attack) gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay) osc.connect(gain).connect(master) osc.start(start) osc.stop(start + attack + decay + 0.02) } type NoiseOptions = { start: number attack?: number decay: number peak: number filter: BiquadFilterType freq: number endFreq?: number q?: number } function noiseBurst({ ctx, master, noise }: Engine, options: NoiseOptions) { const { start, attack = 0.002, decay, peak, filter, freq, endFreq, q = 1 } = options const source = ctx.createBufferSource() source.buffer = noise const biquad = ctx.createBiquadFilter() biquad.type = filter biquad.Q.value = q biquad.frequency.setValueAtTime(freq, start) if (endFreq) biquad.frequency.exponentialRampToValueAtTime(endFreq, start + attack + decay) const gain = ctx.createGain() gain.gain.setValueAtTime(0.0001, start) gain.gain.exponentialRampToValueAtTime(peak, start + attack) gain.gain.exponentialRampToValueAtTime(0.0001, start + attack + decay) source.connect(biquad).connect(gain).connect(master) source.start(start, Math.random() * 0.3) source.stop(start + attack + decay + 0.02) } const VOICES: Record void> = { // Light glassy tick when the pointer enters a link or button hover: (e, t) => tone(e, { freq: vary(2400, 0.06), start: t, attack: 0.002, decay: 0.035, peak: 0.07 }), // Short pitch drop plus a click transient; audible on laptop speakers too click: (e, t) => { tone(e, { freq: 900, endFreq: 280, start: t, attack: 0.002, decay: 0.07, peak: 0.4 }) tone(e, { freq: 1800, endFreq: 900, type: "triangle", start: t, attack: 0.001, decay: 0.03, peak: 0.12 }) noiseBurst(e, { start: t, decay: 0.012, peak: 0.2, filter: "highpass", freq: 3500 }) }, // Typewriter tick for each line of tool output tick: (e, t) => noiseBurst(e, { start: t, decay: 0.02, peak: 0.28, filter: "bandpass", freq: vary(4200, 0.15), q: 4 }), // Sound switched on: two rising notes (a fifth) on: (e, t) => { tone(e, { freq: 660, type: "triangle", start: t, decay: 0.18, peak: 0.28 }) tone(e, { freq: 990, type: "triangle", start: t + 0.08, decay: 0.28, peak: 0.28 }) }, // Sound switched off: the same notes falling off: (e, t) => { tone(e, { freq: 990, type: "triangle", start: t, decay: 0.14, peak: 0.22 }) tone(e, { freq: 660, type: "triangle", start: t + 0.07, decay: 0.2, peak: 0.18 }) }, // Success: bright major arpeggio confirm: (e, t) => { ;[1046.5, 1318.5, 1568, 2093].forEach((freq, i) => tone(e, { freq, type: "triangle", start: t + i * 0.07, attack: 0.005, decay: 0.45, peak: 0.2 }) ) }, // Menu open/close: filtered noise sweep whoosh: (e, t) => noiseBurst(e, { start: t, attack: 0.12, decay: 0.25, peak: 0.3, filter: "bandpass", freq: 350, endFreq: 3200, q: 0.9 }), } export function play(voice: SoundVoice, { force = false } = {}) { if (typeof window === "undefined") return if (!force && !isEnabled()) return // Before the first user activation, a new AudioContext stays "suspended" // and resume() is a no-op. currentTime is then frozen at 0, so every // sound scheduled while hovering (not an activation) would queue up at // t=0 and all fire at once, as one loud burst, once a click resumes it. const activated = navigator.userActivation?.hasBeenActive ?? true if (!engine && !activated) return if (engine && engine.ctx.state !== "running" && !activated) return const current = getEngine() if (current.ctx.state !== "running") return VOICES[voice](current, current.ctx.currentTime) }