{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "use-sound",
  "title": "useSound",
  "author": "ncdai <dai@chanhdai.com>",
  "description": "Hook for playing sound effects with volume, playback rate, and interrupt controls.",
  "files": [
    {
      "path": "src/registry/hooks/sound/use-sound.ts",
      "content": "\"use client\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\n\nimport {\n  fetchAndDecodeAudio,\n  getAudioContext,\n} from \"@/registry/lib/sound/sound-engine\"\nimport type {\n  PlayFunction,\n  SoundControls,\n  UseSoundOptions,\n  UseSoundReturn,\n} from \"@/registry/lib/sound/sound-types\"\n\nexport function useSound(\n  url: string,\n  options: UseSoundOptions = {}\n): UseSoundReturn {\n  const {\n    volume = 1,\n    playbackRate = 1,\n    interrupt = false,\n    soundEnabled = true,\n    lazy = false,\n    onPlay,\n    onEnd,\n    onPause,\n    onStop,\n  } = options\n\n  const [isPlaying, setIsPlaying] = useState(false)\n\n  const sourceRef = useRef<AudioBufferSourceNode | null>(null)\n  const gainRef = useRef<GainNode | null>(null)\n  const bufferRef = useRef<AudioBuffer | null>(null)\n\n  useEffect(() => {\n    bufferRef.current = null\n\n    if (lazy) return\n\n    let cancelled = false\n\n    fetchAndDecodeAudio(url).then((buffer) => {\n      if (cancelled) return\n      bufferRef.current = buffer\n    })\n\n    return () => {\n      cancelled = true\n    }\n  }, [url, lazy])\n\n  const stop = useCallback(() => {\n    if (sourceRef.current) {\n      try {\n        sourceRef.current.stop()\n      } catch {\n        // Already stopped.\n      }\n      sourceRef.current = null\n    }\n    setIsPlaying(false)\n    onStop?.()\n  }, [onStop])\n\n  const play: PlayFunction = useCallback(\n    (overrides?) => {\n      if (!soundEnabled) return\n\n      const startPlayback = (buffer: AudioBuffer) => {\n        const ctx = getAudioContext()\n\n        if (ctx.state === \"suspended\") {\n          ctx.resume()\n        }\n\n        if (interrupt && sourceRef.current) {\n          stop()\n        }\n\n        const source = ctx.createBufferSource()\n        const gain = ctx.createGain()\n\n        source.buffer = buffer\n        source.playbackRate.value = overrides?.playbackRate ?? playbackRate\n        gain.gain.value = overrides?.volume ?? volume\n\n        source.connect(gain)\n        gain.connect(ctx.destination)\n\n        source.onended = () => {\n          setIsPlaying(false)\n          onEnd?.()\n        }\n\n        source.start(0)\n        sourceRef.current = source\n        gainRef.current = gain\n        setIsPlaying(true)\n        onPlay?.()\n      }\n\n      if (bufferRef.current) {\n        startPlayback(bufferRef.current)\n        return\n      }\n\n      // Lazy: load on first play, then play immediately.\n      fetchAndDecodeAudio(url).then((buffer) => {\n        bufferRef.current = buffer\n        startPlayback(buffer)\n      })\n    },\n    [soundEnabled, url, interrupt, playbackRate, volume, stop, onPlay, onEnd]\n  )\n\n  const pause = useCallback(() => {\n    stop()\n    onPause?.()\n  }, [stop, onPause])\n\n  // Keep gain in sync with volume changes without restarting playback.\n  useEffect(() => {\n    if (gainRef.current) {\n      gainRef.current.gain.value = volume\n    }\n  }, [volume])\n\n  // Cleanup on unmount.\n  useEffect(() => {\n    return () => {\n      if (sourceRef.current) {\n        try {\n          sourceRef.current.stop()\n        } catch {\n          // Already stopped.\n        }\n      }\n    }\n  }, [])\n\n  const controls: SoundControls = { stop, pause, isPlaying }\n\n  return [play, controls] as const\n}\n",
      "type": "registry:hook",
      "target": "@hooks/sound/use-sound.ts"
    },
    {
      "path": "src/registry/lib/sound/sound-engine.ts",
      "content": "let audioContext: AudioContext | null = null\n\n// Cache the Promise itself to deduplicate concurrent fetch calls for the same URL.\nconst bufferCache = new Map<string, Promise<AudioBuffer>>()\n\nexport function getAudioContext(): AudioContext {\n  if (!audioContext) {\n    audioContext = new AudioContext()\n  }\n  return audioContext\n}\n\nexport function fetchAndDecodeAudio(url: string): Promise<AudioBuffer> {\n  const cached = bufferCache.get(url)\n  if (cached) return cached\n\n  const ctx = getAudioContext()\n\n  const promise = fetch(url)\n    .then((res) => {\n      if (!res.ok) throw new Error(`Failed to fetch audio: ${res.status}`)\n      return res.arrayBuffer()\n    })\n    .then((data) => ctx.decodeAudioData(data))\n    .catch((err) => {\n      // Remove failed entry so subsequent calls can retry.\n      bufferCache.delete(url)\n      throw err\n    })\n\n  bufferCache.set(url, promise)\n  return promise\n}\n",
      "type": "registry:lib",
      "target": "@lib/sound/sound-engine.ts"
    },
    {
      "path": "src/registry/lib/sound/sound-types.ts",
      "content": "export interface UseSoundOptions {\n  /** Volume level from 0 to 1. Default: 1 */\n  volume?: number\n  /** Playback speed multiplier. Default: 1 */\n  playbackRate?: number\n  /** If true, calling play() stops current playback first. Default: false */\n  interrupt?: boolean\n  /** If false, play() does nothing. Useful for user preferences. Default: true */\n  soundEnabled?: boolean\n  /**\n   * When true, audio is not fetched until the first play() call.\n   * When false, audio is fetched immediately on mount.\n   * Default: false\n   */\n  lazy?: boolean\n  /** Called when playback starts */\n  onPlay?: () => void\n  /** Called when playback ends naturally */\n  onEnd?: () => void\n  /** Called when pause() is called */\n  onPause?: () => void\n  /** Called when stop() is called */\n  onStop?: () => void\n}\n\nexport type PlayFunction = (overrides?: {\n  volume?: number\n  playbackRate?: number\n}) => void\n\nexport interface SoundControls {\n  stop: () => void\n  pause: () => void\n  isPlaying: boolean\n}\n\nexport type UseSoundReturn = readonly [PlayFunction, SoundControls]\n",
      "type": "registry:lib",
      "target": "@lib/sound/sound-types.ts"
    }
  ],
  "type": "registry:hook"
}