メインコンテンツへスキップ
見出し画像

GPT-3とVoiceVoxを活用してAIエージェントを作る!【Unity】

    何を作ったのか?

    OpenAI GPT-3+VoiceVoxで、3D AIエージェントを作ってLooking Glassで良い感じに表示させてみた!
    ふとした時に何か会話するの楽しいし、自分の作業してるすぐ横で見守ってくれるし、AIにどんどんかわいさを感じていってあまりにも良い…👏😭 pic.twitter.com/I5UegeJUrl

    — ねぎぽよし (@CST_negi) February 27, 2023

    やっていることのコアとなる要素を抜き出すと…
    ・自身の発話内容をテキストに変換する
    ・そのテキストをOpenAIのAPIに投げて、AI側の回答を取得する
    ・AIの回答テキストをVoiceVoxを用いて音声として出力する
    ・その音声を再生しつつ、それをベースにキャラクターに口パクをさせる

    となります。

    環境はWindows11で、プログラミング自体はUnity/C#で完結しておりPythonなどを書く必要はありません。

    ある程度Unityでプログラムを書いている人向けに、プロジェクト構成やVRM読み込みなど細かい部分をはしょって、コアとなる要素だけ書いております。
    (コードの正確性の検証や細かいチューニングなどはしていませんのでご注意ください。)


    自身の発話内容をテキストに変換する

    Google Speech APIを使ったり話題のWhisperを動かしたり、様々なやり方があるかと思いますが、自分はUnityEngine.Windows.Speechを使いました。

    Windows限定となりますが、今回デバッグで利用していた限りは高い精度で発話をテキストに起こせていました。

    画像
    "Human:"となっているところが、自身の発話を読み取った結果になります。
    デバッグで何度か試しましたが問題ない精度だと感じます。

    DictationRecognizerを使って開始や終了、発話内容をテキスト化した際のイベントなどを管理するクラスを作りました。

    using System;
    using UnityEngine.Windows.Speech;
    
    public class WinSpeechRecognition : IDisposable
    {
        private readonly DictationRecognizer _dictationRecognizer;
        public event Action<string, ConfidenceLevel> OnDictationResult;
    
        public WinSpeechRecognition()
        {
            _dictationRecognizer = new DictationRecognizer();
            _dictationRecognizer.DictationResult += (t, c) => OnDictationResult!(t, c);
        }
    
        public void Start() => _dictationRecognizer.Start();
        public void Stop() => _dictationRecognizer.Stop();
    
        public void Dispose()
        {
            _dictationRecognizer?.Stop();
            _dictationRecognizer?.Dispose();
        }
    }


    このクラスに対して呼び出し側では以下のように発話内容をテキストに起こした瞬間のイベントを登録します。
    このイベントを起点にAI側の処理に移ります。

    _speechRecognition.OnDictationResult += OnDictationResult;



    人間の発話内容をOpenAIのAPIに投げて、AIの回答を取得する

    OpenAIに人間の発話内容を投げて、AIの回答をもらうクラスを作りました。

    using System;
    using System.Collections.Generic;
    using System.Text;
    using AAA.OpenAI;
    using Cysharp.Threading.Tasks;
    using UnityEngine;
    using UnityEngine.Networking;
    
    public class OpenAIConnection
    {
        private string _apiKey;
    
        public OpenAIConnection(string apiKey)
        {
            _apiKey = apiKey;
        }
        
        public async UniTask<TextCompletionResponseModel> RequestCompletionAsync(string prompt)
        {
            //スクリプトプロパティに設定したOpenAIのAPIキーを取得
            //文章生成AIのAPIのエンドポイントを設定
            var apiUrl = "https://api.openai.com/v1/completions";
    
            //OpenAIのAPIリクエストに必要なヘッダー情報を設定
            var headers = new Dictionary<string, string>
            {
                {"Authorization", "Bearer " + _apiKey},
                {"Content-type", "application/json"},
                {"X-Slack-No-Retry", "1"}
            };
            //文章生成で利用するモデルやトークン上限、プロンプトをオプションに設定
            var options = new OpenAICompletionRequestModel()
            {
                model = "text-davinci-003",
                max_tokens = 256,
                temperature = 0.9f,
                prompt = prompt
            };
            var jsonOptions = JsonUtility.ToJson(options);
            
            //OpenAIの文章生成(Completion)にAPIリクエストを送り、結果を変数に格納
            using var request = new UnityWebRequest(apiUrl, "POST")
            {
                uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(jsonOptions)),
                downloadHandler = new DownloadHandlerBuffer()
            };
    
            foreach (var header in headers)
            {
                request.SetRequestHeader(header.Key, header.Value);
            }
            await request.SendWebRequest();
    
            if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
            {
                Debug.LogError(request.error);
                throw new Exception();
            }
            else
            {
                var responseString = request.downloadHandler.text;
                var responseObject = JsonUtility.FromJson<TextCompletionResponseModel>(responseString);
                return responseObject;
            }
        }
    }

    Responseの取得で行われているTextCompletionResponseModelの定義は以下です。

    namespace AAA.OpenAI
    {
        [System.Serializable]
        public class TextCompletionResponseModel
        {
            public string id;
            public long created;
            public string model;
            public List<Choice> choices;
            public Usage usage;
    
            [System.Serializable]
            public class Choice
            {
                public string text;
                public int index;
                public string finish_reason;
            }
    
            [System.Serializable]
            public class Usage
            {
                public int prompt_tokens;
                public int completion_tokens;
                public int total_tokens;
            }
        }
    }

    このメソッドを呼び出す側はこのようにしています。
    これによってAI側の回答テキストを取得することができました。

    var aiResponseResult = await _openAIConnection.RequestCompletionAsync(micInputText);
    var aiResponseText = aiResponseResult.choices[0].text;
    画像
    aiResponseTextには、"AI:"となっている部分のテキストが取得されています。

    AIの回答テキストをVoiceVoxを用いて音声として出力する

    これを行うために、ローカル上にVoiceVoxエンジンを起動しています。
    以下の記事が非常に参考になりました。

    UnityとVoiceVoxとの連携について

    ・VoiceVoxサーバに読ませたいテキストを投げると、発話方式のパラメータであるAudioQueryが発行される
    ・そのQueryをVoiceVox にわたすことでWAVが生成される
    という仕組みと認識しております。

    筆者のUnityプロジェクトでは、その仕組みをもとに以下のようなコードを書いて実現しました。

    public class VoiceVoxConnection
    {
        private readonly string _voiceVoxUrl = "http://127.0.0.1:50021";
        private readonly int _speaker = 3;
    
        public VoiceVoxConnection(int speaker)
        {
            _speaker = speaker;
        }
    
        public async UniTask<AudioClip> TranslateTextToAudioClip(string text)
        {
            var queryJson = await SendAudioQuery(text);
            var clip = await GetAudioClip(queryJson);
            return clip;
        }
    
        private async UniTask<string> SendAudioQuery(string text)
        {
            var form = new WWWForm();
            using var request = UnityWebRequest.Post($"{_voiceVoxUrl}/audio_query?text={text}&speaker={_speaker}", form);
            await request.SendWebRequest();
    
            if (request.result == UnityWebRequest.Result.ConnectionError ||
                request.result == UnityWebRequest.Result.ProtocolError)
            {
                Debug.LogError(request.error);
            }
            else
            {
                var jsonString = request.downloadHandler.text;
                return jsonString;
            }
    
            return null;
        }
    
        private async UniTask<AudioClip> GetAudioClip(string queryJson)
        {
            var url = $"{_voiceVoxUrl}/synthesis?speaker={_speaker}";
            using var req = new UnityWebRequest(url, "POST");
            // Content-Type を設定
            req.SetRequestHeader("Content-Type", "application/json");
    
            // リクエストボディを設定
            byte[] bodyRaw = System.Text.Encoding.UTF8.GetBytes(queryJson);
            req.uploadHandler = new UploadHandlerRaw(bodyRaw);
            // レスポンスの取得に必要な設定を行う
            req.downloadHandler = new DownloadHandlerBuffer();
    
            await req.SendWebRequest();
    
            if (req.result == UnityWebRequest.Result.ConnectionError ||
                req.result == UnityWebRequest.Result.ProtocolError)
            {
                Debug.LogError(req.error);
            }
            else
            {
                var audioClip = WavUtility.ToAudioClip(req.downloadHandler.data);
                return audioClip;
            }
    
            return null;
        }
    }

    (VoiceVoxエンジンからのaudio/wavのレスポンスをUnityのAudioClipに変換するWavUtilityクラスは以下です。)

    public static AudioClip ToAudioClip(byte[] data)
    {
        // ヘッダー解析
        int channels = data[22];
        int frequency = BitConverter.ToInt32(data, 24);
        int length = data.Length - 44;
        float[] samples = new float[length / 2];
    
        // 波形データ解析
        for (int i = 0; i < length / 2; i++)
        {
            short value = BitConverter.ToInt16(data, i * 2 + 44);
            samples[i] = value / 32768f;
        }
    
        // AudioClipを作成
        AudioClip audioClip = AudioClip.Create("AudioClip", samples.Length, channels, frequency, false);
        audioClip.SetData(samples, 0);
    
        return audioClip;
    }

    このクラスの呼び出し側は以下となります。
    これによって、テキストを渡すとAudioClipが返ってくるようになりました。

    var clip = await _voiceVoxConnection.TranslateTextToAudioClip(aiResponseText);

    その音声を再生しつつ、それをベースにキャラクターに口パクをさせる

    再生

    VoiceVoxから得たClipに対して、以下のように普通に再生しています。

    audioSource.clip = clip;
    audioSource.Play();

    キャラクターの口パク

    OVRLipSyncを利用しています。
    OVRLipSyncといえば、VTuberアプリの文脈でマイク入力の音声から口パクを生成するのが主な使われ方ですが、AudioSourceを指定してあげることでマイク入力の音声でなくても口パクをしてくれるようになっています。

    今回自分が用意したキャラクターモデルはVRMだったため、OVRLipSyncと共に以下のコードも使っています。


    以上で、コアとなる技術を紹介することができました。

    あとはUIを作ったりAutoBlinkなどを実装したり、LookingGlass環境を整備することでキャラクターの存在感や魅力を底上げするような工夫をして完成となります!


    余談:

    今後の可能性

    このプロジェクトをベースに、キャラクターの性格を調整していったり、外部データと連携してタスクをこなせるようにしたり、会話を積み重ねて文脈を持たせた発言をするように実装したり、…等、とにかく楽しいことが無限にあります。

    — ねぎぽよし (@CST_negi) February 27, 2023

    今回はOpenAIからのレスポンスをそのままキャラクターに喋らせているためあっさりとした回答ばかりで魅力はそこまで強くありません。
    そこにまだまだ工夫の余地があると自分は考えています。

    例えば、以下のように通常のPrompt(人間の発話)に追加で、性格や語尾などのキャラ付けPromptや過去のトークPromptも毎回渡すことで、レスポンスを豊かにする工夫ができます。

    また、AIエージェントには外部データと連携して、いずれこういうこともできるようになってほしいという願いのツイートです。

    Applewatchで心拍数データを定期的に取得して、心拍数が極端に上がってたらお風呂沸かすかどうか提案してほしい。
    勝手にやってくれるんじゃなくて、AIが提案して最終的に人間が承認するという流れを踏むことでキャラクターとコミュニケーションできるところが大事

    — ねぎぽよし (@CST_negi) February 26, 2023

    GPT-3の性能を引き出すにはプロンプト次第だと思われるので、外部データを適切なプロンプトに変換するような部分については調査する余地はありそうですね。

    いずれにせよ、今後ChatGPTのような過去の会話の履歴から文脈を解してくれるAPIが出てきた瞬間にこうした話が大進化すると思うので楽しみにしています。

    ChatGPTを活用して最速でプログラミングする

    今回はAIエージェントを作ることを主目的としながら、裏テーマとして「エンジニアがAIを駆使して最速でプログラミングしたらどうなるか」ということも同時に並行してやっていました。

    そのため人間がしなくても良さそうな作業は極力ChatGPTに丸投げして手作業を減らしています。
    例えば…

    • OpenAIやVoiceVoxのJSONレスポンスをChatGPTに投げてJSONUtility用のResponseクラスを生成してもらった

    • UnityWebRequestのPOSTコードを書いて、コルーチン形式なのをasync/await形式になおしてもらった

    • audio/wavのレスポンスをUnityで扱うのは初めてだったので、WavUtilityクラスについては完全にChatGPTに任せた。

    特にWavUtilityの件についてはコードの内容的に、たぶんChatGPTが書いてくれなかったら下調べからだったので相当時間がかかってたのではないかなと思います。

    結果として人間が考えるべきところにフォーカスしてプログラミングをすることができ、空プロジェクト~LookingGlassに表示させるまで5時間で完成しました。
    自分でも信じられないくらい早くて非常に気分が良かったですね。

    というわけで、作ったものの完成度やこうした裏テーマの調査も加味して大成功のものづくりとなりました!
    とてもよかったです。

    今回はAIエージェントを作ったわけですが、最近話題のAITuberについても技術的には似ていると勝手に思っていて
    ・発話をYoutubeのコメントに置き換え
    ・性格や文脈をプロンプトに含めてリクエスト
    といったことをしているのかなと思います。
    なのでAIエージェントとやっていることはそう遠くないはず…(多分)

    AITuberの開発者の皆さんとはキャラクターの魅力を高めるという方向性では一致していると思うので界隈を注視していきたいなと思っています。

    ありがとうございました!




     
     
    1人でできることを全部やります。 キャラクター関連の技術開発(AR・VR・AI)が好きなエンジニア兼経営者です。初音ミクさんが好き。

    あなたへのおすすめ