2021年6月12日土曜日

JavaScript で音声再生まとめ

個人的に毎回ハマっている JavaScript の音声再生と HTML Audio / Web Audio API / AudioContext / unlockAudio についてまとめておきます。 重点的に取り上げるのは同一音声を連続再生する時のテクニックです。 どこで使えるかというと、ゲームを作る時の効果音などです。

同一の音声を同時に鳴らす様々な方法

(1) 何も考えないと以下が浮かぶと思います。BGM として流しっぱなしにするならこれで良いんですがね。 毎回オブジェクトを生成するので、iOS が激重とわかりました。
button.onclick = () => {
  new Audio('test').play();  
}
(2) といって以下はボタンを連打した時に音が一つしか鳴らないので駄目です。
const audio = new Audio('test.mp3');
button.onclick = () => {
  audio.play();  
}
(3) 1 よりずっと軽いですが、まだ重いです。
const audio = new Audio('test.mp3');
button.onclick = () => {
  audio.cloneNode().play();  
}
(4) 高頻度で呼び出されることがわかっている場合は、事前に cloneNode() しておくと良いかも知れません。 これは早いけどメモリをそれなりに食う。
const audio = new Audio('test.mp3');
const audios = [...new Array(10)].map(() => audio.cloneNode());
button.onclick = () => {
  const a = audios.find(a => a.paused);
  if (a) { a.play(); }
}
(5) AudioContext を使う方法がおそらく一番良い実装です。 体感的に 1-4 のどれよりも高速に動作します。ただし実装が面倒くさい。
const AudioContext = window.AudioContext || window.webkitAudioContext;
const audioContext = new AudioContext();

function playAudio(audioBuffer, volume) {
  const audioSource = audioContext.createBufferSource();
  audioSource.buffer = audioBuffer;
  if (volume) {
    const gainNode = audioContext.createGain();
    gainNode.gain.value = volume;
    gainNode.connect(audioContext.destination);
    audioSource.connect(gainNode);
    audioSource.start();
  } else {
    audioSource.connect(audioContext.destination);
    audioSource.start();
  }
}

function unlockAudio() {
  audioContext.resume();
}

function loadAudio(url) {
  return fetch(url)
    .then(response => response.arrayBuffer())
    .then(arrayBuffer => {
      return new Promise((resolve, reject) => {
        audioContext.decodeAudioData(arrayBuffer, (audioBuffer) => {
          resolve(audioBuffer);
        }, (err) => {
          reject(err);
        });
      });
    });
}

function loadAudios() {
  promises = [
    loadAudio('test1.mp3'),
    loadAudio('test2.mp3'),
    loadAudio('test3.mp3'),
  ];
  Promise.all(promises).then(audioBuffers => {
    audio1 = audioBuffers[0];
    audio2 = audioBuffers[1];
    audio3 = audioBuffers[2];
  });
}

let audio1, audio2, audio3;
loadAudios();
document.addEventListener("click", unlockAudios, { once:true, evnetCapture:true });
button.onclick = () => {
  playAudio(audio1);
}
コードにしてしまうと簡単ですが、色々と注意点はあります。 (1) AudioContext は PC/スマホ共に、クリックやタップなどのユーザ操作後にしか使えません。 そこで音声データを fetch した後、unlockAudio を仕込んで音声を再生できるようにする必要があります。 (2) iOS では Promise ベースの decodeAudioData() は使えません。 というより後述する HTML Audio ではユーザ操作の間に非同期処理を挟むと再生されなくなるので、たぶん使ってはいけません。 (3) unlockAudio を仕込む場所にも注意が必要です。 iOS の後方互換性を考慮する必要があります。 非常に闇が深いので、以下も参考にすることをおすすめします。

unlockAudio について

HTML Audio は iOS ではクリックやタップといった操作の後、同期的にしか Audio 再生ができません。 事前に音声をロードさせておかないと、Audio を再生できずに困ることがよくあります。 そのため、タップイベントに音声の初期化処理を仕込んで音声をロードする、unlockAudio の手法がよく使われます。 HTML Audio の場合、私がよく使っているのはこちら。 HTML Audio の unlock 問題が起きるのはスマホやタブレットだけなので、touchstart に仕込むのが良いかと思います。 最近は IE を無視できるようになってきたので、{ once:true } が使えるようになりました。
function unlockAudio(audio) {
  audio.volume = 0;
  audio.play();
  audio.pause();
  audio.currentTime = 0;
  audio.volume = 1;
}

function unlockAudios() {
  unlockAudio(audio1);
  unlockAudio(audio2);
  unlockAudio(audio3);
}

const audio1 = new Audio('test1.mp3');
const audio2 = new Audio('test2.mp3');
const audio3 = new Audio('test3.mp3');
document.addEventListener("touchstart", unlockAudio, { once:true });

タップイベントの 300ms delay について

ここまでの実装例でもわかるように、click や touchstart などのイベントに unlockAudio を仕込む必要があります。 なるべく早く読み込みたいのですが、スマホやタブレットのタップイベントには 300ms の delay が含まれることが知られています。 タップイベントの delay は 以下の方法でなくすことができるので、読込が早くなります。
<meta name="viewport" content="width=device-width">
といっても上記のタグはほとんどの人が書くようになったと思うのであまり深く考える必要はありません。 念のため以下のようにタップイベントを限定しても良いと思います。
#button { touch-action:manipulation; }
他にもいくつか delay を消す方法がありますが、サポート状況なども含めて詳しくは上記のページを参考にすると良いでしょう。

0 件のコメント: