Reactで、音声ファイルの波形を描く方法
ユーザーが選んだ音声ファイルの波形は、ライブラリを使わなくても、ブラウザの中で描けます。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、スマートフォンのブラウザ、人の声や音楽を録音した実際のファイル、どのくらい長い音声まで扱えるか(メモリーの上限)は試していません。

音声ファイルを数値の並びにする
最初に、ファイルの中身を decodeAudioData() でデコードします。結果の AudioBuffer から、getChannelData() でチャンネルごとの Float32Array を取り出せます。波形の処理は React に依存しないので、waveform.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本の縦の線にします。線の上の端は区間の最大値、下の端は最小値です。
// 全体を 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 だけ大きな音を入れたファイルで比べました。

このファイルを 600 の区間に分けると、1区間はおよそ 220 個の数値です。大きな音が入った区間では、先頭の値は 0.048 で、最大値は 0.945 でした。0.5ms は 22 個ほどの数値なので、区間の先頭に当たらなければ、波形には出ません。
canvas に描く
drawWaveform() は、区間ごとの最小値と最大値を縦の線にして描きます。progress(0〜1)より左の線は水色、右は灰色にし、再生位置に濃い色の線を引きます。
// 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> を表示します。
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 の中に置きました。
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秒より短い音を扱うためのものとされています。





