search
LoginSignup
0

動機

アプリケーションに、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のボイスチャットの品質は良く、多機能なのが良かったです。
しかし、初心者は、公式の情報だけだと、導入までに挫折する方がでるだろうと思います。
本記事が、皆様の快適なボイスチャットライフに繋がれば幸いです。

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

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
What you can do with signing up
0