# How the Completion Sound Feature Integrates with the Browser in Pi-Web

> Discover how the completion sound feature integrates with the browser in Pi-Web. Learn about its custom hook, shared AudioContext, and localStorage persistence.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: internals
- Published: 2026-08-15

---

**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`](https://github.com/agegr/pi-web/blob/main/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).

```typescript
// 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`](https://github.com/agegr/pi-web/blob/main/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:

```tsx
// 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`](https://github.com/agegr/pi-web/blob/main/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:

```tsx
// 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:

```tsx
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`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts)**: Implements audio context management, localStorage persistence, and the oscillator-based `playDoneSound` generator
- **[`components/AppShell.tsx`](https://github.com/agegr/pi-web/blob/main/components/AppShell.tsx)**: Orchestrates audio unlock on mount and propagates playback function to chat components
- **[`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/AppShell.tsx)
- **State persistence** uses `localStorage` with the key `pi-sound-enabled` to remember user preferences
- **Playback triggers** occur in [`ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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`](https://github.com/agegr/pi-web/blob/main/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.