PHONE APPLI Engineer blog

エンジニアブログ

Clubhouseっぽい音声チャットを作ってみた

Clubhouse、流行ってますね

みなさん、「Clubhouse」、楽しんでますか?

僕は招待してくれる友達がいないのでやっていません(涙)

こんにちは、株式会社PHONE APPLI、 テクノロジーリサーチ部たかはしとし( @doushiman_jp ) です。

できないなら作ってしまえ

皆さんご存知とは思いますが Clubhouse とは、音声のみで繋がる SNS のことです。

それで話は冒頭に戻るんですが、Clubhouse は招待制ということで、僕のようなテレワーク主体で、毎日が無人島生活のようなボッチマンにはそんなインビテーションは届きません。

ならばセルフで作ってしまおう、というお話です。

方式設計

調べてみると、Clubhouse にはモデレータと呼ばれる部屋主だけが、最初に喋る権利を持っているそうです。

モデレータ以外はみなミュート状態ですが、モデレータが許可した人はスピーカーとなり、ルーム内で発話が可能になります。

リスナーは、モデレータとスピーカーの会話を聞くことのみ可能です。

モデレータ スピーカー リスナー
発話 △(要モデレータの許可) ×
発話許可 × ×
聴く

上記仕様から、なんちゃって Clubhouse の実装には、音声だけのチャット機能と、発話の権限管理が必要ということが見えてきましたので、それぞれを下記で実装してみたいと思います。

Skyway は、NTTコミュニケーションズが公開している、ビデオ・テキストチャットが実装できるライブラリです。

基本的にはビデオチャットとして使われますが、今回は音声チャンネルのみ使用します。

また、mobile backend はメンバー間で権限を同期させる必要上、サーバが必須だったために選択しました。

ルーム作成

Skywayが公開している、多人数チャットのサンプルコードをベースにしています。

今回映像は使いませんのでvideoはオフにします。

なので、ストリームの設定は下記のように変更しています。

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

次にSkywayのアプリケーションキーをこの自分で発行したものに差し替えます。

また、今回使わない UI を削除して、このような見た目にしています。

f:id:doushiman_jp:20210221182557p:plain

Clubhouse ではありません。ここは部室です。

何故アマチュア無線なのかという僕が高校時代に在籍していたからです。

アマチュア無線部とか言いつつ、無線に触れることなくひたすらPCをいじっていたっけ・・・

ここは部室とするならば、最大の権限をもつモデレータはさしずめ部長でしょうか。

なお、この時点でも roomID を入力して JOIN すれば、多人数での音声チャットは可能な状態です。

次に、各役割ごとの権限を設定していきます。

権限保存

権限を設定し、mobile backend にユーザー情報を保存します。

mobile backend で今回のアプリを作成し、データストアに保存用のクラスを作成します。

作成したクラスに、必要なフィールドを追加します。

今回は

  • isModelator (モデレータか)

  • canTalk (発言権をもっているか)

  • peerId (SkywayのpeerId)

  • roomId (参加している部屋のID)

を追加しました。

入室

テキストボックスにroomの名前を入力してJOINすると、

f:id:doushiman_jp:20210221182628p:plain

isModelatorとcanTalkによって人を表すアイコンの色、位置が変わるようになっています。

一人目の参加者はモデーレータになるようにしていますので、isModelator=true,canTalk=trueとします。

f:id:doushiman_jp:20210221182643p:plain 部長、一人部室に佇むの図。

二人目以降はデフォルトではリスナーにしますので、isModelator=false,canTalk=falseとなります。

f:id:doushiman_jp:20210221182702p:plain

リスナーはピンク色として、一段下に描画しています。

この時気をつけるのは、リスナーが新たにJOINした時に、全てのクライアントで同期が必要というところです。

ログインした本人以外のクライアントも、同じ状態にする必要があるので、

  • mobile backend からメンバー一覧を取得 → 再描画

をそれぞれする必要があります。

ここは、 Skyway の room に Join した時のイベントをフックとして、再描画を実行します。

