GME VoiceChat と MLAPIではじめるネットワークゲーム

# 動機
アプリケーションに、VoiceChat機能の追加を検討するときに、世の中には様々なサービスが存在します。
ネットワーク通信機能を持つサービスの中には、ボイスチャット機能を保有しているものもありますが、既に、ネットワーク通信機能を持つアプリケーションを持っており、ボイスチャット機能を追加で、欲しい場合があります。
今回、マルチプレイの開発として、MLAPI「Netcode for GameObjects」を使い、ボイスチャット機能として、TencentCloudの GME「Game Multimedia Engine」をアプリケーションに組み込む方法について記載します。
ボイスチャット機能を利用できるまでには、いくつかハードルがあり、ご参考ください。

# できること
舞台は、迷路！、ステージ上には、至る所にコインが配置されています。
あなたは、アバターとして、ユニティちゃんを操作します。
複数のユーザーが、同時にネットワーク接続し、ボイスチャットしながら、わいわい遊べるアプリケーションとなっています。
迷路は自動生成で、日毎に変わるようにしています。

<iframe width="560" height="315" src="https://www.youtube.com/embed/jO5EM-g4sTU" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>

【操作】
・十字キーの操作で、アバターが動きます。
・数値キー（１～５）の押下で、アバターがリアクションします。
・ボイスチャット（マイクに話しかけると、相手に伝わります）

# アプリケーションの全体構成
ネットワークは、ローカルネットまたは、Unity Relayを使用することで、インターネット越しで楽しめます。
![システム構成.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/fa42458a-7cd6-79e6-641c-8f6116efa2e4.png)

# <a id="dev"/>開発環境

