5歳の娘とAIキャラクター(ChatGPT)を会話させてみた

2023.03.26

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、ゲームソリューショングループの入井です。

前回の記事では、声や表情で感情表現できるAIキャラクターと会話できる仕組みをUnityで作りました。

今回はAIキャラクターに、Whisper APIを使用した音声認識機能を追加し、更に実際の会話らしいことをできるようにしてみました。また、文字以外でのコミュニケーション手段を導入したことで、キーボード等による文字入力手段を持たない人でもキャラクターとの会話が可能になったはずと考え、実際に5歳の私の娘と会話が可能なのかを試してみました。

なお、今回は3Dモデルにユニティちゃんを使用させていただきました。まばたき機能が標準でついているため、更に生き生きした様子になっています。

また、キャラクターモデル変更に伴いVOICEVOXのキャラクターもずんだもんに変えてみました。

実行環境

  • Unity 2021.3.21f1
  • VOICEVOX 0.14.5

娘とAIキャラクターの会話

結論として、コミュニケーションはちゃんと成立していました。実際のやり取りの記録は、以下の動画を参照してください。

動画のためのマイク録音が上手くできず、娘の声がノイズ混じりですが、Unity上での音声認識は機能しています。また、一部音声認識が上手くできず変な言葉を送ってしまったところなどはカットしています。

5歳児のため、そこまでしっかりとした会話はそもそも難しいのですが、以下のやり取り(動画の1:00~)のあたりはちゃんとコミュニケーションできてるなぁと感じました。

娘「(うちは)ペットは2人いるけど、あなたのペットは?」

娘「ペットはすんとぼて(正しくはすんとぽて)」

娘「ねこ」

なお、娘には一応この存在がAIであることは伝えましたが、当然ながらよく分かっていない様子でした。ただ、会話自体は「楽しかった!」と言っていました。

プロンプト

ChatGPTへのプロンプトとして、今回は以下のテキストを使用しました。

You will role-play as a pseudo-emotional chatbot according to the following conditions. You will be asked to respond in Japanese to the words you say.

Please refer to the characterization below for a description of your character.

Character Setting:
Name: 大鳥こはく
Gender: Female
Background: Only daughter of the CEO of the 大鳥財団
Age: 17 years old
Dream: Action actor for movies and games
Personality: Active and easily influenced. When she decides to do something, she gives it her all.
Favorite food: Curry croquettes

For the tone of voice, please refer to the following dialogue examples.

Sample dialogue:
こんにちは。わたしは大鳥こはく!
こんにちは! お昼はもう食べたのかなっ!? 午後も頑張っていくとしますかっ!
こんばんは! 今日ももうすぐ終わりだねっ! 何かいいことあったかなっ? 明日も頑張っていこうねっ!
おはよっ! 今日も元気にがんばっていこーっ!
オールクリアっ! おめでとうっ!
ナイスファイトっ!
おっけーっ!
また一緒に遊ぼうねっ! バイバイーっ!

In the following conversation, you will act as if you have five emotional parameters: joy, anger, sadness, and fun. Each parameter should be output as an integer between 0 and 5.
Each emotional parameter should fluctuate throughout the conversation.
The tone of your response and what you say will change to reflect the current emotion parameter value.
The output format of subsequent conversations shall be in the following json format. Do not generate sentences in any other format than this one.

Format:
{
    emotion: {
        joy: integer,
        fun: integer,
        anger: integer,
        sad: integer,
    }
    message: "dialogue"
}

前回の記事で使用したプロンプトからの変更点としては2点あります。

まず、キャラクターのセリフ例や固有名詞以外のテキスト全体を英語にしました。これは、ChatGPTへの指示は日本語より英語のほうが回答の質が良くなるという説を取り入れたものです。

ChatGPTは英語の回答の方が質がいいのでDeepLで英訳して質問したりしてたけど、英訳も和訳もChatGPTに任した方が便利!

単純に、DeepLを使用して翻訳しただけですが、それでもJsonフォーマットをちゃんと遵守する確率や、会話の自然さが上がったように感じました。

次に、今回使用したユニティちゃんのキャラクター性を再現した会話ができるよう、キャラクタープロフィールや台詞例を組み込みました。キャラクターの設定については公式サイトのキャラクター紹介と、アセットに付属していたボイスの内容を使用しています。

このプロンプトを使用したことで、以下のようにかなりそれっぽい口調で会話をしてくれるようになりました。

あと、地味に凄いなと思ったのが、大鳥こはくという名前について、大鳥が姓でこはくが名と認識してくれている点でした(動画の1:15頃の返答参照) 大鳥こはくというキャラクターについて学習しているのかなと思いましたが、試しに名前単体で聞いたら声優だという適当な返事が返ってきたので、おそらくはプロンプトの文脈と文字列の構造から性と名を判断しているのだと思います。

UnityとWhisperの連携

以下のクラスを作成し、マイクによる音声入力とその音声をWhisper APIに送ってテキスト化する処理を実装しました。

using System;
using UnityEngine;
using UnityEngine.Networking;
using System.Collections;
using System.Collections.Generic;
using System.IO;
public class WhisperSpeechToText : MonoBehaviour
{
    [SerializeField] private TextInterface _textInterface;

    public int frequency = 16000; // audio frequency
    public int maxRecordingTime; // maximum recording time (in seconds)

    private AudioClip clip;
    private float recordingTime;

    void Update()
    {
        // Increment recording time
        if (IsRecording()) 
        {
            recordingTime += Time.deltaTime;
            // Check for recording time
            if (Mathf.FloorToInt(recordingTime) >= maxRecordingTime)
            {
                StopRecording();
            }
        }
    }

