LoginSignup
8
2

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-06-03

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

実際に動かしてみよう

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

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

大まかな流れ

  • サーバー側はworkerrouterを作る
  • クライアント側はdeviceを作る
  • サーバー側でrouterRtpCapabilities(受信可能なメディアの形式などの情報)をクライアント側に渡し、クライアントはそれをdevicesに登録する
  • サーバー側はtransportを作成。そのid, iceParametersなどの情報をクライアント側に渡す。クライアントはその情報をもとに送信用、受信用のtransportを作る。
  • アプリが任意のtrackを取得し、transport.produce()producer()を作成する
    • この際に発火するイベントで情報をサーバー側に渡し、対応するproducerをサーバー側でも作る
  • 別のクライアントはそのproduceridを指定してtransport.consume()を行う
    • サーバーは送信元のtransportに対してconsume()を行い、consumerのid等パラメータを返す
    • アプリは受け取った情報をもとに対応するconsumerを作成する。
    • consumerからtrackを取り出すことで再生できる

前準備: socket.ioでの通信

今回はsocket.ioでサーバーとの通信を行う。

クライアント側

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

サーバー側

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);
  });
});
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入力欄と接続ボタンを用意しておく。

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

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を指定する。

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オブジェクトを作成する。

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の取得

RouterrtpCapabilitiesrouter.rtpCapabilitiesで取得できる。

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

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

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

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

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

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

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

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,
  };
}

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

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

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

app.js
const setTransport = async (msg: any) => {
  sendTransport = await createTransport(msg.sendTransport, "send");
  recvTransport = await createTransport(msg.recvTransport, "recv");
  produceMedia();
}
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()を動かす。

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

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

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

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()を行う。

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

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

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

サーバー側は再び受け取り、produceridを返す。

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を表示しておく。

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

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

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

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

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

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

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

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オブジェクトを作成する。

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を取り出し、映像を再生できる。

8
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
2