見出し画像

GPT-4o と自作音声合成モデルで音声会話できるNPC作ってみました。

「ゲームのキャラクターと自由会話出来たらよくね?」
という概念がずっと昔からありましたが。
まさに今、必要としている技術が揃っていました。

GPT-4o + Unity Webgl + VITS TTS で作れます!

GPT-4o はコストが安い上、会話エージェントとして優れているので、以前使ったGPT-3.5-Turboを差し替えて色々と試しました。
Unity Webgl でカンタンにブラウザゲームが作れる。
VITS TTS の自作モデルでNPCの発言を音声化できてしまいます。

その技術らをまとめて作ったのはこちらです!

ゲーム画面の入力欄から入力することで会話できます!
音声が出るのでご注意ください。

こちらのインプットボックスで会話できます。

全体の構成

基本的に Client ⇔ 音声合成サーバ ⇔ GPT-4o API
でのやり取りとなります。 

サーバ側

GPT-4o とのやり取り

import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["GPT_API_TOKEN"])
init_str = """チャットボットの設定はここに書く
           """
 messages=[
        {"role": "system", "content": init_str},
        {"role": "user", "content": "おはようございます。"},
    ]
response = client.chat.completions.create(model="gpt-4o", messages=messages)
text_response = response.choices[0].message.content
print(text_response)
# おはようございます!今日も元気でいらっしゃいますか?何かお手伝いできることがあれば教えてください。

GPT-4oでのやり取りを行うための最低限のコードです。
OpenAIのAPIを使うため、事前にAPI用のTokenを発行します。
作成手順についてはこちらを参考にしてください。

init_str = """チャットボットの設定はここに書く
           """
 messages=[
        {"role": "system", "content": init_str},
        {"role": "user", "content": "おはようございます。"},
    ]

ここでチャットボットの設定や会話したい内容を messages の配列に入れます。記述しているRoleは以下のようなものを意味します。
・system はシステム的な設定などでチャットボットの機能や人格など設定できるタグです。
・user はユーザーからの入力です、上記では直接文章を指定しています。

response = client.chat.completions.create(model="gpt-4o", messages=messages)
text_response = response.choices[0].message.content
print(text_response )

上記のModelで GPT-4o を指定することで「GPT-4o」に先ほどの messages を送り、返答結果を得ることができます。
問題がなければ「おはようございます!今日も元気でいらっしゃいますか?何かお手伝いできることがあれば教えてください。」みたいな文書が結果に出ます。

ただ、GPT-4o は会話を記録する機能がないので、上記の system や user のタグだけだと毎回会話が最初からスタートしてしまいます。
これを回避するため、assistant のタグを使って、チャットボットでの会話記録を付けます。

# messages に assistant タグを使って先ほどのAPI結果を保存する例
messages=[
    {"role": "system", "content": init_str},
    {"role": "user", "content": "おはようございます。"},
    {"role": "assistant", "content" : "おはようございます!今日も元気でいらっしゃいますか?何かお手伝いできることがあれば教えてください。"},
    {"role": "user", "content": "私は元気だよ!君も元気かな"},
]
response = client.chat.completions.create(model="gpt-4o", messages=messages)
text_response = response.choices[0].message.content
print(text_response)
# ありがとうございます!私も元気ですよ。お話しできて嬉しいです。今日は何か特別な予定や話題がありますか?

このように会話を記録し文脈を踏まえた会話をすることができます。
実際のアプリケーションでは、直接コード上で指定するのではなく、
動的に指定をするような関数を用意して使っていますが、これについては後述します。

クライアント側

Unity でウェブゲーム作成

クライアント側は主に Unity の WebGL ビルド機能を使ってウェブ用ビルドを作成します。カンタンに書き出しができてしまうので、今回のような用途ではとても便利です!

Unity では WebGL ゲームを簡単に作成できます。

Unity側の構成は単純で、背景のオブジェクトやモデルを配置して、
後は、入力するための Input やチャットログを出すための TextBox を配置するたけです。

ちなみに今回使っているモデルは須藤日ちゃん!
VRChat にも使えておすすめです。

クライアント ⇔ サーバのやり取り

サーバにユーザの入力を送信するための関数を作成します。

[Serializable]
public class HuggingfaceData
{
    public List<string> data { get; set; }
    public bool is_generating { get; set; }
    public double duration { get; set; }
    public double average_duration { get; set; }
}

public void RequestSmallTalk(string text)
{
    if (text == "")
        return;
    StartCoroutine(Upload(text));
}

