AIVtuberを制作した話（5カ月前）。Unity、GPT3.5turbo

## introduction

https://www.youtube.com/live/DPRuPLOKrjA?si=xscZ4c1vCKUZnhhg

５カ月前の話ですが、AItuberを作っていました。

とても興味深い、書籍が先日発売されたので、自身の技術整理をかねて、書いておこうと思います。
https://www.amazon.co.jp/dp/4296070789?psc=1&ref=ppx_yo2ov_dt_b_product_details
この書籍

当時はなるべく早く出すことを目的に作ったので、めちゃくちゃ雑なため、あらためて作り直そうと思っています。

## 要件定義
AIでVtuberと同じように配信することができるようにする。
ゲーム配信をすることができるようにする。

## 実装
コメントに反応し、テキストを返す。
返ってきたテキストを読み上げる。
ランダムな時間で指定した件について自身の考えを述べるようにする。
性格や好きなものを与える。
自分で考えゲームを操作するようにする。
GPT3.5turboを使用する。（このころはGPT-4がありませんでした）

## 参考記事
https://note.com/negipoyoc/n/n88189e590ac3

## 躓いたところ
YoutubeのAPIの取得が意外と難しかったです。

## スクリプト

Youtubeのコメント取得など

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using System.Net;
    using System.Text.RegularExpressions;
    using System.Threading;
    using UnityEngine;
    using UnityEngine.Networking;
    using SimpleJSON;
    
    namespace YouTubeLive {
    
        public class YouTubeLiveController : MonoBehaviour {
    
            [SerializeField]
            private string clientId = "client_id";
    
            [SerializeField]
            private string clientSecret = "client_secret";
    
            [SerializeField]
            private string liveBroadcastId = "xxxxxxxxxxx"; // Specify the live broadcast ID
    
            private string accessToken;
            private string chatId;
    
            public event Action<string> OnMessageReceived;
    
            private void Start() {
                StartCoroutine(StartAuthorizationFlow());
            }
    
            private IEnumerator StartAuthorizationFlow() {
                var code = "";
                LocalServer(c => code = c);
    
                var authUrl = "https://accounts.google.com/o/oauth2/v2/auth?response_type=code"
                    + "&client_id=" + clientId
                    + "&redirect_uri=" + "http://localhost:8080"
                    + "&scope=" + "https://www.googleapis.com/auth/youtube.readonly"
                    + "&access_type=" + "offline";
                Application.OpenURL(authUrl);
                yield return new WaitUntil(() => code != "");
    
                Debug.Log(code);
    
                var tokenUrl = "https://www.googleapis.com/oauth2/v4/token";
                var content = new Dictionary<string, string>()
                {
                    { "code", code },
                    { "client_id", clientId },
                    { "client_secret", clientSecret },
                    { "redirect_uri", "http://localhost:8080" },
                    { "grant_type", "authorization_code" },
                    { "access_type", "offline" },
                };
                var request = UnityWebRequest.Post(tokenUrl, content);
                yield return request.SendWebRequest();
    
                var json = JSON.Parse(request.downloadHandler.text);
                accessToken = json["access_token"].Value;
    
                Debug.Log(accessToken);
    
                var url = "https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet";
                url += "&id=" + liveBroadcastId;
    
                var req = UnityWebRequest.Get(url);
                req.SetRequestHeader("Authorization", "Bearer " + accessToken);
                yield return req.SendWebRequest();
    
                json = JSON.Parse(req.downloadHandler.text);
    
                if (json["items"].Count == 0) {
                    Debug.Log("No live broadcast found.");
                    yield break;
                }
    
                chatId = json["items"][0]["snippet"]["liveChatId"].Value;
    
                Debug.Log(chatId);
    
                StartCoroutine(GetChatMessages());
            }
    
            private IEnumerator GetChatMessages()
    {
        var nextPageToken = string.Empty;
        var url = "https://www.googleapis.com/youtube/v3/liveChat/messages?part=snippet,authorDetails";
        url += "&liveChatId=" + chatId;
    
        while (true)
        {
            if (!string.IsNullOrEmpty(nextPageToken))
            {
                url += "&pageToken=" + nextPageToken;
            }
    
            var req = UnityWebRequest.Get(url);
            req.SetRequestHeader("Authorization", "Bearer " + accessToken);
            yield return req.SendWebRequest();
    
            var json = JSON.Parse(req.downloadHandler.text);
            var items = json["items"];
    
            foreach (var item in items)
            {
                var messageId = item.Value["id"].Value;
                if (!IsMessageAlreadyFetched(messageId))
                {
                    var snip = item.Value["snippet"];
                    var author = item.Value["authorDetails"];
                    var message = author["displayName"].Value + ": " + snip["displayMessage"].Value;
                    InvokeOnMessageReceived(message);
                    AddMessageToFetchedList(messageId);
                }
            }
    
            nextPageToken = json["nextPageToken"].Value;
    
            yield return new WaitForSeconds(5); // Adjust the interval based on your needs
        }
    }
    
    
    private List<string> fetchedMessages = new List<string>();
    
    private bool IsMessageAlreadyFetched(string messageId)
    {
        return fetchedMessages.Contains(messageId);
    }
    
    private void AddMessageToFetchedList(string messageId)
    {
        fetchedMessages.Add(messageId);
    }
    
            private void LocalServer(Action<string> onReceive) {
                ThreadStart start = () => {
                    try {
                        var listener = new HttpListener();
                        listener.Prefixes.Add("http://*:8080/");
                        listener.Start();
                        var context = listener.GetContext();
                        var req = context.Request;
                        var res = context.Response;
                        var re = new Regex(@"/\?code=(?<c>.*)");
                        var code = re.Match(req.RawUrl).Groups["c"].ToString();
                        onReceive(code);
                        res.StatusCode = 200;
                        res.Close();
                    } catch (Exception e) {
                        Debug.LogError(e);
                    }
                };
                new Thread(start).Start();
            }
    
            private void InvokeOnMessageReceived(string message) {
                OnMessageReceived?.Invoke(message);
            }
        }
    
        public static class SimpleJsonUtility {
            public static string RawString(this JSONNode node) {
                var len = node.ToString().Length - 2;
                return node.ToString().Substring(1, len);
            }
        }
    }

GPTとのテキストのやり取りや、読み上げに関わること、Youtubeからのコメントの処理など

    using Mochineko.ChatGPT_API;
    using UnityEngine;
    using UnityEngine.UI;
    using VoicevoxBridge;
    using System.Threading.Tasks;
    using VRM;
    using System.Collections.Generic;
    using YouTubeLive;
    
    [System.Serializable]
    public class MouthBlendShapeSettings
    {
 
    }
    
    public enum ControllerCommand
    {
        None,
        Circle,
        Cross,
        Triangle,
        Square,
        DPadUp,
        DPadDown,
        DPadLeft,
        DPadRight,
        L1,
        L2,
        R1,
        R2,
        LeftStickButton,
        RightStickButton,
        Options,
        Share,
        PSButton,
        Touchpad,
    }
    
    public enum Phoneme
    {
        A,
        E,
        I,
        O,
        U,
        B,
        C,
        D,
        F,
        G,
        H,
        J,
        K,
        L,
        M,
        N,
        P,
        Q,
        R,
        S,
        T,
        V,
        W,
        X,
        Y,
        Z
    }
    
    [System.Serializable]
    public class PhonemeCommand
    {
        public Phoneme phoneme;
        public ControllerCommand controllerCommand;
        public float buttonPressDuration;
    }
    
    public class AICharacter : MonoBehaviour
    {
        [SerializeField] private VOICEVOX voicevox;
        [SerializeField] private InputField inputField;
        [SerializeField] private VRMBlendShapeProxy blendShapeProxy;
        [SerializeField] private MouthBlendShapeSettings mouthBlendShapeSettings;
        [SerializeField] private float mouthBlendShapeDuration = 0.1f;
    
        [SerializeField] private List<string> favoriteThing;
        [SerializeField] private List<string> strengths;
        [SerializeField] private List<PhonemeCommand> phonemeCommands;
        [SerializeField] private List<string> messageSuffixes;
    
        private const string APIKey = "トークン";
        private const int SpeakerId = 11;
    
        private ChatCompletionAPIConnection connection;
        private float nextRandomTalkTime;
        private bool isTalking;
        private bool isCooldown;
        private float cooldownEndTime;
    
        [SerializeField]
        YouTubeLive.YouTubeLiveController ctrl;
    
        [System.Serializable]
        public class WordReplacement
        {
            public string word;
            public string replacement;
        }
    
        [SerializeField] private List<WordReplacement> wordReplacements;
    
        public List<string> FavoriteThing
        {
            get { return favoriteThing; }
            set { favoriteThing = value; }
        }
    
        public List<string> Strengths
        {
            get { return strengths; }
            set { strengths = value; }
        }
    
        private void Start()
        {
            connection = new ChatCompletionAPIConnection(APIKey);
            inputField.onEndEdit.AddListener(OnInputEndEdit);
            SetNextRandomTalkTime();
            cooldownEndTime = Time.time + 60f;
            ctrl.OnMessageReceived += HandleYouTubeLiveMessage;
        }
    
        private void Update()
        {
            if (isCooldown && Time.time >= cooldownEndTime)
            {
                isCooldown = false;
            }
            if (!isTalking && !isCooldown && Time.time >= nextRandomTalkTime && Time.time >= cooldownEndTime)
            {
                StartRandomTalk();
                SetNextRandomTalkTime();
            }
        }
    
        public void HandleYouTubeLiveMessage(string message)
        {
        Debug.Log("YouTubeLiveのメッセージ: " + message);
    
        // 受け取ったメッセージをInputFieldのテキストとして設定する
        inputField.text = message;
    
        // 入力の送信をトリガーする
        inputField.onEndEdit.Invoke(message);
        }
    
        private async void OnInputEndEdit(string input)
        {
            if (isTalking || isCooldown)
            {
                return;
            }
    
            string processedInput = ProcessInput(input);
    
            string modifiedInput = processedInput;
            foreach (var suffix in messageSuffixes)
            {
            modifiedInput += " " + suffix;
            }
    
            await ProcessUserInput(modifiedInput);
    
            inputField.text = "";
        }
    
            private string ProcessInput(string input)
        {
            foreach (var wordReplacement in wordReplacements)
            {
                if (input.Contains(wordReplacement.word))
                {
                    input = input.Replace(wordReplacement.word, wordReplacement.replacement);
                }
            }
    
            return input;
        }
    
        private async void StartRandomTalk()
        {
            var topic = GetRandomPokemonTopic();
            var input = "";
            var fullInput = "ポケモンについて話してください：" + topic + " " + input;
            isTalking = true;
            var message = await GetAIResponse(fullInput);
            Debug.Log("AI Response: " + message);
            int phonemeCount = CountPhonemes(message);
            int consonantCount = CountConsonants(message);
            Debug.Log("Number of phonemes: " + phonemeCount);
            Debug.Log("Number of consonants: " + consonantCount);
            Debug.Log("Voice Play Start: " + message);
            await PlayVoice(message);
            isTalking = false;
            StartCooldown();
        }
    
        private async Task<string> GetAIResponse(string input)
        {
            var response = await connection.CompleteChatAsync(input, default);
            return response.Choices[0].Message.Content;
        }
    
        private async Task ProcessUserInput(string input)
        {
            var message = await GetAIResponse(input);
            Debug.Log("AI Response: " + message);
            int phonemeCount = CountPhonemes(message);
            int consonantCount = CountConsonants(message);
            Debug.Log("Number of phonemes: " + phonemeCount);
            Debug.Log("Number of consonants: " + consonantCount);
            Debug.Log("Voice Play Start: " + message);
            await PlayVoice(message);
        }
    
        private string GetRandomPokemonTopic()
        {
            string[] topics = { "ポケモンの進化について", "お気に入りのポケモンについて", "ポケモンバトルの戦略について" };
            int index = Random.Range(0, topics.Length);
            return topics[index];
        }
    
        private void SetNextRandomTalkTime()
        {
            nextRandomTalkTime = Time.time + Random.Range(60f, 120f);
        }
    
        private void StartCooldown()
        {
            isCooldown = true;
            cooldownEndTime = Time.time + 60f;
        }
    
        private async Task PlayVoice(string message)
        {
            Debug.Log("Playing Voice: " + message);
            voicevox.PlayOneShot(SpeakerId, message);
            await Task.Delay((int)(message.Length * mouthBlendShapeDuration * 1000));
        }
    
        private int CountPhonemes(string input)
        {
            int count = 0;
            foreach (char c in input)
            {
                if (IsPhoneme(c))
                {
                    count++;
                }
            }
            return count;
        }
    
        private int CountConsonants(string input)
        {
            int count = 0;
            foreach (char c in input)
            {
                if (IsConsonant(c))
                {
                    count++;
                }
            }
            return count;
        }
    
        private bool IsPhoneme(char c)
        {
            switch (char.ToLower(c))
            {
                case 'a':
                case 'e':
                case 'i':
                case 'o':
                case 'u':
                    return true;
                default:
                    return false;
            }
        }
    
        private bool IsConsonant(char c)
        {
            return !IsPhoneme(c) && char.IsLetter(c);
        }
    
        private async Task ExecuteControllerCommand(ControllerCommand command, float duration)
        {
            // 指定されたコマンドに基づいてコントローラーの動作を実装する
            switch (command)
            {
                case ControllerCommand.None:
                    // Noneの場合、特定のアクションを実行しない
                    break;
                case ControllerCommand.Circle:
                    // Circleボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Cross:
                    // Crossボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Triangle:
                    // Triangleボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Square:
                    // Squareボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.DPadUp:
                    // DPad上ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.DPadDown:
                    // DPad下ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.DPadLeft:
                    // DPad左ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.DPadRight:
                    // DPad右ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.L1:
                    // L1ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.L2:
                    // L2ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.R1:
                    // R1ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.R2:
                    // R2ボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.LeftStickButton:
                    // 左スティックボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.RightStickButton:
                    // 右スティックボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Options:
                    // Optionsボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Share:
                    // Shareボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.PSButton:
                    // PSボタンの操作を実行する
                    // ...
                    break;
                case ControllerCommand.Touchpad:
                    // タッチパッドの操作を実行する
                    // ...
                    break;
                default:
                    // 未知のコマンドの場合、特定のアクションを実行しない
                    break;
            }
    
            // 指定された時間だけコマンドを実行し続ける
            await Task.Delay((int)(duration * 1000));
    
            // コマンドの終了を実装する
            // ...
        }
    
        private PhonemeCommand GetPhonemeCommand(Phoneme phoneme)
        {
            foreach (var command in phonemeCommands)
            {
                if (command.phoneme == phoneme)
                {
                    return command;
                }
            }
            return null;
        }
    
        private async void UserInput(string input)
        {
            Debug.Log("Input: " + input);
    
            // 入力されたテキストを解析してコマンドを生成する
            var commands = new List<PhonemeCommand>();
            foreach (char c in input)
            {
                if (IsPhoneme(c))
                {
                    PhonemeCommand command = GetPhonemeCommand((Phoneme)char.ToLower(c));
                    if (command != null)
                    {
                        commands.Add(command);
                    }
                }
            }
    
            // コマンドを実行する
            foreach (var command in commands)
            {
                await ExecuteControllerCommand(command.controllerCommand, command.buttonPressDuration);
            }
        }
    }
    
    //ver4.0