# How useAudio Handles Completion Sounds with Browser Autoplay Policies in pi-web

> Discover how useAudio overcomes browser autoplay policies in pi-web by managing AudioContext and user interactions for seamless completion sound playback with the Web Audio API.

- Repository: [Alex Yang/pi-web](https://github.com/agegr/pi-web)
- Tags: how-to-guide
- Published: 2026-07-31

---

**The `useAudio` hook manages a single `AudioContext` that initializes in a suspended state to comply with browser autoplay restrictions, resuming audio output only after user interactions while generating dual-tone completion sounds through the Web Audio API.**

The pi-web repository implements an auditory feedback system for AI chat completions that must navigate modern browsers' strict autoplay policies. Located at [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), the `useAudio` hook encapsulates the complexity of creating reusable audio resources, handling user gesture requirements, and synthesizing the characteristic "ding" that plays when the assistant finishes responding.

## AudioContext Architecture and Lazy Initialization

### Single Instance Management

The hook maintains one `AudioContext` across component lifecycles using a mutable ref to prevent expensive recreation and state loss. A helper function retrieves the existing context if valid, or lazily instantiates a new one when first requested.

```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 **Web Audio API context** outside a user gesture immediately places it in a **"suspended"** state, which means the graph exists but produces no audible output until explicitly resumed.

## Respecting Browser Autoplay Policies

### Unlocking Audio with User Gestures

The `unlockAudio` method bridges the gap between React's initialization and the browser's requirement for user interaction. It checks the current state and calls `resume()` only when the context is suspended, typically bound to click events on interactive elements like the sound-toggle button.

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

```

### Defensive Playback Logic

Before generating tones, `playDoneSound` (internally implemented as `playDone`) inspects the context state. If suspended, it chains the resume promise to the playback function, ensuring the tone only executes after the browser grants audio 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]);

```

## Generating the Completion Tone

Rather than loading external audio assets, the hook synthesizes a completion sound using two **sine wave oscillators** programmed to output frequencies of 523.25 Hz (C5) and 659.25 Hz (E5). A gain envelope quickly fades each tone to prevent clipping and create a pleasant chime.

```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 staggered start times (offset by 0.18 seconds) create a harmonic interval that signals completion distinctly from notification sounds.

## Integration with ChatWindow

The `ChatWindow` component at [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx) consumes the hook to coordinate audio feedback with streaming response events:

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

```

**Integration patterns include:**

- Calling `unlockAudio` on the sound-toggle button's `onClick` handler to satisfy autoplay requirements before the first completion tone plays
- Invoking `playDoneSound` via a stable ref callback when the assistant finishes streaming a response, ensuring the latest function reference executes even if the component re-renders
- Persisting the `soundEnabled` boolean to `localStorage` under the key `"pi-sound-enabled"`, defaulting to `true` for new users

## Summary

- **`useAudio`** in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) manages a singleton `AudioContext` that initializes in a **suspended state** to comply with browser autoplay policies from page load
- **`unlockAudio`** must be triggered by user gestures (such as clicking the sound toggle) to call `ctx.resume()` and unlock audio output permissions
- **`playDoneSound`** automatically handles suspended contexts by attempting to resume the `AudioContext` before executing the `playTone` generator, preventing autoplay violations
- **Dual-tone synthesis** uses two sine oscillators at 523 Hz and 659 Hz with exponential gain ramps to generate the completion chime without external file dependencies
- **`ChatWindow`** orchestrates the audio lifecycle by unlocking on interaction and playing tones on completion, while the `"pi-sound-enabled"` **localStorage** key preserves user preferences across sessions

## Frequently Asked Questions

### What prevents the completion sound from violating browser autoplay policies?

The `useAudio` hook initializes the `AudioContext` in a **suspended state**, which is the browser's default for automatically created contexts. The hook only calls `ctx.resume()` inside the `unlockAudio` method bound to user-initiated click events, satisfying the requirement for a user gesture before audio can play.

### Why does pi-web synthesize tones instead of using audio files?

Synthesizing tones with the **Web Audio API oscillator nodes** eliminates HTTP requests for audio assets, reduces bundle size, and allows precise control over the sound characteristics (frequency, envelope, timing) without managing external media files or dealing with asynchronous loading states.

### How does the hook handle browsers where AudioContext is unavailable?

The `getCtx` function wraps `new AudioContext()` in a try-catch block. If the constructor throws (due to unsupported browsers or security restrictions), the function returns `null`, and all subsequent audio operations fail silently without crashing the React component tree.

### Can the completion sound play on the very first chat response?

Only if the user has already interacted with the page to trigger `unlockAudio`. On the first load, the context remains suspended until the user clicks the sound-toggle button or another interactive element, at which point `unlockAudio` resumes the context and subsequent `playDoneSound` calls can produce audible output.