Tips & Tricks (更新: 2026/6/2)

用 Claude Code 实现 Web Audio API:波形、录音和音量表

用 Claude Code 和 React/TypeScript 实现 Web Audio API:波形播放器、录音预览、音量表、清理和测试。

用 Claude Code 实现 Web Audio API:波形、录音和音量表

先确定要做的音频体验

Web Audio API 是浏览器里的音频图 API。你可以把声音加载进来,连接到不同的节点,调整音量,分析波形,再输出到扬声器。普通播放可以用 audio 元素完成,但如果要做波形播放器、录音预览、输入音量表、通知音、效果音,或者发音学习工具,就需要 AudioContextGainNodeAnalyserNodeMediaRecorder 这些能力。

Claude Code 的价值不只是生成一段代码。真正困难的是把音频功能放进现有 React 项目,同时处理 SSR、自动播放限制、麦克风权限、节点释放、移动端延迟和可访问性。如果只让 Claude Code “写一个播放器”,它可能会给出能跑的 demo,却漏掉内存泄漏和权限失败状态。

本文把实现拆成一个完整工作流:先给 Claude Code 清晰指令,再实现 React/TypeScript 的 AudioContext hook、AnalyserNode 波形、录音预览、音量表、通知音、清理代码、Playwright 检查和人工检查。官方资料建议同时阅读 MDN Web Audio APIMDN 自动播放指南MediaRecorder。如果要做完整播放器 UI,也可以参考站内的音频播放器指南

五个实用场景

在写代码前,先判断你要解决的场景。不同场景需要的音频图不同,审查重点也不同。

场景主要 APIClaude Code 要实现什么常见风险
波形播放器AudioContext, AudioBufferSourceNode, AnalyserNode播放、停止、波形、主音量复用已经 start() 过的 source
录音预览getUserMedia, MediaRecorder, MediaStreamAudioSourceNode录音、停止、本地 Blob URL、试听忘记停止麦克风 track
音量表AnalyserNode.getByteTimeDomainDataRMS 计算和实时 meter没有停止 requestAnimationFrame
通知音和效果音OscillatorNode, GainNode短音、淡入淡出、防止爆音用户操作前就播放
语音学习工具播放 hook、录音、区间循环跟读、发音对比、自我复盘录音同意和保存规则不清楚

把场景说清楚之后,Claude Code 更容易写出正确边界。波形播放器不需要麦克风权限;录音预览不应该把麦克风输入接到扬声器;通知音应该短、默认音量低,并且结束后断开节点。

给 Claude Code 的实现指令

下面的 prompt 适合在修改前使用。它要求 Claude Code 先读项目结构,再实现功能和验证说明。

请在 React + TypeScript 中实现一个 Web Audio API demo。

要求:
- AudioContext 只能在用户点击或触摸后创建或 resume。
- 新增 useWebAudioEngine hook,管理 master gain、analyser、播放和 cleanup。
- 实现波形播放器、录音预览、输入音量表和通知音。
- 录音只保存在本地 Blob URL,不上传到服务器。
- 停止录音和组件 unmount 时,停止 MediaStream tracks 并断开 AudioNodes。
- 取消 requestAnimationFrame 循环,并 revoke Blob URL。
- 对自动播放限制、拒绝麦克风权限、不支持浏览器给出 UI 状态。

修改前请先检查现有组件结构、lint 规则和测试环境。
实现后请补充 Playwright 或手动检查步骤。

这个指令比“做一个音频播放器”更可靠,因为它把生命周期也写进了任务。Web Audio API 的错误通常不是第一次点击时暴露,而是第二次播放、页面跳转、权限拒绝或移动端打开时才出现。

React/TypeScript AudioContext Hook

先把音频图的基础放进 hook。AudioContext 是入口,GainNode 控制音量,AnalyserNode 提供波形和音量表数据。集中在 hook 中,后续清理和审查都更简单。

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,
  };
}

这里最重要的是每次播放都重新创建 AudioBufferSourceNode。这个节点调用 start() 后不能再次使用。让 Claude Code 审查“是否复用 source”,可以避免很多初学者错误。

用 AnalyserNode 绘制波形

波形可视化可以先从时间域数据开始。它不是频谱图,而是把音频振幅按时间画出来,适合用来确认声音是否真的经过音频图。

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" }}
    />
  );
}

Canvas 的 CSS 宽度固定为 100%,高度固定为 180px,可以减少横向溢出和布局跳动。实际像素再根据 devicePixelRatio 调整,移动端也会更清晰。

