ipyaudioworklet: Audio Worklet Processor を定義した custom ipywidgets を作った話

# 発端

- Jupyter Lab で音を録りたい。
  - localhost で動かすなら、`sounddevice` で録音できるが、遠隔サーバで動く Jupyter Lab ではそうもいかない
  - クライアントPCのウェブブラウザで音を録れたらいいな。Web Audio APIに手を出すか！
- [ipywebrtc](https://github.com/maartenbreddels/ipywebrtc) は欲しいものに近いのだが、やはり ScriptProcessor を使っているようである。
  - 現状では、ScriptProcessor を使った例を公開しているウェブページが多い印象。
  - 映像はいいから、音声をサクッと手に入れたいのだ。

# Jupyter extension の開発

ipyaudioworklet という Jupyter Lab Extension 作りました。
正確には、ipywidgets の custom widgets なのかもしれませんが、Jupyter Lab で動かすことしか考えていません。

https://github.com/naoh16/ipyaudioworklet

https://pypi.org/project/ipyaudioworklet/

Binder を使って、デモとして、録音GUIの表示と、録音後のちょっとしたアプリケーション例を公開しています。

https://mybinder.org/v2/gh/naoh16/ipyaudioworklet/main?labpath=examples

> ipywidgets に依存しているので、Google colabでは、多分動かないと思われます。

## ウリ

- **どこかの開発ノード上で動かしている Jupyter Lab を使っていても、**
- **クライアントPCのマイクで収録した音を、**
- **OPUS encoding されたWAVファイルではなく、生のPCMとして録音できるし、**
- **一発で numpy.ndarray(dtype=numpy.float32) として音声データ列が手に入るよ**

という言葉が刺さる音声研究者は多い・・・と思っています。

## オフィシャル寄りな情報源

だいたい、以下を参考にしながら作ることになりました。

https://developer.mozilla.org/ja/docs/Web/API/Web_Audio_API

https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API/Using_AudioWorklet

https://developer.mozilla.org/ja/docs/Web/API/AudioWorkletNode/port

> ただ、公式の情報通りに各ブラウザが実装しているとも限らない。。。

## 興味深かった点

どうやら最近の API では、Auto Gain Control, noise suppression とか echo cancel が内蔵(?)されているんですね。
明示的に無効化しないと、自動ですべて有効化されるようです。（デバイス依存？）

ipyaudioworklet では、あえて無効化しましたが・・・

# 開発上のつらかった点

Jupyter Lab と Web Audio API が混ざることで、つらさの化学反応が発生します。

以下、乱文。

## 主に Web Audio API 関係

OSやブラウザごとに挙動が違いすぎる、の一言に尽きるのです。
例えば：

### sampleRate を変える実装は実現できるのだろうか・・・？
  - 基本的には、AudioContext と [MediaDevices.getUseMedia()](https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia) の constraints の両方で sampleRate を指定すればよさそうだが、まぁ動かない。
    - そもそも AudioContext で指定したところで、返ってくるインスタンスは sampleRate が固定される。
  - getUserMedia の constraints でsampleRateを指定するようなサンプルが、巷にあふれかえっているが、すごくヤバい。
  　- 何を指定しようが AudioContext のsampleRate依存で録っている動きをするブラウザがある。
　  - そして、AudioTrack インスタンス の getSettings() の戻り値を確認すると、constraints で指定した値を返してくれたりする。（だが、この数字はまったく意味がない）
    - 44,100 Hz を指定したつもりが、実際は 48,000 Hz で録られている、なんてのもある。
    - そのぐらいの差だと、自分の音声とかを聞きなれていない人は「こんなものか」と思ってしまうのではないか。
  - AudioContext はデフォルトのまま生成されるがままに利用、getUserMedia の constraints の sampleRate には AudioContextインスタンスのsampleRate を与える、という実装が一番無難な形だった。

### 32 bit float で音データが得られるが、ハードウェア的に量子化ビット数が 32 bit というわけではない

  - 量子化ビット数が 16 bit だろうが 24 bit だろうが、ともかく得られるのは -1 ～ +1 というだけ。
    - 16 bitの整数を32768($=2^{15}$)で割った実数を手に入れたところで、収録時点の量子化ビット数は16 bitなわけで。。。
  - getUserMedia の後で、実際に設定された量子化ビット数(sampleSize)を手に入れられるが、これまたブラウザ依存であり、値を返すブラウザと、そんな項目すら返してくれないブラウザが存在する。
  - 結局のところ、constraints で sampleSize は与えられそうだが、sampleRateに関する挙動を思うと、正しく働いているのかどうか、定かではない。

### Noise supplession 等の機能は面白い、が、、、チャネル数が変わってしまう

マルチチャネル信号を処理して、シングルチャネル信号を作ってると考えれば、それはそう、と納得できる。
だが・・・

- `autoGainControl`, `echoCancellation`, `noiseSuppression` を `False` にした場合、チャネル数がマチマチになる
  - ブラウザによって、2チャネル返ってきたり、1チャネル返ってきたり。
  - constraints でチャネルを 1 にしたら 1 にしてほしい。(exact指定しても効かない)
- 無駄が多いなと思いつつ、ipyaudioworklet では、全チャネルを平均する処理を強制している。


## Jupyter Lab (or, ipywidget extension) 関係

- Jupyter Lab 側の公式のチュートリアルを実行しようとすると、日本語圏のWindowsユーザは、おそらくテンプレを用意することすらできない
  - 参考：https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html
  - [copier](https://copier.readthedocs.io/en/stable/) の問題。
    - https://peps.python.org/pep-0540/ を参考に、windows端末なら `set PYTHONUTF8=1` で環境変数設定すれば、問題なく作れるようになる。
- Jupyter Labチュートリアルが難しい
  - これは、自分の理解力＆技術力がなさすぎるせいなので、どうしようもない。。
- ipywidgets のチュートリアルに沿って作ると比較的楽だとわかったので、そちらに移行。
  - 参考：https://github.com/jupyter-widgets/widget-ts-cookiecutter

## 化学反応

- Web Audio API のいくつかについては、**HTTPSコンテキスト** が必要とされている。
  - 氾濫している ScriptWorkletProcessor なら考えなくてもよい
  - Jupyter Lab の拡張機能として動かしたいのだから、Jupyter Lab でもSSL証明書を与えないといけない
    - 適切な管理者がいるような Jupyter Hub Lab でも使ってない限り、http で使い続ける人が多いのでは。（音の研究者では、そんなこと考える人は限りなくレアと思われる。）
    - オレオレ証明書の作成経験があるとどうにかなる。（そんな経験がある、オーディオエンジニアがどれぐらいいるというのか・・・？）
- AudioWorkletProcessor の実装は、呼び出し側とは独立した JavaScript ファイルとして用意する必要がある
  - ipywidget の custom widget テンプレで typescript による実装を選ぶと、webpack がもれなく1つのjsファイルにまとめて minify してくれるし、なんならファイル名もユニークな名前にしてくれる。（固定の名前で参照できない。）
  - 強引な方法で独立した js ファイルをextensionディレクトリにコピーするという荒業で回避はできる。（もっといい方法がありそうだが、これもウェブ系の開発知識不足でどうにもできない。）


# おわりに

音のことをよくわかっていて、かつ、Web系の開発の知識も豊富で、かつ、Windows & Linux (& Mac) にある程度精通していて、かつ、サーバ管理もどんと来い、みたいな人材が世の中にもっと増えたらいいな、と思いました。

# Acknoledgements

素晴らしい先人の開発者の皆様に感謝します。

https://www.hiromasa.info/posts/25/

https://zenn.dev/tatsuyasusukida/articles/097321c14ec6f5

https://github.com/maartenbreddels/ipywebrtc