# How pi-web Implements Completion Sounds with AudioContext and Browser Autoplay Policy

> Discover how pi-web uses AudioContext and browser autoplay policies to implement completion sounds. It leverages a singleton AudioContext, respecting user gestures and persisting preferences in localStorage.

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

---

**pi-web synthesizes a completion "ding" using a lazily-initialized singleton `AudioContext` that respects browser autoplay policies by remaining in a suspended state until a user gesture explicitly resumes it, persisting the sound preference in `localStorage`.**

The pi-web repository (`agegr/pi-web`) provides a web-based chat interface where an audible notification signals when the AI assistant finishes responding. The implementation relies on the Web Audio API's `AudioContext` to generate tones programmatically without external audio files, while carefully navigating modern browser restrictions that block automatic playback until user interaction occurs.

## The useAudio Hook Architecture

The sound system is centralized in the **`useAudio`** React hook located at [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts). To avoid the overhead of multiple audio contexts—which can cause resource leaks and erratic behavior in browsers—the hook stores a single `AudioContext` instance inside a `useRef`.

```typescript
const ctxRef = useRef<AudioContext | null>(null);

const getCtx = useCallback((): AudioContext | null => {
  if (ctxRef.current && ctxRef.current.state !== "closed") {
    return ctxRef.current;
  }
  try {
    ctxRef.current = new AudioContext();
  } catch {
    return null;
  }
  return ctxRef.current;
}, []);

```

Creating the context outside a user gesture places it in the **"suspended"** state by default, which produces silence until explicitly resumed. This lazy initialization pattern ensures the context exists only when needed and survives React re-renders without recreation.

## Handling the Browser Autoplay Policy

Modern browsers implement strict autoplay policies that prevent websites from emitting sound without a preceding user interaction (click, tap, or key press). pi-web handles this through two complementary mechanisms in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts): **`unlockAudio`** and **`playDoneSound`**.

The **`unlockAudio`** function is designed to be called directly from UI event handlers. It accepts an optional `force` parameter and checks the current enabled state before attempting to resume:

```typescript
const unlockAudio = useCallback((force = false) => {
  if (!force && !enabledRef.current) return;
  const ctx = getCtx();
  if (!ctx || ctx.state !== "suspended") return;
  ctx.resume().catch(() => {});
}, [getCtx]);

```

When the assistant finishes a response, **`playDoneSound`** verifies the context state. If suspended, it chains the `resume()` promise to the playback logic, ensuring the tone only fires after the browser grants permission:

```typescript
const playDone = useCallback(() => {
  if (!enabledRef.current) return;
  const ctx = getCtx();
  if (!ctx) return;
  
  const play = () => { try { playTone(ctx); } catch {} };
  
  if (ctx.state === "suspended") {
    ctx.resume().then(play).catch(() => {});
    return;
  }
  play();
}, [getCtx]);

```

This dual-check pattern guarantees that the completion sound never triggers the browser's autoplay blocker, even if the context was suspended between user sessions.

## Synthesizing the Completion Tone

Rather than loading an MP3 file, pi-web generates the notification tone procedurally using the `playTone` helper function. This synthesizes two simultaneous sine waves—**C5 (523.25 Hz)** and **E5 (659.25 Hz)**—to create a pleasant major-third interval.

```typescript
function playTone(ctx: AudioContext) {
  const now = ctx.currentTime;
  const freqs = [523.25, 659.25];
  
  freqs.forEach((freq, i) => {
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    
    osc.connect(gain);
    gain.connect(ctx.destination);
    osc.type = "sine";
    osc.frequency.value = freq;
    
    const t = now + i * 0.18;
    gain.gain.setValueAtTime(0, t);
    gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
    
    osc.start(t);
    osc.stop(t + 0.45);
  });
}

```

The gain envelope applies a quick attack (0.02 seconds) and a gentle exponential decay (0.45 seconds), producing a crisp "ding" that fades naturally without clicking artifacts.

## Persisting User Preferences

The hook manages user consent through a boolean flag stored in **`localStorage`** under the key `"pi-sound-enabled"`. On initialization, the hook reads this value, defaulting to `true` for first-time visitors. The **`onSoundToggle`** callback flips this bit and synchronizes the ref used by the playback functions:

```typescript
const [soundEnabled, setSoundEnabled] = useState(() => {
  if (typeof window === "undefined") return true;
  return localStorage.getItem("pi-sound-enabled") !== "false";
});

const onSoundToggle = useCallback(() => {
  setSoundEnabled(prev => {
    const next = !prev;
    localStorage.setItem("pi-sound-enabled", String(next));
    enabledRef.current = next;
    return next;
  });
}, []);

```

This persistence ensures that a user's sound preference survives page reloads and browser restarts without requiring server-side state.

## Integrating with the ChatWindow

The `ChatWindow` component ([`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx)) consumes the hook to wire sound into the chat flow:

```tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();

```

**`unlockAudio`** is invoked on user-initiated events—specifically when the user clicks the sound-toggle button—to ensure the `AudioContext` enters the "running" state. When the streaming assistant response completes, the component calls **`playDoneSound()`**, which automatically handles the suspended-state edge case.

```tsx
// Example: Toggle button calls unlockAudio to satisfy autoplay policy
<button onClick={() => { unlockAudio(true); onSoundToggle(); }}>
  {soundEnabled ? "🔊 Sound On" : "🔈 Sound Off"}
</button>

// When assistant finishes streaming:
useEffect(() => {
  if (!isStreaming && lastMessageRole === "assistant") {
    playDoneSound();
  }
}, [isStreaming, lastMessageRole, playDoneSound]);

```

This architecture decouples the audio policy logic from the UI, allowing the sound to fire reliably only after the browser has granted audio permission through prior interaction.

## Summary

- **Singleton Pattern**: [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) maintains one `AudioContext` in a ref to prevent resource leaks and state duplication.
- **Autoplay Compliance**: The context initializes in a **suspended** state; **`unlockAudio`** and **`playDoneSound`** explicitly resume it only after user gestures.
- **Procedural Audio**: The completion tone is synthesized at **523.25 Hz** and **659.25 Hz** using oscillator nodes with an exponential gain decay, eliminating external asset dependencies.
- **User Control**: Preferences persist via **`localStorage`** with the key `"pi-sound-enabled"`, defaulting to on for new users.
- **Clean Integration**: [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx) consumes the hook, triggering playback when the assistant finishes while handling the suspended-state promise chain automatically.

## Frequently Asked Questions

### Why does the AudioContext start in a suspended state?

Browsers automatically place a newly created `AudioContext` into the **"suspended"** state if it is instantiated without a preceding user interaction. This prevents abusive autoplay; pi-web respects this by calling `ctx.resume()` only inside event handlers like `unlockAudio`.

### What frequencies make up the completion sound?

The tone consists of two sine waves: **C5 at 523.25 Hz** and **E5 at 659.25 Hz**, played sequentially with an 0.18-second offset to create a harmonically rich "ding" effect without requiring audio file downloads.

### How does pi-web remember if I turned off the sound?

The hook writes the boolean preference to **`localStorage`** under the key `"pi-sound-enabled"` whenever the user toggles the setting. On subsequent page loads, it reads this value to initialize the internal `enabledRef` state, ensuring the preference persists across sessions.

### Can the sound play if I never click the sound-toggle button?

Yes, but only if another user gesture (such as sending a message or clicking anywhere in the interface) has already called **`unlockAudio`**. The browser requires any user interaction to transition the `AudioContext` from "suspended" to "running" before `playDoneSound` can emit audio.