Sansan Builders Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Sansan音声通話の裏側 【後編:Twilio Voice SDK】

はじめに

こんにちは、尾林です。
Sansan事業部プロダクト開発部でSansan iOSアプリの開発を担当しています。

今回の記事は先月投稿させていただいた「Sansan音声通話の裏側 【前編:CallKit/PushKit】」の続きとなる後編の投稿になります。 buildersbox.corp-sansan.com

前編ではIP通話を実現するために必要な要素とAppleが提供するCallKit/PushKitの基本的な使い方を紹介させていただきました。
後編となる今回では、実際の音声情報をやり取りするための手段として我々が採用したTwilio Voice APIの基本的な使い方の紹介を通して、音声通話が可能になるまでの流れをお伝えできればと思います。

前編までの簡単なおさらいとして、改めてSansanアプリの音声通話機能の概観図を載せておきます。

f:id:obayashi1020:20200912150028j:plain
音声通話システムの概観

Twilio Voice SDKを利用した音声通話実装の流れ

Twilioを利用してアプリの音声通話を実現するには以下の手順を踏む必要があります。

  1. Twilioアカウントの作成
  2. Twilio Voice SDKの組み込み
  3. API Keyの生成とTwiML/アクセストークン生成ロジックの実装
  4. Twilioに対してPush Credential SIDを登録
  5. 音声情報つなぎこみの実装

上記の手順を順に紹介していきますが、「3. API Keyの生成とTwiML/アクセストークン生成ロジックの実装」につきましてはアプリケーションサーバ側での実装がメインとなり、本記事ではiOSに重きを置いた実装の紹介に留めさせていただきますのでご了承ください。
TwiMLやアクセストークンの生成方法については公式ドキュメント*1やQiita*2に数多くの投稿がありますのでそちらをご参照いただければと思います。

音声通話の実現

Twilioアカウントの作成

Twilio公式サイトのフォームからTwilioアカウントを作成します。
Twilioアカウントを作成すると、ダッシュボードからの管理が可能になります。
ダッシュボードから通話ログを確認することができる他、後述するPush Credential SIDの登録でも利用しますのでアカウント作成は必須事項です。
また、通話機能を確認する程度であれば無料で試すことが可能です。
jp.twilio.com

Twilio Voice SDKの組み込み

スマホアプリでの音声通話を実現するために、TwilioからSDKが提供されています。 github.com iOSではCarthageとCocoaPodsの2つのライブラリ管理マネージャーから導入が可能ですので、各開発環境に応じてTwilio Voice SDKを導入してください。

API Keyの生成とTwiML/アクセストークン生成ロジックの実装

これらについては前述の通り、アプリケーションサーバ側での実装がメインになるため、本記事では割愛させていただきます。

Twilioに対してPush Credential SIDを登録

着信Push通知を受けるために、TwilioサーバにPush Credential SIDを登録する必要がありますのでその方法を紹介します。
前編記事の紹介で作成したVoIP Services Certificate(.cer)が必要になります。
キーチェーンアクセスを起動し、作成したVoIP Services Certificateを右クリックして.p12形式で書き出します。

f:id:obayashi1020:20201017124640p:plain
.p12形式で書き出し
書き出したp12ファイルに対し、下記のコマンドでcert.pemとkey.pemを抽出します。

$ openssl pkcs12 -in PATH_TO_YOUR_P12 -nokeys -out cert.pem -nodes
$ openssl pkcs12 -in PATH_TO_YOUR_P12 -nocerts -out key.pem -nodes
$ openssl rsa -in key.pem -out key.pem

