How Pi‑Web Plays Completion Sounds Using AudioContext and a localStorage Toggle

Pi‑Web generates completion tones using a custom React hook that manages a persistent AudioContext, reads the sound preference from localStorage, and works around browser autoplay restrictions by unlocking the context on user interaction.

The agegr/pi-web repository implements a lightweight audio system that adds auditory feedback when the AI assistant finishes responding. The entire system lives inside the useAudio hook at hooks/useAudio.ts, which provides tone generation, state persistence, and browser‑compatibility handling in under 100 lines of code.


Core Architecture of the Audio System

Tone Generation with Dual Sine Oscillators

The completion sound consists of two simultaneous sine waves at 523 Hz (C5) and 659 Hz (E5). The playTone helper constructs these oscillators programmatically:

  • Lines 5‑22 in hooks/useAudio.ts define playTone, which:
    • Creates two OscillatorNode instances connected to a shared GainNode
    • Sets frequencies to create a pleasant major‑third interval
    • Applies a short envelope (attack/release) to avoid clicking

This approach avoids external asset dependencies and keeps the bundle size minimal.


localStorage Persistence and Default Behavior

Reading the Sound Preference on Mount

The hook initializes its enabled state by checking localStorage for the key pi-sound-enabled:

// Lines 24-30 in hooks/useAudio.ts
const [enabled, setEnabled] = useState<boolean>(() => {
  if (typeof window === 'undefined') return true;
  const saved = window.localStorage.getItem('pi-sound-enabled');
  return saved === null ? true : saved === 'true';
});

Key behavior: When no preference exists, sound defaults to enabled. This provides immediate feedback for first‑time users while still allowing opt‑out.


Managing a Reusable AudioContext

The getCtx Ref Pattern

Modern browsers suspend AudioContext instances until user interaction occurs. The hook stores a single context in a ref to avoid creating multiple instances:

// Lines 34-46 in hooks/useAudio.ts
const ctxRef = useRef<AudioContext | null>(null);

function getCtx(): AudioContext {
  if (!ctxRef.current) {
    ctxRef.current = new (window.AudioContext || (window as any).webkitAudioContext)();
  }
  return ctxRef.current;
}

This pattern ensures:

  • Memory efficiency: One context for the entire application lifecycle
  • Autoplay compliance: Context creation happens lazily, not on mount
  • Cross‑browser support: Falls back to webkitAudioContext for Safari

Unlocking Suspended Contexts on User Interaction

The unlockAudio Function

Lines 48‑53 implement unlockAudio, which attempts to resume a suspended context:

async function unlockAudio(force = false): Promise<void> {
  const ctx = getCtx();
  if (ctx.state === 'suspended' && (enabled || force)) {
    await ctx.resume();
  }
}

The force parameter allows explicit unlocking even when sound is currently disabled—useful when the user toggles sound on and expects immediate feedback.


The Toggle Mechanism: Persisting Preferences

How toggle Synchronizes State and Storage

When users click the speaker icon, the toggle function executes three operations atomically:

  1. Flips the boolean state with setEnabled(prev => !prev)
  2. Persists the new value to localStorage.setItem('pi-sound-enabled', String(newValue))
  3. Immediately calls unlockAudio(true) to ensure the context is ready for the next tone

This guarantees that re‑enabling sound works instantly, without requiring a second click or page refresh.


Playing the Completion Sound

The playDone Execution Flow

Lines 63‑79 contain playDone, which orchestrates the actual playback:

function playDone() {
  if (!enabledRef.current) return;  // Fast bail-out using ref
  
  const ctx = getCtx();
  if (ctx.state === 'suspended') {
    ctx.resume().then(() => playTone(ctx));
  } else {
    playTone(ctx);
  }
}

Performance note: The hook exports soundEnabledRef alongside soundEnabled so callback handlers can check state without triggering re‑renders.


Hook API and Component Integration

What useAudio Returns

Line 81 packages the complete API:

Export Type Purpose
soundEnabled boolean Reactive state for UI rendering
onSoundToggle () => void Click handler for toggle buttons
playDoneSound () => void Safe wrapper around playDone
unlockAudio (force?: boolean) => Promise<void> Manual context resumption
soundEnabledRef MutableRefObject<boolean> Non‑reactive state for callbacks

Wiring into the Application

AppShell consumption (lines 77‑81):

// components/AppShell.tsx
const { playDoneSound } = useAudio();
// ...
<ChatWindow onComplete={playDoneSound} />

ChatWindow invocation (lines 57‑70):

The component calls playDoneSound() after:

  • Every assistant message completion
  • Extension dialog dismissals

This ensures consistent feedback regardless of how the conversation ends.


Implementation Examples

Building a Custom Sound Toggle Button

import { useAudio } from '@/hooks/useAudio';

export default function SoundToggle() {
  const { soundEnabled, onSoundToggle } = useAudio();

  return (
    <button 
      onClick={onSoundToggle}
      aria-pressed={soundEnabled}
      className="sound-toggle"
    >
      {soundEnabled ? '🔊 Sound On' : '🔈 Sound Off'}
    </button>
  );
}

Manual Tone Triggering with Context Guarantee

import { useAudio } from '@/hooks/useAudio';

function AsyncTaskWithFeedback() {
  const { playDoneSound, unlockAudio } = useAudio();

  async function processData() {
    const result = await fetch('/api/analysis');
    const data = await result.json();
    
    // Ensure audio context is ready before playing
    await unlockAudio();
    playDoneSound();
    
    return data;
  }

  return <button onClick={processData}>Analyze Data</button>;
}

Summary

  • playTone generates dual‑oscillator tones without external assets
  • localStorage persists the pi-sound-enabled flag across sessions with a default‑enabled policy
  • Single AudioContext stored in a ref prevents multiple instantiation and memory leaks
  • unlockAudio works around browser autoplay restrictions by resuming suspended contexts on user gesture
  • toggle synchronizes React state, localStorage, and immediate context unlocking
  • Ref‑based flag access (soundEnabledRef) minimizes re‑renders during rapid callback execution

Frequently Asked Questions

How does Pi‑Web handle browsers that block autoplay?

The hook defers AudioContext initialization until sound actually plays, then checks ctx.state === 'suspended' before each tone. If suspended, it calls ctx.resume()—which requires prior user interaction per browser policies. The unlockAudio(true) call inside toggle explicitly primes the context when users re‑enable sound.

What happens if localStorage is cleared or unavailable?

The useState initializer returns true when localStorage.getItem('pi-sound-enabled') returns null, so sound remains enabled. This matches the principle of progressive enhancement: functionality works without storage, and preferences enhance the experience when available.

Why use two oscillators instead of a single tone or audio file?

Two oscillators create a richer harmonic texture (C5 + E5 = major third) that's more perceptible across different device speakers. Generating tones programmatically eliminates network requests, reduces bundle size, and avoids codec compatibility issues across browsers.

Can the completion sound play while sound is toggled off?

No. The playDone function checks enabledRef.current before any audio operations. Since enabledRef stays synchronized with localStorage via the toggle function, the toggle state is respected immediately—even for events that fire during the same render cycle.

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 →