# How the Completion Sound System Using AudioContext Works in pi-web

> Discover how the pi-web application leverages AudioContext and a custom React hook to play completion sounds with a dual-oscillator ding, enhancing user experience after AI responses.

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

---

**The pi-web application uses a custom React hook called `useAudio` that manages a single shared `AudioContext`, persists user preferences in `localStorage`, and unlocks audio only after user gestures to play a dual-oscillator "ding" when AI responses complete.**

The completion sound system in the agegr/pi-web repository demonstrates a robust implementation of the Web Audio API within a React application. By encapsulating audio logic in a reusable hook, the codebase ensures consistent behavior across components while respecting modern browser autoplay restrictions. This article examines the internal mechanics of how `pi-web` initializes, unlocks, and triggers completion sounds using a carefully managed `AudioContext` lifecycle.

## Architecture of the AudioContext Completion Sound System

The `useAudio` hook in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) serves as the central nervous system for all audio operations. It maintains a singleton `AudioContext` instance and coordinates state persistence with immediate audio feedback.

### Initializing the Audio State

On component mount, the hook reads the user's preference from `localStorage` using the key `pi-sound-enabled`. If no preference exists, it defaults to `true`, ensuring first-time users hear the completion cue.

To prevent stale closures in asynchronous callbacks, the implementation keeps a mutable reference (`enabledRef`) synchronized with the React state. This pattern, visible at lines 31-33 in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), ensures that functions like `playDone` always access the current sound preference without triggering unnecessary re-renders.

### Managing the Shared AudioContext

Rather than creating multiple audio contexts, the hook stores a single instance in a `useRef` container. The `getCtx` function (lines 37-46) implements a lazy initialization pattern: it instantiates `new AudioContext()` only when first requested and recreates it if the previous context enters a `"closed"` state.

This singleton approach conserves system resources and maintains consistent audio graph state throughout the application lifecycle.

### Handling Browser Autoplay Policies

Modern browsers require user interaction before allowing audio playback. The `unlockAudio` function (lines 48-53) specifically addresses this by checking `ctx.state === "suspended"` and calling `ctx.resume()` only when the user has explicitly interacted with the sound toggle.

The hook creates the `AudioContext` lazily and defers resumption until either `unlockAudio` is invoked from a click handler or `playDone` is called immediately before tone generation. This strategy satisfies Chrome and Firefox autoplay restrictions while maintaining a seamless user experience.

## Generating the Completion Tone

When the assistant finishes streaming a response, the `playDone` method (lines 63-79) orchestrates the audible feedback. After verifying that sound is enabled, it retrieves the context and either resumes a suspended instance or proceeds directly to tone generation.

The actual sound synthesis occurs in `playTone` (lines 5-22), which creates a pleasant major-third interval by spinning up two oscillator nodes:

- **Oscillator 1**: C5 at 523.25 Hz
- **Oscillator 2**: E5 at 659.25 Hz

These oscillators connect to a `GainNode` that shapes a rapid attack/decay envelope, producing a crisp "ding" that signals completion without being jarring.

## Implementing the useAudio Hook in Components

Components consume the hook to trigger sounds and render toggle controls. The implementation handles all complexity internally, exposing only the necessary interface.

```typescript
import { useAudio } from "@/hooks/useAudio";

export default function ChatWindow() {
  const {
    soundEnabled,
    onSoundToggle,
    playDoneSound,
    unlockAudio,
  } = useAudio();

  // Trigger sound when assistant finishes streaming
  useEffect(() => {
    if (agentFinished) {
      playDoneSound();
    }
  }, [agentFinished, playDoneSound]);

  return (
    <div>
      <button onClick={onSoundToggle}>
        {soundEnabled ? "🔊" : "🔈"} Sound
      </button>
    </div>
  );
}

```

The `onSoundToggle` function (lines 55-61) flips the enabled state, persists the boolean to `localStorage`, and forces an immediate `unlockAudio` call when the user activates sound. This ensures the context is ready for the next completion event.

## Summary

- **Singleton Pattern**: The `useAudio` hook maintains one `AudioContext` instance via `useRef`, creating it lazily in `getCtx` and recreating only if closed.
- **Autoplay Compliance**: Audio unlocking happens exclusively within user gesture handlers via `unlockAudio`, which resumes suspended contexts before playback.
- **Persistence**: User preferences survive page reloads through `localStorage` key `pi-sound-enabled`, initialized at lines 25-29.
- **Tone Design**: The completion sound uses dual oscillators at 523.25 Hz and 659.25 Hz with a gain envelope to create a distinctive "ding".
- **API Surface**: Components simply call `playDoneSound()` when responses complete, letting the hook handle context state and browser policy compliance.

## Frequently Asked Questions

### Why does pi-web use a single AudioContext instead of creating one per sound?

Using a single `AudioContext` managed in `useRef` prevents resource exhaustion and audio graph fragmentation. The `getCtx` function in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) ensures only one context exists at a time, recreating it only if the previous instance enters a `"closed"` state.

### How does the system handle browsers blocking autoplay?

The implementation respects the Web Audio API autoplay policy by initially creating suspended contexts and only calling `ctx.resume()` inside `unlockAudio` (lines 48-53) or immediately before playback in `playDone`. Both pathways require prior user interaction, satisfying Chrome and Firefox requirements.

### What frequencies make up the completion sound?

The `playTone` function generates a major third interval using two sine wave oscillators: **C5 at 523.25 Hz** and **E5 at 659.25 Hz**. These combine through a `GainNode` with a shaped envelope to produce the characteristic "ding".

### Where is the user's sound preference stored?

The hook persists the boolean flag under the `localStorage` key `pi-sound-enabled`. It reads this value during initialization (lines 25-29) and writes updates whenever `onSoundToggle` is invoked, ensuring settings persist across sessions.