ここで生成したcert.pemとkey.pemのペアを新規Push Credential SIDとしてTwilioに登録します。
Twilioのダッシュボードから「Programmable Voice」→ 「SDKs」 → 「Mobile Push Credentials」 ページに移動します。
「+」ボタンから登録画面を立ち上げ、上記コマンドで抽出したcert.pemとkey.pemの情報を入力します。

  • FRIENDLY NAME: ご自由にどうぞ
  • TYPE: iOSの場合はAPNを指定
  • CERTIFICATE: cert.pemの中身
    • BEGIN CERTIFICATE から END CERTIFICATE までを入力
  • PRIVATE KEY: key.pemの中身
    • BEGIN RSA PRIVATE KEY から END RSA PRIVATE KEY までを入力
  • SANDBOX: ここにチェックを入れると、AppleのSandbox APNsサーバから着信Push通知が送信されるようになります。開発の段階ではここのチェックを入れておくと良さそうです。

f:id:obayashi1020:20201017124323p:plain
Push Credential SID 登録画面

Push Credential SIDの登録が完了すると、iOSで発信リクエストを送った際に、APNsサーバから発信先に対する着信Push通知が送られるようになります。

音声情報つなぎこみの実装

ここまででTwilio SDKを利用するための準備は完了です。
ここからはメインとなる発着信の実装フェーズに入ります。
発信と着信の方法を別個で紹介していきますが、その前に前提として両者共通して行う必要がある「自身の情報をTwilioに登録」から説明します。
また、説明の中でTipsを時折交えながら解説していきます。

自身の情報をTwilioに登録

Twilioの音声通話を利用する前提として、通話に利用するための端末情報をTwilioに登録する必要があります。
登録に必要な情報は「アクセストークン」と「デバイストークン」の2つです。
Twilioに登録するためのメソッドがSDKに用意されているので、それを用いて登録していきます。

import TwilioVoice
︙
TwilioVoice.register(withAccessToken: accessToken, deviceToken: deviceToken) { [weak self] error in    
    if error != nil {
        // 登録失敗時のハンドリング
    }
}

accessTokenはアプリケーションサーバで生成したもの*3deviceTokenはPushKitから返された credentials.token を指定します。
Twilioへの登録タイミングはアプリ起動時やアカウントがあるものに関してはログイン直後等、各アプリでの適切なタイミングで実行してください。

また、同等のインタフェースで unregister メソッドも用意されているので、こちらもログアウト時のようなタイミングで登録解除を実施してください。

TwilioVoice.unregister(withAccessToken: accessToken, deviceToken: deviceToken)

unregister メソッドを実行するとTwilioサーバから情報が削除され、PushKit経由での着信ができなくなります。

発信処理

通話相手への発信方法として Twilio Voice SDKには connect メソッドが用意されています。
この connect メソッドをCallKitが提供する func provider(_ provider: CXProvider, perform action: CXStartCallAction) 内で呼び出し、これらを組み合わせながら実装していきます。

import CallKit
import TwilioVoice
︙
func provider(_ provider: CXProvider, perform action: CXStartCallAction) {
    provider.reportOutgoingCall(with: action.callUUID, startedConnectingAt: Date())

    let connectOptions = TVOConnectOptions(accessToken: accessToken) { builder in
        builder.params = ["To": userID]  // アクセストークンに紐付いた通話相手のID
        builder.uuid = action.callUUID
    }

    let call = TwilioVoice.connect(with: connectOptions, delegate: self)
}

connect メソッドを呼ぶ上で必要な TVOConnectOptions を生成する際、発信先のIDを"To"をkeyとして指定します。
このIDはアクセストークンを生成するロジックの中で紐付けたIDを指定する必要があります。Sansanでは各ユーザのユニークなアカウントIDをベースにして、アクセストークンを生成しています。

[Tips] 発信音の設定

Twilio Voice SDK では発信時の音声を自由にカスタマイズすることができます。 connect メソッドを実行すると TVOCallDelegate が提供する func callDidStartRinging(_ call: TVOCall) が呼ばれるので、そこに音声再生処理を書いていきます。

// connect メソッド実行時に呼ばれる
func callDidStartRinging(_ call: TVOCall) {
    do {
        audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: "流したい発信音声ファイルパス"))
        audioPlayer?.delegate = delegate
        audioPlayer?.numberOfLoops = -1
        audioPlayer?.volume = 1.0
        audioPlayer?.play()
    } catch {}
}

