Tips & Tricks (Updated: 6/2/2026)

Web Audio API with Claude Code: React Hooks, Waveforms, Recording, and Cleanup

Build Web Audio API with Claude Code: React hooks, waveform playback, recording preview, meters, cleanup, and tests.

Web Audio API with Claude Code: React Hooks, Waveforms, Recording, and Cleanup

Start from the Audio Experience

The Web Audio API is the browser API for building an audio graph: load a sound, route it through nodes, analyze it, change its volume, and send it to an output. A normal audio element is enough for a basic podcast player. You need the Web Audio API when the product asks for a waveform player, live input meter, recording preview, notification sound, sound effect chain, pronunciation practice tool, or learning interface that compares the user’s voice with a reference clip.

Claude Code helps because the hard part is not writing one isolated snippet. The hard part is fitting audio into an existing React app without breaking server rendering, browser autoplay rules, microphone permissions, cleanup, accessibility, or mobile latency. If Claude Code can read your components, test setup, routes, and repository rules, it can implement the audio graph and then review it against the same constraints.

This guide turns the article into an implementation workflow. You will get a Claude Code prompt, a copyable React/TypeScript AudioContext hook, an AnalyserNode waveform canvas, a recorder preview with a volume meter, cleanup helpers, Playwright and manual checks, and a review prompt. Keep the official MDN Web Audio API, MDN autoplay guide, and MediaRecorder reference open while adapting this to your app. For the broader player UI, also read How to Build an Audio Player with Claude Code.

Five Practical Use Cases

Audio work becomes easier when you name the use case before writing code. Each case needs a slightly different graph and a different review checklist.

Use caseMain APIsWhat Claude Code should implementMain risk
Waveform playerAudioContext, AudioBufferSourceNode, AnalyserNodeplayback, stop, waveform canvas, master volumetrying to reuse a source node after start()
Recording previewgetUserMedia, MediaRecorder, MediaStreamAudioSourceNoderecord, stop, create a local Blob URL, preview audioleaving microphone tracks active
Volume meterAnalyserNode.getByteTimeDomainDataRMS calculation and live meter UInever stopping requestAnimationFrame
Notification soundsOscillatorNode, GainNodeshort tones, fade in, fade out, click preventionstarting sound before a user gesture
Voice learning toolplayback hook, recorder, loop controlsshadowing, pronunciation practice, self-reviewunclear consent and retention rules for recordings

This table is the difference between a demo and a useful implementation. Ask Claude Code to build only the graph you need. A waveform player does not need microphone permission. A recorder preview should not route microphone input to the speakers. A notification tone should be short, quiet by default, and disconnected after it ends.

Prompt Claude Code Before Editing

Give Claude Code a task with boundaries. The prompt should mention user gestures, recording scope, cleanup, and verification. It should also tell Claude Code to inspect the existing app before editing.

Implement a React + TypeScript Web Audio API demo.

Requirements:
- Create or resume AudioContext only after a user gesture.
- Add a useWebAudioEngine hook for master gain, analyser, playback, and cleanup.
- Implement a waveform player, recording preview, input level meter, and notification tone.
- Keep recorded audio local with Blob URLs; do not upload it.
- Stop MediaStream tracks and disconnect AudioNodes on stop and unmount.
- Cancel requestAnimationFrame loops and revoke Blob URLs.
- Show clear UI states for autoplay blocking, microphone denial, and unsupported browsers.

Before editing, inspect the existing component structure, lint rules, and test setup.
After implementation, add Playwright or manual verification notes.

This is deliberately specific. Claude Code can still choose the exact component names, but it cannot ignore the lifecycle requirements. That matters because Web Audio API bugs often appear after the first success: the first recording works, but the microphone remains active; the first play works, but the second play reuses a dead source node; the first page load works on desktop, but mobile Safari keeps the context suspended.

React/TypeScript AudioContext Hook

Start by putting the audio graph into a hook. AudioContext is the entry point. GainNode controls volume. AnalyserNode gives you data for waveforms and meters. Keeping them in one hook makes cleanup and review much easier.

import { useCallback, useEffect, useRef, useState } from "react";

type WebKitWindow = Window & typeof globalThis & {
  webkitAudioContext?: typeof AudioContext;
};

export type AudioEngine = {
  context: AudioContext;
  masterGain: GainNode;
  analyser: AnalyserNode;
};

export function useWebAudioEngine() {
  const engineRef = useRef<AudioEngine | null>(null);
  const sourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
  const [state, setState] = useState<AudioContextState | "unsupported">("suspended");

  const createEngine = useCallback(() => {
    if (typeof window === "undefined") return null;
    if (engineRef.current) return engineRef.current;

    const AudioContextClass =
      window.AudioContext ?? (window as WebKitWindow).webkitAudioContext;

    if (!AudioContextClass) {
      setState("unsupported");
      return null;
    }

    const context = new AudioContextClass({ latencyHint: "interactive" });
    const masterGain = context.createGain();
    const analyser = context.createAnalyser();

    analyser.fftSize = 2048;
    analyser.smoothingTimeConstant = 0.82;
    masterGain.gain.value = 0.8;
    masterGain.connect(analyser);
    analyser.connect(context.destination);

    engineRef.current = { context, masterGain, analyser };
    setState(context.state);
    return engineRef.current;
  }, []);

  const resume = useCallback(async () => {
    const engine = createEngine();
    if (!engine) return null;

    if (engine.context.state === "suspended") {
      await engine.context.resume();
    }

    setState(engine.context.state);
    return engine;
  }, [createEngine]);

  const playBuffer = useCallback(
    async (url: string) => {
      const engine = await resume();
      if (!engine) return null;

      const response = await fetch(url);
      if (!response.ok) {
        throw new Error(`Failed to load audio: ${response.status}`);
      }

      const arrayBuffer = await response.arrayBuffer();
      const audioBuffer = await engine.context.decodeAudioData(arrayBuffer.slice(0));
      const source = engine.context.createBufferSource();

      source.buffer = audioBuffer;
      source.connect(engine.masterGain);
      source.start();
      sourcesRef.current.add(source);
      source.onended = () => {
        source.disconnect();
        sourcesRef.current.delete(source);
      };

      return source;
    },
    [resume],
  );

  const setVolume = useCallback((volume: number) => {
    const engine = createEngine();
    if (!engine) return;

    const safeVolume = Math.min(1, Math.max(0, volume));
    engine.masterGain.gain.setTargetAtTime(
      safeVolume,
      engine.context.currentTime,
      0.01,
    );
  }, [createEngine]);

  const stopAll = useCallback(() => {
    for (const source of sourcesRef.current) {
      try {
        source.stop();
      } catch {
        source.disconnect();
      }
    }
    sourcesRef.current.clear();
  }, []);

  useEffect(() => {
    return () => {
      stopAll();
      const engine = engineRef.current;
      if (!engine) return;

      engine.masterGain.disconnect();
      engine.analyser.disconnect();
      void engine.context.close();
      engineRef.current = null;
    };
  }, [stopAll]);

  return {
    state,
    resume,
    playBuffer,
    setVolume,
    stopAll,
    getEngine: () => engineRef.current,
  };
}

The important detail is that AudioBufferSourceNode is disposable. After you call start(), create a new source for the next playback. Claude Code should review this specifically, because source reuse is a common beginner mistake.

Draw a Waveform with AnalyserNode

The simplest visualizer is an oscilloscope-style waveform. It reads time-domain data from AnalyserNode and draws it to a canvas. This is easier to explain to beginners than frequency bars, and it immediately shows whether audio is moving through the graph.

import { useEffect, useRef } from "react";

type WaveformCanvasProps = {
  analyser: AnalyserNode | null;
  label?: string;
};

export function WaveformCanvas({
  analyser,
  label = "Audio waveform",
}: WaveformCanvasProps) {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !analyser) return;

    const canvasContext = canvas.getContext("2d");
    if (!canvasContext) return;

    const data = new Uint8Array(analyser.fftSize);
    let frameId = 0;

    const draw = () => {
      const rect = canvas.getBoundingClientRect();
      const dpr = window.devicePixelRatio || 1;
      const width = Math.max(320, Math.floor(rect.width * dpr));
      const height = Math.max(120, Math.floor(rect.height * dpr));

      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }

      analyser.getByteTimeDomainData(data);

      canvasContext.fillStyle = "#0f172a";
      canvasContext.fillRect(0, 0, width, height);
      canvasContext.lineWidth = Math.max(2, 2 * dpr);
      canvasContext.strokeStyle = "#22c55e";
      canvasContext.beginPath();

      const sliceWidth = width / data.length;
      for (let index = 0; index < data.length; index += 1) {
        const value = data[index] / 128;
        const y = (value * height) / 2;
        const x = index * sliceWidth;

        if (index === 0) canvasContext.moveTo(x, y);
        else canvasContext.lineTo(x, y);
      }

      canvasContext.lineTo(width, height / 2);
      canvasContext.stroke();
      frameId = window.requestAnimationFrame(draw);
    };

    draw();
    return () => window.cancelAnimationFrame(frameId);
  }, [analyser]);

  return (
    <canvas
      ref={canvasRef}
      aria-label={label}
      role="img"
      style={{ width: "100%", height: "180px", display: "block" }}
    />
  );
}

The fixed CSS height prevents layout jumps and horizontal overflow. The canvas backing resolution is still adjusted for the device pixel ratio, so the line stays sharp on mobile screens.

Recording Preview and Input Meter

Use MediaRecorder for the recording file and use Web Audio API for the live meter. That separation keeps the code understandable. It also prevents a serious mistake: connecting the microphone source to context.destination, which can create echo or feedback.

import { useCallback, useEffect, useRef, useState } from "react";
import type { AudioEngine } from "./useWebAudioEngine";

type RecorderPreviewProps = {
  resume: () => Promise<AudioEngine | null>;
};

function chooseMimeType() {
  const candidates = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4"];
  return candidates.find((type) => MediaRecorder.isTypeSupported(type)) ?? "";
}

export function RecorderPreview({ resume }: RecorderPreviewProps) {
  const recorderRef = useRef<MediaRecorder | null>(null);
  const streamRef = useRef<MediaStream | null>(null);
  const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
  const analyserRef = useRef<AnalyserNode | null>(null);
  const chunksRef = useRef<BlobPart[]>([]);
  const frameRef = useRef<number | null>(null);

  const [isRecording, setIsRecording] = useState(false);
  const [level, setLevel] = useState(0);
  const [previewUrl, setPreviewUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);

  const cleanupInput = useCallback(() => {
    if (frameRef.current !== null) {
      window.cancelAnimationFrame(frameRef.current);
      frameRef.current = null;
    }

    sourceRef.current?.disconnect();
    analyserRef.current?.disconnect();
    streamRef.current?.getTracks().forEach((track) => track.stop());

    sourceRef.current = null;
    analyserRef.current = null;
    streamRef.current = null;
    recorderRef.current = null;
    setLevel(0);
  }, []);

  const startMeter = useCallback((analyser: AnalyserNode) => {
    const data = new Uint8Array(analyser.fftSize);

    const tick = () => {
      analyser.getByteTimeDomainData(data);
      let sum = 0;

      for (const value of data) {
        const centered = (value - 128) / 128;
        sum += centered * centered;
      }

      const rms = Math.sqrt(sum / data.length);
      setLevel(Math.min(1, rms * 3));
      frameRef.current = window.requestAnimationFrame(tick);
    };

    tick();
  }, []);

  const startRecording = useCallback(async () => {
    try {
      setError(null);
      const engine = await resume();
      if (!engine) throw new Error("AudioContext is not available.");

      const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
      const source = engine.context.createMediaStreamSource(stream);
      const analyser = engine.context.createAnalyser();
      analyser.fftSize = 1024;

      source.connect(analyser);
      const mimeType = chooseMimeType();
      const recorder = new MediaRecorder(
        stream,
        mimeType ? { mimeType } : undefined,
      );

      chunksRef.current = [];
      recorder.ondataavailable = (event) => {
        if (event.data.size > 0) chunksRef.current.push(event.data);
      };
      recorder.onstop = () => {
        const blob = new Blob(chunksRef.current, {
          type: mimeType || "audio/webm",
        });
        const url = URL.createObjectURL(blob);
        setPreviewUrl((oldUrl) => {
          if (oldUrl) URL.revokeObjectURL(oldUrl);
          return url;
        });
        chunksRef.current = [];
        cleanupInput();
        setIsRecording(false);
      };

      streamRef.current = stream;
      sourceRef.current = source;
      analyserRef.current = analyser;
      recorderRef.current = recorder;
      recorder.start();
      startMeter(analyser);
      setIsRecording(true);
    } catch (recordingError) {
      cleanupInput();
      setIsRecording(false);
      setError(recordingError instanceof Error ? recordingError.message : "Recording failed.");
    }
  }, [cleanupInput, resume, startMeter]);

  const stopRecording = useCallback(() => {
    const recorder = recorderRef.current;
    if (recorder && recorder.state !== "inactive") recorder.stop();
    else cleanupInput();
  }, [cleanupInput]);

  useEffect(() => {
    return () => {
      cleanupInput();
      if (previewUrl) URL.revokeObjectURL(previewUrl);
    };
  }, [cleanupInput, previewUrl]);

  return (
    <section aria-label="Recorder preview">
      <div>
        <button type="button" onClick={startRecording} disabled={isRecording}>
          Record
        </button>
        <button type="button" onClick={stopRecording} disabled={!isRecording}>
          Stop
        </button>
      </div>

      <meter min={0} max={1} value={level} aria-label="input level" />
      {error && <p role="alert">{error}</p>}
      {previewUrl && (
        <audio controls src={previewUrl} aria-label="recording preview" />
      )}
    </section>
  );
}

