🍣

Agoraで君だけの最強のClubhouseを作ろう

2021/02/03に公開

Clubhouse に招待されないので自分で作ってみた、みたいな感じです。まあ別に招待してくれなくていいんですけどね、大して興味ないしそのうちオープンになるだろうしそれにほらどうせ Android 使ってるしあのぶどうは酸っぱいし[1]

初挑戦で頑張って調べて書いてる感じなので何かあれば PR とかお願いします。

先にまとめ

  • Clubhouse は Agora を使っているらしい
  • Agora - Real-Time Voice and Video Engagement
  • 無料枠は音声 10,000 分/月
    • (誤って『10,000 時間/月』と記載してました。分です。すみませんでした。)
  • 公式チュートリアル:Start a Voice Call
  • 今回作ったデモ:ginpei/try-agora


毎月 10,000 分無料とのこと。価格のページより


デモのスクリーンショット。開始ボタンや参加者の ID 一覧など

Agora?

音声や映像のストリーミングを行う API を提供するサービス。従量課金制だけど音声 10,000 分/月の無料枠があり、クレジットカード登録等なしで気軽に試せました。ちなみに 10,000 分 ≒ 7日です。豪勢だ。

ウェブ版の SDK は CDN か npm install agora-rtc-sdk-ng でインストール。TypeScript で使える型情報もきっちり提供されていて嬉しい。他にも iOS、Android はもちろん Unity や Flutter、React Native等々幅広く提供されてます。

そして噂の Clubhouse はこれを使って運営されているらしい。

https://zenn.dev/voluntas/scraps/9403b803320d6f

  • 利用しているリアルタイム配信サービスは Agora.io
    • DNS 見ただけ
    • ap-japan.agora.io が見えた

デモを試す

これから作り方を紹介するんだけど、できあがりは GitHub で公開しています。(ライブデモはありませんすみません。)

  • ginpei/try-agora
    • 実装は public/main.jsmain() から
    • 本質的でない UI 操作とかの実装は ui.js

試すには git clone の後 public/secrets.example.jspublic/secrets.js へ複製し、設定をしてください。Agora のアカウント等が必要になります。

友達と会話を試す場合、ngrok を使って localhost を外部へ繋げるのを npm run serve:proxy で提供しています。

デモのスクリーンショット。開始ボタンや参加者の ID 一覧など

作ってみる

ウェブ版(クライアント側 JavaScript)です。

アカウント作成

まずは Agora のアカウントと、デモアプリ用のプロジェクトを作成してください。無料です。

後で必要な情報を取りに戻ってきます。

もうちょっと

初回はなかった気がするんだけども、もしプロジェクト作成のところで Authentication Mechanism の選択が出てきたら Secure mode を選択してください。token なしはちょっとこわいでしょ。


新規プロジェクト作成画面。Testing mode なら token 不要とある。

Signup からはチュートリアルみたいな感じでどんどん進むので特につまづくこともないと思うけど、こちらの記事に詳しく書いてあるのを見かけましたので良ければ併せてご覧ください。

アプリの骨組み作成

適当な HTML を用意してください。画面をブラウザーで表示するところまでできたものとします。

SDK 読み込み

今回のデモでは素の JavaScript で書きました。サーバーも Browsersync なのでごく単純な構成です。ライブラリーは CDN で配信されていますので、差し当たりこいつを使いましょう。

<script src="https://download.agora.io/sdk/release/AgoraRTC_N-4.3.0.js"></script>

お好みで React なりへ混ぜこんでくださっても結構。その場合は npm パッケージの方をどうぞ。

$ npm install agora-rtc-sdk-ng
import AgoraRTC from "agora-rtc-sdk-ng"

クライアントの用意

ここから実装です。

最初に Agora のサーバーへ接続するクライアントのインスタンスを生成します。

const client = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });

この先この client オブジェクトを使いまわすので持っておいてください。

インタラクション

利用者の操作 4 種と Agora からの通知 4 種を処理します。

利用者操作は以下のボタンを HTML にご用意ください。そんで button.onclick とか。

  • join - チャンネル参加
  • leave - チャンネル離脱
  • publish - 音声送信開始(アンミュート)
  • unpublish - 音声送信終了(ミュート)

Agora からの通知は client.on() で監視できます。以下のイベントを利用します。

  • user-joined - 誰か join した
  • user-left - 誰か leave した
  • user-published - 誰か publish した
  • user-unpublished - 誰か unpublish した

というわけで、こんな感じです。コールバックの関数はこれから作っていきます。

document.querySelector("#join").onclick = onJoinClick;
document.querySelector("#leave").onclick = onLeaveClick;
document.querySelector("#publish").onclick = onPublishClick;
document.querySelector("#unpublish").onclick = onUnpublishClick;

client.on("user-joined", onAgoraUserJoined);
client.on("user-left", onAgoraUserLeft);
client.on("user-published", onAgoraUserPublished);
client.on("user-unpublished", onAgoraUserUnpublished);

チャンネル参加

async function onJoinClick() {
  const uid = await client.join(appId, channel, token, null);
}

さてここで 4 つの値が必要です。Agora のコンソール > Project Managementから探してきてください。

値の探し方
  • appId - "App ID" のやつ
  • channel - これはお好きに。空白や記号も使える様子(join() API の仕様を参照)
  • token - channel などを元に生成されるもの
    • Project Management > Edit > Features > Generate temp token で生成
    • 通常はプロジェクト作成時に Secure mode を選択するので実質必須
  • uid - 自動生成させるので null

トークン生成の際は利用するチャンネル名 demo_channel_name を正確に入力してください。これが食い違うと CAN_NOT_GET_GATEWAY_SERVER エラーになります。


Generate temp token の様子。チャンネル名を入力する。

