バックグラウンド期間をジェントルに過ごす

  • 7
    いいね
  • 0
    コメント

ジェントルに過ごそう

我々、音を出すアプリを書くものとしてはユーザが望まない時に音を出さないように気をつける必要があります。そうでないと「音うぜー」と思われて二度とページを開いてもらえません。「俺の歌を聴けー!!!!!」という気持ちもあるとは思いますが、まずはユーザー体験第一です。

バックグラウンドを知る

想定する状況

ここではユーザーが別のタブを開いて後ろで見えなくなっているタブの状態、いわゆるバックグラウンドタブの状況を想定しています。最近ではバックグラウンドに回るとタイマーの精度も極端に落ちるようになっています。アクティブ時には滑らかに再生できていた物もバックグラウンドに回った途端にボロボロになる可能性もありますので、品質の面でもバックグラウンドの検出は重要です。

検出方法

Page Visibility APIを使いましょう。2つの使い方があります。

  1. document.visibilityStateを調べる
  2. documentに対して"visibilitychange"イベントが飛んで来るので捕まえて状態変化を追いかける

詳しくみてみましょう。

document.visibilityState

スペックによれば、"hidden", "visible", "prerender", "unloaded"の4つのいずれかの文字列を持つようです。我々の目的としては"visible"かどうかを確認すれば十分でしょう。

この値は、サイズ0のiframeのような実質見えていないdocumentに対しても、親documentが"visible"なら"visible"を返してくれるようです。こうした隠しフレームでもわざわざ親とメッセージのやり取りをしなくて済むのはありがたいです。

"visibilitychange"イベント

document.visibilityStateで現在の状態を調べる事ができました。しかし、バックグラウンドになった瞬間に再生を止めたい、といった用途には不向きです。そこで必要となるのがイベント監視です。

document.addEventListener('visibilitychange', () => {
  // document.visibilityStateを調べて何かする。例えば……
  if (document.visibilityState == "visible")
    foo.resume();
  else
    foo.suspend();
}, false);

基本的にはこれだけ。バックグラウンドに回る際、アクティブになる際などにそれぞれイベントが届きます。どちらのステートに遷移したかはdocument.visibilityStateを調べれば良いでしょう。必要に応じて再生を止める、音量を下げる、アルゴリズムを低負荷な物に変えるなどの処置をしましょう。

ScriptProcessorNodeとうまく付き合う

AudioWorkletによってdeprecateが決まっているScriptProcessorNodeですが、AudioWorkletの実装が出揃うまでもうしばらくはうまく付き合う必要がありそうです。そこで今更ですがPage Visibility APIと組み合わせてなるべくノイズを載せずに再生する方法を紹介したいと思います。

先回りしてレンダリング

波形のレンダリングは"audioprocess"イベントの中で行ういのが基本ですが、ノイズを出さないためにもイベントが発行されたら可能な限り即座に波形を返す必要があります。そこで思いつくのが、レンダリングは事前に行っておき、イベントが起きたらレンダリング済みのデータをコピーする、という手法です。

prerenderBuffer() {
  // 実際の波形生成処理
}

scriptProcessor.addEventListener('audioprocess', function(e) {
  // prerenderBuffer()で生成済みのデータをe.outputBufferにコピーするだけ
  copyFromPrerenderedBuffer(e.outputBuffer);

  // 次の波形を用意するための関数をsetTimeoutでキューに積む
  setTimeout(prerenderBuffer, 0);
}, false);

ここではまずcopyFromPrerenderedBuffer()を呼び出し、レンダリング済データをe.outputBufferに書き出します。そして、次のレンダリングをsetTimeoutを使って非同期に呼び出しています。ここでprerenderBuffer()を直接呼ばずにsetTimeoutを使ってキューに積む点が重要です。これにより、次のレンダリングを開始する前にコピーした波形をWeb Audio APIに返す事ができます。

イベントリスナーから抜けて波形がWeb Audio APIに送り出されると、他にタスクが無ければprerenderBuffer()がすぐに実行されるでしょう。ところがこの方法で問題になるのがバックグラウンド期間です。バックグラウンドに回るとsetTimeoutは、例え0が指定されて他の仕事がなかったとしても、一定の頻度でしかタイマーを発火してくれません。一方でaudioprocessは結構な頻度で処理が必要になります。ざっくり48kHz再生、バッファサイズ2048だとしても2/48秒=秒間24回の呼び出しです。タイマーの間隔は1秒程度まで低下しますから、全然間に合いません。よって、バックグラウンドでは再生しない、あるいは再生方法を変える必要があります。

scriptProcessor.addEventListener('audioprocess', function(e) {
  var visible = document.visibilityState == 'visible';
  // 見えない場合は諦めて直接prerenderBuffer()を呼んでしまう
  if (!visible)
    prerenderBuffer();
  copyFromPrerenderedBuffer(e.outputBuffer);

  // 見えている場合のみsetTimeoutを使ったプリレンダリングを行う
  if (visible)
    setTimeout(prerenderBuffer, 0);
}, false);

どうせプリレンダリングを止めるなら、最初から普通にレンダリングしておけば良かったのでは……と思うかもしれませんが、アクティブの時には画面描画などの処理も重く、容易に再生が間に合わなくなる状況にあります。一方でバックグラウンド中は描画を完全に止めておけば良いわけですから(requestAnimationFrameを使っていれば自然とそうなります)、その分audioprocessイベントをリアルタイムでさばく余裕もあります。より完璧を目指すなら、

prerenderBuffer() {
  // 直接呼ばれた場合、呼び出しが重複するのでその帳尻を合わせる
  if (isPrerenderedBufferReady)
    return;

  // 実際の波形生成処理

  isPrerenderedBufferReady = true;
}

scriptProcessor.addEventListener('audioprocess', function(e) {
  // プリレンダリングが間に合っていなければ直接prerenderBuffer()を呼ぶ
  if (!isPrerenderedBufferReady)
    prerenderBuffer();
  copyFromPrerenderedBuffer(e.outputBuffer);
  isPrerenderedBufferReady = false;

  // 見えている場合のみsetTimeoutを使ったプリレンダリングを予約
  // 実は常に積んでしまっても良いのかもしれませんが……
  if (document.visibilityState == 'visible')
    setTimeout(prerenderBuffer, 0);
}, false);

document.addEventListener('visibilitychange', () => {
  // フェードはprerenderBuffer、audioprocessリスナーなどで適宜実装しましょう
  // Gainノードに繋げてAudioParamでオートメーションを書くと楽ですね
  if (document.visibilityState == "visible")
    startFadeIn();
  else
    startFadeOut();

このようにして、変わり目が綺麗に繋がるようにした上で、イベントを監視してフェードイン・フェードアウトをかけてやるのはどうでしょうか?

まとめ

  • Page Visibility APIを使ってユーザーに嫌われないページを作りましょう
  • ScriptProcessorを使う時はうまくプリレンダリングするとローエンドマシンにおけるノイズ発生を低減できます
この投稿は WebAudio Web MIDI API Advent Calendar 201615日目の記事です。