Safari の AudioWorklet で音がちゃんと出ない話

この記事は当初 2021/5/4 時点の Safari 14.1 (16611.1.21.161.3) で調査したものですが、2023/03/25 に Safari 15.6 で再調査して大幅に加筆修正しました。

# はじめに

[Safari 14.1](https://webkit.org/blog/11648/new-webkit-features-in-safari-14-1/) でついに [AudioWorklet](https://developer.mozilla.org/en-US/docs/Web/API/AudioWorklet) が利用可能になりました。早速使ってみたのですが、まったく音が出せずに1日、音が出てからも音が出る条件がよく分からず、合計3日ほどハマりました。せっかく人柱になったので、ハマりどころを書いてみようと思います。

なお、筆者は macOS のデスクトップ版 Safari のほか、 iOS の モバイル Safari でも本記事の内容を確認しています。

# Chrome / Firefox では音が出るが、 Safari では音が出ないコード

はじめに Chrome や Firefox では音が出るのに、Safari では出ないコードを掲載します。画面に出た PLAY ボタンを押すと、440Hzのサイン波が鳴る、という単純なものです。

お手元で動作確認される場合は、以下の3ファイル (index.html index.js processor.js) を localhost:8000 あたりで配信して index.html を開いてみてください（localhost 以外に置く場合は https 通信ができるセキュアな場所に置かないと AudioWorklet は利用できません）。

```index.html
<!DOCTYPE html>
<html>
<head>
  <script src="./index.js"></script>
  <style>
    button#play:after { content: 'PLAY'; }
    .playing button#play:after { content: 'NOW PLAYING (RELOAD BROWSER TO STOP)'; }
  </style>
</head>
<body><button id="play"></button></body>
</html>
```

```index.js
let audioContext;
let node;
let isPlaying;

window.addEventListener('DOMContentLoaded', async () => {
  document.getElementById('play').addEventListener('click', async () => {
    if (isPlaying) return;
    isPlaying = true;
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    await audioContext.audioWorklet.addModule('./processor.js');
    node = new (window.AudioWorkletNode || window.webkitAudioWorkletNode)(audioContext, 'my-processor');
    node.connect(audioContext.destination);
    document.body.classList.add('playing');
  });
});
```

```processor.js
export class MyAudioWorkletProcessor extends AudioWorkletProcessor {
  static get parameterDescriptors() { return [] };
  process(inputs, outputs, parameters) {
    const output = outputs[0];
    output.forEach((channel) => {
      for (let i = 0; i < channel.length; i++) {
        channel[i] = 0.5 * Math.sin(440 * 2.0 * Math.PI * (currentFrame + i) / sampleRate);
      }
    });
    return true;
  }
}
registerProcessor('my-processor', MyAudioWorkletProcessor);
```

# Safari で音がでない原因

## UIイベント処理期間内で addModule すると音がでない
WebAudioは、ユーザー操作なしで勝手に音を再生する行儀の悪いサイトができないように、UIイベントの処理期間中に AudioContext 生成や AudioNode との接続を行う必要があります。そうでないと音が出ません。

しかし、以下のように UIイベントの処理期間内で、AudioContext の生成と addModule を行うと、Chrome/Firefox では音が出ますが、Safari では音が出ません。

```javascript
window.addEventListener('DOMContentLoaded', async () => {
  document.getElementById('play').addEventListener('click', async () => {
    ...
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    await audioContext.audioWorklet.addModule('./processor.js');
    node = new (window.AudioWorkletNode || window.webkitAudioWorkletNode)(audioContext, 'my-processor');
    node.connect(audioContext.destination);
    ...
  });
});
```

なぜ Chrome と Safari で差が出るかというと、UIイベントの処理期間内で AudioContext を new した場合、

- Chrome は AudioContext が実行状態（AudioContext.state == "running"）で生成されるが
- Safari は AudioContext が停止状態（AudioContext.state == "suspended"）で生成される

という差があるためのようです。Safari はこの後（UIイベントの処理期間内で）AudioContext.destination に何らかの AudioNode を connect すると AudioContext が実行状態になるのですが、AudioContext.audioWorklet.addModule は非同期なので、これを呼び出してしまうと、UIイベントの処理期間ではなくなってしまいます。したがって、addModule 以降に何を書いても AudioContext は停止状態のままとなってしまいます。結果として、音が出ません。

対処としては addModule の前に、UIイベントの処理期間内で、AudioContext.resume() を呼びます。

```javascript
window.addEventListener('DOMContentLoaded', async () => {
  document.getElementById('play').addEventListener('click', async () => {
    ...
    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
    await audioContext.resume(); // 明示的にリジュームする
    await audioContext.audioWorklet.addModule('./processor.js');
    node = new (window.AudioWorkletNode || window.webkitAudioWorkletNode)(audioContext, 'my-processor');
    node.connect(audioContext.destination);
    ...
  });
});
```

なお、AudioContext.resume() は Promise を返しますが、Safari の場合、resume()を誤ってUIイベントの処理期間外に呼び出してしまうと、Promise が無限に完了しません。UIイベントハンドラ内かどうか確証がない場所では、次のように resume がタイムアウトできるように実行したほうが安全と思います。

```javascript
  async resumeWithTimeout(audioContext) {
    if (audioContext.state == 'suspended') {
      await Promise.race([
        audioContext.resume(),
        new Promise((_, reject) => {
          setTimeout(() => reject(
            new Error('Failed to resume AudioContext. Make sure this is called within the call stack of a UI event handler.')
          ), 1000);
        }),
      ]);
    }
  }
```

## 出力デバイスを切り替えると音がノイズだらけになる

演奏中にOS側で出力デバイスを切り替える（例えばスピーカー→ヘッドホン）と音がノイズだらけになります。
元のデバイスに戻すと直りますが、切り替え先のデバイスで正しく演奏しようとすると、Safariを再起動する必要があります。
ウィンドウやタブを閉じても直りません。

ScriptProcessorNodeでは発生せず、AudioWorkletでのみ発生するようです。
筆者は以下の環境で再現を確認しました。

- MacBook Air (M2, macOS 12.5 Monterey, Safari 15.6)
- MacBook Pro 2018 (x86, macOS 12.5 Monterey, Safari 15.6)
- iPhone 12 mini (iOS 16.3)


## iOSでSafariをバックグラウンドに回すと、フォアグラウンドに戻しても演奏が再開しない

これはMDNに[Resuming interrupted play states in iOS Safari
](https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state) という解説があり、Safariをバックグラウンドに回すと AudioContext が interrupted 状態になって停止するようです。

interrupted状態になった後は、明示的に AudioContextのresumeを呼ぶ必要があるようです。

以下のようすれば、Safariをフォアグラウンドに戻した際に演奏を再開できます。

```javascript
document.addEventListener("visibilitychange", () => {
  console.log(document.visibilityState);
  console.log(audioContext.state);
  if (document.visibilityState === "visible") {
    audioContext.resume();
  }
});
```

## [解決] AudioWorkletProcessor を継承したクラスに export 修飾子があると Safari だけエラーになる

こちらの問題は Safari 14.1 で発生していましたが、15.6 で確認したところ、起きなくなっていました。
以下は古い内容です。

class 宣言にうっかり export を書いても、Chrome と FireFox は問題ありませんが、 Safari では動作しません。 audioWorklet.addModule 時点では何もエラーが出ませんが、AudioWorkletNode の生成時に `No ScriptProcessor was registered with this name` というエラーが出てしまいます。

```javascript
export class MyAudioWorkletProcessor extends AudioWorkletProcessor {
^^^^^^
```

ちなみにtypescriptを使っていてモジュール形式を es系 (es2015とかesnext) にすると、コンパイル後のクラス宣言にexport指定がついてしまいますので、typescriptの場合は モジュール形式にes系以外を選ばざるを得ないということになります。

おそらく内部的には JavaScript のパース時にエラーが出ていると思うのですが、恐ろしいことに Safari では AudioWorkletProcessor の js ファイル側で生じたエラーは、構文エラーであろうとランタイムエラーであろうと、一切 console.log に出力されないため、確認の術がありません。Worklet の動作状態も観察するすべがないので、現時点では Safari を使った AudioWorkletProcessor のデバッグは不可能と思って良いと思います。

なお、AudioWorkletProcessor のログが見えないという問題は、WebKit の Bugzilla に [Unable to log to console from Audio Worklet processor](https://bugs.webkit.org/show_bug.cgi?id=220039) が2020年の12月ごろに登録済みで、既知の問題ではあるようです。

# おわりに
音は鳴ったものの、現時点の Safari で AudioWorklet を本番投入するのはリスキーだなという感じです。

AudioWorkletProcessor の動作ログが一切見えず、デバッグの難易度が高い点はどうにか postMessage などをやりくりするとしても、再生中に出力デバイスを変更できないのは厳しい感じです。

一方 Chrome なら安心して AudioWoklet が使えるか、というと[まだメモリリークがあったりする](https://bugs.chromium.org/p/chromium/issues/detail?id=1298955)ので、こちらもこちらでなかなか本番に導入するのはためらわれますが...。