How useAudio Handles Completion Sounds with Browser Autoplay Policies in pi-web
The useAudio hook manages a single AudioContext that initializes in a suspended state to comply with browser autoplay restrictions, resuming audio output only after user interactions while generating dual-tone completion sounds through the Web Audio API.
The pi-web repository implements an auditory feedback system for AI chat completions that must navigate modern browsers' strict autoplay policies. Located at hooks/useAudio.ts, the useAudio hook encapsulates the complexity of creating reusable audio resources, handling user gesture requirements, and synthesizing the characteristic "ding" that plays when the assistant finishes responding.
AudioContext Architecture and Lazy Initialization
Single Instance Management
The hook maintains one AudioContext across component lifecycles using a mutable ref to prevent expensive recreation and state loss. A helper function retrieves the existing context if valid, or lazily instantiates a new one when first requested.
const ctxRef = useRef<AudioContext | null>(null);
const getCtx = useCallback((): AudioContext | null => {
if (ctxRef.current && ctxRef.current.state !== "closed") return ctxRef.current;
try {
ctxRef.current = new AudioContext();
} catch {
return null;
}
return ctxRef.current;
}, []);
Creating the Web Audio API context outside a user gesture immediately places it in a "suspended" state, which means the graph exists but produces no audible output until explicitly resumed.
Respecting Browser Autoplay Policies
Unlocking Audio with User Gestures
The unlockAudio method bridges the gap between React's initialization and the browser's requirement for user interaction. It checks the current state and calls resume() only when the context is suspended, typically bound to click events on interactive elements like the sound-toggle button.
const unlockAudio = useCallback((force = false) => {
if (!force && !enabledRef.current) return;
const ctx = getCtx();
if (!ctx || ctx.state !== "suspended") return;
ctx.resume().catch(() => {});
}, [getCtx]);
Defensive Playback Logic
Before generating tones, playDoneSound (internally implemented as playDone) inspects the context state. If suspended, it chains the resume promise to the playback function, ensuring the tone only executes after the browser grants audio permission.
const playDone = useCallback(() => {
if (!enabledRef.current) return;
const ctx = getCtx();
if (!ctx) return;
const play = () => {
try { playTone(ctx); } catch {}
};
if (ctx.state === "suspended") {
ctx.resume().then(play).catch(() => {});
return;
}
play();
}, [getCtx]);
Generating the Completion Tone
Rather than loading external audio assets, the hook synthesizes a completion sound using two sine wave oscillators programmed to output frequencies of 523.25 Hz (C5) and 659.25 Hz (E5). A gain envelope quickly fades each tone to prevent clipping and create a pleasant chime.
function playTone(ctx: AudioContext) {
const now = ctx.currentTime;
const freqs = [523.25, 659.25];
freqs.forEach((freq, i) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = "sine";
osc.frequency.value = freq;
const t = now + i * 0.18;
gain.gain.setValueAtTime(0, t);
gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
osc.start(t);
osc.stop(t + 0.45);
});
}
The staggered start times (offset by 0.18 seconds) create a harmonic interval that signals completion distinctly from notification sounds.
Integration with ChatWindow
The ChatWindow component at components/ChatWindow.tsx consumes the hook to coordinate audio feedback with streaming response events:
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();
Integration patterns include:
- Calling
unlockAudioon the sound-toggle button'sonClickhandler to satisfy autoplay requirements before the first completion tone plays - Invoking
playDoneSoundvia a stable ref callback when the assistant finishes streaming a response, ensuring the latest function reference executes even if the component re-renders - Persisting the
soundEnabledboolean tolocalStorageunder the key"pi-sound-enabled", defaulting totruefor new users
Summary
useAudioinhooks/useAudio.tsmanages a singletonAudioContextthat initializes in a suspended state to comply with browser autoplay policies from page loadunlockAudiomust be triggered by user gestures (such as clicking the sound toggle) to callctx.resume()and unlock audio output permissionsplayDoneSoundautomatically handles suspended contexts by attempting to resume theAudioContextbefore executing theplayTonegenerator, preventing autoplay violations- Dual-tone synthesis uses two sine oscillators at 523 Hz and 659 Hz with exponential gain ramps to generate the completion chime without external file dependencies
ChatWindoworchestrates the audio lifecycle by unlocking on interaction and playing tones on completion, while the"pi-sound-enabled"localStorage key preserves user preferences across sessions
Frequently Asked Questions
What prevents the completion sound from violating browser autoplay policies?
The useAudio hook initializes the AudioContext in a suspended state, which is the browser's default for automatically created contexts. The hook only calls ctx.resume() inside the unlockAudio method bound to user-initiated click events, satisfying the requirement for a user gesture before audio can play.
Why does pi-web synthesize tones instead of using audio files?
Synthesizing tones with the Web Audio API oscillator nodes eliminates HTTP requests for audio assets, reduces bundle size, and allows precise control over the sound characteristics (frequency, envelope, timing) without managing external media files or dealing with asynchronous loading states.
How does the hook handle browsers where AudioContext is unavailable?
The getCtx function wraps new AudioContext() in a try-catch block. If the constructor throws (due to unsupported browsers or security restrictions), the function returns null, and all subsequent audio operations fail silently without crashing the React component tree.
Can the completion sound play on the very first chat response?
Only if the user has already interacted with the page to trigger unlockAudio. On the first load, the context remains suspended until the user clicks the sound-toggle button or another interactive element, at which point unlockAudio resumes the context and subsequent playDoneSound calls can produce audible output.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →