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

Web Audio API dengan Claude Code: waveform, rekaman, dan volume meter

Implementasikan Web Audio API dengan Claude Code: waveform, rekaman, volume meter, cleanup, dan tes.

Web Audio API dengan Claude Code: waveform, rekaman, dan volume meter

Mulai dari pengalaman audio

Web Audio API adalah API browser untuk membuat graph audio. Kamu bisa memuat suara, menghubungkannya ke node, mengubah volume, menganalisis sinyal, lalu mengirimkannya ke output. Untuk playback biasa, elemen audio sering cukup. Web Audio API dibutuhkan ketika produk memerlukan waveform player, preview rekaman, input volume meter, suara notifikasi, efek suara, atau alat belajar pengucapan.

Claude Code membantu karena masalah sebenarnya bukan menyalin satu snippet. Masalahnya adalah memasukkan audio ke aplikasi React yang sudah ada tanpa merusak SSR, aturan autoplay, permission mikrofon, cleanup node, aksesibilitas, dan perilaku mobile. Jika Claude Code membaca component, route, test, dan aturan repository, hasilnya lebih dekat ke implementasi production.

Panduan ini berisi workflow lengkap: prompt untuk Claude Code, hook AudioContext React/TypeScript, waveform dengan AnalyserNode, preview rekaman, volume meter, suara notifikasi, cleanup, Playwright, dan pemeriksaan manual. Rujukan resmi yang perlu dibuka adalah MDN Web Audio API, MDN autoplay guide, dan MediaRecorder. Untuk player UI yang lebih lengkap, lihat juga panduan audio player Claude Code.

Lima use case praktis

Sebelum menulis kode, pisahkan use case. Setiap pengalaman audio memakai node berbeda dan punya risiko berbeda.

Use caseAPI utamaYang perlu dibuat Claude CodeRisiko utama
Waveform playerAudioContext, AudioBufferSourceNode, AnalyserNodeplay, stop, waveform, master volumememakai ulang source setelah start()
Preview rekamangetUserMedia, MediaRecorder, MediaStreamAudioSourceNoderecord, stop, Blob URL lokal, previewlupa menghentikan track mikrofon
Volume meterAnalyserNode.getByteTimeDomainDatahitung RMS dan meter liverequestAnimationFrame terus berjalan
Notifikasi dan efekOscillatorNode, GainNodetone pendek, fade, cegah clickmemutar suara sebelum gesture user
Alat belajar suaraplayback hook, recorder, loopshadowing, latihan pengucapan, reviewconsent dan retention rekaman tidak jelas

Tabel ini membuat instruksi Claude Code lebih tajam. Waveform player tidak perlu permission mikrofon. Preview rekaman tidak boleh menghubungkan input mikrofon ke speaker. Suara notifikasi harus pendek, volume default rendah, dan node-nya diputus setelah selesai.

Prompt untuk Claude Code

Prompt yang baik menyebut gesture user, rekaman lokal, cleanup, dan verifikasi. Claude Code juga perlu membaca proyek sebelum mengubah file.

Implementasikan demo Web Audio API dengan React + TypeScript.

Requirements:
- AudioContext hanya dibuat atau di-resume setelah aksi user.
- Tambahkan hook useWebAudioEngine untuk master gain, analyser, playback, dan cleanup.
- Implementasikan waveform player, preview rekaman, input volume meter, dan suara notifikasi.
- Simpan rekaman sebagai Blob URL lokal; jangan upload ke server.
- Hentikan MediaStream tracks dan disconnect AudioNodes saat stop dan unmount.
- Batalkan requestAnimationFrame dan revoke Blob URLs.
- Tampilkan state untuk autoplay blocked, microphone denied, dan browser unsupported.

Sebelum edit, baca struktur component, lint rules, dan test setup.
Setelah implementasi, tambahkan catatan verifikasi Playwright atau manual.

Detail seperti ini penting. Bug Web Audio API sering muncul setelah demo pertama berhasil: playback kedua tidak berbunyi karena source reuse, indikator mikrofon tetap aktif setelah navigasi, atau mobile Safari tetap menahan context dalam state suspended.

Hook React/TypeScript untuk AudioContext

Mulailah dengan mengumpulkan graph audio dasar di satu hook. AudioContext adalah pintu masuk, GainNode mengatur volume, dan AnalyserNode menyediakan data waveform serta meter.

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

Detail pentingnya adalah membuat AudioBufferSourceNode baru untuk setiap playback. Setelah start(), node ini tidak bisa dipakai ulang. Jadikan ini checklist review untuk Claude Code.

Menggambar waveform dengan AnalyserNode

Visualizer paling sederhana memakai data time-domain. Ini bukan frequency bar, melainkan garis amplitudo yang menunjukkan apakah sinyal benar-benar mengalir di 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" }}
    />
  );
}

Tinggi canvas dibuat stabil agar tidak terjadi layout jump dan horizontal overflow. Resolusi internal tetap menyesuaikan devicePixelRatio, sehingga garis lebih tajam di layar mobile.

Preview rekaman dan volume meter

