{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "use-sound", "title": "Sound Hook", "description": "Custom React hook to load and play a sound from a given URL using the Web Audio API.", "files": [ { "path": "src/hooks/use-sound.ts", "content": "import { useCallback, useEffect, useRef, useState } from \"react\"\n\n/**\n * Cache storage for loaded audio buffers to prevent duplicate network requests and memory usage.\n * Maps audio URL to its decoded AudioBuffer.\n */\nconst audioCache = new Map<\n string,\n {\n buffer: AudioBuffer\n loading: Promise\n } | null\n>()\n\n/**\n * Shared AudioContext instance to avoid creating multiple contexts.\n * Multiple AudioContexts can cause performance issues and resource exhaustion.\n */\nlet sharedAudioContext: AudioContext | null = null\n\n/**\n * Gets or creates a shared AudioContext instance.\n */\nfunction getAudioContext(): AudioContext | null {\n if (sharedAudioContext) return sharedAudioContext\n\n const AudioContextClass =\n window.AudioContext ||\n (window as unknown as { webkitAudioContext: typeof AudioContext })\n .webkitAudioContext\n\n if (!AudioContextClass) {\n console.warn(\"Web Audio API is not supported in this browser.\")\n return null\n }\n\n sharedAudioContext = new AudioContextClass()\n return sharedAudioContext\n}\n\n/**\n * Custom React hook to load and play a sound from a given URL using the Web Audio API.\n *\n * This hook implements caching to prevent duplicate network requests and memory usage\n * when the same audio file is used across multiple components.\n *\n * @param url - The URL of the audio file to load and play.\n * @returns A function that, when called, plays the loaded sound.\n *\n * @remarks\n * - Audio buffers are cached globally, so the same file is only loaded once\n * - Uses a shared AudioContext to avoid resource exhaustion\n * - If the Web Audio API is not supported in the browser, a warning is logged and playback is disabled\n * - Errors during fetching or decoding the audio are logged to the console\n *\n * @example\n * ```tsx\n * const playClick = useSound('/sounds/click.mp3');\n * // Later in an event handler:\n * playClick();\n * ```\n */\nexport function useSound(url: string) {\n const audioCtxRef = useRef(null)\n const bufferRef = useRef(null)\n\n useEffect(() => {\n const audioCtx = getAudioContext()\n if (!audioCtx) return\n\n audioCtxRef.current = audioCtx\n\n // Check if already cached\n const cached = audioCache.get(url)\n if (cached?.buffer) {\n bufferRef.current = cached.buffer\n return\n }\n\n // Check if already loading\n if (cached?.loading) {\n cached.loading\n .then((decoded) => {\n bufferRef.current = decoded\n })\n .catch(() => {\n // Error already logged during fetch\n })\n return\n }\n\n // Start loading\n const loadingPromise = fetch(url)\n .then((res) => res.arrayBuffer())\n .then((data) => audioCtx.decodeAudioData(data))\n .then((decoded) => {\n // Store in cache\n audioCache.set(url, { buffer: decoded, loading: loadingPromise })\n bufferRef.current = decoded\n return decoded\n })\n .catch((err) => {\n console.log(`Failed to load sound from ${url}:`, err)\n // Mark as failed in cache\n audioCache.set(url, null)\n throw err\n })\n\n // Mark as loading in cache\n audioCache.set(url, { buffer: null!, loading: loadingPromise })\n }, [url])\n\n const play = useCallback((volume: number = 1) => {\n if (audioCtxRef.current && bufferRef.current) {\n const source = audioCtxRef.current.createBufferSource()\n const gainNode = audioCtxRef.current.createGain()\n\n source.buffer = bufferRef.current\n gainNode.gain.value = volume\n\n source.connect(gainNode)\n gainNode.connect(audioCtxRef.current.destination)\n source.start(0)\n }\n }, [])\n\n return play\n}\n\n/**\n * Custom React hook for lazy loading and playing sounds with manual preload control.\n *\n * Unlike `useSound()`, this hook does NOT load audio on mount. Audio is only fetched when:\n * - `preload()` is manually called (e.g., on hover)\n * - `play()` is called and audio is not yet loaded (auto-load fallback)\n *\n * This is ideal for audio that may not be needed by most users, saving initial bandwidth and memory.\n *\n * @param url - The URL of the audio file to load and play.\n * @returns Object with play function, preload function, and loading states.\n *\n * @remarks\n * - Audio buffers are cached globally and shared with `useSound()`\n * - Uses a shared AudioContext to avoid resource exhaustion\n * - If the Web Audio API is not supported, warnings are logged and playback is disabled\n * - Errors during fetching or decoding are logged to the console\n *\n * @example\n * ```tsx\n * const { play, preload, isLoading, isLoaded } = useSoundLazy('/sounds/rare.mp3');\n *\n * // Preload on hover for instant playback on click\n * preload()}\n * onClick={() => play()}\n * >\n * Play Sound\n * \n * ```\n */\nexport function useSoundLazy(url: string) {\n const audioCtxRef = useRef(null)\n const bufferRef = useRef(null)\n const loadingPromiseRef = useRef | null>(null)\n const [isLoading, setIsLoading] = useState(false)\n const [isLoaded, setIsLoaded] = useState(() => {\n // Check if already cached on initial render\n const cached = audioCache.get(url)\n return !!cached?.buffer\n })\n\n useEffect(() => {\n // Initialize AudioContext reference\n const audioCtx = getAudioContext()\n if (audioCtx) {\n audioCtxRef.current = audioCtx\n }\n\n // Check if already cached (e.g., loaded by another component)\n const cached = audioCache.get(url)\n if (cached?.buffer) {\n bufferRef.current = cached.buffer\n }\n }, [url])\n\n const load = useCallback(() => {\n // Early return if already loaded\n if (bufferRef.current) {\n return Promise.resolve(bufferRef.current)\n }\n\n // Return existing loading promise if already loading\n if (loadingPromiseRef.current) {\n return loadingPromiseRef.current\n }\n\n const audioCtx = getAudioContext()\n if (!audioCtx) {\n return Promise.reject(new Error(\"Web Audio API not supported\"))\n }\n\n audioCtxRef.current = audioCtx\n\n // Check cache\n const cached = audioCache.get(url)\n if (cached?.buffer) {\n bufferRef.current = cached.buffer\n setIsLoaded(true)\n return Promise.resolve(cached.buffer)\n }\n\n // Check if already loading by another component\n if (cached?.loading) {\n setIsLoading(true)\n const promise = cached.loading\n .then((decoded) => {\n bufferRef.current = decoded\n setIsLoaded(true)\n return decoded\n })\n .catch((err) => {\n // Error already logged during fetch\n throw err\n })\n .finally(() => {\n setIsLoading(false)\n loadingPromiseRef.current = null\n })\n\n loadingPromiseRef.current = promise\n return promise\n }\n\n // Start new load\n setIsLoading(true)\n const loadingPromise = fetch(url)\n .then((res) => res.arrayBuffer())\n .then((data) => audioCtx.decodeAudioData(data))\n .then((decoded) => {\n audioCache.set(url, { buffer: decoded, loading: loadingPromise })\n bufferRef.current = decoded\n setIsLoaded(true)\n return decoded\n })\n .catch((err) => {\n console.log(`Failed to load sound from ${url}:`, err)\n audioCache.set(url, null)\n throw err\n })\n .finally(() => {\n setIsLoading(false)\n loadingPromiseRef.current = null\n })\n\n // Mark as loading in cache\n audioCache.set(url, { buffer: null!, loading: loadingPromise })\n loadingPromiseRef.current = loadingPromise\n return loadingPromise\n }, [url])\n\n const preload = useCallback(() => {\n load().catch(() => {\n // Error already logged in load()\n })\n }, [load])\n\n const play = useCallback(\n (volume: number = 1) => {\n const playSound = () => {\n if (audioCtxRef.current && bufferRef.current) {\n const source = audioCtxRef.current.createBufferSource()\n const gainNode = audioCtxRef.current.createGain()\n\n source.buffer = bufferRef.current\n gainNode.gain.value = volume\n\n source.connect(gainNode)\n gainNode.connect(audioCtxRef.current.destination)\n source.start(0)\n }\n }\n\n // If already loaded, play immediately\n if (bufferRef.current) {\n playSound()\n return\n }\n\n // Auto-load fallback: load then play\n load()\n .then(() => {\n playSound()\n })\n .catch(() => {\n // Error already logged in load()\n })\n },\n [load]\n )\n\n return { play, preload, isLoading, isLoaded }\n}\n", "type": "registry:hook" } ], "type": "registry:hook" }