🧊

JavaScriptでWebRTCやるための基礎知識

未来の自分のためのメモです。
仕事でやってないせいですぐ忘れるし、都度思い出すの大変なので・・。

ただまぁだいたいの人はSkyWayとかEasyRTCとか何かしらのライブラリを使うはずで、そういう人たちにはあまり関係ない内容かも。
生のjsでWebRTCを書くときに、先に知っておきたかった系のメモです。

素人ではないがベテランでもない、そんな微妙な知識レベルだと思います。

まだ枯れた仕様ではないので、記事を読む時は日付に注意してください...

WebRTC is 何

リアルタイムっても既にWebSocketとかあるやんとかいう気持ちはあるやろうけどそれはそれ、これはこれ。

  • ブラウザのカメラやマイクなどをストリームとして拾う・拾われたストリームを再生する
  • 実際にP2Pで通信する

この2つが大きな概念で、これらを組み合わせて使う。
簡単にいうと、いわゆるP2Pのビデオ・音声チャットがブラウザだけで作れるぜ!っていうやつ。

というわけでこの2本柱についてそれぞれ。

MediaStream

`DataChannel`(少しだけ後述)みたいなものもあるけど、基本的にWebRTCでやりたいのは互いのストリームを送り合うこと。
なので送るために取得するし、送られてきたのを取り扱う必要がある。

ストリームの取得

まず取得。

  • `navigator.mediaDevices.getUserMedia()` <- パソコンのカメラやマイクから
  • `$canvas.captureStream()` <- canvas要素から
  • `($audio|$video).captureStream()` <- 将来的にはaudio要素やvideo要素からも

などなど取得する方法はいろいろある。
WebRTCなら`getUserMedia()`っしょ!みたいなところもあるかもしれないが、それだけではないということ。
たとえばお絵かきアプリの`canvas`の様子もストリームとして送れる。

ちなみにこれらストリームは仕様的には`MediaStream`って呼んだりする。
`MediaStream`はいくつかの`MediaStreamTrack`から成るもので、音声つきの映像ストリームの場合、

  • `audio`のTrack
  • `video`のTrack

ってな感じになってる。
音声がないならVideoTrackがひとつだけ。

このTrackとStreamの関係を知っておくべし。

getUserMedia a.k.a. gUM

navigator.mediaDevices.getUserMedia(options)
  .then(successCallback)
  .catch(errorCallback);

function successCallback(stream) {}
function errorCallback(err) {}
  • 昔は`navigator.getUserMedia()`でアクセスしてた
  • 最近はPromiseが返ってくるけど、以前はコールバックをそれぞれ渡してた

`options`で指定してるところは`MediaStreamConstraints`なるもので、IDL的には、

dictionary MediaStreamConstraints {
    (boolean or MediaTrackConstraints) video = false;
    (boolean or MediaTrackConstraints) audio = false;
};

という感じに`video`と`audio`をそれぞれオブジェクト(`MediaTrackConstraints`) or Boolで指定する。

  • キャプチャするサイズとか
  • 解像度とか
  • モバイルだとフロントカメラ or リアカメラ

仕様的には`MediaTrackConstraints`で色々指定できるようにはなってるけど、それがきっちりどの環境でも反映されるかというと、そうではなさ気。
てかChromeでだけ使える設定とかもあった気がする。

Media Capture and Streams

ストリームの再生

`audio`や`video`から取れるってことは、それで再生もできるってこと。
`autoPlay`が効かなくてハマるとかそういうのはWebRTCに関係なくあるのでよしなに。

const $video = document.createElement('video');
$video.autoPlay = true;

// 自身のストリーム or
// なんらかの手段で取得したP2P相手のストリーム
$video.srcObject = stream;
// or
$video.src = URL.createObjectURL(stream);

`audio`要素にもそのままストリームをつっこんで良くて、AudioTrackがあれば音が流れる。
もちろん、WebAudioAPIの`MediaStreamAudioSourceNode`も使える。

const ctx = new AudioContext();
const source = ctx.createMediaStreamSource(stream);
source.connect(ctx.destination);

こうすると音声レベルとかも取れるのでデバッグにも便利。

ちなみにこの`MediaStream`には`stop()`的なメソッドは存在せず、各Track側に`stop()`があるので、根本から再生停止したい時にはご注意。

// 全部止める
stream.getTracks().forEach(track => track.stop());

さて、ストリームの扱いはわかったが、それをどうやって取得してくるか。
つまりはP2P通信する本丸を。

RTCPeerConnection

