Web Audio API:複数の音声ソース読み込み後に処理を実行したい

JavaScript

JavaScriptのWeb Audio APIを使ってゲームのBGMや効果音などを再生する際、音声ソースが全て読み込まれてから処理を行いたい場合があります。

具体的にはJavaScriptのfetchPromiseを使って実現しています。
以下ソースコードと解説です。

サンプルではWeb Audio APIの利点でもある同時に複数の音声を再生するが確認できます。ボタンを連続して押してみてください。きちんと重複して再生されることがわかります。

BGM再生ボタンについては長い曲が再生されるため少し間をあけてボタンを押すと重複が確認できると思います。

ソースコード

index.html

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <script src="main.js"></script>
    <title>Web Audio API:複数の音声ソース読み込み後に処理を実行したい(fetch利用)</title>
</head>
<body>
    <button class="btnBGM">BGM再生</button>
    <button class="btnSE1">SE1再生</button>
    <button class="btnSE2">SE2再生</button>
</body>
</html>

main.js

/*
 * Web Audio API:複数の音声ソース読み込み後に処理を実行したい(fetch利用)
 */

// Web Audio API用のコンテキスト
let audioContext = null;

// 読み込む音声ソース用のオブジェクト(プロパティ名は再生時に使用)
const audioFiles = {
	bgm: "maou_bgm_8bit29.mp3",	// BGM(長め)
	se1: "poka01.mp3",			// SE音1(短め)
	se2: "powerdown07.mp3",		// SE音2(短め)
};

// 音声ソース読み込み後のバッファ格納用
let audioBuffers = {};	

// 音声ソース読み込み関数
const getAudioBuffer = async (entries) => {
	const promises = [];	// 読み込み完了通知用
	const buffers = {};		// オーディオバッファ格納用

	entries.forEach((entry)=>{
		const promise = new Promise((resolve)=>{
			const [name, url] = entry;	// プロパティ名、ファイルのURLに分割
			console.log(`${name}[${url}] 読み込み開始...`);

			// 音声ソース毎に非同期で読み込んでいく
			fetch(url)
			.then(response => response.blob())	// ファイル生データ
			.then(data => data.arrayBuffer())	// ArrayBufferとして取得
			.then(arrayBuffer => {
				// ArrayBufferを音声データに戻してオブジェクト配列に格納する
				  audioContext.decodeAudioData(arrayBuffer, function(audioBuffer){
					buffers[name] = audioBuffer;
					console.log(`audioBuffers["${name}"] loaded. オーディオバッファに格納完了!`);
					resolve();	// 読み込み完了通知をpromiseに送る
				});
			});
		})
		promises.push(promise);		// 現在実行中のPromiseを格納しておく
	});
	await Promise.all(promises);	// 全ての音声ソース読み込みが完了してから
	return buffers;					// オーディオバッファを返す
};

// 再生関数(引数はaudioBuffersのプロパティ名)
const playSound = function(name){
	if(audioContext.state === "suspended"){
		audioContext.resume();
	}

	let source = audioContext.createBufferSource();	// 再生用のノードを作成
	source.buffer = audioBuffers[name];	// オーディオバッファをノードに設定
	source.connect(audioContext.destination);	// 出力先設定
	source.start();	// 再生
};

// 起動時の処理
window.addEventListener("load", async ()=>{
	// 古いブラウザ向けの設定
	const AudioContext = window.AudioContext || window.webkitAudioContext;

	// Web Audio API用音声コンテキスト生成
	audioContext = new AudioContext();

	// プロパティ毎にオブジェクトにして配列として取得
	const entries = Object.entries(audioFiles);

	// 音声ソースを読み込んで音声バッファに格納する
	audioBuffers = await getAudioBuffer(entries);
	alert("音声ソース読み込み完了!");

	// ボタンイベント設定
	document.querySelector(".btnBGM").addEventListener("click", ()=>{
		playSound("bgm");
	});

	document.querySelector(".btnSE1").addEventListener("click", ()=>{
		playSound("se1");
	});

	document.querySelector(".btnSE2").addEventListener("click", ()=>{
		playSound("se2");
	});

	// 準備が整ったことが分かるように背景色を青くしている
	document.querySelector("body").style.backgroundColor = "royalblue";

});

解説

HTML部分

特別なことはしていません。
音声再生用の<button>タグが3つあるだけです。
class名がついていますが、JavaScript側でボタンを区別するためです。

index.html

<body>
<button class="btnBGM">BGM再生</button>
<button class="btnSE1">SE1再生</button>
<button class="btnSE2">SE2再生</button>
</body>

