# How Pi‑Web Plays Completion Sounds Using AudioContext and a localStorage Toggle

> Learn how Pi-Web uses AudioContext and localStorage to play completion sounds, overcoming browser autoplay limits with user interaction.

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

---

**Pi‑Web generates completion tones using a custom React hook that manages a persistent `AudioContext`, reads the sound preference from `localStorage`, and works around browser autoplay restrictions by unlocking the context on user interaction.**

The `agegr/pi-web` repository implements a lightweight audio system that adds auditory feedback when the AI assistant finishes responding. The entire system lives inside the `useAudio` hook at [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), which provides tone generation, state persistence, and browser‑compatibility handling in under 100 lines of code.

---

## Core Architecture of the Audio System

### Tone Generation with Dual Sine Oscillators

The completion sound consists of two simultaneous sine waves at **523 Hz (C5)** and **659 Hz (E5)**. The `playTone` helper constructs these oscillators programmatically:

- **Lines 5‑22** in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) define [`playTone`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L5-L22), which:
  - Creates two `OscillatorNode` instances connected to a shared `GainNode`
  - Sets frequencies to create a pleasant major‑third interval
  - Applies a short envelope (attack/release) to avoid clicking

This approach avoids external asset dependencies and keeps the bundle size minimal.

---

## localStorage Persistence and Default Behavior

### Reading the Sound Preference on Mount

The hook initializes its enabled state by checking `localStorage` for the key `pi-sound-enabled`:

```typescript
// Lines 24-30 in hooks/useAudio.ts
const [enabled, setEnabled] = useState<boolean>(() => {
  if (typeof window === 'undefined') return true;
  const saved = window.localStorage.getItem('pi-sound-enabled');
  return saved === null ? true : saved === 'true';
});

```

**Key behavior:** When no preference exists, sound defaults to **enabled**. This provides immediate feedback for first‑time users while still allowing opt‑out.

---

## Managing a Reusable AudioContext

### The `getCtx` Ref Pattern

Modern browsers suspend `AudioContext` instances until user interaction occurs. The hook stores a single context in a ref to avoid creating multiple instances:

```typescript
// Lines 34-46 in hooks/useAudio.ts
const ctxRef = useRef<AudioContext | null>(null);

function getCtx(): AudioContext {
  if (!ctxRef.current) {
    ctxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
  }
  return ctxRef.current;
}

```

This pattern ensures:
- **Memory efficiency:** One context for the entire application lifecycle
- **Autoplay compliance:** Context creation happens lazily, not on mount
- **Cross‑browser support:** Falls back to `webkitAudioContext` for Safari

---

## Unlocking Suspended Contexts on User Interaction

### The `unlockAudio` Function

Lines 48‑53 implement [`unlockAudio`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L48-L53), which attempts to resume a suspended context:

```typescript
async function unlockAudio(force = false): Promise<void> {
  const ctx = getCtx();
  if (ctx.state === 'suspended' && (enabled || force)) {
    await ctx.resume();
  }
}

```

The `force` parameter allows explicit unlocking even when sound is currently disabled—useful when the user toggles sound on and expects immediate feedback.

---

## The Toggle Mechanism: Persisting Preferences

### How `toggle` Synchronizes State and Storage

When users click the speaker icon, the [`toggle`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L55-L61) function executes three operations atomically:

1. Flips the boolean state with `setEnabled(prev => !prev)`
2. Persists the new value to `localStorage.setItem('pi-sound-enabled', String(newValue))`
3. Immediately calls `unlockAudio(true)` to ensure the context is ready for the next tone

This guarantees that re‑enabling sound works instantly, without requiring a second click or page refresh.

---

## Playing the Completion Sound

### The `playDone` Execution Flow