This example keeps the recording local. If your product uploads audio, add consent copy, retention rules, deletion behavior, and server validation. A voice learning tool can start with local preview only; a support tool that stores recordings needs a much stricter privacy review.

Notification Sound and Cleanup Helpers

Short notification sounds do not need an audio file. OscillatorNode can generate a tone, while GainNode fades it in and out to avoid clicks.

export async function playNotificationTone(context: AudioContext) {
  if (context.state === "suspended") {
    await context.resume();
  }

  const oscillator = context.createOscillator();
  const gain = context.createGain();
  const now = context.currentTime;

  oscillator.type = "sine";
  oscillator.frequency.setValueAtTime(880, now);
  gain.gain.setValueAtTime(0.0001, now);
  gain.gain.exponentialRampToValueAtTime(0.2, now + 0.02);
  gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.18);

  oscillator.connect(gain);
  gain.connect(context.destination);
  oscillator.start(now);
  oscillator.stop(now + 0.2);

  oscillator.onended = () => {
    oscillator.disconnect();
    gain.disconnect();
  };
}

export function disconnectSafely(nodes: Array<AudioNode | null>) {
  for (const node of nodes) {
    try {
      node?.disconnect();
    } catch {
      // The node may already be disconnected.
    }
  }
}

export function stopMediaStream(stream: MediaStream | null) {
  stream?.getTracks().forEach((track) => track.stop());
}

Ask Claude Code to review effect sounds too. Tiny sound effects are easy to forget because they do not look like a full player, but they still create nodes and still need to be disconnected.

Pitfalls to Catch Before Publishing

The first pitfall is creating or resuming AudioContext before a user gesture. Browsers commonly block audio that starts outside a click, tap, or key interaction. Do not hide resume() in a mount effect. Call it from the Play, Record, Start lesson, or Enable sound button.

The second pitfall is treating autoplay blocking as a bug instead of a product state. If the context remains suspended, the UI should say what the user can do next. A muted page that asks for a tap is better than a page that silently fails.

