はじめに
この記事は 非公式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 で、対象のサイトにアクセスする
- 左上に [+][_] ボタンを持つ小さなパネルがオーバーレイ表示される
- [+]をクリックするとパネルが広り、オーディオファイルの選択や音量調整が可能
- [_]をクリックすると、パネルが左下に移動
- [Choose File]でBGMで流したいオーディオファイルを選択
- 別のファイルを選ぶと、切り替わります
- [clear]ボタンで、選択がクリアされオーディオ再生は停止
- [Audio Gain]のスライダーで音量を調整
- [Playback]をチェックすると、自分のブラウザでもモニター再生(BGMを確認)
- [Balance]のスライダーで、デバイス(マイク)とオーディオファイル(BGM)のバランスを調整
- この後、Webアプリを操作してカメラ映像/マイク音声を取得すると、BGMと合成された音声が取得できます
- ※この際、[Playback]のチェックが外れモニター再生は停止します
- ※こちらのシンプルなテストページでも利用できます
- https://mganeko.github.io/simple_usermedia_testpage/
- [audio]をチェック
- [start video]をクリック
- Video要素の音量を上げる
Google Meetで使う場合
残念ながら今回の機能拡張は、Google Meetのようにユーザー操作を待たずにページロードの際にマイク音声を取得するアプリでは効果がありません(フックが間に合いません)。そこで次の手順でマイク音声を再取得すると有効になります。
- 「その他のオプション」でメニューを表示し、「設定」ダイアログを開く
- 「音声」でマイクデバイスを一旦他のマイクに切り替え、その後使いたいマイクを再選択する
- 設定ダイアログを閉じる
これでマイクを含むMediaStreamが再取得され、フックで差し替えた処理により選択したBGMが合成されます。
注意点
- 相手に流れている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 で音量調整
図にするとこんな感じです。
コードの抜粋はこちら
// --- 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%になるようにバランスを調整
図にするとこんな感じです。
コードの抜粋はこちら
// -- 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を、通信に利用
図にするとこんな感じです。
コードの抜粋
// 新規のメディアストリーム
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()をフックして、こちらで用意した処理に差し替えます。
実際に差し替える処理は次のようになります。
// 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_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 の処理は、次のようになっています。
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%)
<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ですが、[+][_] という簡易ボタンが並んだパネルを左上に表示しています。
- [+]をクリックすると、パネルが広がってオーディオファイルの選択や、合成のバランスを調整できます
- [_]をクリックすると、パネルが左下に移動します
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で実現できれば、自分でも色々なエフェクトや効果音が追加できますね。