diff --git a/js/assets/sounds/click.mp3 b/js/assets/sounds/click.mp3 new file mode 100644 index 0000000..4fd472c Binary files /dev/null and b/js/assets/sounds/click.mp3 differ diff --git a/js/src/App.tsx b/js/src/App.tsx index be7e21d..7c131b1 100644 --- a/js/src/App.tsx +++ b/js/src/App.tsx @@ -18,6 +18,7 @@ import { RouteErrorBoundary, TopLevelErrorBoundary, } from "@/components/app-error-boundary"; +import { SoundEffectsProvider } from "@/lib/sound-effects/sound-effects-provider"; const queryClient = createQueryClient(); @@ -87,8 +88,10 @@ const AppWithProviders = () => ( - - + + + + diff --git a/js/src/assets.d.ts b/js/src/assets.d.ts index 9e7051b..198d866 100644 --- a/js/src/assets.d.ts +++ b/js/src/assets.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const src: string; export default src; } + +declare module "*.mp3" { + const src: string; + export default src; +} diff --git a/js/src/features/settings-page.tsx b/js/src/features/settings-page.tsx index d6988a9..fb6b9f0 100644 --- a/js/src/features/settings-page.tsx +++ b/js/src/features/settings-page.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText } from "lucide-react"; +import { ChevronRight, LogOut, User, Info, Shield, Mail, Mic, LifeBuoy, FileText, Volume2 } from "lucide-react"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Separator } from "@/components/ui/separator"; import { Switch } from "@/components/ui/switch"; @@ -10,6 +10,7 @@ import { Muted } from "@/components/ui/typography"; import { ScrollArea } from "@/components/ui/scroll-area"; import { CopyableEmail } from "@/components/copyable-email"; import { useAuthStore } from "@/stores/auth-store"; +import { useSoundEffectsStore } from "@/stores/sound-effects-store"; import { apiClient } from "@/api/client"; import { logError, toUserMessage } from "@/lib/errors"; import { toast } from "sonner"; @@ -73,6 +74,8 @@ export default function SettingsPage() { const [emailNotifications, setEmailNotifications] = useState( user?.email_notifications_enabled ?? true, ); + const soundEffectsEnabled = useSoundEffectsStore((s) => s.enabled); + const setSoundEffectsEnabled = useSoundEffectsStore((s) => s.setEnabled); const [version, setVersion] = useState(); 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: