SkyWayの公式チュートリアルでInsertable Streamsを試してみた

10月6日にリリースされたChrome M86（安定版）から、**WebRTC Insertable Streams API**が利用できるようになりました。

Insertable Streamsは簡単に言うと、音声や映像のフレーム毎のバイナリデータに任意の値を差し込む技術です。バイナリデータを触れるので、映像データを暗号化（E2EE）する等の用途に利用されている印象です。また、音声や映像とタイミングをズラさずにデータを届けられるという利点もあります。

さっそく、国内のWebRTC PaaSである[SkyWay](https://webrtc.ecl.ntt.com/)で試してみました。


javascript SDKの[公式チュートリアル(P2P)](https://webrtc.ecl.ntt.com/documents/javascript-sdk.html#%E3%83%81%E3%83%A5%E3%83%BC%E3%83%88%E3%83%AA%E3%82%A2%E3%83%AB)を少しだけ改造して、任意のデータをやり取りできるようにしてみます。


## デモ

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89866/0d538ca9-18f5-420f-41dc-a668ba351b6b.gif)



[CODEPEN](https://codepen.io/ibuibu/pen/KKMWaPb?editors=1011)
でお試しいただけます。


## コード

```html

<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Insertable Streams on SkyWay</title>
    <script src="https://cdn.webrtc.ecl.ntt.com/skyway-latest.js"></script>
  </head>
  <body>
    <video id="my-video" width="400px" autoplay muted></video>
    <video id="their-video" width="400px" autoplay></video>
    <p id="my-id"></p>
    <input id="their-id" placeholder="their peer id" />
    <button id="make-call">発信</button>
    <input id="num" placeholder="send number" />
    <p id="receive-num"></p>
    <script>
      const APIKEY = "YOUR APIKEY";

      const senderTransform = (pc) => {
        pc.getSenders().forEach((sender) => {
          const senderStreams = sender.createEncodedStreams();
          const readableStream = senderStreams.readable;
          const writableStream = senderStreams.writable;
          const transformStream = new TransformStream({
            transform: (encodedFrame, controller) => {
              const len = encodedFrame.data.byteLength;
              if (sender.track.kind === "audio") {
                const container = new Uint8Array(len + 1);
                container.set(new Uint8Array(encodedFrame.data), 0);
                const num = new Uint8Array(1);
                num[0] = document.getElementById("num").value;
                container.set(num, encodedFrame.data.byteLength);
                encodedFrame.data = container.buffer;
              }
              controller.enqueue(encodedFrame);
            },
          });
          readableStream.pipeThrough(transformStream).pipeTo(writableStream);
        });
      };

      const receiverTransform = (pc) => {
        pc.getReceivers().forEach((receiver) => {
          const receiverStreams = receiver.createEncodedStreams();
          const readableStream = receiverStreams.readable;
          const writableStream = receiverStreams.writable;
          const transformStream = new TransformStream({
            transform: (encodedFrame, controller) => {
              if (receiver.track.kind === "audio") {
                const mediaData = new Uint8Array(
                  encodedFrame.data.slice(0, -1)
                );
                const num = new Uint8Array(encodedFrame.data.slice(-1));
                document.getElementById("receive-num").textContent = num[0];
                encodedFrame.data = mediaData.buffer;
              }
              controller.enqueue(encodedFrame);
            },
          });
          readableStream.pipeThrough(transformStream).pipeTo(writableStream);
        });
      };

      navigator.mediaDevices
        .getUserMedia({ video: true, audio: true })
        .then((stream) => {
          const videoElm = document.getElementById("my-video");
          videoElm.srcObject = stream;
          videoElm.play();
          localStream = stream;
        })
        .catch((error) => {
          console.error("mediaDevice.getUserMedia() error:", error);
          return;
        });

      const peer = new Peer({
        key: APIKEY,
        debug: 2,
        config: {
          encodedInsertableStreams: true,
        },
      });

      peer.on("open", () => {
        document.getElementById("my-id").textContent = peer.id;
      });

      peer.on("call", (mediaConnection) => {
        mediaConnection.answer(localStream);
        mediaConnection.on("stream", (stream) => {
          document.getElementById("their-video").srcObject = stream;
          const pc = mediaConnection.getPeerConnection();
          receiverTransform(pc);
          senderTransform(pc);
        });
      });

      document.getElementById("make-call").onclick = async () => {
        const theirId = document.getElementById("their-id").value;
        const mediaConnection = peer.call(theirId, localStream);
        mediaConnection.on("stream", (stream) => {
          document.getElementById("their-video").srcObject = stream;
        });
        setTimeout(() => {
          const pc = mediaConnection.getPeerConnection();
          senderTransform(pc);
          receiverTransform(pc);
        }, 1000);
      };
    </script>
  </body>
</html>

```

## 解説

やっていることの概要は以下のとおりです。

- 送信側：音声バイナリデータの先頭に、ある1byteのデータを差し込む
- 受信側：音声バイナリデータの先頭から、1byte分のデータを取得すると同時に削除する

送信をsenderTransform、受信receiverTransform関数として、最初に用意しています。

では次に、通信が確率するまでの流れを追ってみます。

### Peerの作成

```javascript

      const peer = new Peer({
        key: APIKEY,
        debug: 2,
        config: {
          encodedInsertableStreams: true,
        },
      });
```

peerインスタンスを作成する際に、configというパラメータで、`          encodedInsertableStreams: true`を与えます。
これにより、このpeerでInsertable Streams APIを使えるようになります。

### 発信

```javascript
      // 発信ボタン押下
      document.getElementById("make-call").onclick = async () => {
        const theirId = document.getElementById("their-id").value;
        const mediaConnection = peer.call(theirId, localStream);
        // 相手映像が来たらDOMに適用
        mediaConnection.on("stream", (stream) => {
          document.getElementById("their-video").srcObject = stream;
        });
        // 1秒待つ
        setTimeout(() => {
          // RTCPeerConnectionを取得
          const pc = mediaConnection.getPeerConnection();
          senderTransform(pc); // 送信streamに任意データを差し込み
          receiverTransform(pc); // 受信streamから任意データを取得
        }, 1000);
      };
```

まず、発信ボタンを押した時に、`peer.call()`の戻り値としてMediaConnectionのインスタンスが取得できます。

MediaConnectionが確立する（openプロパティがtrueになる）まで、setTimeoutで1秒間待った上で、[getPeerConnection](https://webrtc.ecl.ntt.com/api-reference/javascript.html#getpeerconnection)を実行し、`RTCPeerConnection`を取得します。（Insertable Streamsを行うにはこの`RTCPeerConnection`が必要です）

`RTCPeerConnection`を先のsenderTransform, receiverTransform関数に渡してあげることで、流れているstreamに任意データを差し込み＆取得します。

### 着信
```javascript
      peer.on("call", (mediaConnection) => {
        mediaConnection.answer(localStream);
        mediaConnection.on("stream", (stream) => {
          document.getElementById("their-video").srcObject = stream;
          // RTCPeerConnectionを取得
          const pc = mediaConnection.getPeerConnection();
          senderTransform(pc); // 送信streamに任意データを差し込み
          receiverTransform(pc); // 受信streamから任意データを取得
        });
      });
```

発信側と同様に、getPeerConnection()で`RTCPeerConnection`を取得し、sender/receiverTransform関数に渡してあげます。


### 任意データの差し込み＆取得

sender/receiverTransform関数では、以下を実施しています。

- sender：numという1バイトのUint8Array型の変数を用意し、DOMから取得した値を入れ、音声バイナリデータの先頭に足す

- receiver：音声バイナリデータの先頭から値を削除 ＆ 取得しDOMに適用

Insertable Streamsの詳細な使い方についてはここでは割愛します。

（以下に公式サイトや参考にした記事のリンクを貼りましたので、そちらをご参照ください。）


## 参考にしたInsertable Streams関連記事

- [W3C 公式ドキュメント](https://w3c.github.io/webrtc-insertable-streams/)
- [ついに映像同期したインライン Data に Webが対応。WebRTC Insertable Streams でのインラインData送受信のやり方](https://qiita.com/komasshu/items/269a8b0a375eab253c3a) / @komasshu 
- [WebRTC Insertable Streams で映像ストリームをいじってみた](https://qiita.com/massie_g/items/2b0b6d4f61f1865b4da5) / @massie_g