6
1

Web Audio API を用いてCanvas 上に音声ファイルの波形を描画する

Last updated at Posted at 2023-12-10

✏️ 前書き

音声を扱うUIを作成する際、波形を描画したいという要望が出てくると思います。波形の描画は視覚的に再生位置の特定がやりやすく、リッチな音声UIによく用いられている一般的なUIです。

この記事ではWeb Audio API を使い取得した音声ファイルのデータをAudioBuffer にデコードし、そのAudioBuffer から波形データを描画する為の方法を紹介します。

🗨️ 用語解説

一連の流れを理解するために把握しておくと良さそうな用語を簡単に解説します。

Web Audio API

Webの音声APIの総称。WebAudioAPIっていうAPIがあるわけではないです。

Web Audio API の殆どはAudioContext を始めとして各種APIを接続して利用します。このAudioContext は処理によっては(ブラウザポリシーの為)ユーザーの操作による許可が必要になりますので、適宜resume 関数を叩いてあげる必要があります。

const audioCtx = new AudioContext();

if (audioContext?.state === 'suspended') {
	document.addEventListener('click', () => audioCtx.resume());
}

こんな感じで仕込んでおいてもいいかもしれません😈

AudioBuffer

fetch などで取得した音声ファイルを再生できる様にWeb Audio API にてデコードされたオーディオデータです。

このインターフェースは、メモリー上にあるオーディオデータを表します。そのフォーマットはノンインタリーブな 32 ビットの浮動小数点の リニア PCM で、通常は [−1,1][−1,1] の範囲になりますが、値はこの範囲に限定はされません。通常、PCM データの長さはかなり短く ( 通常は 1 分未満 ) と想定されています。音楽サウンドトラックなどのより長いサウンドの場合、audio 要素と MediaElementAudioSourceNode によるストリーミングを使うべきです。

オーディオチャンネル

スピーカーなどの表記で見る2.1chや5.1ch表記と同じで音が出てる数と思って良さそうです。
AudioBuffer には各チャンネル毎のPCMデータが格納されています。

PCMデータ

○PCMとはアナログ信号をデジタルデータに変換する方式の一つ。 アナログ信号を標本化(サンプリング)・量子化し、得られた信号の大きさを2進の数値データとして表現する。

アナログ信号をデジタル化してサンプリング周波数を基に配列に収めたものです。
サンプリング周波数(サンプリングレート)は一秒間辺りの分割数。一般的なCD音源は44,100 Hz(sampleRate: 44100)が使われています。映像で言うところのフレームレートと思っていいはずです。
このPCM配列は[-1~1] の範囲の値が格納されています。これは音の波形を再現しているものなので、これらの絶対値が大きいほど音が大きいです。

⌨️ 実装

ここからは実際に波形を描画する所を目的とした実装をしていこうと思います。

取得した音声ファイルをAudioBuffer にデコードする

const audioCtx = new AudioContext();
const arrayBuffer = await fetch('./hoge.mp3')
	.then((response) => response.arrayBuffer());

const audioBuffer = audioCtx.decodeAudioData(arrayBuffer);

このたった3行のコードでAudioBuffer にデコードすることができます。
audioBuffer の中身を見てみると以下のデータが内包されています。

duration: 60
length: 2646000
numberOfChannels: 2
sampleRate: 44100

length はPCMデータの大きさ。これはsampleRate x duration の大きさになります。
numberOfChannels はチャンネル数です。
また、Prototype も見てみると幾つかの関数があることもわかると思います。詳しくはW3CAudioBuffer についての記述を確認してください。

interface AudioBuffer {
  constructor (AudioBufferOptions options);
  readonly attribute float sampleRate;
  readonly attribute unsigned long length;
  readonly attribute double duration;
  readonly attribute unsigned long numberOfChannels;
  Float32Array getChannelData (unsigned long channel);
  undefined copyFromChannel (Float32Array destination,
                             unsigned long channelNumber,
                             optional unsigned long bufferOffset = 0);
  undefined copyToChannel (Float32Array source,
                           unsigned long channelNumber,
                           optional unsigned long bufferOffset = 0);
};

getChannelData に参照したいチャンネル番号を渡すことで、PCMデータを取得することができます。

const channels = audioBuffer.numberOfChannels;

for (let i = 0; i < channels; i++) {
	const channelData = audioBuffer.getChannelData(i);
	console.log(channelData); // => 各チャンネルのPCMデータ
}

各チャンネルのPCMデータを丸める

次はこのchannelDataCanvas 上に描画しやすいように丸める処理を書いていきます。

前述したようにサンプリングレート44,100 Hzなら1秒辺り44,100つの値が存在します。これをそのまま1秒辺り44,100pxで描画しようとすると、1つの値を1pxの棒でとても正確な波形が描画できます。また1秒辺り1pxで描画しようとすると1 / 44,100pxの棒が描画できますがモニターの解像度によっては潰れてしまいます。
実際1秒辺りに44,100の値は不要だと思うのである程度の数に丸めてしまおうと思います。またこの棒のサイズもある程度決め打ちすることで見栄えが良くなります。

まずは1秒辺り何pxで描画するか考えると良いです。この数値はCanvas の全長を基に決めても良いし、決め打ちしても良いです。また描画する棒の太さと、棒と棒の間のスペースも決めてしまいます。
実装するに辺り一旦以下の値で決め打ちします。

1秒辺りのpx数: 20px/
棒の太さ: 2px
棒のスペース: 1px

上記の値の場合、音声ファイルの総再生時間が60秒の時Canvas の全長は1200pxになり、全長を太さ + スペースで割ると適切な分割数を求めることができます。

const pxPerSec = 20
const barSize = 2
const barGap = 1
const duration = 60

const canvasWidth = pxPerSec * duration // 1200(Canvasの全長)
const splitLength = Math.ceil(canvasWidth / (barSize + barGap)); // 400(分割数)

総再生時間が60秒でサンプリングレートが44,100の場合、丸べきオリジナルのデータ数は2,646,000にもなります。これを上記で求めた400という数に丸めていきます。できるだけ大きく波形が出て欲しいので、絶対値の最大値で丸めようと思います。

const getShapeData = (audioBuffer, splitLength) => {
	const channels = audioBuffer.numberOfChannels;
	const splitPeaks = [];
	const sampleSize = audioBuffer.length / splitLength;
	const sampleStep = Math.floor(sampleSize / 10) || 1;
	for (let c = 0; c < channels; c++) {
		splitPeaks[c] = new Array(splitLength);
		const peaks = splitPeaks[c];
		const channelData = audioBuffer.getChannelData(c);
		for (let i = 0; i < splitLength; i++) {
			const start = Math.floor(i * sampleSize);
			const end = Math.floor(start + sampleSize);
			let max = 0;
			for (let j = start; j < end; j += sampleStep) {
				const value = Math.abs(channelData[j]);
				max = Math.max(max, value);
			}
			peaks[i] = max
		}
	}
	const mergedPeaks = new Array(splitLength);
	for (let i = 0; i < splitLength; i++) {
		let max = 0;
		for (let c = 0; c < channels; c++) {
			const peaks = splitPeaks[c];
			const value = peaks[i];
			max = Math.max(max, value);
		}
		mergedPeaks[i] = max;
	}
	return mergedPeaks
}

分割された中で最大の値をチャンネル毎の配列に収め、その配列を最大の値で結合した配列を生成することが出来ました。後はこの配列データを描画するだけです。

データを描画する

Canvas 上に丸めた配列を描画していこうと思います。

前項で決めた通りCanvas の全長を1200pxとして考えていきます。実際は計算後に割り当てるのが正しいですが良い感じに変換してください。Canvas の高さを適当に指定、波形用の棒の太さやスペースも前項で決めたように指定。これら数値を基に波形を描画していきます。

<canvas id="canvas" />
const drawCanvas = (shapeData) => {
	const barSize = 2;
	const barGap = 1;
	const width = 1200;
	const height = 60

	const canvas = document.querySelector("#canvas");
	const ctx = canvas.getContext('2d');
	canvas.width = width;
	canvas.height = height;
	ctx.clearRect(0, 0, width, height);
}

Canvas の全長を決める注意点として、ブラウザによってCanvas の最大寸法が決まっています。より長い音声ファイルの正確な波形描画をしたい時は8,000px毎にCanvas を分割して描画する必要があります。

キャンバスの最大寸法
<canvas> 要素の最大寸法はとても広いのですが、正確な寸法はブラウザーに依存します。以下のものは様々なテストやその他の情報源 (Stack Overflow など) から収集したいくらかのデータです。

