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

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

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:

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:

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:

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

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

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

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

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:

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:

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

Summary

  • useAudio in 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 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 and uses hardcoded frequencies (C5 and E5) with fixed envelope timing. To customize, modify the oscillator frequencies or gain envelope parameters in that function.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →