オンライン会議での発言率を表示する

# はじめに
会議をしていても「いつも同じ人が喋ってるなぁ」と思うことありませんか？
話すべき役割の人であれば問題ありませんが，誰かの発言機会を奪ってしまっていたり，他の人が発言する気がないのなら改善する必要がありそうです．そんな時に，発言率のデータを取れる機能を持ったオンライン会議アプリを開発してみましょう！

**本記事ではあくまでチームの状況把握を目的としているので，この機能を評価に使うのが本当に良いのか気をつけてください．**

## 本題
こんな感じで会議での発言率(正確には発言秒数)を円グラフで表します．
![142756488-a2d9851f-6f26-4623-a67c-d414ee6f642c.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/a81a4996-576e-6b35-239c-d6b1caab3a67.jpeg)

今回の実装は[Github](https://github.com/masibw/twilio-video/pull/3)で確認できます．

## 概要
ビデオ通話機能には，[Twilio](https://www.twilio.com/ja/) を使います．
## 準備
### 1. Twilioアカウントを作成します．

https://www.twilio.com/blog/how-to-create-twilio-account-jp

### 2.  ベースとなるコードをFork & Cloneする．
基本的なビデオ機能は既に実装したものを使いましょう．

https://github.com/masibw/twilio-video


### 3. 必要となる認証情報を準備する
#### ACCOUNT SIDを取得する
ACCOUNT_SIDは [Console](https://twilio.com/console)から取得できます．

#### API KEYを作成する
API_KEY_SIDとAPI_KEY_SECRETは [API Keys](https://www.twilio.com/console/project/api-keys)から「Create API Key」を選択します．

![スクリーンショット 2021-11-12 19.40.19.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/7c84e472-df4b-352b-15a9-70cb90c6cdac.jpeg)

API KEYに分かりやすい名前をつけます．今回は「Speak Rate」にします．
![スクリーンショット 2021-11-21 18.32.03.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/40c3a8b0-1d32-f825-0f5c-078479c288ca.jpeg)


するとSIDとSecretが表示されるので，次項の`.env`へコピペします．

#### .envに書き込む
`.env.template`があるので，**これを`.env`という名前に変更**し，中身を以下のように書き換えます．

``` text:.env
TWILIO_ACCOUNT_SID=ACCOUNT_SIDをコピペする
TWILIO_API_KEY_SID=API_KEY_SIDをコピペする
TWILIO_API_KEY_SECRET=API_KEY_SECRETをコピペする
```

### 4. 依存関係をインストールする
今回はPythonを使うので`venv`を使って仮想環境を準備し，`pip install`します．

```
$ python -m venv venv
$ source /venv/bin/activate
(windowsの人は $ source venv\Scripts\activate)
(venv) $ pip install -r requirements.txt
```

### 5. 起動してみる
```
(venv) $ FLASK_ENV=development flask run
```
ブラウザで`localhost:5000`にアクセスすし，別タブからも同様に接続すると以下のような画面が表示されると思います．ビデオと音声の使用は許可してください．

![スクリーンショット 2021-11-21 18.34.05.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/d479a03b-d2d7-d562-9511-926d10733c8a.jpeg)
## 実装していく
ここからがこの記事のメインです．「ボタンを押すとその時点での会話率を円グラフで表示する」機能を作っていきます．

### 1. UI(ボタン・表示領域を用意する)
まずは必要となるボタンと，円グラフの表示領域を作成します．(空のcanvas領域が表示されるので少し汚いですが，今回は許してください)

```diff:templates/index.html
 <button id="join_leave">Join call</button>
+<button id="toggle_audio" disabled>Mute Audio</button>
+<button id="display_speak_rate_button" disabled>Display Speak Rate</button>
 </form>
 <p id="count"></p>
+ <div class="chart-container" style="position: relative; height:300px; width:300px">
+  <canvas id="display_speak_rate" height="300" width="300"></canvas>
+ </div>
```
以下の画像のように「MuteAudioボタン」と「Display Speak Rateボタン」が追加され，「自分のカメラ映像との間に空白」ができていればOKです．
![スクリーンショット 2021-11-21 20.06.41.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/3cc439ec-af16-eecf-bcd3-ceb762d3367b.jpeg)

### 2. ミュート機能を実装する
「ボタンを押すとその時点での会話率を円グラフで表示する」機能を１人で試すためにはミュートを使って擬似的に話者を切り替える必要があるので，まずはミュート機能を実装します．
通話中にはミュートボタンを押せるようにし，退室すると押せないようにします．また，現在ミュート中の時にはボタンの表示を「UnMute Audio」に，現在マイクがオンの時には「Mute Audio」にします．（発言率表示ボタン(display_speak_rate_button)も同様なので一緒に記述します)

```diff:static/app.js
 const connectButtonHandler = (event) => {
  ... 省略 ...
  connect(username)
    .then(() => {
        joinLeaveButton.innerText = "Leave call";
        joinLeaveButton.disabled = false;
+       document.getElementById("toggle_audio").disabled = false;
+       document.getElementById("display_speak_rate_button").disabled = false;
      })
  ... 省略...
};

const disconnect = () => {
 ... 省略 ...
 document.getElementById("join_leave").setAttribute("innerHTML", "Join call");
+ document
    .getElementById("toggle_audio")
    .setAttribute("innerHTML", "Mute Audio");
  connected = false;
+ document.getElementById("toggle_audio").disabled = true;
+ document.getElementById("display_speak_rate_button").disabled = true;
  updateParticipantCount();
};

+const toggleAudioHandler = (event) => {
+  event.preventDefault();
+  room.localParticipant.audioTracks.forEach((publication) => {
+    if (publication.track.isEnabled) {
+      publication.track.disable();
+      document.getElementById("toggle_audio").innerHTML = "Unmute Audio";
+    } else {
+      publication.track.enable();
+      document.getElementById("toggle_audio").innerHTML = "Mute Audio";
+    }
+  });
+};
+document.getElementById("toggle_audio").addEventListener("click", toggleAudioHandler);

```
これで通話中に自由にミュートできるようになったはずです．

### 3. 話者の変更を検知する
話している人が変わったことを検知するには[TwilioのDominant Speaker Detection API](https://www.twilio.com/docs/video/detecting-dominant-speaker)を使用します．なお，この機能は２人以上のグループルームでないと有効になりません．


```diff:static/app.js
const connect = (username) =>
  ... 省略 ...
-.then((data) => {
- return Twilio.Video.connect(data.token);
-})
+return Twilio.Video.connect(data.token, {
+  dominantSpeaker: true,
+ });

... 省略...
});
```

サーバー側で参加者と発言時間を保持します．発言者が変わったことを検知しているので，そのタイミングで１つ前の参加者の発言時間を計算・追加する形です．

```diff:app.py
+from datetime import date, datetime, timedelta
+speakMap = dict()
+lastDominantSpeakerChanged = None
+lastSpeaker = None
 @app.route('/')
 def index():
    return render_template('index.html')


+@app.route('/speaks', methods=['POST'])
+def saveSpeak():
+    global lastDominantSpeakerChanged
+    global lastSpeaker
+    username = request.get_json(force=True).get('username')
+    if not username:
+        abort(400)
+    # lastSpeakerがいない = 初めての発言者なので，登録だけして終わる
+    if not lastSpeaker:
+        lastSpeaker = username
+        lastDominantSpeakerChanged = datetime.now()
+        return {'status': 'ok'}
+
+    now = datetime.now()
+    duration =  now - lastDominantSpeakerChanged
+    print(duration)
+
+    if lastSpeaker in speakMap:
+        speakMap[lastSpeaker] += duration
+    else:
+        speakMap[lastSpeaker] = duration
+    lastDominantSpeakerChanged = now
+    lastSpeaker = username
+    return {'status': 'ok'}
```

`dominantSpeakerChanged`イベントを検知して，`POST /speaks`へ次の発言者名を送信します．

```diff:static/app.js
const connect = (username) =>
  ... 省略 ...
 .then((_room) => {
        room = _room;
        room.participants.forEach(participantConnected);
        room.on("participantConnected", participantConnected);
        room.on("participantDisconnected", participantDisconnected);
+       room.on("dominantSpeakerChanged", (participant) =>
+         dominantSpeaker(participant)
+        );
        connected = true;
  ... 省略...
});

+const dominantSpeaker = (participant) => {
+  fetch("/speaks", {
+    method: "POST",
+    body: JSON.stringify({
+      username: participant.identity,
+    }),
+  }).catch((err) => {
+    console.log(err);
+    reject();
+  });
+};
```


### 4. 発言率を表示します．
やっと最後です．`display_speak_rate_button`を押すと発言率を表す円グラフを表示します．グラフ表示には[Chart.js](https://www.chartjs.org/)を用います．

```diff:app.py
+from flask.json import JSONEncoder


+# timedeltaをJSONで出力できるようにする
+class CustomJSONEncoder(JSONEncoder):
+    def default(self, obj):
+        try:
+            if isinstance(obj, 
+             timedelta):
+                return obj.seconds
+            iterable = iter(obj)
+        except TypeError:
+            pass
+        else:
+            return list(iterable)
+        return JSONEncoder.default(self, obj)


+app = Flask(__name__)
+app.json_encoder = CustomJSONEncoder
speakMap = dict()
lastDominantSpeakerChanged = None
lastSpeaker = None


+@app.route('/speaks', methods=['GET'])
+def getSpeaks():
+    return {'speaks': speakMap}
```


```diff:templates/index.html
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/3.6.0/chart.min.js"
+            integrity="sha512-GMGzUEevhWh8Tc/njS0bDpwgxdCJLQBWG3Z2Ct+JGOpVnEmjvNx6ts4v6A2XJf1HOrtOsfhv3hBKpK9kE5z8AQ=="
+            crossorigin="anonymous" referrerpolicy="no-referrer"></script>
```

```diff:static/app.js
+const displaySpeakRateHandler = (event) => {
+  event.preventDefault();
+  fetch("/speaks")
+    .then((res) => res.json())
+    .then((data) => {
+      console.log(data.speaks);
+
+      const ctx = document
+        .getElementById("display_speak_rate")
+        .getContext("2d");
+      const myChart = new Chart(ctx, {
+        type: "pie",
+        data: {
+          labels: Object.keys(data.speaks),
+          datasets: [
+            {
+              label: "発言時間(s)",
+              data: Object.values(data.speaks),
+              backgroundColor: [
+                "rgba(255, 99, 132, 0.2)",
+                "rgba(54, 162, 235, 0.2)",
+                "rgba(255, 206, 86, 0.2)",
+                "rgba(75, 192, 192, 0.2)",
+                "rgba(153, 102, 255, 0.2)",
+                "rgba(255, 159, 64, 0.2)",
+              ],
+              borderColor: [
+                "rgba(255,99,132,1)",
+                "rgba(54, 162, 235, 1)",
+                "rgba(255, 206, 86, 1)",
+                "rgba(75, 192, 192, 1)",
+                "rgba(153, 102, 255, 1)",
+                "rgba(255, 159, 64, 1)",
+              ],
+            },
+          ],
+        },
+      });
+    })
+    .catch((err) => {
+      console.log(err);
+    });
+};
+document.getElementById("display_speak_rate_button").addEventListener("click", displaySpeakRateHandler);
```

ここまで実装すれば２つのタブで`localhost:5000`を開き，ルームに入った後は交互にmuteして適当に何か喋ります．そして「Display Speak Rate」ボタンを押せば以下のように円グラフが表示されるはずです．

![142756488-a2d9851f-6f26-4623-a67c-d414ee6f642c.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/379424/a81a4996-576e-6b35-239c-d6b1caab3a67.jpeg)

# 最後に
[Twilio](https://www.twilio.com/ja/)を用いることでビデオ通話機能や発言者の検出などをとても簡単に実装できました．

# 参考
https://www.twilio.com/docs/video/detecting-dominant-speaker