IEnumerator Upload(string text)
{
    using (UnityWebRequest www = 
        new UnityWebRequest("https://lycoris52.net/sutouharu/api/predict", "POST"))
    {
        mLog.Add("user:" + text);
        string dialogue = "{\"data\":[\"";
        for (int i = 0; i < mLog.Count; ++i)
            dialogue += mLog[i] +  "|";
        dialogue += "\", \"mokuran\"]}";

        byte[] bodyRaw = Encoding.UTF8.GetBytes(dialogue);
        www.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
        www.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
        www.SetRequestHeader("Content-Type", "application/json");
        yield return www.SendWebRequest();
        if (www.isNetworkError || www.isHttpError)
        {
            Debug.Log(www.error);
            SetLogTextBoxText(www.error);
            //! remove last user utterance when error
            if (mLog.Count > 0)
                mLog.RemoveAt(mLog.Count - 1);
        }
        else
        {
            string result = www.downloadHandler.text;

            HuggingfaceData hfd = JsonConvert.DeserializeObject<HuggingfaceData>(result);
            TextToSpeech tts = JsonConvert.DeserializeObject<TextToSpeech>(hfd.data[0]);

            mLog.Add("assistant:" + tts.utterance);
            SetLogTextBoxText(tts.utterance);

            AudioClip audioClip = AudioClip.Create("voice", tts.audio_wave.Length, 1, 22050, false); //! base freq 22050 from VITS
            audioClip.SetData(tts.audio_wave, 0);
            mAudioClipList.Add(audioClip);
        }
    }
}

順番に説明しますと

public void RequestSmallTalk(string text)
{
    if (text == "")
        return;
    StartCoroutine(Upload(text));
}

こちらの RequestSmallTalk() は Input の UI に配置して、テキストが送信すると、この関数を呼ぶようにしています。

using (UnityWebRequest www = 
        new UnityWebRequest("https://lycoris52.net/sutouharu/api/predict", "POST"))

APIのURLを指定し、POSTでリクエストを送ります。

mLog.Add("user:" + text);
string dialogue = "{\"data\":[\"";
for (int i = 0; i < mLog.Count; ++i)
    dialogue += mLog[i] +  "|";
dialogue += "\", \"mokuran\"]}";

messages の配列に入れることを意識し、送るデータのフォーマットを整えます。フォーマットに関しては受け取り側がちゃんと処理できればなんでもいいですが、
今回は下記のようなフォーマットにしました。

user:テキスト1|assistant:チャットボットの返事1|user:テキスト2|assistant:チャットボットの返事2|user: …

byte[] bodyRaw = Encoding.UTF8.GetBytes(dialogue);
www.uploadHandler = (UploadHandler)new UploadHandlerRaw(bodyRaw);
www.downloadHandler = (DownloadHandler)new DownloadHandlerBuffer();
www.SetRequestHeader("Content-Type", "application/json");
yield return www.SendWebRequest();

ここでデータを送ります。日本語を使うので、Encoding は UTF8 にします。

if (www.isNetworkError || www.isHttpError)
{
    Debug.Log(www.error);
    SetLogTextBoxText(www.error);
    //! remove last user utterance when error
    if (mLog.Count > 0)
        mLog.RemoveAt(mLog.Count - 1);
}

なんらかエラーの場合最後のメッセージだけ消して、データ配列を正しく調整します。

string result = www.downloadHandler.text;

HuggingfaceData hfd = JsonConvert.DeserializeObject<HuggingfaceData>(result);
TextToSpeech tts = JsonConvert.DeserializeObject<TextToSpeech>(hfd.data[0]);

mLog.Add("assistant:" + tts.utterance);
SetLogTextBoxText(tts.utterance);

AudioClip audioClip = AudioClip.Create("voice", tts.audio_wave.Length, 1, 22050, false); //! base freq 22050 from VITS
audioClip.SetData(tts.audio_wave, 0);
mAudioClipList.Add(audioClip);

APIが正常に動いた場合、Response でもらったデータを処理します。
もらったデータには音声データも含めるので、その音声データを元に AudioClip を作成し、その後、Unity に設定していた Audio Source にプレイしてもらいます。

クライアント側からのデータを受けるためサーバ側の処理

