【typescript】WebRTCによるビデオ通話を簡単に繋げてみる

これらの記事を参考に、WebRTCのP2P通信を繋げていく。
https://html5experts.jp/mganeko/19814/
https://html5experts.jp/mganeko/20013/

## WebRTCって何？

WebRTC(Web Real-Time Communication)とは、ブラウザやアプリ間でリアルタイムに映像・音楽等を送受信するための技術。サーバーを経由しないP2P通信を行い、かつ通信プロトコルにはTCPではなくUDPを採用することで大量のデータをリアルタイムに高速で送受信することができる。

WebRTCを行うためのRTCPeerConnectionオブジェクトは以下のように作成する。

```ts
let peer = new RTCPeerConnection();
```

双方向のクライアントがこのオブジェクトを持つことで通信を行うが、そのためにはそれぞれのSDPを交換するプロセスを踏む必要がある。

## SDPの交換

SDP(Session Description Protocol)とは、ざっくり言うと「データの送り方」をまとめた情報。

* 送るメディアの形式(コーデック)
* IPアドレス、ポート番号
* データ転送プロトコル
* 通信で使用する帯域
* ICE(Interactive Connectivity Establishment)
    * 通信経路の候補

といった情報が含まれている。もっと詳細を知りたい場合は[こちら](https://www.slideshare.net/iwashi86/webrtc-for-sdp)を見るとよい。

それぞれのブラウザ・環境等によって使用できる形式・通信経路が異なるため、SDPを送りあうことによってそのズレを合わせることができる。

```クライアントA```、```クライアントB```の二人が通信を繋ぎたいとして、おおまかに以下のような流れを取る。

#### 1. クライアントAでoffer SDP(自分の情報)を生成し、クライアントBに送信する

クライアントAはまず自分の情報を相手に伝える必要がある。```peerConnection```オブジェクトから```createOffer()```を動かし、```sessionDescription```を取得する。

```ts
peerConnection.createOffer().
then((sessionDescription) => {
    return peerConnection.setLocalDescription(sessionDescription);
});
```

```setLocalDescription```として生成したoffer SDPを登録。この時に動くイベントを利用して送信を行う。

#### 2. クライアントBは受け取ったofferSDPを登録。Answer SDP(相手の情報と自分の情報を合わせて作った最適な形式)を生成し、クライアントAに送信する

クライアントBは受け取った```sessionDescription```を```setRemoteDescription(sessionDescription)```として登録する。

```ts
peerConnection.setRemoteDescription(sessionDescription)
    .then( ()=> {
        // answer sdp生成用の処理をここに書く
    });
```

offerSDPの登録が完了したら、```createAnswer()```によってanswerSDPの生成を行う。

```ts
peerConnection.createAnswer()
    .then( (sessionDescription) =>{
        return peerConnection.setLocalDescription(sessionDescription);
    });
```

```setLocalDescription(sessionDescription)```としてクライアントBはAnswer SDPを登録。このイベントを取得してクライアントAに生成したAnswer SDPを送信する。

#### 3. クライアントAは受け取ったAnswer SDPを設定する

クライアントAは```setRemoteDescription(sessionDescription)```でAnswer SDPを登録する。
```ts
const setAnswer = (sessionDescription: RTCSessionDescription) => {
    peerConnection.setRemoteDescription(sessionDescription)
        .then( () => {
            console.log('準備完了！');
        });
}
```


これで双方向の通信を始めることができるようになる。


## 実装してみよう

この交換のプロセスと通話を繋ぐまでを実装してみる。

### 表示用ページの作成

確認用の自分のビデオエレメント、受信ビデオ表示用のビデオエレメント、接続用のボタンだけ置く。

```html
<html>

<body>
    <video id="my-video" width="400"></video>
    <video id="rtc-video" width="400"></video>
    <button type="button" id="connectButton">connect</button><br />
    <script type="module" src="src/app.js"></script>

</body>
</html>
```

### サーバーの準備

socket.ioを用いて、「送信されたメッセージを全クライアントに渡す」ようなサーバーを作る。

[socket.ioの使い方](https://qiita.com/NNNiNiNNN/items/53329d69fce9cb8e34af)


```ts: server.ts
import { Server, Socket }  from 'socket.io';
import http from 'http';

const server: http.Server = http.createServer();
const io = new Server(server, {
  cors:{
      origin: "http://localhost:1234",
  }
});

var port = 5000;
server.listen(port);

io.on('connection', (socket: Socket) => {

  // メッセージを受け取った時
  socket.on('message', (message) => {

    // 誰から送られたかわかるようにしておく
    message.from = socket.id;

    // 全員に送る
    io.emit("message", message);

  });


});
```

### クライアント側がサーバーと繋ぐ準備



クライアント側の設定も行う。サーバーへの送信用の関数の設定と、サーバーからの受信用のイベントの登録を行った。受け取った内容によって```setOffer()```, ```setAnswer()```のどちらか(後述)を行う。

```ts: client.ts
import { io } from 'socket.io-client';

const socket = io('http://localhost:5000');

// サーバーへの送信用
const emitTo = (msg: any) => {
    socket.emit('message', msg);
}

socket.on('message', (message) => {
    // 自分のメッセージには反応しないように
    if(socket.id != message.from)
    {
        if (message.type === 'offer') {
            let offer = new RTCSessionDescription(message);
            setOffer(offer);
        }
        else if (message.type === "answer") {
            let answer = new RTCSessionDescription(message);
            setAnswer(answer);
        }
    }
});
```

### ビデオの取得

自分のビデオをカメラから取得し、MediaStreamとして持つように設定する。
```ts: client.ts
const myVideo = <HTMLVideoElement>document.getElementById('my-video');
const recieveVideo = <HTMLVideoElement>document.getElementById('rtc-video');

let localStream: MediaStream;
let videoTrack: MediaStreamTrack;

const startVideo = () =>{
    navigator.mediaDevices.getUserMedia({ video: true, audio: false })
        .then( (stream) => {
            localStream = stream;
            playVideo(myVideo, localStream);

        });
}

startVideo();
```

MediaStreamからビデオを再生する時は以下の様にして行う。

```ts: client.ts
const playVideo = (element: HTMLVideoElement, stream: MediaStream) => {
    element.srcObject = stream;
    element.play();
    element.volume = 0;
}
```

これで一通りの準備はできた。

### offer SDPの生成、送信

まず、ボタンを押した時にofferSDPの生成を行う。
```ts: client.ts
document.getElementById("connectButton")!.onclick = () => {
    makeOffer();
}
```

```ts: client.ts
const makeOffer = () => {

    // RTCPeerConnectionのオブジェクトを作る
    let pc_config = { "iceServers": [] };
    peerConnection = new RTCPeerConnection(pc_config);
    
    // 送りたいstreamを設定する。これがないとSDPに書くことがなくなる
    peerConnection.addTrack(videoTrack, localStream);

    // trackが追加された際に発火。ビデオを表示する
    peerConnection.ontrack =  (event) => {
        let stream = event.streams[0];
        playVideo(recieveVideo, stream);
    };
    // ice生成時イベントの処理を登録する。候補生成が全部完了したら送信。
    peerConnection.onicecandidate = (evt) => {
        if (evt.candidate) {
            // ice candidateを一個分生成した時
        } else {
            // ice candidateが一通り完了した時
            let sessionDescription = peerConnection.localDescription!;
            let message = { type: sessionDescription.type, sdp: sessionDescription.sdp };
            
            // サーバーにSDPを送信
            emitTo(message);
        }
    };

    // offer SDPを生成。
    peerConnection.createOffer().
        then((sessionDescription) => {
            // 返ってきたsessionDescriptionを登録。
            return peerConnection.setLocalDescription(sessionDescription);
            // この際にpeerConnection.onicecandidateイベントが発火する
        });
}
```

```peerConnection.createOffer()```でsdpを生成。返ってきた値```sessionDescription```を```peerConnection.setLocalDescription(sessionDescription)```で設定する。この際に```peerConnection.onicecandidate```イベントが発火するため、そこでice candidateを一通り作り終えたらその情報まで含んだSDPを送信する。


### Answer SDPの生成、送信

クライアントAからクライアントBに送られたメッセージは```socket.on('message')```を通し、typeから分類されて```setOffer(offer)```関数を動かす。
クライアントBでもRTCSessionDescriptionオブジェクトを作成し、SDPを登録する。
基本的な流れは先ほどと同じ。

```ts: client.ts
const setOffer = (sessionDescription: RTCSessionDescription) =>{
    // RTCPeerConnectionのオブジェクトを作る
    let pc_config = { "iceServers": [] };
    peerConnection = new RTCPeerConnection(pc_config);
    
    // 送りたいstreamを設定する。
    peerConnection.addTrack(videoTrack, localStream);
    // trackが追加された際に発火。ビデオを表示する
    peerConnection.ontrack =  (event) => {
        let stream = event.streams[0];
        playVideo(recieveVideo, stream);
    };
    // ice生成時イベントの処理を登録する。
    peerConnection.onicecandidate = (evt) => {
        if (evt.candidate) {
            // ice candidateを一個分生成した時
        } else {
            // ice candidateが一通り完了した時
            let sessionDescription = peerConnection.localDescription!;
            let message = { type: sessionDescription.type, sdp: sessionDescription.sdp };
            
            // サーバーにanswer SDPを送信
            emitTo(message);
        }
    };

    
    peerConnection.setRemoteDescription(sessionDescription)
        .then( ()=> {
            makeAnswer();
        });
}
```

登録完了時に```makeAnswer()```として以下の関数を動かす。

```ts: client.ts
const makeAnswer = () => {
    peerConnection.createAnswer()
        .then( (sessionDescription) =>{
            return peerConnection.setLocalDescription(sessionDescription);
            // この際にpeerConnection.onicecandidate()が発火し、得られたanswer sdpを送信する。
        });
}
```

#### Answer SDPの受信、設定

クライアントBからクライアントAに送られたメッセージはまた```socket.on('message')```を通し、typeから分類されて```setAnswer()```関数を動かす。

```ts: client.ts
const setAnswer = (sessionDescription: RTCSessionDescription) => {
    peerConnection.setRemoteDescription(sessionDescription)
        .then( () => {
            console.log('準備完了！');
        });
}
```


これでお互いの画面が映れば完了。