SkyWayとNuxt.jsでWebRTCのグループビデオ通話機能開発

[繋がらないマッチングプラットフォーム「FLAPTALK」](https://flaptalk.jp)を運営する[株式会社OneSmallStep](https://1smallstep.jp)の西([@_takeshi_24](https://twitter.com/_takeshi_24))です！

最近新型コロナの影響で在宅ワークも増え、それに伴い、ビデオチャットやTV会議システムも色々と登場してきています。
オンラインのビデオ通話支える仕組みがWebRTCなのですが、今回はこのWebRTCを簡単に実装できる[SkyWay](https://webrtc.ecl.ntt.com/)についてご紹介します！

## WebRTCとは？
WebRTCとはHTMLのAPIの一種で、ブラウザ間で映像や音声などの大容量のデータをリアルタイムに送受信するための技術です。
従来のWebRTCはコンピュータ同士を直接P2Pでつなぐメッシュ方式でした。
メッシュ方式の場合、複数のコンピュータ同士で映像や音声を配信すると、端末が増えるにつれて回線や端末に大きな負荷がかかります。
同時に接続できる端末数に上限があります。

## WebRTC SFU 
WebRTC SFU (Selective Forwarding Unit ) は、メッシュ方式と異なり、音声や映像をサーバ経由で配信します。
接続するコンピュータが増えても、端末自体にかかる負荷は少なく、複数端末で同時に接続することが可能です。

## SkyWay
[SkyWay](https://webrtc.ecl.ntt.com/)とはWebRTCを用いたビデオ通話や音声通話を簡単に実装できるSDK、APIサービスです。
[導入事例](https://webrtc.ecl.ntt.com/usecase.html)を見るとレアジョブ英会話など様々なサービスで利用されているようです。

![skyway.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/ef25cca1-0ff4-27b7-5a6a-4a1243c48f0b.png)

しかも驚くべきことに、月当たり**50万接続までは無料**で使えると言う料金設定！
桁間違えてるんじゃないの？笑

![SkyWay料金](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/1a513e05-5021-73ea-71e0-c6b0afb00185.png)

WebRTCを使った音声・ビデオ通話のためにクライアントサイドに必要なSDKもちゃんと準備されています。
これらを使うことで、ブラウザアプリはもちろん、iOS、Androidなどのスマホ、IoT機器からも簡単に利用することができます。
![SkyWaySDK](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/90e8c134-7304-562e-c4d7-e41209aceaf9.png)

## Nuxt.js/TypeScriptでSkyWayを利用
今回は、Nuxt.js(TypeScript)を使ったビデオ通話Webアプリの開発について説明します。

なお、Nuxt.jsについてはこの記事では紹介しませんので、Nuxt.jsの環境は事前にご用意してください。

## 開発
### SkyWayAPIキーの取得
1.SkyWayを利用するために、まずは何はともあれ、SkyWayのアカウント登録をします。
https://console-webrtc-free.ecl.ntt.com/users/registration

2.アカウント登録後、「新しくアプリケーションを追加する」からアプリケーションを追加します。
![スクリーンショット 2020-05-16 19.23.30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/607abb94-6a74-d52d-1c0e-284d4ae22c74.png)

3.アプリケーション名、利用可能なドメイン名などを入力し、「アプリケーションを作成する」をクリックします。
![スクリーンショット 2020-05-16 19.25.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/9eb8087b-2e56-77e6-25b9-5a1b42180881.png)

4.アプリが作成され、APIキーが発行されます
![スクリーンショット 2020-05-16 19.27.09.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3003/6a1bcc81-ce20-c386-0abc-ea6464587a44.png)

### SDKのインストール
1.Nuxt.jsのプロジェクトにJavaScriptのSDKをインストールします。

```terminal
yarn add skyway-js
```

### グループビデオ通話機能の開発
Interfaceの定義を作成します。

```typescript:types/interface.ts
export interface SkywayMediaStream extends MediaStream {
  peerId: string
}

```

Componentを作成します。

```vuejs:SkywayVideo.vue
<template>
  <div class="skyway-video">
    <video id="local-stream"></video>
    <div>
      <button @click="mute">{{ muteText }}</button>
      <button @click="disconnect">切断</button>
    </div>
    <div id="remote-streams" class="remote-streams">
      <div
        v-for="remoteStream in remoteStreams"
        :ref="remoteStream.peerId"
        :key="remoteStream.peerId"
      >
        <video autoplay :srcObject.prop="remoteStream"></video>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'
import Peer, { SfuRoom } from 'skyway-js'
import { SkywayMediaStream } from '@/types/interface.ts'
interface SkywayData {
  peer: Peer | null
  room: SfuRoom | null
  localStream: MediaStream | undefined
  isMute: boolean
  remoteStreams: SkywayMediaStream[]
}
export default Vue.extend({
  name: 'SkywayVideo',
  props: {
    userName: {
      type: String,
      default: null
    },
    roomName: {
      type: String,
      default: null
    }
  },
  data: (): SkywayData => ({
    peer: name,
    room: null,
    localStream: undefined,
    isMute: false,
    remoteStreams: []
  }),
  computed: {
    muteText(): string {
      return this.isMute ? 'ミュート解除' : 'ミュート'
    }
  },
  async mounted() {
    const localVideo = document.getElementById(
      'local-stream'
    ) as HTMLMediaElement

    this.localStream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: true
    })

    localVideo.muted = true
    localVideo.srcObject = this.localStream
    await localVideo.play()

    this.peer = await new Peer(this.userName, {
      key: process.env.SKYWAY_API_KEY || '',
      debug: 3
    })
    this.peer.on('open', this.connect)
  },
  methods: {
    // 接続処理
    connect() {
      if (!this.peer || !this.peer.open) {
        return
      }

      this.room = this.peer.joinRoom(this.roomName, {
        mode: 'sfu',
        stream: this.localStream
      }) as SfuRoom

      if (this.room) {
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          this.remoteStreams.push(stream)
        })

        this.room.on('peerLeave', (peerId: string): void => {
          const audio = document.getElementById(peerId)
          if (audio) {
            audio.remove()
          }
        })
      }
    },
    // ミュート切り替え
    mute(): void {
      if (this.localStream) {
        const audioTrack = this.localStream.getAudioTracks()[0]
        this.isMute = !this.isMute
        audioTrack.enabled = !this.isMute
      }
    },
    // 切断
    disconnect(): void {
      if (this.room) {
        this.room.close()
      }
    }
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
</style>
```

コンポーネントを読み込むページは以下のような感じです。


```vuejs:index.vue
<template>
  <skyway-video :room-name="roomName" :user-name="userName"></skyway-video>
</template>
<script lang="ts">
import Vue from 'vue'
import SkywayVideo from '@/components/molecules/SkywayVideo/SkywayVideo.vue'
export default Vue.extend({
  components: {
    SkywayVideo
  },
  data: () => ({
    roomName: 'sample'
  }),
  computed: {
    userName(): string {
      return this.makeRandum(10)
    }
  },
  methods: {
    makeRandum(retLength: number): string {
      // 生成する文字列に含める文字セット
      const chars: string = 'abcdefghijklmnopqrstuvwxyz0123456789'
      const charsLength: number = chars.length
      const ret: string[] = [...Array(retLength)].map((): string => {
        return chars[Math.floor(Math.random() * charsLength)]
      })
      return ret.join('')
    }
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
</style>
```

ポイントを解説。

- SkywayVideo.vueの`local-stream`内に自分の映像が埋め込まれます。
- SkywayVideo.vueの以下の箇所で、ユーザーの接続をトリガーに、remoteStreamsにMediaStreamが追加されます。

```typescript
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          this.remoteStreams.push(stream)
        })
```

- userNameとroomNameをPropsで親コンポーネントから受け取っています。
    - userNameが接続のIDになります。
    - roomNameはグループ通話の部屋名になり、roomNameが同じユーザーとグループ通話が可能です。
- SkywayVideo.vueの`mounted()`のなかで、ローカルのビデオを表示し、SkyWayに接続します。
    - 音がハウリングしないように、`localVideo.muted = true`で、自分自身のVideoは常にミュートにしています。
    - SKYWAY_API_KEYは、SkyWayで取得したAPIキーを環境変数に設定したものです。
- SkywayVideo.vueの`mute()`は、自分の音声をミュートにしています。
- SkywayVideo.vueの`disconnect()`は切断処理です。

[SkyWayの公式ドキュメント](https://webrtc.ecl.ntt.com/skyway/function.html)に詳細はありますので、そちらを参考にしてください。

### SkyWayで話し中のユーザーにマークをつけたい
ビデオ会議システムでよくあるのが話しているユーザーのビデオにマークをつける処理です。
こちらはvoice-activity-detectionというライブラリで実現可能です。
https://github.com/Jam3/voice-activity-detection

まずは、voice-activity-detectionをインストール

```terminal
yarn add voice-activity-detection
```

pluginsにvad.tsを以下の内容で作成します。

```typescript:plugins/vad.ts
import Vue from 'vue'
import { SkywayMediaStream } from '@/types/interface.ts'
const vad = require('voice-activity-detection')

Vue.mixin({
  methods: {
    startVoiceDetection(
      this: any,
      stream: SkywayMediaStream,
      talkUpdate: (peerId: string | null) => void
    ) {
      const audioContext = new AudioContext()
      const vadOptions = {
        onVoiceStart() {
          talkUpdate(stream.peerId)
        },
        onVoiceStop() {
          talkUpdate(null)
        }
      }
      // streamオブジェクトの音声検出を開始
      this.vadobject = vad(audioContext, stream, vadOptions)
    },
    stopVoiceDetection(this: any) {
      if (this.vadobject) {
        // 音声検出を終了する
        this.vadobject.destroy()
      }
    }
  }
})

```

nuxt.config.tsで、pluginを読み込みます。

```typescript:nuxt.config.ts
plugins: [{ src: '~/plugins/vad.ts', ssr: false }],
```

SkywayVideo.vueに処理を追加します。追加箇所のコメントを参照。

```vuejs:SkywayVideo.vue
<template>
  <div class="skyway-video">
    <video id="local-stream"></video>
    <div>
      <button @click="mute">{{ muteText }}</button>
      <button @click="disconnect">切断</button>
    </div>
    <div id="remote-streams" class="remote-streams">
      <!-- ↓追加箇所：classを追加 -->
      <div
        v-for="remoteStream in remoteStreams"
        :ref="remoteStream.peerId"
        :key="remoteStream.peerId"
        :class="talkingId === remoteStream.peerId ? 'talking' : ''"
      >
        <video autoplay :srcObject.prop="remoteStream"></video>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'
import Peer, { SfuRoom } from 'skyway-js'
import { SkywayMediaStream } from '@/types/interface.ts'
interface SkywayData {
  peer: Peer | null
  room: SfuRoom | null
  localStream: MediaStream | undefined
  isMute: boolean
  remoteStreams: SkywayMediaStream[]
  // ↓追加箇所
  talkingId: string | null
  // ↑追加箇所
}
export default Vue.extend({
  name: 'SkywayVideo',
  props: {
    userName: {
      type: String,
      default: null
    },
    roomName: {
      type: String,
      default: null
    }
  },
  data: (): SkywayData => ({
    peer: name,
    room: null,
    localStream: undefined,
    isMute: false,
    remoteStreams: [],
    // ↓追加箇所
    talkingId: null
    // ↑追加箇所
  }),
  computed: {
    muteText(): string {
      return this.isMute ? 'ミュート解除' : 'ミュート'
    }
  },
  async mounted() {
    const localVideo = document.getElementById(
      'local-stream'
    ) as HTMLMediaElement

    this.localStream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: true
    })

    localVideo.muted = true
    localVideo.srcObject = this.localStream
    await localVideo.play()

    this.peer = await new Peer(this.userName, {
      key: process.env.SKYWAY_API_KEY || '',
      debug: 3
    })
    this.peer.on('open', this.connect)
  },
  methods: {
    // 接続処理
    connect() {
      if (!this.peer || !this.peer.open) {
        return
      }

      this.room = this.peer.joinRoom(this.roomName, {
        mode: 'sfu',
        stream: this.localStream
      }) as SfuRoom

      if (this.room) {
        // ↓追加箇所
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          ;(this as any).startVoiceDetection(stream, this.talkUpdate)
          this.remoteStreams.push(stream)
        })
        // ↑追加箇所

        this.room.on('peerLeave', (peerId: string): void => {
          const audio = document.getElementById(peerId)
          if (audio) {
            audio.remove()
          }
        })
      }
    },
    // ミュート切り替え
    mute(): void {
      if (this.localStream) {
        const audioTrack = this.localStream.getAudioTracks()[0]
        this.isMute = !this.isMute
        audioTrack.enabled = !this.isMute
      }
    },
    // 切断
    disconnect(): void {
      if (this.room) {
        this.room.close()
      }
    },
    // ↓追加箇所
    talkUpdate(peerId: string) {
      this.talkingId = peerId
    }
    // ↑追加箇所
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
// ↓追加箇所
.talking {
  border: 3px solid #0000ff;
}
// ↑追加箇所
</style>

```

plugins/vad.tsの`startVoiceDetection`で、MediaStreamの音声を検出しています。
SkywayVideo.vueで、話し中のユーザーのpeerIdをtalkingIdにセットしています。
remoteStreamsのpeerIdとtalkingIdが一致した`video`タグに`talking`のclassを指定して、スタイルを適用させています。

##まとめ

話し中のユーザーにマークをつける処理は苦戦しましたが、それ以外はSkyWayのおかげで簡単にWebRTCのテレビ電話処理を作成する事ができました！
今回はテレビ電話でしたが、音声通話だけなら、videoタグじゃなくて、audioタグで可能です。

いつもNuxt.jsのこととか、Firebaseのことを中心につぶやいていますので、Twitter([@_takeshi_24](https://twitter.com/_takeshi_24))のフォローよろしくお願いします！