Reactで、音声ファイルの波形を描く方法

​​React​Canvas API,Playwright,TypeScript,Web Audio API

ユーザーが選んだ音声ファイルの波形は、ライブラリを使わなくても、ブラウザの中で描けます。Web Audio API の decodeAudioData() でファイルを -1〜1 の数値の並びにし、canvas の横幅(ピクセル数)と同じ数の区間に分けて、区間ごとの最小値と最大値を <canvas> に縦の線で描きます。

ここでは、波形を描いたうえで、再生したところまでを水色にし、波形を押すとその位置へ移動するコンポーネントを作ります。区間の値の取り方を間違えると短い音が消えることや、長いファイルでどのくらいメモリーを使うかも、検証の結果とあわせて書きます。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、Playwright 1.63.0 です。音声は ffmpeg で作った検証用のファイル(WAV、MP3、AAC の M4A、Opus の Ogg、FLAC)で、Playwright でファイルを選んで確かめました。Firefox と Safari、スマートフォンのブラウザ、人の声や音楽を録音した実際のファイル、どのくらい長い音声まで扱えるか(メモリーの上限)は試していません。

ファイルを選ぶと灰色の波形が表示され、再生ボタンを押すと左から水色に変わっていく。波形の右から4分の1あたりを押すと、再生位置が 9 秒台に移り、そこから水色が続く
ファイルを選び、再生し、波形を押して移動するまで(カーソル、操作の回数、再生位置の表示は GIF 用に重ねたもの)

音声ファイルを数値の並びにする

最初に、ファイルの中身を decodeAudioData() でデコードします。結果の AudioBuffer から、getChannelData() でチャンネルごとの Float32Array を取り出せます。波形の処理は React に依存しないので、waveform.ts にまとめました。

ts
export type Peaks = { min: Float32Array; max: Float32Array };

// 音声ファイルを PCM(-1〜1 の数値の並び)にデコードする。
// 再生はしないので OfflineAudioContext を使う。デコード結果は、この 44100Hz に変換される
export async function decodeAudio(file: Blob): Promise<AudioBuffer> {
  const data = await file.arrayBuffer();
  const ctx = new OfflineAudioContext(1, 1, 44100);
  return ctx.decodeAudioData(data);
}

decodeAudioData() は AudioContext にもありますが、ここでは音を鳴らさないので、OfflineAudioContext を使いました。引数はチャンネル数・長さ・サンプリングレートです。デコードにしか使わないので、長さは 1 にしています。

デコードした結果は、元のファイルのサンプリングレートではなく、コンテキストのサンプリングレートに変換されます。検証環境では、48000Hz の Opus のファイルも、22050Hz のモノラルの WAV も、sampleRate が 44100 の AudioBuffer になりました。OfflineAudioContext を 8000Hz で作ると、同じ WAV が 8000Hz になります。波形の形を見るだけなら、この変換は気にしなくてかまいません。

ほかに、検証で分かったことが2つあります。

  • decodeAudioData() に渡した ArrayBuffer は、デコードの後に byteLength が 0 になりました。同じデータを別の処理にも使うなら、先に slice() で写しを作っておきます。
  • 中身が音声ではないファイルを渡すと、EncodingError(メッセージは「Unable to decode audio data」)で失敗しました。同じファイルを <audio> に読み込ませると、こちらも error.code が 4(形式に対応していない)になりました。

試した5つの形式は、Chromium 153 ではどれもデコードできました。

ファイル 元のサンプリングレート デコード後
WAV(16bit、ステレオ) 44100Hz 44100Hz、2ch、12.000秒
MP3(128kbps) 44100Hz 44100Hz、2ch、12.000秒
AAC の M4A(128kbps) 44100Hz 44100Hz、2ch、12.000秒
Opus の Ogg(96kbps) 48000Hz 44100Hz、2ch、12.000秒
FLAC 44100Hz 44100Hz、2ch、12.000秒

区間ごとに、最小値と最大値を取る

12秒・44100Hz の音声は、1チャンネルあたり 529,200 個の数値になります。横幅 600px の canvas に描くなら、これを 600 の区間に分け、区間ごとに1本の縦の線にします。線の上の端は区間の最大値、下の端は最小値です。

ts
// 全体を columns 本の区間に分け、区間ごとの最小値と最大値を取る(全チャンネルをまとめて見る)
export function computePeaks(buffer: AudioBuffer, columns: number): Peaks {
  const channels = Array.from({ length: buffer.numberOfChannels }, (_, c) => buffer.getChannelData(c));
  const min = new Float32Array(columns);
  const max = new Float32Array(columns);
  for (let i = 0; i < columns; i++) {
    const start = Math.floor((i * buffer.length) / columns);
    const end = Math.max(start + 1, Math.floor(((i + 1) * buffer.length) / columns));
    let lo = 0;
    let hi = 0;
    for (const data of channels) {
      for (let j = start; j < end; j++) {
        const v = data[j];
        if (v < lo) lo = v;
        if (v > hi) hi = v;
      }
    }
    min[i] = lo;
    max[i] = hi;
  }
  return { min, max };
}

