🐣

Twilio Programmable Videoを用いたビデオ通話の実装 with React(通話編)

2022/09/04に公開

はじめに

これは今さらな2021年振り返りカレンダーの7日目の記事です.

前回はsqldefをCloud Build上で動かし,マイグレーションの自動化を行うという内容でした.

https://zenn.dev/sheep96/articles/fad712dacde91e

今まではCI/CD周りの内容を主に書いてきましたが,今回はうって変わってビデオ通話機能についてです.

ビデオ通話機能を実現するためのSDK, SaaSとして有名なものとしては以下があります.

  • Twilio
    • SendGridとかでも有名
  • Agora
    • clubhouseで使われてたやつ
    • 数百,数千人規模の接続も可能なのが強み?
  • SkyWay
    • 国産
  • Zoom SDK (New!)

今回はとてもしっかりしたReactサンプルがあるTwilio Programmable Videoを採用することとしました.

https://github.com/twilio/twilio-video-app-react

Google Meetに近いものがすぐに立ち上がりますし,コードもすごいしっかりしています.
これをベースとして独自のビデオ通話を作る時のポイントを書いていきます.

Twilioの料金回り(グループ通話まわり)

  • 初期費用なし
  • ユーザ1人,1分につき0.44円
  • グループ通話は最大50人まで(相談可能ぽい?)

詳しくは以下.

https://www.twilio.com/go/video-api-sales-jp-1

仕様

フロントエンドはReact (typescript),バックエンドはgoとします.
また,ビデオ通話は最大でも十数人程度で行うとします(ライブ配信みたいに数百,数千人とかは想定していない).

画面は一旦,twilioのサンプルみたいな形を想定します.

https://user-images.githubusercontent.com/12685223/94631109-cfca1c80-0284-11eb-8b72-c97276cf34e4.png:image=https://user-images.githubusercontent.com/12685223/94631109-cfca1c80-0284-11eb-8b72-c97276cf34e4.png

実装

プロジェクトの設定

以下を参考にプロジェクトを作成し,APIキーの取得を行います.

https://dev.classmethod.jp/articles/making-a-video-conferencing-app-with-twilio-webrtc-hands-on/

ビデオ通話のための最小構成

Twilioでは1対1での通話を行うためのGo Room,P2Pで複数人通話を行うP2P Room,WebRTC SFUで複数人
通話を行うGroup Roomがあります.

https://www.twilio.com/docs/video/tutorials/understanding-video-rooms?_ga=2.266049743.1339819029.1643338952-229085524.1629733298&_gac=1.115915124.1643338952.Cj0KCQiAosmPBhCPARIsAHOen-OwC5Drb6uxMV6PsUy_2fBKNiKoTlYvsE1GUkVrdmkRhUg-TbqYuJ4aAo1sEALw_wcB#video-webrtc-go-rooms

今回はGroup Roomを用います.

通話開始から終了までの流れは以下の感じになっています.

  1. クライアント側でユーザがカメラやマイクを確認するための準備画面を出す.

  2. クライアントは,サーバから識別子とともに生成したRoom接続用のTokenを取得する.

  3. クライアントは,取得したTokenをもとに通話Roomへの接続を行う.

  4. 他ユーザが接続してきたらそれを表示.適宜画面共有など.

  5. 通話終了.

通話参加のためのトークンを返すAPI

ユーザが通話用ルームに入るためには,識別子をもとに生成したトークンが必要です.今回はgoのtwilioクライアントとして,twilio-goを用います.

https://github.com/kevinburke/twilio-go

まだ開発中なようですが,公式のやつも出てました.

https://github.com/twilio/twilio-go

twilio周りの処理をラップしたパッケージのコードは以下のような感じです.

package twilio

import (
	"context"
	"net/url"
	"time"

	"github.com/kevinburke/twilio-go"
	"github.com/kevinburke/twilio-go/token"
)

type Client struct {
	service        *twilio.Client
	accountSID     string
	apiKeySID      string
	apiKeySecret   string
}

func New(accountSID, apiKeySID, apiKeySecret, syncServiceSID string) *Client {
	return &Client{
		service:        nil,
		accountSID:     accountSID,
		apiKeySID:      apiKeySID,
		apiKeySecret:   apiKeySecret,
		syncServiceSID: syncServiceSID,
	}
}

func (c *Client) NewVideoToken(identity, roomName string, ttl time.Duration) (string, error) {
	twToken := token.New(
		c.accountSID,
		c.apiKeySID,
		c.apiKeySecret,
		identity,
		ttl,
	)
	videoGrant := token.NewVideoGrant(roomName)
	twToken.AddGrant(videoGrant)
	return twToken.JWT()
}

func (c *Client) NewRoom(ctx context.Context, authToken, roomName string) (*twilio.Room, error) {
	client := twilio.NewVideoClient(c.accountSID, authToken, nil)
	param := url.Values{}
	param.Add("uniqueName", roomName)
	return client.Rooms.Create(ctx, param)
}

上記のパッケージを以下のような感じで呼び出してtokenをクライアントに返します.

