How the Completion Sound System Using AudioContext Works in pi-web
The pi-web application uses a custom React hook called useAudio that manages a single shared AudioContext, persists user preferences in localStorage, and unlocks audio only after user gestures to play a dual-oscillator "ding" when AI responses complete.
The completion sound system in the agegr/pi-web repository demonstrates a robust implementation of the Web Audio API within a React application. By encapsulating audio logic in a reusable hook, the codebase ensures consistent behavior across components while respecting modern browser autoplay restrictions. This article examines the internal mechanics of how pi-web initializes, unlocks, and triggers completion sounds using a carefully managed AudioContext lifecycle.
Architecture of the AudioContext Completion Sound System
The useAudio hook in hooks/useAudio.ts serves as the central nervous system for all audio operations. It maintains a singleton AudioContext instance and coordinates state persistence with immediate audio feedback.
Initializing the Audio State
On component mount, the hook reads the user's preference from localStorage using the key pi-sound-enabled. If no preference exists, it defaults to true, ensuring first-time users hear the completion cue.
To prevent stale closures in asynchronous callbacks, the implementation keeps a mutable reference (enabledRef) synchronized with the React state. This pattern, visible at lines 31-33 in hooks/useAudio.ts, ensures that functions like playDone always access the current sound preference without triggering unnecessary re-renders.
Managing the Shared AudioContext
Rather than creating multiple audio contexts, the hook stores a single instance in a useRef container. The getCtx function (lines 37-46) implements a lazy initialization pattern: it instantiates new AudioContext() only when first requested and recreates it if the previous context enters a "closed" state.
This singleton approach conserves system resources and maintains consistent audio graph state throughout the application lifecycle.
Handling Browser Autoplay Policies
Modern browsers require user interaction before allowing audio playback. The unlockAudio function (lines 48-53) specifically addresses this by checking ctx.state === "suspended" and calling ctx.resume() only when the user has explicitly interacted with the sound toggle.
The hook creates the AudioContext lazily and defers resumption until either unlockAudio is invoked from a click handler or playDone is called immediately before tone generation. This strategy satisfies Chrome and Firefox autoplay restrictions while maintaining a seamless user experience.
Generating the Completion Tone
When the assistant finishes streaming a response, the playDone method (lines 63-79) orchestrates the audible feedback. After verifying that sound is enabled, it retrieves the context and either resumes a suspended instance or proceeds directly to tone generation.
The actual sound synthesis occurs in playTone (lines 5-22), which creates a pleasant major-third interval by spinning up two oscillator nodes:
- Oscillator 1: C5 at 523.25 Hz
- Oscillator 2: E5 at 659.25 Hz
These oscillators connect to a GainNode that shapes a rapid attack/decay envelope, producing a crisp "ding" that signals completion without being jarring.
Implementing the useAudio Hook in Components
Components consume the hook to trigger sounds and render toggle controls. The implementation handles all complexity internally, exposing only the necessary interface.
import { useAudio } from "@/hooks/useAudio";
export default function ChatWindow() {
const {
soundEnabled,
onSoundToggle,
playDoneSound,
unlockAudio,
} = useAudio();
// Trigger sound when assistant finishes streaming
useEffect(() => {
if (agentFinished) {
playDoneSound();
}
}, [agentFinished, playDoneSound]);
return (
<div>
<button onClick={onSoundToggle}>
{soundEnabled ? "🔊" : "🔈"} Sound
</button>
</div>
);
}
The onSoundToggle function (lines 55-61) flips the enabled state, persists the boolean to localStorage, and forces an immediate unlockAudio call when the user activates sound. This ensures the context is ready for the next completion event.
Summary
- Singleton Pattern: The
useAudiohook maintains oneAudioContextinstance viauseRef, creating it lazily ingetCtxand recreating only if closed. - Autoplay Compliance: Audio unlocking happens exclusively within user gesture handlers via
unlockAudio, which resumes suspended contexts before playback. - Persistence: User preferences survive page reloads through
localStoragekeypi-sound-enabled, initialized at lines 25-29. - Tone Design: The completion sound uses dual oscillators at 523.25 Hz and 659.25 Hz with a gain envelope to create a distinctive "ding".
- API Surface: Components simply call
playDoneSound()when responses complete, letting the hook handle context state and browser policy compliance.
Frequently Asked Questions
Why does pi-web use a single AudioContext instead of creating one per sound?
Using a single AudioContext managed in useRef prevents resource exhaustion and audio graph fragmentation. The getCtx function in hooks/useAudio.ts ensures only one context exists at a time, recreating it only if the previous instance enters a "closed" state.
How does the system handle browsers blocking autoplay?
The implementation respects the Web Audio API autoplay policy by initially creating suspended contexts and only calling ctx.resume() inside unlockAudio (lines 48-53) or immediately before playback in playDone. Both pathways require prior user interaction, satisfying Chrome and Firefox requirements.
What frequencies make up the completion sound?
The playTone function generates a major third interval using two sine wave oscillators: C5 at 523.25 Hz and E5 at 659.25 Hz. These combine through a GainNode with a shaped envelope to produce the characteristic "ding".
Where is the user's sound preference stored?
The hook persists the boolean flag under the localStorage key pi-sound-enabled. It reads this value during initialization (lines 25-29) and writes updates whenever onSoundToggle is invoked, ensuring settings persist across sessions.
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 →