ブラウザー 最大高 最大幅 最大面積
Chrome 32,767 pixels 32,767 pixels 268,435,456 pixels (つまり 16,384 x 16,384)
Firefox 32,767 pixels 32,767 pixels 472,907,776 pixels (つまり 22,528 x 20,992)
Safari 32,767 pixels 32,767 pixels 268,435,456 pixels (つまり 16,384 x 16,384)
IE 8,192 pixels 8,192 pixels ?

他にもCanvas 自体のサイズは固定しつつスクロール位置などを計算して描画範囲を限定しても良いかもしれません。

今回は中央を基準に、上下対称に波形を描画してみようと思います。

Canvas では左上が(0, 0)の座標になりますので、n番目の棒の座標は((棒の太さ + 棒のスペース) * n, Canvas の高さ/2 - 棒の高さ/2)といった風に求めることができます。

const drawCanvas = (shapeData) => {
	// 実装の続き
	ctx.fillStyle = "#17A1E6";
	const length = shapeData.length;
	const halfHeight = height / 2;

	for (let i = 0; i < length; i++) {
	  const waveHeight = height * shapeData[i];
	  const halfWaveHeight = waveHeight / 2;
	  const x = (barSize + barGap) * i;
	  const y = halfHeight - halfWaveHeight;
	  ctx.fillRect(x, y, barSize, waveHeight);
	}
}


一連の処理を実行することでこの様な波形グラフを描画することができました👏🏻

🦜 余談: リッチなUIを実装する為のアイデア

音声の波形を表示することができました。ですがこれはファイル全体の波形を表示しているだけでユーザー体験を上げる機能は何も提供されていません。

ユーザーが音声UIに期待するであろう幾つかの機能を簡単に紹介してみようと思います。

現在の再生位置を表示

前項で決めた通り今回の実装では1秒辺りのpx数を20pxで描画すると決め打ちしました。Canvas 上の現在の再生位置は現在の再生時間 x 1秒辺りのpx数で簡単に求めることができます。

描画方法としては、全体の波形描画のCanvas と現在の再生位置まで描画したCanvas を重ねて上げることで色で進捗率を表現することができます。またシンプルに進捗率を縦のバーで表現することもできます。

再生位置の変更

クリックした波形の位置に飛べると嬉しいです。これも1秒辺りのpx数を決めていますので、クリックした際のCanvas 上の位置を1秒辺りのpx数で割ることで求めることができます。

const getClickTime = (event) => {
	const x = event.clientX;
	const time = x / pxPerSec;
	return time
}

またマウスホバー時にその地点の再生時間を表示してあげると便利なUIになりそうです。

波形グラフの拡大・縮小

1秒辺りのpx数とそれに合わせた棒のサイズ・スペースの幾つかのパターンを用意してあげると良いです。5段階くらいあるとニッチなユーザーに刺さります。

また最小サイズは画面サイズを基準に決めてあげるのも良いです。1秒辺りのpx数は画面サイズ / 総再生時間になります。

🤖 余談: 実際に運用する際

実際に運用する際、オーディオをクライアント側でデコーダーする処理は負荷がかなり掛かりますし都度生成するのも、ユーザー体験の観点でよろしく無いです。パフォーマンスを考えた場合波形描画の為の配列生成をサーバーサイドで行い、HTMLMediaElement にて音声を配信、連携するのがベストプラクティスになるかと思います。

ただHTMLMediaElement にも音声ファイルがVBR(可変ビットレート)でエンコードされていると再生位置がズレてしまう問題があります。その場合Web Audio API にて音声再生を行うと正確に再生できるという利点があるので覚えておくといつか役に立つかもしれません。

注意点として音声ファイルをデコードする際にブラウザがメモリリークを起こしてクラッシュすることがあります。(メモリ16GBのPCで2時間超えの音声ファイルをデコードする際に確認しました)クライアントで波形生成するサービスを提供する際はその点にも注意する必要があります。

🏁 終わりに

今回はWeb Audio API を用いてCanvas 上に波形を描画する一連の実装を行ってみました。

複雑なUIの実装はどうしても地道な計算が必要で、実装の取っ掛かりが無いと手を付けるのが大変な印象を受けます。この記事が、UIの実装に悩む方々の参考になれば幸いです。

📌 参考

6
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
1