The third pitfall is leaking audio resources. AudioNode.disconnect(), MediaStreamTrack.stop(), cancelAnimationFrame(), URL.revokeObjectURL(), and AudioContext.close() should appear in the implementation or in shared cleanup helpers. Review this after navigation, not only after clicking Stop.

The fourth pitfall is microphone privacy. A recorder preview is not just another input field. Explain whether the recording stays local, whether it is uploaded, how long it is stored, and how the user can delete it. That matters for learning tools, support dashboards, interview tools, and internal training apps.

The fifth pitfall is mobile latency. latencyHint: "interactive" helps, but Bluetooth headphones, low-power mode, and browser differences still matter. If you are building a rhythm game or a pronunciation scorer, define the acceptable delay before you polish the waveform.

Playwright and Manual Verification

Automated tests can verify state changes and DOM behavior. They cannot fully prove that real speakers, microphones, and mobile hardware feel right. Use Playwright for the interaction path, then run manual checks on real devices.

import { chromium, expect, test } from "@playwright/test";

test("audio demo starts after a gesture and creates a recording preview", async () => {
  const browser = await chromium.launch({
    args: [
      "--use-fake-device-for-media-stream",
      "--use-fake-ui-for-media-stream",
    ],
  });
  const context = await browser.newContext({ permissions: ["microphone"] });
  const page = await context.newPage();

  await page.goto("http://localhost:5173/audio-demo");
  await page.getByRole("button", { name: /start audio/i }).click();
  await expect(page.getByTestId("audio-state")).toContainText(/running|ready/i);

  await page.getByRole("button", { name: /record/i }).click();
  await page.waitForTimeout(600);
  await page.getByRole("button", { name: /stop/i }).click();
  await expect(page.getByLabel("recording preview")).toBeVisible();

  await browser.close();
});

Manual checks should cover Chrome, Safari, iOS Safari, and Android Chrome. Confirm that the first tap starts audio, microphone denial shows a useful message, the browser’s microphone indicator disappears after stop or navigation, the waveform does not create horizontal scroll, and notification sounds are not too loud with headphones. For performance review, combine this with Claude Code performance optimization.

Review Prompt for Claude Code

After implementation, use Claude Code as a reviewer, not only as the code generator. This prompt forces it to inspect the audio lifecycle.

Review this React + TypeScript Web Audio API implementation.

Focus on:
- AudioContext is created or resumed only after a user gesture.
- AudioBufferSourceNode is not reused after start().
- MediaStream tracks stop on recording stop and component unmount.
- AudioNodes, requestAnimationFrame loops, and Blob URLs are released.
- Microphone input is not accidentally connected to destination.
- Autoplay blocking, mobile behavior, and permission denial have UI states.
- Automated checks and manual checks are separated clearly.

Return findings by severity with file, line, reason, and suggested fix.

The official Claude Code common workflows are useful when turning this into a repeatable team workflow. If your organization uses automated PR review, put these audio-specific rules in CLAUDE.md or REVIEW.md so they are not lost after the first implementation.

ClaudeCodeLab CTA

Web Audio API demos are fast to build, but production audio features touch privacy, accessibility, analytics, mobile testing, and product flow. ClaudeCodeLab can help your team set up Claude Code rules, review prompts, Playwright checks, and repository-specific audio UI patterns through Claude Code training and consultation.

For a solo project, build the waveform player and recorder preview first. For a team project, review consent copy, retention policy, event tracking, and support paths before adding more effects. The code is only one layer of an audio feature.

What We Verified

Masa tested this structure in a small React demo. Centralizing AudioContext and cleanup inside useWebAudioEngine made later additions, such as the recorder preview and notification tone, much easier to review. The version where every component created its own context was more fragile: navigation left microphone indicators active, repeated play clicks made source ownership unclear, and mobile autoplay failures were harder to explain. The useful Claude Code instruction was not just “build audio UI”; it was “review connection cleanup and permission errors.”

#Claude Code #Web Audio API #audio processing #visualizer #TypeScript
Free

Free PDF: Claude Code Cheatsheet

Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.

We handle your data with care and never send spam.

Level up your Claude Code workflow

Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.

Masa

About the Author

Masa

Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.