Binary file not shown.
+5
-2
@@ -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>
|
||||
|
||||
Vendored
+5
@@ -2,3 +2,8 @@ declare module "*.wav" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
declare module "*.mp3" {
|
||||
const src: string;
|
||||
export default src;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user