SkyWayとvoice-activity-detectionを使って音声検出を実装する

# はじめに

先日、チームの開発合宿でSkyWayを使ったトランシーバーサービスを試作しました。そのサービス内で、voice-activity-detectionを使って音声検出を実装したので、その方法を紹介します。

# 音声検出の必要性

WebRTCが登場し誰でも簡単にビデオ・ボイスチャットサービスを開発できるようになりました。しかし、UXを考慮すると、ただビデオ通話ができれば良いわけではなく、快適に利用するにはいろいろな付加機能が必要だということがわかってきます。

その一つとして、音声検出があります。活用するシーンはいくつかあるとおもいますが、今回フォーカスするのは、チャット相手が声を発しているのかどうかを視覚的に伝える機能です。

Web会議をやっていると、よく相手の音声が聞こえないというシチュエーションに出くわします。その時、利用者は原因としていくつかの可能性を考えます。マイクがミュートor入力ボリュームが小さいのか、音声は入力されているが何らかの理由で相手に届いていないのか、再生側のスピーカーがミュートなのか…などなど。日頃Web会議をやりなれている人であれば直感で対処はできるかもしれませんが、そうじゃない人は切り分けて復旧するまでがそれなりに大変です。

そんな時、チャット相手が発話していることがわかるインジケーターがあれば、原因をある程度は絞り込むことができます。以下、その実装例です。[Jitsi Meet](https://meet.jit.si/)で実装されている機能です。

![スクリーンショット 2018-06-04 19.45.05.png](https://qiita-image-store.s3.amazonaws.com/0/6651/db29e010-a59e-1f14-fe4e-672e2f6439a0.png)

画面左側に青い点が出てきます。音量によって点の数が増減します。

では、実装してみましょう。

# voice-activity-detectionを利用してお手軽VAD

今回の開発では、[voice-activity-detection](https://www.npmjs.com/package/voice-activity-detection)というライブラリを利用して音声検出を試しました。公式サンプルほぼそのままですが、その実装方法を紹介します。

音声の検出の開始と停止は以下のように実装します。

```js
this.vadobject = null;

startVoiceDetection(stream,update) {
    window.AudioContext = window.AudioContext;
    let audioContext = new AudioContext();
    let vadOptions = {
        onVoiceStart: function() {
            console.log('voice start');
        },
        onVoiceStop: function() {
            console.log('voice stop');
        },
        onUpdate: function(val) {
            // 音声が検出されると発火
            update(val);
        }
    };
    // streamオブジェクトの音声検出を開始
    this.vadobject = vad(audioContext,stream,vadOptions);
}

stopVoiceDetection(){
    if(this.vadobject){
        // 音声検出を終了する
        this.vadobject.destroy();
    }
}
```

`stream`は検出する音声が入ったオブジェクトを指定します。
音声検出が検出されると`onUpdate`が発火します。
検出を明示的に止めたい場合は`destroy()`を実行します。

上記以外の検出オプションについては[公式README](https://github.com/Jam3/voice-activity-detection/blob/master/README.md)を参考にして下さい。


# SkyWayと組み合わせる

わかりやすく公式サンプルアプリで使う場合を考えてみました。（こちらについては動作検証していませんが、おそらく動くはず）

## ローカルの音声を検出したい場合（検出停止処理は省略）

```javascript
navigator.mediaDevices.getUserMedia(constraints).then(stream => {
    $('#my-video').get(0).srcObject = stream;
    localStream = stream;
    // 音声検出
    startVoiceDetection(stream,(val) => {
      //検出時のエフェクト等
    }

    if (existingCall) {
      existingCall.replaceStream(stream);
      return;
    }

    step2();
  }).catch(err => {
    $('#step1-error').show();
    console.error(err);
  });
}
```
引用元: [skyway-js-sdk/examples/p2p-videochat/script.js#L100](https://github.com/skyway/skyway-js-sdk/blob/master/examples/p2p-videochat/script.js#L100)

## リモートの音声を検出したい場合

```javascript
call.on('stream', stream => {
    const el = $('#their-video').get(0);
    el.srcObject = stream;
    el.play();
    // 音声検出
    startVoiceDetection(stream,(val) => {
      //検出時のエフェクト等
    }
});
```
引用元: [skyway-js-sdk/examples/p2p-videochat/script.js#L128](https://github.com/skyway/skyway-js-sdk/blob/master/examples/p2p-videochat/script.js#L128)

## 検出を停止

```javascript
function step2() {
    $('#step1, #step3').hide();
    $('#step2').show();
    $('#callto-id').focus();
    // 音声検出中であれば停止
    stopVoiceDetection();
}
```
引用元: [skyway-js-sdk/examples/p2p-videochat/script.js#L128](https://github.com/skyway/skyway-js-sdk/blob/master/examples/p2p-videochat/script.js#L116)

# トランシーバーサービスの中で使ってみる

このサービスは同じRoomに入室したメンバ同士でトランシーバーのように片方向通話ができます。トランシーバーなので、同時に話せるのは1名のみという制約があります。

## 動作イメージ

![docotora.gif](https://qiita-image-store.s3.amazonaws.com/0/6651/2ccb236a-4d5d-716d-89ff-09b7a0b7e4d1.gif)

## 利用方法

1. ルーム名をハッシュタグ(#****)で指定して以下のURLにアクセス
  - https://yusuke84.github.io/docotra/public/#*****
2. 「マイクボタンを押して喋ってね」と表示されている時にマイクボタンをクリック（タップ）
3. 喋る
4. 喋り終わったらもう一度マイクボタンをクリック（タップ）

※ Room名は複雑にしたほうが良いです
※ PC版Chrome、Android版Chrome M67で動作確認済み
※ あくまでデモなのでうまく動かない場合もあります

## ソースコードの解説

ソースコードはgithubで公開しています。
https://github.com/yusuke84/docotra/

### ルームを2つ使い片方向配信を実現

まず参加者は`skyway.joinControlRoom()`でコントロール用のRoomにjoinします。
喋りたい人は、そのRoomを利用して`speak`メッセージを他のメンバに送信し、`skyway.joinMediaRoom()`でメディア用のRoomにjoinし、配信を開始します。
他の参加者は`speak`メッセージを受信したら、受信のみモードで`skyway.joinMediaRoom()`にjoinします。

**2021.04.19追記**
**2019.04にSFUの不具合が解消されているため、このワークアラウンドは不要となりました。**
~~SkyWayのSFUには[2018/6/4現在一部制約](https://qiita.com/yusuke84/items/13fab1d0f97c466e4d4b#replacestreamsfu%E3%83%A2%E3%83%BC%E3%83%89)があり、スピーカー→オーディエンスの順にRoomにjoinしなければ、片方向配信ができません。~~そのため、2つのRoomを併用し、スピーカー→オーディエンスの順番でjoinできるように制御し、片方向配信を実現しています。

余談ですが、このあたりの仕様は今後改善していきたいと考えています。

```js:index.js
'use strict';

import utility from './utility';
import skywayHelper from './skywayHelper';
import viewController from './viewController';
import voiceDetertor from './voiceDetertor';

const skywayOptions = {
    APIKEY: 'Your APIKEY',
    mode: 'sfu',
    isVideo: false,
    roomName: utility.getURLHash()
}

const joinRoomElement = document.getElementById('joinRoom');
const micElement = document.getElementById('mic');

const skyway = new skywayHelper(skywayOptions);
const view = new viewController();
const vad = new voiceDetertor();
view.initView();

joinRoomElement.addEventListener('click', () =>{
    skyway.joinControlRoom(function(result){
        if(result.value === 'open') console.log(result);
        if(result.value === 'speak'){
            console.log(result);
            setTimeout(() => {
                skyway.joinMediaRoom().then(result =>{
                    if(result.type === 'stream'){
                        utility.playMediaStream(document.getElementById('remote'),result.value);
                        vad.startVoiceDetection(result.value,(val) =>{
                            console.log('curr val:', val);
                            view.micEffecter(val);
                        });
                    }
                });
            },2000);
            view.micDisabled();
            view.setInfomation('誰か喋ってるよ');
        }
        if(result.value === 'stopSpeak'){
            vad.stopVoiceDetection();    
            utility.stopMediaStream(document.getElementById('remote')); 
            view.micEnabled();
            view.micEffectOff();
            view.setInfomation('マイクボタンを押して喋ってね');   
        }
        view.joinedView();
    });
});

micElement.addEventListener('click', () =>{
    if(skyway.isSpeaker === false){
        skyway.isSpeaker = true;
        skyway.joinMediaRoom().then(result =>{
            console.log(result);
        });            
    }else{
        skyway.isSpeaker = false;
        skyway.mediaRoomInstance.close();
        skyway.mediaRoomInstance = null;
        skyway.controlRoomInstance.send({message:'stopSpeak'});
    }
    view.switchMicButton(skyway.getSpeakStatus());
});
```

### SkyWayのRoomAPIのラッパークラス

特段変わった事はしていません。
`joinMediaRoom()`では、オーディエンスであれば受信のみモード（Streamオブジェクトを指定しない）でRoomを作成しています。また、ビデオをやり取りする用に、SFUの[キーフレーム強制送信ワークアラウンド](https://qiita.com/yusuke84/items/13fab1d0f97c466e4d4b#%E5%8F%97%E4%BF%A1%E3%81%AE%E3%81%BF%E3%83%A2%E3%83%BC%E3%83%89sfu%E3%83%A2%E3%83%BC%E3%83%89%E3%81%AE%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%A2%E3%83%A9%E3%82%A6%E3%83%B3%E3%83%89)も実装しています。

```js:skywayHelper.js
'use strict';

import utility from './utility';
import Peer from 'skyway-js';

class skywayHelper {

    constructor(param) {
        this.localAudioStream = null;
        this.skywayControlInstance = null;
        this.controlRoomInstance = null;
        this.mediaRoomInstance = null;
        this.peerId = null;
        this.roomName = param.roomName;
        this.controlRoomPrefix = '_ctl_';
        this.isSpeaker = false;

        delete param.roomName;
        this.options = param;
    }

    joinControlRoom(successCb,errorCb){
        this.skywayControlInstance = new Peer({key: this.options.APIKEY,debug: 3});
        const self = this;
        self.skywayControlInstance.on('open', peerId => {
            self.peerId = peerId;
            self.controlRoomInstance = self.skywayControlInstance.joinRoom(self.controlRoomPrefix + self.roomName,{mode: self.options.mode});
            self.controlRoomInstance.on('open', () =>{
                console.log('joined control room.');
                successCb({type:'open',value:peerId});
            });
            self.controlRoomInstance.on('peerJoin', peerId =>{
                console.log('join the peer:' + peerId);
                if(self.isSpeaker){
                    self.controlRoomInstance.send({message:'speak'});
                }
            });
            self.controlRoomInstance.on('data', data =>{
                console.log('received data');
                if(data.data.message === 'speak'){
                    successCb({type:'data',value:data.data.message});
                }
                if(data.data.message === 'stopSpeak'){
                    if(self.mediaRoomInstance){
                        self.mediaRoomInstance.close();
                    }
                    self.mediaRoomInstance = null;
                    successCb({type:'data',value:data.data.message});
                }
            });
            self.controlRoomInstance.on('error', error =>{
                errorCb(error);
            });
        });
    }

    joinMediaRoom(){
        const self = this;
        return new Promise(async (resolve,reject) => {
            if(self.skywayControlInstance){
                if(self.isSpeaker){
                    await self._getlocalStream(self.options.isVideo);
                    self.mediaRoomInstance = self.skywayControlInstance.joinRoom(self.roomName,{mode: self.options.mode,stream:self.localAudioStream});
                }else{
                    self.mediaRoomInstance = self.skywayControlInstance.joinRoom(self.roomName,{mode: self.options.mode});
                }
                self.mediaRoomInstance.on('open', async () =>{
                    (self.options.mode === 'sfu' && self.options.isVideo === true)? await self._sfuWorkAround():false;
                    console.log('joined media room:');
                    if(self.isSpeaker){
                        self.controlRoomInstance.send({message:'speak'});
                    }
                });
                self.mediaRoomInstance.on('peerJoin', peerId =>{
                    console.log('join the peer:' + peerId);
                });
                self.mediaRoomInstance.on('stream', stream =>{
                    console.log('receive stream');
                    resolve({type:'stream',value:stream});
                });
                self.mediaRoomInstance.on('error', error =>{
                    reject(error);
                });
            }
        });        
        
    }

    async _getlocalStream(videoFlag = false){
        try{
            this.localAudioStream = await navigator.mediaDevices.getUserMedia(utility.createGumConstraints(videoFlag,true,320,240,10));
        } catch(err){
            console.error('mediaDevice.getUserMedia() error:', err);
        }
    }

    getRoomName(){
        return this.roomName;
    }

    getSpeakStatus(){
        return this.isSpeaker;
    }

    _sfuWorkAround(){
        const self = this;
        return new Promise((resolve, reject) => {
            const dummyPeer = new Peer({ key: self.options.APIKEY });
            dummyPeer.on('open', () => {
                const dummyRoom = dummyPeer.joinRoom(self.roomName, {mode: 'sfu'});
                dummyRoom.on('close', () => {
                    dummyPeer.destroy();
                    resolve(true);
                });
                dummyRoom.on('open', () => {
                    dummyRoom.close();
                });
                dummyRoom.on('error', (err) =>{
                    reject('SFU work around is error:' + err);
                });
            });
        });
    }
}

export default skywayHelper;

```

### 音声検出

skyway.joinMediaRoom()では、AudioStreamを受信した場合にイベントが発火します。そのイベントをトリガに、voice-activity-detectionにStreamを渡します。
voice-activity-detectionのラッパークラスは以下の通り。

```js:voiceDetector.js
'use strict';

import vad from 'voice-activity-detection';

class voiceDetertor {

    constructor() {
        this.vadobject = null;
    }

    startVoiceDetection(stream,update) {
        window.AudioContext = window.AudioContext;
        let audioContext = new AudioContext();
        let vadOptions = {
            onVoiceStart: function() {
                console.log('voice start');
            },
            onVoiceStop: function() {
                console.log('voice stop');
            },
            onUpdate: function(val) {
                update(val);
            }
        };
        this.vadobject = vad(audioContext,stream,vadOptions);
    }

    stopVoiceDetection(){
        if(this.vadobject){
            this.vadobject.destroy();
        }
    }

}

export default voiceDetertor;
```

### マイクボタンにエフェクトをかける

voice-activity-detectionでアクティビティが取得できます。0は無検知、0以上で音声が検知されたことがわかるので、その値で判別してマイクボタンにエフェクトをかけていきます。0と0の2値ではなく、数値の大きさを判定してエフェクトを調整することも可能です。

```js:viewController.js（抜粋）
    micEffecter(level){
        if(level > 0){
            this.micElement.classList.add('speaking');
        }else if(level == 0){
            this.micElement.classList.remove('speaking');
        }      
    }
```

### メディアの再生

auioまたはvideoエレメントのsrcObjectプロパティにStreamオブジェクトを指定し、`play()`で再生しますが、これ実はPromiseベースの非同期メソッドになっています。イベントをキャッチできるように処理を書いておきます。

```js:utility.js(抜粋)
/**
 * video要素でMediaStreamを再生する
 * @param element
 * @param stream
 */
static async playMediaStream(element,stream) {
    element.srcObject = stream;
    let playPromise = await element.play();
    if(playPromise !== undefined){
        playPromise.then(() => {
            console.log('play video');
        }).catch(error => {
            console.log('error auto play:' + error);
        });
    }
}
```

### ローカルで動かす方法

```console
# git clone git@github.com:yusuke84/docotra.git
# cd docotora
# npm install
# npm run dev
# php -S localhost:8080
```

* webpackのbrowser-syncだと同一PC上でうまくデモができないので別のWebサーバをおすすめします

# スペシャル・サンクス

voice-activity-detectionの利用については[leader22](https://twitter.com/leader22)氏のコードを参考にさせていただきました。
ありがとうございます。