since3-website/lib/sound.ts
2026-09-25 00:45:33 +02:00

161 lines
5.8 KiB
TypeScript

// 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, which is always inside a user gesture
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<SoundVoice, (engine: Engine, now: number) => 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
const current = getEngine()
VOICES[voice](current, current.ctx.currentTime)
}