スマートフォンのブラウザーでは、音の出る再生は、利用者の操作 (タップなど) をきっかけにしないと始められません。ところが、ボタンの click の処理の中で play() を呼んでいるのに、鳴ったり鳴らなかったりすることがあります。原因は、ブラウザーが「操作に続く処理」と見なす範囲の外で呼んでいることです。ここでは、その範囲の決まりと、確実に音を出せる書き方を説明します。

昔の iOS では「イベントの処理の中」だけだった

初期の iOS の Safari では、audio 要素の再生だけでなく、読み込み (load()) も、タップのイベントを処理している最中に呼ばないと動きませんでした。イベントの処理の中で setTimeout() を使って少し後に呼んだり、通信が終わってから呼んだりすると、それだけで再生できなくなりました。

そのため、「どのイベントの中で呼べば再生できるのか」を、機種や OS の版ごとに試して確かめる、ということが行われていました。今は、この「操作に続く処理」の範囲が、HTML の仕様でユーザーアクティベーションという仕組みとして決められています。

ユーザーアクティベーションの仕組み

ブラウザーは、利用者がページを操作したかどうかを、ウィンドウごとの状態として持っています。状態は 2 種類あります。

  • 一時的なアクティベーション (transient activation): 操作の直後だけ有効になる状態。一定の時間が過ぎると切れる。window.open() でポップアップブロックに止められずにウィンドウを開けるのは、この状態のときだけ
  • 持続的なアクティベーション (sticky activation): ページを開いてから一度でも操作があれば、その後もずっと有効な状態

この状態を有効にするのは、次のような操作のイベントです。

  • キーの入力 (keydown。Esc キーなどは除く)
  • マウスのボタンを押す (mousedown、マウスでの pointerdown)
  • 指やペンを離す (マウス以外での pointerup、touchend)

指が触れた瞬間の touchstart は含まれていない点に注意します。スクロールしようとして触れただけかもしれないからです。click は、これらのイベントのあとに起きるので、click の処理の中なら一時的なアクティベーションが有効です。

一時的なアクティベーションが続く時間は、仕様では具体的な長さが決められておらず (数秒以内とされています)、ブラウザーによって違います。

音声の再生はブラウザーによって扱いが違う

音声の自動再生や AudioContext の開始は、MDN Web Docs では持続的なアクティベーションで許される機能として挙げられています。つまり、ページで一度操作があれば、その後は再生できるという考え方です。ただし、実際の扱いはブラウザーによって違い、Safari のように、操作の処理の中で再生を始めないと許さないことがあるブラウザーもあります。

今の状態は、対応しているブラウザーでは navigator.userActivation で確かめられます。

if ("userActivation" in navigator) {
  // 操作の直後なら true (一時的なアクティベーション)
  console.log(navigator.userActivation.isActive);
  // ページを開いてから一度でも操作があれば true (持続的なアクティベーション)
  console.log(navigator.userActivation.hasBeenActive);
}

デバッグのときに、再生に失敗した時点で状態が切れていたかどうかを調べるのに役立ちます。ただし、どのブラウザーでも同じ結果で再生できるとは限らないので、処理を分ける条件には使わず、次に示す書き方で作るのが確実です。

通信を待ってから再生すると失敗することがある

よくある失敗は、click の処理の中で通信を待ち、そのあとで play() を呼ぶ書き方です。

// 失敗することがある書き方
button.addEventListener("click", async () => {
  const response = await fetch("/api/next-track"); // 通信に時間がかかると…
  const track = await response.json();
  audio.src = track.url;
  await audio.play(); // 操作から時間が経っていて、再生を許されないことがある
});

await の先は、操作から時間が経ってから動きます。通信が速ければ鳴り、遅ければ鳴らない、という不安定な動きになります。再生する音声の情報は前もって取っておき、操作の処理の中では、待たずにすぐ play() を呼びます。

// ページを開いたときに、次に鳴らす音声の URL を取っておく
let nextTrackUrl = null;
fetch("/api/next-track")
  .then((response) => response.json())
  .then((track) => {
    nextTrackUrl = track.url;
  });

button.addEventListener("click", () => {
  if (!nextTrackUrl) return;
  audio.src = nextTrackUrl;
  // 操作の処理の中で、待たずに呼ぶ
  audio.play().catch((error) => console.warn("再生できませんでした:", error.name));
});

最初のタップで、音を出せる状態にしておく

ゲームのように、あとからタイマーや通信の完了をきっかけに音を鳴らしたいときは、最初のタップの中で音を出せる状態にしておきます。Web Audio API の AudioContext は、一度 resume() で動いている状態 (running) になれば、その後は操作の処理の外から鳴らしても音が出ます。audio 要素も、最初のタップの中で play() を呼んでおきます。

const context = new AudioContext();
const bgm = document.querySelector("#bgm");
const startButton = document.querySelector("#start");

startButton.addEventListener("click", () => {
  // どちらも、await より前に同期的に呼ぶ
  const resumed = context.resume();
  const played = bgm.play();

  Promise.all([resumed, played])
    .then(() => {
      startButton.hidden = true; // 音が出せるようになったらボタンを隠す
    })
    .catch((error) => {
      console.warn("音を出せませんでした:", error.name);
    });
});

ほかのアプリに切り替えたり、画面を消したりすると、AudioContext が止まることがあります。context.state が running でなくなっていたら、次のタップでもう一度 resume() を呼びます。

まとめ

  • 音の出る再生は、利用者の操作に続く処理の中で始める。範囲はユーザーアクティベーションという仕組みで決まっている
  • 一時的なアクティベーションは操作の直後だけ有効で、数秒程度で切れる。touchstart では有効にならない
  • 音声の再生の扱いはブラウザーによって違う。navigator.userActivation はデバッグに使い、処理の分岐には頼らない
  • click の処理の中で通信を待ってから play() を呼ばない。情報は前もって取っておき、待たずに呼ぶ
  • 最初のタップの中で AudioContext を resume() し、audio 要素の play() を呼んでおく

参考