ユーザ名にはユーザ固有のidなどを入れてユニークにするとともに,クライアント側で表示するための名前も入れましょう.

また,ルーム名もユニークになるようにします.例えば1つのミーティングに対してルームを作るなら,ミーティングIDなどをもとにすると良いです.

最後にトークンの有効時間(TTL)ですが,こちらは想定される通話時間よりも少し長いぐらいに設定するのが良いです.短めに設定してしまうと,通話中にネットが不安定になるなどして再接続を試みる際にそれが失敗してしまいます.

func f() {
        TWILIO_ACCOUNT_SID := "hogehogehoge"
        TWILIO_API_KEY_SID := "fugaugafuga"
        TWILIO_API_KEY_SECRET := "piyopiyopiyo"
        TWILIO_SYNC_SERVICE_SID := "paupau"
	twl := twilio.New(accountSID, apiKeySID, apiKeySecret, syncServiceSID)
        token, err := twl.NewVideoToken("user:1:けんた", "room_abc", 4 * time.Hour)
}

クライアント側の実装

クライアント側の実装は前述したサンプルに準拠させます(1から自分で作るにはかなりの修練と時間が必要そう).
特にhooksとcomponentsをほぼコピーしてきて魔改造します.

https://github.com/twilio/twilio-video-app-react/tree/master/src

肝になる部分について解説していきます.

通話関連の状態を管理する VideoProvider

まず,VideoProviderです.

https://github.com/twilio/twilio-video-app-react/blob/master/src/components/VideoProvider/index.tsx

これは通話関連のstateなどを持つProviderです.以下のような変数,関数を提供しています.

  • 通話ルームの実態 room
  • クライアントのローカルトラック(ビデオやマイクなど)
  • roomへの接続用関数 connect
  • roomへ接続属中かを表す状態変数 isConnecting
  • 画面共有のオンオフに利用する toggleScreenShare
  • その他諸々...

通話用のコンポーネントはVideoProviderの下におき,上記のような関数,変数を参照しながら通話画面の制御を行なっていきます.

const Video: FC = () => (
  <VideoProvider
    options={defaultConnectionOptions}
    onError={() => {
      console.log("error");
    }}
  >
      <VideoApp />
  </VideoProvider>
);

ビデオ,音声などの確認画面

次は,通話ルーム接続前の確認画面です.このステップではクライアントのローカルトラックを画面上に出すだけです.

実装は以下のような感じになります.

VideoProviderが持っているlocal tracksからビデオを探し,それをVideoTrackコンポーネントで表示しています.
また,ユーザがキャンセルを行なった場合はlocal tracksをremoveし,前の画面に戻ります.
ユーザが入室ボタンを押した場合は,取得したトークンをconnectに渡し,ルームへの接続を行います.

const PreJoinRoom: FC = () => {
  const {
    connect,
    isConnecting,
    localTracks,
    removeLocalAudioTrack,
    removeLocalVideoTrack,
  } = useVideoContext();

  const handleClickEnter = async () => {
    let lessonTokens: LessonTokens;
    const {
        response: tokenRes,
        error: tokenErr,
      } = await apiGet(`/token`, {});
      const token = tokenRes.data;
    }
    connect(token);
  };

  const handleCancel = () => {
    removeLocalAudioTrack();
    removeLocalVideoTrack();
  };

  const videoTrack = localTracks.find((track) =>
    track.name.includes("camera")
  ) as LocalVideoTrack;

  const audioTrack = localTracks.find(
    (track) => track.kind === "audio"
  ) as LocalAudioTrack;

  return (
    <div>
        <VideoTrack track={videoTrack} isLocal />
      <div css={homeBar}>
        <button
          css={enterButton(isConnecting)}
          onClick={handleClickEnter}
          disabled={isConnecting}
        >
          {isConnecting ? "接続中" : "入室"}
        </button>
        <button css={cancelButton} onClick={handleCancel}>
          "戻る"
        </button>
      </div>
    </div>
  );
};

export default PreJoinRoom;

ルームへの接続

前述した準備画面で,connect関数を用いてルームへの接続を行いました.
connectが成功すると,VideoProvider中にあるroomStateがdisconnectedからconnectedに変化するので,それをもとに表示する画面の切り替えを行います.
以下のような感じです.

また,接続が不安定になった場合などは,roomStateがreconnectingになるので,その場合はユーザに通知を行うなどすると親切です.

const VideoApp: FC = () => {
  const history = useHistory();
  const roomState = useRoomState();
  const { getAudioAndVideoTracks, localTracks } = useVideoContext();
  const { hasAudioInputDevices, hasVideoInputDevices } = useDevices();

  useEffect(() => {
    getAudioAndVideoTracks().catch((error) => {
      console.log(error);
      notify.error(
        `${getErrorContent(
          hasAudioInputDevices,
          hasVideoInputDevices,
          error
        )} ${error}`
      );
      history.push("/");
    });
  }, [getAudioAndVideoTracks]);

  useEffect(() => {
    if (roomState === "reconnecting") {
      notify.error(
        "接続が中断されました。ルームへ再接続中です。",
        "video_reconnecting"
      );
    }
  }, [roomState]);

  // roomStateをもとに画面を切り替える
  return (
    <div css={{ height: "100%", width: "100%", background: colors.black }}>
      {localTracks.length > 0 &&
        (roomState === "disconnected" ? <PreJoinRoom /> : <Room />)}
    </div>
  );
};

