How Pi-Web Triggers and Manages the Completion Sound
Pi-Web plays a two-tone "ding" when an agent finishes responding by wrapping the session-end callback in ChatWindow.tsx and using a reusable AudioContext managed by the useAudio hook.
The open-source agegr/pi-web repository implements a lightweight audio feedback system that notifies users when AI agent responses complete. This feature relies on a custom React hook to manage browser audio constraints while ensuring the completion sound only fires after successful agent runs.
The useAudio Hook: Core Audio Architecture
The audio subsystem lives in hooks/useAudio.ts and exposes four key callbacks that handle everything from context creation to sound generation.
Creating a Reusable AudioContext
The hook initializes a single AudioContext that persists for the entire page lifecycle. According to lines [38‑46] of hooks/useAudio.ts, the getCtx function creates the context on first call and returns the existing instance thereafter:
// Simplified logic from hooks/useAudio.ts lines 38-46
const getCtx = () => {
if (!ctxRef.current) {
ctxRef.current = new (window.AudioContext || window.webkitAudioContext)();
}
return ctxRef.current;
};
This reuse pattern prevents the overhead of multiple audio contexts and ensures state consistency across components.
Implementing the Two-Tone Ding
The playDoneSound function generates a "ding" using two oscillators at C5 (~523 Hz) and E5 (~659 Hz). As implemented in lines [5‑21] and **[63‑79], the function first checks if sound is enabled, then resumes the context if suspended before triggering playTone`:
// From hooks/useAudio.ts lines 63-79
const playDone = () => {
if (!soundEnabledRef.current) return;
const ctx = getCtx();
if (ctx.state === 'suspended') {
ctx.resume();
}
// Plays C5 followed by E5
playTone(ctx, 523.25, 0.1); // C5
setTimeout(() => playTone(ctx, 659.25, 0.1), 150); // E5
};
Persisting User Preferences
User preferences survive page reloads through localStorage. Lines [24‑30] show the hook reading the pi-sound-enabled key during initialization:
// From hooks/useAudio.ts lines 24-30
const [soundEnabled, setSoundEnabled] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('pi-sound-enabled') === 'true';
});
The onSoundToggle callback flips this flag and updates storage, while unlockAudio handles browser autoplay policies by resuming the context on user gesture.
Integration in the Chat UI
The components/ChatWindow.tsx file bridges the audio hook with the chat interface, ensuring the completion sound fires exactly once per agent response.
Wrapping the Session End Callback
At line **[12], the component imports the hook and wraps the onAgentEnd` callback to conditionally trigger the sound. Lines [86‑88] demonstrate how the session hook receives the wrapped handler:
// From components/ChatWindow.tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();
const wrappedOnAgentEnd = useCallback(() => {
if (soundEnabledRef.current) {
playDoneSoundRef.current();
}
onAgentEnd?.();
}, [onAgentEnd]);
This guarantees the completion sound only plays after successful completions, not during errors or interruptions.
Providing UI Controls
The component passes audio state and controls to ChatInput via props at lines [23‑26]:
// From components/ChatWindow.tsx lines 23-26
<ChatInput
soundEnabled={soundEnabled}
onSoundToggle={onSoundToggle}
onAudioUnlock={unlockAudio}
// ...other props
/>
This gives users a toggle to enable or disable sounds and a mechanism to unlock the audio context when browser policies suspend it.
Handling Browser Autoplay Restrictions
Modern browsers suspend AudioContext instances until a user gesture occurs. Pi-Web handles this through the unlockAudio helper, which can be called from click handlers to resume a suspended context.
Code Example: Using the Hook Directly
import { useAudio } from "@/hooks/useAudio";
export default function Settings() {
const {
soundEnabled,
onSoundToggle,
playDoneSound,
unlockAudio,
} = useAudio();
return (
<div>
<label>
<input type="checkbox" checked={soundEnabled} onChange={onSoundToggle} />
Enable completion sound
</label>
<button onClick={() => { unlockAudio(); playDoneSound(); }}>
Test Sound
</button>
</div>
);
}
Code Example: Integrating with Custom Session Handlers
import { useAudio } from "@/hooks/useAudio";
function MyChat({ onSessionEnd }) {
const { soundEnabled, playDoneSound } = useAudio();
const handleEnd = useCallback(() => {
if (soundEnabled) playDoneSound();
onSessionEnd?.();
}, [onSessionEnd, soundEnabled, playDoneSound]);
// Pass handleEnd to your session logic
}
Code Example: Manually Unlocking Audio
import { useAudio } from "@/hooks/useAudio";
function UnlockButton() {
const { unlockAudio } = useAudio();
return (
<button onClick={() => unlockAudio(true)}>
Unlock Audio (needed after page load)
</button>
);
}
Summary
hooks/useAudio.tscreates a singletonAudioContextand exposesplayDoneSoundto generate a two-tone ding at C5 and E5 frequencies.- User preferences persist in
localStorageunder the keypi-sound-enabled, allowing settings to survive page reloads. components/ChatWindow.tsxwraps theonAgentEndcallback to ensure the completion sound fires only after successful agent responses.- Browser autoplay restrictions are managed through the
unlockAudiohelper, which resumes suspended contexts on user gesture.
Frequently Asked Questions
Why does the completion sound not play immediately on page load?
Browsers suspend AudioContext instances until the user interacts with the page. Pi-Web handles this by providing an unlockAudio function that resumes the context when called from a click handler or toggle interaction, satisfying browser autoplay policies.
How does Pi-Web prevent multiple audio contexts from consuming memory?
The useAudio hook implements a singleton pattern where getCtx (lines [38‑46]) creates the AudioContext only once and returns the cached reference on subsequent calls. This prevents the memory overhead and state conflicts that occur when creating multiple contexts.
Can I customize the completion sound frequency or duration?
The current implementation hardcodes frequencies of 523.25 Hz (C5) and 659.25 Hz (E5) with a 150ms delay between tones in the playTone function (lines [5‑21]). To customize, you would modify the playDone function in hooks/useAudio.ts to accept frequency parameters or adjust the timing constants.
Where is the sound preference stored between sessions?
Pi-Web stores the boolean flag in localStorage under the key pi-sound-enabled (lines [24‑30]). The hook reads this value during initialization and updates it whenever the user toggles the sound setting via onSoundToggle.
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 →