24
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

InfocomAdvent Calendar 2020

Day 16

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

Last updated at Posted at 2020-12-15

はじめに

この記事は 非公式Infocom アドベントカレンダー2020 16日目の記事です。

はじめに2

以前、Google Meetでもバーチャル背景を使いたい という記事で書いたように、body-pixを使ってバーチャル背景を実現する Chrome 機能拡張「Chrome Virtual Camera」を作って公開していました。
が、2020年11月にMeetがバージョンアップし、背景ぼかしだけでなくバーチャル背景も使えるようになりました。そのためこの機能拡張は用済みになってしまいました。

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

拡張機能を使ってみる

Chrome Extension の読み込み

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

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

chrome_ext_audiomix.png

対象サイトでの利用

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

chrome_ext_audiomix_panel.png

Google Meetで使う場合

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

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

meet_option_audio.png

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

注意点

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

利用している要素

WebAudio API

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

Chrome Extension (機能拡張)

自分のアプリではないのでコードに直接手を入れることはできません。そこで前回と同じく、Chrome Extensionを使って処理を横取りします。

実装

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

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

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

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

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

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

audiofile.png

コードの抜粋はこちら

    // --- 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

コードの抜粋はこちら

    // -- 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

コードの抜粋

    // 新規のメディアストリーム
    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アプリに対して行うのが目的です。

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

hook_user_media.png

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

  // 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要素をいじったいるすることができます。以前の記事と同様ですが、tenserflow.jsを使わない分シンプルです。

manifest.json

Chrome Extension を使うには、manifest.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 の処理は、次のようになっています。

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要素を差し込んでいます。

// --- ファイル選択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

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で実現できれば、自分でも色々なエフェクトや効果音が追加できますね。

24
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
24
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?