# How Pi-Web Triggers and Manages the Completion Sound

> Discover how Pi-Web triggers and manages the completion sound. Learn about its use of ChatWindow.tsx and the reusable AudioContext hook for a distinct two-tone ding.

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

---

**Pi-Web plays a two-tone "ding" when an agent finishes responding by wrapping the session-end callback in [`ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/ChatWindow.tsx) and using a reusable `AudioContext` managed by the `useAudio` hook.**

The open-source `agegr/pi-web` repository implements a lightweight audio feedback system that notifies users when AI agent responses complete. This feature relies on a custom React hook to manage browser audio constraints while ensuring the completion sound only fires after successful agent runs.

## The useAudio Hook: Core Audio Architecture

The audio subsystem lives in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) and exposes four key callbacks that handle everything from context creation to sound generation.

### Creating a Reusable AudioContext

The hook initializes a single `AudioContext` that persists for the entire page lifecycle. According to lines **[38‑46]** of [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), the `getCtx` function creates the context on first call and returns the existing instance thereafter:

```typescript
// Simplified logic from hooks/useAudio.ts lines 38-46
const getCtx = () => {
  if (!ctxRef.current) {
    ctxRef.current = new (window.AudioContext || window.webkitAudioContext)();
  }
  return ctxRef.current;
};

```

This reuse pattern prevents the overhead of multiple audio contexts and ensures state consistency across components.

### Implementing the Two-Tone Ding

The `playDoneSound` function generates a "ding" using two oscillators at **C5** (~523 Hz) and **E5** (~659 Hz). As implemented in lines **[5‑21]** and **[63‑79]`, the function first checks if sound is enabled, then resumes the context if suspended before triggering `playTone`:

```typescript
// From hooks/useAudio.ts lines 63-79
const playDone = () => {
  if (!soundEnabledRef.current) return;
  
  const ctx = getCtx();
  if (ctx.state === 'suspended') {
    ctx.resume();
  }
  
  // Plays C5 followed by E5
  playTone(ctx, 523.25, 0.1); // C5
  setTimeout(() => playTone(ctx, 659.25, 0.1), 150); // E5
};

```

### Persisting User Preferences

User preferences survive page reloads through `localStorage`. Lines **[24‑30]** show the hook reading the `pi-sound-enabled` key during initialization:

```typescript
// From hooks/useAudio.ts lines 24-30
const [soundEnabled, setSoundEnabled] = useState(() => {
  if (typeof window === 'undefined') return false;
  return localStorage.getItem('pi-sound-enabled') === 'true';
});

```

The `onSoundToggle` callback flips this flag and updates storage, while `unlockAudio` handles browser autoplay policies by resuming the context on user gesture.

## Integration in the Chat UI

The [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx) file bridges the audio hook with the chat interface, ensuring the completion sound fires exactly once per agent response.

### Wrapping the Session End Callback

At line **[12]`, the component imports the hook and wraps the `onAgentEnd` callback to conditionally trigger the sound. Lines **[86‑88]** demonstrate how the session hook receives the wrapped handler:

```tsx
// From components/ChatWindow.tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();

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

```

This guarantees the **completion sound** only plays after successful completions, not during errors or interruptions.

### Providing UI Controls

The component passes audio state and controls to `ChatInput` via props at lines **[23‑26]**:

```tsx
// From components/ChatWindow.tsx lines 23-26
<ChatInput
  soundEnabled={soundEnabled}
  onSoundToggle={onSoundToggle}
  onAudioUnlock={unlockAudio}
  // ...other props
/>

```

This gives users a toggle to enable or disable sounds and a mechanism to unlock the audio context when browser policies suspend it.

## Handling Browser Autoplay Restrictions

Modern browsers suspend `AudioContext` instances until a user gesture occurs. Pi-Web handles this through the `unlockAudio` helper, which can be called from click handlers to resume a suspended context.

### Code Example: Using the Hook Directly

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

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

  return (
    <div>
      <label>
        <input type="checkbox" checked={soundEnabled} onChange={onSoundToggle} />
        Enable completion sound
      </label>

      <button onClick={() => { unlockAudio(); playDoneSound(); }}>
        Test Sound
      </button>
    </div>
  );
}

```

### Code Example: Integrating with Custom Session Handlers

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

function MyChat({ onSessionEnd }) {
  const { soundEnabled, playDoneSound } = useAudio();

  const handleEnd = useCallback(() => {
    if (soundEnabled) playDoneSound();
    onSessionEnd?.();
  }, [onSessionEnd, soundEnabled, playDoneSound]);

  // Pass handleEnd to your session logic
}

```

### Code Example: Manually Unlocking Audio

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

function UnlockButton() {
  const { unlockAudio } = useAudio();

  return (
    <button onClick={() => unlockAudio(true)}>
      Unlock Audio (needed after page load)
    </button>
  );
}

```

## Summary

- **[`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts)** creates a singleton `AudioContext` and exposes `playDoneSound` to generate a two-tone ding at C5 and E5 frequencies.
- **User preferences** persist in `localStorage` under the key `pi-sound-enabled`, allowing settings to survive page reloads.
- **[`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx)** wraps the `onAgentEnd` callback to ensure the completion sound fires only after successful agent responses.
- **Browser autoplay restrictions** are managed through the `unlockAudio` helper, which resumes suspended contexts on user gesture.

## Frequently Asked Questions

### Why does the completion sound not play immediately on page load?

Browsers suspend `AudioContext` instances until the user interacts with the page. Pi-Web handles this by providing an `unlockAudio` function that resumes the context when called from a click handler or toggle interaction, satisfying browser autoplay policies.

### How does Pi-Web prevent multiple audio contexts from consuming memory?

The `useAudio` hook implements a singleton pattern where `getCtx` (lines **[38‑46]**) creates the `AudioContext` only once and returns the cached reference on subsequent calls. This prevents the memory overhead and state conflicts that occur when creating multiple contexts.

### Can I customize the completion sound frequency or duration?

The current implementation hardcodes frequencies of 523.25 Hz (C5) and 659.25 Hz (E5) with a 150ms delay between tones in the `playTone` function (lines **[5‑21]**). To customize, you would modify the `playDone` function in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) to accept frequency parameters or adjust the timing constants.

### Where is the sound preference stored between sessions?

Pi-Web stores the boolean flag in `localStorage` under the key `pi-sound-enabled` (lines **[24‑30]**). The hook reads this value during initialization and updates it whenever the user toggles the sound setting via `onSoundToggle`.