How Pi-Web Triggers and Manages the Completion Sound

Pi-Web plays a two-tone "ding" when an agent finishes responding by wrapping the session-end callback in ChatWindow.tsx and using a reusable AudioContext managed by the useAudio hook.

The open-source agegr/pi-web repository implements a lightweight audio feedback system that notifies users when AI agent responses complete. This feature relies on a custom React hook to manage browser audio constraints while ensuring the completion sound only fires after successful agent runs.

The useAudio Hook: Core Audio Architecture

The audio subsystem lives in hooks/useAudio.ts and exposes four key callbacks that handle everything from context creation to sound generation.

Creating a Reusable AudioContext

The hook initializes a single AudioContext that persists for the entire page lifecycle. According to lines [38‑46] of hooks/useAudio.ts, the getCtx function creates the context on first call and returns the existing instance thereafter:

// Simplified logic from hooks/useAudio.ts lines 38-46
const getCtx = () => {
  if (!ctxRef.current) {
    ctxRef.current = new (window.AudioContext || window.webkitAudioContext)();
  }
  return ctxRef.current;
};

This reuse pattern prevents the overhead of multiple audio contexts and ensures state consistency across components.

Implementing the Two-Tone Ding

The playDoneSound function generates a "ding" using two oscillators at C5 (~523 Hz) and E5 (~659 Hz). As implemented in lines [5‑21] and **[63‑79], the function first checks if sound is enabled, then resumes the context if suspended before triggering playTone`:

// From hooks/useAudio.ts lines 63-79
const playDone = () => {
  if (!soundEnabledRef.current) return;
  
  const ctx = getCtx();
  if (ctx.state === 'suspended') {
    ctx.resume();
  }
  
  // Plays C5 followed by E5
  playTone(ctx, 523.25, 0.1); // C5
  setTimeout(() => playTone(ctx, 659.25, 0.1), 150); // E5
};

Persisting User Preferences

User preferences survive page reloads through localStorage. Lines [24‑30] show the hook reading the pi-sound-enabled key during initialization:

// From hooks/useAudio.ts lines 24-30
const [soundEnabled, setSoundEnabled] = useState(() => {
  if (typeof window === 'undefined') return false;
  return localStorage.getItem('pi-sound-enabled') === 'true';
});

The onSoundToggle callback flips this flag and updates storage, while unlockAudio handles browser autoplay policies by resuming the context on user gesture.

Integration in the Chat UI

The components/ChatWindow.tsx file bridges the audio hook with the chat interface, ensuring the completion sound fires exactly once per agent response.

Wrapping the Session End Callback

At line **[12], the component imports the hook and wraps the onAgentEnd` callback to conditionally trigger the sound. Lines [86‑88] demonstrate how the session hook receives the wrapped handler:

// From components/ChatWindow.tsx
const { soundEnabled, onSoundToggle, playDoneSound, unlockAudio } = useAudio();

const wrappedOnAgentEnd = useCallback(() => {
  if (soundEnabledRef.current) {
    playDoneSoundRef.current();
  }
  onAgentEnd?.();
}, [onAgentEnd]);

This guarantees the completion sound only plays after successful completions, not during errors or interruptions.

Providing UI Controls

The component passes audio state and controls to ChatInput via props at lines [23‑26]:

// From components/ChatWindow.tsx lines 23-26
<ChatInput
  soundEnabled={soundEnabled}
  onSoundToggle={onSoundToggle}
  onAudioUnlock={unlockAudio}
  // ...other props
/>

This gives users a toggle to enable or disable sounds and a mechanism to unlock the audio context when browser policies suspend it.

Handling Browser Autoplay Restrictions

Modern browsers suspend AudioContext instances until a user gesture occurs. Pi-Web handles this through the unlockAudio helper, which can be called from click handlers to resume a suspended context.

Code Example: Using the Hook Directly

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

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

  return (
    <div>
      <label>
        <input type="checkbox" checked={soundEnabled} onChange={onSoundToggle} />
        Enable completion sound
      </label>

      <button onClick={() => { unlockAudio(); playDoneSound(); }}>
        Test Sound
      </button>
    </div>
  );
}

Code Example: Integrating with Custom Session Handlers

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

function MyChat({ onSessionEnd }) {
  const { soundEnabled, playDoneSound } = useAudio();

  const handleEnd = useCallback(() => {
    if (soundEnabled) playDoneSound();
    onSessionEnd?.();
  }, [onSessionEnd, soundEnabled, playDoneSound]);

  // Pass handleEnd to your session logic
}

Code Example: Manually Unlocking Audio

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

function UnlockButton() {
  const { unlockAudio } = useAudio();

  return (
    <button onClick={() => unlockAudio(true)}>
      Unlock Audio (needed after page load)
    </button>
  );
}

Summary

  • hooks/useAudio.ts creates a singleton AudioContext and exposes playDoneSound to generate a two-tone ding at C5 and E5 frequencies.
  • User preferences persist in localStorage under the key pi-sound-enabled, allowing settings to survive page reloads.
  • components/ChatWindow.tsx wraps the onAgentEnd callback to ensure the completion sound fires only after successful agent responses.
  • Browser autoplay restrictions are managed through the unlockAudio helper, which resumes suspended contexts on user gesture.

Frequently Asked Questions

Why does the completion sound not play immediately on page load?

Browsers suspend AudioContext instances until the user interacts with the page. Pi-Web handles this by providing an unlockAudio function that resumes the context when called from a click handler or toggle interaction, satisfying browser autoplay policies.

How does Pi-Web prevent multiple audio contexts from consuming memory?

The useAudio hook implements a singleton pattern where getCtx (lines [38‑46]) creates the AudioContext only once and returns the cached reference on subsequent calls. This prevents the memory overhead and state conflicts that occur when creating multiple contexts.

Can I customize the completion sound frequency or duration?

The current implementation hardcodes frequencies of 523.25 Hz (C5) and 659.25 Hz (E5) with a 150ms delay between tones in the playTone function (lines [5‑21]). To customize, you would modify the playDone function in hooks/useAudio.ts to accept frequency parameters or adjust the timing constants.

Where is the sound preference stored between sessions?

Pi-Web stores the boolean flag in localStorage under the key pi-sound-enabled (lines [24‑30]). The hook reads this value during initialization and updates it whenever the user toggles the sound setting via onSoundToggle.

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 →