How the Completion Sound is Implemented in pi-web Using AudioContext

Pi-web implements completion sounds using a custom useAudio hook that manages a single AudioContext instance, generating a dual-oscillator "ding" tone while respecting browser autoplay policies by resuming suspended contexts only after user gestures.

The pi-web repository (agegr/pi-web) leverages the Web Audio API to provide audible feedback when AI assistant responses finish streaming. Rather than loading external audio files, the application synthesizes a pleasant chime programmatically, storing user preferences in localStorage and carefully managing the AudioContext lifecycle to comply with modern browser autoplay restrictions.

Architecture of the useAudio Hook

The implementation resides in hooks/useAudio.ts and exposes a clean interface for components to trigger sounds without managing the complex lifecycle of Web Audio nodes directly.

Persisting User Preferences

On initialization, the hook reads the pi-sound-enabled key from localStorage (defaulting to true) to determine if sounds should play. This value is stored in React state and synchronized with a mutable ref (enabledRef) to ensure asynchronous callbacks always access the current preference without stale closures. The initialization logic appears at lines 25-29, while the ref synchronization occurs at lines 31-33.

Lazy AudioContext Initialization

To conserve resources and comply with browser policies, the hook creates the AudioContext lazily. The getCtx function (lines 37-46) checks a useRef container and only instantiates new AudioContext() if no context exists or the previous one was closed. This ensures the application maintains a single, reusable AudioContext across the entire component tree.

Handling Autoplay Restrictions

Browsers suspend AudioContext instances created outside user gestures. The unlockAudio function (lines 48-53) handles this by calling ctx.resume() only when the context state is "suspended" and sound is enabled. This method is invoked from click handlers, satisfying Chrome and Firefox autoplay requirements before any tone generation occurs.

Synthesizing the Completion Sound

When the assistant finishes responding, the playDone function (lines 63-79) validates that sound is enabled, retrieves the context, resumes it if necessary, and triggers playTone.

Dual-Oscillator Implementation

The playTone function (lines 5-22) creates two oscillator nodes tuned to C5 (523.25 Hz) and E5 (659.25 Hz). These oscillators connect to a GainNode that shapes a rapid attack/decay envelope, producing a crisp "ding" that signals completion without requiring external audio assets.

Using the Hook in Components

Components consume the hook to trigger sounds when streaming ends. The following pattern from components/ChatWindow.tsx demonstrates integration:

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

export default function ChatWindow() {
  const {
    soundEnabled,
    onSoundToggle,
    playDoneSound,
    unlockAudio,
  } = useAudio();

  // Call `playDoneSound` when the assistant finishes streaming
  useEffect(() => {
    if (agentFinished) {
      playDoneSound();
    }
  }, [agentFinished, playDoneSound]);

  return (
    <div>
      <button onClick={onSoundToggle}>
        {soundEnabled ? "🔊" : "🔈"} Sound
      </button>
      {/* … rest of the chat UI … */}
    </div>
  );
}

The component calls playDoneSound upon completion signals from the backend, while the hook manages unlocking and tone generation automatically.

Summary

  • Single Context Pattern: hooks/useAudio.ts maintains one AudioContext via useRef to avoid resource duplication and state conflicts.
  • Autoplay Compliance: The context only resumes inside unlockAudio or playDone, ensuring sounds play only after user interaction.
  • Synthesized Audio: The completion sound uses two oscillators (C5 and E5) with a gain envelope instead of pre-recorded files.
  • Persistent Settings: User preferences store in localStorage under pi-sound-enabled and sync via enabledRef to prevent stale closures.

Frequently Asked Questions

How does pi-web handle browsers that block autoplay?

Pi-web respects browser autoplay policies by creating the AudioContext lazily and keeping it in a "suspended" state until a user gesture occurs. The unlockAudio function in hooks/useAudio.ts (lines 48-53) explicitly checks ctx.state === "suspended" and calls ctx.resume() only from click handlers, ensuring the completion sound is audible without triggering browser blocking mechanisms.

What frequencies does the pi-web completion sound use?

According to the source code in hooks/useAudio.ts (lines 5-22), the tone generation creates two oscillators: one at C5 (523.25 Hz) and another at E5 (659.25 Hz). These frequencies form a major third interval that creates a pleasant, attention-grabbing "ding" when combined with the gain envelope.

Where does pi-web store the sound preference?

The application persists the sound setting under the localStorage key pi-sound-enabled. The useAudio hook reads this value during initialization (lines 25-29) and updates it whenever the user toggles sound on or off, ensuring the preference survives page reloads.

Can multiple components create separate AudioContexts?

No, the architecture prevents this. The getCtx function within useAudio uses a useRef to store the context instance (lines 37-46). If a context exists and is not closed, the hook returns the existing instance rather than creating a new one, ensuring the entire application shares a single AudioContext for all completion sounds.

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 →