How the Completion Sound Feature Works with Browser AudioContext in agegr/pi-web
The completion sound feature uses a singleton AudioContext managed by the useAudio hook to synthesize a dual-tone notification, unlocking the context on user gestures to comply with browser autoplay policies.
The implementation resides in the Next.js frontend of the agegr/pi-web repository, where the system generates audible feedback programmatically without external audio files. By leveraging the Web Audio API, the feature manages browser-specific restrictions while providing a distinct "ding" when an AI agent finishes responding.
AudioContext Lifecycle Management
The useAudio hook, located at hooks/useAudio.ts, maintains a single AudioContext instance inside a React ref called ctxRef. This singleton pattern ensures the browser allocates only one audio processing graph per session, conserving memory and CPU resources.
Browsers initially create the AudioContext in a suspended state to block automatic playback. The hook exposes an unlockAudio function that inspects ctx.state; if the value is "suspended", it calls ctx.resume() to transition the context to the running state. This activation must occur within a user-initiated event handler—such as clicking the send button—to satisfy browser security requirements.
Synthesizing the Completion Tone
When a chat session terminates, the playDoneSound function retrieves the active AudioContext and invokes the internal playTone helper. This method instantiates two OscillatorNode objects tuned to 523 Hz and 659 Hz, creating a pleasant major-third interval.
Each oscillator connects to a dedicated GainNode to shape the amplitude envelope. The gain ramps up immediately then decays exponentially over approximately 0.45 seconds, producing a short, non-intrusive sound. If the context is unavailable or closed, playDoneSound exits silently without throwing errors.
// Conceptual implementation inside hooks/useAudio.ts
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.frequency.value = 523; // C5
osc1.connect(gain1);
gain1.connect(ctx.destination);
gain1.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
osc1.start();
osc1.stop(ctx.currentTime + 0.45);
Persisting Sound Preferences
The hook manages a boolean soundEnabled state that defaults to false. On initialization, it reads the pi-sound-enabled key from localStorage to restore the user's prior selection. The onSoundToggle function flips this state and synchronizes the storage entry, ensuring the preference persists across page reloads.
Components consume these controls to render UI toggles, allowing users to enable or disable the completion sound feature dynamically without disrupting the underlying AudioContext lifecycle.
Integration with Chat Components
In components/ChatWindow.tsx, the onAgentEnd callback is wrapped to conditionally trigger audio feedback. When the session ends and soundEnabled is true, the component invokes playDoneSound, decoupling termination logic from the audio system.
The components/ChatInput.tsx file ensures the audio pipeline is ready before the first tone is required. The send button's onClick handler executes unlockAudio() immediately before calling sendMessage(), guaranteeing the AudioContext enters the running state through a permissible user gesture.
// components/ChatInput.tsx
<button
onClick={() => {
unlockAudio(); // Resume AudioContext if suspended
sendMessage();
}}
>
Send
</button>
Summary
- The
useAudiohook inhooks/useAudio.tsmaintains a singletonAudioContextinctxRefto optimize resource usage and prevent multiple graph instantiations. - Autoplay restrictions are handled by the
unlockAudiofunction, which resumes the context during user-initiated events such as clicking the send button inChatInput.tsx. - The completion tone is synthesized in
playToneusing two oscillators at 523 Hz and 659 Hz with exponential gain decay over 0.45 seconds. - User preferences are stored under the
pi-sound-enabledkey inlocalStorageand managed via thesoundEnabledstate andonSoundTogglehandler. ChatWindow.tsxinvokesplayDoneSoundupon session end, contingent on the user's sound preference, to signal completion without external assets.
Frequently Asked Questions
Why does the AudioContext need to be unlocked?
Browsers suspend the AudioContext by default to prevent websites from playing audio automatically without user consent. The unlockAudio function in hooks/useAudio.ts checks ctx.state and calls ctx.resume() during a user interaction, such as clicking the send button in ChatInput.tsx, thereby satisfying browser autoplay policies before any sound is generated.
What frequencies are used for the completion sound?
The playTone function creates two OscillatorNode instances tuned to 523 Hz (C5) and 659 Hz (E5). This combination produces a major-third harmonic interval that is audibly distinct and calibrated to be pleasant rather than jarring.
How is the sound preference persisted across sessions?
The preference is stored in the browser's localStorage under the key pi-sound-enabled. The useAudio hook reads this value on component mount and updates it whenever the user toggles the setting via onSoundToggle, ensuring the choice remains consistent after page refreshes or browser restarts.
What happens if the AudioContext fails to initialize?
The useAudio hook includes defensive checks within playDoneSound. If the AudioContext creation throws an exception, the context is closed, or the reference is null, the function returns early without attempting to create oscillator nodes, thereby preventing runtime crashes or unhandled promise rejections.
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 →