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 AudioContext that must be resumed after user interaction
  • Preference persistence: Stores the pi-sound-enabled key in localStorage to 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:

  1. User enables sound → useAudio writes pi-sound-enabled: true to localStorage and updates soundEnabledRef

  2. Component mounts → AppShell calls unlockAudio(), which resumes the suspended AudioContext using audioContext.resume()

  3. Agent generates response → Server-sent events or WebSocket messages stream to ChatWindow

  4. Generation completes → onAgentEnd callback fires, executing playDoneSoundRef.current()

  5. Sound plays → useAudio generates a short oscillator tone through the now-active AudioContext

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-based playDoneSound generator
  • components/AppShell.tsx: Orchestrates audio unlock on mount and propagates playback function to chat components
  • components/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 useAudio hook that manages browser AudioContext lifecycle
  • Autoplay restrictions are handled via unlockAudio(), called during user-initiated component mount in AppShell.tsx
  • State persistence uses localStorage with the key pi-sound-enabled to remember user preferences
  • Playback triggers occur in ChatWindow.tsx using a ref pattern to ensure correct callback access during asynchronous agent completions
  • The architecture is DRY and composable—any component can import useAudio to 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →