mediasoupをできるだけシンプルな構成で動かしてみる。
SFUとは?
webRTCにおける接続をサーバー経由で行うことで多人数通話の負荷を下げる仕組みのこと。
上記動画のサムネイルのように、送信方向の接続が1つだけで済むことになる。
これを実現するためにOSSで公開されているライブラリがmediasoup。
導入
ドキュメントを参照すること。なお、これの通り行っても自分の環境だとあるwindows1台では成功、別のwindows1台では失敗した。原因は不明。(代わりにubuntuでの導入を行った)
理解する必要のある概念
参考:
まず、サーバー(mediasoup)とクライアント(mediasoup-client)のそれぞれ出てくるオブジェクトを理解する必要がある。
サーバーサイド
- Worker
- C++のサブプロセスであり、どんなシーンでも一つは必要となる。
- 以下のように作成する。
const worker = await createWorker();
- Router
- 複数の接続を作りだす、ルームのようなもの。
- 以下のように作成する。
router = await worker.createRouter({ mediaCodecs });
- Transport
- Routerと繋げることによってメディアの転送を行う橋のようなもの。
- 送信用で一本、受信用で一本。クライアント側と同じものを持つ
- 以下のようにして作成する。
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: '192.168.0.110' }],
});
- Producer
- produceとはメディアに送信することを指す。
- クライアントと対応してProducerオブジェクトとして持つ。
- transport上で作成する。
const producer = await transport.produce(parameters);
- Consumer
- consumeとはメディアに送信することを指す。
- クライアントと対応してConsumerオブジェクトとして持つ。
- transportで作成する。
const consumer = await transport.consume({ producerId, rtpCapabilities });
クライアントサイド
- Device
- クライアント、ユーザーエージェントそのもの
- 以下のように作成する。
const device = new mediasoupClient.Device();
- Transport
- サーバー側のTransportに対応して持つ。
- 送信用で一本、受信用で一本。作成用の関数が異なる。
- 以下のようにして作成する。
const sendTransport = device.createSendTransport(params);
const recvTransport = device.createRecvTransport(params);
- Producer
- サーバーと対応してProducerオブジェクトとして持つ。
const producer = await sendTransport.produce({ track });
- Consumer
- サーバーと対応してConsumerオブジェクトとして持つ。
- transportで作成する。
const consumer = await recvTransport.consume(params);
実際に動かしてみよう
ソースコードは以下に公開した。
以下のような流れによって行うことができる。
大まかな流れ
- サーバー側は
worker
、router
を作る - クライアント側は
device
を作る - サーバー側で
routerRtpCapabilities
(受信可能なメディアの形式などの情報)をクライアント側に渡し、クライアントはそれをdevices
に登録する - サーバー側は
transport
を作成。そのid
,iceParameters
などの情報をクライアント側に渡す。クライアントはその情報をもとに送信用、受信用のtransport
を作る。 - アプリが任意の
track
を取得し、transport.produce()
でproducer()
を作成する- この際に発火するイベントで情報をサーバー側に渡し、対応する
producer
をサーバー側でも作る
- この際に発火するイベントで情報をサーバー側に渡し、対応する
- 別のクライアントはその
producer
のid
を指定してtransport.consume()
を行う- サーバーは送信元の
transport
に対してconsume()
を行い、consumer
のid等パラメータを返す - アプリは受け取った情報をもとに対応する
consumer
を作成する。 -
consumer
からtrack
を取り出すことで再生できる
- サーバーは送信元の
前準備: socket.ioでの通信
今回はsocket.io
でサーバーとの通信を行う。
クライアント側
import { io } from 'socket.io-client';
const socket = io('http://localhost:5000');
// サーバーへの送信用
const emitTo = (msg: any) => {
socket.emit('message', msg);
}
socket.on('message', (message) => {
// 必要な処理は以下の関数で行う
getServerMessage();
});
// 受け取ったメッセージによって処理を分ける
const getServerMessage = (msg: any) => {
if (msg.type == "rtpCapabilities") {
return setCapabilities(msg);
}
if (msg.type == "transport") {
return setTransport(msg);
}
if (msg.type == "produce") {
return setProduce(msg);
}
if (msg.type == "consume") {
return setConsume(msg);
}
}
サーバー側
import { Server, Socket } from 'socket.io';
import http from 'http';
const server: http.Server = http.createServer();
const io = new Server(server);
var port = 5000;
server.listen(port);
io.on('connection', (socket: Socket) => {
// メッセージを受け取った時
socket.on('message', (message) => {
// 必要な処理は以下の関数で行う
const res: any = await getClientMessage(msg);
socket.emit("message", res);
});
});
// 受け取ったメッセージによって処理を分ける
const getClientMessage = async(msg: any) => {
if (msg.type == "rtpCapabilities") {
return getCapabilities();
}
if (msg.type == "transport") {
return get2Transports();
}
if (msg.type == "connect") {
return getConnect(msg);
}
if (msg.type == "produce") {
return getProduce(msg);
}
if (msg.type == "consume") {
return getConsume(msg);
}
クライアントの表示用ページを作っておく。自分のid
表示、自分と相手のビデオ、通話相手のid
入力欄と接続ボタンを用意しておく。
<!DOCTYPE html>
<body>
<label>my producer id: </label>
<input id="myProducerId"></div>
<video id="localVideo" autoplay></video>
<label><input type="text" id="callProducerId"></label>
<button type="button" id="call">call</button>
<video id="remoteVideo" autoplay></video>
<script src="app/app.js" type="module"></script>
</body>
</html>
app.js内でこの5つのエレメントを読み込んでおく。
const localVideo = <HTMLVideoElement>document.getElementById("localVideo");
const remoteVideo = <HTMLVideoElement>document.getElementById("remoteVideo");
const myProducerId = <HTMLTextAreaElement>document.getElementById('myProducerId');
const producerId = <HTMLTextAreaElement>document.getElementById('callProducerId');
const callButton = <HTMLButtonElement>document.getElementById('call');
サーバー側準備: WorkerとRouterの作成
Worker
オブジェクト、Router
オブジェクトを作成しておく。Router
にはmediaCodecs
を指定する。
import { Router } from 'mediasoup/node/lib/Router';
import { Transport } from 'mediasoup/node/lib/Transport';
import { Producer } from 'mediasoup/node/lib/Producer';
let router: Router;
const initMediaSoupServer = async () => {
const worker = await createWorker();
const mediaCodecs = [
{
kind: 'audio',
mimeType: 'audio/opus',
clockRate: 48000,
channels: 2,
},
{
kind: 'video',
mimeType: 'video/H264',
clockRate: 90000,
parameters: {
'packetization-mode': 1,
'profile-level-id': '42e01f',
'level-asymmetry-allowed': 1,
},
},
];
router = await worker.createRouter({ mediaCodecs });
}
initMediaSoupServer();
クライアント側準備: Deviceの作成
クライアント側でビデオからstream
を取得して、確認用に再生しておく。
Device
オブジェクトを作成する。
import { Device } from "mediasoup-client";
import { Transport } from "mediasoup-client/lib/Transport";
let device: Device;
let stream: MediaStream;
const initMediaSoupClient = async () => {
stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
localVideo.srcObject = stream;
device = new Device();
emitTo({"type": "rtpCapabilities"});
}
initMediaSoupClient();
最後のemitTo()
でサーバーにrtpCapabilities
を要求し、接続処理を開始する。
サーバー→クライアント rtpCapabilitiesの取得
Router
のrtpCapabilities
はrouter.rtpCapabilities
で取得できる。
サーバー側はこの情報を返す。
const getCapabilities = () => {
return { "type": "rtpCapabilities", "rtpCapabilities": router.rtpCapabilities };
}
クライアント側はこの情報を受け取り、設定する。
const setCapabilities = async (msg: any) => {
const routerRtpCapabilities = msg.rtpCapabilities;
device.load({ routerRtpCapabilities });
emitTo({ "type": "transport" });
}
device.load()
が済んだら、今度はクライアントがtransport
をサーバーに要求する。
サーバー→クライアント transportの取得
サーバーはtransport
の情報を作成し、その情報を返す。id
に対応するように連想配列に登録しておく。
let transports: { [key: string]: Transport } = {};
const getTransport = async () => {
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: '192.168.0.110' }],
});
const { id, iceParameters, iceCandidates, dtlsParameters } = transport;
transports[id] = transport;
return {
id,
iceParameters,
iceCandidates,
dtlsParameters,
};
}
これを送信用と受信用の二つ作成し、セットでクライアントに送信する。
const get2Transports = async () => {
const sendTransport = await getTransport();
const recvTransport = await getTransport();
return {
"type": "transport",
"sendTransport": sendTransport,
"recvTransport": recvTransport,
}
}
クライアントは二つ分を受け取り、それぞれを登録する。
const setTransport = async (msg: any) => {
sendTransport = await createTransport(msg.sendTransport, "send");
recvTransport = await createTransport(msg.recvTransport, "recv");
produceMedia();
}
const createTransport = async(params: any, dir: string) => {
let transport: Transport;
if(dir == "send")
{
transport = device.createSendTransport(params);
}
else
{
transport = device.createRecvTransport(params);
}
transport.on("connect", async ({ dtlsParameters }, callback, errback) => {
emitTo({ "type": "connect", id: transport.id, "dtlsParameters": dtlsParameters });
callback();
});
transport.on("produce", async (parameters, callback, errback) => {
emitTo({ "type": "produce", id: transport.id, parameters });
});
return transport;
}
これが完了したらproducer
作成の関数を動かす。
クライアント→サーバー→クライアント producerの作成
クライアント側でsendTransport.produce()
を動かす。
const produceMedia = async () => {
const track = stream.getVideoTracks()[0];
const producer = await sendTransport.produce({ track });
}
この際に先ほど設定しておいたtransport
内のイベント2つが順番に実行される。
まずはconnect
が発火。(以下は再掲)
const createTransport = async(params: any, dir: string) => {
// 省略
transport.on("connect", async ({ dtlsParameters }, callback, errback) => {
emitTo({ "type": "connect", id: transport.id, "dtlsParameters": dtlsParameters });
callback();
});
// 省略
return transport;
}
サーバーはこれを受け取ってtransport.connect()
を行う。
const getConnect = async (msg: any) => {
const { id, dtlsParameters } = msg;
const transport = transports[id];
await transport.connect({ dtlsParameters });
return {"type": "connect"};
}
クライアント側のconnect
でcallback()
が行われたことをトリガーにイベントproduce
が発火する。(以下は再掲)
const createTransport = async(params: any, dir: string) => {
// 省略
transport.on("produce", async (parameters, callback, errback) => {
emitTo({ "type": "produce", id: transport.id, parameters });
});
// 省略
return transport;
}
サーバー側は再び受け取り、producer
のid
を返す。
const getProduce = async (msg: any) => {
const { id, parameters } = msg;
const transport = transports[id];
const producer = await transport.produce(parameters);
producers[producer.id] = producer;
return ({ "type": "produce", id: producer.id });
}
クライアント側でこれを受け取り、id
を表示しておく。
const setProduce = (msg: any) => {
myProducerId.value = msg.id;
console.log("produce ready");
}
これで自身の映像をproduce
するまでの処理が完成。
クライアント→サーバー→クライアント Consumeの実行
クライアント側で、ボタンを押された時にconsume
処理を開始するように設定。この際に自分の受信用transport
のid、相手のproducer
のid
、rtpCapabilities
を合わせて送る。
const call = () => {
emitTo({
"type": "consume",
id: recvTransport.id,
producerId: producerId.value,
rtpCapabilities: device.rtpCapabilities,
})
}
callButton.onclick = () => {
call();
}
サーバー側はこれを受け取り、transport.consume()
を実行する。consumer
のid
等の情報を返す。
const getConsume = async (msg: any) => {
const { id, producerId, rtpCapabilities } = msg;
const transport = transports[id];
const consumer = await transport.consume({ producerId, rtpCapabilities });
return ({
"type": "consume",
id: consumer.id,
kind: consumer.kind,
rtpParameters: consumer.rtpParameters
});
}
クライアント側がこの情報を受け取り、consumer
オブジェクトを作成する。
const setConsume = async(msg: any) =>
{
const consumerOptions = msg;
const consumer = await recvTransport.consume({
id: consumerOptions.id,
producerId: producerId.value,
kind: consumerOptions.kind,
rtpParameters: consumerOptions.rtpParameters,
});
// 以下から再生
remoteVideo.srcObject = new MediaStream([consumer.track]);
}
ここからtrack
を取り出し、映像を再生できる。