区間の中の値をすべて調べているのは、先頭の値だけで済ませると、短い音が波形から消えるためです。3秒の小さな音の途中に、0.5ms だけ大きな音を入れたファイルで比べました。

上は区間の先頭の値だけで描いた波形で、灰色の細い帯が続くだけで大きな音が見えない。下は区間の最小値と最大値で描いた波形で、水色の帯の途中に、上へ突き出た線が1本ある
同じ音声を600列にした2つの描き方。下だけに 0.5ms の大きな音が出ている

このファイルを 600 の区間に分けると、1区間はおよそ 220 個の数値です。大きな音が入った区間では、先頭の値は 0.048 で、最大値は 0.945 でした。0.5ms は 22 個ほどの数値なので、区間の先頭に当たらなければ、波形には出ません。

canvas に描く

drawWaveform() は、区間ごとの最小値と最大値を縦の線にして描きます。progress(0〜1)より左の線は水色、右は灰色にし、再生位置に濃い色の線を引きます。

ts
// 1区間を横1列にして、最大値から最小値までを縦の線で描く。progress(0〜1)より左は再生済みの色にする
export function drawWaveform(canvas: HTMLCanvasElement, peaks: Peaks, progress: number) {
  const dpr = window.devicePixelRatio || 1;
  const w = canvas.clientWidth;
  const h = canvas.clientHeight;
  canvas.width = Math.round(w * dpr);
  canvas.height = Math.round(h * dpr);
  const ctx = canvas.getContext('2d')!;
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.clearRect(0, 0, w, h);

  const n = peaks.min.length;
  const colW = w / n;
  const mid = h / 2;
  for (let i = 0; i < n; i++) {
    const top = mid - peaks.max[i] * mid;
    const bottom = mid - peaks.min[i] * mid;
    ctx.fillStyle = (i + 0.5) / n <= progress ? '#00c8e8' : '#c5ccd0';
    ctx.fillRect(i * colW, top, colW, Math.max(1, bottom - top));
  }
  // 再生位置の線
  ctx.fillStyle = '#1f2a30';
  ctx.fillRect(Math.min(progress * w, w - 2), 0, 2, h);
}

canvas の画素数を、表示の大きさに devicePixelRatio を掛けた数にしているのは、高解像度の画面でぼやけないようにするためです。devicePixelRatio が 2 の画面で試したところ、表示の大きさは 600×120 のまま、canvas の画素数は 1200×240 になりました。

無音の区間は最小値も最大値も 0 になり、線の高さが 0 で見えなくなるので、Math.max(1, …) で高さ 1px の線を残しています。上の GIF のように、無音の部分は真ん中を通る横線になります。

再生と、押した位置への移動をコンポーネントにまとめる

AudioWaveform は、選ばれた File を受け取り、波形と <audio> を表示します。

tsx
import { type MouseEvent, useEffect, useRef, useState } from 'react';
import { computePeaks, decodeAudio, drawWaveform, type Peaks } from './waveform';

type Props = { file: File; width?: number; height?: number };

export function AudioWaveform({ file, width = 600, height = 120 }: Props) {
  const audioRef = useRef<HTMLAudioElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const [peaks, setPeaks] = useState<Peaks | null>(null);
  const [error, setError] = useState('');
  const [src, setSrc] = useState('');
  const [progress, setProgress] = useState(0);

  // ファイルが変わったら、再生用の URL を作り、波形のデータを作り直す
  useEffect(() => {
    let ignore = false;
    setPeaks(null);
    setError('');
    setProgress(0);
    const url = URL.createObjectURL(file);
    setSrc(url);
    decodeAudio(file)
      .then((buffer) => {
        if (!ignore) setPeaks(computePeaks(buffer, width));
      })
      .catch((e: unknown) => {
        if (!ignore) setError(`波形を作れませんでした(${e instanceof Error ? e.name : 'Error'})`);
      });
    return () => {
      ignore = true;
      URL.revokeObjectURL(url);
    };
  }, [file, width]);

  useEffect(() => {
    if (peaks) drawWaveform(canvasRef.current!, peaks, progress);
  }, [peaks, progress]);

  // 再生中は、画面の書き換えごとに再生位置を読む
  useEffect(() => {
    const audio = audioRef.current!;
    let id = 0;
    const update = () => setProgress(audio.duration ? audio.currentTime / audio.duration : 0);
    const loop = () => {
      update();
      id = requestAnimationFrame(loop);
    };
    const start = () => {
      cancelAnimationFrame(id);
      loop();
    };
    const stop = () => {
      cancelAnimationFrame(id);
      update();
    };
    audio.addEventListener('play', start);
    audio.addEventListener('pause', stop);
    audio.addEventListener('seeked', update);
    return () => {
      cancelAnimationFrame(id);
      audio.removeEventListener('play', start);
      audio.removeEventListener('pause', stop);
      audio.removeEventListener('seeked', update);
    };
  }, []);

  // 波形を押した位置へ移動する
  const seek = (e: MouseEvent<HTMLCanvasElement>) => {
    const audio = audioRef.current!;
    if (!audio.duration) return;
    const rect = e.currentTarget.getBoundingClientRect();
    audio.currentTime = ((e.clientX - rect.left) / rect.width) * audio.duration;
  };

  return (
    <div>
      {error ? <p role="alert">{error}</p> : !peaks && <p>波形を作っています…</p>}
      <canvas
        ref={canvasRef}
        onClick={seek}
        style={{ width, height, display: peaks ? 'block' : 'none', cursor: 'pointer' }}
      />
      <audio ref={audioRef} src={src || undefined} controls />
    </div>
  );
}

