Tips & Tricks (업데이트: 2026. 6. 2.)

Claude Code로 Web Audio API 구현하기: 파형, 녹음, 볼륨 미터

Claude Code와 React/TypeScript로 Web Audio API 파형 플레이어, 녹음 미리듣기, 볼륨 미터, 정리 코드를 구현합니다.

Claude Code로 Web Audio API 구현하기: 파형, 녹음, 볼륨 미터

먼저 만들 오디오 경험을 정한다

Web Audio API는 브라우저 안에서 오디오 그래프를 만드는 API입니다. 소리를 불러오고, 노드에 연결하고, 볼륨을 조절하고, 파형을 분석한 뒤 출력 장치로 보낼 수 있습니다. 단순 재생은 audio 요소로 충분하지만, 파형 플레이어, 녹음 미리듣기, 입력 볼륨 미터, 알림음, 효과음, 발음 학습 도구를 만들려면 AudioContext, GainNode, AnalyserNode, MediaRecorder를 함께 다뤄야 합니다.

Claude Code를 쓰는 이유는 단순히 예제 코드를 빨리 얻기 위해서가 아닙니다. 실제로 어려운 부분은 기존 React 앱 안에서 SSR, 자동 재생 제한, 마이크 권한, 노드 정리, 모바일 지연, 접근성까지 함께 맞추는 일입니다. Claude Code가 컴포넌트 구조, 테스트 설정, 라우팅, 저장소 규칙을 읽은 뒤 구현해야 안전합니다.

이 글은 Claude Code에 줄 구현 지시부터 React/TypeScript AudioContext hook, AnalyserNode 파형, 녹음 미리듣기, 볼륨 미터, 알림음, cleanup, Playwright 테스트, 수동 점검까지 한 흐름으로 정리합니다. 공식 문서는 MDN Web Audio API, MDN autoplay guide, MediaRecorder를 확인하세요. 전체 오디오 플레이어 UI는 내부 글인 Claude Code 오디오 플레이어 가이드와 함께 보면 좋습니다.

다섯 가지 실전 유스케이스

오디오 기능은 먼저 유스케이스를 나누면 구현과 리뷰가 쉬워집니다. 같은 Web Audio API라도 필요한 노드와 위험이 다릅니다.

유스케이스주요 APIClaude Code가 구현할 것주요 위험
파형 플레이어AudioContext, AudioBufferSourceNode, AnalyserNode재생, 정지, 파형, 마스터 볼륨start()한 source를 재사용함
녹음 미리듣기getUserMedia, MediaRecorder, MediaStreamAudioSourceNode녹음, 정지, 로컬 Blob URL, 미리듣기마이크 track을 멈추지 않음
볼륨 미터AnalyserNode.getByteTimeDomainDataRMS 계산과 실시간 meterrequestAnimationFrame을 계속 돌림
알림음/효과음OscillatorNode, GainNode짧은 톤, fade in/out, 클릭음 방지사용자 제스처 전에 재생함
음성 학습 도구playback hook, recorder, loop control따라 말하기, 발음 비교, 복습녹음 동의와 보관 정책이 불분명함

이 표를 먼저 정하면 Claude Code에 줄 요구사항도 선명해집니다. 파형 플레이어는 마이크 권한이 필요 없습니다. 녹음 미리듣기는 마이크 입력을 스피커로 보내면 안 됩니다. 알림음은 짧고 기본 볼륨이 낮아야 하며, 끝나면 노드를 끊어야 합니다.

Claude Code에 줄 구현 프롬프트

아래 프롬프트는 수정 전에 사용하기 좋습니다. 기존 파일을 읽고, 구현 범위와 검증 범위를 함께 잡도록 지시합니다.

React + TypeScript에서 Web Audio API 데모를 구현해 주세요.

요구사항:
- AudioContext는 사용자 클릭이나 터치 이후에만 생성하거나 resume합니다.
- useWebAudioEngine hook을 만들고 master gain, analyser, playback, cleanup을 관리합니다.
- 파형 플레이어, 녹음 미리듣기, 입력 볼륨 미터, 알림음을 구현합니다.
- 녹음 파일은 로컬 Blob URL로만 미리듣고 서버로 업로드하지 않습니다.
- 녹음 정지와 component unmount 시 MediaStream tracks와 AudioNodes를 정리합니다.
- requestAnimationFrame loop를 취소하고 Blob URL을 revoke합니다.
- 자동 재생 제한, 마이크 권한 거부, 미지원 브라우저 상태를 UI에 표시합니다.

수정 전에 기존 컴포넌트 구조, lint 규칙, 테스트 환경을 확인하세요.
구현 후 Playwright 또는 수동 점검 절차를 추가하세요.

이렇게 쓰면 Claude Code가 단순한 demo가 아니라 수명주기까지 포함한 작업으로 이해합니다. Web Audio API의 문제는 첫 재생보다 두 번째 재생, 페이지 이동, 권한 거부, 모바일 실행에서 더 자주 드러납니다.

React/TypeScript AudioContext Hook

먼저 오디오 그래프의 기반을 hook으로 모읍니다. AudioContext는 진입점이고, GainNode는 볼륨 제어, AnalyserNode는 파형과 미터 데이터를 제공합니다. 한 hook에 모으면 cleanup과 코드 리뷰가 단순해집니다.

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 높이를 고정하면 레이아웃 흔들림과 가로 스크롤을 줄일 수 있습니다. 실제 픽셀은 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>
  );
}

