VANNAN GAMES.

← 制作ノート一覧

ブラウザゲームの音が鳴らない問題、全部踏んだので地図を描く

サイト全体

このサイトのゲームで、「音が鳴らない」に何度もぶつかりました。そのたびに原因が違いました。ブラウザで音を鳴らすのは、思っていたより何倍も地雷原です。同じ道を歩く人のために、踏んだ罠の全体像を描いておきます。

罠1: 自動再生ブロック

ページを開いた直後にBGMのplay()を呼んでも、ユーザーが一度も操作していないとブラウザは再生を拒否します。厄介なのは拒否のされ方で、エラーは投げられずPromiseが静かにrejectされるだけ。catchで握りつぶしていると、何も起きずにただ無音です。

さらにうちのBGM関数は「同じ曲が指定されたら何もしない」最適化をしていたので、最初の再生が拒否されると、曲が切り替わるまでずっと無音のままでした。症状は「たまに最初だけBGMが鳴らない」。たまに、なのはブラウザがサイトの利用実績によって自動再生を許可したりしなかったりするからです。再現しないバグの見本のような挙動でした。対策は、最初のタップ時に「意図した曲が止まったままなら鳴らし直す」リトライを仕込むこと。

罠2: iOSのマナーモード

これが一番意地悪でした。iPhoneの消音スイッチがONのとき、HTMLのAudio要素で再生する音は鳴るのに、Web Audio APIで合成した音だけが消音されます。うちはBGMがファイル再生、効果音がWebAudio合成だったので、「BGMは聞こえるのにSEだけ鳴らない」という、コードを何度見ても原因の分からない状態になりました。回避策は navigator.audioSession.type = "playback" の宣言です。これ自体は当時から入れていました。それでも鳴らなかったので、私は「この端末では効かないのだろう」と結論して、再生方式のほうを作り替える道に進みました。そこが間違いでした。真相は罠4に書きます。

罠3: Audio要素ごとの「解錠」

ではSEもファイル再生にすれば解決かというと、もうひとつ罠があります。iOSはAudio要素ごとに「ユーザー操作の中で一度再生した実績」を要求します。BGMはタップ起点で再生されるので自然に解錠されますが、効果音は戦闘処理などの非同期な流れの中でplay()されるため、一度も解錠されないまま延々と拒否され続けます。対策は、最初のタップ時に全ての音要素を無音で一瞬再生してすぐ止めること。これで以後はプログラムからいつでも鳴らせます。

方針転換: 合成をやめてファイルにした

WebAudioでの波形合成には「音源ファイル不要で軽い」「コードで音を作れる」という良さがあり、当初は全ゲームで採用していました。しかし罠2のような端末依存の消音は、こちらのコードでは制御しきれません。そこで合成をやめて、効果音も全てWAVファイルにする方針に変えました。合成はあきらめず、Pythonで波形を生成してファイルに焼くスクリプトを作ったので、「コードで音を作る」楽しさは残っています。全ゲームぶんのビープ音22種を合わせても約600KB。この軽さなら割に合います。

ただし再生の経路はあとでWeb Audioに戻しました。音1種類につきAudio要素1個を使い回す方式は、鳴らすたびに現在位置を巻き戻すことになり、これがシーク扱いで9〜100msの遅延を生みます。さらに発音体がひとつしかないので、同じ音が連続すると前の音が切れて重なりません。敵が同時に倒れた場面で、鳴るべき音が消えていました。デコード済みのバッファを使い捨ての音源で鳴らすと、遅延は0.1〜0.3msになり、音は自然に重なります。「合成をやめる」と「Web Audioをやめる」は別の話で、私はここを一度混同していました。

罠4: 宣言する順番

ここまで直しても、手元のiPhoneでは鳴りませんでした。4回目です。同じ症状に4回戻ってくるのは、さすがにこたえます。

今度は推測をやめて、端末の状態を画面に出す診断パネルを作りました。URLに ?sedebug=1 を付けると、オーディオまわりの内部状態が全部見えるようにしたのです。そのつもりでコードを読み直していて、ようやく気づきました。

オーディオセッションの宣言が、AudioContextを作ったあとになっていた。

iOSのオーディオセッションは、contextを作った時点の種別で決まります。あとから playback に変えても、すでに作られたcontextには適用されません。既定値はサイレントスイッチに従う設定なので、そのまま消音されます。しかもうちのコードは、音源の読み込みをページを開いた直後に始めていました。つまりAudioContextはユーザーが一度も触っていない、いちばん不利な瞬間に作られていたわけです。

直し方は順番を入れ替えるだけでした。読み込んだ瞬間に宣言し、AudioContextは最初のタップまで作らない。音源の取得だけ先に済ませておけば、待ち時間は増えません。罠2で「この端末では効かない」と決めつけた判断が、そのあとの遠回りを全部生んでいました。効かなかったのではなく、効く順番で呼んでいなかっただけです。

いま新しいゲームを作るときは、①宣言はいちばん先に、②AudioContextは初回操作まで作らない、③初回タップでのBGM再生リトライ、④長い音のAudio要素は個別に解錠、の4点セットを最初から入れています。

おまけ: 症状から罠を絞る手順

「音が鳴らない」に出くわしたとき、どの罠かを絞る順番を決めておくと早いです。人に遊んでもらって報告が来たときにも、そのまま質問として使えます。①BGMは鳴っていますか——鳴っていなければ自動再生ブロックかミュート設定。②ボタンを押したときのピコピコ音は鳴りますか。BGMだけ鳴るならマナーモード、つまり罠2の可能性が高い。③特定の効果音だけ鳴りませんか。それは解錠漏れ、罠3です。

ただ、この手順にも限界がありました。罠4のときの症状は「BGMもSEも鳴らない」で、どれにも当てはまらなかったのです。いまは順番に思い出す代わりに、診断パネルを開いて端末の状態を直接見ています。記憶をたどるより、そこに出ている事実のほうが速くて正確でした。

もうひとつ、音を直すときに一緒に見直したいのがミックスです。効果音が「鳴っているのに聞こえない」ことがあり、これは罠ではなく音量バランスの問題です。BGMを基準音量より下げる、派手な演出の間だけBGMを一時的に絞る、といった引き算で効果音は立ちます。技術の問題と聞こえ方の問題を分けて考える、が音まわりの基本でした。

教訓

「PCのChromeで鳴っている」は、何の保証にもなりません。音の不具合は再生経路×端末×設定の組み合わせで起きるので、コードレビューでは見つかりません。

そして、いちばんの学びは技術のほうではありませんでした。症状が消えないとき、私は毎回「実装を作り替える」方向に動いていたのです。合成をやめ、経路を変え、解錠を足し。どれも改善ではありましたが、真因は最初から同じ場所にありました。実機で何が起きているかを見ないまま、手だけを動かしていたからです。

4回目にやっと診断を作りました。最初に作っておけばよかった、と書くのは簡単ですが、たぶん次は別の分野で同じことをやります。せめてこの記事を、そのときの自分が読み返せる場所に置いておきます。