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

Claude CodeでWeb Audio APIを実装する方法: 波形、録音、音量メーター

Claude CodeでWeb Audio APIを使い、波形プレーヤー・録音プレビュー・音量メーターをReact/TypeScriptで実装します。

Claude CodeでWeb Audio APIを実装する方法: 波形、録音、音量メーター

最初に作るべき音声体験を決める

Web Audio APIは、ブラウザ上で音声を読み込み、加工し、分析し、再生先へ流すための低レベルAPIです。単純な再生だけならaudio要素で十分ですが、波形表示、音量メーター、通知音、録音プレビュー、学習用のリピート再生まで作るなら、AudioContextGainNodeAnalyserNodeMediaRecorderを組み合わせる必要があります。

初心者がつまずきやすいのは、APIそのものよりも「どのノードをいつ作り、いつ接続解除するか」です。Claude Codeに丸投げしても、プロジェクトのReact構成、SSRの有無、録音権限、モバイルブラウザの制限を読ませなければ、動くように見えてメモリリークする実装になります。

この記事では、Claude Codeに実装を依頼するときの指示、React/TypeScriptでコピーして試せるhook、波形描画、録音プレビュー、通知音、検証手順までを一つの流れで整理します。公式仕様はMDN Web Audio API、自動再生制限はMDNの自動再生ガイドも確認してください。既存の音声UI全体を作りたい場合は、内部リンクのClaude Codeで音声プレーヤーを作るも役に立ちます。

ユースケースを5つに分ける

Web Audio APIの実装は、作りたい体験ごとに必要なノードが変わります。最初にユースケースを分けると、Claude Codeに渡す指示もレビュー観点も明確になります。

ユースケース使うAPI実装の焦点失敗しやすい点
波形プレーヤーAudioContext, AudioBufferSourceNode, AnalyserNode再生、停止、波形表示、音量調整一度使ったsourceを再利用しようとする
録音プレビューgetUserMedia, MediaRecorder, MediaStreamAudioSourceNode録音、停止、Blob化、試聴マイクのtrackを止め忘れる
音量メーターAnalyserNode.getByteTimeDomainDataRMS計算、入力レベル表示メーター更新を止めずCPUを使い続ける
通知音と効果音OscillatorNode, GainNode, BiquadFilterNode短い音、フェード、クリック音防止ユーザー操作前に鳴らそうとして失敗する
音声学習ツール再生hook、区間リピート、録音プレビュー発音比較、シャドーイング、復習導線録音データの扱いと同意文言が曖昧になる

Claude Codeに依頼するときは、「音が鳴ればよい」ではなく「録音は保存しない」「マイク入力はスピーカーへ接続しない」「unmount時にAudioContextを閉じる」のように制約まで書きます。Web Audio APIは接続グラフを作るAPIなので、UIの見た目よりもライフサイクル設計が品質を左右します。

Claude Codeへの実装依頼プロンプト

最初のプロンプトでは、ファイル名、期待するUI、禁止事項、検証方法をまとめます。Claude Codeは周辺ファイルを読めるため、既存のhook、ボタン、テスト方針に合わせて実装できます。

React + TypeScriptでWeb Audio APIの音声デモを実装してください。

要件:
- AudioContextはユーザー操作後に作成またはresumeする
- useWebAudioEngine hookを作り、master gain、analyser、cleanupをまとめる
- 波形プレーヤー、録音プレビュー、音量メーター、通知音を分けて実装する
- 録音した音声はローカルのBlob URLで試聴し、サーバーへ送らない
- MediaStreamのtrack、AudioNode、requestAnimationFrame、Blob URLをunmount時に解放する
- モバイルの自動再生制限とマイク権限エラーをUIに表示する

変更前に既存のコンポーネント構成、テスト環境、lint設定を読んでください。
実装後はPlaywrightまたは手動検査のチェックリストも追加してください。

この指示を入れるだけで、Claude Codeは単発のコード生成ではなく、実装、レビュー、検証までの作業単位として扱いやすくなります。特に「サーバーへ送らない」「マイク入力をスピーカーへ返さない」は、音声アプリの信頼性に直結します。

React/TypeScriptのAudioContext hook

まずは音声グラフの土台をhookに閉じ込めます。AudioContextはブラウザの音声処理全体を管理する入口です。GainNodeは音量調整、AnalyserNodeは波形や音量メーター用の分析ノードです。

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で波形を描く

波形プレーヤーでは、AnalyserNodeから時間領域データを取り、Canvasへ描画します。時間領域データとは、音の振幅を短い間隔で並べたデータです。周波数分析よりも初心者にわかりやすく、再生中かどうかも視覚的に確認できます。

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で調整するため、スマホでも線がぼやけにくくなります。

録音プレビューと音量メーター

録音はMDN MediaRecorderの範囲です。Web Audio APIだけで録音ファイルを作るより、MediaRecorderでBlobを作り、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>
  );
}

ここで大事なのは、マイク入力をcontext.destinationへつないでいない点です。入力をスピーカーへ返すとハウリングやエコーの原因になります。メーターに必要なのは分析だけなので、録音中のMediaStreamAudioSourceNodeは専用のAnalyserNodeで止めます。

通知音とcleanup helper

短い通知音や効果音は、音声ファイルを用意せずOscillatorNodeで生成できます。クリックノイズを避けるため、音量は急に0から1へ上げず、短いフェードを入れます。

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で接続解除するところまでレビューさせます。小さな通知音ほど見落としやすく、画面遷移を繰り返すとノードが残ります。

落とし穴とレビュー観点

1つ目の落とし穴は、ユーザー操作前にAudioContextを起動しようとすることです。多くのブラウザは、クリックやタップなどの明確な操作なしに音声を開始する処理を止めます。useEffectでページ表示直後にresume()する実装は避け、再生ボタンや録音ボタンのhandlerから起動します。

2つ目は、自動再生制限をUIで扱わないことです。context.statesuspendedのままなら、ボタン文言やエラー表示で「再生を開始するにはタップしてください」と伝えます。広告や教材ページでいきなり音を鳴らす設計は、体験としてもSEOとしても弱くなります。

3つ目は、AudioNodeの接続解除漏れです。source.connect(gain).connect(destination)のように書くと簡単ですが、画面を閉じたときにdisconnect()しなければノードが残ります。AudioBufferSourceNodeMediaStreamAudioSourceNodeAnalyserNodeGainNodeをどこで解放するかをコードレビューのチェック項目にします。

4つ目は、録音権限とデータの扱いです。録音は個人情報になり得ます。サーバーへ送らないならその旨を画面に書き、送るなら保存期間、削除方法、用途を明確にします。学習ツールや発音練習では、録音データをローカルプレビューに限定するだけでも不安を減らせます。

5つ目は、モバイルの遅延と端末差です。latencyHint: "interactive"を指定しても、Bluetoothイヤホンや省電力モードでは遅延が出ます。音ゲーのような厳密な同期が必要なら、Web Audio APIだけでなく設計段階で許容遅延を決めてください。

Playwrightと手動検査

音声はCIで完全に保証しにくい領域です。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にレビューさせる指示

実装後は、Claude Codeに生成者ではなくレビュアーとして読ませます。以下のプロンプトは、音声API特有の問題を拾うためのものです。

React + TypeScriptのWeb Audio API実装をレビューしてください。

重点チェック:
- AudioContextの作成またはresumeがユーザー操作後に限定されているか
- AudioBufferSourceNodeを再利用していないか
- MediaStreamのtrackを録音停止時とunmount時に止めているか
- AudioNode、requestAnimationFrame、Blob URLを解放しているか
- マイク入力を意図せずdestinationへ接続していないか
- モバイル、自動再生制限、権限拒否のエラー表示があるか
- Playwrightで自動化できる検査と手動検査に分けているか

重大度順に、ファイル名、該当行、理由、修正案を返してください。

Claude Codeの作業方法は公式のClaude Code common workflowsにもまとまっています。レビュー用の指示をCLAUDE.mdREVIEW.mdへ残すと、次回の音声UI改善でも同じ観点を使えます。

ClaudeCodeLabで相談できること

Web Audio APIは、動くデモを作るだけなら短時間で終わります。しかし実務では、録音同意、端末差、アクセシビリティ、分析イベント、教材や商品ページへの導線まで含めて設計する必要があります。ClaudeCodeLabでは、既存リポジトリを前提に、Claude Codeの導入、CLAUDE.md、レビュー観点、Playwright検証、音声UIの改善をClaude Code研修・導入相談で整理できます。

個人開発なら、まず波形プレーヤーと録音プレビューを小さく作り、ブラウザ制限を体で覚えるのが近道です。チーム導入なら、音声を扱う画面だけでなく、権限説明、録音データの扱い、問い合わせ導線まで一緒にレビューしてください。

この記事で紹介した内容を実際に試した結果

Masaがこの構成を小さなReactデモで試したところ、最初にuseWebAudioEngineAudioContextとcleanupを集約したほうが、後から録音プレビューや通知音を足しても破綻しにくいと感じました。逆に、各コンポーネントで自由にnew AudioContext()していた版は、画面遷移後もマイク表示が残ったり、再生ボタンを連打したときにsource管理が曖昧になったりしました。Claude Codeには実装だけでなく「接続解除と権限エラーをレビューして」と明示するのが、Web Audio API記事として一番効果がありました。

#Claude Code #Web Audio API #音声処理 #ビジュアライザー #TypeScript
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。