LoginSignup
1
0

More than 1 year has passed since last update.

動機

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

できること

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

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

アプリケーションの全体構成

ネットワークは、ローカルネットまたは、Unity Relayを使用することで、インターネット越しで楽しめます。
システム構成.png

開発環境

サービス 種類 備考
TencentCloud Voice Chat GME Voice Chat 2022-04-12
Unity UNITY 2020.3.42f1 Windowsスタンドアロンアプリケーション
Unity Asset ゲームに使用 Maze Generator
MLAPI Sample ゲームに使用 MLAPI_UnitychanSample

サンプルコード

完成サンプルプロジェクトコードをのせておきます。
Github

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

TencentCloud とは

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

GME「Game Multimedia Engine」とは

製品概要
TencentCloudのサービスの1つで、ワンストップの音声ソリューションを提供します。
ゲームシーンに関して、リアルタイム音声、音声メッセージ、ボイスツーテキスト変換などのサービスを提供し、FPS、MOBA、MMORPG、大逃殺、チェスカード、オンラインテーブルゲームなど、さまざまなゲームプレイタイプに適用され、モバイルゲーム、コンシューマーゲーム、およびブラウザーゲームのプラットフォーム間の相互運用をサポートします。

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

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

TencentCloud のアカウントの作成

アカウントの作成は、他社と比べて、情報セキュリティが高めです。
アカウント開設の手順はこちらを参考に進めてください。注意点は、2つあります。

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

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

tencentcloud.jpg

Game Multimedia Engine の設定

Game Multimedia Engine(GME) のサイトを開き、「今すぐ使用する」を選択してください。
gme_dashboard00.png

表示文言は、Englishにしています。
左ツリーの「ServiceManagement」を選択し、「Create application」を選択して、アプリケーションの情報を設定してください。
gme_dashboard01.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

VoiceChatServiceのサンプルコードの理解

ボイスチャットを動かすまでには、正直なところ、簡単にはいきませんでした。
公式サイトに、Unityでの使い方といて、 Quick Integration of SDK for Unity があるのですが、
これだけを見て、実装できる方は、いないでしょう。
Unityのサンプルアプリケーション があるので、そちらの熟知が必要となります。中国語表示なのと、直感的にどこに何を設定したら動くのかが、分からず、難儀しました。
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
API Call Flow Chartを参考に、プログラムを作成した場合のフローは下記のイメージとなります。
gme_Voiceシーケンス図.png

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

私が作った Github のUnity Project内の GmeVoiceChatTestSceneシーンを開いてください。
GmeVoiceSample.jpg

使い方
・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を利用させていただきました。
各々の詳細については、開発環境のリンクを参照ください。

開発の流れは下記となります。
1.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);
		}

2.Mazeのサンプルは、操作する自機がボールのため、ユニティちゃんに置き換えます。
3.Maze用の当たり判定処理を、ユニティちゃんのスクリプトに追加します。
4.MLAPI SampleのステージにMazeの位置を調整し、完成です。

ボイスチャットのゲームへの導入

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

開発の流れは下記となります。
1.ボイスチャットの設定
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]

2.ネットワーク入室時に、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);
    }

3.ネットワーク退出時に、GMEのボイスチャットを終了します。

GmeVoiceChatScript.cs
    public void GmeClose(bool isLog = true) {
        ・・・
    }

おわりに

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

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0