そんなストリームを使ってP2P、つまりPeerToPeerで通信するためのオブジェクト。
これを把握すること = 素のJavaScriptでWebRTCすることに等しい。

const peer = new RTCPeerConnection(options);

これが、通信したい相手「ごと」に必要になる。
つまりブラウザAがブラウザBと通信するために1つ、新たにブラウザCと通信するならもう1つ必要。

単一のPeerConnectionでもって複数人と通信・・はできない仕様なので、相手ごとにこのPeerConnectionとさっきのMediaStreamを良い感じに取り回すのがjs実装のキモ。

後々でさっき手に入れておいた自身のストリームをセットして使うことになる。

peer.addStream(stream);

Offer / Answerモデル

P2Pで通信するためには、

  • ピアAがOfferを作って出す
  • ピアBはそれを受けてAnswerを作って出す
  • ピアAがそのAnswerを受け取る

このSYN-ACK的なやり取りがあってはじめてつながる。

なので、複数人とつながる場合には各ピアがそれぞれ各ピアとコレをやる必要がある。
正直面倒くさいけどそういう仕様。

どういうコードでやるか

`RTCPeerConnection`に生えてる各メソッドを使う。

  • `createOffer()`
  • `createAnswer()`
  • `setLocalDescription()`
  • `setRemoteDiscripution()`

その際にやり取りされるものが、SDPと呼ばれるもの。

すごいざっくりしたコードを書くと、

// [1] A -> BのOffer
peerA.createOffer()
  .then(sdp => peerA.setLocalDescription(sdp))
  .then(() => {
    // これをなんとかして送る
    const sdpOfA = peerA.localDescription;
  });

// [2] B -> AのAnswer
peerB.setRemoteDescription(sdpOfA)
  .then(() => peerB.createAnswer())
  .then(sdp => peerB.setLocalDescription())
  .then(() => {
    // これをなんとかして送り返す
    const sdpOfB = peerB.localDescription;
  });

// [3] A <-> Bの接続が確立
peerA.setRemoteDescription(sdpOfB)

という感じ。
これも昔はPromiseですらなく普通のコールバックを指定するシンタックスでした。
Promiseが返ってくる = モッダーンな環境では`async/await`で書けることになるけどまあお好きに。

Firefoxだと`new RTCSessionDescription(sdp)`しないと`setRemoteDescription()`できなくて、Chromeだと`type` / `sdp` のあるオブジェクト(仕様でいう`RTCSessionDescriptionInit `)でもいけるとか罠。

SDP

Session Description Protocolのこと。

Offer/Answerする際に生成し、やり取りするもので、

  • 自分のIP、ポート
  • 通信経路の候補
  • こういう映像・音声に対応してます
  • etc...

などなど書かれたただの巨大な文字列。

v=0
o=- 2776741559184865868 2 IN IP4 127.0.0.1
s=
c=IN IP4 192.168.0.4
t=0 0
m=audio 49170 RTP/AVP 0 8 97
a=rtpmap:0 PCMU/8000
a=rtpmap:8 PCMA/8000
a=rtpmap:97 iLBC/8000
m=video 51372 RTP/AVP 31 32
a=rtpmap:31 H261/90000
a=rtpmap:32 MPV/90000

こういうやつ。
WebRTC力が一定のレベルを超えてると、この状態でも解読できるらしい。

このSDPをお互い知ることができれば、晴れてP2Pで通信できるというわけ。

ただしどうやってお互いを知ればいいんだという話がもちろんあって、往々にしてお互いのことを知ってる第三者のサーバーを介してシグナリング(SDPをやり取り)するのが基本の流れになる。

もちろん単一のPC内で別々のブラウザで・・とかの場合は、愚直にコピペでもいい。

ちなみにこのSDPを手動で書き換えることで、通信するやり方を調整できたりもする。

  • 帯域を絞ったり
  • コーデックの優先順位を変えたり

ただし文字列操作でやるしかないので、必要ならパーサーとかを。npmでもちらほら見つかる。

シグナリング

さてこんなSDPをお互いにどうやって知るかが次なる問題・・。
けど、決められた手段もプロトコルも仕様もない!

なので↑の文字列をなんとかして相手に伝えらればそれでいい。

  • XHRしてサーバーから渡したり
  • WebSocketで渡したり
  • QRコードにして配布するとか

つまりなんでもいい。
けど、この後の続きを読めば、まあレイテンシの低いWebSocketとかが妥当かなーという気持ちになると思う。

