How the Completion Sound Feature Integrates with the Browser in Pi-Web
The completion sound feature in Pi-Web uses a custom useAudio hook that manages a shared AudioContext, persists user preferences in localStorage, and exposes a playDoneSound function invoked by the ChatWindow component when AI generations finish.
The Pi-Web repository implements a polished browser-based chat interface where audio feedback signals task completion. According to the agegr/pi-web source code, this feature relies on a carefully orchestrated flow between a reusable React hook and the application's top-level layout components to navigate browser autoplay restrictions while maintaining clean separation of concerns.
The Core Architecture: The useAudio Hook
All sound functionality is centralized in hooks/useAudio.ts. This hook solves three specific browser integration challenges:
- Autoplay policy compliance: Creates and manages a single
AudioContextthat must be resumed after user interaction - Preference persistence: Stores the
pi-sound-enabledkey inlocalStorageto remember user choices across sessions - Programmatic playback: Exposes
playDoneSound()for any component to trigger the completion "ding"
The hook returns an object containing soundEnabled (boolean state), onSoundToggle (handler), playDoneSound (callback), unlockAudio (context resumer), and soundEnabledRef (mutable reference for synchronous reads).
// hooks/useAudio.ts – conceptual structure
import { useState, useRef, useCallback } from 'react';
export function useAudio() {
const audioContextRef = useRef<AudioContext | null>(null);
const soundEnabledRef = useRef<boolean>(false);
// Initialize from localStorage, create AudioContext lazily
// unlockAudio() resumes suspended context after user gesture
// playDoneSound() generates and plays the completion tone
}
AppShell: Unlocking Audio and Wiring the Feature
The components/AppShell.tsx file serves as the integration point between the hook and the chat interface. It imports useAudio and immediately extracts all necessary controls:
// components/AppShell.tsx (excerpt)
import { useAudio } from "@/hooks/useAudio";
export function AppShell() {
const {
soundEnabled,
onSoundToggle,
playDoneSound,
unlockAudio,
soundEnabledRef
} = useAudio();
useEffect(() => {
// Critical: resume AudioContext after user interaction
unlockAudio();
// Play sound for initial session if user has enabled it
if (soundEnabledRef.current) {
playDoneSound();
}
}, [playDoneSound, soundEnabledRef, unlockAudio]);
// playDoneSound is passed to ChatWindow as a prop
return <ChatWindow playDoneSound={playDoneSound} /* ... */ />;
}
The unlockAudio() call is essential—modern browsers suspend AudioContext instances until a user gesture occurs. Pi-Web handles this during component mount, ensuring subsequent playDoneSound() calls succeed.
ChatWindow: Triggering Sound on Generation Complete
Inside components/ChatWindow.tsx, the completion sound integration follows a ref-based pattern to avoid stale closures in asynchronous callbacks. The component receives playDoneSound as a prop and stores it in a mutable ref updated on every render:
// components/ChatWindow.tsx (excerpt)
import { useRef, useEffect } from 'react';
interface ChatWindowProps {
playDoneSound: () => void;
// ...
}
export function ChatWindow({ playDoneSound, /* ... */ }: ChatWindowProps) {
const playDoneSoundRef = useRef(playDoneSound);
// Keep ref synchronized with latest prop value
playDoneSoundRef.current = playDoneSound;
// Inside agent completion handler (simplified)
const handleAgentEnd = () => {
// Notify parent component
onAgentEnd?.();
// Play completion sound if enabled
playDoneSoundRef.current(); // line 265 in source
};
// Also triggered on session creation (line 301)
const handleSessionCreated = () => {
// ...
playDoneSoundRef.current();
};
}
The ref pattern ensures that even if playDoneSound is redefined during re-renders, the asynchronous agent completion callback always accesses the current implementation.
Browser Integration Flow: Step by Step
The complete browser integration follows this precise sequence:
-
User enables sound →
useAudiowritespi-sound-enabled: truetolocalStorageand updatessoundEnabledRef -
Component mounts →
AppShellcallsunlockAudio(), which resumes the suspendedAudioContextusingaudioContext.resume() -
Agent generates response → Server-sent events or WebSocket messages stream to
ChatWindow -
Generation completes →
onAgentEndcallback fires, executingplayDoneSoundRef.current() -
Sound plays →
useAudiogenerates a short oscillator tone through the now-activeAudioContext
Reusing the Hook in Custom Components
Any component in Pi-Web can import useAudio to participate in the audio system without duplicating logic:
import { useAudio } from "@/hooks/useAudio";
export function NotificationSettings() {
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();
const testSound = () => {
unlockAudio(); // Ensure context is ready
playDoneSound(); // Preview the completion sound
};
return (
<div className="settings-panel">
<label>
<input
type="checkbox"
checked={soundEnabled}
onChange={onSoundToggle}
/>
Enable completion sounds
</label>
<button onClick={testSound}>
Test sound
</button>
</div>
);
}
Key Files and Their Roles
hooks/useAudio.ts: Implements audio context management, localStorage persistence, and the oscillator-basedplayDoneSoundgeneratorcomponents/AppShell.tsx: Orchestrates audio unlock on mount and propagates playback function to chat componentscomponents/ChatWindow.tsx: Consumes the playback function and triggers it on agent completion and session creation events
Summary
- The completion sound feature in Pi-Web is implemented through a centralized
useAudiohook that manages browserAudioContextlifecycle - Autoplay restrictions are handled via
unlockAudio(), called during user-initiated component mount inAppShell.tsx - State persistence uses
localStoragewith the keypi-sound-enabledto remember user preferences - Playback triggers occur in
ChatWindow.tsxusing a ref pattern to ensure correct callback access during asynchronous agent completions - The architecture is DRY and composable—any component can import
useAudioto participate in the sound system
Frequently Asked Questions
Why does Pi-Web require unlockAudio() before playing sounds?
Browsers suspend AudioContext instances until a user interaction occurs to prevent unwanted autoplay. The unlockAudio() function in hooks/useAudio.ts calls audioContext.resume(), which must happen after a click, tap, or keyboard event. Pi-Web triggers this during AppShell mount, satisfying the browser requirement before any completion sounds are needed.
What storage mechanism preserves the sound preference across sessions?
The useAudio hook persists the boolean soundEnabled value to localStorage under the key pi-sound-enabled. On initialization, the hook reads this key to restore the user's previous choice, ensuring consistent behavior without requiring reconfiguration on every visit.
Can multiple components trigger the completion sound simultaneously?
Yes—any component that imports useAudio gains access to the same playDoneSound function. However, the implementation creates a new oscillator for each call, so rapid successive invocations may result in overlapping audio. The hook's shared AudioContext ensures efficient resource usage regardless of how many components participate.
Why does ChatWindow.tsx use a ref for playDoneSound instead of calling it directly?
The playDoneSoundRef pattern prevents stale closure bugs. Agent completion callbacks may execute asynchronously after multiple re-renders, and a direct playDoneSound reference would capture the function from the render where the callback was defined. By assigning playDoneSoundRef.current = playDoneSound on every render, the asynchronous handler always invokes the most recent implementation.
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 →