# How the Completion Sound Unlocks and Plays in the useAudio Hook: A Technical Deep Dive

> Discover how the useAudio hook unlocks and plays completion sounds. Explore its three-stage workflow from localStorage initialization to AudioContext activation and tone synthesis.

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

---

**The `useAudio` hook manages sound playback through a three-stage workflow: initializing state from `localStorage`, unlocking the browser-restricted `AudioContext` upon user interaction, and synthesizing a dual-oscillator tone when tasks complete.**

This hook powers the "ding" notification in the agegr/pi-web repository whenever background tasks or chat messages finish. It solves the browser autoplay policy problem while giving users persistent control over audio preferences.

## The Three-Stage Audio Architecture

### Stage 1: Initialize Persistent State

The hook first establishes whether sound is enabled by checking `localStorage` for the `pi-sound-enabled` key. This value defaults to **true** for new users.

```typescript
// hooks/useAudio.ts, lines 24-30
const [enabled, setEnabled] = useState<boolean>(() => {
  if (typeof window === 'undefined') return true;
  const saved = localStorage.getItem(LOCAL_STORAGE_KEY);
  return saved !== 'false';
});

```

A **mutable ref `enabledRef`** mirrors this state. This is critical because audio callbacks often execute outside React's render cycle, where state closures would be stale.

### Stage 2: Unlock the AudioContext

Browsers enforce **autoplay policies** that block audio until triggered by a user gesture. The `unlockAudio` function handles this restriction:

```typescript
// hooks/useAudio.ts, lines 48-53
const unlockAudio = useCallback((force = false) => {
  if (!enabledRef.current && !force) return;
  if (ctxRef.current?.state === 'suspended') {
    void ctxRef.current.resume();
  }
}, [enabledRef]);

```

The `force` parameter allows components to prepare the audio context even when the user has sound disabled—useful for ensuring zero-latency playback if they re-enable it mid-session.

### Stage 3: Play the Completion Tone

The `playDoneSound` function serves as the public API. It guards against disabled sound, ensures the context is running, then delegates to `playTone`:

```typescript
// hooks/useAudio.ts, lines 63-79
const playDoneSound = useCallback(() => {
  if (!enabledRef.current) return;
  
  const ctx = getCtx(); // returns singleton AudioContext
  if (ctx.state === 'suspended') {
    void ctx.resume().then(() => playTone(ctx));
  } else {
    playTone(ctx);
  }
}, [enabledRef, getCtx]);

```

The `playTone` synthesizer (lines 5-22) creates two sine oscillators at **523 Hz** (C5) and **659 Hz** (E5), applying gain envelopes for a pleasant, fading "ding" characteristic.

## Integration Points in the Application

### AppShell.tsx: Central Task Completion Handler

The main shell component consumes `useAudio` and wires it to background task completion:

```typescript
// components/AppShell.tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio, soundEnabledRef } = useAudio();

const handleBackgroundTaskDone = useCallback(() => {
  if (soundEnabledRef.current) playDoneSound();
}, [playDoneSound, soundEnabledRef]);

```

Using `soundEnabledRef` rather than `soundEnabled` ensures the callback sees the latest toggle state even if it hasn't re-rendered.

### ChatWindow.tsx: Message Completion Trigger

The chat component receives `playDoneSound` via props and invokes it through a ref to avoid re-renders:

```typescript
// components/ChatWindow.tsx receives:
// playDoneSoundRef: MutableRefObject<() => void>

// After assistant message streams complete:
playDoneSoundRef.current();

```

## Complete Working Examples

### Sound Toggle Button with Immediate Unlock

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

export function SoundToggleButton() {
  const { soundEnabled, onSoundToggle, unlockAudio } = useAudio();

  const handleToggle = () => {
    onSoundToggle();
    unlockAudio(true); // force unlock on interaction
  };

  return (
    <button onClick={handleToggle}>
      {soundEnabled ? '🔊 Sound On' : '🔇 Sound Off'}
    </button>
  );
}

```

The `unlockAudio(true)` call ensures the `AudioContext` resumes immediately on the user gesture, eliminating any latency for subsequent tones.

### Async Task with Completion Sound

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

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

  useEffect(() => {
    async function process() {
      const result = await fetch('/api/heavy-task');
      const data = await result.json();
      
      // Optional: pre-unlock for lower latency
      unlockAudio();
      playDoneSound(); // "ding" on completion
    }
    process();
  }, [playDoneSound, unlockAudio]);

  return <ProcessingUI />;
}

```

## Key Design Patterns in useAudio

| Pattern | Implementation | Location |
|---------|---------------|----------|
| **Singleton AudioContext** | `ctxRef` holds single instance | [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), line ~35 |
| **State/Ref mirroring** | `enabled` state + `enabledRef` for callbacks | lines 24-30, 31 |
| **Lazy context creation** | `getCtx()` function initializes on first use | lines 32-43 |
| **Suspended-state recovery** | `resume()` called in both `unlockAudio` and `playDoneSound` | lines 48-53, 63-79 |
| **Dual-oscillator synthesis** | Two sines with gain ramps for rich tone | lines 5-22 |

## Summary

- **State persistence**: Sound preference survives sessions via `localStorage` key `pi-sound-enabled`
- **Browser compliance**: `unlockAudio` satisfies autoplay policies by requiring user-gesture-originated `resume()` calls
- **Zero-latency playback**: The singleton `AudioContext` pattern prevents recreation overhead
- **Flexible triggering**: Any component can call `playDoneSound()`; the hook centrally manages all audio policy
- **Ref-based freshness**: `enabledRef` ensures callbacks always respect the current user preference

## Frequently Asked Questions

### Why does useAudio use both state and a ref for the enabled flag?

React state creates closed-over values in callbacks. Since `playDoneSound` and `unlockAudio` may execute asynchronously or in event handlers outside the render cycle, `enabledRef` provides mutable access to the current preference without requiring callback regeneration. This prevents stale closures where a disabled sound would still play because the callback captured an old `enabled` value.

### What happens if playDoneSound is called before any user interaction?

The browser's `AudioContext` starts in a `suspended` state. When `playDoneSound` detects this, it calls `ctx.resume()`—which returns a Promise—and chains `playTone` in a `.then()` handler. If the resume succeeds (allowed by prior user gesture elsewhere in the app), the tone plays. If denied, the promise rejects silently and no sound occurs. Calling `unlockAudio()` during user interactions (like button clicks) pre-emptively resolves this.

### How does the tone generator actually create the "ding" sound?

The `playTone` function instantiates two `OscillatorNode` instances connected to `GainNode` envelopes. Each oscillator runs at distinct frequencies (523 Hz and 659 Hz, a perfect fifth interval). The gain nodes apply exponential ramp curves to create attack and decay, producing a bell-like character without loading external audio files. This keeps the bundle size minimal and avoids network latency.

### Can the completion sound play if the user disables it mid-session?

No. The `playDoneSound` function's first line checks `enabledRef.current` and returns early if false. Since `enabledRef` updates synchronously when `onSoundToggle` fires, subsequent calls to `playDoneSound` are immediately suppressed—even if triggered by in-flight async operations that started before the toggle.