def request_openai_message(input_text):
    client = OpenAI(api_key=os.environ["GPT_API_TOKEN"])
    init_str = """あなたの名前ははる。あなたは冒険者でこの町の隅っこに住んでいます。
好きな食べ物はカレー。エッチな質問やいたずらに対して沈黙してください。
町の名前はルコー。あなたは今暇なので、町案内をしている。
わからない質問されたら適当に答える。
返事は短くすること。会話で使わない文字も使わない。
酒場:町の東側にあって、酒の販売は17時からだが、食事なら11時から。メニューはカレー、ステーキ、野菜スープ、ソーセージなど。24時までやっている
冒険者ギルド:町の中央広場にある。24時間受付だが、新人登録は受付がいる10時から19時の間だ。お使いクエストや討伐クエストなど受けることができる。
ポーション屋:町の南側にある。冒険に必須のポーションを売っている店だ。店長の対応はドライだが、実は人見知りだけで、普通に優しい。定番のポーション以外も受注生産承るので、欲しいポーションがなければ聞いてみた方がいい。
雑貨店:冒険者ギルドの隣にある。日用品から冒険者用アイテムまで売っている。
               """
    messages=[
        {"role": "system", "content": init_str}
    ]
    
    text_log = input_text.split("|")
    for val in text_log:
        userind = val.find("user:")
        assistantind = val.find("assistant:")
        # remove conversation tag
        val = val.replace("user:","")
        val = val.replace("assistant:","")
        if userind != -1:
            messages.append({"role": "user", "content": val})
        elif assistantind != -1:
            messages.append({"role": "assistant", "content": val})

    text = ""
    retry_count = 0
    while text == "" and retry_count < 3:
        try:        
            response = client.chat.completions.create(model="gpt-4o", messages=messages)
            text = response.choices[0].message.content
        except Exception as inst:
            retry_count += 1
            time.sleep(1)
            
    text = text.replace("user:","")
    text = text.replace("assistant:","")

    return text

先ほどのGPT-4oのやり取りとClient側から受け取ったデータをGPT-4oに送るためのコードをまとめました。

    init_str = """あなたの名前ははる。あなたは冒険者でこの町の隅っこに住んでいます。
好きな食べ物はカレー。エッチな質問やいたずらに対して沈黙してください。
町の名前はルコー。あなたは今暇なので、町案内をしている。
わからない質問されたら適当に答える。
返事は短くすること。会話で使わない文字も使わない。
酒場:町の東側にあって、酒の販売は17時からだが、食事なら11時から。メニューはカレー、ステーキ、野菜スープ、ソーセージなど。24時までやっている
冒険者ギルド:町の中央広場にある。24時間受付だが、新人登録は受付がいる10時から19時の間だ。お使いクエストや討伐クエストなど受けることができる。
ポーション屋:町の南側にある。冒険に必須のポーションを売っている店だ。店長の対応はドライだが、実は人見知りだけで、普通に優しい。定番のポーション以外も受注生産承るので、欲しいポーションがなければ聞いてみた方がいい。
雑貨店:冒険者ギルドの隣にある。日用品から冒険者用アイテムまで売っている。
               """

今回のNPCの設定、冒険者で暇人なので、町案内する、という設定にしてみました。

ちなみに、「返事は短くすること」などを入れないとものすごい長いセリフが生成されることがあります。そうするとAPIの処理が長くなる分、使用料金も多く請求されますので、絶対入れたほうがいいです。

「返事は短くすること」などの指示をいれないとこうなる


    messages=[
        {"role": "system", "content": init_str}
    ]

ここで system タグを使うことでチャットボットを設定します。

text_log = input_text.split("|")
for val in text_log:
    userind = val.find("user:")
    assistantind = val.find("assistant:")
    # remove conversation tag
    val = val.replace("user:","")
    val = val.replace("assistant:","")
    if userind != -1:
        messages.append({"role": "user", "content": val})
    elif assistantind != -1:
        messages.append({"role": "assistant", "content": val})

ここで先ほど送られてきた Client 側のコードを GPT-4o API が使える形に揃えます。

    text = ""
    retry_count = 0
    while text == "" and retry_count < 3:
        try:        
            response = client.chat.completions.create(model="gpt-4o", messages=messages)
            text = response.choices[0].message.content
        except Exception as inst:
            retry_count += 1
            time.sleep(1)

最後にAPIを呼び出しています。なお、エラー時の3回までリトライする設定にします。

これで概ねできあがりとなります。
完成版のソースは下記で公開もしていますので、興味があればぜひご確認ください。


音声合成について

現在、音声合成は自作モデルを使っています。
ベースとなるコードやモデルは Plachtaa さんが公開している VITS-fast-fine-tuning を使っています。
詳しく説明すると非常に長くなってしまうため、割愛いたします。
もし興味があれば下記の Github リンクをご参考にして下さい。

まとめ

個人的な感想ですが、GPT-4oに差し替えただけで会話がかなり人間に近づいて来ましたね!
皆さんも是非お試しください!



\ カヤックボンドでは一緒に働く仲間を募集しています /

この記事が気に入ったらサポートをしてみませんか?