ビデオ通話画面

通話画面については内容が多いかつ仕様によってかなり異なると思うので,サンプル中のRoomコンポーネントを起点として,コードを読んで勉強するのが一番良い気がします.それとMenuBarあたり.

https://github.com/twilio/twilio-video-app-react/blob/master/src/components/Room/Room.tsx

https://github.com/twilio/twilio-video-app-react/blob/master/src/components/MenuBar/MenuBar.tsx

よく出てくる用語をまとめると以下の感じです.

Participant

ビデオ通話への参加者.Main Participantは大きく表示される参加者.

Track

各ユーザが通話のために使うメディア?のこと.ビデオやオーディオなど.入力デバイスごとに存在する感じぽい.
特にローカルユーザのものに関してはlocalTrackと呼ぶ.

Publication

ユーザが通話のために配信するもののこと.Trackの他,オンオフの状態,優先度なども持っている.リモートとローカルで異なる.

そして,Roomを起点とするコンポーネントの階層とそれぞれの役割は以下のような感じになっています.

Room
├── MainParticipant(メインの参加者.喋ってる人など.)
|  └── MainParticipantInfo(メインの参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
|      └── ParticipantTracks(publicationのうち,ビデオ,共有スクリーンなど対応してるもののみを取り出し下に渡す)
|         └── Publication(参加者とそのトラックの状態をもとにVideoかAudioかなどを切り替える)
|            └── VideoTrack, AudioTrack(ビデオ or 音声の用コンポーネント)
└── ParticipantList(メイン以外の参加者)
   └── Participant(メイン以外の参加者)
       └── ParticipantInfo(メイン以外の参加者の名前や,ビデオ,ミュートなどの状態の表示を行う)
            └── ParticipantTracks
               └── 以下Main下と同様なので省略

上記のものを読んで依存関係や使い方などを理解した後,自分の要件に合う形で実装するのがいいと思います.

画面共有

画面共有は,VideoProviderが持つ toggleScreenShareを呼び出すことで行います(めちゃ簡単).
これを呼ぶと,呼び出したクライアントのpublicationがスクリーンになるので,それを他ユーザが受け取るという感じです.

サンプルだと以下のあたりのコードが参考になります.

https://github.com/twilio/twilio-video-app-react/blob/master/src/components/MenuBar/MenuBar.tsx

通話の終了

通話を終了するには,VideoProviderが持つroomのdisconnect関数を呼び出します.

実際のコードはEndCallButtonなどが参考になります.

https://github.com/twilio/twilio-video-app-react/blob/master/src/components/Buttons/EndCallButton/EndCallButton.tsx

これで一旦,通話の開始から終了までの実装が完了しました.
他にも配信設定やルーム作成のタイミング,通信状態の表示,エラーハンドリングなどたくさんあるのですが,次回に回そうと思います.

その他細かい知見など

  • クライアント側でユーザをグループごとに分けたい場合(教師と生徒,スピーカーと聴衆など)は,トークン作成の際のidentityにグループの識別子を入れ,それをもとに制御する.
  • トークンの有効期限が切れてもユーザは強制的にルームから退出させられたりしないので,強制退出を行いたい場合はクライアント側で再起動をタイマーで仕込むのが良いと思う.
    • TwilioのAPI経由でRoomを終了できるが,バッチ処理必要になるので,クライアント側でやった方がいい気がしてます.
  • Windows, Linuxだと,他のアプリケーション(Zoom, Meetその他諸々)でビデオカメラやオーディオなどを使っている場合,トラックの取得部分でNotReadableが起きて失敗する.Macだと起きないので気づかなかった...
    • WebRCT上の問題らしい.https://github.com/twilio/twilio-video.js/issues/325
    • 動作確認の際は他の通話アプリは落とすように注意.
    • ユーザ側にもその旨の通知をした方が良い.
    • Mac以外でブラウザ2つ立ち上げてテストができなかったのでだるい.

最後に

twilio programmable videoを用いてビデオ通話を実装してみました.
自分はtwilioを触るまでWebRTCに関わったことがなかったのですが,これのおかげでそこそこ短いスパンでプロダクション用の通話機能を実装することができました(まだWebRTC詳細について理解できてない部分も多いですが...).よくできたReactサンプルの存在が何よりの助けになった気がします.また,やはりマネージドなSaaSは強い.それと,独自でWebRTC使って実装してる人すげぇ...ってなりました.いつかちゃんと勉強してみたいです.

課金形態は人数及び時間による従量課金なので,ライブ配信みたいな無料通話が発生するケースには向いてないかもですが,レッスンのような通話単位でお金が発生するケースにおいては非常に便利だと思います.

次回は配信設定やエラーハンドリングなど,通話のクオリティやUXを向上させるための部分について書いていこうと思います.

Discussion