How Pi‑Web Plays Completion Sounds Using AudioContext and a localStorage Toggle
Pi‑Web generates completion tones using a custom React hook that manages a persistent AudioContext, reads the sound preference from localStorage, and works around browser autoplay restrictions by unlocking the context on user interaction.
The agegr/pi-web repository implements a lightweight audio system that adds auditory feedback when the AI assistant finishes responding. The entire system lives inside the useAudio hook at hooks/useAudio.ts, which provides tone generation, state persistence, and browser‑compatibility handling in under 100 lines of code.
Core Architecture of the Audio System
Tone Generation with Dual Sine Oscillators
The completion sound consists of two simultaneous sine waves at 523 Hz (C5) and 659 Hz (E5). The playTone helper constructs these oscillators programmatically:
- Lines 5‑22 in
hooks/useAudio.tsdefineplayTone, which:- Creates two
OscillatorNodeinstances connected to a sharedGainNode - Sets frequencies to create a pleasant major‑third interval
- Applies a short envelope (attack/release) to avoid clicking
- Creates two
This approach avoids external asset dependencies and keeps the bundle size minimal.
localStorage Persistence and Default Behavior
Reading the Sound Preference on Mount
The hook initializes its enabled state by checking localStorage for the key pi-sound-enabled:
// Lines 24-30 in hooks/useAudio.ts
const [enabled, setEnabled] = useState<boolean>(() => {
if (typeof window === 'undefined') return true;
const saved = window.localStorage.getItem('pi-sound-enabled');
return saved === null ? true : saved === 'true';
});
Key behavior: When no preference exists, sound defaults to enabled. This provides immediate feedback for first‑time users while still allowing opt‑out.
Managing a Reusable AudioContext
The getCtx Ref Pattern
Modern browsers suspend AudioContext instances until user interaction occurs. The hook stores a single context in a ref to avoid creating multiple instances:
// Lines 34-46 in hooks/useAudio.ts
const ctxRef = useRef<AudioContext | null>(null);
function getCtx(): AudioContext {
if (!ctxRef.current) {
ctxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return ctxRef.current;
}
This pattern ensures:
- Memory efficiency: One context for the entire application lifecycle
- Autoplay compliance: Context creation happens lazily, not on mount
- Cross‑browser support: Falls back to
webkitAudioContextfor Safari
Unlocking Suspended Contexts on User Interaction
The unlockAudio Function
Lines 48‑53 implement unlockAudio, which attempts to resume a suspended context:
async function unlockAudio(force = false): Promise<void> {
const ctx = getCtx();
if (ctx.state === 'suspended' && (enabled || force)) {
await ctx.resume();
}
}
The force parameter allows explicit unlocking even when sound is currently disabled—useful when the user toggles sound on and expects immediate feedback.
The Toggle Mechanism: Persisting Preferences
How toggle Synchronizes State and Storage
When users click the speaker icon, the toggle function executes three operations atomically:
- Flips the boolean state with
setEnabled(prev => !prev) - Persists the new value to
localStorage.setItem('pi-sound-enabled', String(newValue)) - Immediately calls
unlockAudio(true)to ensure the context is ready for the next tone
This guarantees that re‑enabling sound works instantly, without requiring a second click or page refresh.
Playing the Completion Sound
The playDone Execution Flow
Lines 63‑79 contain playDone, which orchestrates the actual playback:
function playDone() {
if (!enabledRef.current) return; // Fast bail-out using ref
const ctx = getCtx();
if (ctx.state === 'suspended') {
ctx.resume().then(() => playTone(ctx));
} else {
playTone(ctx);
}
}
Performance note: The hook exports soundEnabledRef alongside soundEnabled so callback handlers can check state without triggering re‑renders.
Hook API and Component Integration
What useAudio Returns
Line 81 packages the complete API:
| Export | Type | Purpose |
|---|---|---|
soundEnabled |
boolean |
Reactive state for UI rendering |
onSoundToggle |
() => void |
Click handler for toggle buttons |
playDoneSound |
() => void |
Safe wrapper around playDone |
unlockAudio |
(force?: boolean) => Promise<void> |
Manual context resumption |
soundEnabledRef |
MutableRefObject<boolean> |
Non‑reactive state for callbacks |
Wiring into the Application
AppShell consumption (lines 77‑81):
// components/AppShell.tsx
const { playDoneSound } = useAudio();
// ...
<ChatWindow onComplete={playDoneSound} />
ChatWindow invocation (lines 57‑70):
The component calls playDoneSound() after:
- Every assistant message completion
- Extension dialog dismissals
This ensures consistent feedback regardless of how the conversation ends.
Implementation Examples
Building a Custom Sound Toggle Button
import { useAudio } from '@/hooks/useAudio';
export default function SoundToggle() {
const { soundEnabled, onSoundToggle } = useAudio();
return (
<button
onClick={onSoundToggle}
aria-pressed={soundEnabled}
className="sound-toggle"
>
{soundEnabled ? '🔊 Sound On' : '🔈 Sound Off'}
</button>
);
}
Manual Tone Triggering with Context Guarantee
import { useAudio } from '@/hooks/useAudio';
function AsyncTaskWithFeedback() {
const { playDoneSound, unlockAudio } = useAudio();
async function processData() {
const result = await fetch('/api/analysis');
const data = await result.json();
// Ensure audio context is ready before playing
await unlockAudio();
playDoneSound();
return data;
}
return <button onClick={processData}>Analyze Data</button>;
}
Summary
playTonegenerates dual‑oscillator tones without external assetslocalStoragepersists thepi-sound-enabledflag across sessions with a default‑enabled policy- Single
AudioContextstored in a ref prevents multiple instantiation and memory leaks unlockAudioworks around browser autoplay restrictions by resuming suspended contexts on user gesturetogglesynchronizes React state,localStorage, and immediate context unlocking- Ref‑based flag access (
soundEnabledRef) minimizes re‑renders during rapid callback execution
Frequently Asked Questions
How does Pi‑Web handle browsers that block autoplay?
The hook defers AudioContext initialization until sound actually plays, then checks ctx.state === 'suspended' before each tone. If suspended, it calls ctx.resume()—which requires prior user interaction per browser policies. The unlockAudio(true) call inside toggle explicitly primes the context when users re‑enable sound.
What happens if localStorage is cleared or unavailable?
The useState initializer returns true when localStorage.getItem('pi-sound-enabled') returns null, so sound remains enabled. This matches the principle of progressive enhancement: functionality works without storage, and preferences enhance the experience when available.
Why use two oscillators instead of a single tone or audio file?
Two oscillators create a richer harmonic texture (C5 + E5 = major third) that's more perceptible across different device speakers. Generating tones programmatically eliminates network requests, reduces bundle size, and avoids codec compatibility issues across browsers.
Can the completion sound play while sound is toggled off?
No. The playDone function checks enabledRef.current before any audio operations. Since enabledRef stays synchronized with localStorage via the toggle function, the toggle state is respected immediately—even for events that fire during the same render cycle.
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 →