なお token は、デモではコンソールから生成できたけど本番では自力で生成します。秘密の情報 App certificate を用いる必要があるためです。コードが露出するクライアント側では行えません。サーバー側かサーバーレスなやつでやろう。

チャンネル離脱

client.localAudioTrack は配列で、後の publish 時に増えます。

async function onLeaveClick() {
  // 音声のトラックを閉じる(必須)
  client.localTracks.forEach((v) => v.close());

  await client.leave();
}

音声のトラックは次の onPublishClick() で作成します。

離脱時にこの音声のトラックを閉じるのは必須とのこと。API の説明には特に記載がないけど、ちゃんと閉じないと何かが残るみたいで(実装未確認)、例えば Chrome がタブに表示する録音中マークが付いたままになったりしました。(client.unpublish() はしなくて良いのかな?)

Destroying the local media tracks is mandatory. You can follow your own implementation preferences.

それはさておき、これで参加と離脱ができるようになりました。聞き専ならこれで終わり。

音声送信開始

喋り始めます。

async function onPublishClick() {
  const localAudioTrack = await AgoraRTC.createMicrophoneAudioTrack();
  await client.publish([localAudioTrack]);
}

音声送信終了

自身の音声をミュートします。

async function onUnpublishClick() {
  await client.unpublish();
}

ちなみに音声トラックは publish() で複数登録できて、unpublish() の引数で指定して任意のものを閉じることができるようです。また省略すると全て閉じるとのこと。leave() との関係はどんな感じなんだろ?

よくわかんないけどこのタイミングで client.localTracks.forEach((v) => v.close()) 呼んだ方が良いのかな。

まあともかくこれで自身の操作はできるようになりました。ここからは他の人の音声を取得していきます。

誰か join した

参加者一覧に表示するとかしてください。(実際デモのコードではここで更新を行っています。)

// client.on("user-joined", onAgoraUserJoined);

async function onAgoraUserJoined(user) {
}

user: IAgoraRTCRemoteUser は以下のプロパティを持ちます。

  • audioTrack
  • hasAudio
  • hasVideo
  • uid
  • videoTrack

人の名前やアイコン画像なんかは Agora の責務の範囲外なので自前で名寄せしてください。ここの user.uid はその人の client.join() の戻り値と合致するので、参加時に Redis へ置くとか。

誰か leave した

// client.on("user-left", onAgoraUserLeft);

async function onAgoraUserLeft(user, reason) {
}

reason は以下の 3 種。

  • "Quit" - 離脱した
  • "ServerTimeOut" - オフラインになった
  • "BecomeAudience" - role が audience になった

role は本稿では扱っていません。

誰か publish した

話し始めたので傾聴します。

// client.on("user-published", onAgoraUserPublished);

async function onAgoraUserPublished(user, mediaType) {
  const track = await client.subscribe(user, mediaType);
  track.play();
}

誰か unpublish した

// client.on("user-unpublished", onAgoraUserUnpublished);

async function onAgoraUserUnpublished(user, mediaType) {
}

特に close() など音声トラックを操作する必要はないみたいです。

ちなみにここ、チュートリアルだと謎に <div> を削除しようとしてるんだけどそんなもの作ってないし確認もしていないのでエラーになります。なんだろ、古い API ではそういう作業があったのかな?

ともかくこれで人の声も聞こえるようになり、音声チャットが完成しました。やったね。

トラブルシューティング

画面読み込み時のエラー

AgoraRTCError NOT_SUPPORTED: enumerateDevices() not supported.

AgoraRTCError WEB_SECURITY_RESTRICT: Your context is limited by web security, please try using https protocol or localhost.

localhost あるいは https:// の URL で開いてください。http:// でこのエラーになります。

チャンネル参加時のエラー

AgoraRTCException
AgoraRTCError CAN_NOT_GET_GATEWAY_SERVER: dynamic use static key

tokennull にせず、文字列を与える。ドキュメントには string | null とあるが実質必須。

AgoraRTCError INVALID_PARAMS: Invalid token: . If you don not use token, set it to null

secrets.jstoken を設定する。空文字列のままになっている。

Choose server https://webrtc2-ap-web-1.agora.io/api/v1 failed, message: AgoraRTCError CAN_NOT_GET_GATEWAY_SERVER: dynamic key expired, retry: false

token を再生成する。期限切れ。24 時間程度?

AgoraRTCError CAN_NOT_GET_GATEWAY_SERVER: invalid token, authorized failed

token が正しいチャンネル名で生成されたか確認する。

token を取得するには

こちら:

あるいはこちらのドキュメントに従ってください: Generate a Token

音声送信開始時のエラー

AgoraRTCError INVALID_OPERATION: Can't publish stream, haven't joined yet!

→ 先に client.join() を呼ぶ。

誰かが leave したときのエラー

TypeError: Cannot read property 'remove' of null

→ 要素が存在することを確認する。

ドキュメントでは document.getElementById(user.uid) の結果の要素を削除しろと言うんだけど、ドキュメント探してもそんなものは作られていないんだよなあ。

録音中マークが消えない

→ 確実に client.localTracks.forEach((v) => v.close()) を実行する。たしかに必須だった。

ログ出力が多すぎる

とりあえず DevTools コンソールのフィルタリングで -[INFO] -[DEBUG] あたり置いておけば消えます。

おしまい

これで Clubhouse クローンが作れるようになりました。あとはログイン制御とかして、良い感じに UI を整えたら良さそうです。Agora 簡単だし無料枠もなかなかあるので、別のアプリに組み込んでもおもしろいかも。映像の方も。

君だけの最強の Clubhouse を作ろう!

参考

脚注
  1. すっぱい葡萄 - Wikipedia ↩︎

Discussion