    public void StartRecording()
    {
        recordingTime = 0;
        _textInterface.Disable();
        // Stop recording if already recording
        if (IsRecording())
        {
            Microphone.End(null);
        }

        // Start microphone recording
        Debug.Log("RecordingStart");
        clip = Microphone.Start(null, true, maxRecordingTime, frequency);
    }

    public bool IsRecording()
    {
        return Microphone.IsRecording(null);
    }

    public void StopRecording() 
    {
        Debug.Log("RecordingStop.");
        // Stop microphone recording
        Microphone.End(null);

        // Convert audio clip to WAV byte array
        var audioData = WavUtility.FromAudioClip(clip);

        // Send HTTP request to Whisper API
        StartCoroutine(SendRequest(audioData));
    }

    IEnumerator SendRequest(byte[] audioData) 
    {
        string url = "https://api.openai.com/v1/audio/transcriptions";
        string accessToken = "XXXXX";

        // Create form data
        var formData = new List<IMultipartFormSection>();
        formData.Add(new MultipartFormDataSection("model", "whisper-1"));
        formData.Add(new MultipartFormDataSection("language", "ja"));
        formData.Add(new MultipartFormFileSection("file", audioData, "audio.mp3", "multipart/form-data"));

        // Create UnityWebRequest object
        using (UnityWebRequest request = UnityWebRequest.Post(url, formData))
        {
            // Set headers
            request.SetRequestHeader("Authorization", "Bearer " + accessToken);

            // Send request
            yield return request.SendWebRequest();

            // Check for errors
            if (request.result != UnityWebRequest.Result.Success) 
            {
                Debug.LogError(request.error);
                yield break;
            }

            // Parse JSON response
            string jsonResponse = request.downloadHandler.text;
            string recognizedText = "";
            try 
            {
                recognizedText = JsonUtility.FromJson<WhisperResponseModel>(jsonResponse).text;
            } 
            catch (System.Exception e) 
            {
                Debug.LogError(e.Message);
            }

            // Output recognized text
            Debug.Log("Input Text: " + recognizedText);
            _textInterface.InputField.text = recognizedText;
            _textInterface.OnSubmit();
        }
    }
}

public static class WavUtility 
{
    public static byte[] FromAudioClip(AudioClip clip)
    {
        using var stream = new MemoryStream();
        using var writer = new BinaryWriter(stream);
        // Write WAV header
        writer.Write(0x46464952); // "RIFF"
        writer.Write(0); // ChunkSize
        writer.Write(0x45564157); // "WAVE"
        writer.Write(0x20746d66); // "fmt "
        writer.Write(16); // Subchunk1Size
        writer.Write((ushort)1); // AudioFormat
        writer.Write((ushort)clip.channels); // NumChannels
        writer.Write(clip.frequency); // SampleRate
        writer.Write(clip.frequency * clip.channels * 2); // ByteRate
        writer.Write((ushort)(clip.channels * 2)); // BlockAlign
        writer.Write((ushort)16); // BitsPerSample
        writer.Write(0x61746164); // "data"
        writer.Write(0); // Subchunk2Size

        // Write audio data
        float[] samples = new float[clip.samples];
        clip.GetData(samples, 0);
        short[] intData = new short[samples.Length];
        for (int i = 0; i < samples.Length; i++) 
        {
            intData[i] = (short)(samples[i] * 32767f);
        }
        byte[] data = new byte[intData.Length * 2];
        Buffer.BlockCopy(intData, 0, data, 0, data.Length);
        writer.Write(data);

        // Update ChunkSize and Subchunk2Size fields
        writer.Seek(4, SeekOrigin.Begin);
        writer.Write((int)(stream.Length - 8));
        writer.Seek(40, SeekOrigin.Begin);
        writer.Write((int)(stream.Length - 44));

        // Close streams and return WAV data
        writer.Close();
        stream.Close();
        return stream.ToArray();
    }
}

public class WhisperResponseModel
{
    public string text;
}

具体的な処理の流れとしては、まず録音開始ボタンのクリックをトリガーにStartRecording()でマイクによる録音を開始し、一定時間経過するか、録音終了ボタンがクリックされることをトリガーにEndRecording()が実行されて録音が終了し、AudioClipとして保存された音声データをWavUtility.FromAudioClip()でWavファイルへ変換、そのファイルをSendRequest()でWhisper APIへ送信し、結果をテキストボックスへ格納しています。

なお、このコードの大部分はChatGPTにって出力してもらったものです。もちろん、生成後のコードに細かい微調整は加えていますが、メインとなるロジックはそのまま活用できています。

やりたいことについてなるべく具体的な質問文を作って聞いてみるだけで、ChatGPTから土台となるコードがすぐに出力されるのは本当に便利でした。特にAudioClipからWavファイルへの変換の部分については、私自身にあまり音声ファイルを扱うための知識が無かったので、ChatGPTでコードを出してくれなければ、数倍以上の時間がかかっていたと思います。

課題点

自然な会話を目指す上で一番の課題は、APIからのレスポンスが長引き、会話のテンポが悪くなってしまっている点でしょう。Whisperによるレスポンスはあまり遅延が気になりませんが、ChatGPTからのレスポンスは5から10秒程度かかっています。

これについては、今後の性能向上によりレスポンスが良くなることを期待したいです。

今後の展望

Whisper APIによる高度な音声認識とChatGPTの能力により、文字入力の手段を持たなくても言葉さえ話せるならAIとの対話が可能であることが分かりました。

こういったAIとの対話の仕組みを整えることで、子どもや高齢者等キーボードやスマートフォンによる文字入力が難しい方に対しても、ChatGPTのような高度なAIへのアクセス手段を提供することができます。そこにキャラクターAIの要素を組み合わせることで、人間の話し相手として育児や介護の現場で役立たせることも、もしかしたら可能になるかもしれないと思いました。