Meta Quest 3 から SORACOM Beam へつないで返答する ChatGPT API の性格を変更してみるメモ

この記事は SORACOM Advent Calendar 2023 の 7 日目の記事です。Meta Quest 3 から SORACOM Beam へつないで返答する ChatGPT API の性格を変更してみるメモです。

今回の仕組み

image

XREAL Air からマイク録音して音データを Whisper API で文字起こしして ChatGPT API とやり取りするメモ を基礎にした仕組みで、Meta Quest 3 からも音声認識させて Node-RED で組んだサーバー経由で ChatGPT API とやり取りしています。

image

Meta Quest 3 を SORACOM SIM の入ったモバイルルータにつないで SORACOM Beam を挟んで、はじめは Node-RED で組んだ ChatGPT API の性格 1 につなぎます。

image

つづいて、SORACOM Beam で API の行き先を切り替えて Node-RED で組んだ ChatGPT API の性格 2 につなぎます。

Unity での API のつなぎなおしをすると、Unity のコード変更→ビルド→xR デバイスへの転送が送るため結構時間がかかる中、SORACOM Beam で切り替えられれば Unity の変更なしに挙動を変えることができるので楽しみです。

Node-RED の仕組み

今回はザっと enebular を使って、ChatGPT API へつなぐ 2 つの性格 API を作りました。

image

/api/soracom/chatgpt/1 のパスが入り口の性格 1 API では、{"message":"げんき?"} と送られてくると message 値を元に質問して「性格1 いぬ」と書かれている返答です。

image

そして Node-RED では、この simple-chatgpt ノードで ChatGPT API につながります。

image

ChatGPT API では system 値で性格の前振りができるので simple-chatgpt では SystemSetting 値で「あなたは質問者に忠実なわんこです。末尾は「わん」を自然な会話の形でつけてください。」として性格を決めています。

image

げんき?と聞くと、末尾に「わん」をつけてくれます。わんこテイストかはプロンプトは甘いのでちょっとあやしいところがあります。

image

なお、同様の流れで /api/soracom/chatgpt/2 のパスが入り口の性格 2 API では「なんでも SORACOM に寄せて話して。末尾は「!」で元気よく!」という、こってり SORACOM を推してくれる性格です。

image

げんき?と聞くと、gpt-3.5-turbo で、SORACOM の情報を結構貯められているようで、これくらいのプロンプトでもそれっぽくふるまってくれます。すごい。しれっと入れてくる。

さて、これで仕組みが出来上がったので、こちらのサーバーに対して Soracom Beam からつなぎます。

Soracom Beam の設定

Soracom Beam は enebular の先ほどのフローが動いてる Web エディタからサーバー URL を取得して設定します。

image

HTTP エントリポイントで /api/soracom/chatgpt/1 につなぐようにします。

Meta Quest 3 での設定

Meta Quest 3 のコンテンツは Unity C# で作っていますが、文字起こししたテキストを最終的に beam.soracom.io に送っています。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Networking;
using System;
using System.IO;
using System.Text;
using TMPro;

public class ButtonChatGPT : MonoBehaviour, IPointerClickHandler
{
    // TextChat
    TextMeshProUGUI TextChat;

    // 現在の会話
    string currentTextChat = "";

    // マイクの開始・終了管理
    bool flagMicRecordStart = false;

    // マイクデバイスがキャッチできたかどうか
    bool catchedMicDevice = false;

    // 現在録音するマイクデバイス名
    string currentRecordingMicDeviceName = "null";

    // PC の録音のターゲットになるマイクデバイス名
    // これはお使いのデバイスで変わります
    // 完全一致でないと受け取れないので注意
    string recordingTargetMicDeviceName = "Krisp Microphone (Krisp Audio)";

    // XREAL Air の録音のターゲットになるマイクデバイス名 "Android audio input"
    string recordingTargetMicDeviceNameForXREALAir = "Android audio input";

    // ヘッダーサイズ
    int HeaderByteSize = 44;

    // BitsPerSample
    int BitsPerSample = 16;

    // AudioFormat
    int AudioFormat = 1;

    // 録音する AudioClip
    AudioClip recordedAudioClip;

    // サンプリング周波数
    int samplingFrequency = 44100;

    // 最大録音時間[sec]
    int maxTimeSeconds = 10;

    // Wav データ
    byte[] dataWav;

    // OpenAIAPIKey
    // WhisperAPI と ChatGPTAPI で共通
    string OpenAIAPIKey = "OpenAIAPIKey";

    // Wisper API で受信した JSON データを Unity で扱うデータにする WhisperAPIResponseData ベースクラス
    [Serializable]
    public class WhisperAPIResponseData
    {
        public string text;
    }