이 예시는 녹음을 로컬 미리듣기로만 유지합니다. 서버 업로드가 필요하면 동의 문구, 보관 기간, 삭제 방법, 서버 검증을 별도로 설계해야 합니다. 발음 학습 도구의 첫 버전은 로컬 미리듣기만으로도 충분한 경우가 많습니다.

알림음과 cleanup helper

짧은 알림음은 파일 없이 OscillatorNode로 만들 수 있습니다. GainNode로 아주 짧은 fade를 넣으면 클릭 노이즈를 줄일 수 있습니다.

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

효과음은 작아 보여도 노드를 만듭니다. onended에서 끊는지, 화면 이동 후에도 남는 노드가 없는지 Claude Code에게 확인시키세요.

배포 전에 잡아야 할 함정

첫째, 사용자 조작 전에 AudioContext를 시작하는 문제입니다. 많은 브라우저는 클릭, 터치, 키 입력 없이 시작되는 소리를 막습니다. useEffect에서 페이지 로드 직후 resume()하지 말고, Play, Record, Start 같은 버튼 handler 안에서 실행하세요.

둘째, 자동 재생 제한을 UI 상태로 다루지 않는 문제입니다. context.statesuspended라면 사용자가 다음에 무엇을 해야 하는지 보여줘야 합니다. 조용히 실패하는 페이지보다 “탭해서 소리를 켜세요”가 낫습니다.

셋째, 리소스 누수입니다. AudioNode.disconnect(), MediaStreamTrack.stop(), cancelAnimationFrame(), URL.revokeObjectURL(), AudioContext.close()가 정지와 unmount에서 호출되는지 확인합니다.

넷째, 녹음 권한과 데이터 처리입니다. 녹음은 개인정보가 될 수 있습니다. 로컬에만 둘지, 업로드할지, 얼마나 보관할지, 어떻게 삭제할지 설명해야 합니다.

다섯째, 모바일 지연입니다. latencyHint: "interactive"는 도움이 되지만 블루투스 이어폰, 절전 모드, 브라우저 차이는 남습니다. 리듬 게임이나 발음 점수처럼 타이밍이 중요한 기능은 허용 지연을 먼저 정해야 합니다.

Playwright와 수동 검사

자동 테스트는 버튼 흐름과 DOM 상태를 검증합니다. 실제 스피커, 마이크, 모바일 지연은 실제 기기에서 확인해야 합니다.

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를 생성자가 아니라 리뷰어로 사용합니다.

React + TypeScript Web Audio API 구현을 리뷰해 주세요.

중점:
- AudioContext가 사용자 조작 후에만 생성 또는 resume되는가
- AudioBufferSourceNode를 start() 후 재사용하지 않는가
- 녹음 정지와 component unmount에서 MediaStream tracks를 멈추는가
- AudioNodes, requestAnimationFrame, Blob URLs를 해제하는가
- 마이크 입력이 실수로 destination에 연결되지 않았는가
- 자동 재생 제한, 모바일 동작, 권한 거부에 UI 상태가 있는가
- 자동 검사와 수동 검사가 명확히 분리되어 있는가

심각도 순서로 파일, 줄, 이유, 수정안을 반환해 주세요.

공식 Claude Code common workflows는 팀 워크플로에 적용하기 좋습니다. 이 규칙을 CLAUDE.mdREVIEW.md에 남기면 다음 오디오 UI 수정에서도 같은 기준을 유지할 수 있습니다.

ClaudeCodeLab 상담과 트레이닝

Web Audio API demo는 빠르게 만들 수 있지만 운영 기능은 개인정보, 접근성, 모바일 테스트, 분석 이벤트, 상품 또는 교육 흐름까지 연결됩니다. ClaudeCodeLab은 Claude Code training and consultation을 통해 Claude Code 규칙, review prompt, Playwright 검사, 저장소별 오디오 UI 패턴을 함께 정리할 수 있습니다.

개인 프로젝트라면 파형 플레이어와 녹음 미리듣기부터 작게 시작하세요. 팀 프로젝트라면 동의 문구, 녹음 데이터 정책, 이벤트 추적, 지원 경로까지 함께 리뷰해야 합니다.

실제 검증 메모

Masa가 작은 React demo에서 이 구조를 시험했을 때, AudioContext와 cleanup을 useWebAudioEngine에 모은 구성이 가장 다루기 쉬웠습니다. 이후 녹음 미리듣기, 볼륨 미터, 알림음을 추가해도 리뷰 포인트가 한곳에 모였습니다. 반대로 각 컴포넌트가 직접 new AudioContext()를 호출한 버전은 페이지 이동 후 마이크 표시가 남거나, 반복 클릭 시 source 소유권이 불분명했습니다. 효과적인 Claude Code 지시는 “오디오 UI를 만들어줘”가 아니라 “연결 해제와 권한 오류를 리뷰해줘”였습니다.

#Claude Code #Web Audio API #오디오 처리 #시각화 #TypeScript
무료

무료 PDF: Claude Code 치트시트

이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.

개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.

Masa

작성자 소개

Masa

Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.