| サービス | 種類 | 備考 |
| ---- | ---- | ---- |
| TencentCloud | Voice Chat | [GME Voice Chat 2022-04-12](https://dldir1v6.qq.com/hudongzhibo/QCloud_TGP/GME/GME2.9.1/Other/GME_Unity_Audio_SDK_2.9.1.5a26265a.zip) |
| Unity | UNITY 2020.3.42f1 | Windowsスタンドアロンアプリケーション |
| Unity Asset | ゲームに使用 | [Maze Generator](https://assetstore.unity.com/packages/tools/modeling/maze-generator-38689) |
| MLAPI Sample | ゲームに使用 | [MLAPI_UnitychanSample](https://github.com/wotakuro/NetcodeForGameObjects_UnitychanSample) |

# サンプルコード
完成サンプルプロジェクトコードをのせておきます。
[Github](https://github.com/TKTK360/UnityNetVoiceChatApp)

以降、開発の大まかな流れを記載しています。

# TencentCloud とは
Tencent（テンセント：本社：中国 広東省深圳市）が提供しているパブリッククラウドサービスプロバイダーです。
コンピューティング、ストレージ、データーベース、セキュリティー、CDN、ネットワークの高速化、ゲーム内マルチメディア、セキュリティーサポートなどの製品を提供しています。
さらに、Tencent Cloudはライブブロードキャスト・プラットフォームでライブビデオ放送（LVB）機能など様々なソリューションを提供しています。

# GME「Game Multimedia Engine」とは
[製品概要](https://www.tencentcloud.com/jp/document/product/607/10835)
TencentCloudのサービスの１つで、ワンストップの音声ソリューションを提供します。
ゲームシーンに関して、リアルタイム音声、音声メッセージ、ボイスツーテキスト変換などのサービスを提供し、FPS、MOBA、MMORPG、大逃殺、チェスカード、オンラインテーブルゲームなど、さまざまなゲームプレイタイプに適用され、モバイルゲーム、コンシューマーゲーム、およびブラウザーゲームのプラットフォーム間の相互運用をサポートします。

■機能
　リアルタイム音声、音声メッセージ、ボイスツーテキスト変換、
　リアルタイムのインタラクティブなバーチャルヒューマン
　音声タグ、音声のレコーディング

■サポートするプラットフォーム
　OS、Android、Windows、macOS、Web、Unity、Unreal、Cocosなど

# TencentCloud のアカウントの作成
アカウントの作成は、他社と比べて、情報セキュリティが高めです。
[アカウント開設の手順はこちら](https://marketing-material-1301344332.cos.ap-seoul.myqcloud.com/TencentCloud%E3%82%A2%E3%82%AB%E3%82%A6%E3%83%B3%E3%83%88%E7%99%BB%E9%8C%B2%E6%89%8B%E9%A0%86.pdf)を参考に進めてください。注意点は、２つあります。

注意１．身分証明（免許書など）の設定が必要となります。
　　　　画像をアップロード後、2日程度で承認してもらえました。
注意２．支払い情報（クレジットカード）の設定が必要となります。

※ Account Centerの「Verification Status」が「Verified」になっていることを確認してください。

![tencentcloud.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/a9f3a22e-f6c9-1c50-2440-3fcd5d0ef9f0.jpeg)

# Game Multimedia Engine の設定
[Game Multimedia Engine(GME)](https://www.tencentcloud.com/jp/products/gme) のサイトを開き、「今すぐ使用する」を選択してください。
![gme_dashboard00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/9eb2d6d2-e7be-bc43-c2c6-0c32460746df.png)

表示文言は、Englishにしています。
左ツリーの「ServiceManagement」を選択し、「Create application」を選択して、アプリケーションの情報を設定してください。
![gme_dashboard01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/01b2b4d9-2f53-25d8-bed7-e0caa3b268bc.png)

設定内容は、Operationの「Set」を選択することで編集ができます。
ボイスチャットをする上で、必ず「Real-time Voice Service」を「Enable」にしてください。
> Billed by voice duration: free of charge if the monthly duration is below 10,000 minutes

月間再生時間が 10,000 分未満の場合は無料とのことです。

Authentication infoの「AppID」、「Permission key」を使用するので、メモしてください。
![gme_dashboard02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/e8f23d02-52fe-46e9-8e2a-7624fac17fdb.png)


# VoiceChatServiceのサンプルコードの理解
ボイスチャットを動かすまでには、正直なところ、簡単にはいきませんでした。
公式サイトに、Unityでの使い方といて、 [Quick Integration of SDK for Unity](https://www.tencentcloud.com/jp/document/product/607/44544) があるのですが、
これだけを見て、実装できる方は、いないでしょう。
[Unityのサンプルアプリケーション](https://dldir1v6.qq.com/hudongzhibo/QCloud_TGP/GME/GME2.9.1/Other/GME_Unity_Audio_Demo_2.9.1.5a26265a.zip) があるので、そちらの熟知が必要となります。中国語表示なのと、直感的にどこに何を設定したら動くのかが、分からず、難儀しました。
Quick Integration of SDK for Unity内の認証のQAVAuthBuffer.GenAuthBufferの使い方を理解するために、Unityのサンプルアプリケーションの実装方法を調べてみたのですが、下記（UserConfig.cs）で、関数の引数の順番と、実際に渡す引数の順番が異なっています。
値の理解について、混乱しましたが、ボイスチャットが動いた時の達成感が楽しめました。
``` 公式サンプルアプリケーション UserConfig.cs 抜粋
	public static byte[] GetAuthBuffer(string sdkAppID, string userID, string roomID,string authKey)
	{
        string key = "";
        key = authKey;
        return QAVAuthBuffer.GenAuthBuffer(int.Parse(sdkAppID), roomID, userID, key);
	}
``` 

ボイスチャットを正常に動かすためには、値の意味で、sdkAppId, openID, appId, roomId, keyが、それぞれ何を意味しているかが重要となります。整理したものが、下記となります。
| 値 | 該当変数 | 該当変数 |
| ---- | ---- | ---- |
| GME の AppID | sdkAppId | appId |
| 自身を識別するUNIQUEコード（INT64） | openID | |
| 部屋値(127character) | roomId | |
| GME の APermission key | key | |

※Tencent cloud のソースコードは、sdkAppIDとappIdの変数名を揃えていないのには、改善余地ありです。
　appIdを見て、直感的には、Account CenterのAppIdを指定すると思うのではないでしょうか。

# VoiceChatServiceの流れ
気を取り直して、私と同じ轍を踏まないよう、フローと、ボイスチャットの最短コードを作りました。
先人の知恵として、参考にしてください。

[Quick Integration of SDK for Unity](https://www.tencentcloud.com/jp/document/product/607/44544)
API Call Flow Chartを参考に、プログラムを作成した場合のフローは下記のイメージとなります。
![gme_Voiceシーケンス図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/981cc22c-fefb-d530-c499-c977359a2d3a.png)

# VoiceChatServiceの最短サンプルコードつくりました

私が作った Github のUnity Project内の GmeVoiceChatTestSceneシーンを開いてください。
![GmeVoiceSample.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/76959/a809d560-9c0c-d58c-2d59-e9259c07de99.jpeg)

使い方
・IDに自身の認識する番号（INT64）を指定してください
・STARTボタンを押下で、GmeInitClick()を呼び出し、開始します。
・STOPボタンを押下で、GmeCloseClick()を呼び出し、終了します。
・CLEARボタンを押下で、ログの表示を消去します。

Unityのスクリプトファイルで重要なところを説明します。
Game Multimedia Engine の設定で作った値を使います。
GmeVoiceChatScript.cs の「sdkAppId」に、AppIDを、「authkey」に、Permission key を設定してください。
``` GmeVoiceChatScript.cs抜粋
    string sdkAppId = "XXXXXXXXXX";         // Tencent Account [GME AppID]
    string authkey = "XXXXXXXXXXXXXXXX";    // Tencent Account [GME Permission key]
```

### ボイスチャットの音声の保存について、
保存先のファイルパスを渡すことで、音声の保存ができます。
``` GmeVoiceChatScript.cs抜粋 
string recordPath = Application.persistentDataPath + string.Format("/{0}.silk", sUid++);
ret = instance.GetPttCtrl().StartRecordingWithStreamingRecognition(recordPath, speechLanguage, speechLanguage);
``` 

### ボイスチャットの状態について、
EndpointsUpdateInfo　の status で、ユーザーの入退室、ボイスチャットの喋り始め、終わりを判定できます。
```  GmeVoiceChatScript.cs抜粋
public void OnEndpointsUpdateInfo(int eventID, int count, string[] openIdList)
{
    // eventID
    const int ITMG_EVENT_ID_USER_ENTER     = 1; // 入室
    const int ITMG_EVENT_ID_USER_EXIT      = 2; // 退室
    const int ITMG_EVENT_ID_USER_HAS_AUDIO = 5; // ボイスチャットの喋り始め
    const int ITMG_EVENT_ID_USER_NO_AUDIO  = 6; // ボイスチャットの喋り終わり
　　・・・
``` 

### ボイスチャットの音声認識について、
OnStreamingRecisRunning が開始で、OnStreamingRecComplete が終了の判定ができます。
``` GmeVoiceChatScript.cs抜粋
    void OnStreamingRecComplete(int code, string fileid, string filePath, string result) {
　　・・・

    void OnStreamingRecisRunning(int code, string fileid, string filePath, string result) {
　　・・・
``` 

以下に、スクリプトファイルの全体を掲載します。

``` GmeVoiceChatScript.cs
using UnityEngine;
using UnityEngine.UI;
using GME;
using System;

/// <summary>
/// GmeVoiceChatScript
/// </summary>
public class GmeVoiceChatScript : MonoBehaviour
{
    public InputField userInputField;
    public Text logText;
    
    protected string _log;
    protected int _logCnt = 0;
    protected int _maxCnt = 20;

    public string roomId = "dev_room";
    public string openID = "20221225";      // INT64 

    string sdkAppId = "XXXXXXXXXX";         // Tencent Account [GME AppID]
    string authkey = "XXXXXXXXXXXXXXXX";    // Tencent Account [GME Permission key]

    static int sUid = 0;

    ITMGRoomType _roomType = ITMGRoomType.ITMG_ROOM_TYPE_FLUENCY;
    string _speechLanguage = "ja-JP";

#region << Property >>
    public bool IsInit {
        get;
        private set;
    }

    public bool IsRoom {
        get;
        private set;
    }

    public bool IsRecord {
        get;
        private set;
    }

    public bool IsInitSpeak {
        get;
        private set;
    }

    public bool IsSpeak {
        get;
        private set;
    }
#endregion << Property >>

    /// <summary>
    /// Update
    /// </summary>
    void Update() {
        if (this.IsInit) {
            ITMGContext.GetInstance().Poll();
        }
    }

    /// <summary>
    /// OnDestroy
    /// </summary>
    private void OnDestroy() {
        GmeClose(false);
    }

    /// <summary>
    /// GmeCloseClick
    /// </summary>
    public void GmeCloseClick() {
        GmeClose();
    }

    /// <summary>
    /// GmeInitClick
    /// </summary>
    public void GmeInitClick() {
        if (userInputField == null) {
            return;
        }
        var user = userInputField.text;
        if (string.IsNullOrEmpty(user)) {
            return;
        }
        GmeOpen(user);
    }

    /// <summary>
    /// LogClearClick
    /// </summary>
    public void LogClearClick(){
        try {
            _log = string.Empty;
            if (logText != null)
                logText.text = string.Empty;
        } catch { }
    }

    /// <summary>
    /// AddLogData
    /// </summary>
    /// <param name="msg"></param>
    /// <param name="isWarning"></param>
    public void AddLogData(string msg, bool isWarning = false) {
        try {
            if (isWarning) {
                Debug.LogWarning(msg);
            } else {
                Debug.Log(msg);
            }

            _logCnt++;

            if (_logCnt <= 1) {
                _log = msg;
            } else if (_logCnt > _maxCnt) {
                _logCnt = 0;
                _log = msg;
            } else {
                _log += "\r\n" + msg;
            }

            if (logText != null)
                logText.text = _log;
        } catch { }
    }

    /// <summary>
    /// GmeOpen
    /// </summary>
    public void GmeOpen() {
        var date = DateTime.Now;
        this.openID = string.Format("{0:D2}{1:D2}{2:D2}{3:D2}{4:D3}", date.Month, date.Day, date.Hour, date.Minute, date.Millisecond);
        GmeOpen(openID, _roomType, _speechLanguage);
    }

    /// <summary>
    /// GmeOpen
    /// </summary>
    /// <param name="id"></param>
    public void GmeOpen(string id) {
        this.openID = id;
        GmeOpen(openID, _roomType, _speechLanguage);
    }

    /// <summary>
    /// GmeOpen
    /// </summary>
    /// <param name="sRoomID"></param>
    /// <param name="roomType"></param>
    /// <param name="speechLanguage"></param>
    protected void GmeOpen(string sOpenID, ITMGRoomType roomType, string speechLanguage) {
        var instance = ITMGContext.GetInstance();
        if (instance == null) {
            return;
        }

        AddLogData("Init");
        int ret = instance.Init(sdkAppId, sOpenID);
        if (ret != QAVError.OK) {
            AddLogData("SDK initialization failed:" + ret, true);
            return;
        }

        LoginFunction();

        this.IsInit = true;

        byte[] authBuffer = GetAuthBuffer(sdkAppId, roomId, sOpenID, authkey);

        AddLogData("EnterRoom");
        ret = instance.EnterRoom(roomId, roomType, authBuffer);
        if (ret != QAVError.OK) {
            AddLogData("EnterRoom failed:" + ret, true);
            return;
        }

        this.IsRoom = true;

        AddLogData("ApplyPTTAuthbuffer");
        instance.GetPttCtrl().ApplyPTTAuthbuffer(authBuffer);

        AddLogData("StartRecordingWithStreamingRecognition");
        string recordPath = Application.persistentDataPath + string.Format("/{0}.silk", sUid++);
        ret = instance.GetPttCtrl().StartRecordingWithStreamingRecognition(recordPath, speechLanguage, speechLanguage);
        if (ret != 0) {
            AddLogData("StartRecordingWithStreamingRecognition failed:" + ret, true);
            return;
        }
        this.IsRecord = true;
        EnterJoinFunction();
    }

    /// <summary>
    /// GmeClose
    /// </summary>
    /// <param name="isLog"></param>
    public void GmeClose(bool isLog = true) {
        var instance = ITMGContext.GetInstance();
        if (instance == null) {
            return;
        }

        if (this.IsRecord) {
            this.IsRecord = false;
            if (isLog)
                AddLogData("StopRecording");
            instance.GetPttCtrl().StopRecording();
        }

        if (this.IsRoom) {
            this.IsRoom = false;
            if (isLog)
                AddLogData("ExitRoom");
            instance.ExitRoom();
        }

        if (this.IsInit) {
            this.IsInit = false;
            if (isLog)
                AddLogData("Uninit");
            LeaveJoinFunction();
            LeaveFunction();

            instance.Uninit();
        }
    }

    /// <summary>
    /// LoginFunction
    /// </summary>
    private void LoginFunction() {
        var instance = ITMGContext.GetInstance();
        if (instance != null) {
            instance.OnEnterRoomCompleteEvent += new QAVEnterRoomComplete(OnEnterRoomComplete);
            instance.OnExitRoomCompleteEvent += new QAVExitRoomComplete(OnExitRoomComplete);
            instance.OnRoomDisconnectEvent += new QAVRoomDisconnect(OnRoomDisconnect);
            instance.OnEndpointsUpdateInfoEvent += new QAVEndpointsUpdateInfo(OnEndpointsUpdateInfo);
        }
    }

    /// <summary>
    /// LeaveFunction
    /// </summary>
    private void LeaveFunction() {
        var instance = ITMGContext.GetInstance();
        if (instance != null) {
            instance.OnEnterRoomCompleteEvent -= new QAVEnterRoomComplete(OnEnterRoomComplete);
            instance.OnExitRoomCompleteEvent -= new QAVExitRoomComplete(OnExitRoomComplete);
            instance.OnRoomDisconnectEvent -= new QAVRoomDisconnect(OnRoomDisconnect);
            instance.OnEndpointsUpdateInfoEvent -= new QAVEndpointsUpdateInfo(OnEndpointsUpdateInfo);
        }
    }

    /// <summary>
    /// EnterJoinFunction
    /// </summary>
    private void EnterJoinFunction() {
        var ctl = ITMGContext.GetInstance().GetPttCtrl();
        if (ctl != null) {
            ctl.OnStreamingSpeechComplete += new QAVStreamingRecognitionCallback(OnStreamingRecComplete);
            ctl.OnStreamingSpeechisRunning += new QAVStreamingRecognitionCallback(OnStreamingRecisRunning);
        }
    }

    /// <summary>
    /// LeaveJoinFunction
    /// </summary>
    private void LeaveJoinFunction() {
        var ctl = ITMGContext.GetInstance().GetPttCtrl();
        if (ctl != null) {
            ctl.OnStreamingSpeechComplete += new QAVStreamingRecognitionCallback(OnStreamingRecComplete);
            ctl.OnStreamingSpeechisRunning += new QAVStreamingRecognitionCallback(OnStreamingRecisRunning);
        }
    }

    /// <summary>
    /// OnEnterRoomComplete
    /// </summary>
    /// <param name="result"></param>
    /// <param name="error_info"></param>
    public void OnEnterRoomComplete(int result, string error_info) {
        AddLogData(string.Format("OnEnterRoomComplete = {0}", result));
        if (result != 0) {
            return;
        }

        var audioCtl = ITMGContext.GetInstance().GetAudioCtrl();
        if (audioCtl != null) {
            var ret = audioCtl.EnableMic(true);
            AddLogData(string.Format("EnableMic = {0}", ret));

            var ret2 = audioCtl.EnableSpeaker(true);
            AddLogData(string.Format("EnableSpeaker = {0}", ret2));

            if (ret == 0 && ret2 == 0) {
                this.IsInitSpeak = true;
            }
        }
    }

    /// <summary>
    /// OnExitRoomComplete
    /// </summary>
    public void OnExitRoomComplete() {
        AddLogData("OnExitRoomComplete");

        var audioCtl = ITMGContext.GetInstance().GetAudioCtrl();
        if (audioCtl != null) {
            var ret = audioCtl.EnableMic(false);
            AddLogData(string.Format("DisableMic = {0}", ret));

            ret = audioCtl.EnableSpeaker(false);
            AddLogData(string.Format("DisableSpeaker = {0}", ret));

            this.IsInitSpeak = false;
        }
    }

    public void OnRoomDisconnect(int result, string error_info) {
        AddLogData(string.Format("OnRoomDisconnect = {0}", result));
    }

    /// <summary>
    /// OnEndpointsUpdateInfo
    /// </summary>
    /// <param name="eventID"></param>
    /// <param name="count"></param>
    /// <param name="openIdList"></param>
    public void OnEndpointsUpdateInfo(int eventID, int count, string[] openIdList) {
        const int ITMG_EVENT_ID_USER_ENTER = 1;
        const int ITMG_EVENT_ID_USER_EXIT = 2;
        const int ITMG_EVENT_ID_USER_HAS_AUDIO = 5;
        const int ITMG_EVENT_ID_USER_NO_AUDIO = 6;

        string strEvent = "unknown";
        switch(eventID)
        {
            case ITMG_EVENT_ID_USER_ENTER:
                strEvent = "USER_ENTER";
                break;
            case ITMG_EVENT_ID_USER_EXIT:
                strEvent = "USER_EXIT";
                break;
            case ITMG_EVENT_ID_USER_HAS_AUDIO:
                strEvent = "USER_HAS_AUDIO";
                this.IsSpeak = true;
                break;
            case ITMG_EVENT_ID_USER_NO_AUDIO:
                strEvent = "USER_NO_AUDIO";
                this.IsSpeak = false;
                break;
        }
        AddLogData(string.Format("OnEndpointsUpdateInfo : eventID = {0}", strEvent));
    }

    public byte[] GetAuthBuffer(string sdkAppID, string roomID, string userID, string authKey) {
        return QAVAuthBuffer.GenAuthBuffer(int.Parse(sdkAppID), roomID, userID, authKey);
    }

    void OnStreamingRecComplete(int code, string fileid, string filePath, string result) {
        AddLogData(string.Format("OnStreamingRecComplete = {0}, {1}", code, fileid));
    }

    void OnStreamingRecisRunning(int code, string fileid, string filePath, string result) {
        AddLogData(string.Format("OnStreamingRecisRunning = {0}, {1}", code, result));
    }
}
```

以下から、迷路のアプリケーション開発についての情報となります。
※詳細は、完成コードを参照ください。

# ネットワーク迷路ゲームの作成
ネットワーク通信の土台として、MLAPI Sample を利用させていただきました。
また、迷路の自動生成については、UnityAssetのMaze Generatorを利用させていただきました。
各々の詳細については、[開発環境のリンク](#dev)を参照ください。

開発の流れは下記となります。
１．MLAPI SampleのUnityプロジェクトを開き、UnityAssetのMaze Generatorをインポートします。
　　Mazeの自動生成は、マジックナンバーとして乱数を使用しています。
　　その値を日付に置き換えることで、ユーザー間の差異が無いようにしています。
``` MazeSpawner.cs
		if (!FullRandom) {
			var now = DateTime.Now;
            RandomSeed = now.Year + now.Month + now.Day;

            UnityEngine.Random.InitState(RandomSeed);
		}
```
２．Mazeのサンプルは、操作する自機がボールのため、ユニティちゃんに置き換えます。
３．Maze用の当たり判定処理を、ユニティちゃんのスクリプトに追加します。
４．MLAPI SampleのステージにMazeの位置を調整し、完成です。

# ボイスチャットのゲームへの導入
 ネットワーク迷路ゲームの作成で作ったUnityプロジェクトに、VoiceChatServiceの最短サンプルコードを埋め込みます。

開発の流れは下記となります。
１．ボイスチャットの設定
Game Multimedia Engine の設定で作った値を使います。
GmeVoiceChatScript.cs の「sdkAppId」に、AppIDを、「authkey」に、Permission key を設定してください。
``` GmeVoiceChatScript.cs
    string sdkAppId = "XXXXXXXXXX";         // Tencent Account [GME AppID]
    string authkey = "XXXXXXXXXXXXXXXX";    // Tencent Account [GME Permission key]
```
２．ネットワーク入室時に、GMEのボイスチャットを開始します。
　　自身を識別するUNIQUEコードである「openID」は、時間をミリ秒まで指定することで、
　　同一ユーザにならないようにしています。
　　GUID値を生成して、設定させるのも良いかもしれません。
``` GmeVoiceChatScript.cs
    public void GmeOpen() {
        var date = DateTime.Now;
        this.openID = string.Format("{0:D2}{1:D2}{2:D2}{3:D2}{4:D3}", date.Month, date.Day, date.Hour, date.Minute, date.Millisecond);
        GmeOpen(openID, _roomType, _speechLanguage);
    }
```
３．ネットワーク退出時に、GMEのボイスチャットを終了します。
``` GmeVoiceChatScript.cs
    public void GmeClose(bool isLog = true) {
        ・・・
    }
```


# おわりに
いかがでしたでしょうか。
ボイスチャット機能を、導入するイメージを持っていただけたのではないでしょうか。
GMEのボイスチャットの品質は良く、多機能なのが良かったです。
しかし、初心者は、公式の情報だけだと、導入までに挫折する方がでるだろうと思います。
本記事が、皆様の快適なボイスチャットライフに繋がれば幸いです。

よりよいご意見有りましたらお待ちしております。