発信先が着信に応答すると func callDidConnect(_ call: TVOCall) が呼ばれるので、音声中断処理を実装します。

func callDidConnect(_ call: TVOCall) {
    audioPlayer.stop()
}

ここで一点注意点があります。発信音をアプリで指定するにはTwilioアカウントに課金を行った状態である必要があります。 *4
実装面の話に置き換えると、無課金アカウントでは connect メソッド実行時に callDidStartRinging(_ call: TVOCall) はスキップされ callDidConnect(_ call: TVOCall) のみが呼ばれる仕様のようです。 したがって発信音声をカスタマイズする実装をするには、わずかではありますが課金の必要性が出てきます。

着信処理

続いて着信処理を実装していきます。
発信側から connect メソッドが呼ばれると、APNsサーバ経由で端末にPush通知が届きます。
着信Push通知を受信すると、PushKitのfunc didReceiveIncomingPush が呼ばれるのでそこを起点にTwilio Voice SDKに対してその旨を通知します。

import PushKit
import TwilioVoice
︙
func didReceiveIncomingPush(_ payload: PKPushPayload) {
    // Twilio Voice SDKに着信が届いたことを通知する
    TwilioVoice.handleNotification(payload.dictionaryPayload, delegate: self, delegateQueue: nil)
}

Twilio Voice SDKにPush通知の受信が通知されると、TVONotificationDelegatefunc callInviteReceivedが呼ばれます。 このタイミングでやるべきは前編でも紹介した着信UIの表示です。

func callInviteReceived(_ callInvite: TVOCallInvite) {
    let callHandle = CXHandle(type: .generic, value: from)
    let callUpdate = CXCallUpdate()

    // callUpdateのパラメータ設定

    // CallKitの着信UI表示
    provider.reportNewIncomingCall(with: callInvite.uuid, update: callUpdate) { _ in }
}

表示された着信UIに対してユーザが応答すると、CallKitのfunc provider(_ provider: CXProvider, perform action: CXAnswerCallAction) が呼ばれます。
この応答のタイミングで、Twilio Voice SDKが提供する accept メソッドを呼び出します。

func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
    // callInviteは callInviteReceived から渡ってきたcallInviteオブジェクトを適用
    let acceptOptions = TVOAcceptOptions(callInvite: callInvite) { builder in
        builder.uuid = callInvite.uuid
    }

    let call = callInvite.accept(with: acceptOptions, delegate: self)
    action.fulfill()
}

acceptメソッドを実行すると発信者と着信者での疎通が成功し、音声通話が可能になります。

終わりに

本稿では前後編の2記事に渡って、Sansan音声通話機能実現に向けて採用したCallKit/PushKit、そしてTwilio Voice SDKの使い方について紹介させていただきました。
今回はあくまで通話に至るまでの基本的な部分のみの紹介でしたが、実用的なレベルまで完成度を引き上げるには考慮しなければならないことがまだ数多くあります。
「割り込み通話はどうするのか」「二人が同時に発信し合った場合はどうなるのか」等、普段私達が何気なく使っているIP電話ですが、通話体験を構造化して掘り下げるとそれはかなり深いもので、通話の品質や体験を高いレベルで提供することの難易度の高さを痛感しました。
Sansanの音声通話機能はまだファーストリリースの段階で、未完成な部分も多くあるのが現状です。しかし、今後もプロダクトの進化と共に通話機能の品質/体験向上を見据えていますので、そのレベルがもう一段アップしましたら、また紹介できればと思っています。


buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

buildersbox.corp-sansan.com

*1:公式ドキュメント: TwiML for Programmable Voice - Twilio

*2:Qiitaの参考記事: Search result of “Twilio” - Qiita

*3:以降もaccessTokenが頻出するが、何れもアプリケーションサーバで生成したもの

*4:Twilio音声通話は従量課金制となり、チャージ金額が通話時間分差し引かれる

© Sansan, Inc.