なんしかこのP2Pのピア同士を結ぶお互いを知る第三者のサーバーを、シグナリングサーバーと呼んだりして、基本的にWebRTCやるなら必須な存在。

P2Pって言うからには何も仲介しなくていいと思ってた・・人もいるかもしらんけど、まあよくよく考えたらどうしようもないよなーという。

この仕事にかこつけて、ルーム管理とかユーザー認証とか他サービスとの連携とかあれこれやらされてるサーバーををよく見る。

ICE Candidate

さて、↑のコードにあった時点で取得できる初期のSDPを送り合うだけでは情報が足りない。

というのも、このSDPに書かれたIPはグローバルなエンドポイントのIPではない(ことが多い)ため、いわゆるインターネット上ではどうなのよ、どういうネットワークを経由すればいいのよっていうレベルの情報も交換しないとつながらない。

この情報こそがICE(Interactive Connectivity Establishment)のCandidateと呼ばれるもので、SDPとあわせてお互いに知る必要がある。

そしてこの通信経路は非同期で取得されるので、それ用のハンドラがある。(`setLocalDescription()`されると、収拾が開始される)

peer.addEventListener('icecandidate', ev => {
  if (ev.candidate) {
    // 候補が見つかったとき
  }
  // 候補が出揃ったとき
});

このどっちのタイミングで処理をするかによって、2つの方式に分けられる。

Trickle ICE

候補が見つかったタイミングで都度処理をするパターン。

// 送る側
peer.addEventListener('icecandidate', ev => {
  if (ev.candidate) {
    // 候補が見つかったとき
    // ev.candidate をシグナリングサーバー経由で送る
  }
});

// 受ける側
peer.addIceCandidate(new RTCIceCandidate(candidate));

ICEの名の通り、仕様的にはこっちが理想であり標準なんかね?
候補で揃ってなくとも、途中の段階でもつながるならつなげられるので効率的というメリットがある反面、それ用のハンドラを用意したり、SDPはSDPで前もって送っておく必要があったりとコードがやや煩雑になる。

Vanilla ICE

候補が全て出揃ったタイミングで一括処理をするパターン。

これは特に難しいことはなくて、

peer.addEventListener('icecandidate', ev => {
  if (ev.candidate) {
    // 候補が見つかったときは無視
    return;
  }
  // 候補が出揃ったときにはSDPに乗ってるので、そもそもこれだけを送る
  peer.localDescription;
});

ってだけ。
相手にSDPを送るのを、全てのICE Candidateが出揃ってからにするのがVanilla ICE方式。(特殊なことしないプレーンなやつ=VanilaJS的な意味)

コードとしては圧倒的にすっきりするけど、デメリットとしては通信経路が全て出揃うまで待つ必要があるところ。
後述するSTUNサーバーとかに自分の居場所を確認するのもネットワークコストであり、待つのもタダではないということ。

STUNサーバー

ちなみにローカルなネットワーク内であれば何もしなくてもつながるけど、そんなケースはほとんどないはず。
ほとんどの場合、インターネットを通してつなげたいはず。
(このインターネット上でつながりたい気持ちを「NAT越え」したいとか「壁を越えたい」とか表したりする。)

このグローバルなIPやら通信経路を知るためにSTUN(プロトコルを理解する)サーバーというのがあり、`RTCPeerConnection`のコンストラクタにオプションで指定する。

const peer = new RTCPeerConnection({
  iceServers: [ { urls: 'stun:stun.skyway.io:3478' } ],
});

ここでは某社のサーバーを指定してるけど、他にもGoogleやらいろいろ各社が建ててくれてたりするし、自分で建ててもいい。
なんしかこれを指定することで、グローバルな経路・IPでIce Candidateを収拾できるようになる。

TURNサーバー

STUNサーバーを使ってグローバルなIPを手に入れても、以後それが継続的に使えるとは限らず・・。(ガチガチのFirewallの中にいるとか)
というわけで、全ての通信を中継するサーバーを介することでP2PP2Pとは言ってない)できるようにする手法もあって、その中継の仕組みをTURNとか言ったりする。

コードとしてはSTUNと同じく`RTCPeerConnection`のコンストラクタで指定するだけ。

const peer = new RTCPeerConnection({
  iceServers: [ {urls: "turn:x.x.x.x", credential: "yoursecret", username: "yourname"} ],
});

コードを見てもわかる通り、ID/PWが必要になるので色々な意味でサーバーをちゃんと運用しないといけない。
STUNと違ってTURNは全てのやり取りを中継するので、データ転送量とか気をつけないといけない。

