Edited at

Web Audio APIの闇

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();

ページリロードを3回繰り返した図

before.png

もろもろ掴まれたまま積み上がっているように見える。

再現確認できるページはこちら

https://zprodev.github.io/web-audio-test/tests/decode/


原因

Chrome64のバグっぽい。

Chrome63とChrome65では再現しない。

再現確認したバージョンは 64.0.3282.140

65.0.3325.181では再現しないの図

version65.png


解決策

色々試した結果、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());
});

解決した図

After.png

ただし、SafariのdecodeAudioDataはPromise構文に非対応なので、現状はChrome64用の限定的な対処にするのが良さそう。

解決確認できるページはこちら

https://zprodev.github.io/web-audio-test/tests/decode-promise/


デコードでめっちゃメモリ食う

リロードのメモリリークの話は置いておいて、

そもそもAudioBufferデコードのメモリ使用量がかなり大きい。

「デコード > ガベージコレクション」を10回繰り返した時のメモリ使用量の推移

スクリーンショット 2018-05-23 20.35.27.png

検証ページ

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回繰り返した時のメモリ使用量の推移

スクリーンショット 2018-05-23 21.18.17.png

しばらく放置するといつのまにか解放されるので掴みっぱなしではないようだが、コントロール出来ないし、この増え方は怖い。

検証ページ

https://zprodev.github.io/web-audio-test/tests/multiple-play/


解決策①

AudioContextが参照しているであろうbufferにnull詰めを行う

audioSource.disconnect();

audioSource.buffer = null;

null詰め追加して10回繰り返した時のメモリ使用量の推移

スクリーンショット 2018-05-23 21.18.26.png

微妙に増えていくが、デコードでの増分だと思われ、再生で掴まれた参照は切れてそう。

ただし、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();

都度closeにして10回繰り返した時のメモリ使用量の推移

スクリーンショット 2018-05-23 21.18.41.png

buffer = nullとほぼ同じ結果が得られた。解放処理としてはこちらの方が正しそう。

ただし、都度作成したAudioContextに「自動再生ができない問題」への対処が必要だったりして結構面倒。

検証ページ

https://zprodev.github.io/web-audio-test/tests/multiple-play-close/