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

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.

// 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:

// 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:

// 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:

// 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:

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

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

Complete Working Examples

Sound Toggle Button with Immediate Unlock

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

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, 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.

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 →