tensorflow.jsとWebRTCを組み合わせて、プライバシー保護のビデオチャットを作ってみた（後編）

# はじめに

これは[Infocom Advent Calendar 2019](https://qiita.com/advent-calendar/2019/infocom) 20日目の記事です。

過去に社内でWebRTCを使ったビデオ会議のデモをした際に、「在宅勤務で使うときに、自分の顔よりも部屋の様子（背景）を隠したい」というリクエストを受けたことがありました。

それを実現すべく次の要素を組み合わせたサンプルを作ったので、[前回](https://qiita.com/massie_g/items/af3f8eb1e2bf5e807ba6)に引き続き紹介します。

- [tensorflow.js](https://github.com/tensorflow/tfjs) の人体検出モデル [tfjs-models/body-pix](https://github.com/tensorflow/tfjs-models/tree/master/body-pix) (Apache 2.0 ライセンス)
- WebAudioを使ったピッチシフト [Pitch shifter](https://github.com/urtzurd/html-audio) (MIT ライセンス)
- WebRTC シグナリングサービス [Ayame Lite](https://ayame-lite.shiguredo.jp/beta)（株式会社時雨堂)
    - そのSDK [ayame-web-sdk](https://github.com/OpenAyame/ayame-web-sdk)(Apache 2.0 ライセンス)

# おさらい

[前回](https://qiita.com/massie_g/items/af3f8eb1e2bf5e807ba6)はtensorflow.js+body-pixを使って、カメラで取得した映像から人物 or 周囲（背景）をマスクする部分を実装しました。

# Canvasでマスクした映像を取得

Canvasは画面に見えるだけですが、これを今後の通信で使うために、captureStream()を使って映像ストリーム（MediaStream）として取得します。引数として、fps（フレームレート）を指定することもできます。

```js
      let canvasStream = canvas.captureStream();
```

# 音声のピッチシフト加工

人体をマスクする場合に、声も変えたいことがあると思います。そのため、WebAudioを使ったピッチシフトで声も加工できうようにしました。

処理は基本的に [Pitch shifter](https://github.com/urtzurd/html-audio) (MIT ライセンス) を踏襲しています。そのままでは動かなかったので、必要な部分を抜き出して利用させてもらいました。

カメラ映像と同様にマイク音声をgetUserMedia()で取得し、それをWebAudioのScriptProcessorで加工しています。元の音声と加工音声を切り替えられるように、gainNodeでミックスしています。

[構成図]

<img width="1010" alt="webaudio_pitch.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/cc7277b1-e095-1dec-a73a-e36a940e3a5a.png">

```js
    function initSoundProcesser(stream) {
      clearAudioNode();

      audioSrcNode = audioContext.createMediaStreamSource(stream);
      mediaDestNode = audioContext.createMediaStreamDestination();
      voiceChangeStream = mediaDestNode.stream;

      normalGainNode = audioContext.createGain();
      normalGainNode.gain.value = 0;
      audioSrcNode.connect(normalGainNode);
      //normalGainNode.connect(audioContext.destination);
      normalGainNode.connect(mediaDestNode);

      // ---- pitch shifter ---
      pitchShifterProcessor = audioContext.createScriptProcessor(grainSize, 1, 1);
      pitchShifterProcessor.buffer = new Float32Array(grainSize * 2);
      pitchShifterProcessor.grainWindow = hannWindow(grainSize);
      pitchShifterProcessor.onaudioprocess = function (event) {
        const inputData = event.inputBuffer.getChannelData(0);
        const outputData = event.outputBuffer.getChannelData(0);

        // ... 詳細は https://github.com/urtzurd/html-audio を参照 ...

      };
      audioSrcNode.connect(pitchShifterProcessor);

      shiftGainNode = audioContext.createGain();
      shiftGainNode.gain.value = 1.0;
      pitchShifterProcessor.connect(shiftGainNode);
      //shiftGainNode.connect(audioContext.destination);
      shiftGainNode.connect(mediaDestNode);
      
    }
```

# 映像との連結

Canvasから取得した映像と、WebAudioで加工した音声を、1つのメディアストリームにまとめます。新しくMediaStreamのインスタンスを作り、そこに映像トラックと音声トラックを追加します。

<img width="1036" alt="concat_video_audio.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/cbded50e-ac9a-c56d-27de-d40660ec8dd4.png">


```js
      // 新しいメディアストリーム
      modifiedStream = new MediaStream();

      // 映像加工を開始
      const maskStream = startCanvasVideo();
      modifiedStream.addTrack(maskStream.getVideoTracks()[0]); // 映像トラックを追加

      // 音声加工を開始
      const ctx = initWebAudio();
      pitchShifter = new PitchShifter(0.7);
      pitchStream = pitchShifter.initSoundProcesser(ctx, localStream);
      modifiedStream.addTrack(pitchStream.getAudioTracks()[0]); // 音声トラックを追加
```


# WebRTC で通信

映像と音声の準備ができたので、WebRTCを使って通信します。今回は手軽に1対1通信ができる、[Ayame Lite](https://ayame-lite.shiguredo.jp/beta) (株式会社時雨堂 運営)を使ってみました。

- 未登録の場合 ... 認証なしのシンプルな通信
- 登録ありの場合 ... 認証あり、STUN/TURNも利用可能

となっています。無償で無保証のサービスです。

実際の利用には各種SDKとサンプルがOSSとして用意されているで、そちら利用するのが便利です。

- [ayame-web-sdk](https://github.com/OpenAyame/ayame-web-sdk) (Apache 2.0 ライセンス)
- [ayame-web-sdk-samples](https://github.com/OpenAyame/ayame-web-sdk-samples)(Apache 2.0 ライセンス)

今回の利用例はこちら。

```js
// -------- ayame -----------
    // --- 接続処理 ---
    async function connect() {
      let roomId = roomIdInput.value;
      if ((!roomId) || (roomId === '')) {
        roomId = defaultRoomId
      }

      if (!ayameConn) {
        ayameConn = Ayame.connection(signalingUrl, roomId);
      }

      await ayameConn.connect(modifiedStream);

      ayameConn.on('connect', () => {
        console.log('ayame connected. ayame state=', ayameConn.connectionState);
      });
      ayameConn.on('disconnect', (e) => {
        if (remoteVideo.srcObject) {
          remoteVideo.pause();
          remoteVideo.srcObject = null;
        }

        ayameConn.disconnect();
      });
      ayameConn.on('addstream', async (e) => {
        remoteVideo.srcObject = e.stream;
        await remoteVideo.play().catch(err => console.warn('remote play ERROR:', err));
        remoteVideo.volume = 0.5;
      });
      ayameConn.on('removestream', () => {
        remoteVideo.pause();
        remoteVideo.srcObject = null;
      });
    }

    // --- 切断処理 ---
    function disconnect() {
      if (!ayameConn) {
        return;
      }
      ayameConn.disconnect();

      if (remoteVideo.srcObject) {
        remoteVideo.pause();
        remoteVideo.srcObject = null;
      }
    }
```

# ここまでのデモ（映像マスク＋音声変換の通信）

ここまでのデモがこちらです。Webカメラが必要です。

- ソースコード(GitHub) [video_mask_voice_ayame.html](https://github.com/mganeko/bodypix_ayame/blob/master/video_mask_voice_ayame.html)
- デモページ(GitHub Pages) [video_mask_voice_ayame.html](https://mganeko.github.io/bodypix_ayame/video_mask_voice_ayame.html)


- 準備 ... 2台のPCで、それぞれブラウザを開く(Chrome 78で動作確認）
    - 1台のPCで、2つのウィンドウでも可
    - ハウリング防止のため、ヘッドフォンの利用を推奨
- 開始
    - それぞれのブラウザで [video_mask_voice_ayame.html](https://mganeko.github.io/bodypix_ayame/video_mask_voice_ayame.html) を開く
    - それぞれのブラウザで[Start Video] ボタンをクリック
        - 自分の映像が、左上にビデオ要素に表示
        - 数秒待つとBody-Pixが初期化され、右上のビデオ要素にマスクされた映像が表示
    - マスクの種類は選択可能
        - mask background ... 人物の周囲の部屋の様子をマスク
        - mask person ... 人物をマスク
        - no mask ... マスク無し
    - 音声の種類を選択可能
        - mute ... 音声ミュート
        - low pitch ... 低いピッチの声
        - high pitch ... 高いピッチの声
        - normal ... 通常の声
    - Ayame Lite サービスに接続する room-id を指定
    - それぞれのブラウザで [connect] ボタンをクリック
- 停止
    - [disconnect] ボタンをクリック
    - [Stop Video] ボタンをクリック

<img width="560" alt="bodypix_capture.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/0d68116b-0427-8987-7976-6d2fa4c4dec6.png">

# おわりに

WebRTCの通信には今回 [Ayame Lite](https://ayame-lite.shiguredo.jp/beta) を利用しましたが、他にも [Skyway](https://webrtc.ecl.ntt.com) や最近WebRTCをサポートした [Amazon Kinesis Video Streams](https://aws.amazon.com/jp/about-aws/whats-new/2019/12/amazon-kinesis-video-streams-adds-support-for-real-time-two-way-media-streaming-with-webrtc/) などを使うこともできるはずです。お試しください。