🛠️

Node.js用のmediasoup clientを作ってみた

2021/06/02に公開

mediasoup とは

OSS で公開されている WebRTC の SFU です。
ユーザが触る API 部分 を Node.js で、内部の WebRTC 周りを C++で書かれています。
MediaChannel だけでなく、DataChannel に対応していたり、RTP の口を生やせたりと結構多機能です。
mediasoup には SFU に接続するためのクライアント SDK があり、公式が用意してるのは以下です。

  • mediasoup-client
    • js , browser/react-native
  • libmediasoupclient
    • c++
  • mediasoup-client-aiortc
    • node.js (ただし Python 製の aiortc をラップして作られている)

mediasoup-client-node

著者は TypeScript(Node.js) で WebRTC のプロトコルスタック( werift )を実装中なので、mediasoup との相互接続試験を兼ねて mediasoup の Node.js 用 client sdk を自作してみることにしました。

成果物

https://github.com/shinyoshiaki/mediasoup-client-node

開発方針

mediasoup-client をフォークして改造する方向で開発しました。
mediasoup-client はマルチブラウザ & ReactNative 対応を行うために WebRTC に関するコードを抽象化して、各プラットフォーム毎に書き分けています。

今回は Node.js 用の handler をここに付け足して Node.js で動く mediasoup-client を作ります。

実装

元の mediasoup-client からの改変点について書いていきます

handler

handlers/Chrome74.ts を参考にwerift.tsを実装しました。
基本的には Chrome74 のコピーですが、werift は Chrome の WebRTC の全ての機能に対応出来ている訳ではないので、そういった機能については今回は未対応としています。

未対応機能

  • iceServers の更新
  • restartIce
  • getStats
  • SVC
  • 送信側 simulcast
  • 一部 rtcp feedback
  • 一部 RTP 拡張ヘッダ

handler.getNativeRtpCapabilities

この機能も Chrome と挙動が大きく異なります。
werift は MediaEngine(audio/video のエンコーダ/デコーダなど)を持たない WebRTC 実装なので、どういったコーデックに対応するかはユーザの用意した MediaEngine 次第なところがあるので、RtpCapabilities についてはこのメソッドで取得させるのではなく、予めユーザ側で注入する必要が出てきました。

Device

src/Device.tsでは上記の handler を出し分ける機能を持っているのでここも改変する必要がありました。

detectDevice

ここではブラウザ名(or react-native)を文字列として返しています。
今回はここを常にweriftと返すようにしました。

class Device

handler の実体を持つクラスです。
このクラスの constructor に前述の RtpCapabilities をユーザが注入するインターフェースを設けました

constructor(weriftRtpCapabilities:WeriftRtpCapabilities,
		{ handlerName, handlerFactory, Handler }: DeviceOptions = {})

使い方

import {
  Device,
  RTCRtpCodecParameters,
  useAbsSendTime,
  useFIR,
  useNACK,
  usePLI,
  useREMB,
  useSdesMid,
  MediaStreamTrack,
} from "msc-node";
import { exec } from "child_process";
import { createSocket } from "dgram";
import mySignaling from "./my-signaling"; // Our own signaling stuff.

// Create a device with RtpCapabilities
const device = new Device({
  headerExtensions: {
    video: [useSdesMid(), useAbsSendTime()],
  },
  codecs: {
    video: [
      new RTCRtpCodecParameters({
        mimeType: "video/VP8",
        clockRate: 90000,
        payloadType: 98,
        rtcpFeedback: [useFIR(), useNACK(), usePLI(), useREMB()],
      }),
    ],
  },
});

// 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, sctpParameters } =
  await mySignaling.request("createTransport", {
    sctpCapabilities: device.sctpCapabilities,
  });

// Create the local representation of our server-side transport.
const sendTransport = device.createSendTransport({
  id,
  iceParameters,
  iceCandidates,
  dtlsParameters,
  sctpParameters,
});

// 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);
    }
  }
);

