Web Audio API con Claude Code: ondas, grabación y medidor de volumen
Implementa Web Audio API con Claude Code: onda, grabación, medidor, cleanup y pruebas en React/TypeScript.
Empieza por la experiencia de audio
La Web Audio API permite construir un grafo de audio dentro del navegador: cargar un sonido, conectarlo a nodos, medirlo, cambiar su volumen y enviarlo a la salida. Para reproducir un archivo sencillo, el elemento audio suele bastar. Necesitas Web Audio API cuando el producto pide un reproductor con forma de onda, una vista previa de grabación, un medidor de entrada, sonidos de notificación, efectos o una herramienta de aprendizaje de voz.
Claude Code ayuda porque el problema real no es copiar un snippet aislado. El problema es integrar audio en una aplicación React existente sin romper SSR, reglas de autoplay, permisos de micrófono, limpieza de nodos, accesibilidad ni comportamiento móvil. Si Claude Code lee tus componentes, rutas, tests y reglas del repositorio, puede implementar el grafo y revisarlo con el mismo contexto.
Esta guía muestra un flujo completo: prompt para Claude Code, hook de AudioContext en React/TypeScript, onda con AnalyserNode, grabación con vista previa, medidor de volumen, sonido de notificación, limpieza, verificación con Playwright y revisión manual. Mantén abiertas las referencias oficiales de MDN Web Audio API, autoplay en medios y Web Audio y MediaRecorder. Para una UI de player más amplia, lee también cómo construir un audio player con Claude Code.
Cinco casos de uso prácticos
Antes de pedir código, separa el caso de uso. Cada experiencia necesita nodos distintos y una lista de revisión distinta.
| Caso de uso | APIs principales | Qué debe implementar Claude Code | Riesgo principal |
|---|---|---|---|
| Reproductor con onda | AudioContext, AudioBufferSourceNode, AnalyserNode | reproducir, detener, onda, volumen maestro | reutilizar un source después de start() |
| Vista previa de grabación | getUserMedia, MediaRecorder, MediaStreamAudioSourceNode | grabar, detener, crear Blob URL local, escuchar | dejar tracks del micrófono activos |
| Medidor de volumen | AnalyserNode.getByteTimeDomainData | cálculo RMS y meter en vivo | no detener requestAnimationFrame |
| Notificaciones y efectos | OscillatorNode, GainNode | tonos cortos, fade, prevención de clicks | sonar antes de una acción del usuario |
| Herramienta de voz | hook de reproducción, grabación, loop | shadowing, comparación, autoevaluación | reglas de consentimiento y retención poco claras |
Esta tabla evita que el artículo se convierta en una colección genérica de trucos. Un reproductor con onda no necesita permiso de micrófono. Una vista previa de grabación no debe conectar el micrófono a los altavoces. Un sonido de notificación debe terminar rápido, tener volumen bajo por defecto y desconectar sus nodos.
Prompt para Claude Code
Usa un prompt con límites claros. Debe incluir gesto de usuario, alcance de grabación, limpieza y verificación.
Implementa una demo de Web Audio API con React + TypeScript.
Requisitos:
- Crear o reanudar AudioContext solo después de una acción del usuario.
- Añadir un hook useWebAudioEngine para master gain, analyser, playback y cleanup.
- Implementar reproductor con onda, vista previa de grabación, medidor de entrada y sonido de notificación.
- Mantener el audio grabado como Blob URL local; no subirlo al servidor.
- Detener MediaStream tracks y desconectar AudioNodes al parar y al desmontar.
- Cancelar requestAnimationFrame y revocar Blob URLs.
- Mostrar estados para bloqueo de autoplay, permiso de micrófono denegado y navegador no soportado.
Antes de editar, revisa la estructura de componentes, lint y tests existentes.
Después de implementar, añade notas de verificación con Playwright o manuales.
El detalle importa. Los fallos de Web Audio API aparecen después del primer éxito: la segunda reproducción no suena porque se reutilizó un source, el micrófono queda encendido al cambiar de pantalla, o iOS mantiene el contexto suspendido porque no hubo una acción clara.
Hook React/TypeScript para AudioContext
El primer paso es encerrar el grafo básico en un hook. AudioContext es la entrada, GainNode controla volumen y AnalyserNode entrega datos para ondas y medidores. Centralizarlo reduce errores de limpieza.
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,
};
}
La parte crítica es crear un AudioBufferSourceNode nuevo en cada reproducción. Ese nodo no se reutiliza después de start(). Pide a Claude Code que lo revise de forma explícita.
Dibujar una onda con AnalyserNode
La visualización más simple usa datos de dominio temporal. No es un ecualizador de frecuencias; es una línea que muestra la amplitud del audio y permite confirmar que el sonido fluye por el 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" }}
/>
);
}
La altura fija del canvas evita saltos visuales y desbordes horizontales. La resolución real se ajusta con devicePixelRatio, así que la línea se mantiene nítida en pantallas móviles.
Grabación, vista previa y medidor
Para crear el archivo grabado, usa MediaRecorder. Usa Web Audio API para analizar la entrada y mostrar el medidor. Separar ambas responsabilidades reduce errores y evita conectar el micrófono a la salida.
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>
);
}
El ejemplo mantiene la grabación local. Si tu producto sube audio, añade consentimiento, tiempo de retención, borrado y validación de servidor. Para una herramienta de pronunciación, la primera versión puede limitarse a la vista previa local.
Sonido de notificación y limpieza
Un tono corto puede generarse con OscillatorNode. El GainNode hace fade para evitar un click fuerte al inicio o al final.
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());
}
Pide a Claude Code que revise también los efectos pequeños. Parecen inofensivos, pero crean nodos y pueden quedar vivos después de navegar por la aplicación.
Errores comunes que debes detectar
El primer error es iniciar AudioContext antes de una acción del usuario. Muchos navegadores bloquean audio que empieza sin click, toque o teclado. Evita llamar resume() en useEffect al cargar la página; hazlo desde el botón de reproducción o grabación.
El segundo error es no mostrar el estado de autoplay. Si el contexto sigue suspended, la interfaz debe explicar que el usuario debe tocar para activar el sonido. Un fallo silencioso parece un bug roto.
El tercer error es perder recursos. Revisa AudioNode.disconnect(), MediaStreamTrack.stop(), cancelAnimationFrame(), URL.revokeObjectURL() y AudioContext.close(). Haz la prueba después de navegar fuera de la pantalla, no solo después de pulsar Stop.
El cuarto error es tratar la grabación como un input normal. El audio puede contener datos personales. Explica si queda local, si se sube, cuánto se guarda y cómo se elimina.
El quinto error es ignorar la latencia móvil. latencyHint: "interactive" ayuda, pero auriculares Bluetooth, ahorro de batería y diferencias entre navegadores siguen afectando. Si haces un juego rítmico o una herramienta de pronunciación con puntuación, define la latencia aceptable antes de pulir la visualización.
Verificación con Playwright y manual
Playwright sirve para confirmar el flujo de UI. El sonido real, el eco, el volumen y la latencia deben revisarse en dispositivos reales.
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();
});
La revisión manual debería cubrir Chrome, Safari, iOS Safari y Android Chrome. Comprueba que el primer toque activa el audio, que el rechazo de micrófono muestra un mensaje útil, que el indicador del micrófono desaparece al parar o navegar, que la onda no provoca scroll horizontal y que el sonido de notificación no es demasiado fuerte con auriculares. Para rendimiento, conecta este trabajo con optimización de performance en Claude Code.
Prompt de revisión para Claude Code
Después de implementar, usa Claude Code como revisor.
Revisa esta implementación de Web Audio API en React + TypeScript.
Enfócate en:
- AudioContext se crea o resume solo después de una acción del usuario.
- AudioBufferSourceNode no se reutiliza después de start().
- MediaStream tracks se detienen al parar la grabación y al desmontar.
- AudioNodes, requestAnimationFrame y Blob URLs se liberan.
- La entrada del micrófono no se conecta por accidente a destination.
- Autoplay, móvil y permisos denegados tienen estados de UI.
- Las pruebas automáticas y manuales están separadas claramente.
Devuelve hallazgos por severidad con archivo, línea, razón y propuesta de arreglo.
Los flujos comunes de Claude Code ayudan a convertir esta revisión en práctica de equipo. Guarda estas reglas en CLAUDE.md o REVIEW.md para que no se pierdan después del primer desarrollo.
CTA de ClaudeCodeLab
Una demo de Web Audio API se construye rápido, pero una función de producción toca privacidad, accesibilidad, analítica, pruebas móviles y flujo de producto. ClaudeCodeLab puede ayudar a definir reglas de Claude Code, prompts de revisión, pruebas Playwright y patrones de UI de audio para tu repositorio mediante Claude Code training and consultation.
En un proyecto personal, empieza por el reproductor con onda y la vista previa de grabación. En un equipo, revisa también consentimiento, retención de datos, eventos de analytics y rutas de soporte antes de añadir más efectos.
Nota de verificación
Masa probó esta estructura en una pequeña demo de React. Centralizar AudioContext y cleanup en useWebAudioEngine hizo más fácil añadir grabación, medidor y sonido de notificación sin perder el control del ciclo de vida. La versión en la que cada componente hacía new AudioContext() era más frágil: al navegar quedaban indicadores de micrófono, los clicks repetidos mezclaban ownership de sources y los fallos de autoplay en móvil eran difíciles de explicar. La instrucción útil para Claude Code fue: “revisa limpieza de conexiones y errores de permisos”, no solo “crea una UI de audio”.
PDF gratis: cheatsheet de Claude Code
Introduce tu email y descarga una hoja con comandos, hábitos de revisión y flujos seguros.
Cuidamos tus datos y no enviamos spam.
Sobre el autor
Masa
Ingeniero enfocado en workflows prácticos con Claude Code.
Artículos relacionados
Escalera de permisos de Claude Code para ampliar acceso sin perder control
Pasa de read-only a ediciones limitadas, comandos de prueba y checks de deploy con menos riesgo.
Claude Code Small PR Proof Pack: cambios pequeños que sí se pueden revisar
Un paquete de prueba para PRs de Claude Code: diff, checks, URL pública, CTA y rollback.
Gate de revisión antes del commit con Claude Code
Cómo revisar con Claude Code antes del commit: diff, build, URL pública, Gumroad, consultoría, tests y archivos ajenos.