Lines 63‑79 contain [`playDone`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts#L63-L79), which orchestrates the actual playback:

```typescript
function playDone() {
  if (!enabledRef.current) return;  // Fast bail-out using ref
  
  const ctx = getCtx();
  if (ctx.state === 'suspended') {
    ctx.resume().then(() => playTone(ctx));
  } else {
    playTone(ctx);
  }
}

```

**Performance note:** The hook exports `soundEnabledRef` alongside `soundEnabled` so callback handlers can check state without triggering re‑renders.

---

## Hook API and Component Integration

### What `useAudio` Returns

Line 81 packages the complete API:

| Export | Type | Purpose |
|--------|------|---------|
| `soundEnabled` | `boolean` | Reactive state for UI rendering |
| `onSoundToggle` | `() => void` | Click handler for toggle buttons |
| `playDoneSound` | `() => void` | Safe wrapper around `playDone` |
| `unlockAudio` | `(force?: boolean) => Promise<void>` | Manual context resumption |
| `soundEnabledRef` | `MutableRefObject<boolean>` | Non‑reactive state for callbacks |

### Wiring into the Application

**`AppShell` consumption (lines 77‑81):**

```typescript
// components/AppShell.tsx
const { playDoneSound } = useAudio();
// ...
<ChatWindow onComplete={playDoneSound} />

```

**`ChatWindow` invocation (lines 57‑70):**

The component calls `playDoneSound()` after:
- Every assistant message completion
- Extension dialog dismissals

This ensures consistent feedback regardless of how the conversation ends.

---

## Implementation Examples

### Building a Custom Sound Toggle Button

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

export default function SoundToggle() {
  const { soundEnabled, onSoundToggle } = useAudio();

  return (
    <button 
      onClick={onSoundToggle}
      aria-pressed={soundEnabled}
      className="sound-toggle"
    >
      {soundEnabled ? '🔊 Sound On' : '🔈 Sound Off'}
    </button>
  );
}

```

### Manual Tone Triggering with Context Guarantee

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

function AsyncTaskWithFeedback() {
  const { playDoneSound, unlockAudio } = useAudio();

  async function processData() {
    const result = await fetch('/api/analysis');
    const data = await result.json();
    
    // Ensure audio context is ready before playing
    await unlockAudio();
    playDoneSound();
    
    return data;
  }

  return <button onClick={processData}>Analyze Data</button>;
}

```

---

## Summary

- **`playTone`** generates dual‑oscillator tones without external assets
- **`localStorage`** persists the `pi-sound-enabled` flag across sessions with a default‑enabled policy
- **Single `AudioContext`** stored in a ref prevents multiple instantiation and memory leaks
- **`unlockAudio`** works around browser autoplay restrictions by resuming suspended contexts on user gesture
- **`toggle`** synchronizes React state, `localStorage`, and immediate context unlocking
- **Ref‑based flag access** (`soundEnabledRef`) minimizes re‑renders during rapid callback execution

---

## Frequently Asked Questions

### How does Pi‑Web handle browsers that block autoplay?

The hook defers `AudioContext` initialization until sound actually plays, then checks `ctx.state === 'suspended'` before each tone. If suspended, it calls `ctx.resume()`—which requires prior user interaction per browser policies. The `unlockAudio(true)` call inside `toggle` explicitly primes the context when users re‑enable sound.

### What happens if `localStorage` is cleared or unavailable?

The `useState` initializer returns `true` when `localStorage.getItem('pi-sound-enabled')` returns `null`, so sound remains enabled. This matches the principle of progressive enhancement: functionality works without storage, and preferences enhance the experience when available.

### Why use two oscillators instead of a single tone or audio file?

Two oscillators create a richer harmonic texture (C5 + E5 = major third) that's more perceptible across different device speakers. Generating tones programmatically eliminates network requests, reduces bundle size, and avoids codec compatibility issues across browsers.

### Can the completion sound play while sound is toggled off?

No. The `playDone` function checks `enabledRef.current` before any audio operations. Since `enabledRef` stays synchronized with `localStorage` via the `toggle` function, the toggle state is respected immediately—even for events that fire during the same render cycle.