search
LoginSignup
8

More than 1 year has passed since last update.

posted at

updated at

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

はじめに

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

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
What you can do with signing up
8