🧊

OSSのWebRTC SFU mediasoup v3のコードを読む(クライアント編)

OSSSFUである`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は同じ(当然
    • たとえばChromeだと`unified-plan`なAPI or NOTなどが差分
    • 試してないけどEdgeへの対応もあって偉い
  • 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すると動き出す
  • Transportを用意するときに方向が決まってる
    • `send` or `recv`
    • `send`で作ったTransportは`produce()`しかできない
  • send(video+video+audio)の場合
    • sendTransportは1つでよい
  • つまりPCは、送信で1つ、受信で1つ
    • 最低1つあればいい
    • それ以上は分けてもいいが、分ける必要はないかも

APIもコードもシンプルでシュッとしてて、WebRTCのJSのAPIも熟知しながらSDPをハックしていく感じでお見事!参考にさせていただきます!というコードでした。

サーバー側も踏まえて、SDPと各メディアをどのように紐付けて扱えばいいかは、サーバー編があればそっちで書こうかと。