    // ChatGPT API で受信した JSON データを Unity で扱うデータにする ResponseData ベースクラス
    // API仕様 : https://platform.openai.com/docs/api-reference/completions/object
    [Serializable]
    public class ResponseData
    {
        public string id;
        public string @object; // object は予約語なので @ を使ってエスケープしています
        public int created;
        public List<ResponseDataChoice> choices;
        public ResponseDataUsage usage;
    }

    [Serializable]
    public class ResponseDataUsage
    {
        public int prompt_tokens;
        public int completion_tokens;
        public int total_tokens;
    }
    [Serializable]
    public class ResponseDataChoice
    {
        public int index;
        public RequestDataMessages message;
        public string finish_reason;
    }

    // ChatGPT API に送信する Unity データを JSON データ化する RequestData ベースクラス
    [Serializable]
    public class RequestData
    {
        public string model;
        public List<RequestDataMessages> messages;
    }

    [Serializable]
    public class RequestDataMessages
    {
        public string role;
        public string content;
    }

    // MessageRequestData ベースクラス
    [Serializable]
    public class MessageRequestData
    {
        public string message;
    }

    // MessageResponseData ベースクラス
    [Serializable]
    public class MessageResponseData
    {
        public string message;
    }

    void Start()
    {
        catchedMicDevice = false;

        TextChat = GameObject.Find("TextChat").GetComponent<TextMeshProUGUI>();

        Launch();
    }

    void Launch()
    {

        // マイクデバイスを探す
        foreach (string device in Microphone.devices)
        {
            Debug.Log($"Mic device name : {device}");

            // PC 用のマイクデバイスを割り当て
            if (device == recordingTargetMicDeviceName)
            {
                Debug.Log($"{recordingTargetMicDeviceName} searched");

                currentRecordingMicDeviceName = device;

                catchedMicDevice = true;
            }

            // XREAL Air 用のマイクデバイスを割り当て
            if (device == recordingTargetMicDeviceNameForXREALAir)
            {
                Debug.Log($"{recordingTargetMicDeviceNameForXREALAir} serched");

                currentRecordingMicDeviceName = device;

                catchedMicDevice = true;
            }

        }

        if (catchedMicDevice)
        {
            Debug.Log($"マイク捜索成功");
            Debug.Log($"currentRecordingMicDeviceName : {currentRecordingMicDeviceName}");
        }
        else
        {
            Debug.Log($"マイク捜索失敗");
        }

    }

    void Update()
    {

    }

    public void OnAppButtonClick()
    {
        ButtonCore();
    }

    void ButtonCore()
    {
        if (catchedMicDevice)
        {
            if (flagMicRecordStart)
            {
                // Stop
                // マイクの録音を開始
                flagMicRecordStart = false;
                Debug.Log($"Mic Record Stop");

                TextChat.SetText(currentTextChat);

                RecordStop();

            }
            else
            {
                // Start
                // マイクの停止
                flagMicRecordStart = true;
                Debug.Log($"Mic Record Start");

                // 現在の会話の記録
                currentTextChat = TextChat.text;

                TextChat.SetText($"自分 : 音声録音中... \n" + currentTextChat);

                RecordStart();
            }
        }
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        ButtonCore();
    }

    void RecordStart()
    {
        // マイクの録音を開始して AudioClip を割り当て
        recordedAudioClip = Microphone.Start(currentRecordingMicDeviceName, false, maxTimeSeconds, samplingFrequency);
    }

