# How the Completion Sound Feature Works with Browser AudioContext in agegr/pi-web

> Explore how the completion sound feature in agegr/pi-web uses AudioContext to create dual-tone notifications. Learn how it syncs with browser AudioContext and handles autoplay policies.

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

---

**The completion sound feature uses a singleton AudioContext managed by the `useAudio` hook to synthesize a dual-tone notification, unlocking the context on user gestures to comply with browser autoplay policies.**

The implementation resides in the Next.js frontend of the `agegr/pi-web` repository, where the system generates audible feedback programmatically without external audio files. By leveraging the Web Audio API, the feature manages browser-specific restrictions while providing a distinct "ding" when an AI agent finishes responding.

## AudioContext Lifecycle Management

The `useAudio` hook, located at [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts), maintains a single **AudioContext** instance inside a React ref called `ctxRef`. This singleton pattern ensures the browser allocates only one audio processing graph per session, conserving memory and CPU resources.

Browsers initially create the `AudioContext` in a **suspended** state to block automatic playback. The hook exposes an `unlockAudio` function that inspects `ctx.state`; if the value is `"suspended"`, it calls `ctx.resume()` to transition the context to the **running** state. This activation must occur within a user-initiated event handler—such as clicking the send button—to satisfy browser security requirements.

## Synthesizing the Completion Tone

When a chat session terminates, the `playDoneSound` function retrieves the active `AudioContext` and invokes the internal `playTone` helper. This method instantiates two **OscillatorNode** objects tuned to **523 Hz** and **659 Hz**, creating a pleasant major-third interval.

Each oscillator connects to a dedicated **GainNode** to shape the amplitude envelope. The gain ramps up immediately then decays exponentially over approximately **0.45 seconds**, producing a short, non-intrusive sound. If the context is unavailable or closed, `playDoneSound` exits silently without throwing errors.

```typescript
// Conceptual implementation inside hooks/useAudio.ts
const osc1 = ctx.createOscillator();
const gain1 = ctx.createGain();
osc1.frequency.value = 523; // C5
osc1.connect(gain1);
gain1.connect(ctx.destination);
gain1.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.45);
osc1.start();
osc1.stop(ctx.currentTime + 0.45);

```

## Persisting Sound Preferences

The hook manages a boolean `soundEnabled` state that defaults to `false`. On initialization, it reads the **`pi-sound-enabled`** key from `localStorage` to restore the user's prior selection. The `onSoundToggle` function flips this state and synchronizes the storage entry, ensuring the preference persists across page reloads.

Components consume these controls to render UI toggles, allowing users to enable or disable the completion sound feature dynamically without disrupting the underlying `AudioContext` lifecycle.

## Integration with Chat Components

In [`components/ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatWindow.tsx), the `onAgentEnd` callback is wrapped to conditionally trigger audio feedback. When the session ends and `soundEnabled` is `true`, the component invokes `playDoneSound`, decoupling termination logic from the audio system.

The [`components/ChatInput.tsx`](https://github.com/agegr/pi-web/blob/main/components/ChatInput.tsx) file ensures the audio pipeline is ready before the first tone is required. The send button's `onClick` handler executes `unlockAudio()` immediately before calling `sendMessage()`, guaranteeing the `AudioContext` enters the running state through a permissible user gesture.

```tsx
// components/ChatInput.tsx
<button
  onClick={() => {
    unlockAudio(); // Resume AudioContext if suspended
    sendMessage();
  }}
>
  Send
</button>

```

## Summary

- The `useAudio` hook in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) maintains a singleton `AudioContext` in `ctxRef` to optimize resource usage and prevent multiple graph instantiations.
- **Autoplay restrictions** are handled by the `unlockAudio` function, which resumes the context during user-initiated events such as clicking the send button in [`ChatInput.tsx`](https://github.com/agegr/pi-web/blob/main/ChatInput.tsx).
- The completion tone is synthesized in `playTone` using two oscillators at **523 Hz** and **659 Hz** with exponential gain decay over 0.45 seconds.
- User preferences are stored under the **`pi-sound-enabled`** key in `localStorage` and managed via the `soundEnabled` state and `onSoundToggle` handler.
- [`ChatWindow.tsx`](https://github.com/agegr/pi-web/blob/main/ChatWindow.tsx) invokes `playDoneSound` upon session end, contingent on the user's sound preference, to signal completion without external assets.

## Frequently Asked Questions

### Why does the AudioContext need to be unlocked?

Browsers suspend the `AudioContext` by default to prevent websites from playing audio automatically without user consent. The `unlockAudio` function in [`hooks/useAudio.ts`](https://github.com/agegr/pi-web/blob/main/hooks/useAudio.ts) checks `ctx.state` and calls `ctx.resume()` during a user interaction, such as clicking the send button in [`ChatInput.tsx`](https://github.com/agegr/pi-web/blob/main/ChatInput.tsx), thereby satisfying browser autoplay policies before any sound is generated.

### What frequencies are used for the completion sound?

The `playTone` function creates two **OscillatorNode** instances tuned to **523 Hz** (C5) and **659 Hz** (E5). This combination produces a major-third harmonic interval that is audibly distinct and calibrated to be pleasant rather than jarring.

### How is the sound preference persisted across sessions?

The preference is stored in the browser's `localStorage` under the key **`pi-sound-enabled`**. The `useAudio` hook reads this value on component mount and updates it whenever the user toggles the setting via `onSoundToggle`, ensuring the choice remains consistent after page refreshes or browser restarts.

### What happens if the AudioContext fails to initialize?

The `useAudio` hook includes defensive checks within `playDoneSound`. If the `AudioContext` creation throws an exception, the context is closed, or the reference is null, the function returns early without attempting to create oscillator nodes, thereby preventing runtime crashes or unhandled promise rejections.