ちなみにこのTURNなしでつながる率は70%くらいらしい。
この数字を見て高いと取るか低いと取るかは、その作ろうとしてるサービス次第。

おさらい

だいぶ横道にそれた・・。

なにはともあれ無事にシグナリングして、お互いに接続されたとすると、さっきのピアに相手のストリームがやってくる。

peer.addEventListener('addstream', ev => {
  // const stream = ev.stream;
}, false);

// or

peer.addEventListener('track', ev => {
  // const stream = ev.streams[0];
}, false);

2パターン書いてるのは、ブラウザによって対応してるしてないがあるからで・・。
実際にはどっちかだけでよい。

これで何事もなければつながって、P2Pビデオチャットができるようになったはず!

まとめ

  • 送りたいストリームを用意
  • 通信相手ごとに`RTCPeerConnection`を用意
  • `RTCPeerConnection`の各メソッド、イベントハンドラを駆使して準備
    • そんなに多くないし、覚えられる
  • 自身のストリームをピアにつなぐ
  • Offer / Answerの決まった手順でSDPをやり取り
    • `iceConnectionState`とか`signalingState`とかで進行状態も取れる
  • 加えて、ICE Candidateも必要に応じてやり取り
    • ICEをどのタイミングで送るかで処理の流れが変わる
  • それができるとストリームが相互につながる
    • 自身のストリームと同じく、相手のストリームも表示するなり

コードを書くだけならポイントってこんなもんかな・・?
Pub側なのかSub側なのかを明確に分けてコードは書くときれいになる + 共通部分も多いのでそこをどうするかくらい。

その他

こっからは輪をかけてメモ感が高まるので半目で。

関連トピック

  • `MediaStream`を送るやつ以外にも`DataChannel`というのもある
    • プロトコルとしてはSCTP
    • BlobとかArrayBufferが送れるのでファイル共有とかできる
    • まずこれをP2Pでつなげてから、メディアのシグナリングするとかも(非効率かもやけど
  • `MediaRecorder`
    • なんとストリームを録画・録音できる・・!

ブラウザ差異

という具合で、踏み込めば踏み込むほど一筋縄ではいかない感じ。

最近Promiseが返ってくるAPIが増えたけど、EdgeとかだとPromiseで返ってこなくてハマったりする人とか出てくるんやろな・・。

デバッグ

  • そもそもHTTPじゃないのでDevToolsがほぼ役に立たない
  • chrome://webrtc-internals/
  • Wiresharkとか?

デバッグしないといけない状況になったことないのでわからん。

SFU/MCU

P2Pとはいっても、相互接続( = フルメッシュ)での接続は何かと負荷が高くて、数人の相互接続ですらカクつくしファンが回る。
ネットワークの負荷もそこそこ + 動画のエンコードが特に辛い。音声だけなら十数人まではそれなりにいける感。

そこでのソリューションとしてSFUやらMCUというのがある。
どっちもつまりはP2Pをやめる。

ざっくりいうと、

  • 接続先全員に自分のストリームを送るのではなく(= P2Pではなく)
  • サーバーにだけ送って
  • そのサーバーが代わりにそれぞれ下りの接続先に配る

もしくは、上りだけではなく下りもサーバーが1本にまとめるようにしたり。
なんしか動画つきでN:Nを一定数以上でやりたいなら、視野に入れる必要がある。

1:Nとかならやろうと思えばHLSとかでもできる気がするので、何がしたいのかにあわせてそういう目線で技術を選べばよし。

ただWebRTCはリアルタイムを謳ってるだけあってHLSでリアルタイム(疑似)するより圧倒的にリアルタイムなので、その方面でもWebRTCに対する期待は高まってるはず・・?(と勝手に思ってる
HLSにすればiOSSafariでも見れるし。

おわりに

ここに書いてあるのは氷山の一角であり、実運用にこぎつけるまでにはさらに広範囲の知識が必要。

そういうところを各社のWebRTCソリューションに丸投げして、ちょっとしたjs書くくらいなら、ちょっとやればデキるはず。
それでもココに書いてる内容とかを広く浅く知ってる前提ではあるけど・・。

どこで情報を追うか

狭いいんたーねっつを見てる感じ、国内だと詳しい人達は本当に限られてる。
ので、ピンポイントでその人達をフォローしたりすればいいと思う。

ただしいわゆる低レイヤーなとこも把握していながら、JavaScriptでうまい感じにやる術も併せ持ってる人は本当に希少生物っぽい。