Gunakan MediaRecorder untuk membuat file rekaman, lalu gunakan Web Audio API untuk meter input. Pembagian ini lebih mudah dirawat dan mencegah mikrofon tersambung ke output speaker.

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

Contoh ini menjaga rekaman tetap lokal. Jika produk mengunggah audio, tambahkan consent copy, durasi penyimpanan, cara hapus, dan validasi server. Untuk alat latihan pengucapan, preview lokal sering cukup untuk versi awal.

Suara notifikasi dan cleanup helpers

Suara pendek bisa dibuat dengan OscillatorNode tanpa file audio. GainNode memberi fade agar tidak muncul click keras.

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

Minta Claude Code meninjau efek kecil juga. Walau sederhana, efek itu tetap membuat node dan tetap perlu diputus setelah selesai.

Pitfall yang harus dicek

Pertama, jangan memulai AudioContext sebelum gesture user. Banyak browser memblokir suara yang dimulai tanpa click, tap, atau keyboard. Hindari resume() di useEffect saat halaman load; panggil dari tombol Play, Record, atau Start.

Kedua, autoplay blocked harus dianggap state produk. Jika context.state masih suspended, UI perlu menjelaskan bahwa user harus mengetuk untuk mengaktifkan suara. Gagal diam-diam terlihat seperti bug.

Ketiga, jangan bocorkan resource. Pastikan AudioNode.disconnect(), MediaStreamTrack.stop(), cancelAnimationFrame(), URL.revokeObjectURL(), dan AudioContext.close() berjalan saat stop dan unmount. Uji setelah navigasi, bukan hanya setelah tombol Stop.

Keempat, rekaman adalah isu privasi. Jelaskan apakah audio tetap lokal, diunggah, berapa lama disimpan, dan bagaimana user menghapusnya. Ini penting untuk media pembelajaran, dashboard support, dan tool internal.

Kelima, latensi mobile tetap nyata. latencyHint: "interactive" membantu, tetapi Bluetooth, mode hemat daya, dan perbedaan browser tetap terasa. Untuk rhythm game atau penilaian pengucapan, tentukan delay yang bisa diterima sebelum memoles visual.

Playwright dan pemeriksaan manual

Playwright bagus untuk memeriksa alur UI dan state DOM. Suara nyata, echo, volume, dan latency harus dicek di perangkat sebenarnya.

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

Pemeriksaan manual minimal mencakup Chrome, Safari, iOS Safari, dan Android Chrome. Pastikan tap pertama memulai audio, penolakan mikrofon memberi pesan yang jelas, indikator mikrofon hilang setelah stop atau navigasi, waveform tidak membuat horizontal scroll, dan suara notifikasi tidak terlalu keras saat memakai headset. Untuk performa, lanjutkan dengan optimasi performa Claude Code.

Prompt review untuk Claude Code

Setelah implementasi, gunakan Claude Code sebagai reviewer.

Review implementasi Web Audio API React + TypeScript ini.

Fokus:
- AudioContext dibuat atau di-resume hanya setelah gesture user.
- AudioBufferSourceNode tidak dipakai ulang setelah start().
- MediaStream tracks berhenti saat stop recording dan component unmount.
- AudioNodes, requestAnimationFrame, dan Blob URLs dilepas.
- Input mikrofon tidak sengaja terhubung ke destination.
- Autoplay, mobile behavior, dan permission denied punya UI state.
- Automated checks dan manual checks dipisahkan jelas.

Kembalikan temuan berdasarkan severity dengan file, line, alasan, dan saran fix.

Dokumentasi resmi Claude Code common workflows berguna untuk membuat review ini berulang. Simpan aturan audio di CLAUDE.md atau REVIEW.md.

CTA ClaudeCodeLab

Demo Web Audio API bisa dibuat cepat, tetapi fitur production menyentuh privasi, aksesibilitas, analytics, mobile testing, dan alur produk. ClaudeCodeLab dapat membantu menyusun aturan Claude Code, review prompt, Playwright checks, dan pola audio UI sesuai repository lewat Claude Code training and consultation.

Untuk project pribadi, mulai dari waveform player dan preview rekaman. Untuk tim, review consent, kebijakan data rekaman, event analytics, dan jalur support sebelum menambah efek.

Catatan verifikasi

Masa menguji struktur ini di demo React kecil. Saat AudioContext dan cleanup dikumpulkan di useWebAudioEngine, penambahan preview rekaman, volume meter, dan suara notifikasi lebih mudah direview. Versi yang membuat new AudioContext() di tiap component lebih rapuh: indikator mikrofon bisa tetap aktif setelah navigasi, repeated click membuat ownership source tidak jelas, dan kegagalan autoplay mobile sulit dijelaskan. Instruksi Claude Code yang paling berguna adalah “review connection cleanup dan permission errors,” bukan hanya “buat audio UI.”

#Claude Code #Web Audio API #pemrosesan audio #visualizer #TypeScript
Gratis

PDF gratis: cheatsheet Claude Code

Masukkan email dan unduh satu halaman berisi command, kebiasaan review, dan workflow aman.

Kami menjaga datamu dan tidak mengirim spam.

Masa

Tentang penulis

Masa

Engineer yang berfokus pada workflow Claude Code praktis dan adopsi tim.