();
useEffect(() => {
@@ -146,6 +149,19 @@ export default function SettingsPage() {
onCheckedChange={handleToggleEmailNotifications}
/>
+
+
+
+
+
+ Sound effects
+
+
+
diff --git a/js/src/lib/sound-effects/engine.ts b/js/src/lib/sound-effects/engine.ts
new file mode 100644
index 0000000..0f5adda
--- /dev/null
+++ b/js/src/lib/sound-effects/engine.ts
@@ -0,0 +1,152 @@
+/**
+ * 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 };
diff --git a/js/src/lib/sound-effects/sound-effects-provider.tsx b/js/src/lib/sound-effects/sound-effects-provider.tsx
new file mode 100644
index 0000000..c22c1d7
--- /dev/null
+++ b/js/src/lib/sound-effects/sound-effects-provider.tsx
@@ -0,0 +1,102 @@
+import { useEffect } from "react";
+import { preloadAllSounds, playSound } from "./sounds";
+
+/**
+ * Mounts global HUD sound behavior:
+ * - Preloads every registered sound file once.
+ * - Plays "click" on any click whose target (or ancestor) is recognizably
+ * clickable: