feat: add sound effects for hud actions

Closes #174
This commit is contained in:
talksik
2026-04-25 14:25:24 -07:00
parent 13c6bbd0fb
commit 6b755a426b
9 changed files with 393 additions and 3 deletions
Binary file not shown.
+5 -2
View File
@@ -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 = () => (
<TopLevelErrorBoundary>
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<App />
<Toaster />
<SoundEffectsProvider>
<App />
<Toaster />
</SoundEffectsProvider>
</QueryClientProvider>
</TooltipProvider>
</TopLevelErrorBoundary>
+5
View File
@@ -2,3 +2,8 @@ declare module "*.wav" {
const src: string;
export default src;
}
declare module "*.mp3" {
const src: string;
export default src;
}
+17 -1
View File
@@ -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<string>();
useEffect(() => {
@@ -146,6 +149,19 @@ export default function SettingsPage() {
onCheckedChange={handleToggleEmailNotifications}
/>
</div>
<div className="flex w-full items-center gap-3 px-4 py-3">
<span className="text-muted-foreground flex size-5 items-center justify-center">
<Volume2 className="size-4" />
</span>
<span className="min-w-0 flex-1 text-sm font-medium">
Sound effects
</span>
<Switch
size="sm"
checked={soundEffectsEnabled}
onCheckedChange={setSoundEffectsEnabled}
/>
</div>
</SettingsGroup>
<Separator className="mt-4" />
+152
View File
@@ -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<string, AudioBuffer>();
private loading = new Map<string, Promise<AudioBuffer | null>>();
private lastPlayedAt = new Map<string, number>();
private options: EngineOptions = { masterVolume: 0.02, enabled: true };
setOptions(next: Partial<EngineOptions>) {
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<AudioBuffer | null> {
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 };
@@ -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: <button>, <a>, role="button"/etc., or `cursor: pointer`.
* Centralizing here means div/span clickables get sound automatically
* without touching every call site.
* - Plays "key-tap" on keydown OUTSIDE text inputs, so navigating menus and
* overlays feels tactile but typing into compose stays silent.
*/
export function SoundEffectsProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
preloadAllSounds();
}, []);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (e.button !== 0) return; // left click only
if (!(e.target instanceof Element)) return;
if (isClickable(e.target)) playSound("click");
};
document.addEventListener("click", handler);
return () => document.removeEventListener("click", handler);
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.repeat) return;
if (isPureModifier(e.key)) return;
if (isTextInputTarget(e.target)) return;
if (e.key === "Escape" || e.key === "Enter" || e.key === "Tab") {
playSound("key-action");
} else {
playSound("key-tap");
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
return children;
}
const CLICKABLE_ROLES = new Set([
"button",
"link",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"tab",
"switch",
"checkbox",
"radio",
"option",
]);
/**
* Walks up to MAX_DEPTH ancestors looking for a recognizably-clickable element.
* Tag/role checks are cheap — getComputedStyle is the fallback for div-style
* clickables that only signal intent through `cursor: pointer`.
*/
function isClickable(target: Element): boolean {
const MAX_DEPTH = 6;
let el: Element | null = target;
for (let depth = 0; el && depth < MAX_DEPTH; depth++, el = el.parentElement) {
const tag = el.tagName;
if (tag === "BUTTON" || tag === "A" || tag === "SUMMARY") return true;
const role = el.getAttribute("role");
if (role && CLICKABLE_ROLES.has(role)) return true;
if (window.getComputedStyle(el).cursor === "pointer") return true;
}
return false;
}
function isPureModifier(key: string): boolean {
return key === "Shift" || key === "Control" || key === "Meta" || key === "Alt";
}
function isTextInputTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
if (tag === "TEXTAREA") return true;
if (tag === "INPUT") {
const type = (target as HTMLInputElement).type;
return (
type === "" ||
type === "text" ||
type === "search" ||
type === "email" ||
type === "url" ||
type === "password" ||
type === "tel" ||
type === "number"
);
}
if (target.isContentEditable) return true;
return false;
}
+57
View File
@@ -0,0 +1,57 @@
/**
* Sound registry. To add a new effect, drop the file under
* `js/assets/sounds/` and add a line to REGISTERED below.
*
* Names referenced from code that aren't in REGISTERED fall back to "click",
* so the system feels alive end-to-end with one asset and stays consistent
* as more are added.
*/
import clickUrl from "../../../assets/sounds/click.mp3";
import { soundEffects, type PlayOptions } from "./engine";
/** Effect names referenced from code. Unmapped names fall back to "click". */
export type SoundName =
| "click"
| "key-tap"
| "key-action"
| "submit"
| "error"
| "open"
| "close";
/** Files that exist on disk. Add a line when you drop a new file. */
const REGISTERED: Partial<Record<SoundName, string>> = {
click: clickUrl,
};
/** Per-sound default play options. Tuned for HUD feel — subtle and slightly varied. */
const SOUND_DEFAULTS: Record<SoundName, PlayOptions> = {
click: { volume: 0.7, pitchVariance: 0.04 },
"key-tap": { volume: 0.4, pitchVariance: 0.08, throttleMs: 25 },
"key-action": { volume: 0.6, pitchVariance: 0.04 },
submit: { volume: 0.85, pitchVariance: 0.02 },
error: { volume: 0.7, pitchVariance: 0 },
open: { volume: 0.6, pitchVariance: 0.03 },
close: { volume: 0.5, pitchVariance: 0.03 },
};
const FALLBACK: SoundName = "click";
let preloaded = false;
/** Preload every registered sound file. Idempotent. Called once at app start. */
export function preloadAllSounds(): void {
if (preloaded) return;
preloaded = true;
for (const [name, url] of Object.entries(REGISTERED)) {
if (url) void soundEffects.preload(name, url);
}
}
/** Play a sound by name. Falls back to "click" if no dedicated file is registered. */
export function playSound(name: SoundName, override?: PlayOptions) {
const defaults = SOUND_DEFAULTS[name];
const resolvedName = REGISTERED[name] ? name : FALLBACK;
soundEffects.play(resolvedName, { ...defaults, ...override });
}
@@ -0,0 +1,15 @@
import { useCallback } from "react";
import { playSound, type SoundName } from "./sounds";
import type { PlayOptions } from "./engine";
/**
* Returns a stable `play(name, opts?)` function. The engine respects the
* global enabled/volume state from the sound effects store, so callers don't
* need to subscribe themselves.
*/
export function useSoundEffect() {
const play = useCallback((name: SoundName, opts?: PlayOptions) => {
playSound(name, opts);
}, []);
return { play };
}
+40
View File
@@ -0,0 +1,40 @@
import { create } from "zustand";
import { soundEffects } from "@/lib/sound-effects/engine";
const ENABLED_KEY = "llink:soundEffects-enabled";
const DEFAULT_ENABLED = true;
function loadEnabled(): boolean {
try {
const v = localStorage.getItem(ENABLED_KEY);
return v === null ? DEFAULT_ENABLED : v === "true";
} catch {
return DEFAULT_ENABLED;
}
}
interface SoundEffectsState {
enabled: boolean;
setEnabled: (enabled: boolean) => void;
}
export const useSoundEffectsStore = create<SoundEffectsState>((set) => {
const initialEnabled = loadEnabled();
// Sync engine with persisted state on first load. Master volume is a fixed
// tuning constant in the engine — no UI control.
soundEffects.setOptions({ enabled: initialEnabled });
return {
enabled: initialEnabled,
setEnabled: (enabled) => {
set({ enabled });
soundEffects.setOptions({ enabled });
try {
localStorage.setItem(ENABLED_KEY, String(enabled));
} catch {
// storage unavailable
}
},
};
});