    void RecordStop()
    {
        // マイクの停止
        Microphone.End(currentRecordingMicDeviceName);

        Debug.Log($"WAV データ作成開始");

        // using を使ってメモリ開放を自動で行う
        using (MemoryStream currentMemoryStream = new MemoryStream())
        {
            // ChunkID RIFF
            byte[] bufRIFF = Encoding.ASCII.GetBytes("RIFF");
            currentMemoryStream.Write(bufRIFF, 0, bufRIFF.Length);

            // ChunkSize
            byte[] bufChunkSize = BitConverter.GetBytes((UInt32)(HeaderByteSize + recordedAudioClip.samples * recordedAudioClip.channels * BitsPerSample / 8));
            currentMemoryStream.Write(bufChunkSize, 0, bufChunkSize.Length);

            // Format WAVE
            byte[] bufFormatWAVE = Encoding.ASCII.GetBytes("WAVE");
            currentMemoryStream.Write(bufFormatWAVE, 0, bufFormatWAVE.Length);

            // Subchunk1ID fmt
            byte[] bufSubchunk1ID = Encoding.ASCII.GetBytes("fmt ");
            currentMemoryStream.Write(bufSubchunk1ID, 0, bufSubchunk1ID.Length);

            // Subchunk1Size (16 for PCM)
            byte[] bufSubchunk1Size = BitConverter.GetBytes((UInt32)16);
            currentMemoryStream.Write(bufSubchunk1Size, 0, bufSubchunk1Size.Length);

            // AudioFormat (PCM=1)
            byte[] bufAudioFormat = BitConverter.GetBytes((UInt16)AudioFormat);
            currentMemoryStream.Write(bufAudioFormat, 0, bufAudioFormat.Length);

            // NumChannels
            byte[] bufNumChannels = BitConverter.GetBytes((UInt16)recordedAudioClip.channels);
            currentMemoryStream.Write(bufNumChannels, 0, bufNumChannels.Length);

            // SampleRate
            byte[] bufSampleRate = BitConverter.GetBytes((UInt32)recordedAudioClip.frequency);
            currentMemoryStream.Write(bufSampleRate, 0, bufSampleRate.Length);

            // ByteRate (=SampleRate * NumChannels * BitsPerSample/8)
            byte[] bufByteRate = BitConverter.GetBytes((UInt32)(recordedAudioClip.samples * recordedAudioClip.channels * BitsPerSample / 8));
            currentMemoryStream.Write(bufByteRate, 0, bufByteRate.Length);

            // BlockAlign (=NumChannels * BitsPerSample/8)
            byte[] bufBlockAlign = BitConverter.GetBytes((UInt16)(recordedAudioClip.channels * BitsPerSample / 8));
            currentMemoryStream.Write(bufBlockAlign, 0, bufBlockAlign.Length);

            // BitsPerSample
            byte[] bufBitsPerSample = BitConverter.GetBytes((UInt16)BitsPerSample);
            currentMemoryStream.Write(bufBitsPerSample, 0, bufBitsPerSample.Length);

            // Subchunk2ID data
            byte[] bufSubchunk2ID = Encoding.ASCII.GetBytes("data");
            currentMemoryStream.Write(bufSubchunk2ID, 0, bufSubchunk2ID.Length);

            // Subchuk2Size
            byte[] bufSubchuk2Size = BitConverter.GetBytes((UInt32)(recordedAudioClip.samples * recordedAudioClip.channels * BitsPerSample / 8));
            currentMemoryStream.Write(bufSubchuk2Size, 0, bufSubchuk2Size.Length);

            // Data
            float[] floatData = new float[recordedAudioClip.samples * recordedAudioClip.channels];
            recordedAudioClip.GetData(floatData, 0);

            foreach (float f in floatData)
            {
                byte[] bufData = BitConverter.GetBytes((short)(f * short.MaxValue));
                currentMemoryStream.Write(bufData, 0, bufData.Length);
            }

            Debug.Log($"WAV データ作成完了");

            dataWav = currentMemoryStream.ToArray();

            Debug.Log($"dataWav.Length {dataWav.Length}");

            // 現在の会話の記録
            currentTextChat = TextChat.text;

            // まず Wisper API で文字起こし
            StartCoroutine(PostWhisperAPI());

        }

    }

    // Wisper API で文字起こし
    IEnumerator PostWhisperAPI()
    {
        // IMultipartFormSection で multipart/form-data のデータとして送れます
        // https://docs.unity3d.com/ja/2018.4/Manual/UnityWebRequest-SendingForm.html
        // https://docs.unity3d.com/ja/2019.4/ScriptReference/Networking.IMultipartFormSection.html
        // https://docs.unity3d.com/ja/2020.3/ScriptReference/Networking.MultipartFormDataSection.html
        List<IMultipartFormSection> formData = new List<IMultipartFormSection>();

        // https://platform.openai.com/docs/api-reference/audio/createTranscription
        // Whisper モデルを使う
        formData.Add(new MultipartFormDataSection("model", "whisper-1"));
        // 日本語で返答
        formData.Add(new MultipartFormDataSection("language", "ja"));
        // WAV データを入れる
        formData.Add(new MultipartFormFileSection("file", dataWav, "whisper01.wav", "multipart/form-data"));

        // HTTP リクエストする(POST メソッド) UnityWebRequest を呼び出し
        // 第 2 引数で上記のフォームデータを割り当てて multipart/form-data のデータとして送ります
        string urlWhisperAPI = "https://api.openai.com/v1/audio/transcriptions";
        UnityWebRequest request = UnityWebRequest.Post(urlWhisperAPI, formData);

        // OpenAI 認証は Authorization ヘッダーで Bearer のあとに API トークンを入れる
        request.SetRequestHeader("Authorization", $"Bearer {OpenAIAPIKey}");

        // ダウンロード(サーバ→Unity)のハンドラを作成
        request.downloadHandler = new DownloadHandlerBuffer();

        Debug.Log("WhisperAPI リクエスト開始");

        TextChat.SetText($"自分 : WhisperAPI リクエスト開始... \n" + currentTextChat);

        // リクエスト開始
        yield return request.SendWebRequest();


        // 結果によって分岐
        switch (request.result)
        {
            case UnityWebRequest.Result.InProgress:
                Debug.Log("WhisperAPI リクエスト中");
                break;

            case UnityWebRequest.Result.ProtocolError:
                Debug.Log("ProtocolError");
                Debug.Log(request.responseCode);
                Debug.Log(request.error);
                break;

            case UnityWebRequest.Result.ConnectionError:
                Debug.Log("ConnectionError");
                break;

            case UnityWebRequest.Result.Success:
                Debug.Log("WhisperAPI リクエスト成功");

                // コンソールに表示
                Debug.Log($"responseData: {request.downloadHandler.text}");

                WhisperAPIResponseData resultResponseWhisperAPI = JsonUtility.FromJson<WhisperAPIResponseData>(request.downloadHandler.text);

                TextChat.SetText($"自分 : {resultResponseWhisperAPI.text}\n" + currentTextChat);

                // 現在の会話の記録
                currentTextChat = TextChat.text;

                // テキストが起こせたら ChatGPT API に聞く
                yield return PostSORACOMBeam(resultResponseWhisperAPI.text);

                break;
        }


    }

