How useAudio Handles Completion Sounds with Browser Autoplay Policies in pi-web

The useAudio hook manages a single AudioContext that initializes in a suspended state to comply with browser autoplay restrictions, resuming audio output only after user interactions while generating dual-tone completion sounds through the Web Audio API.

The pi-web repository implements an auditory feedback system for AI chat completions that must navigate modern browsers' strict autoplay policies. Located at hooks/useAudio.ts, the useAudio hook encapsulates the complexity of creating reusable audio resources, handling user gesture requirements, and synthesizing the characteristic "ding" that plays when the assistant finishes responding.

AudioContext Architecture and Lazy Initialization

Single Instance Management

The hook maintains one AudioContext across component lifecycles using a mutable ref to prevent expensive recreation and state loss. A helper function retrieves the existing context if valid, or lazily instantiates a new one when first requested.

const ctxRef = useRef<AudioContext | null>(null);
const getCtx = useCallback((): AudioContext | null => {
  if (ctxRef.current && ctxRef.current.state !== "closed") return ctxRef.current;
  try { 
    ctxRef.current = new AudioContext(); 
  } catch { 
    return null; 
  }
  return ctxRef.current;
}, []);

Creating the Web Audio API context outside a user gesture immediately places it in a "suspended" state, which means the graph exists but produces no audible output until explicitly resumed.

Respecting Browser Autoplay Policies

Unlocking Audio with User Gestures

The unlockAudio method bridges the gap between React's initialization and the browser's requirement for user interaction. It checks the current state and calls resume() only when the context is suspended, typically bound to click events on interactive elements like the sound-toggle button.

const unlockAudio = useCallback((force = false) => {
  if (!force && !enabledRef.current) return;
  const ctx = getCtx();
  if (!ctx || ctx.state !== "suspended") return;
  ctx.resume().catch(() => {});
}, [getCtx]);

Defensive Playback Logic

Before generating tones, playDoneSound (internally implemented as playDone) inspects the context state. If suspended, it chains the resume promise to the playback function, ensuring the tone only executes after the browser grants audio permission.

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]);

Generating the Completion Tone

Rather than loading external audio assets, the hook synthesizes a completion sound using two sine wave oscillators programmed to output frequencies of 523.25 Hz (C5) and 659.25 Hz (E5). A gain envelope quickly fades each tone to prevent clipping and create a pleasant chime.

function playTone(ctx: AudioContext) {
  const now = ctx.currentTime;
  const freqs = [523.25, 659.25];
  
  freqs.forEach((freq, i) => {
    const osc = ctx.createOscillator();
    const gain = ctx.createGain();
    
    osc.connect(gain); 
    gain.connect(ctx.destination);
    
    osc.type = "sine"; 
    osc.frequency.value = freq;
    
    const t = now + i * 0.18;
    gain.gain.setValueAtTime(0, t);
    gain.gain.linearRampToValueAtTime(0.18, t + 0.02);
    gain.gain.exponentialRampToValueAtTime(0.001, t + 0.45);
    
    osc.start(t); 
    osc.stop(t + 0.45);
  });
}

The staggered start times (offset by 0.18 seconds) create a harmonic interval that signals completion distinctly from notification sounds.

Integration with ChatWindow

The ChatWindow component at components/ChatWindow.tsx consumes the hook to coordinate audio feedback with streaming response events:

const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();

Integration patterns include:

  • Calling unlockAudio on the sound-toggle button's onClick handler to satisfy autoplay requirements before the first completion tone plays
  • Invoking playDoneSound via a stable ref callback when the assistant finishes streaming a response, ensuring the latest function reference executes even if the component re-renders
  • Persisting the soundEnabled boolean to localStorage under the key "pi-sound-enabled", defaulting to true for new users

Summary

  • useAudio in hooks/useAudio.ts manages a singleton AudioContext that initializes in a suspended state to comply with browser autoplay policies from page load
  • unlockAudio must be triggered by user gestures (such as clicking the sound toggle) to call ctx.resume() and unlock audio output permissions
  • playDoneSound automatically handles suspended contexts by attempting to resume the AudioContext before executing the playTone generator, preventing autoplay violations
  • Dual-tone synthesis uses two sine oscillators at 523 Hz and 659 Hz with exponential gain ramps to generate the completion chime without external file dependencies
  • ChatWindow orchestrates the audio lifecycle by unlocking on interaction and playing tones on completion, while the "pi-sound-enabled" localStorage key preserves user preferences across sessions

Frequently Asked Questions

What prevents the completion sound from violating browser autoplay policies?

The useAudio hook initializes the AudioContext in a suspended state, which is the browser's default for automatically created contexts. The hook only calls ctx.resume() inside the unlockAudio method bound to user-initiated click events, satisfying the requirement for a user gesture before audio can play.

Why does pi-web synthesize tones instead of using audio files?

Synthesizing tones with the Web Audio API oscillator nodes eliminates HTTP requests for audio assets, reduces bundle size, and allows precise control over the sound characteristics (frequency, envelope, timing) without managing external media files or dealing with asynchronous loading states.

How does the hook handle browsers where AudioContext is unavailable?

The getCtx function wraps new AudioContext() in a try-catch block. If the constructor throws (due to unsupported browsers or security restrictions), the function returns null, and all subsequent audio operations fail silently without crashing the React component tree.

Can the completion sound play on the very first chat response?

Only if the user has already interacted with the page to trigger unlockAudio. On the first load, the context remains suspended until the user clicks the sound-toggle button or another interactive element, at which point unlockAudio resumes the context and subsequent playDoneSound calls can produce audible output.

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 →