【TypeScript】mediasoup+socket.ioによる通話を簡単に動かしてみる

mediasoupをできるだけシンプルな構成で動かしてみる。

## SFUとは？

webRTCにおける接続をサーバー経由で行うことで多人数通話の負荷を下げる仕組みのこと。

https://www.youtube.com/watch?v=d2N0d6CKrbk

上記動画のサムネイルのように、送信方向の接続が1つだけで済むことになる。

これを実現するためにOSSで公開されているライブラリがmediasoup。

## 導入

https://mediasoup.org/documentation/v3/mediasoup/installation/

ドキュメントを参照すること。なお、これの通り行っても自分の環境だとあるwindows1台では成功、別のwindows1台では失敗した。原因は不明。(代わりにubuntuでの導入を行った)

## 理解する必要のある概念
参考: 

https://lealog.hateblo.jp/entry/2020/03/24/152051

まず、サーバー(mediasoup)とクライアント(mediasoup-client)のそれぞれ出てくるオブジェクトを理解する必要がある。

### サーバーサイド

* Worker
    * C++のサブプロセスであり、どんなシーンでも一つは必要となる。
    * 以下のように作成する。
```ts
const worker = await createWorker();
```
* Router
    * 複数の接続を作りだす、ルームのようなもの。
    * 以下のように作成する。
```ts
router = await worker.createRouter({ mediaCodecs });
```
* Transport
    * Routerと繋げることによってメディアの転送を行う橋のようなもの。
    * 送信用で一本、受信用で一本。クライアント側と同じものを持つ
    * 以下のようにして作成する。
```ts
  const transport = await router.createWebRtcTransport({
    listenIps: [{ ip: '0.0.0.0', announcedIp: '192.168.0.110' }],
  });
```
* Producer
    * produceとはメディアに送信することを指す。
    * クライアントと対応してProducerオブジェクトとして持つ。
    * transport上で作成する。
```ts
const producer = await transport.produce(parameters);
```
* Consumer
    * consumeとはメディアに送信することを指す。
    * クライアントと対応してConsumerオブジェクトとして持つ。
    * transportで作成する。
```ts
  const consumer = await transport.consume({ producerId, rtpCapabilities });
```

### クライアントサイド


* Device
    * クライアント、ユーザーエージェントそのもの
    * 以下のように作成する。
```ts
const device = new mediasoupClient.Device();
```
* Transport
    * サーバー側のTransportに対応して持つ。
    * 送信用で一本、受信用で一本。作成用の関数が異なる。
    * 以下のようにして作成する。
```ts
   const sendTransport = device.createSendTransport(params);
   const recvTransport = device.createRecvTransport(params);
```
* Producer
    * サーバーと対応してProducerオブジェクトとして持つ。
```ts
const producer = await sendTransport.produce({ track });
```
* Consumer
    * サーバーと対応してConsumerオブジェクトとして持つ。
    * transportで作成する。
```ts
const consumer = await recvTransport.consume(params);
```

## 実際に動かしてみよう

ソースコードは以下に公開した。

https://github.com/NNNiNiNNN/mediasoup-socketio-test



以下のような流れによって行うことができる。

### 大まかな流れ

- サーバー側は```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```でサーバーとの通信を行う。

クライアント側

```ts: app.js
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();
});
```

```ts: app.js
// 受け取ったメッセージによって処理を分ける
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);
  }
}
```

サーバー側

```ts: server.js
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);
  });
});
```

```ts: server.js
// 受け取ったメッセージによって処理を分ける
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```入力欄と接続ボタンを用意しておく。

```html: index.html
<!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つのエレメントを読み込んでおく。

```ts: app.js
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```を指定する。

```ts: server.js
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```オブジェクトを作成する。

```ts: app.js
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```で取得できる。

サーバー側はこの情報を返す。

```ts: server.js
const getCapabilities = () => {
  return { "type": "rtpCapabilities", "rtpCapabilities": router.rtpCapabilities };
}
```

クライアント側はこの情報を受け取り、設定する。

```ts: app.js
const setCapabilities = async (msg: any) => {
  const routerRtpCapabilities = msg.rtpCapabilities;
  device.load({ routerRtpCapabilities });
  emitTo({ "type": "transport" });
}
```

```device.load()```が済んだら、今度はクライアントが```transport```をサーバーに要求する。

### サーバー→クライアント transportの取得

サーバーは```transport```の情報を作成し、その情報を返す。```id```に対応するように連想配列に登録しておく。

```ts: server.js
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,
  };
}
```

これを送信用と受信用の二つ作成し、セットでクライアントに送信する。

```ts: server.js
const get2Transports = async () => {
  const sendTransport = await getTransport();
  const recvTransport = await getTransport();
  return {
    "type": "transport",
    "sendTransport": sendTransport,
    "recvTransport": recvTransport,
  }
}
```

クライアントは二つ分を受け取り、それぞれを登録する。

```ts: app.js
const setTransport = async (msg: any) => {
  sendTransport = await createTransport(msg.sendTransport, "send");
  recvTransport = await createTransport(msg.recvTransport, "recv");
  produceMedia();
}
```

```ts: app.js
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()```を動かす。

```ts: app.js
const produceMedia = async () => {
  const track = stream.getVideoTracks()[0];
  const producer = await sendTransport.produce({ track });
}
```

この際に先ほど設定しておいた```transport```内のイベント２つが順番に実行される。

まずは```connect```が発火。(以下は再掲)

```ts: app.js
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()```を行う。

```ts: server.js
const getConnect = async (msg: any) => {
  const { id, dtlsParameters } = msg;
  const transport = transports[id];
  await transport.connect({ dtlsParameters });
  return {"type": "connect"};
}
```

クライアント側の```connect```で```callback()```が行われたことをトリガーにイベント```produce```が発火する。(以下は再掲)

```ts: app.js
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```を返す。

```ts: server.js
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```を表示しておく。

```ts: app.js
const setProduce = (msg: any) => {
  myProducerId.value = msg.id;
  console.log("produce ready");
}
```

これで自身の映像を```produce```するまでの処理が完成。

### クライアント→サーバー→クライアント Consumeの実行

クライアント側で、ボタンを押された時に```consume```処理を開始するように設定。この際に自分の受信用```transport```のid、相手の```producer```の```id```、```rtpCapabilities```を合わせて送る。

```ts: app.js
const call = () => {
  emitTo({
    "type": "consume", 
    id: recvTransport.id,
    producerId: producerId.value,
    rtpCapabilities: device.rtpCapabilities,
  })
}

callButton.onclick = () => {
  call();
}
```

サーバー側はこれを受け取り、```transport.consume()```を実行する。```consumer```の```id```等の情報を返す。

```ts: server.js
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```オブジェクトを作成する。

```ts: app.js
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```を取り出し、映像を再生できる。