Web Audio API com Claude Code: onda, gravação e medidor de volume
Implemente Web Audio API com Claude Code: onda, gravação, medidor, cleanup e testes em React/TypeScript.
Comece pela experiência de áudio
A Web Audio API permite montar um grafo de áudio no navegador. Você carrega som, conecta nós, altera volume, analisa o sinal e envia para a saída. Para tocar um arquivo simples, o elemento audio costuma bastar. Você precisa da Web Audio API quando o produto exige player com forma de onda, preview de gravação, medidor de entrada, som de notificação, efeitos ou uma ferramenta de estudo de voz.
Claude Code ajuda porque a dificuldade real não é copiar um snippet. A dificuldade é integrar áudio em um app React existente sem quebrar SSR, regras de autoplay, permissões de microfone, cleanup de nós, acessibilidade e comportamento mobile. Quando Claude Code lê componentes, rotas, testes e regras do repositório, ele consegue implementar e revisar com o contexto correto.
Este guia mostra o fluxo completo: prompt para Claude Code, hook de AudioContext em React/TypeScript, onda com AnalyserNode, preview de gravação, medidor de volume, som de notificação, cleanup, Playwright e inspeção manual. Consulte também as referências oficiais MDN Web Audio API, guia de autoplay e MediaRecorder. Para um player completo, veja o artigo interno como construir um player de áudio com Claude Code.
Cinco casos de uso práticos
Antes de escrever código, defina o caso de uso. Cada experiência usa nós diferentes e tem riscos diferentes.
| Caso de uso | APIs principais | O que Claude Code deve implementar | Risco principal |
|---|---|---|---|
| Player com onda | AudioContext, AudioBufferSourceNode, AnalyserNode | tocar, parar, desenhar onda, volume mestre | reutilizar source depois de start() |
| Preview de gravação | getUserMedia, MediaRecorder, MediaStreamAudioSourceNode | gravar, parar, Blob URL local, ouvir | deixar tracks do microfone ativos |
| Medidor de volume | AnalyserNode.getByteTimeDomainData | cálculo RMS e meter ao vivo | não parar requestAnimationFrame |
| Notificações e efeitos | OscillatorNode, GainNode | tons curtos, fade, evitar click | tocar antes de gesto do usuário |
| Ferramenta de voz | playback hook, recorder, loop | shadowing, comparação, revisão | consentimento e retenção pouco claros |
Essa tabela impede que a implementação fique genérica demais. Um player com onda não precisa pedir microfone. Um preview de gravação não deve enviar o microfone para os alto-falantes. Um som de notificação deve ser curto, baixo por padrão e desconectado no fim.
Prompt para Claude Code
Use um prompt com limites. Ele deve mencionar gesto do usuário, gravação local, cleanup e verificação.
Implemente uma demo de Web Audio API com React + TypeScript.
Requisitos:
- Criar ou retomar AudioContext somente após uma ação do usuário.
- Adicionar um hook useWebAudioEngine para master gain, analyser, playback e cleanup.
- Implementar player com onda, preview de gravação, medidor de entrada e som de notificação.
- Manter o áudio gravado como Blob URL local; não enviar para o servidor.
- Parar MediaStream tracks e desconectar AudioNodes ao parar e ao desmontar.
- Cancelar requestAnimationFrame e revogar Blob URLs.
- Mostrar estados para autoplay bloqueado, permissão de microfone negada e browser sem suporte.
Antes de editar, revise a estrutura de componentes, lint e testes existentes.
Depois de implementar, adicione notas de verificação com Playwright ou manual.
Esse nível de detalhe evita demos frágeis. Os bugs de Web Audio API costumam aparecer na segunda reprodução, na navegação, no mobile ou quando o usuário nega a permissão de microfone.
Hook React/TypeScript para AudioContext
Coloque o grafo básico em um hook. AudioContext é a entrada, GainNode controla volume e AnalyserNode fornece dados para onda e medidor. Com tudo em um hook, cleanup e revisão ficam mais simples.
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,
};
}
O detalhe essencial é criar um novo AudioBufferSourceNode para cada reprodução. Depois de start(), esse nó não pode ser usado de novo. Peça ao Claude Code para revisar isso explicitamente.
Desenhar onda com AnalyserNode
A visualização mais simples usa dados no domínio do tempo. Ela mostra a amplitude do sinal, não frequências. Para iniciantes, é fácil de entender e ajuda a ver se o áudio está passando pelo grafo.
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" }}
/>
);
}
A altura fixa evita saltos de layout e overflow horizontal. A resolução interna usa devicePixelRatio, então a linha continua nítida em telas móveis.
Preview de gravação e medidor
Use MediaRecorder para o arquivo gravado e Web Audio API para analisar a entrada. Essa separação torna o código mais claro e evita conectar o microfone à saída por engano.
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>
);
}
O exemplo mantém o áudio local. Se o produto enviar gravações, inclua texto de consentimento, prazo de retenção, exclusão e validação no servidor. Para estudo de pronúncia, o preview local costuma ser suficiente no primeiro release.
Som de notificação e cleanup
Um som curto pode ser criado com OscillatorNode. O GainNode faz fade in/out para evitar 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());
}
Peça revisão também para efeitos pequenos. Eles parecem simples, mas ainda criam nós e podem ficar vivos depois de navegação repetida.
Armadilhas antes de publicar
A primeira armadilha é iniciar AudioContext antes de uma ação do usuário. Muitos browsers bloqueiam áudio sem click, toque ou teclado. Evite resume() dentro de useEffect no carregamento; use o handler de Play, Record ou Start.
A segunda é não tratar autoplay como estado de produto. Se context.state continua suspended, a interface deve dizer que o usuário precisa tocar para ativar o som. Falha silenciosa parece bug.
A terceira é vazamento de recursos. Verifique AudioNode.disconnect(), MediaStreamTrack.stop(), cancelAnimationFrame(), URL.revokeObjectURL() e AudioContext.close(). Teste depois de navegar para fora da tela.
A quarta é privacidade. Gravação pode conter dados pessoais. Explique se fica local, se será enviada, por quanto tempo será mantida e como o usuário pode apagar.
A quinta é latência mobile. latencyHint: "interactive" ajuda, mas Bluetooth, modo economia e diferenças de navegador continuam. Para jogo rítmico ou pontuação de pronúncia, defina a latência aceitável antes de polir a UI.
Playwright e inspeção manual
Playwright valida o fluxo de UI. Som real, eco, volume e atraso precisam de dispositivos reais.
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();
});
Na inspeção manual, cubra Chrome, Safari, iOS Safari e Android Chrome. Confirme que o primeiro toque inicia o áudio, que a recusa de microfone mostra uma mensagem útil, que o indicador de microfone desaparece ao parar ou navegar, que a onda não cria scroll horizontal e que a notificação não fica alta demais com fones. Para performance, combine com otimização de performance com Claude Code.
Prompt de revisão para Claude Code
Depois de implementar, use Claude Code como revisor.
Revise esta implementação de Web Audio API em React + TypeScript.
Foco:
- AudioContext é criado ou retomado apenas após ação do usuário.
- AudioBufferSourceNode não é reutilizado após start().
- MediaStream tracks param ao encerrar gravação e no unmount.
- AudioNodes, requestAnimationFrame e Blob URLs são liberados.
- Entrada do microfone não está conectada por acidente a destination.
- Autoplay, mobile e permissão negada têm estados de UI.
- Verificações automáticas e manuais estão separadas claramente.
Retorne achados por severidade com arquivo, linha, motivo e sugestão de correção.
Os common workflows do Claude Code ajudam a tornar essa revisão repetível. Coloque as regras em CLAUDE.md ou REVIEW.md.
CTA ClaudeCodeLab
Uma demo de Web Audio API nasce rápido, mas produção envolve privacidade, acessibilidade, analytics, testes mobile e fluxo de produto. ClaudeCodeLab pode ajudar com regras de Claude Code, prompts de revisão, Playwright e padrões de áudio específicos do seu repositório em Claude Code training and consultation.
Em projeto individual, comece pelo player com onda e preview de gravação. Em equipe, revise consentimento, retenção de dados, eventos de analytics e caminho de suporte antes de adicionar mais efeitos.
Nota de verificação
Masa testou essa estrutura em uma pequena demo React. Centralizar AudioContext e cleanup em useWebAudioEngine facilitou adicionar preview de gravação, medidor e som de notificação sem perder o controle do ciclo de vida. A versão em que cada componente fazia new AudioContext() era mais frágil: após navegação, o indicador do microfone podia ficar ativo; clicks repetidos confundiam ownership de sources; e falhas de autoplay no mobile eram difíceis de explicar. A instrução útil para Claude Code foi “revise cleanup de conexões e erros de permissão”, não apenas “crie uma UI de áudio”.
PDF grátis: cheatsheet do Claude Code
Informe seu e-mail e baixe uma página com comandos, hábitos de revisão e workflows seguros.
Cuidamos dos seus dados e não enviamos spam.
Sobre o autor
Masa
Engenheiro focado em workflows práticos com Claude Code.
Artigos relacionados
Escada de segurança de permissões no Claude Code
Amplie de read-only para edições limitadas, comandos de prova e deploy checks sem perder controle.
Claude Code Small PR Proof Pack: pequenas mudanças fáceis de revisar
Um pacote de prova para PRs do Claude Code: diff, checks, URL pública, CTA e rollback.
Gate de revisão antes do commit com Claude Code
Revisão antes do commit com Claude Code: diff, build, URL pública, Gumroad, consultoria, testes e arquivos fora do escopo.