录音预览和输入音量表

录音文件建议交给 MediaRecorder,Web Audio API 负责输入分析。这样代码更清楚,也可以避免把麦克风输入接回扬声器造成回声。

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>
  );
}

这个示例只生成本地预览。如果产品要上传录音,必须补充同意文案、保存期限、删除方式和服务端校验。对于发音练习工具,本地试听通常已经能覆盖最初版本。

通知音和清理工具

短通知音可以用 OscillatorNode 生成,不一定要准备音频文件。使用 GainNode 做很短的淡入淡出,可以避免点击爆音。

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());
}

通知音很小,但也会创建节点。让 Claude Code 检查 onended 里的断开逻辑,可以减少页面反复进入后残留资源的问题。

发布前要抓住的坑

第一,不能在用户操作前启动 AudioContext。很多浏览器会拦截没有点击、触摸或按键触发的音频播放。不要在 useEffect 页面加载时直接 resume(),而是放在播放、录音、开始练习之类的按钮事件里。

第二,自动播放限制不是单纯的技术错误,而是 UI 状态。context.state 仍然是 suspended 时,界面要告诉用户下一步是点击或触摸开始。

第三,资源泄漏很常见。请检查 AudioNode.disconnect()MediaStreamTrack.stop()cancelAnimationFrame()URL.revokeObjectURL()AudioContext.close() 是否在停止和 unmount 时执行。

第四,录音涉及隐私。即使只是练习发音,也要说明录音是否仅留在本地、是否上传、保存多久、如何删除。内容站、教学工具、客服后台都不能忽略这点。

第五,移动端延迟不能完全靠代码消除。latencyHint: "interactive" 有帮助,但蓝牙耳机、省电模式、系统浏览器差异都会影响体验。如果是节奏游戏或严格打分工具,需要先定义可接受延迟。

Playwright 和手动检查

自动化测试可以验证按钮流程和 DOM 状态,但不能完全证明真实设备上的声音体验。用 Playwright 覆盖交互路径,再用真机检查声音、权限和延迟。

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();
});

手动检查至少覆盖 Chrome、Safari、iOS Safari 和 Android Chrome。确认首次点击能启动音频,拒绝麦克风时有说明,停止或跳转后浏览器的麦克风指示消失,波形不会造成横向滚动,耳机下通知音不会过大。性能方面可以结合性能优化指南继续检查。

Claude Code 审查指令

实现后,把 Claude Code 当成审查者使用,而不是继续让它追加功能。

请审查这个 React + TypeScript Web Audio API 实现。

重点:
- AudioContext 是否只在用户操作后创建或 resume。
- AudioBufferSourceNode 是否在 start() 后被复用。
- 录音停止和组件 unmount 时是否停止 MediaStream tracks。
- AudioNodes、requestAnimationFrame、Blob URLs 是否释放。
- 麦克风输入是否被意外连接到 destination。
- 自动播放限制、移动端、权限拒绝是否有 UI 状态。
- 自动检查和手动检查是否清楚分开。

请按严重程度返回文件、行号、原因和修复建议。

Claude Code 的官方常见工作流也适合团队化使用。把这些音频审查规则写进 CLAUDE.mdREVIEW.md,下次修改播放器时就不会丢失。

ClaudeCodeLab 咨询和训练

Web Audio API 的 demo 很快能做出来,但生产功能会牵涉隐私、可访问性、移动端测试、分析事件和产品导线。ClaudeCodeLab 可以通过 Claude Code training / consultation 帮团队整理 Claude Code 规则、review prompt、Playwright 检查和仓库专用的音频 UI 模式。

个人项目可以先完成波形播放器和录音预览。团队项目则应该同时审查同意文案、录音数据规则、事件埋点和客服路径。音频功能不是一段代码,而是一组用户信任设计。

实际验证记录

Masa 用一个小型 React demo 试过这套结构。把 AudioContext 和 cleanup 集中到 useWebAudioEngine 后,后续增加录音预览、音量表和通知音更容易审查。相反,每个组件都自行 new AudioContext() 的版本更脆弱:页面跳转后麦克风指示可能残留,重复点击播放后 source 归属不清楚,移动端自动播放失败也更难解释。最有效的 Claude Code 指令不是“做音频 UI”,而是“审查连接释放和权限错误”。

#Claude Code #Web Audio API #音频处理 #可视化 #TypeScript
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。