mirror of
https://github.com/Since-3/since3-website.git
synced 2026-10-12 19:51:16 +00:00
161 lines
5.8 KiB
TypeScript
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)
|
|
}
|