コード的には下記のようになります。

    room.on('peerJoin', peerId => {
        reload(roomId.value);
    });

reload メソッドの中では、 mobile backend から roomId に紐づくリストを取得し、権限に従って再描画しています。

こうすることで、room に Join したことをトリガーとして、他のクライアント間でユーザーの状態を同期することができます。

また、二人目以降の参加者は、デフォルトでは発言しないようにミュート状態にする必要があります。

Skyway 側で room の参加人数を取得する機能が無いようなので、ここでは mobile backend 側のユーザー数をカウントします。

その部屋の参加者が一人以上(=すでに参加者がいる)の場合、そのユーザーの参加時はミュート状態である必要がありますので、下記を設定します。

let audioTrack = localStream.getAudioTracks()[0];
audioTrack.enabled = false;

その部屋のユーザー数が0人の場合、=モデレータとなるので、上記は不要です。(モデレータは喋れますので)

これで、モデレータのみが発話できる部室が完成です。

権限設定

このままだと、延々と部長一人が喋るだけの地獄の部室と化しますので、他のメンバーにも発言の権利を与えたいと思います。

ピンク色のメンバーアイコン(聞くだけのメンバー)をクリックしたら、そのイベントを拾って、mobile backend 側の canTalk を true にアップデートします。

let joienr = ncmb.DataStore("joiner");
joienr.equalTo("peerid", peerId)
.fetchAll()
.then(function(results){
     
     if(results[0].isModelator){
         return
     }
     let objId = results[0].objectId
     let canTalk = results[0].canTalk
     let object  = new TestClass
     object
     .set('objectId', objId)
     .set('canTalk', !canTalk)
     .update()
     .then(function(results){
         //再描画処理
     })
 })

うっかりモデレータがリスナーなどに変更できてしまうと、今度は誰も発言できない部屋になってしまいますので、モデレータはモデレータ以外の役割になれないように制限しています。

また、権限変更が可能なのもモデレータに限定しています。

モデレータが、ピンクアイコンをクリックすると

f:id:doushiman_jp:20210221182723p:plain

赤いアイコン(=スピーカー)になるようにしています。 これで、モデレータとスピーカーの、会話が可能になりました。

また、スピーカは複数人設定できますので

f:id:doushiman_jp:20210221182740p:plain

みたいな全員でワイワイガヤガヤ会話する仲良し部室でもいいですし、逆に

f:id:doushiman_jp:20210221182751p:plain

みたいに、モデレータ一人が喋り、あとは全員リスナーなこともできます。

これは…部長からのお説教タイムですかね?

また、モデレータが発話権限を修正しても、他のユーザーにはその知らせが飛んでいないので、こちらも同期してあげる必要があります。

先ほどの入室の時は、 Skyway の room 入室イベントを使いましたが、今回は Skyway 側にはなんの変更も入らないのでこの手は使えません。

なので、修正されたことを、Skywayのデータコネクションを使って他のクライアントに通知してあげます。

通常、データコネクション、はチャットなどの文字ベースの会話に使用するのですが、今回はテキストチャットを使用しませんので、これを利用します。

room.send(peerId);

このようにルームに対して、権限が変更されたIDを通知します。

各クライアントは、その通知を受けて、再描画処理を行います。

また、自分の peerId を通知されたユーザーは、クライアントのミュート状態を解除し、話ができるようにします。

これで、クライアント間の同期と、ミュート状態の自動解除を実現しています。

ここまでで、当初目的の機能は実装できたかな、と思います。

まとめ

いかがでしょう。

冒頭にもお話ししましたように、本物の Clubhouse を使ったことがなく、伝え聞く話を元に思いつきで作ってしまったので、かなりの割合でオレオレClubhouse になっていると思いますがご容赦いただければと。

Skyway を使うことでチャット部分はほぼ作り込み不要だったこともあり、数時間くらいで実装することができました。(room に JOIN している人数が取得できるともっと楽だったのですが…)

ドキュメントも充実していますし(日本語ですし!)他にも色々使っていきたいなと思いました。