HTML5でゲームやリッチなコンテンツを作る上で欠かせない「Web Audio API」
しかしコイツがなかなか・・・モバイルでの特殊実装やメモリ使用量まわりで色々と闇を抱えていて・・・
ということで闇を見つけ次第、検証結果や対処方法など記録を残していきます。
iOS SafariはBGMなどの自動再生ができない
iOS Safariは以下ようなコードでサウンドの自動再生ができない
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
const request = new XMLHttpRequest();
const url = 'https://zprodev.github.io/web-audio-test/assets/Campfire_Song.mp3';
request.open('GET', url, true);
request.responseType = 'arraybuffer';
request.onload = () => {
ctx.decodeAudioData(request.response, (audioBuffer) => {
const audioSource = ctx.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(ctx.destination);
audioSource.start();
});
}
request.send();
再現確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/auto-start/
原因
iOS Safariの仕様で、ユーザー操作が無いと音が鳴らないようになっている。
最初の1回は、ユーザー操作と同時にサウンド再生が必要。
解決策
無音でも良いので、touchstart
と同時にサウンド再生する処理を仕込む。
touchend
でも可能だが、長押しで発火するtouchend
だとダメ。
document.addEventListener('touchstart', initAudioContext);
function initAudioContext(){
document.removeEventListener('touchstart', initAudioContext);
// wake up AudioContext
const emptySource = ctx.createBufferSource();
emptySource.start();
emptySource.stop();
}
すでにstartされている音源があれば、このタイミングで同時に始まる。
複数のAudioContextを使う場合は、各インスタンスで1回この処理が必要。
解決確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/auto-start-touch-play/
Chromeもv66から自動再生ができない
66.0.3359.181で戻された。70で再導入予定とのこと。
(2018/10/5追記) 71に延期されたようです。
iOS Safariと同様、Chromeも2018年4月リリースのv66から自動再生が出来なくなった。
ただし、MediaEngagementが高いと自動再生できる等の条件があるようで、再現しない場合もある。
再現確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/auto-start/
原因
iOS Safari同様、ユーザー操作が無いと音が鳴らないよう自動再生ポリシーが変更された。
最初の1回は、ユーザー操作と同時にAudioContextの作成かresumeの実行が必要。
https://developers.google.com/web/updates/2017/09/autoplay-policy-changes#webaudio
解決策
「ユーザー操作と同時にAudioContextの作成」はタイミングが厄介なので「resumeの実行」での対処が簡単。
Androidはtouchstart
はダメでtouchend
ならOK。PCも対象なのでmouseup
でも処理を行う。
const eventName = typeof document.ontouchend !== 'undefined' ? 'touchend' : 'mouseup';
document.addEventListener(eventName, initAudioContext);
function initAudioContext(){
document.removeEventListener(eventName, initAudioContext);
// wake up AudioContext
ctx.resume();
}
解決確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/auto-start-touch-resume/
ページリロードでメモリリーク
Chromeでページリロードするとガッツリメモリリークする現象が発生。
調べた結果、リークのキッカケは AudioContext.decodeAudioData() の実行で、以下のようなXHRで音源のArrayBufferを取得してAudioBufferにデコードする一般的な実装で再現する。
const AudioContext = window.AudioContext || window.webkitAudioContext;
const ctx = new AudioContext();
const request = new XMLHttpRequest();
const url = 'https://zprodev.github.io/web-audio-test/assets/Campfire_Song.mp3';
request.open('GET', url, true);
request.responseType = 'arraybuffer';
request.onload = () => {
ctx.decodeAudioData(request.response, (audioBuffer) => {
console.log("length:" + audioBuffer.length.toString());
});
}
request.send();
もろもろ掴まれたまま積み上がっているように見える。
再現確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/decode/
原因
Chrome64のバグっぽい。
Chrome63とChrome65では再現しない。
再現確認したバージョンは 64.0.3282.140
解決策
色々試した結果、decodeAudioDataの結果受け取りをcallback形式でなくPromise形式にすれば再現しないことが発覚。
before
ctx.decodeAudioData(request.response, (audioBuffer) => {
console.log("length:" + audioBuffer.length.toString());
});
after
ctx.decodeAudioData(request.response).then((audioBuffer) => {
console.log("length:" + audioBuffer.length.toString());
});
ただし、SafariのdecodeAudioDataはPromise構文に非対応なので、現状はChrome64用の限定的な対処にするのが良さそう。
解決確認できるページはこちら
https://zprodev.github.io/web-audio-test/tests/decode-promise/
デコードでめっちゃメモリ食う
リロードのメモリリークの話は置いておいて、
そもそもAudioBufferデコードのメモリ使用量がかなり大きい。
「デコード > ガベージコレクション」を10回繰り返した時のメモリ使用量の推移
検証ページ
https://zprodev.github.io/web-audio-test/tests/decode-touch-start/
解決策
デコード後のAudioBufferが必要なAudioBufferSourceNodeでなく、AudioElementをデータ元として使えるMediaElementSourceNodeなら一括デコード不要なのでメモリ使用量を抑えられる。
ただし、MediaElementSourceNodeは使い勝手の面でさらに闇が深いので、あまりお勧めできない。(MediaElementSourceNodeについては別の機会にまとめようと思うコメントに軽く問題点を書きました)
AudioContextにBufferが解放されない
デコード後、以下のような再生処理 > 終了処理をした場合、 GCを走らせてもAudioBufferが掴まれたままのようなメモリの増え方をする。
const audioSource = ctx.createBufferSource();
audioSource.buffer = audioBuffer;
audioSource.connect(ctx.destination);
audioSource.start();
.
.
.
audioSource.disconnect();
「デコード > 再生 > disconnect > ガベージコレクション」を10回繰り返した時のメモリ使用量の推移
しばらく放置するといつのまにか解放されるので掴みっぱなしではないようだが、コントロール出来ないし、この増え方は怖い。
検証ページ
https://zprodev.github.io/web-audio-test/tests/multiple-play/
解決策①
AudioContextが参照しているであろうbufferにnull詰めを行う
audioSource.disconnect();
audioSource.buffer = null;
微妙に増えていくが、デコードでの増分だと思われ、再生で掴まれた参照は切れてそう。
ただし、buffer = null
は少し古いChromeだと「AudioBuffer以外詰めないでね」エラーを吐くので注意。
検証ページ
https://zprodev.github.io/web-audio-test/tests/multiple-play-null/
解決策②
AudioContextを都度作成してcloseする
const playCtx = new AudioContext();
.
.
.
const audioSource = playCtx.createBufferSource();
.
.
.
audioSource.disconnect();
playCtx.close();
buffer = null
とほぼ同じ結果が得られた。解放処理としてはこちらの方が正しそう。
ただし、都度作成したAudioContextに「自動再生ができない問題」への対処が必要だったりして結構面倒。
検証ページ
https://zprodev.github.io/web-audio-test/tests/multiple-play-close/