新しいSkyWayを使って文化祭やイベントで使えそうなWebトランシーバーを作ってみた

# はじめに
2023年1月31日から提供されている新しいバージョンのSkyWayを使って、Webアプリ上で動作するトランシーバー(以下Webトランシーバーと記載)を作ってみました。

この記事は「[新しくなったSkyWayを使ってみよう！](https://qiita.com/official-events/93564ad363199fa7999c)」のキャンペーンに誘われて作成したものです。

## 注意
::: note warn
この記事は私が個人として作成したものであり、私が所属する学校や委員会などの団体は一切関係ありません。
また、この記事のソースコードを利用される際は自己責任でお願いします。被った損害などについて筆者は一切責任を負いません。
:::

## とりあえず成果物
<img width="270" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/fe377e06-032d-eec3-deb8-30e41dff5234.png">

<img width="270" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/61feebf2-0723-911a-b054-1be83191781a.png">



GitHub Pagesに置いてみました。
下記リンクからアクセスできます。

[Webトラ！](https://kazu-iroiro.github.io/WebTransceiver/)

実際に動かすには[SkyWayへの登録](https://skyway.ntt.com/)(無料/メールアドレスだけでOK)が必要です。

登録後新しいアプリケーションを作成し、コンソールを開いてアプリケーションIDとシークレットキーを取得します。それらをWebトランシーバーのページ下部にあるボックスに入力し、保存ボタンをクリックした後、接続ボタンをクリックしてください。端末を2台用意すると、相互に音声通話をすることができます。

【2023/7/20追記】
**発話時はミュート解除ボタンを長押ししてください。**

![skyway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2921137/3cf891c5-bce0-ee88-ddb6-28fdcc1e9c27.png)

## なぜWebトランシーバーを開発しようと考えたか
私が通っている高校では、毎年比較的大規模な文化祭を開催しています。
少なくともここ数年間、文化祭当日の実行委員同士の連絡は特定小電力トランシーバーを用いていたそうです。しかし、免許が不要という利点の一方で、出力が弱く、校内全域をカバーできるものではありません。また、ノイズが酷く、実用的ではなかったようです。

代用策として、LINEを用いたIP電話「グループ通話」を用いることを挙げられると思いますが、
+ モバイルデータ経由では通信量が馬鹿にならない
+ 学校が設置した校内Wi-FiにはLINE電話の使用するポートに制限がかけられており、通信できない

という問題があります。
ここで、SkyWayを用いてWebトランシーバーを作れないかと考えました。

SkyWayが今回の用途に向いている点は次の４つです。
+ **JavaScript用のSDKが用意されている**
+ **音声のみ**の伝達ができる
+ **Freeプラン**が用意されており、TURNサーバーも月500GBまで使える
+ **P2P通信**ではTURNサーバー通信量を利用しない
+ トークンによってユーザーの**権限を細かく設定**できる

### JavaScript用のSDKの重要性
JavaScriptで使えるということは、Webアプリケーションに搭載できるということ。つまりOSによらない開発ができます。
特に私の高校ではiPhoneのシェアが非常に高いため(およそiPhone:Android=7:1)、折角アプリを開発しても沢山の人に配布するためにはAppleに開発者登録しなければならず、高額な費用がかかるため現実的ではありません(全員Androidだったら楽なのに！)。
しかしWebアプリにしてしまえば、無料のレンタルWebサーバーに配置することもできます。

### 音声のみの伝達
今回利用したいのはビデオ通話ではなく、あくまで音声通話です。
余計な通信量の削減ができるので、これは必要な機能です。

### Freeプラン
学生にとって非常にありがたいです。「数か月間のみ無料！」というようなサービスもあるので、本当に感謝…

Freeプランに設けられている制限は、
+ 接続回数：50万回/月
+ TURNサーバーの利用：500GB/月
+ SFUサーバーの利用：500GB/月

    で、この枠内であれば無料で使うことができます。(絶対使い切れない…)

### P2P通信とTURNサーバーについて
SkyWayの公式ドキュメントにある「[TURNとは](https://skyway.ntt.com/ja/docs/user-guide/turn/)」が分かりやすいです。

これによると、SkyWayでは、P2Pで通信するか、TURNサーバーを経由する必要があるかを自動で判断するように設計されているそうです。
+ P2Pの場合
 端末同士で直接通信を行います。TURNサーバーの利用量にはカウントされません。ただし、利用する端末が同一ネットワーク上に存在する必要があります。
+ TURNサーバー経由の場合
　端末がデータを一度TURNサーバーに送信し、そのデータを他の端末が受信します。SkyWayのTURNサーバーは、UDPでの通信がルーターなどで規制されている場合に、TCP443番ポートを使うようになっているようです(校内Wi-Fiのポート制限の関係上非常にありがたい)。


重要なのは、利用量が500GB/月に制限されているのはTURNサーバーの利用量ということ。つまりP2P通信でのみ利用している場合の制限は接続回数のみなんです！

また、TURNサーバーの利用は基本的に自動で判断するようになっていますが、必ず利用/必ず利用しないという設定もできるので非常に便利です。

### トークンについて
JWT(JSON Web Token)を用いて接続するようになっています。各ユーザーの権限はトークン生成時に行います。JSON形式なので記述しやすく、SkyWay公式のSDKで簡単に生成することができます。
トークンの設定項目については[SkyWayの公式ドキュメント](https://skyway.ntt.com/ja/docs/user-guide/authentication/)を参照してください。

## 想定環境について
+ ユーザーについて全員把握済みで、不便だが別途連絡手段(LINE)を使うことができる
+ ユーザーの端末はiPhoneが大半を占め、残りはAndroidスマートフォンまたはWindows PCである
+ ユーザーにIT知識はあまり無く、端末の設定変更などは不可能
+ 想定利用環境内はWi-Fiが整備されている
+ 利用できるのはレンタルWebサーバーの無料プランであり、PHPなどのバックエンドで動くプログラムなどは動かせない
+ コミュニケーションを行う場所(Room)は一つ

## 注意事項
「想定環境について」で書いたように、今回のWebアプリケーションのユーザーは**全員把握済み**であり、ユーザーは**知り得た情報を悪用しない前提**で開発しています。利用しているサーバーでバックエンドのプログラムが動かないということもあり、**事前に生成したトークン**をユーザーに対して**別の安全な連絡手段で通知**してあるものとします。

利便性のため、コードにはアプリケーションIDとシークレットキーで参加する方法を残してありますが、もし実際に利用される際はユーザーに**トークンを配布**してください。

## 基本設計
0. ユーザーに対してトークンを知らせる
0. ユーザーがWebサイトにアクセス
0. トークンを入力し、Webストレージに保存
0. トークンを使用して既定のRoomに接続
0. 既に参加しているユーザーの声を再生できるように設定
0. 発話したい場合はミュート解除ボタンを長押しする
0. 退出時は退出ボタンを押す(リロード)

## コードについて
### 基本のコード
トークンの生成から接続までの基本的なコードは[公式ドキュメント](https://skyway.ntt.com/ja/docs/user-guide/javascript-sdk/quickstart/)に記載されています。また、Qiitaで他の方が投稿されているものが既に分かりやすいので、ここでは割愛します。

https://qiita.com/takoraisutaro/items/63c5134c560ce4433684

### コード本体

#### フォルダー構成
```
folder
├ index.html
├ main.js
└ style.css
```

``` html:index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="stylesheet" href="style.css">
    <title>SkyWay</title>
</head>
<body>
    <header><h1 class="header-text">Webトラ!</h1></header>
    <p id="id-disp">ID: <span id="my-id"></span></p>
    <p id="Menber-disp">参加人数: <span id="Members">不明</span></p>
    <div>
        <!--room name: <input id="room-name" type="text" />-->
        <button id="join" class="join-btn"><p class="btn-str">接続する</p></button>
    </div>

    <button id="NonMute-btn" style="touch-action: none; user-select: none;" oncontextmenu="return false;"><p class="btn-str">ミュート解除</p></button>
    <p id="MuteInfo">接続されていません</p>
    <p><button id="leave" class="leave-btn">退出</button></p>
    <div id="button-area"></div>
    <div id="remote-media-area"></div>

    <div class="Id-Key">
        <p>Application ID:<input id="App-id" type="text" /></p>
        <p>Secret Key:<input id="Secret-key" type="text" /></p>
        <p>Token:<input id="Token" type="text" /></p>
        <p><button id="save" class="save-btn" onclick="IdKeySave()">保存</button></p>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/@skyway-sdk/room/dist/skyway_room-latest.js"></script>
    <script src="main.js"></script>
</body>
</html>
```

``` JavaScript:main.js
const { nowInSec, SkyWayAuthToken, SkyWayContext, SkyWayRoom, SkyWayStreamFactory, uuidV4 } = skyway_room;

window.onload = async function () {
    var Token = localStorage.getItem('Token');

    console.log(Token);
    if (Token != "") {
        console.log("Tokenをロードしました");
        await SkyWay_main(String(Token));
    } else {
        alert("認証情報を入力してください");
    }
}

async function IdKeySave() {
    const AppId = document.getElementById('App-id').value;
    const SecretKey = document.getElementById('Secret-key').value;
    var Token = document.getElementById('Token').value;

    if (AppId != "" && SecretKey != "") {
        Token = await SkyWay_MakeToken(AppId, SecretKey);
        await localStorage.setItem('Token', Token);
        console.log("保存済み");
        location.reload();
    } else {
        if (Token != "") {
            await localStorage.setItem('Token', Token);
            location.reload();
        } else {
            alert("認証情報を入力してください");
        }
    }
}

function SkyWay_MakeToken(AppId, SecretKey) {
    const token = new SkyWayAuthToken({
        jti: uuidV4(),
        iat: nowInSec(),
        exp: nowInSec() + 60 * 60 * 24 * 3,
        scope: {
            app: {
                id: AppId,
                turn: true,
                actions: ['read'],
                channels: [
                    {
                        id: '*',
                        name: '*',
                        actions: ['write'],
                        members: [
                            {
                                id: '*',
                                name: '*',
                                actions: ['write'],
                                publication: {
                                    actions: ['write'],
                                },
                                subscription: {
                                    actions: ['write'],
                                },
                            },
                        ],
                        sfuBots: [
                            {
                                actions: ['write'],
                                forwardings: [
                                    {
                                        actions: ['write'],
                                    },
                                ],
                            },
                        ],
                    },
                ],
            },
        },
    }).encode(SecretKey);
    return token;
}

function SkyWay_main(token) {
    const { nowInSec, SkyWayAuthToken, SkyWayContext, SkyWayRoom, SkyWayStreamFactory, uuidV4 } = skyway_room;

    (async () => {
        const buttonArea = document.getElementById('button-area');
        const remoteMediaArea = document.getElementById('remote-media-area');
        //const roomNameInput = document.getElementById('room-name');
        const roomNameInput = "transceiver";

        var Members = 1;


        const myId = document.getElementById('my-id');
        const Memberselem = document.getElementById('Members');
        const IdDisp = document.getElementById('id-disp');
        const joinButton = document.getElementById('join');

        const target = document.getElementById('MuteInfo');
        const NonMutebtn = document.getElementById('NonMute-btn');

        const leavebtn = document.getElementById('leave');

        joinButton.onclick = async () => {

            // ここに配置すると、ページをロードしてから参加ボタンを押すまでのマイクON表示を回避可能
            const audio =
                await SkyWayStreamFactory.createMicrophoneAudioStream();

            if (roomNameInput === '') return;

            const context = await SkyWayContext.Create(token);
            const room = await SkyWayRoom.FindOrCreate(context, {
                type: 'p2p',
                name: roomNameInput,
            });
            const me = await room.join();

            const publication = await me.publish(audio);

            await publication.disable();
            target.textContent = "ミュート中";

            myId.textContent = me.id;
            Memberselem.textContent = Members + "人";
            IdDisp.style.visibility = "visible";

            NonMutebtn.style.visibility = "visible";
            NonMutebtn.style.opacity = 1;
            joinButton.style.visibility = "hidden";
            leavebtn.style.visibility = "visible";


            leavebtn.onclick = () => {
                me.leave();
                location.reload();
            };

            //window.addEventListener('beforeunload', (e) => {me.leave();const message = '退出します';e.preventDefault();e.returnValue = message;return message;})

            NonMutebtn.addEventListener('pointerdown', async () => {
                const intervalId = await setInterval(increment, 20)

                document.addEventListener('pointerup', async () => {
                    await clearInterval(intervalId);
                    await publication.disable();
                    const target = document.getElementById('MuteInfo');
                    target.textContent = "ミュート中";
                    NonMutebtn.style.backgroundColor = "rgb(147, 235, 235)";
                    await publication.disable();
                }, { once: true });
            });

            const increment = async () => {
                const target = document.getElementById('MuteInfo');
                target.textContent = "ミュート解除中";
                NonMutebtn.style.backgroundColor = "red";
                await publication.enable();
            };

            const subscribeAndAttach = (publication) => {
                if (publication.publisher.id === me.id) return;

                const subscribeButton = document.createElement('button');
                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 'audio':
                            newMedia = document.createElement('audio');
                            newMedia.controls = true;
                            newMedia.autoplay = true;
                            break;
                        default:
                            return;
                    }
                    stream.attach(newMedia);
                    remoteMediaArea.appendChild(newMedia);
                };

                subscribeButton.click();
                Members++;
                Memberselem.textContent = Members + "人";
            };

            me.onPublicationUnsubscribed.add(() => {
                Members--;
                Memberselem.textContent = Members + "人";
            });

            room.publications.forEach(subscribeAndAttach);
            room.onStreamPublished.add((e) => subscribeAndAttach(e.publication));
        };
    })();
}

// マイクの権限が与えられているか確認
navigator.permissions.query({ name: 'microphone' }).then((result) => {
    if (result.state === 'granted') {
        console.log("マイクを利用します");
    } else {
        console.log("マイクの権限取得エラーです");
        alert("マイクを使用する権限を与えて下さい");
    }
});
```

``` css:style.css
body {
    text-align: center;
    margin: 0px;
    padding: 0px;
}

header {
    margin: 0px;
    padding: 1vh;
    background-color: rgb(158, 238, 124);
}

.header-text {
    margin: 0px;
    padding: 0px;
}

#id-disp{
    visibility:hidden;
}

.btn-str {
    text-align: center;
    font-size: 5vw;
    font-weight: bold;
}

#NonMute-btn {
    z-index: 9;
    visibility: hidden;
    text-align: left;
    position: absolute;
    user-select: none;
    border-radius: 50%;
    width: 50vw;
    height: 50vw;
    border: none;
    outline: none;
    background-color: rgb(147, 235, 235);
    top: 35vh;
    right: 25vw;
}

.join-btn {
    z-index: 10;
    text-align: left;
    position: absolute;
    user-select: none;
    border-radius: 50%;
    width: 50vw;
    height: 50vw;
    border: none;
    outline: none;
    background: rgb(219, 161, 66);
    top :35vh;
    right: 25vw;

}

#leave {
    visibility: hidden;
    position: absolute;
    user-select: none;
    border-radius: 50%;
    width: 25vw;
    height: 25vw;
    border: none;
    outline: none;
    background: rgb(252, 63, 63);
    top :55vh;
    right: 10vw;
}

#button-area {
    display: none;
}

#remote-media-area {
    display: none;
}

.Id-Key{
    position: relative;
    margin-top: 50vh;
}
```

## 基本のコードから変更した点
### 入室するRoomの固定
基本のコードではinputタグの内容から取得していたRoom名を、コード内に直接記述し、固定しました。transceiverという名前にしています。

``` html:index.html
<!--この行を消去-->
room name: <input id="room-name" type="text" />
```

``` JavaScript:main.js
// この行を消去
const roomNameInput = document.getElementById('room-name');

// この行を追加
const roomNameInput = "transceiver";
```

### 動画を送信・再生するコードの削除
基本のコードの各所に存在した動画に関するコードの削除をしました。
```html:index.html
<!--このコードを削除-->
<video id="local-video" width="400px" muted playsinline></video>
```

```JavaScript:main.js
// これらのコードを削除
const localVideo = document.getElementById('local-video');

// このコードはvideoの部分を削って、別のところで実行しています
const { audio, video } =
    await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();

video.attach(localVideo);
await localVideo.play();

await me.publish(video);

// switch (stream.track.kind)の部分のコード
case 'audio':
    newMedia = document.createElement('audio');
    newMedia.controls = true;
    newMedia.autoplay = true;
    break;
```


### 音声の自動再生と参加人数の表示
基本のコードではbutton要素をクリックすることで音声の再生が開始されています。今回はこのbutton要素をJavaScriptでクリックしたことにすることで、参加者全員の音声が再生されるようにしました。

Subscribe(音声が送信されている)されているデータを自動再生する際にカウンタを+1していくことで参加人数を増加、誰かが退出する際に-1していくことで参加人数を減少させています。

```JavaScript:main.js
// このあたりは基本のコードのまま
const subscribeAndAttach = (publication) => {
                if (publication.publisher.id === me.id) return;

                const subscribeButton = document.createElement('button');
                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) {
                        // video関連の処理を消去
                        case 'audio':
                            newMedia = document.createElement('audio');
                            newMedia.controls = true;
                            newMedia.autoplay = true;
                            break;
                        default:
                            return;
                    }
                    stream.attach(newMedia);
                    remoteMediaArea.appendChild(newMedia);
                };

                // JavaScriptからボタンをクリック
                subscribeButton.click();
                Members++;
                Memberselem.textContent = Members + "人";
            };



            // 参加者が減った場合に行われる処理
            me.onPublicationUnsubscribed.add(() => {
                Members--;
                Memberselem.textContent = Members + "人";
            });
```

### ミュート機能の実装
https://qiita.com/rahhi555/items/c1028b5a13ed3bf970b3

ボタンの長押し検知はこちらの記事を参考に実装しました。

``` JavaScript:mute.js
// 音声の送信を有効にしたい場合
await publication.enable();

// 音声の送信を無効にしたい場合
await publication.disable();
```
ボタンが長押しされている場合はenable()を、
ボタンが離された時にdisable()を実行します。

``` JavaScript:main.js
const NonMutebtn = document.getElementById('NonMute-btn');
NonMutebtn.addEventListener('pointerdown', async () => {
                const intervalId = await setInterval(increment, 20)

                document.addEventListener('pointerup', async () => {
                    await clearInterval(intervalId);
                    await publication.disable();
                    const target = document.getElementById('MuteInfo');
                    target.textContent = "ミュート中";
                    NonMutebtn.style.backgroundColor = "rgb(147, 235, 235)";
                    await publication.disable();
                }, { once: true });
            });

            const increment = async () => {
                const target = document.getElementById('MuteInfo');
                target.textContent = "ミュート解除中";
                NonMutebtn.style.backgroundColor = "red";
                await publication.enable();
            };
```

### 退出ボタンの実装
退出ボタンが押された場合、me.leave()を実行した後、ページをリロードさせています。

``` html:index.html
<p><button id="leave" class="leave-btn">退出</button></p>
```

``` JavaScript:main.js
const leavebtn = document.getElementById('leave');

leavebtn.onclick = () => {
    me.leave();
    location.reload();
};
```

### ページが読み込まれてから参加ボタンを押すまでにマイクが使用状態として表示される状態の改善とビデオを使用しない場合の権限取得について
公式ドキュメントのクイックスタートガイドでは参加ボタンを押した際に実行されるjoin関数の前にマイクのリソースの獲得が行われています。しかしこの状態ではページがロードされた時点でマイクが使用状態になり、気持ち悪いため、join関数の中で初めてマイクを使用するように変更しました。
また、基本のコードを流用すると、カメラを使用しないのにも関わらず撮影の権限を取得してしまうため、音声のみ取得するようにしています。

``` JavaScript:main.js
// 参加ボタンが押された時に実行される
joinButton.onclick = async () => {
        // マイクのみ
        const audio =　await SkyWayStreamFactory.createMicrophoneAudioStream();
        };
```

#### 参考
``` JavaScript:sample.js
// マイクとカメラを使用する場合
const { audio, video } = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
//　マイクのみ使用する場合
const audio = await SkyWayStreamFactory.createMicrophoneAudioStream();
```

### マイクの権限が付与されているか確認するコードの追加
マイクの権限が付与されていないとSkyWayが動かないので、alertを表示するようにしました。
```JavaScript:main.js
navigator.permissions.query({ name: 'microphone' }).then((result) => {
    if (result.state === 'granted') {
        console.log("マイクを利用します");
    } else {
        console.log("マイクの権限取得エラーです");
        alert("マイクを使用する権限を与えて下さい");
    }
});
```

### トークンの生成
クイックスタートガイドでは、アプリケーションIDとシークレットキーをmain.jsの中に直接記述していましたが、index.html上に入力フォームを設け、保存ボタンを押した際にトークン生成スクリプトを実行するように変更しました。生成されたトークンはWebストレージへ自動保存されます。

### 生成済みのトークンでの接続
ページ内で自動保存されたトークンや、新規で入力されたトークンを用いて、直接参加できるようにしました。

### Webストレージへの保存と読み取り
Webストレージにはトークンのみ保存するようにしています。アプリケーションIDとシークレットキーは念のため保存しない方針にしました。
既にストレージにトークンが書き込まれている状態で新規トークン生成を行ったり、新しいトークンを入力した場合は、そのトークンで上書き保存されます。
SkyWay_MakeToken()は、基本のコードのトークン生成部を再利用しやすいようにアプリケーションIDとシークレットキーを引数とした関数にしたものです。

``` JavaScript:main.js
//書き込み
Token = await SkyWay_MakeToken(AppId, SecretKey);
await localStorage.setItem('Token', Token);
//読み取り
var Token = localStorage.getItem('Token');
```

## トークンの配布方法について
現在考えているトークンの配布方法の案を紹介します

0. トークンを指定件数生成/出力する関数をJavaScriptで作り、HTML上で動作するようにする
0. 出力されたトークンをWebトランシーバーを配置しているリンクと共にQRコードにまとめる(例:https://sample.sample/index.html#token)←getにするとサーバーログにトークンが残ってしまうため#の形でURLに組み合わせる。
0. 出力されたQRコード達を印刷し、1つのQRコード/枚になるように裁断機でカット
0. QRコードを関係各所に配布
0. Webトランシーバーのページが読み込まれた際に#以下の文字列を読み取る
0. 読み込んだ文字列をトークンとして処理

# さいごに
SkyWayは非常に使い勝手が良いサービスでした。
学校で数人の友人と接続テストを行った際、「実際に使ってみたい」という評価をもらったので嬉しかったです。
文化祭で実際に使えるように、トークンの配布を行うためのJavaScriptを組んだり、操作方法などのブラッシュアップを行っていきたいと思います。

参考記事として記事中で紹介させていただいた@takoraisutaroさん、
ミュートボタンの長押し検知でコードを参考にさせていただいた、@rahhi555さん、
SkyWayを提供していただいているNTTコミュニケーションズ株式会社さんに感謝を申し上げます。

最後まで読んでいただきありがとうございました。