用 Claude Code 实现 Web Audio API:波形、录音和音量表
用 Claude Code 和 React/TypeScript 实现 Web Audio API:波形播放器、录音预览、音量表、清理和测试。
先确定要做的音频体验
Web Audio API 是浏览器里的音频图 API。你可以把声音加载进来,连接到不同的节点,调整音量,分析波形,再输出到扬声器。普通播放可以用 audio 元素完成,但如果要做波形播放器、录音预览、输入音量表、通知音、效果音,或者发音学习工具,就需要 AudioContext、GainNode、AnalyserNode、MediaRecorder 这些能力。
Claude Code 的价值不只是生成一段代码。真正困难的是把音频功能放进现有 React 项目,同时处理 SSR、自动播放限制、麦克风权限、节点释放、移动端延迟和可访问性。如果只让 Claude Code “写一个播放器”,它可能会给出能跑的 demo,却漏掉内存泄漏和权限失败状态。
本文把实现拆成一个完整工作流:先给 Claude Code 清晰指令,再实现 React/TypeScript 的 AudioContext hook、AnalyserNode 波形、录音预览、音量表、通知音、清理代码、Playwright 检查和人工检查。官方资料建议同时阅读 MDN Web Audio API、MDN 自动播放指南 和 MediaRecorder。如果要做完整播放器 UI,也可以参考站内的音频播放器指南。
五个实用场景
在写代码前,先判断你要解决的场景。不同场景需要的音频图不同,审查重点也不同。
| 场景 | 主要 API | Claude Code 要实现什么 | 常见风险 |
|---|---|---|---|
| 波形播放器 | AudioContext, AudioBufferSourceNode, AnalyserNode | 播放、停止、波形、主音量 | 复用已经 start() 过的 source |
| 录音预览 | getUserMedia, MediaRecorder, MediaStreamAudioSourceNode | 录音、停止、本地 Blob URL、试听 | 忘记停止麦克风 track |
| 音量表 | AnalyserNode.getByteTimeDomainData | RMS 计算和实时 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.md 或 REVIEW.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”,而是“审查连接释放和权限错误”。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
Claude Code权限安全阶梯:逐步放开访问而不失控
从只读到有限编辑、验证命令和部署检查的 Claude Code 权限升级流程。
Claude Code 小PR证据包:让小改动真正可审查
用差异、验证命令、公开URL、CTA路径和回滚说明,把Claude Code的小PR变得可审查。
Claude Code 提交前 Review Gate:同时检查差异、测试、公开 URL 和 CTA
提交前用 Claude Code 审查差异范围、build、公开 URL、Gumroad 链接、咨询 CTA、缺少测试和无关文件。