16MBの画像が、1MBの音楽を5秒待たせていた
初回だけBGMが鳴らない。自分のスマホで何度もそうなりました。前に音の罠を4つ書いたばかりで、もう地図は描けたつもりでいたのです。自動再生ブロック、マナーモード、要素ごとの解錠、宣言する順番。全部潰した。それでも鳴らない。
五つ目の罠は、音の処理の中には無かったという話です。
疑っていなかった場所
行き詰まって画面を眺めていて、ふと思い当たりました。カード画像を読み終わってから音が鳴っているのでは、と。
盲点でした。ずっと再生の側だけを見ていたのです。play()が拒否されていないか、AudioContextが動いているか、解錠は済んでいるか。全部OKでした。だから次は端末の設定を疑っていた。ロードそのものが遅れている可能性を、一度も考えていなかったのです。
測ってみました。本番のフルスタック!を開いて、リソースの開始と完了を並べただけです。
| 開始 | 完了 | |
|---|---|---|
| 画像53枚 16.2MB | 1,364ms | 6,658ms |
| タイトルBGM 1.0MB | 1,467ms | 7,119ms |
1MBのファイルの完了に、5.6秒かかっています。単体なら1秒もかからない量です。しかもこれは光回線のPCでの数字。
犯人は隣の行にいました。カードイラスト43枚と敵10体、合わせて16.2MB。こいつらが先に走り出して、帯域を全部持っていっていたのです。
全部読ませたい、でも音は先に鳴らしたい
厄介なのは、画像を後回しにしたいわけではないことでした。このゲームはカードを引いた瞬間に絵が出ないと格好がつきません。読み込みが遅れて記号のまま表示されるくらいなら、多少待ってでも最初に全部落としておきたい。実際そういう設計にしてあります。
つまり「画像は全部読む。ただし音より後で」という順番の指定がほしい。
それ、ちゃんとありました。fetchPriority です。画像の Image オブジェクトに low を、音の fetch に high を渡すだけ。読み込む量は変わりません。並ぶ順番が変わるだけです。
const i = new Image();
i.fetchPriority = "low"; // 音より後でいい
i.src = "img/card_gugu.png";
fetch(bgmUrl, { priority: "high" }); // こっちが先
もうひとつ、開始のタイミングも直しました。画像の先読みはスクリプトのトップレベルで走ります。BGMの取得はその後ろに書いてあった。数行前に動かしただけで、読み込み順の先頭がBGMになりました。171.3ms対172.8ms。1.5msの勝ちですが、勝ちは勝ちです。
白状すると
この問題、最初に気づいてから4日かかっています。その間にやったのは、オーディオセッションの宣言順を直し、AudioContextの生成を遅らせ、診断パネルを作り、先読みを入れる。どれも必要な改善でしたが、症状は消えませんでした。
なぜかというと、私が「音が鳴らない」を「音の処理の問題」だと決めつけていたからです。言葉に引きずられていました。実際には「音のファイルが届いていない」で、それはネットワークの問題です。同じ症状でも層が違う。
診断パネルを作ったとき、私はオーディオの内部状態ばかり並べていました。AudioContextの状態、デコードの進捗、最後の再生経路。そこに buffered を足したのは、帯域を疑ったあとです。最初から入れていれば「0秒のまま増えない」が見えて、もっと早く気づけました。
ついでに見つかったもの
同じ日、DevToolsでソースを開いていて、日本語が全部化けているのに気づきました。
本番のJSが Content-Type: application/javascript で返っていて、charsetの指定がありませんでした。ファイルはUTF-8なのに、宣言していないからブラウザが別の文字コードで読む。ゲーム画面はHTMLの meta charset を引き継ぐので正常、ソースを直接開いたときだけ化ける、という状態です。
うちは難読化していません。コンソールに隠しメッセージを置いて、デバッグ用のフックも残してあります。読まれる前提で作っているのに、肝心のソースが読めない。配信設定に3行足して直しました。
どちらも、ふだん見ていない場所でした。作った本人は、画面が動いていれば満足してしまう。
教訓というほどでもないこと
「Xが動かない」と言われたとき、Xの実装を読みに行くのは自然な反応です。でも動かない理由がXの外にあることは、わりとあります。今回は、音が鳴らない理由が画像にありました。
次に同じ場所で詰まったら、まず「そのファイル、届いてますか」を見ます。performance.getEntriesByType("resource") をコンソールに打つだけで、開始と完了が全部出てきます。5分の作業でした。4日かけたのに。