繋がらないマッチングプラットフォーム「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サービスです。
導入事例を見るとレアジョブ英会話など様々なサービスで利用されているようです。
しかも驚くべきことに、月当たり50万接続までは無料で使えると言う料金設定!
桁間違えてるんじゃないの?笑
WebRTCを使った音声・ビデオ通話のためにクライアントサイドに必要なSDKもちゃんと準備されています。
これらを使うことで、ブラウザアプリはもちろん、iOS、Androidなどのスマホ、IoT機器からも簡単に利用することができます。
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.アカウント登録後、「新しくアプリケーションを追加する」からアプリケーションを追加します。
3.アプリケーション名、利用可能なドメイン名などを入力し、「アプリケーションを作成する」をクリックします。
SDKのインストール
1.Nuxt.jsのプロジェクトにJavaScriptのSDKをインストールします。
yarn add skyway-js
グループビデオ通話機能の開発
Interfaceの定義を作成します。
export interface SkywayMediaStream extends MediaStream {
peerId: string
}
Componentを作成します。
<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>
コンポーネントを読み込むページは以下のような感じです。
<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を以下の内容で作成します。
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を読み込みます。
plugins: [{ src: '~/plugins/vad.ts', ssr: false }],
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)のフォローよろしくお願いします!