// Set transport "producedata" event handler.
sendTransport.on(
  "producedata",
  async (
    { sctpStreamParameters, label, protocol, appData },
    callback,
    errback
  ) => {
    // Here we must communicate our local parameters to our remote transport.
    try {
      const { id } = await mySignaling.request("produceData", {
        transportId: sendTransport.id,
        sctpStreamParameters,
        label,
        protocol,
        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 rtp video.
exec(
  "ffmpeg -re -f lavfi -i testsrc=size=640x480:rate=30 -vcodec libvpx -cpu-used 5 -deadline 1 -g 10 -error-resilient 1 -auto-alt-ref 1 -f rtp rtp://127.0.0.1:5030"
);
const udp = createSocket("udp4");
udp.bind(5030);
const rtpTrack = new MediaStreamTrack({ kind: "video" });
udp.addListener("message", (data) => {
  rtpTrack.writeRtp(data);
});
const rtpProducer = await sendTransport.produce({ track: rtpTrack });

// Produce data (DataChannel).
const dataProducer = await sendTransport.produceData({
  ordered: true,
  label: "foo",
});

...

const consumer = await recvTransport.consume({
  id,
  producerId,
  kind,
  rtpParameters,
});
consumer.track.onReceiveRtp.subscribe((rtp) => {
  // RTPパケットをどこかに送る
  udp.send(rtp.serialize(), 4002);
});

素の mediasoup-client とほぼ同じですが違う箇所が数点あります。

1. new Device

先程、実装の項で書いたとおり、ここで使用する Media の情報などを注入します。

  • headerExtensions
    • RTP 拡張ヘッダ
  • codecs
    • RTCRtpCodecParameters
// Create a device with RtpCapabilities
const device = new Device({
  headerExtensions: {
    video: [useSdesMid(), useAbsSendTime()],
  },
  codecs: {
    video: [
      new RTCRtpCodecParameters({
        mimeType: "video/VP8",
        clockRate: 90000,
        payloadType: 98,
        rtcpFeedback: [useFIR(), useNACK(), usePLI(), useREMB()],
      }),
    ],
  },
});

2. sendTransport.produce

ブラウザならMediaStreamTrackgetUserMediaで持ってこれますが、werift は MediaEngine を持っておらず、getUserMedia も実装されていません。その代わりに MediaStreamTrack に該当するものを直接作って、RTP のパケットを流し込むことが出来ます。
ここでは ffmpeg が作ったサンプル動画の RTP パケットを produce する例を示しています。

// Produce our rtp video.
import {
  ...
  MediaStreamTrack,
} from "msc-node";

...

exec(
  "ffmpeg -re -f lavfi -i testsrc=size=640x480:rate=30 -vcodec libvpx -cpu-used 5 -deadline 1 -g 10 -error-resilient 1 -auto-alt-ref 1 -f rtp rtp://127.0.0.1:5030"
);
const udp = createSocket("udp4");
udp.bind(5030);
const rtpTrack = new MediaStreamTrack({ kind: "video" });
udp.addListener("message", (data) => {
  rtpTrack.writeRtp(data);
});
const rtpProducer = await sendTransport.produce({ track: rtpTrack });

3. recvTransport.consume

recvTransport.consumeで得られる MediaStreamTrack がブラウザの物ではなく、werift の物になるので、MediaStreamTrack の 利用方法がブラウザとは異なります

...

const consumer = await recvTransport.consume({
  id,
  producerId,
  kind,
  rtpParameters,
});
consumer.track.onReceiveRtp.subscribe((rtp) => {
  // RTPパケットをどこかに送る
  udp.send(rtp.serialize(), 4002);
});

track から直接、RTP のパケットを取り出すことが出来ます

まとめ

元の mediasoup-client との差分が最小になるように気をつけて実装したので、upstream の mediasoup-client が更新されても追従できそうな気がします。
werift が MediaEngine を積んでいない事に由来するコードの差分が多かったので、werift にも MediaEngine を実装したい気持ちが出てきました。
一方で DataChannel 周りに関しては、すんなりと動いてよかったです!

Discussion