# How Pi Web Unlocks and Plays the Completion Sound in the Browser

> Learn how Pi Web plays the completion sound in your browser. Discover how the useAudio hook unlocks AudioContext and generates a sine wave on user interaction.

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

---

**Pi Web uses a custom React hook called `useAudio` to lazily create an `AudioContext`, resume it on user interaction, and generate a two-tone sine wave when generation tasks finish.**

The browser audio lifecycle is notoriously tricky—contexts start suspended, require user gestures to unlock, and must be reused for performance. In the `agegr/pi-web` repository, the completion sound system solves all three challenges through a centralized hook consumed across the application.

## The Core Hook: `useAudio` in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts)

All audio functionality lives in a single reusable hook. It manages three responsibilities: context creation, gesture-based unlocking, and tone generation.

### 1. Lazy AudioContext Creation

The hook defers `AudioContext` instantiation until first use, storing it in a ref for reuse:

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

```

This pattern ensures the context is **created once** and survives React re-renders, avoiding the "blocked audio" warnings that plague eagerly initialized contexts.

### 2. Unlocking on User Gesture

Browsers suspend audio contexts until a user interaction occurs. The `unlockAudio` function handles resumption:

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

```

The `force` parameter allows programmatic unlocking—for example, when a user explicitly enables sound in settings. Otherwise, the check against `enabledRef.current` respects the user's preference.

### 3. Playing the Completion Tone

When a generation finishes, `playDoneSound` checks state, resumes if needed, and delegates to `playTone`:

```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]);

```

The `playTone` function (internal to the hook) generates two simultaneous sine-wave oscillators at C5 and E5, applying a quick gain envelope for a pleasant chime effect.

## Hook Consumption in [`components/AppShell.tsx`](https://github.com/agegr/pi-web/blob/main/components/AppShell.tsx)

The `AppShell` component owns the single `useAudio` instance, ensuring tone playback works even without an active chat window (e.g., for background tasks):

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

```

It forwards these values to child components, maintaining one source of truth for audio state across the application.

## Integration in [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx)

Each `ChatWindow` receives the audio callbacks and wires them into session lifecycle events:

```tsx
const wrappedOnAgentEnd = useCallback(() => {
  if (soundEnabledRef.current) playDoneSoundRef.current();
  onAgentEnd?.();
}, [onAgentEnd]);

```

The ref-based check (`soundEnabledRef.current`) avoids stale closures in async callbacks, ensuring the **current** preference is respected when the agent actually finishes.

## Complete Usage Example

Here's how to integrate the completion sound into a custom component:

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

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

  const runTask = async () => {
    unlockAudio();           // ensure context is running
    await performWork();
    playDoneSound();         // chime on completion
  };

  return (
    <>
      <button onClick={onSoundToggle}>
        Sound: {soundEnabled ? 'On' : 'Off'}
      </button>
      <button onClick={runTask}>Run Task</button>
    </>
  );
}

```

## Forced Unlock Pattern

To unlock audio independently of the enabled flag—useful after settings changes:

```typescript
unlockAudio(true);  // force resume even if sound currently disabled

```

## Summary

- **`useAudio`** in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) centralizes all browser audio logic: context management, gesture unlocking, and tone synthesis
- **Lazy initialization** prevents premature `AudioContext` creation, avoiding autoplay policy violations
- **`unlockAudio`** bridges the browser's user-gesture requirement, with optional forcing for settings-driven flows
- **`playDoneSound`** handles both immediate playback and resume-then-play for suspended contexts
- **Single ownership in `AppShell`** ensures global availability; **per-window integration in `ChatWindow`** ties sound to session completion

## Frequently Asked Questions

### Why does the audio context need to be unlocked?

Browsers suspend `AudioContext` instances until a user interaction occurs as part of autoplay policies. The `unlockAudio` function in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) calls `ctx.resume()` to lift this restriction, typically triggered by clicks or toggle interactions.

### What happens if `playDoneSound` is called while the context is suspended?

The hook detects the `suspended` state and chains `ctx.resume()` with a `.then(play)` callback. This ensures the tone plays immediately after the context becomes active, rather than failing silently.

### Where is the completion sound triggered for background tasks?

`AppShell` owns the `useAudio` hook instance, so `playDoneSound` remains available even when no `ChatWindow` is mounted. Background task handlers can access the same callback passed through the component tree or context.

### Can I change the tone frequency or duration?

The `playTone` function is internal to [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) and uses hardcoded frequencies (C5 and E5) with fixed envelope timing. To customize, modify the oscillator frequencies or gain envelope parameters in that function.