ブラウザゲーに音つけたいからWeb Audio APIいじりはじめた。


今回はあらかじめ用意したwavファイルを音源にしました。オシレータは使ってません。(ピコピコしたまぎらわしい音ですみません)

HTML
<!DOCTYPE html>
<html lang="ja">
<head>
	<meta charset="UTF-8" />
	<meta name="robots" content="noarchive" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
	<meta name="landscape" content="width=device-width, initial-scale=1.0, minimum-scale=1.0" />
	<style>
body * { margin: 0; padding: 0; }
button { padding: 2px 4px; }
input[type="range"] { margin: 0 7px; }
.track { margin: 5px 0; padding: 5px; max-width: 500px; border: 1px solid #999; border-radius: 3px; }
.slider { display: flex; align-items: center; }
.slider dt { width: 3em; }
	</style>
	<title>Web Audio APIサンプル</title>
</head>
<body>
	<span style="color:red;">※音が出ます!</span>
	<div class="track">
		Master
		<dl class="slider">
			<dt>Vol</dt>
			<dd>0<input type="range" min="0" max="1" step="0.01" value="0.8" id="volume_master" />1</dd>
		</dl>
	</div>
	<div class="track">
		<select id="select-audio_1"></select><button type="button" class="play-button" id="play_1">play</button>
		<dl class="slider">
			<dt>Vol</dt>
			<dd>0<input type="range" min="0" max="1" step="0.01" value="0.8" id="volume_1" />1</dd>
		</dl>
		<dl class="slider">
			<dt>Pan</dt>
			<dd>L<input type="range" min="-1" max="1" step="0.01" value="0" id="pan_1" />R</dd>
		</dl>
		<dl class="slider">
			<dt>LP</dt>
			<dd>0<input type="range" min="0" max="20000" step="10" value="20000" id="lp_1" />20000</dd>
		</dl>
	</div>
	<div class="track">
			<select id="select-audio_2"></select><button type="button" class="play-button" id="play_2">play</button>
		<dl class="slider">
			<dt>Vol</dt>
			<dd>0<input type="range" min="0" max="1" step="0.01" value="0.8" id="volume_2" />1</dd>
		</dl>
		<dl class="slider">
			<dt>Pan</dt>
			<dd>L<input type="range" min="-1" max="1" step="0.01" value="0" id="pan_2" />R</dd>
		</dl>
		<dl class="slider">
			<dt>LP</dt>
			<dd>0<input type="range" min="0" max="20000" step="10" value="20000" id="lp_2" />20000</dd>
		</dl>
	</div>
	<script src="script.js"></script>
</body>
</html>
JavaScript
(() => {
	// ブラウザによって異なるAudioContextをまとめる
	window.AudioContext = window.AudioContext || window.webkitAudioContext;
	if (!window.AudioContext) {
		alert('AudioContextに対応していないブラウザです。');
		return;
	}
	
	const _ctx = new AudioContext();
	_ctx.listener.setPosition(0, 0, 0);
	
	const _audioBuffers = {};
	const _tracks = {};
	let _loadingNum = 0;
	
	
	/**
	 * 指定した音声ファイルを全て取得してデコードする
	 * @param {Array.<String>} pathList - 音声ファイルのパスの配列
	 * @param {Function} callback - 全てのファイルをデコード後に呼び出されるコールバック
	 */
	const loadSamples = (pathList, callback) => {
		const load = (path) => {
			// 音声ファイルなのでレスポンスはarraybufferで受け取る
			const req = new XMLHttpRequest();
			req.open('GET', path, true);
			req.responseType = 'arraybuffer';
			req.addEventListener('load', () => {
				// load完了したら、デコードしてとっておく
				_ctx.decodeAudioData(req.response, (buffer) => {
					_audioBuffers[path] = buffer;
					// 全てデコード完了したらコールバック実行
					if (--_loadingNum <= 0) {
						callback();
					}
				}, () => {
					alert(path +'のデコードに失敗しました。');
				});
			});
			req.send();
		};
		
		_loadingNum += pathList.length;
		for (let i = 0; i < pathList.length; ++i) {
			const path = pathList[i];
			_audioBuffers[path] = null;
			load(path, callback);
		}
	};
	
	/**
	 * トラッククラス
	 * @param {AudioNode} destination - 出力先ノード
	 */
	const Track = function(destination) {
		// Gainを生成して出力先ノードにつなげる
		this.gain = _ctx.createGain();
		this.gain.connect(destination);

		// Pannerを生成してGainに繋げる
		this.panner = _ctx.createPanner();
		this.panner.connect(this.gain);
		_ctx.listener.setPosition(0, 0, 0);

		// BiquadFilterを生成してPannerに繋げる
		this.filter = _ctx.createBiquadFilter();
		this.filter.connect(this.panner);
		this.filter.type = 'lowpass';
		this.filter.frequency.value = 20000;

		this.audioBuffer = null;
		this.bufferSource = null;
	
		this.setVolume(0.7);
		this.setPan(0);
	};
	/**
	 * トラックにAudioBufferを割り当てる
	 * @param {AudioBuffer} audioBuffer
	 */
	Track.prototype.assign = function(audioBuffer) {
		this.audioBuffer = audioBuffer;
	};
	/**
	 * トラックの終端ノードを返却
	 * @return {AudioNode} 終端ノード (ここではBiquadFilter)
	 */
	Track.prototype.getTerminalNode = function() {
		return this.filter;
	};
	/**
	 * トラック音量をセット
	 * @param {Number} value - 音量 (0 ~ 1)
	 */
	Track.prototype.setVolume = function(value) {
		this.gain.gain.value = value;
	};
	/**
	 * トラックのパンをセット
	 * @param {Number} value - パン (-1 ~ 1)
	 */
	Track.prototype.setPan = function(value) {
		this.panner.setPosition(value, 0, 1); // (x, y, z) だが、zが0だとlistenerに近すぎて左右に振られすぎるらしい
	};
	/**
	 * トラックのLowPassフィルタのカットオフをセット
	 * @param {Number} value - カットオフ周波数 (0 ~ 20000)
	 */
	Track.prototype.setLowPassFilterFrequency = function(value) {
		this.filter.frequency.value = value;
	};
	/**
	 * トラックに割り当てられたAudioBufferを再生する
	 */
	Track.prototype.play = function() {
		if (!this.audioBuffer) {
			return;
		}
		if (this.bufferSource) {
			// 再生中なら止める
			this.bufferSource.stop();
		}
		// BufferSource生成して再生
		this.bufferSource = _ctx.createBufferSource();
		this.bufferSource.buffer = this.audioBuffer;
		this.bufferSource.connect(this.getTerminalNode()); // 終端のノードにつなぐ
		this.bufferSource.start(0);
	};
	
	/**
	 * トラックの操作ボタンなどの準備
	 * @param trackName 
	 */
	const setupTrackControl = (trackName) => {
		document.getElementById('play_'+ trackName).addEventListener('click', (e) => {
			_tracks[trackName].play();
		});
		document.getElementById('select-audio_'+ trackName).addEventListener('change', (e) => {
			_tracks[trackName].assign(_audioBuffers[e.target.value]);
		});
		document.getElementById('volume_'+ trackName).addEventListener('change', (e) => {
			_tracks[trackName].setVolume(e.target.value);
		});
		document.getElementById('pan_'+ trackName).addEventListener('change', (e) => {
			_tracks[trackName].setPan(e.target.value);
		});
		document.getElementById('lp_'+ trackName).addEventListener('change', (e) => {
			_tracks[trackName].setLowPassFilterFrequency(e.target.value);
		});
	};
	
	
	// 音声をロードして…
	loadSamples(['a.wav', 'b.wav', 'c.wav', 'd.wav'], () => {
		// 全てロード完了したらセレクトボックス用のリストを作る
		let options = '';
		for (let name in _audioBuffers) {
			options += '<option calue="'+ name +'">'+ name +'</option>';
		}
		document.getElementById('select-audio_1').innerHTML = options;
		document.getElementById('select-audio_2').innerHTML = options;

		// トラック準備
		// 1と2はMasterの末端ノードにつなぐ
		_tracks['master'] = new Track(_ctx.destination);
		_tracks['1'] = new Track(_tracks['master'].getTerminalNode());
		_tracks['1'].assign(_audioBuffers['a.wav']);
		_tracks['2'] = new Track(_tracks['master'].getTerminalNode());
		_tracks['2'].assign(_audioBuffers['a.wav']);

		// 操作ボタンなど準備
		setupTrackControl('1');
		setupTrackControl('2');
	});
})();

基本は AudioContext でノードを作って、ノード同士を繋げていく感じだった。繋げていく感じを掴むために、ここで作ったサンプルでは、トラック音量とマスター音量を用意してみた。各トラック内で LowPassフィルター → パンナー → Gain というふうに繋げて、それらをさらにマスタートラックに繋げた。

ざっくりした使い方は次のサイト様を参考にしました。

フィルターとかパンナーとか細かいところは、g200kgさんのサイトを参考にしました。

それと、自分が使ってるブラウザがWeb Audio APIにどれだけ対応してるかを、g200kgさんの下記ツールでチェックできます。便利です。

しばらく createStereoPanner の存在に気づかなくて、左右に音を振りたいだけなのに、ずいぶん createPanner の使い方に振り回された。と思いきや、iPhoneのSafariでは createStereoPanner がサポートされてなくて、やはり createPanner に戻らなければならなかった。

PannerNodesetPosition(x, y, z)z をゼロにすると (listenerz と近すぎる値にすると?)、左右の振りが極端になってしまったので、ここでは適当に1をセットした。