    // PostSORACOMBeam
    IEnumerator PostSORACOMBeam(string text)
    {
        UnityWebRequest request = new UnityWebRequest("http://beam.soracom.io:8888/", "POST");

        MessageRequestData requestData = new MessageRequestData();
        requestData.message = text;

        // 送信データを JsonUtility.ToJson で JSON 文字列を作成
        // RequestData, RequestDataMessages の構造に基づいて変換してくれる
        string strJSON = JsonUtility.ToJson(requestData);
        Debug.Log($"strJSON : {strJSON}");
        // 送信データを Encoding.UTF8.GetBytes で byte データ化
        byte[] bodyRaw = Encoding.UTF8.GetBytes(strJSON);

        // アップロード(Unity→サーバ)のハンドラを作成
        request.uploadHandler = new UploadHandlerRaw(bodyRaw);
        // ダウンロード(サーバ→Unity)のハンドラを作成
        request.downloadHandler = new DownloadHandlerBuffer();

        // JSON で送ると HTTP ヘッダーで宣言する
        request.SetRequestHeader("Content-Type", "application/json");
        // ChatGPT 用の認証を伝える設定
        request.SetRequestHeader("Authorization", $"Bearer {OpenAIAPIKey}");

        TextChat.SetText($"ChatGPTさん : ChatGPT リクエスト開始... \n" + currentTextChat);

        // リクエスト開始
        yield return request.SendWebRequest();

        Debug.Log("ChatGPT リクエスト...");

        // 結果によって分岐
        switch (request.result)
        {
            case UnityWebRequest.Result.InProgress:
                Debug.Log("ChatGPT リクエスト中");
                break;

            case UnityWebRequest.Result.ProtocolError:
                Debug.Log("ProtocolError");
                Debug.Log(request.responseCode);
                Debug.Log(request.error);
                break;

            case UnityWebRequest.Result.ConnectionError:
                Debug.Log("ConnectionError");
                break;

            case UnityWebRequest.Result.Success:
                Debug.Log("ChatGPT リクエスト成功");

                // コンソールに表示
                Debug.Log($"responseData: {request.downloadHandler.text}");

                MessageResponseData resultResponse = JsonUtility.FromJson<MessageResponseData>(request.downloadHandler.text);

                // 返答
                Debug.Log($"resultResponse.message : {resultResponse.message}");

                TextChat.SetText($"ChatGPTさん : {resultResponse.message}\n" + currentTextChat);

                break;
        }


    }

}

速度クラスを(一時的に)変更

image

今回の仕組みは文字起こしで音声データもやりとりするのでデータ量が多いので s1.standard だと、ちょっと厳しいので、一時的に s1.4xfast に速度を上げます。

動かしてみる

image

Meta Quest 3 はパススルーで現実世界も見えつつ SORACOM Beam 経由で ChatGPT API にやりとりできます。今回はまず、性格 1 API につながって、ややキャラづくりが甘いわんこと会話ができました。

image

というわけで、いよいよ SORACOM Beam で /api/soracom/chatgpt/2 のパスが入り口の性格 2 API につないでみてしゃべってみると・・・

image

無事、性格が変わって SORACOM を推す会話に変わりました~~~。がっつり推してきますね!

実際やってみると切り替えた瞬間から、応答の性格が変わるので割と生々しさがあってよかったです。また、地味に速度変更によってやり取りが高速になったあたりも、しっかり動いてるなーという印象で勉強になりました!