JavaScript部分

処理はすべてmain.jsに記述してあります。
順を追って解説します。

定数や関数の説明

// Web Audio API用のコンテキスト
let audioContext = null;

audioContextは、Web Audio APIを扱うための変数です。
この変数を通じて読み込んだ音声ソースの再生などができると考えてください。

// 読み込む音声ソース用のオブジェクト(プロパティ名は再生時に使用)
const audioFiles = {
bgm: "maou_bgm_8bit29.mp3", // BGM(長め)
se1: "poka01.mp3", // SE音1(短め)
se2: "powerdown07.mp3", // SE音2(短め)
};

今回音声ソースとしてMP3ファイルを3つ読み込んでいます。
再生する際に扱いやすいようなプロパティ名bgm, se1, se2)をつけてURLmaou_bgm_8bit29.mp3等)とセットでオブジェクトとして登録してあります。

オブジェクトにした理由は、再生するときに

playSound("se1");

などとプロパティ名を利用して分かりやすく再生実行させたいためです。(poka01.mp3を再生する場合)

// 音声ソース読み込み後のバッファ格納用
let audioBuffers = {};

音声ソース(mp3などの音声ファイル)をサーバから読み込んでWeb Audio API上で利用するには音声バッファとして格納する必要があるため、その際に格納する変数がaudioBuffersです。

audioBuffers["se1"]

のように前述した音声ソースのプロパティ名で保存されます。

// 音声ソース読み込み関数
const getAudioBuffer = async (entries) => { /* ...省略...*/ }

本記事の目的である「複数の音声ソース読み込み後に処理を実行したい」部分のメインとなる関数です。

async宣言してあるのはこの関数の処理をすべて完了後に次の処理へ渡すためです。(非同期処理をする関数だよ、という宣言です)
関数内容の解説については後述します。

// 再生関数(引数はaudioBuffersのプロパティ名)
const playSound = function(name){ /* ... 省略 ... */ }

音声を再生するための関数です。
今回はボタンを押した時にplaySoundを実行するように設定しています。
こちらの関数も詳しくは後述します。

loadイベントの処理

