# How the Completion Sound is Implemented in pi-web Using AudioContext

> Discover how pi-web implements completion sounds using AudioContext and a custom useAudio hook. Learn about dual-oscillator tones and browser autoplay policies.

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

---

**Pi-web implements completion sounds using a custom `useAudio` hook that manages a single `AudioContext` instance, generating a dual-oscillator "ding" tone while respecting browser autoplay policies by resuming suspended contexts only after user gestures.**

The pi-web repository (`agegr/pi-web`) leverages the Web Audio API to provide audible feedback when AI assistant responses finish streaming. Rather than loading external audio files, the application synthesizes a pleasant chime programmatically, storing user preferences in `localStorage` and carefully managing the `AudioContext` lifecycle to comply with modern browser autoplay restrictions.

## Architecture of the useAudio Hook

The implementation resides in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) and exposes a clean interface for components to trigger sounds without managing the complex lifecycle of Web Audio nodes directly.

### Persisting User Preferences

On initialization, the hook reads the `pi-sound-enabled` key from `localStorage` (defaulting to `true`) to determine if sounds should play. This value is stored in React state and synchronized with a mutable ref (`enabledRef`) to ensure asynchronous callbacks always access the current preference without stale closures. The initialization logic appears at [lines 25-29](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L25-L29), while the ref synchronization occurs at [lines 31-33](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L31-L33).

### Lazy AudioContext Initialization

To conserve resources and comply with browser policies, the hook creates the `AudioContext` lazily. The `getCtx` function ([lines 37-46](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L37-L46)) checks a `useRef` container and only instantiates `new AudioContext()` if no context exists or the previous one was closed. This ensures the application maintains a single, reusable `AudioContext` across the entire component tree.

### Handling Autoplay Restrictions

Browsers suspend `AudioContext` instances created outside user gestures. The `unlockAudio` function ([lines 48-53](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L48-L53)) handles this by calling `ctx.resume()` only when the context state is `"suspended"` and sound is enabled. This method is invoked from click handlers, satisfying Chrome and Firefox autoplay requirements before any tone generation occurs.

## Synthesizing the Completion Sound

When the assistant finishes responding, the `playDone` function ([lines 63-79](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L63-L79)) validates that sound is enabled, retrieves the context, resumes it if necessary, and triggers `playTone`.

### Dual-Oscillator Implementation

The `playTone` function ([lines 5-22](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L5-L22)) creates two oscillator nodes tuned to **C5 (523.25 Hz)** and **E5 (659.25 Hz)**. These oscillators connect to a `GainNode` that shapes a rapid attack/decay envelope, producing a crisp "ding" that signals completion without requiring external audio assets.

## Using the Hook in Components

Components consume the hook to trigger sounds when streaming ends. The following pattern from [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx) demonstrates integration:

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

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

  // Call `playDoneSound` when the assistant finishes streaming
  useEffect(() => {
    if (agentFinished) {
      playDoneSound();
    }
  }, [agentFinished, playDoneSound]);

  return (
    <div>
      <button onClick={onSoundToggle}>
        {soundEnabled ? "🔊" : "🔈"} Sound
      </button>
      {/* … rest of the chat UI … */}
    </div>
  );
}

```

The component calls `playDoneSound` upon completion signals from the backend, while the hook manages unlocking and tone generation automatically.

## Summary

- **Single Context Pattern**: [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) maintains one `AudioContext` via `useRef` to avoid resource duplication and state conflicts.
- **Autoplay Compliance**: The context only resumes inside `unlockAudio` or `playDone`, ensuring sounds play only after user interaction.
- **Synthesized Audio**: The completion sound uses two oscillators (C5 and E5) with a gain envelope instead of pre-recorded files.
- **Persistent Settings**: User preferences store in `localStorage` under `pi-sound-enabled` and sync via `enabledRef` to prevent stale closures.

## Frequently Asked Questions

### How does pi-web handle browsers that block autoplay?

Pi-web respects browser autoplay policies by creating the `AudioContext` lazily and keeping it in a `"suspended"` state until a user gesture occurs. The `unlockAudio` function in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) ([lines 48-53](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L48-L53)) explicitly checks `ctx.state === "suspended"` and calls `ctx.resume()` only from click handlers, ensuring the completion sound is audible without triggering browser blocking mechanisms.

### What frequencies does the pi-web completion sound use?

According to the source code in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) ([lines 5-22](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L5-L22)), the tone generation creates two oscillators: one at **C5 (523.25 Hz)** and another at **E5 (659.25 Hz)**. These frequencies form a major third interval that creates a pleasant, attention-grabbing "ding" when combined with the gain envelope.

### Where does pi-web store the sound preference?

The application persists the sound setting under the `localStorage` key `pi-sound-enabled`. The `useAudio` hook reads this value during initialization ([lines 25-29](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L25-L29)) and updates it whenever the user toggles sound on or off, ensuring the preference survives page reloads.

### Can multiple components create separate AudioContexts?

No, the architecture prevents this. The `getCtx` function within `useAudio` uses a `useRef` to store the context instance ([lines 37-46](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L37-L46)). If a context exists and is not closed, the hook returns the existing instance rather than creating a new one, ensuring the entire application shares a single `AudioContext` for all completion sounds.