WebRTC でビデオチャットアプリを作ってみた

リモート会議をやる機会が増えて、Google Meet、Microsoft Teams、Zoom などを利用している。ビデオチャットとか面白いなー、WebRTC とかいうヤツがあったと思うけど触ったことないなー、と思ったので、作ってみることにした。

目次

成果物とデモサイト

作成したコード全量は以下の GitHub リポジトリに格納した。

動作するデモは以下にデプロイした。

タブやブラウザ、端末を分けて2つからこのサイトにアクセスし、それぞれで画面上部の「Start」ボタンを押すと、ビデオ映像が双方向に通信されて、1対1のビデオチャットとして動作する。

動作確認したのは PC の Chrome 系ブラウザと Firefox と、iOS Safari。iOS Safari は既知の不具合があったりする。うまく接続できない場合は一度「Stop」ボタンを押してもう一度「Start」ボタンで接続したりしてみて欲しい。

参考にした文献・コード

今回実装する上で参考にした文献やコードを先に紹介する。コレから書く内容は、これらを眺めながら何となくコードを書いた結果覚えたことである。

WebRTC を始めるために理解すること

WebRTC (Web Real-Time Communication) によるビデオチャットアプリを作りたいワケだが、予め勉強しておかないといけないことがいくつかあるようだ。ココではキーワードと簡単な理解を書いていこうと思う。

WebRTC による双方向通信の仕組み

WebRTC は、ブラウザ内で P2P 通信を実現し、ある端末と双方向にやり取りするモノである。Peer-To-Peer ということは、お互いの端末を直で特定しないと、通信が始められないワケだ。

もしもグローバル IP を持っているマシン同士なら、双方が相手のグローバル IP を知っていれば直接通信できそうな気もするが、それにしても、通信相手のグローバル IP をどうやって知るか、という問題は残る。

そこで、P2P 通信を始める前に、お互いの情報を交換するための「シグナリング」という処理が必要になる。「シグナリングサーバ」と呼ばれる役割を持ったサーバを作ってやり、コレを経由することでお互いの情報をやり取りするのだ。その際にやり取りする情報は以下のようなモノがある。

SDP や ICE をやり取りすることで、相手と直接通信するための経路を確立し、P2P 接続を開始できるようになる。

WebSocket を利用したシグナリング処理

ということで、WebRTC によるチャットアプリを作るには、画面以外に、相手との通信を確立するまでの仲介を行う「シグナリングサーバ」が必要になりそうだ。

シグナリングの処理自体は色んな技術で実現できるのだが、広く使われているのは WebSocket という双方向通信を実現するための仕組み。

そしてコレを簡単に実装できるようにしてくれる npm パッケージとして、Socket.io というモノが存在する。Socket.io は古くから存在するためか、解説記事の「鮮度」がまちまち。メソッド名や API も色々変化があり、正しく参考にするのが大変な印象だった。

RTCPeerConnection

WebRTC による P2P 通信を行うための Web API として、RTCPeerConnection というモノが各ブラウザに実装されている。

P2P 通信を確立するまでの流れは次のようになる。

  1. クライアント 1
    1. Web ページを開いた時に、WebSocket (Socket.io) を利用してシグナリングサーバとの接続を確立しておく
    2. navigator.mediaDevices.getUserMedia() を使ってウェブカメラを起動、ストリームを取得する
    3. RTCPeerConnection インスタンスを生成し、取得したストリームを持たせる (addTrack())
    4. Offer SDP というモノを作る (createOffer())
    5. Offer SDP を自分のピア (= RTCPeerConnection) に登録して覚えておく (setLocalDescription())
    6. Offer SDP を WebSocket (Socket.io) でシグナリングサーバに送信する
  2. シグナリングサーバ
    1. クライアント 1 から受け取った Offer SDP を、他に接続されているクライアントにリレー (中継) 送信する
  3. クライアント 2
    1. Web ページを開いた時に、WebSocket (Socket.io) を利用してシグナリングサーバとの接続を確立しておく
    2. navigator.mediaDevices.getUserMedia() を使ってウェブカメラを起動、ストリームを取得する
    3. RTCPeerConnection インスタンスを生成し、取得したストリームを持たせる (addTrack())
    4. WebSocket (Socket.io) を利用してシグナリングサーバが中継してきた Offer SDP を受け取る
    5. 受け取った Offer SDP をリモートの情報として登録して覚えておく (setRemoteDescription())
    6. Answer SDP というモノを作る (createAnswer())
    7. Answer SDP を自分のピアに登録して覚えておく (setLocalDescription())
    8. Answer SDP を WebSocket (Socket.io) でシグナリングサーバに送信する
  4. シグナリングサーバ
    1. クライアント 2 から受け取った Answer SDP を、他に接続されているクライアントにリレー (中継) 送信する
  5. クライアント 1
    1. WebSocket (Socket.io) を利用してシグナリングサーバが中継してきた Answer SDP を受け取る
    2. 受け取った Answer SDP をリモートの情報として登録して覚えておく (setRemoteDescription())

コレで P2P 接続が確立される。

