shadimage
Chanhdai
hookuse-soundsource: ok · refreshed

useSound

Hook for playing sound effects with volume, playback rate, and interrupt controls.

npx shadcn add "https://chanhdai.com/r/use-sound.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Files3 · source cached 10/2/2026, 12:38:46 AM

src/registry/hooks/sound/use-sound.ts
"use client"

import { useCallback, useEffect, useRef, useState } from "react"

import {
  fetchAndDecodeAudio,
  getAudioContext,
} from "@/registry/lib/sound/sound-engine"
import type {
  PlayFunction,
  SoundControls,
  UseSoundOptions,
  UseSoundReturn,
} from "@/registry/lib/sound/sound-types"

export function useSound(
  url: string,
  options: UseSoundOptions = {}
): UseSoundReturn {
  const {
    volume = 1,
    playbackRate = 1,
    interrupt = false,
    soundEnabled = true,
    lazy = false,
    onPlay,
    onEnd,
    onPause,
    onStop,
  } = options

  const [isPlaying, setIsPlaying] = useState(false)

  const sourceRef = useRef<AudioBufferSourceNode | null>(null)
  const gainRef = useRef<GainNode | null>(null)
  const bufferRef = useRef<AudioBuffer | null>(null)

  useEffect(() => {
    bufferRef.current = null

    if (lazy) return

    let cancelled = false

    fetchAndDecodeAudio(url).then((buffer) => {
      if (cancelled) return
      bufferRef.current = buffer
    })

    return () => {
      cancelled = true
    }
  }, [url, lazy])

  const stop = useCallback(() => {
    if (sourceRef.current) {
      try {
        sourceRef.current.stop()
      } catch {
        // Already stopped.
      }
      sourceRef.current = null
    }
    setIsPlaying(false)
    onStop?.()
  }, [onStop])

  const play: PlayFunction = useCallback(
    (overrides?) => {
      if (!soundEnabled) return

      const startPlayback = (buffer: AudioBuffer) => {
        const ctx = getAudioContext()

        if (ctx.state === "suspended") {
          ctx.resume()
        }

        if (interrupt && sourceRef.current) {
          stop()
        }

        const source = ctx.createBufferSource()
        const gain = ctx.createGain()

        source.buffer = buffer
        source.playbackRate.value = overrides?.playbackRate ?? playbackRate
        gain.gain.value = overrides?.volume ?? volume

        source.connect(gain)
        gain.connect(ctx.destination)

        source.onended = () => {
          setIsPlaying(false)
          onEnd?.()
        }

        source.start(0)
        sourceRef.current = source
        gainRef.current = gain
        setIsPlaying(true)
        onPlay?.()
      }

      if (bufferRef.current) {
        startPlayback(bufferRef.current)
        return
      }

      // Lazy: load on first play, then play immediately.
      fetchAndDecodeAudio(url).then((buffer) => {
        bufferRef.current = buffer
        startPlayback(buffer)
      })
    },
    [soundEnabled, url, interrupt, playbackRate, volume, stop, onPlay, onEnd]
  )

  const pause = useCallback(() => {
    stop()
    onPause?.()
  }, [stop, onPause])

  // Keep gain in sync with volume changes without restarting playback.
  useEffect(() => {
    if (gainRef.current) {
      gainRef.current.gain.value = volume
    }
  }, [volume])

  // Cleanup on unmount.
  useEffect(() => {
    return () => {
      if (sourceRef.current) {
        try {
          sourceRef.current.stop()
        } catch {
          // Already stopped.
        }
      }
    }
  }, [])

  const controls: SoundControls = { stop, pause, isPlaying }

  return [play, controls] as const
}
src/registry/lib/sound/sound-engine.ts
let audioContext: AudioContext | null = null

// Cache the Promise itself to deduplicate concurrent fetch calls for the same URL.
const bufferCache = new Map<string, Promise<AudioBuffer>>()

export function getAudioContext(): AudioContext {
  if (!audioContext) {
    audioContext = new AudioContext()
  }
  return audioContext
}

export function fetchAndDecodeAudio(url: string): Promise<AudioBuffer> {
  const cached = bufferCache.get(url)
  if (cached) return cached

  const ctx = getAudioContext()

  const promise = fetch(url)
    .then((res) => {
      if (!res.ok) throw new Error(`Failed to fetch audio: ${res.status}`)
      return res.arrayBuffer()
    })
    .then((data) => ctx.decodeAudioData(data))
    .catch((err) => {
      // Remove failed entry so subsequent calls can retry.
      bufferCache.delete(url)
      throw err
    })

  bufferCache.set(url, promise)
  return promise
}
src/registry/lib/sound/sound-types.ts
export interface UseSoundOptions {
  /** Volume level from 0 to 1. Default: 1 */
  volume?: number
  /** Playback speed multiplier. Default: 1 */
  playbackRate?: number
  /** If true, calling play() stops current playback first. Default: false */
  interrupt?: boolean
  /** If false, play() does nothing. Useful for user preferences. Default: true */
  soundEnabled?: boolean
  /**
   * When true, audio is not fetched until the first play() call.
   * When false, audio is fetched immediately on mount.
   * Default: false
   */
  lazy?: boolean
  /** Called when playback starts */
  onPlay?: () => void
  /** Called when playback ends naturally */
  onEnd?: () => void
  /** Called when pause() is called */
  onPause?: () => void
  /** Called when stop() is called */
  onStop?: () => void
}

export type PlayFunction = (overrides?: {
  volume?: number
  playbackRate?: number
}) => void

export interface SoundControls {
  stop: () => void
  pause: () => void
  isPlaying: boolean
}

export type UseSoundReturn = readonly [PlayFunction, SoundControls]

More from Chanhdai

Browse all components in this library →