How pi-web Implements Completion Sounds with AudioContext and Browser Autoplay Policy

pi-web synthesizes a completion "ding" using a lazily-initialized singleton AudioContext that respects browser autoplay policies by remaining in a suspended state until a user gesture explicitly resumes it, persisting the sound preference in localStorage.

The pi-web repository (agegr/pi-web) provides a web-based chat interface where an audible notification signals when the AI assistant finishes responding. The implementation relies on the Web Audio API's AudioContext to generate tones programmatically without external audio files, while carefully navigating modern browser restrictions that block automatic playback until user interaction occurs.

The useAudio Hook Architecture

The sound system is centralized in the useAudio React hook located at hooks/useAudio.ts. To avoid the overhead of multiple audio contexts—which can cause resource leaks and erratic behavior in browsers—the hook stores a single AudioContext instance inside a useRef.

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 context outside a user gesture places it in the "suspended" state by default, which produces silence until explicitly resumed. This lazy initialization pattern ensures the context exists only when needed and survives React re-renders without recreation.

Handling the Browser Autoplay Policy

Modern browsers implement strict autoplay policies that prevent websites from emitting sound without a preceding user interaction (click, tap, or key press). pi-web handles this through two complementary mechanisms in hooks/useAudio.ts: unlockAudio and playDoneSound.

The unlockAudio function is designed to be called directly from UI event handlers. It accepts an optional force parameter and checks the current enabled state before attempting to resume:

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

When the assistant finishes a response, playDoneSound verifies the context state. If suspended, it chains the resume() promise to the playback logic, ensuring the tone only fires after the browser grants 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]);

This dual-check pattern guarantees that the completion sound never triggers the browser's autoplay blocker, even if the context was suspended between user sessions.

Synthesizing the Completion Tone

Rather than loading an MP3 file, pi-web generates the notification tone procedurally using the playTone helper function. This synthesizes two simultaneous sine waves—C5 (523.25 Hz) and E5 (659.25 Hz)—to create a pleasant major-third interval.

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 gain envelope applies a quick attack (0.02 seconds) and a gentle exponential decay (0.45 seconds), producing a crisp "ding" that fades naturally without clicking artifacts.

Persisting User Preferences

The hook manages user consent through a boolean flag stored in localStorage under the key "pi-sound-enabled". On initialization, the hook reads this value, defaulting to true for first-time visitors. The onSoundToggle callback flips this bit and synchronizes the ref used by the playback functions:

const [soundEnabled, setSoundEnabled] = useState(() => {
  if (typeof window === "undefined") return true;
  return localStorage.getItem("pi-sound-enabled") !== "false";
});

const onSoundToggle = useCallback(() => {
  setSoundEnabled(prev => {
    const next = !prev;
    localStorage.setItem("pi-sound-enabled", String(next));
    enabledRef.current = next;
    return next;
  });
}, []);

This persistence ensures that a user's sound preference survives page reloads and browser restarts without requiring server-side state.

Integrating with the ChatWindow

The ChatWindow component (components/ChatWindow.tsx) consumes the hook to wire sound into the chat flow:

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

unlockAudio is invoked on user-initiated events—specifically when the user clicks the sound-toggle button—to ensure the AudioContext enters the "running" state. When the streaming assistant response completes, the component calls playDoneSound(), which automatically handles the suspended-state edge case.

// Example: Toggle button calls unlockAudio to satisfy autoplay policy
<button onClick={() => { unlockAudio(true); onSoundToggle(); }}>
  {soundEnabled ? "🔊 Sound On" : "🔈 Sound Off"}
</button>

// When assistant finishes streaming:
useEffect(() => {
  if (!isStreaming && lastMessageRole === "assistant") {
    playDoneSound();
  }
}, [isStreaming, lastMessageRole, playDoneSound]);

This architecture decouples the audio policy logic from the UI, allowing the sound to fire reliably only after the browser has granted audio permission through prior interaction.

Summary

  • Singleton Pattern: hooks/useAudio.ts maintains one AudioContext in a ref to prevent resource leaks and state duplication.
  • Autoplay Compliance: The context initializes in a suspended state; unlockAudio and playDoneSound explicitly resume it only after user gestures.
  • Procedural Audio: The completion tone is synthesized at 523.25 Hz and 659.25 Hz using oscillator nodes with an exponential gain decay, eliminating external asset dependencies.
  • User Control: Preferences persist via localStorage with the key "pi-sound-enabled", defaulting to on for new users.
  • Clean Integration: components/ChatWindow.tsx consumes the hook, triggering playback when the assistant finishes while handling the suspended-state promise chain automatically.

Frequently Asked Questions

Why does the AudioContext start in a suspended state?

Browsers automatically place a newly created AudioContext into the "suspended" state if it is instantiated without a preceding user interaction. This prevents abusive autoplay; pi-web respects this by calling ctx.resume() only inside event handlers like unlockAudio.

What frequencies make up the completion sound?

The tone consists of two sine waves: C5 at 523.25 Hz and E5 at 659.25 Hz, played sequentially with an 0.18-second offset to create a harmonically rich "ding" effect without requiring audio file downloads.

How does pi-web remember if I turned off the sound?

The hook writes the boolean preference to localStorage under the key "pi-sound-enabled" whenever the user toggles the setting. On subsequent page loads, it reads this value to initialize the internal enabledRef state, ensuring the preference persists across sessions.

Can the sound play if I never click the sound-toggle button?

Yes, but only if another user gesture (such as sending a message or clicking anywhere in the interface) has already called unlockAudio. The browser requires any user interaction to transition the AudioContext from "suspended" to "running" before playDoneSound can emit audio.

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 →