はじめに
サウンドボードや効果音ライブラリを作っていると、ボタンに文字だけ並べるのは少し寂しく感じます。特に笑い声やリアクション系の音は、タイトルだけでは「短く一発で終わる音」なのか「だんだん盛り上がる音」なのか分かりません。
そこで、各ボタンに小さな波形サムネイルを付けてみます。音の長さや強弱の形がひと目で分かるだけでなく、再生中に進捗を重ねれば「あと何秒で終わるか」も伝わります。
この記事でやることは次のとおりです。
-
decodeAudioDataで音声をPCMにデコードする - 表示幅に合わせてピーク(最小値・最大値)を間引いて抽出する
- 高DPIディスプレイでもにじまないようにCanvasへ描画する
- 再生位置に合わせて、再生済み部分の色を変える
- 大量のボタンがある場合に、ピークを事前計算してJSONで配る
ライブラリは使わず、ブラウザ標準のAPIだけで実装します。
1. 音声をデコードする
まず音声ファイルを取得し、AudioContextでデコードします。
const audioCtx = new AudioContext();
async function decodeClip(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`取得に失敗しました: ${res.status}`);
const data = await res.arrayBuffer();
return audioCtx.decodeAudioData(data);
}
AudioContextはユーザー操作の前はsuspended状態になっていることがありますが、デコード自体はsuspendedのままでも実行できます。再生するときにだけresume()すれば大丈夫です。
2. ピークを抽出する
デコード結果のAudioBufferには、1チャンネルあたり数万〜数十万個のサンプル(-1〜1の浮動小数点数)が入っています。これをそのまま描くのは無駄なので、表示する横幅のピクセル数に合わせて間引きます。
単純に平均を取ると、短く鋭い音(手拍子やスネアなど)が潰れて見えなくなります。そこで各区間の最小値と最大値を残すのが定番です。
/**
* @param {AudioBuffer} buffer
* @param {number} buckets 横方向の分割数(=描画する棒の本数)
* @returns {Float32Array} [min0, max0, min1, max1, ...]
*/
function extractPeaks(buffer, buckets) {
const channels = Array.from({ length: buffer.numberOfChannels }, (_, i) => buffer.getChannelData(i));
const len = buffer.length;
const size = len / buckets;
const peaks = new Float32Array(buckets * 2);
for (let b = 0; b < buckets; b++) {
const start = Math.floor(b * size);
const end = Math.min(len, Math.floor((b + 1) * size));
let min = 1, max = -1;
for (const ch of channels) {
for (let i = start; i < end; i++) {
const v = ch[i];
if (v < min) min = v;
if (v > max) max = v;
}
}
if (start >= end) { min = 0; max = 0; }
peaks[b * 2] = min;
peaks[b * 2 + 1] = max;
}
return peaks;
}
ステレオの場合は全チャンネルをまとめて最小・最大を取っています。左右で別々に描きたい場合はチャンネルごとに配列を分けてください。
ミーム音源は音圧がばらばらなことが多いので、サムネイル用途なら全ピークを最大振幅で割って正規化すると見た目が揃います(ただし音量差の情報は消えます)。
3. Canvasに描く(高DPI対応)
CSS上のサイズのままCanvasに描くと、Retinaなどの高DPI画面ではぼやけます。devicePixelRatioを掛けた解像度で描くのがポイントです。
function setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1;
const { width, height } = canvas.getBoundingClientRect();
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // 以降はCSSピクセルで描ける
return { ctx, width, height };
}
function drawWaveform(canvas, peaks, { progress = 0, played = "#ff4d6d", rest = "#c9ced6", barGap = 1 } = {}) {
const { ctx, width, height } = setupCanvas(canvas);
const buckets = peaks.length / 2;
const barWidth = width / buckets;
const mid = height / 2;
ctx.clearRect(0, 0, width, height);
for (let b = 0; b < buckets; b++) {
const min = peaks[b * 2];
const max = peaks[b * 2 + 1];
const x = b * barWidth;
const top = mid - max * mid;
const h = Math.max(1, (max - min) * mid); // 無音でも1pxは描く
ctx.fillStyle = b / buckets < progress ? played : rest;
ctx.fillRect(x, top, Math.max(1, barWidth - barGap), h);
}
}
描画頻度が高い場合は、setupCanvasをリサイズ時だけ呼ぶように分けると効率的です。
4. 再生位置を重ねる
再生中はrequestAnimationFrameで進捗を計算して再描画します。AudioBufferSourceNodeには「現在の再生位置」を返すプロパティがないので、開始時刻を覚えておいてaudioCtx.currentTimeとの差から計算します。
async function playWithWaveform(button, buffer, peaks) {
if (audioCtx.state === "suspended") await audioCtx.resume();
const canvas = button.querySelector("canvas");
const source = audioCtx.createBufferSource();
source.buffer = buffer;
source.connect(audioCtx.destination);
const startedAt = audioCtx.currentTime;
let rafId = 0;
const tick = () => {
const progress = Math.min(1, (audioCtx.currentTime - startedAt) / buffer.duration);
drawWaveform(canvas, peaks, { progress });
if (progress < 1) rafId = requestAnimationFrame(tick);
};
source.onended = () => {
cancelAnimationFrame(rafId);
drawWaveform(canvas, peaks, { progress: 0 }); // 終わったら元に戻す
};
source.start();
rafId = requestAnimationFrame(tick);
}
5. ボタンが大量にあるなら事前計算する
ボタンが数十個程度ならブラウザで毎回デコードしても問題ありませんが、数百〜数千個あるサウンドボードでは、波形を見るためだけに全音声をダウンロードしてデコードするのは明らかに無駄です。
その場合は、ビルド時やアップロード時にピークを計算してJSONとして配ります。ffmpegでモノラル・低サンプルレートの生PCMに変換してしまえば、Node側は数値を読むだけです。
ffmpeg -v error -i laugh.mp3 -ac 1 -ar 8000 -f f32le laugh.f32
// scripts/peaks.mjs
import { readFile, writeFile } from "node:fs/promises";
const BUCKETS = 64;
const buf = await readFile(process.argv[2]);
// Bufferのオフセットが4の倍数とは限らないので、コピーしてから読む
const samples = new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
const size = samples.length / BUCKETS;
const peaks = [];
for (let b = 0; b < BUCKETS; b++) {
let min = 0, max = 0;
for (let i = Math.floor(b * size); i < Math.floor((b + 1) * size); i++) {
if (samples[i] < min) min = samples[i];
if (samples[i] > max) max = samples[i];
}
peaks.push(+min.toFixed(3), +max.toFixed(3));
}
await writeFile(process.argv[3], JSON.stringify(peaks));
64本分なら128個の数値なので、1ボタンあたり数百バイト程度です。ページの初期HTMLやAPIレスポンスに含めておけば、音声を1バイトもダウンロードせずに波形が表示できます。音声本体は、タップされたときに初めて取得すれば十分です。
実際のサウンドボードで考える
たとえばMeme Tapの笑い声サウンドボードのように、似たタイトルの笑い声クリップがたくさん並ぶページを想像すると、波形の価値が分かりやすいと思います。「一瞬で終わる笑い」と「じわじわ大きくなる爆笑」が、タイトルを読まなくても形で区別できます。なお、この記事のコードは一般的な実装例として私が書いたもので、サイトの実際のソースコードではありません。
まとめ
-
decodeAudioDataでPCMを取り出し、表示幅に合わせて最小値・最大値で間引く - 平均ではなくピークを使うと、短く鋭い音も波形に残る
- Canvasは
devicePixelRatioを考慮して描画する - 再生位置は
audioCtx.currentTimeと開始時刻の差から計算する - ボタンが多い場合は、ffmpeg+Nodeでピークを事前計算してJSONで配る
小さな波形があるだけで、ボタンの情報量は意外なほど増えます。ぜひ試してみてください。
Abid Ali:パキスタンのWeb開発者。ブラウザで遊べる無料のミーム効果音ボードMeme Tapを作っています。