この裏で onicecandidate というイベントが絶えず発火している。ICE Candidate とは ICE (通信経路) の候補情報が追加された時に発火するそうで、タイミングとしては createOffer() 後に発火するイベントとなる。この Candidate 情報は WebSocket を経由して投げあっておく。

P2P 接続が確立された後、相手側に addTrack() された映像や音声の情報があると、ontrack イベントが発火する。このイベントで、受け取った相手のストリームを video 要素なんかに流し込んでやれば良いというワケ。

RTCPeerConnection によるやり取りと、getUserMedia() による映像の取得とは、それぞれ切り離して処理できるので、落ち着いて処理の順番を見極め、ハンドリングしてやろう。

シグナリングサーバの実装

シグナリングサーバ (index.js) の実装は、Socket.io の公式サンプルに沿って行った。

Express サーバを http モジュールでラップし、さらにその http サーバを Socket.io でラップしてやると、Socket.io の準備ができる。

Socket.io の処理としてはクライアントから受け取った情報を中継してブロードキャスト (よそに送信) してやるだけなので、コードは30行ほどで済んでしまった。

クライアントの実装

Socket.io でラップした Express サーバは、/socket.io/socket.io.js というパスで JavaScript ファイルを提供するようになる。コレをクライアントサイドの HTML で読み込むと、グローバル変数 io が使えるようになり、Socket.io サーバとのやり取りが実装できるようになる。

クライアントサイド (index.html) では、このクライアント用 socket.io.js を読み込み、getUserMedia() を叩いたり、RTCPeerConnection を作ったり、io が受け取ったメッセージを基に処理したり、というのを愚直に実装している。

キモはこのクライアント側の実装なので、頑張った。

つまづいたところ

RTCPeerConnection の仕様

RTCPeerConnection も、Socket.io のようにそれなりに歴史が長く、色々と仕様変更があったり、ブラウザごとの実装差異が大きい時期が長かった。

そのため、記事によっては RTCPeerConnection の各メソッドをコールバック形式で実装していたり、ブラウザごとに異なる API をハンドリングしていたりと、つらみが高かった。

2020年現在は、RTCPeerConnection で検索して出てくる MDN のリファレンスに従うのが一番正しく、Chrome と Firefox はこのとおりに動作する。

RTCPeerConnection は各メソッドを Promise 形式で返すので、async・await で処理してやると良いだろう。

getUserMedia()video 要素の扱い

navigator.getUserMedia() は古い。navigator.mediaDevices.getUserMedia() を使う。コレも Promise。

localhost で動かしている端末に別の端末からアクセスした際に、getUserMedia() が動作しない。Chrome の起動オプションに次のように書き込む必要がある。

# Windows マシンから、LAN 内の Mac 端末が公開している開発サーバにアクセスする例
$ chrome.exe --unsafely-treat-insecure-origin-as-secure="http://neos-macbook.local:3000"

video 要素もハマりどころが多い。URL.createObjectURL() はもう古くて使う必要なし。srcObjectstream をブチ込む。

iOS Safari はセキュリティが厳しくて自動再生とかが大変。video 要素に playsinline muted autoplay を振っておくと良いかな。

ユーザの明示的なクリックイベント以外で video.play() などをやろうとすると怒られることが多い。特にリモートの映像を表示する時がソレに当たった。Chrome ブラウザの場合は chrome://flags#enable-webrtc-remote-event-log フラグなどを片っ端から有効化すると、自動再生ができるようになる。

addStream() ではなく addTrack()

RTCPeerConnection に映像ストリームを渡す方法として、addStream() が使える。現在も Chrome などでは動作するが、iOS Safari では動作しなかった。

調べてみると、コレもまた古くなっていて、最近は addTrack() を使う実装方法になっているようだ。

onaddstreamonremovestream もそれに対応して、ontrack および removetrack に変わっている。メンドクサ。

既知の不具合

Chrome ではひととおりはキレイに動くようになってきたが、処理できていないところがあったりする。また、iOS Safari でだけ上手くいかないところがあるので、なんとかしたい。

  1. 相手との接続が切れた後に、他の人と自動再接続したいができていない
    • 1対1の通信が確立できたあと、相手が切断した時に、コチラは取り残されてしまう
    • removetrack あたりに処理を入れて、別の Offer 中の人を拾い上げたりできるようにしたい
  2. iOS Safari が後から接続してきた人の Offer を正しく処理できない
    • iOS Safari において、setLocalDescription(localStream) した後に Offer を受け取り setRemoteDescription(offer) を実行しようとすると、InvalidStateError: description type incompatible with current signaling state なるエラーが発生する
    • 一度も setLocalDescription() をせずに setRemoteDescription(offer)setLocalDescription(answer) と叩けばエラーにならないが、Chrome ブラウザでもキレイに動くようなハンドリングが出来ていない
    • なんで Chrome ではキレイに動くんだろう?iOS Safari は何が違うんだろう?

以上

歴史はそれなりに長いが、まだまだ発展途上といったところで、自分で実装してみると世のビデオチャットアプリはなんてよく作られているんだろうと感心する。

まだまだ分からないところが多いので、もう少しキレイに動くアプリになるよう学習していきたい。