Google Meetでも使える、WebRTCでBGMをミックスする機能拡張

# はじめに

この記事は [非公式Infocom アドベントカレンダー2020](https://qiita.com/advent-calendar/2020/infocom) 16日目の記事です。

# はじめに2

以前、[Google Meetでもバーチャル背景を使いたい](https://qiita.com/massie_g/items/667627b6d12acc0163af) という記事で書いたように、body-pixを使ってバーチャル背景を実現する Chrome 機能拡張「[Chrome Virtual Camera](https://chrome.google.com/webstore/detail/chrome-virtual-camera/dphplfdpilhebmikohheeiblilajfmin/)」を作って公開していました。
が、2020年11月にMeetがバージョンアップし、背景ぼかしだけでなくバーチャル背景も使えるようになりました。そのためこの機能拡張は用済みになってしまいました。

そこで今のところGoogle Meetでは対応していない、自分の声とBGMをミックスして流す機能拡張を作成したので公開します。（やっていることは変わり映えしないのですが）

# 拡張機能を使ってみる

## Chrome Extension の読み込み

Chrome Web App には登録していないので、自分でChromeに読み込む必要があります。

- コードを https://github.com/mganeko/chrome_audio_mix からダウンロード
    - git clone https://github.com/mganeko/chrome_audio_mix
- 使いたいサイトに合わせ、manifest.json を編集
    - content_scripts, permissions のセクションを編集し、使いたいサイトを追加する
    - 同様に、使いたくないサイトを除外する
- 拡張機能の設定画面(chrome://extensions/)を開く
- 右上の「デペロッパーモード」を有効に
- 「パッケージ化されていない拡張機能を読み込む」からダウンロードしたリポジトリのフォルダーを選択し、読み込む
- 拡張機能のページで、読み込んだ「Chrome Audio Mix」が表示されれ、有効になっているのを確認

![chrome_ext_audiomix.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/dd5bed0d-64fe-60a1-b6e2-f67b5ca1a846.png)


## 対象サイトでの利用

- Chrome で、対象のサイトにアクセスする
    - 左上に [+][_] ボタンを持つ小さなパネルがオーバーレイ表示される
    - [+]をクリックするとパネルが広り、オーディオファイルの選択や音量調整が可能
    - [_]をクリックすると、パネルが左下に移動
- [Choose File]でBGMで流したいオーディオファイルを選択
    - 別のファイルを選ぶと、切り替わります
    - [clear]ボタンで、選択がクリアされオーディオ再生は停止
- [Audio Gain]のスライダーで音量を調整
- [Playback]をチェックすると、自分のブラウザでもモニター再生（BGMを確認）
- [Balance]のスライダーで、デバイス（マイク）とオーディオファイル（BGM）のバランスを調整
- この後、Webアプリを操作してカメラ映像/マイク音声を取得すると、BGMと合成された音声が取得できます
    - ※この際、[Playback]のチェックが外れモニター再生は停止します

![chrome_ext_audiomix_panel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/64c23331-724a-b3dd-02fe-c2725b26396b.png)


- ※こちらのシンプルなテストページでも利用できます
    - https://mganeko.github.io/simple_usermedia_testpage/
    - [audio]をチェック
    - [start video]をクリック
    - Video要素の音量を上げる


## Google Meetで使う場合

残念ながら今回の機能拡張は、Google Meetのようにユーザー操作を待たずにページロードの際にマイク音声を取得するアプリでは効果がありません（フックが間に合いません）。そこで次の手順でマイク音声を再取得すると有効になります。

- 「その他のオプション」でメニューを表示し、「設定」ダイアログを開く
- 「音声」でマイクデバイスを一旦他のマイクに切り替え、その後使いたいマイクを再選択する
- 設定ダイアログを閉じる

![meet_option_audio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/bfc45957-39a6-1278-9038-cfc4e95caadd.png)

これでマイクを含むMediaStreamが再取得され、フックで差し替えた処理により選択したBGMが合成されます。

## 注意点

- 相手に流れているBGMの音量がわからない
    - 残念ながら、実際に通信相手側でどれぐらいの音量でBGMが流れているかは分かりません
        - ※意外と大きな音量で流れているの注意
    - 万全を期すなら、別の端末でも接続して、そちらで音量を確認してください
        - ※この際はハウリングにご注意ください（ミュートにする、ヘッドフォンを使うなど）
- 流す音源の権利に注意
    - 録画されたり一般に公開されるイベントでは、音源の権利に配慮してご利用ください

# 利用している要素

## WebAudio API

MP3などの音声ファイルを再生したり、音を合成するのに利用します。Google Meetが内部で利用しているMediaStreamとも相互変換できます。

## Chrome Extension (機能拡張)

自分のアプリではないのでコードに直接手を入れることはできません。そこで[前回]((https://qiita.com/massie_g/items/667627b6d12acc0163af))と同じく、Chrome Extensionを使って処理を横取りします。

# 実装

実際のソースはGitHub ([mganeko/chrome_audio_mix](https://github.com/mganeko/chrome_audio_mix))にあります。処理の一部を簡略化して紹介します。

## マイク音声とBGM(オーディオファイル)の合成

### BGM(オーディオファイル)の再生

WebAudioを使って、次の様に処理を行います。

- FileRaderで読みこみ
- AudioContext.decodeAudioData()でデコード
- AudioBufferSouceNode で再生
- GainNode で音量調整

図にするとこんな感じです。

![audiofile.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/117a3565-0f73-33a5-3155-7f79d63e0484.png)

コードの抜粋はこちら

```js
    // --- file ---
    const audioFileInput = document.getElementById('camix_audio_file');
    const audioFileToPlay = audioFileInput.files[0];

    // --- read ---
    const reader = new FileReader;
    reader.readAsArrayBuffer(file);
    reader.onload = function (evt) {
      audioContext.decodeAudioData(reader.result)
        .then(buffer => {
          // --- ゲインを作成 ---
          const audioSourceGain = audioContext.createGain();

          // source を作成
          const audioSource = audioContext.createBufferSource();
          audioSource.buffer = buffer;
          audioSource.loop = true;
          audioSource.connect(audioSourceGain);

          // 再生開始
          audioSource.start(0);

          // モニター用出力
          audioSourceGain.connect(audioContext.destination);
        })
      }
```

### マイク音声の取得

- navigator.mediaDevices.getUserMedia()で取得


### マイク音声とBGMの合成

WebAudioを使って、次の様に処理を行います。

- マイク音声を含むMediaStreamを、WebAudioのMediaStreamAudioSourceNodeに変換
- BGM再生で取得したノードと、GainNodeを使って合成
    - 合計100%になるようにバランスを調整

図にするとこんな感じです。

![mix_mic_audio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/1dc98ee9-9cff-97b3-9c9b-54e716ced500.png)

コードの抜粋はこちら

```js
    // -- device --
    const deviceSource = audioContext.createMediaStreamSource(deviceStream);
    const deviceGain = audioContext.createGain();
    deviceGain.gain.value = 0.5;
    deviceSource.connect(deviceGain);

    // -- audio file --
    const audioGain = audioContext.createGain();
    audioGain.gain.value = 0.5;
    audioSource.connect(audioGain);

    // -- mix --
    const mixedOutput = audioContext.createMediaStreamDestination();
    deviceGain.connect(mixedOutput);
    audioGain.connect(mixedOutput);
    const mediaStream = mixedOutput.stream;
```

### 映像と音声の連結

- getUserMedia()では、カメラ映像とマイク音声を同時に取得
- 新規にMediaStreamを生成
- 映像トラックを取得(MediaStreamTrack)
    - →新規に作ったMediaStreamに追加
- 音声は前節の様に合成して得られたMediaStreamから、音声トラックを取得(MediaStreamTrack)
    - →新規に作ったMediaStreamに追加
- 新規に作ったMediaStreamを、通信に利用

図にするとこんな感じです。

![connect_video_audio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/32c9887a-31fc-797c-5a3b-44d795dae047.png)

コードの抜粋

```js
    // 新規のメディアストリーム
    const mixStream = new MediaStream();

    // --- video track ---
    const videoTrack = deviceStream.getVideoTracks()[0];
    if (videoTrack) {
      mixStream.addTrack(videoTrack);
    }

    // --- audio track ---
    const audioTrack = mediaStream.getAudioTracks()[0];
    if (audioTrack) {
      mixStream.addTrack(audioTrack);

      // -- stop device audio --
      audioTrack._stop = audioTrack.stop;
      audioTrack.stop = function () {
        audioTrack._stop();
        deviceStream.getAudioTracks().forEach(track => {
          track.stop();
        });
      };
    }
```

コードの中でautioTrack.stop()をフックしているのは、合成後の音声トラックが停止された時に、元のマイク音声の停止するための処理です。

## mediaDevices.getUserMedia()のフック

今回も自分の作ったアプリを対象とするのではなく、Google Meetのような既存のWebアプリに対して行うのが目的です。

- 参考：[Google MeetのWebカメラを加工してみよう！](https://techblog.securesky-tech.com/entry/2020/04/30/)

そこで、次のように mediaDevices.getUserMedia()をフックして、こちらで用意した処理に差し替えます。

![hook_user_media.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/64fe5905-94c9-ca3e-e39f-045db29a3c60.png)

実際に差し替える処理は次のようになります。

```js
  // getUserMedia()を差し替える
  function _replaceGetUserMedia() {
    if (navigator.mediaDevices._getUserMedia) {
      console.warn('ALREADY replace getUserMedia()');
      return;
    }

    navigator.mediaDevices._getUserMedia = navigator.mediaDevices.getUserMedia
    navigator.mediaDevices.getUserMedia = _modifiedGetUserMedia;
  }

  // 差し換え後のgetUserMedia()
  function _modifiedGetUserMedia(constraints) {
    // --- video constraints ---
    const withVideo = !(!constraints.video);

    // --- audio constraints ---
    const withAudio = !(!constraints.audio);

    // --- bypass for desktop capture ---
    if (constraints?.video?.mandatory?.chromeMediaSource === 'desktop') {
      return navigator.mediaDevices._getUserMedia(constraints);
    }

    // --- start media ---
    if (withAudio) {
      if (_isAudioFileSelected()) {
        // オーディオファイルが選択されていたら、音声を合成したメディアストリームを作成
        return _startMixedStream(constraints);
      }
      else {
        // オーディオファイルが選択されていない場合は、通常のメディアストリームを返す
        return navigator.mediaDevices._getUserMedia(constraints);
      }
    }
    else {
      // 映像だけの場合は通常の処理
      return navigator.mediaDevices._getUserMedia(constraints);
    }
  }
```

## Chrome Extension の Contents Script

上記のコードを使って既存のWebアプリをフックするのは、Chrome Extension を利用します。対象となるページにJavaScriptを差し込んだり、DOM要素をいじったいるすることができます。[以前の記事](https://qiita.com/massie_g/items/667627b6d12acc0163af#chrome-extension-%E3%81%AE-contents-script-%E3%81%AE%E5%88%A9%E7%94%A8)と同様ですが、tenserflow.jsを使わない分シンプルです。

### manifest.json

Chrome Extension を使うには、manifest.json を用意します。今回も次のような感じです。

```json:manifest.json抜粋
{
  "manifest_version": 2,
  "content_scripts": [
    {
      "matches": [
        "http://localhost:*/*",
        "https://meet.google.com/*"
      ],
      "js": [
        "loader.js"
      ],
      "run_at": "document_start"
    }
  ],
  "permissions": [
    "http://localhost:*/",
    "https://meet.google.com/"
  ],
  "web_accessible_resources": [
    "cs.js"
  ]
}

```

- content_scripts
    - maches: 対象とするサイトのURL（ここでは、localhostと、Google Meet）
    - js: 実行するJavaScript（用意しておいたjsファイルを、対象サイトに差し込む処理を担う
    - run_at: 上記jsを実行するタイミング。この例では、元のサイトに元々含まれるJavaScriptよりも先に実行する
- permissions ... このExtensionが操作できる対象サイトを指定（ここでは、localhostと、Google Meet）
- web_accessible_resources ... Extensionに埋め込んで配布するリソース
    - ここでは、サイトに差し込むJavaScriptファイル。マイク音声とBGMのオーディオファイルの合成処理を行う

### コンテンツを差し込む、loader.js

対象サイト読み込み時に先立って実行される loader.js の処理は、次のようになっています。

```js:loader.js
async function load() {
  const res = await fetch(chrome.runtime.getURL('cs.js'), { method: 'GET' })
  const js = await res.text()
  const script = document.createElement('script')
  script.textContent = js
  document.body.insertBefore(script, document.body.firstChild)
}

window.addEventListener('load', async (evt) => {
  // 元のindex.html の中の処理より後に呼ばれる
  await load()
}, true) // use capture

```

### 差し込まれるコンテンツ cs.js

差し込まれる cs.js は、色々な処理を行っていますが、主要な役割は次の3つです。

- navigator.meidaDevices.getUserMedia()をフックして、自前の処理を呼ぶように置き換える
- WebAudioを用いて、マイク音声とBGMのオーディオファイルの合成を行う
- オーディオファイルの選択や、合成のバランスを調整するUIを提供する

最初の2つはすでに概要を示しています。最後のUIを提供するのは、次のようにHTMLにDOM要素を差し込んでいます。

```js
// --- ファイル選択GUIを挿入 ---
  function _insertPanel(node) {
    try {
      const html_ja =
        `<div id="camix_gum_panel" style="border: 1px solid blue; position: absolute; left:2px; top:2px;  z-index: 2001; background-color: rgba(192, 250, 192, 0.7);">
          <div><span id="camix_gum_pannel_button">[+]</span><span id="camix_gum_position_button">[_]</span></div>
          <div id="camix_gum_control" style="display: none;">
            <label for="camix_audio_file">Audio File</label>
            <input type="file" accept="audio/*" id="camix_audio_file" />
            <button id="camix_clear_file">clear</button>
            <br />
            Audio Gain: <input type="range" id="camix_audio_file_range" min="0" max="200" value="100" step="1">Max(200%)
            &nbsp;
            <input type="checkbox" id="camix_playback_check" checked>Playback</input>
            <br />
            <br />
            Balance: Device <input type="range" id="camix_mix_range" min="0" max="100" value="50" step="1"> Audio File <br />
          </div>
        </div>`;
      const html_en = html_ja;

      // DOM要素を差し込む
      node.insertAdjacentHTML('beforeend', html_ja);
      }
 
      // イベントハンドラを追加
      node.querySelector('#camix_audio_file').addEventListener('change', (evt) => {
        loadAndPlay();
      }, false);

      // ... 省略 ...

    } catch (e) {
      console.error('_insertPanel() ERROR:', e);
    }
  }
```

今回もしょぼいUIですが、[+][_] という簡易ボタンが並んだパネルを左上に表示しています。

- [+]をクリックすると、パネルが広がってオーディオファイルの選択や、合成のバランスを調整できます
- [_]をクリックすると、パネルが左下に移動します

![camix_panel.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/26eeef5c-afc6-b210-877a-2827e37c641f.png)

### contents scriptが動くタイミング

- loader.js ... run_at:"document_start" を指定しているため、元のページのJavaScriptよりも前に動く
- cs.js ... 実際に差し込まれるのは window.onload()イベントの最初。元のページのbodyに直接書かれていたJavaScriptよりは後になる
    - そのため、なのでbodyで直接 getUserMedia()が呼び出されていると、フックする前に元々のgetUserMedia()が呼び出されてしまう

残念ながら、ユーザーの操作を待たずにカメラ映像/マイク音声を取得していまうサイトでは、今回のExtensionによる差し替えは通用しません。（つまり、Google Meetではそのままでは使えず、小細工が必要になってしまいます）

# おわりに

多くの仮想マイクとして見えるソフトがあるので、それを使えばGoogle MeetでもBGMやシステムの音を合成して利用できます。が、今回のようにChrome Extensionで実現できれば、自分でも色々なエフェクトや効果音が追加できますね。