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]