Node.js用のmediasoup clientを作ってみた
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 を自作してみることにしました。
成果物
開発方針
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
ブラウザならMediaStreamTrack
はgetUserMedia
で持ってこれますが、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