How the Completion Sound Unlocks and Plays in the useAudio Hook: A Technical Deep Dive
The useAudio hook manages sound playback through a three-stage workflow: initializing state from localStorage, unlocking the browser-restricted AudioContext upon user interaction, and synthesizing a dual-oscillator tone when tasks complete.
This hook powers the "ding" notification in the agegr/pi-web repository whenever background tasks or chat messages finish. It solves the browser autoplay policy problem while giving users persistent control over audio preferences.
The Three-Stage Audio Architecture
Stage 1: Initialize Persistent State
The hook first establishes whether sound is enabled by checking localStorage for the pi-sound-enabled key. This value defaults to true for new users.
// hooks/useAudio.ts, lines 24-30
const [enabled, setEnabled] = useState<boolean>(() => {
if (typeof window === 'undefined') return true;
const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
return saved !== 'false';
});
A mutable ref enabledRef mirrors this state. This is critical because audio callbacks often execute outside React's render cycle, where state closures would be stale.
Stage 2: Unlock the AudioContext
Browsers enforce autoplay policies that block audio until triggered by a user gesture. The unlockAudio function handles this restriction:
// hooks/useAudio.ts, lines 48-53
const unlockAudio = useCallback((force = false) => {
if (!enabledRef.current && !force) return;
if (ctxRef.current?.state === 'suspended') {
void ctxRef.current.resume();
}
}, [enabledRef]);
The force parameter allows components to prepare the audio context even when the user has sound disabled—useful for ensuring zero-latency playback if they re-enable it mid-session.
Stage 3: Play the Completion Tone
The playDoneSound function serves as the public API. It guards against disabled sound, ensures the context is running, then delegates to playTone:
// hooks/useAudio.ts, lines 63-79
const playDoneSound = useCallback(() => {
if (!enabledRef.current) return;
const ctx = getCtx(); // returns singleton AudioContext
if (ctx.state === 'suspended') {
void ctx.resume().then(() => playTone(ctx));
} else {
playTone(ctx);
}
}, [enabledRef, getCtx]);
The playTone synthesizer (lines 5-22) creates two sine oscillators at 523 Hz (C5) and 659 Hz (E5), applying gain envelopes for a pleasant, fading "ding" characteristic.
Integration Points in the Application
AppShell.tsx: Central Task Completion Handler
The main shell component consumes useAudio and wires it to background task completion:
// components/AppShell.tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio, soundEnabledRef } = useAudio();
const handleBackgroundTaskDone = useCallback(() => {
if (soundEnabledRef.current) playDoneSound();
}, [playDoneSound, soundEnabledRef]);
Using soundEnabledRef rather than soundEnabled ensures the callback sees the latest toggle state even if it hasn't re-rendered.
ChatWindow.tsx: Message Completion Trigger
The chat component receives playDoneSound via props and invokes it through a ref to avoid re-renders:
// components/ChatWindow.tsx receives:
// playDoneSoundRef: MutableRefObject<() => void>
// After assistant message streams complete:
playDoneSoundRef.current();
Complete Working Examples
Sound Toggle Button with Immediate Unlock
import { useAudio } from '@/hooks/useAudio';
export function SoundToggleButton() {
const { soundEnabled, onSoundToggle, unlockAudio } = useAudio();
const handleToggle = () => {
onSoundToggle();
unlockAudio(true); // force unlock on interaction
};
return (
<button onClick={handleToggle}>
{soundEnabled ? '🔊 Sound On' : '🔇 Sound Off'}
</button>
);
}
The unlockAudio(true) call ensures the AudioContext resumes immediately on the user gesture, eliminating any latency for subsequent tones.
Async Task with Completion Sound
import { useAudio } from '@/hooks/useAudio';
import { useEffect } from 'react';
export function DataProcessor() {
const { playDoneSound, unlockAudio } = useAudio();
useEffect(() => {
async function process() {
const result = await fetch('/api/heavy-task');
const data = await result.json();
// Optional: pre-unlock for lower latency
unlockAudio();
playDoneSound(); // "ding" on completion
}
process();
}, [playDoneSound, unlockAudio]);
return <ProcessingUI />;
}
Key Design Patterns in useAudio
| Pattern | Implementation | Location |
|---|---|---|
| Singleton AudioContext | ctxRef holds single instance |
hooks/useAudio.ts, line ~35 |
| State/Ref mirroring | enabled state + enabledRef for callbacks |
lines 24-30, 31 |
| Lazy context creation | getCtx() function initializes on first use |
lines 32-43 |
| Suspended-state recovery | resume() called in both unlockAudio and playDoneSound |
lines 48-53, 63-79 |
| Dual-oscillator synthesis | Two sines with gain ramps for rich tone | lines 5-22 |
Summary
- State persistence: Sound preference survives sessions via
localStoragekeypi-sound-enabled - Browser compliance:
unlockAudiosatisfies autoplay policies by requiring user-gesture-originatedresume()calls - Zero-latency playback: The singleton
AudioContextpattern prevents recreation overhead - Flexible triggering: Any component can call
playDoneSound(); the hook centrally manages all audio policy - Ref-based freshness:
enabledRefensures callbacks always respect the current user preference
Frequently Asked Questions
Why does useAudio use both state and a ref for the enabled flag?
React state creates closed-over values in callbacks. Since playDoneSound and unlockAudio may execute asynchronously or in event handlers outside the render cycle, enabledRef provides mutable access to the current preference without requiring callback regeneration. This prevents stale closures where a disabled sound would still play because the callback captured an old enabled value.
What happens if playDoneSound is called before any user interaction?
The browser's AudioContext starts in a suspended state. When playDoneSound detects this, it calls ctx.resume()—which returns a Promise—and chains playTone in a .then() handler. If the resume succeeds (allowed by prior user gesture elsewhere in the app), the tone plays. If denied, the promise rejects silently and no sound occurs. Calling unlockAudio() during user interactions (like button clicks) pre-emptively resolves this.
How does the tone generator actually create the "ding" sound?
The playTone function instantiates two OscillatorNode instances connected to GainNode envelopes. Each oscillator runs at distinct frequencies (523 Hz and 659 Hz, a perfect fifth interval). The gain nodes apply exponential ramp curves to create attack and decay, producing a bell-like character without loading external audio files. This keeps the bundle size minimal and avoids network latency.
Can the completion sound play if the user disables it mid-session?
No. The playDoneSound function's first line checks enabledRef.current and returns early if false. Since enabledRef updates synchronously when onSoundToggle fires, subsequent calls to playDoneSound are immediately suppressed—even if triggered by in-flight async operations that started before the toggle.
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 →