SkyWayでWebRTCを実装する概要

# はじめに
WebRTCの実装をする機会があり、SkyWayというサービスについて調べたので記事にします。
WebRTCの実装をするにあたり、他には以下のサービスの選択肢があります。
- [マルキャス](https://marucast.jp/)
- [Vonage](https://developer.vonage.com/en/home)
- [Twilio](https://www.twilio.com/ja-jp)
- [Chime](https://aws.amazon.com/jp/chime/chime-sdk/)
- [VCube](https://jp.vcube.com/sdk)		

本来は比較して、選定を検討できるようにしたかったのですが、プロジェクトの方針でSkyWayを使うことが決まっていたので、なるべく比較しやすいような形で記事にできればと思っています。

:::note warn
SkyWay(他のサービスも同様だと思うが)には、接続数などの制約事項がありますので、事前に確認が必要です。
プロジェクトで使用を検討する際には、要件に合うかを確認、もしくは、制約事項に合わせて要件を調整する必要があります。（制約事項については、一番下にまとめています）
:::

# WebRTCとは
リアルタイムの音声、映像、データ通信する技術です。
ビデオ通話するシステムや、ライブ配信システムが作成できます。

https://imageflux.sakura.ad.jp/column/webrtc/?gad_source=1&gclid=Cj0KCQiAlsy5BhDeARIsABRc6ZsQN8ZL8oJ66GUiPPjagvkchM5zzAYV0If3Ehgm7FsaN70ZD41DovkaAvNMEALw_wcB

# SkyWay
WebRTCで通信するサーバーと、クライアント側の開発（音声の送受信や動画の送受信）を簡単にできるSDKやAPIを提供するサービスです。
音声、動画、ファイルのやり取りと、録画機能があるようです。

https://skyway.ntt.com/ja/docs/user-guide/introduction/

# 実装
## SkyWayでWeb RTCサーバーを作成
::: note 
Freeプランで検証ができるので気軽に試せました
> Freeプラン(無料版)では開発・検証に足りる十分なデータ通信量枠を用意しております。50万回までの接続と500GBまでのサーバー通信が無料でご利用いただけます。
:::

- SkyWayにユーザー登録
- ログインしてプロジェクトを作成する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/46591b79-e25b-48f7-6dfa-274f37d6c04e.png)

- アプリケーション作成
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/91d47020-146d-dec0-8f4e-8d987e485b27.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2475427/aa572c49-b7cc-3d50-1c44-3d4e784db990.png)

- 作成したアプリケーションIDとシークレットキーはJavascriptのコード側で使用します

## コード
[デモ](https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/#94)の実装をしてみたので、コードを書いておきます。

```html
<html>
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <title>SkyWay Tutorial</title>
  </head>
  <body>
    <p>ID: <span id="my-id"></span></p>
    <div>
      room name: <input id="room-name" type="text" />
      <button id="join">join</button>
      <button id="leave">leave</button>
    </div>
    <video id="local-video" width="400px" muted playsinline></video>
    <div id="button-area"></div>
    <div id="remote-media-area"></div>
    <script type="module" src="main.js"></script>
  </body>
</html>
```

```js
// SkyWayで作成したアプリケーションID
const APPLICATION_ID = "";
// SkyWayで作成したアプリケーションのシークレットキー
const SECRET_KEY = "";
const token = new SkyWayAuthToken({
  // トークンのユニークなid
  jti: uuidV4(),
  // トークンが発行された日時（UNIX）
  iat: nowInSec(),
  // トークンが無効になる時間（UNIX）
  exp: nowInSec() + 60 * 60 * 24,
  // トークンのバージョン（2以上じゃないとメンバー名にワイルドカードを指定できないらしい）
  version: 1,
  scope: {
    app: {
      // SkyWayで作成したアプリケーションID
      id: APPLICATION_ID,
      turn: true,
      actions: ["read"],
      channels: [
        {
          // Channelはidかnameどちらかを指定する必要がある
          // *の場合は、すべての文字列がOKとなる
          // custom_room_*みたいに文字列の一部にワイルドカードを使う場合は、トークンのバージョンを2以上にしないといけない
          id: "*",
          name: "*",
          // write, read, create, delete, updateMetadataのアクションを許可可能
          actions: ["write"],
          // Memberもidかnameどちらかを指定する必要がある
          // *の場合は、すべての文字列がOKとなる
          // プロダクトの実装の場合は、参加者自身のIDか名前を指定する感じになると思う
          // custom_room_*みたいに文字列の一部にワイルドカードを使う場合は、トークンのバージョンを2以上にしないといけない
          members: [
            {
              id: "*",
              name: "*",
              // メンバーに対する許可を設定
              // write, create, delete, signal, updateMetadata
              // writeは、Memberのすべての操作の許可
              // TODO: signalは、Pub/Sub を行う場合に必要シグナリング情報のやり取りの許可と記載があったが、どういう時に必要かは要調査
              actions: ["write"],
              // このメンバーがPublisherであるPublicationに対する許可を設定
              // write, create, delete, updateMetadata, enable, disable
              publication: {
                actions: ["write"],
              },
              // このメンバーがSubscriberであるSubscriptionに対する許可を設定
              // write, create, deleteが設定可能
              subscription: {
                actions: ["write"],
              },
            },
          ],
          // SFUBotは特殊な目的を持ってChannelにJoinする、SkyWayが提供するMember。特殊な目的とは多人数通話や映像配信らしい。
          sfuBots: [
            {
              actions: ["write"],
              // Forwardingsある Member の Publication を SFU Bot が Subscribe して、その内容を改めて SFU Bot が Publish することで、複数の Member に対して配信を行う操作
              forwardings: [
                {
                  actions: ["write"],
                },
              ],
            },
          ],
        },
      ],
    },
  },
}).encode(SECRET_KEY);

    document.addEventListener("DOMContentLoaded", function() {
  (async () => {
    const skywayContext = await SkyWayContext.Create(token);

    const localVideo = document.getElementById("local-video");
    const buttonArea = document.getElementById("button-area");
    const remoteMediaArea = document.getElementById("remote-media-area");
    const roomNameInput = document.getElementById("room-name");
  
    const myId = document.getElementById("my-id");
    const joinButton = document.getElementById("join");
    const leaveButton = document.getElementById("leave");
  
    const { audio, video } =
      await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream(); 
    video.attach(localVideo);
    await localVideo.play();
    joinButton.onclick = async () => {
      if (roomNameInput.value === "") return;
      const room = await SkyWayRoom.FindOrCreate(skywayContext, {
        type: "sfu",
        name: roomNameInput.value,
      });
      const me = await room.join();
      myId.textContent = me.id;
      await me.publish(audio);
      await me.publish(video);
      const subscribeAndAttach = (publication) => {
        if (publication.publisher.id === me.id) return;
        const subscribeButton = document.createElement("button");
        subscribeButton.id = `subscribe-button-${publication.id}`;
        subscribeButton.textContent = `${publication.publisher.id}: ${publication.contentType}`;
        buttonArea.appendChild(subscribeButton);
        subscribeButton.onclick = async () => {
          const { stream } = await me.subscribe(publication.id);
          let newMedia;
          switch (stream.track.kind) {
            case "video":
              newMedia = document.createElement("video");
              newMedia.playsInline = true;
              newMedia.autoplay = true;
              break;
            case "audio":
              newMedia = document.createElement("audio");
              newMedia.controls = true;
              newMedia.autoplay = true;
              break;
            default:
              return;
          }
          newMedia.id = `media-${publication.id}`;
          stream.attach(newMedia);
          remoteMediaArea.appendChild(newMedia);
        };
      };
  
      room.publications.forEach(subscribeAndAttach);
      room.onStreamPublished.add((e) => subscribeAndAttach(e.publication));
      leaveButton.onclick = async () => {
        await me.leave();
        await room.dispose();
        myId.textContent = "";
        buttonArea.replaceChildren();
        remoteMediaArea.replaceChildren();
      };
      room.onStreamUnpublished.add((e) => {
        document.getElementById(`subscribe-button-${e.publication.id}`)?.remove();
        document.getElementById(`media-${e.publication.id}`)?.remove();
      });
    };
  })();  
});
```
<!--
## UnityでビルドしたWebGLのソースコードを実行する方法
Unity Editor上で、Build And Runしたものは、Unityで実装した画面が表示されるが、
ビルドしたものを動かしたらUnityで実装した画面が表示されなかった。
以下のエラーとなる。
Failed to download file Build/Test WebRTC.data.br. Loading web pages via a file:// URL without a web server is not supported by this browser. Please use a local development web server to host Unity content, or use the Unity Build and Run option.

Build And Runしたときは、Unityでサーバーを立てくれるみたいだが、ビルドしたものを実行するときはサーバーを立てないとだめらしい。

https://qiita.com/mikito/items/9a49f4b7cc13e2e82692

上記の記事では、Project Settings > Player > Publishing Settings > CompressionFormatを変更しないとだめと書いてあるが、もし変更した場合かつ、WebGLTemplatesで自作したindex.htmlを使用してる場合は、下記の部分を変更しておく必要がある
```javascript
var config = { 　　// Gzipの場合は、/Test WebRTC.data.gz、Disabledの場合は、/Test WebRTC.data // 他のファイルも同様 dataUrl: buildUrl + "/Test WebRTC.data.br", frameworkUrl: buildUrl + "/Test WebRTC.framework.js.br", codeUrl: buildUrl + "/Test WebRTC.wasm.br", streamingAssetsUrl: "StreamingAssets", companyName: "DefaultCompany", productName: "Test WebRTC", productVersion: "1.0", showBanner: unityShowBanner, };
```

Apacheではbrとgzを使う場合は、設定が必要らしい。
-->
## 押さえておくべき用語
### ChannelとRoom
両方ともグループの単位。Zoomのミーティングのイメージで、ユーザーが参加するルームのような感じです。
ChannelはSFUのみで、RoomはP2PとSFUが選べるみたいです。

### Member
ざっくりいうと、ChannelとRoomに参加するメンバー。
正確には、Channel/Room 内で他のクライアントとの通信を管理するエージェントという定義。

### Publication
Publishの操作によって Channel/Room 内に作られるリソースです。
Publishとは映像、音声などの Stream を他の Member が受信可能にするように Channel/Room 内に公開することです。
PublicationをSubscribeすることで、音声や映像を受信できます。
どの Member がどのような形式の Stream を Publish しているかの情報は取得可能です。

### Subscription
PublicationをSubscribeすることで、Channel/Room 内に作られます。どの Member がどの Publication を Subscribe しているかの情報が含まれています。

### RecordingSession
> RecordingSessionは、どのような Publication をどのクラウドストレージに保存するのかの設定を保持するオブジェクトです。1つの Channel に対して複数作成できます。1つの RecordingSessionには、0個以上の Publication が含まれます。また、1つの RecordingSessionで設定可能なクラウドストレージは1つのみです。

## 制約事項
※2024/11/13時点での確認結果なので、使用を検討する際には最新の情報を確認してください

 - 制約事項は[こちら](https://skyway.ntt.com/ja/docs/user-guide/commons/quotas-and-limits/)に記載がある
 - 制約事項を確認する際には合わせて[用語集](https://skyway.ntt.com/ja/docs/user-guide/terminology/)も確認したほうが理解しやすい

ChannelかRoom、Roomの場合はP2PかSFUで制限事項は変わります。
こちらに記載してるのはRoomでSFUの場合です。
- Roomに参加できるMemberは320
- Roomに同時に存在できるPublicationの最大数は128
:::note warn
要確認
Publicationは映像、音声ごとに必要だと思っているので、Web会議の場合は、Memberにつき二つ消費するため、64人が最大にした方が良さそう？
:::
- Roomに同時に存在できるSubscriptionの最大数は5120
- Roomに同時に存在できるRecordingSessionの最大数は256
:::note warn
要確認
RecordingSession=録画できる単位という認識なので、最大256人が同時で使える？
:::
- RecordingSessionの有効期限は7日間、で延長はできないそうです
:::note info
後述しますが、録画の最大時間が12時間のようなので、録画が終わったら消すという運用が良さそうです
:::
- 録音・録画は最小1秒から最大12時間まで行うことができます
  - 開始から1秒以内に終了した録音・録画はファイルとして保存されません
  - 開始から12時間が経過した録音・録画は停止され、クラウドストレージに録音・録画ファイルが保存されます
  - 以下の場合は、有効期限を待たずに録音・録画が停止されます
    - Publication が unpublish された場合
    - Room, Channel が削除された場合
    - Room, Channel の有効期限を迎えた場合
    - RecordingSession の有効期限を迎えた場合
    - 録音・録画中に回復不能なエラーが発生した場合
  - 録音・録画の時間制限は、Publisher および RecordingSessionに紐づいてカウントされます
    - 時間制限は、録音・録画対象となる1つ目の Publication の録音・録画が開始されてからカウントが始まります。 録音・録画可能な時間のカウントは、以下のいずれかのタイミングでリセットされます
    - Publisherがleaveした
    - 録音・録画対象となっているPublicationが全てunpublishされた
    - RecordingSession が削除された
    - すでに録音・録画が行われている状況で、録音・録画対象となる2つ目以降の Publication が publish された場合でも、制限時間のカウントは継続されます。 したがって、 2つ目以降の Publication については、録音・録画が行われる時間の上限が12時間未満となります
- RoomとChannelの有効期限は7日間、で延長はできないそうです
:::note warn
要確認
> 期限が切れたら通話の途中で接続が切れる可能性があります

と書いてあったので、絶対切れる訳じゃない？（けど、使わないようにする方がベストですね）

> これを防ぐために、同じ Channel を長期間使い続けるのではなく、ユーザーが居なくなったタイミングで Channel を削除するといった実装をおすすめします。

:::

- Room、Channel、Memberに設定できる名前は、以下の制限がある
  - 使用可能な文字種: a-z, A-Z, 0-9, -, ., _, %, * で構成された文字のみ
  - 文字数: 1~128文字(0文字は許容されない)
- Room、Channel、Memberのmetadataに設定できる名前は、以下の制限がある
  - 使用可能な文字種: 使用可能な文字種(アルファベット、数字、記号、日本語等を含む)任意の文字
  - 文字数: 0~1024文字
:::note info
Room,Channel,Member名に日本語の名称もつけるならmeatadataを使う必要があります
:::

# その他
※2024/11/13時点での確認結果なので、使用を検討する際には最新の情報を確認してください

- 以下の動作を行い、しばらくするとRoomから退出する
  - ブラウザのタブを閉じる
  - 通信がOFFになる
  - ブラウザを閉じる

JavascriptのSDKでは、ネットワークが切断された時再接続するようになっているみたいですが、長時間切断されると退出扱いになるみたいでした。
以下の長時間ネットワークが切断された場合(onFatalError)のハンドリングを参照してください
https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/tips/

# 用途別の実装サンプル 
## ミュート
```javascript
const muteButton = document.getElementById("mute");
    muteButton.onclick = () => {
      if(audioPublication) {
        if(muteButton.textContent === "mute"){
          audioPublication.disable();
          muteButton.textContent = "on";
        } else {
          audioPublication.enable();
          muteButton.textContent = "mute";
        }
      }
    }
    const { audio, video } =
      await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
    video.attach(localVideo);
    await localVideo.play();
  
    joinButton.onclick = async () => {
      if (roomNameInput.value === "") return;
  
      
      const room = await SkyWayRoom.FindOrCreate(skywayContext, {
        type: "sfu",
        name: roomNameInput.value,
      });
      console.log(`Created Room ${room.name}`);
      if(room.name && !createdRoomList.includes(room.name)) {
        createdRoomList.push(room.name);
        console.log(createdRoomList);
      }
      const me = await room.join();
  
      myId.textContent = me.id;
  
      audioPublication = await me.publish(audio);
```

## マイクの変更
```
    devices = await SkyWayStreamFactory.enumerateInputAudioDevices();
    devices.forEach((device) => {
      console.log(device);
      const menu = document.getElementById("select-mic");
        const item = document.createElement("option");
        item.textContent = device.label;
        item.value = device.id;
        menu.appendChild(item);
    });
    const changeMicButton = document.getElementById("mic");
    changeMicButton.onclick = async () => {
      const select = document.getElementById("select-mic");
      console.log(select);
      console.log(select.value);
      const audio = await SkyWayStreamFactory.createMicrophoneAudioStream({deviceId: select.value});
      await me.unpublish(audioPublication.id);
      audioPublication = await me.publish(audio);
      //audioPublication.release();
    }
```

## その他
他にも公式にTipsが載っているので参考にしてみてください
https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/tips/