使う側では、<input type="file"> で選んだファイルを渡します。検証用のページでは、これを StrictMode の中に置きました。

tsx
function App() {
  const [file, setFile] = useState<File | null>(null);
  return (
    <>
      <input type="file" accept="audio/*" onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
      {file && <AudioWaveform file={file} />}
    </>
  );
}

デコードを Effect の中で待つ

デコードは非同期なので、待っている間に別のファイルが選ばれることがあります。公式リファレンスにある、Effect でデータを取得する例と同じように、クリーンアップで ignore を true にして、古いファイルの結果を state に入れないようにしています。

StrictMode の開発用ビルドでは、最初のファイルを選んだときに decodeAudioData() が2回呼ばれました。React が開発時だけ Effect のセットアップとクリーンアップを1回多く実行するためで、1回目の結果は ignore で捨てられます。本番用ビルドでは1回でした。2つ目以降のファイルを選んだときは、開発用ビルドでも1回ずつです。

再生用の URL も同じ Effect で作り、クリーンアップで URL.revokeObjectURL() を呼んで解放しています。デコードできないファイルを選ぶと、「波形を作れませんでした(EncodingError)」と表示され、波形は隠れます。

再生位置は requestAnimationFrame で読む

再生位置の変化を知らせる timeupdate イベントは、発生する頻度がシステムの負荷によって変わり、1秒におよそ4回から66回とされています。検証環境で再生ボタンを押して2秒待ったところ、timeupdate は7回でした。これで線を動かすと、計算上はおよそ 0.3 秒に1回しか線が動きません。

そこで、play イベントで requestAnimationFrame() のループを始め、画面を描き直すたびに currentTime を読んでいます。同じ2秒の間に、canvas は116回描き直されました。描き直しは progress が変わったときに動く Effect の中で行っているので、再生中は AudioWaveform も、およそ同じ回数だけ再レンダーされていることになります。ここではコードを短くするために progress を state にしました。ref は値を変えても再レンダーが起きないので、再生位置を ref に入れて canvas を直接描き直す形にもできますが、この記事ではその形は試していません。pause イベントでループを止めるので、一時停止している1秒の間は、1回も描き直されませんでした。再生が最後まで進んだときも、pause、ended の順にイベントが発生したので、同じ処理でループが止まりました。

押した位置へ移動する

seek では、押した位置が canvas の横幅のどのあたりかを求め、duration を掛けて currentTime に入れます。12秒の音声で、一時停止中に波形の横幅の 75% の位置を押すと、currentTime は 9.00 になり、canvas が1回描き直されました。止まっているときは、移動が終わったときの seeked イベントで progress を更新しているためです。

押した位置を時間に直すときは、AudioBuffer の長さではなく <audio> の duration を使っています。Opus のファイルでは、AudioBuffer の長さが 12.000 秒、<audio> の duration が 12.007 秒と、わずかに違いました。表示のずれは 1px にも満たないので、ここでは気にしていません。

長い音声ファイルでの重さ

デコードした結果は、圧縮されていない Float32Array なので、元のファイルよりずっと大きくなります。3分のステレオの MP3(4.1MB)をデコードすると、2チャンネル×7,938,000 個の数値になり、Float32Array だけで 60.6MB でした。検証環境では、3回測って、デコードに 0.3〜0.8 秒、600 の区間の最小値と最大値を取るのに 37〜56ms かかりました。

メモリーを減らしたいときは、OfflineAudioContext のサンプリングレートを下げる方法があります。8000Hz にすると、同じ MP3 が 11.0MB になりました。ただし、変換によって値が少し変わります。0.5ms の大きな音が入ったファイルでは、最大値が 0.945 から 0.965 になりました。波形の形を見るだけなら差は小さいですが、値をそのまま音量として表示したいときは、サンプリングレートを下げずに(このコードの 44100Hz のまま)扱うほうが安全です。

デコードした結果の大きさは、長さに比例します。3分で 60.6MB だったので、44100Hz・ステレオの音声が1時間あると、計算上はその20倍の、およそ 1.2GB になります。MDN の AudioBuffer の説明でも、AudioBuffer は、おもに45秒より短い音を扱うためのものとされています。

​Posted by 86