agora.ioで人狼アプリにボイスチャット機能を実装してみた

チャットで人狼をするアプリを2013年から運用しています。
Clubhouseが利用していることで話題になったagora.ioのSDKを使って、ボイスチャットで人狼ができたらおもしろいんじゃないかと思い、実装することにしました。

# やりたいこと
人狼アプリという性質上、次のような実装を目指します。

* 昼間の時間は生存している人のみが発言できる
* 死亡した人は生存者の会話は聞けるが発言はできない
* 死亡した人は死亡した人同士でいつでも会話できる
* 人狼が複数いる場合、夜の時間は人狼同士で会話できる
* ゲームに参加していない人は、生存している人の会話を観覧(?)できる

# agora.ioについて
agora.ioは手軽にビデオチャットやボイスチャットができるようになるサービスです。
サービスの登録方法、SDKの導入方法につきましては割愛します。
今回はiOSアプリに導入してみました。

https://www.agora.io/en/


# マルチチャンネル接続する
agoraについての記事はだいたいシングルチャンネル接続について解説しているようです。
今回の要件ではマルチチャンネル接続をしたかったのですが、あまりドキュメントがなかったので解説していきます。

こちらは公式ドキュメントのビデオのほうでマルチチャンネル接続する方法です。
ボイスの場合も基本的には同じです。

https://docs.agora.io/en/Video/multiple_channel_apple?platform=iOS

注意点としては、`ChannelProfile`を `.liveBroadcasting`にしないといけません。
初期化コードは次のようにしました。

```swift
self.agoraKit = AgoraRtcEngineKit.sharedEngine(withAppId: agoraAppId, delegate: self)
self.agoraKit?.disableVideo()
self.agoraKit?.setAudioProfile(self.audioProfile, scenario: self.audioScenario)
self.agoraKit?.setChannelProfile(.liveBroadcasting)
self.agoraKit?.enableAudioVolumeIndication(200, smooth: 3, report_vad: true)
```

## 生存者のチャンネルにジョインする
生存者のチャンネルは、昼の間は生存者は`broadcaster`、それ以外の人は`audience`としてジョインします。
マルチチャンネルの場合、誰かがすでに作成したチャンネルにジョインするのではなく、すべての人が自分でチャンネルを作成してそこにジョインします。
チャンネル名が同じ場合は同じチャンネルとして扱われますので、チャンネルがたくさんできてしまうというわけではありません。

生存者のチャンネルにジョインするコードは以下のようになりました。

```swift
private func doJoinAliveChannel(channelId: String, playerId: String, isAudience: Bool, token: String) {
    guard let agoraKit = self.agoraKit else {
        return
    }

    if let channel = agoraKit.createRtcChannel(channelId) {
        channel.setRtcChannelDelegate(self)
        if isAudience {
            channel.setClientRole(.audience)
            print("Joining Alive channel as audience.")
        } else {
            channel.setClientRole(.broadcaster)
            print("Joining Alive channel as broadcaster.")
        }
        channel.publish()

        let mediaOptions = AgoraRtcChannelMediaOptions()
        mediaOptions.autoSubscribeAudio = true

        let result = channel.join(byUserAccount: playerId, token: token, options: mediaOptions)
        if result != 0 {
            print("Error joining channel: \(channelId) \(result)")
            return
        }
        self.aliveChannel = channel
    }
}
```

死者の場合は同じように死者のチャンネルに`broadcaster`としてジョインします。
マルチチャンネルなので同時にジョインできます。

夜の時間になったら、一旦生存者チャンネルをクローズして、人狼の人のみを人狼チャンネルに接続させます。

## delegateを設定する
こちらが今回一番苦労した部分です。
公式ドキュメントはだいたいシングルチャンネルについて書かれているので、試行錯誤しました。

delegateは、agoraの動作、およびシングルチャンネルに関するdelegateの`AgoraRtcEngineDelegate`と、マルチチャンネルの各チャンネルに対するメッセージを受け取る `AgoraRtcChannelDelegate`に分かれています。

それぞれの役割について解説します。

### AgoraRtcEngineDelegate
マルチチャンネルの場合、使用するのは以下の３つです

```swift
// agoraエンジンでWarningがあった場合
func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurWarning warningCode: AgoraWarningCode) {
    print("warning: \(warningCode.rawValue)")
}
```

```swift
// agoraエンジンでerrorがあった場合
func rtcEngine(_ engine: AgoraRtcEngineKit, didOccurError errorCode: AgoraErrorCode) {
    print("error: \(errorCode.rawValue)")
}
```

