OSSのSFUである`mediasoup`のコードを読みました。
サーバーの実装とJS-SDKがあって、JS-SDKの方です。
GitHub - versatica/mediasoup-client: mediasoup client side JavaScript library
現時点でのstableはv2.xなので、今回読んだv3は次期バージョン。
つまりはAPIがまだ変わるかもしれないんですが、まあ読んだことが無駄にはならんやろーという感じ。
コードの入手
すでに`master`ブランチがv3に向いてるので、そのまま`git clone`すれば落ちてきます。
サーバー実装の`master`はまだv2.xなので、むしろなぜ・・って感じではある。
近いうちにv3になるんでしょうね。
コードの雰囲気
読み込む前にいわゆるOverviewを。
READMEに書いてあるコードをそのまま抜粋。
import { Device } from "mediasoup-client"; import mySignaling from "./my-signaling"; // Our own signaling stuff. // Create a device (use browser auto-detection). const device = new Device(); // Communicate with our server app to retrieve router RTP capabilities. const routerRtpCapabilities = await mySignaling.request( "getRouterCapabilities" ); // Load the device with the router RTP capabilities. await device.load({ routerRtpCapabilities }); // Check whether we can produce video to the router. if (!device.canProduce("video")) { console.warn("cannot produce video"); // Abort next steps. } // Create a transport in the server for sending our media through it. const { id, iceParameters, iceCandidates, dtlsParameters } = await mySignaling.request("createTransport"); // Create the local representation of our server-side transport. const sendTransport = device.createSendTransport({ id, iceParameters, iceCandidates, dtlsParameters }); // Set transport "connect" event handler. sendTransport.on("connect", async ({ dtlsParameters }, callback, errback) => { // Here we must communicate our local parameters to our remote transport. try { await mySignaling.request("transport-connect", { transportId: sendTransport.id, dtlsParameters }); // Done in the server, tell our transport. callback(); } catch (error) { // Something was wrong in server side. errback(error); } }); // Set transport "produce" event handler. sendTransport.on( "produce", async ({ kind, rtpParameters, appData }, callback, errback) => { // Here we must communicate our local parameters to our remote transport. try { const { id } = await mySignaling.request("produce", { transportId: sendTransport.id, kind, rtpParameters, appData }); // Done in the server, pass the response to our transport. callback({ id }); } catch (error) { // Something was wrong in server side. errback(error); } } ); // Produce our webcam video. const stream = await navigator.mediaDevices.getUserMedia({ video: true }); const webcamTrack = stream.getVideoTracks()[0]; const webcamProducer = await sendTransport.produce({ track: webcamTrack });
ざっくり書くと、
- `Device`の初期化
- サーバー側で対応してるコーデックやらRTPの要件を確認
- それがこちらの環境でも使えるかチェック
- サーバーから接続に必要な情報をサーバーからもらう(ICE/DTLS)
- サーバー側でOffer作って投げるイメージ
- それを使ってトランスポートを作る(ここでは送信専用)
- そこに対してメディアをつなぐ
- トランスポートに対して`MediaStreamTrack`を
- そしてローカルの接続情報をサーバーに送る
- SRDしてAnswer作って投げるイメージ
- その後は`Producer`を介してやりとり
なのでこの送信専用のサンプルコードの登場人物として重要なのは、`Device`, `Transport`, `Producer`の3つで、順に依存関係がある。
`MediaStream`ではなく`MediaStreamTrack`をそのままI/Oに使ってるのもポイント。
うーん、ORTC・・!
それを踏まえてコードを読んでいく。
ファイル一覧
. ├── CommandQueue.js ├── Consumer.js ├── Device.js ├── EnhancedEventEmitter.js ├── Logger.js ├── Producer.js ├── Transport.js ├── detectDevice.js ├── errors.js ├── handlers │ ├── Chrome55.js │ ├── Chrome67.js │ ├── Chrome70.js │ ├── Edge11.js │ ├── Firefox60.js │ ├── ReactNative.js │ ├── Safari11.js │ ├── Safari12.js │ ├── ortc │ │ └── edgeUtils.js │ └── sdp │ ├── RemotePlainRtpSdp.js │ ├── RemotePlanBSdp.js │ ├── RemoteUnifiedPlanSdp.js │ ├── commonUtils.js │ ├── plainRtpUtils.js │ ├── planBUtils.js │ └── unifiedPlanUtils.js ├── index.js ├── internals.js ├── ortc.js └── utils.js
ディレクトリはフラットで、さっきの依存関係は察せない。
むしろクロスブラウザ対応っていつの時代も大変よね・・っていう感じが押し出された構造になってる。
モジュールとしての`main`である`index.js`がエクスポートしてるのは、
- `Device`: `Device.js`
- `version`: ただのバージョン番号
なので、いわゆるエントリーポイントは`Device`クラス。
v2.x時代は他にも関数があったりしてたと思うので、そこが整理された感。
Device
エンドポイントごとに1つ存在する概念。
いま使ってるブラウザならブラウザ、Nodeならそのプロセスって感じかな?
`Device`は唯一の引数として`Handler`を受け取るようになってて、ブラウザから利用する場合はデフォルトのものがUAによって用意される。
その処理をしてるのが`detectDevice.js`。
- ReactNativeの場合は`RTCPeerConnection`がないとダメとか
- Chrome/Firefox/Safariに関してはバージョンごとに違うものを
- 非対応な場合は`null`が返って、`Device`のインスタンスが作れずに`throw`される
Handler
`/handlers`にあるやつら。
概要はさっき書いた通りで、細かい差分をざっと見ておく。
- 各ブラウザごとに別れてるが基本のI/Oは同じ(当然
- Send用のHandlerとRecv用のHandlerの2つがコアで、どちらかを返す
- それぞれに`RTCPeerConnection`のインスタンスがある
- = Senderで1つ、Receiverで1つ別のができる
- `send()/stopSend()/iceRestart()`みたいに役割がある
- `MediaStream`ではなく`MediaStreamTrack`をI/Oに使う
- 中で自前の`new MediaStream()`はする
- SDPはテキストではなくオブジェクトにしたものを使う
- RTPとかメディアに関するものを主に操作
- ローカルとリモートで分けていて、それらを操作する処理が`/handlers/sdp`にある
`SendHandler`か`RecvHandler`かどっちかまたは両方が、`Device`の操作によって行われる。
load()
- ユーザーが使用したい!と渡してきたRTPの要件
- `getNativeRtpCapabilities()`で取得できる対応している要件
- これは各`Handler`から取れる
- `RTCRtpCapabilities`自体は、いわゆるORTCからきた概念
- その要件を元に関数をロードしたりフラグを立てたりする
`Device`のインスタンスを作ったらまずコレをしろっていうやつ。
canProduce()
`mediasoup`において、メディアを送信することを`produce()`するという。
なので、さっきのクライアント・サーバー側のRTP要件を照らして、実際に送信可能かどうかをチェックしてる処理。
createSendTransport()
- メディア送信用の`Transport`を作る処理
- もちろん受信用の`createRecvTransport()`もある
- この2つの違いは、directionが`send`か`recv`かだけ
- あらかじめサーバーにICE/DTLSの情報をもらっておいて、それを渡す
- ちなみにサーバーはICE Lite
ここから先が`Transport.js`です。
Transport
- `send`/`recv`どちらかの役割になる
- さっきの`Handler`が内部的に使われる
- メディアを送信・受信するようの処理
- `send`の`Transport`なら`produce()`
- `recv`の`Transport`なら`consume()`
- ICEの管理
- `restartIce()`
- `getStats()`も生えてる
あとはこの`Transport`に対してイベントハンドラを設定しておくと、後述する処理でフックされてP2Pがつながる。
イベント
3つのイベントが発火される。
`connect`
- `Handler`の`@connect`イベントをつなげる
- `Handler`の`send()`/`recv()`の処理中に呼ばれる
- = `Transport`の`produce()`/`consume()`の処理中に呼ばれる
- `createOffer()`/`sLD()`したSDPから、DTLSの情報を抜いたものが飛んでくる
`connectionstatechange`
- `Handler`の`@connectionstatechange`イベントをつなげる
- `RTCPeerConnection`のイベントがそのまま飛んでくる
`produce`
- `Transport`の`produce()`の処理中に呼ばれる
- `consume()`のイベントはない
produce()
`send`な`Transport`がメディアを送る処理。
- なので`MediaStreamTrack`を引数に
- 自身の`Handler`を使って`send()`する
- `addTransceiver(sendonly)`
- `createOffer()`/`sLD()`
- リモートのSDP情報は`new Transport()`の時点で知ってるので、即`sRD()`できる
- さっきの`produce`イベントの発火
- 後述する`Producer`を返す
- イベントハンドラをセットしてから
consume()
`recv`な`Transport`がメディアを受け取る処理。
- 基本的に`produce()`の逆
- 自身の`Handler`を使って`receive()`する
- サーバーのSDPはわかってるので即`sRD()`
- `createAnswer()`/`sLD()`
- 後述する`Consumer`が返る
- イベントハンドラをセットしてから
Producer / Consumer
`Producer.js`が送信メディアを管理するクラス。
- 単一の`MediaStreamTrack`の面倒を見る
- `replaceTrack()`/`close()`
- `pause()`/`resume()`でミュート/解除できる
- `this.track`で取れる`MediaStreamTrack`を、画面には描画する
`Consumer.js`が受信メディアを管理するクラス。
- 単一の`MediaStreamTrack`の面倒を見る
- とはいえ受信専用なのでできることは少ない
- `close()`
- `pause()`/`resume()`でミュート/解除できる
- `this.track`で取れる`MediaStreamTrack`を、画面には描画する
その他
EnhancedEventEmitter
純正の`EventEmitter`ではなく、コレを継承するクラスがほとんど。
- `emit()`を`try/catch`したり
- 中で`await`できるように、`Promise`を返す`emit()`を作ったり
このワザ、Electronのアプリ書いてたときに使ってたなー。
読んでみて
そういえばORTCってこんな感じでしたね・・!
- `Device`を1つ作って
- そこから`Transport`をつなげていく
- メディア送信用の`Transport`とその`Producer`
- メディア受信用の`Transport`とその`Consumer`
- `Producer`/`Consumer`は`MediaStreamTrack`が紐づくので、それを画面に出す
送受信で`RTCPeerConnection`を2つ使うの、コードを分離できるメリットがあるのでなるほどな〜という感じ。
- send:video + send:audio = 2pc
- sendTransportで1pc
- sendonly <-> recvonly
- recvTransportで1pc
- recvonly <-> sendonly
- 部屋に入ると = consumeすると動き出す
- sendTransportで1pc
- Transportを用意するときに方向が決まってる
- `send` or `recv`
- `send`で作ったTransportは`produce()`しかできない
- send(video+video+audio)の場合
- sendTransportは1つでよい
- つまりPCは、送信で1つ、受信で1つ
- 最低1つあればいい
- それ以上は分けてもいいが、分ける必要はないかも
APIもコードもシンプルでシュッとしてて、WebRTCのJSのAPIも熟知しながらSDPをハックしていく感じでお見事!参考にさせていただきます!というコードでした。
サーバー側も踏まえて、SDPと各メディアをどのように紐付けて扱えばいいかは、サーバー編があればそっちで書こうかと。