// 起動時の処理
window.addEventListener("load", async ()=>{ .... }

ページが表示されてからloadイベントが実行されます。
async宣言しているのは、loadイベント内で同じasync宣言したgetAudioBuffer関数を利用しているからです。

loadイベントの中身を追っていきます。

// 古いブラウザ向けの設定
const AudioContext = window.AudioContext || window.webkitAudioContext;

// Web Audio API用音声コンテキスト生成
audioContext = new AudioContext();

Web Audio APIを利用する際はAudioContextインスタンスを作成する必要があります。(決まり文句みたいなものです)

古いブラウザ向けの設定である const AudioContext = …は、最新のブラウザ(2024年時点)では特に必要ありませんでした。

ここまでWeb Audio APIを利用する上での準備になりますが、MDNの以下を参考にしました。

Web Audio API の使用 - Web API | MDN
Web Audio API の入門を見てみましょう。ここではいくつかの概念を短く確認してから、簡単な boombox の例で、音声トラックの読み込み、再生と一時停止、音量やステレオ位置の変更の方法を学...
// プロパティ毎にオブジェクトにして配列として取得
const entries = Object.entries(audioFiles);

オブジェクト形式のものをプロパティ毎に配列に変換するための記述です。

具体的にはオブジェクトとして宣言した

const audioFiles = {
bgm: "maou_bgm_8bit29.mp3",
se1: "poka01.mp3",
se2: "powerdown07.mp3",
};

が、

const entries = [
['bgm', 'maou_bgm_8bit29.mp3']
['se1', 'poka01.mp3']
['se2', 'powerdown07.mp3']
];

のような配列になります。

これは音声ソースを読み込むときにentries.forEachとして繰り返し処理しやすくするためです。(それだけの理由です)

// 音声ソースを読み込んで音声バッファに格納する
audioBuffers = await getAudioBuffer(entries);
alert("音声ソース読み込み完了!");

非同期処理関数のgetAudioBufferが呼び出されます。
async宣言された関数はawaitをつけて呼び出すことで処理の完了まで待ちます。(3つの音声ソースが全て読み込まれてから次の処理に移るわけです)

通知確認のため、あえてalert命令を配置しています。
全ての音声ソースの読み込みが完了してからアラート表示されることを確認できると思います。

loadイベントの最後に音声に対応した3つのボタンイベント設定をしています。

// ボタンイベント設定
document.querySelector(".btnBGM").addEventListener("click", ()=>{
playSound("bgm");
});

document.querySelector(".btnSE1").addEventListener("click", ()=>{
playSound("se1");
});

document.querySelector(".btnSE2").addEventListener("click", ()=>{
playSound("se2");
});

// 準備が整ったことが分かるように背景色を青くしている
document.querySelector("body").style.backgroundColor = "royalblue";

class分けされた各ボタンをクリックすると対象の音声ソースを再生します。

最後に背景色を青くしていますが、音声ソース読み込みが完了し、かつボタンの準備ができたことを示すためです。(たいした意味はありません。)

getAudioBuffer関数の解説

この記事のメインでもある関数getAudioBufferに関して解説します。

const getAudioBuffer = async (entries) => { /* ...関数の処理... */ }

getAudioBuffer関数では、引数として配列entriesを受け渡しています。
entriesには、音声ソースのプロパティ名URL(ファイル名)が以下のように入っているイメージです。

const entries = [
['bgm', 'maou_bgm_8bit29.mp3']
['se1', 'poka01.mp3']
['se2', 'powerdown07.mp3']
];

関数内で定義されている定数は次の2つです。

const promises = [];	// 読み込み完了通知用
const buffers = {}; // オーディオバッファ格納用

今回利用するfetch命令は非同期処理を行うため、処理が返ってきたとき(response)にPromiseオブジェクトを返すことになっています。

変数promiseには音声ソースごとに読み込み完了を通知するPromiseオブジェクトを格納していきます。

今回のメイン処理である音声ソースを全て読み込むまで次の処理に移らない処理は以下のforEach部分となります。

entries.forEach((entry)=>{
const promise = new Promise((resolve)=>{
const [name, url] = entry; // プロパティ名、ファイルのURLに分割
console.log(`${name}[${url}] 読み込み開始...`);

// 音声ソース毎に非同期で読み込んでいく
fetch(url)
.then(response => response.blob()) // ファイル生データ
.then(data => data.arrayBuffer()) // ArrayBufferとして取得
.then(arrayBuffer => {
// ArrayBufferを音声データに戻してオブジェクト配列に格納する
audioContext.decodeAudioData(arrayBuffer, function(audioBuffer){
buffers[name] = audioBuffer;
console.log(`audioBuffers["${name}"] loaded. オーディオバッファに格納完了!`);
resolve(); // 読み込み完了通知をpromiseに送る
});
});
})
promises.push(promise); // 現在実行中のPromiseを格納しておく
});

forEach文の中身はPromiseオブジェクトで成り立っています。

イメージ

Promiseは非同期処理の完了通知を受け取ることができるオブジェクトです。
Promiseオブジェクト生成時(new Promise内の処理)にfetch文による非同期処理をしてます。
promises.push(promise)としてすぐに配列に入れてしまうため、forEachループの処理はすぐに終わってしまうように感じますが、実際は音声ソースの読み込み時間が掛かります。

forEachループの後、全ての音声ソース(この場合3つ)読み込みまでの待ち時間が必要になります。これはawait Promise.all() を使うことで解決できます。

Promise.all(Promiseオブジェクト)

構文

Promise.allの引数はPromiseオブジェクトです。Promiseオブジェクトであれば今回のように配列も可能です。

await Promise.all(promises);

こうすることでforEachのループで複数の音声ソースを読み込んで完了通知を待ってから次の処理に移ることを実現しています。

実際のforEachループの中身についても説明しておきます。

entriesから順番に1つずつentryに取り出しています。( entries.forEach( (entry) =>… の部分。)

fetchでは以下の構文で非同期処理されるようになっています。

fetch(ファイル名).then(ファイル名を読み込んだあとの処理).then(その次の処理).then(その次の処理)…

したがってファイル(この場合音声ソースURL)を読み込み完了してから次の処理⇒次の処理…と続くわけです。

ある意味わかりやすい構文かもしれません。(最近そう思うようになりました)

具体的にはfetch構文の中で、ファイルの生データを取り出し、

.then(response => response.blob())

そのままでは扱いが出来ないので、ArrayBufferとして取り出してから

.then(data => data.arrayBuffer())

AudioContextオブジェクトのdecodeAudioDataメソッドを使って音声データ(音声バッファ)に戻してWeb Audio APIで再生できる形式に変換しています。

.then(arrayBuffer => {
// ArrayBufferを音声データに戻してオブジェクト配列に格納する
audioContext.decodeAudioData(arrayBuffer, function(audioBuffer){
buffers[name] = audioBuffer;
console.log(`audioBuffers["${name}"] loaded. オーディオバッファに格納完了!`);
resolve(); // 読み込み完了通知をpromiseに送る
});
});

変換した音声データは以下のようにオブジェクト形式で保存しておきます。

buffers[name] = audioBuffer;

イメージとしては

buffers[“bgm“] = ‘maou_bgm_8bit29.mp3‘の音声バッファ

のような感じだと考えてください。

音声ソースの読み込み部分はこれで問題ないのですが、音声ソースを読み終えたことをPromiseオブジェクトに通知する処理が以下の部分です。(これが大事です!)

resolve();

forEachループの処理が終わったあと次の処理に移ります。

await Promise.all(promises);	// 全ての音声ソース読み込みが完了してから
return buffers; // オーディオバッファを返す

コメント通りなのですが、forEachループの中で使ったfetch文による非同期処理(音声ソースによって処理時間がまちまち)があるためawaitを使ってすべての処理が完了してから(Promiseオブジェクトに対してresolve()が呼ばれてから)最後に音声バッファを返しています。

playSound関数の解説

// 再生関数(引数はaudioBuffersのプロパティ名)
const playSound = function(name){
if(audioContext.state === "suspended"){
audioContext.resume();
}

let source = audioContext.createBufferSource(); // 再生用のノードを作成
source.buffer = audioBuffers[name]; // オーディオバッファをノードに設定
source.connect(audioContext.destination); // 出力先設定
source.start(); // 再生
};

関数は音声ソースオブジェクト(audioFiles)のプロパティ名の文字列を引数に指定して利用します。

playSound(“se1“);

利用例

実行するとプロパティ名に対応した音声ソースが再生されます。
もし、同じ音声ソースが再生中であっても重複して再生できることがWeb Audio APIの特徴です。

関数の最初に

if(audioContext.state === "suspended"){
audioContext.resume();
}

としています。

音声コンテキストが一時停止中の場合、再開する

という意味です。(よくわからない意味ですよね!)

これは自動再生ブロックの問題に対処するための記述と考えてください。

多くのブラウザはサイトの読み込みをしたとき自動的に再生されることを防ぐ「自動再生ブロック」という仕様です。

ちなみに音声コンテキストが再開するとaudioContext.stateの状態が running となり、再生できる状態になります。

自動再生ブロックの問題とは?

昔のブラウザではページ読み込みと同時に音声や動画が再生できました。
現在はできなくなりました。ユーザがなんらかのアクションを起こすか無音で再生するかなど色々と条件がつくようになりました。

参考

メディアおよびウェブ音声 API の自動再生ガイド - ウェブメディア技術 | MDN
ページが読み込まれるとすぐに音声(または音声トラックを含む動画)の再生を自動的に開始することは、ユーザーにとって歓迎されない驚きです。 メディアの自動再生は便利な目的に役立ちますが、注意して必要なとき...
let source = audioContext.createBufferSource();	// 再生用のノードを作成

変数sourceは(まだ設定されていませんが)どんな音声をどこに出力するかを設定できる入れ物みたいなものです。(再生用のノードとコメント記述がありますが、MDNのドキュメントにならっただけです)

source.buffer = audioBuffers[name];	// オーディオバッファをノードに設定

どの音声ソースを鳴らすか?を設定しています。
audioBuffersの中身は音声バッファのためプロパティbufferに設定しています。

source.connect(audioContext.destination);	// 出力先設定

スピーカーで再生してね!

という意味です。
audioContext.destinationは最終的な出力先を表しています。通常は端末に繋がったスピーカーです。

source.start();	// 再生

この時点で音声が再生されます。
start()メソッドは、createBufferSource()で作成した音声バッファを再生します。

以上で Web Audio API:複数の音声ソース読み込み後に処理を実行したい の解説を終わります。

ゲームへの利用例

わたしは今回の処理を自分で作ったあまりゲーム性のないパズルゲームに組み込みました。
関数getAudioBufferと関数playSoundは少し手直ししましたが、ほぼ同じです。
参考になれば幸いです。

otimono 0.9.9

PC&スマホ対応のパズルゲームです

← クリックで遊べます

サンプルで利用した音源のURL

maou_bgm_8bit29.mp3

8bit29 | 魔王魂
魔王魂(森田交一)の音楽を無料ダウンロード。全曲フリーBGMとして使用可能です。

poka01.mp3

Freesound - poka01.mp3 by Taira Komori
Freesound: collaborative database of creative-commons licensed sound for musicians and sound lovers....

powerdown07.mp3

ファミコン風効果音
sfxrで作った音をまとめてます。こんな感じの音が作れるんだと参考にしてください。音は自由に使ってもらって構いません。

コメント

タイトルとURLをコピーしました