大体の場合、UIで誰が発言したかのインジケーターを表示したいと思いますが、その場合はこちらを実装します。
agoraの初期化の際に `enableAudioVolumeIndication` を設定しておいてください。

```swift
func rtcEngine(_ engine: AgoraRtcEngineKit, reportAudioVolumeIndicationOfSpeakers speakers: [AgoraRtcAudioVolumeInfo], totalVolume: Int) {
    for speaker in speakers {
        // speaker.uid の人が speaker.volume の音量で発言した旨の表示をここで行う
    }
}
```


### AgoraRtcChannelDelegate
マルチチャンネルの場合、ほとんどのメッセージはこちらのdelegateを使います。

#### Local user (自分) に関するメッセージ

```swift
// Local user(自分)がチャンネルにジョインした
func rtcChannelDidJoin(_ rtcChannel: AgoraRtcChannel, withUid uid: UInt, elapsed: Int) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("Joined: \(channelId)  \(uid)")
}
```

```swift
// Local user(自分)が再びチャンネルにジョインした
func rtcChannelDidRejoin(_ rtcChannel: AgoraRtcChannel, withUid uid: UInt, elapsed: Int) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("ReJoined: \(channelId)  \(uid)")
}
```

```swift
// Local user(自分)がチャンネルから離れた (呼ばれない？)
func rtcChannelDidLeave(_ rtcChannel: AgoraRtcChannel, with stats: AgoraChannelStats) {
    print("DidLeave: \(rtcChannel.getId() ?? "")  \(stats)")
}
```

```swift
// Local user(自分)がネットワークなどの原因で切断された
func rtcChannelDidLost(_ rtcChannel: AgoraRtcChannel) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("DidLost: \(channelId)")
}
```

#### Remote user (他人) に関するメッセージ

```swift
// Remote userにwarningが起きた
func rtcChannel(_ rtcChannel: AgoraRtcChannel, didOccurWarning warningCode: AgoraWarningCode) {
    print("channel: \(rtcChannel.getId() ?? ""), warning: \(warningCode.rawValue)")
}

// Remote userにerrorが起きた
func rtcChannel(_ rtcChannel: AgoraRtcChannel, didOccurError errorCode: AgoraErrorCode) {
    print("channel: \(rtcChannel.getId() ?? ""), Error: \(errorCode.rawValue)")
}
```

`broadcaster`の人がジョインしたときはこちらが呼ばれます。 `audience` の人がジョインした時にはメッセージは来ないようなので、agoraのReal-time Messaging SDKなどを利用して自前でやる必要があります。

```swift
// broadcaster の人がJoinした
func rtcChannel(_ rtcChannel: AgoraRtcChannel, didJoinedOfUid uid: UInt, elapsed: Int) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("didJoinedOfUid: \(channelId) \(uid)")

    var error: AgoraErrorCode = .noError
    if let info = agoraKit?.getUserInfo(byUid: uid, withError: &error) {
        if error == .noError, let userAccount = info.userAccount {
            // joinした時の処理をここで行う
        }
    }
}
```

```swift
// remote userの人がオフラインになった
func rtcChannel(_ rtcChannel: AgoraRtcChannel, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("didOfflineOfUid: \(channelId) \(uid) reason \(reason.rawValue)")

    var error: AgoraErrorCode = .noError
    if let info = agoraKit?.getUserInfo(byUid: uid, withError: &error) {
        if error == .noError, let userAccount = info.userAccount {
            //オフラインになったときの処理をここで行う
        }
    }
}
```

Remote userの人がマイクをミュートしたり解除したりした場合に呼ばれます。
Joinedが呼ばれずにいきなりこちらが呼ばれる場合もあるので、そちらも想定して実装しましょう。

```swift
func rtcChannel( _ rtcChannel: AgoraRtcChannel, remoteAudioStateChangedOfUid uid: UInt,
                     state: AgoraAudioRemoteState, reason: AgoraAudioRemoteStateReason, elapsed: Int
    ) {
    guard let channelId = rtcChannel.getId() else {
        return
    }
    print("remoteAudioStateChangedOfUid: \(channelId) \(uid) reason \(reason.rawValue)")

    if state == .stopped || state == .starting {
        let isMute = (state == .stopped)
        // MuteのUI表示などの処理をここで行う。
        // Joinedが呼ばれなかった人の処理もここで。
    }
}
```

## 終了時の処理

チャンネルを終了する時の処理です

```swift
self.aliveChannel?.leave()
self.aliveChannel?.unpublish()
self.aliveChannel?.destroy()
```
<br>

# 人狼アプリについて
今回実装したアプリはこちらです。
要望が多ければAndroid版やWeb版にも実装していきたいです。

http://www.werewolfonline.net/home/