パソコンのブラウザーで思いどおりに鳴っていた音が、iPhone の Safari では鳴らない、音量が変わらない、ということがよくあります。iOS では、利用者の意図しない音や通信量を防ぐために、音声の扱いにいくつかの制限があるためです。ここでは主な制限と、その中で音を鳴らす方法をまとめます。

タップがきっかけでないと再生できない

iOS の Safari では、音の出る再生は、利用者の操作 (タップなど) をきっかけにしないと始められません。ページを開いた直後に play() を呼んでも、音は鳴りません。いわゆる自動再生の制限で、今ではほかのブラウザーにも同じような決まりがあります。

例外は、音を消した動画です。muted と playsinline を付けた video 要素は、操作が無くても自動再生できます。背景に流す動画などに使われます。

<!-- 音を消した動画は自動再生できる -->
<video src="/movies/loop.mp4" autoplay muted loop playsinline></video>

play() は Promise を返します。再生が許されなかったときは、Promise が NotAllowedError という名前のエラーで失敗するので、それを捕まえて「再生」ボタンを見せるようにします。

const audio = document.querySelector("#bgm");
const playButton = document.querySelector("#play");

playButton.addEventListener("click", async () => {
  try {
    await audio.play();
  } catch (error) {
    if (error.name === "NotAllowedError") {
      // 再生が許されなかった。操作をうながす表示を出す
      playButton.textContent = "もう一度タップして再生";
    } else {
      console.error("再生できませんでした:", error);
    }
  }
});

なお、iOS では preload を指定しても、利用者が操作するまで先読みされないことがあります。モバイル回線の通信量を抑えるための扱いです。「読み込みが終わってから再生ボタンを出す」作りにすると、いつまでもボタンが出ないことがあるので注意します。

volume を変えられない

iOS では、音量は端末の音量ボタンで利用者が決めるもの、という考え方を取っています。そのため、audio 要素の volume プロパティに値を代入しても、音量は変わりません。読み取ると、いつも 1 が返ります。

audio.volume = 0.3;
console.log(audio.volume); // iOS では 1 のまま

音を消すだけなら、muted プロパティが使えます。音量そのものを調整したい場合は、後で説明する Web Audio API の GainNode を通します。音量のつまみ (スライダー) を作る場合は、iOS では効かないことを前提に、隠すか別の作りにします。

audio 要素を重ねて鳴らしにくい

古い iOS では、同時に再生できる audio 要素は 1 つだけで、別の要素を再生すると、前の音が止まりました。今の iOS では制限が緩くなっていますが、効果音のたびに audio 要素を作って重ねて鳴らす作りは、端末によって音が遅れたり、途切れたりしやすいままです。

BGM や長い音声は audio 要素で、効果音のように短い音を何度も重ねて鳴らすものは Web Audio API で、と分けるのが確実です。

効果音は Web Audio API で鳴らす

Web Audio API は、音声を JavaScript から細かく扱うための API です。音声データを前もってメモリに展開しておけるので、遅れが少なく、同じ音をいくつでも重ねて鳴らせます。

AudioContext は、作った直後は止まった状態 (suspended) のことがあります。最初のタップの中で resume() を呼んで、音を出せる状態にします。

const context = new AudioContext();
let tapSound = null;

// 音声ファイルを読み込み、すぐ鳴らせる形 (AudioBuffer) に展開する
async function loadSound(url) {
  const response = await fetch(url);
  const data = await response.arrayBuffer();
  return context.decodeAudioData(data);
}

function playTapSound() {
  if (!tapSound) return;
  // 音源のノードは使い捨てなので、鳴らすたびに作る
  const source = context.createBufferSource();
  source.buffer = tapSound;
  const gain = context.createGain();
  gain.gain.value = 0.5; // 音量はここで調整できる (iOS でも効く)
  source.connect(gain).connect(context.destination);
  source.start();
}

document.querySelector("#start").addEventListener("click", async () => {
  // 最初のタップの中で、止まっている AudioContext を動かす
  await context.resume();
  tapSound ??= await loadSound("/sounds/tap.mp3");
  playTapSound();
});

createBufferSource() で作るノードは、1 回 start() したら使い回せません。そのかわり作るのは軽いので、鳴らすたびに作ります。同じ AudioBuffer を何度でも使えるので、読み込みと展開は最初の 1 回だけで済みます。

どこまでが「操作に続く処理」と見なされるかは、ブラウザーによって扱いが違う部分があります。詳しくは「音声の再生は、利用者の操作の中で始める」で説明しています。

まとめ

  • iOS の Safari では、音の出る再生はタップなどの操作をきっかけにしないと始められない。音を消した動画 (muted と playsinline) は例外
  • play() が返す Promise の失敗 (NotAllowedError) を捕まえて、再生ボタンを見せる
  • iOS では volume を変えられず、読み取りは 1。音量の調整は Web Audio API の GainNode で行う
  • 効果音のように重ねて鳴らす音は、audio 要素ではなく Web Audio API で鳴らす
  • AudioContext は、最初のタップの中で resume() する

参考