LoginSignup
124
122

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-05-18

繋がらないマッチングプラットフォーム「FLAPTALK」を運営する株式会社OneSmallStepの西(@_takeshi_24)です!

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

WebRTCとは?

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

WebRTC SFU

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

SkyWay

SkyWayとはWebRTCを用いたビデオ通話や音声通話を簡単に実装できるSDK、APIサービスです。
導入事例を見るとレアジョブ英会話など様々なサービスで利用されているようです。

skyway.png

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

SkyWay料金

WebRTCを使った音声・ビデオ通話のためにクライアントサイドに必要なSDKもちゃんと準備されています。
これらを使うことで、ブラウザアプリはもちろん、iOS、Androidなどのスマホ、IoT機器からも簡単に利用することができます。
SkyWaySDK

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

3.アプリケーション名、利用可能なドメイン名などを入力し、「アプリケーションを作成する」をクリックします。
スクリーンショット 2020-05-16 19.25.31.png

4.アプリが作成され、APIキーが発行されます
スクリーンショット 2020-05-16 19.27.09.png

SDKのインストール

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

yarn add skyway-js

グループビデオ通話機能の開発

Interfaceの定義を作成します。

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

Componentを作成します。

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>

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

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が追加されます。
        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の公式ドキュメントに詳細はありますので、そちらを参考にしてください。

SkyWayで話し中のユーザーにマークをつけたい

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

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

yarn add voice-activity-detection

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

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を読み込みます。

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

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

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)のフォローよろしくお願いします!

124
122
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
124
122