[ChatGPT]2次元キャラと会話したかったので会話アプリを自作してみた

こんにちは、@daifukusanです。

ChatGPTが登場してから約半年たちましたが、随分と色々なところで話を聞くようになりましたね。
最近、NHKなどでも取り上げられていて、改めてChatGPTが世の中に与えた影響の大きさを感じています。

これだけ浸透してくると、その使われ方も多種多様で本当に面白いサービスがどんどん登場しています。

そんな中、私が少し前に注目したのは、こんな記事でした。

https://note.com/kotarodayo1126/n/nfcfda7d64abb

元々、オタク趣味が講じて、情報系の大学に進み、システムエンジニアの道へ進んだ身としては、この記事を見たときに **「ついに二次元に旅立つ日が来たか！」** と思いました。

ただ、せっかくならキャラクターが表示されて、会話に合わせて動いてほしいところです。

というわけで、今回はChatGPTのAPIを組み込んだアニメキャラ会話アプリを作成しました。

この記事では、導入方法や技術的な説明などを紹介したいと思います。

# 1.導入方法
はじめに、以下の手順を実行するために、gitとdockerを使用できる環境を整えてください。

まずは、次のリポジトリをローカルにクローンします。

https://github.com/daifukusan/AIChat

Readmeに従って、以下のコマンドをターミナルで実行します。

```cmd
$ cd ./AIChat
$ docker-compose up -d
```

初回起動時のみ、追加で以下の作業が必要になります。

---

まず、下記コマンドを実行します。
```bash
docker run --rm -v $PWD:/code aichat python secret_regene.py
```

実行して表示された`SECRET_KEY = ・・・`をAIChat/settings.pyに書き込んでください。

---

`http://127.0.0.1:8000`にアクセスして、以下の画面が出てくればOKです。

![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2781058/7b36d8ea-20ac-a86b-5363-53c961d3f8bd.png)

簡単な使い方については、Githubのreadmeに掲載しているので、そちらを見てください。

# 2. システム構成について

今回のアプリの構成をまとめると以下の図のようになります。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2781058/4cf6df15-91cb-9391-9d2d-30d29b906eb9.png)

処理の流れに沿って、仕組みの解説をしていきたいと思います。

## 2.1. 音声入力
会話の入力方法については、①テキスト入力と②音声入力の2種類を用意しました。

そのうち、音声入力については、JavaScriptのSpeechRecognitionAPIを使用しています。
画面上のマイクボタンを押すとmain.jsのspeak関数が実行され、音声認識が開始されます。  
また、音声認識の結果をもとに、チャットエリアに自分の発言が書き込まれる仕組みです。

```javascript:main.js
const SpeechRecognition = webkitSpeechRecognition || window.SpeechRecognition;
const recognition = new SpeechRecognition();

// ユーザの音声を受け取りを開始する関数
async function speak() {

    recognition.onresult = (event) => {
        inputText = event.results[0][0].transcript;
        items.push({
            kind: 'me',
            at: getAt(),
            text: inputText,
        });
        updateChat();
    }
    await recognition.start();
}

document.getElementById("mic").addEventListener('click', (event) => {
    speak();
});
```

さらに、音声認識が完了すると、生成されたテキストを非同期通信でWebサーバに送ります。

```javascript:main.js
recognition.addEventListener('end', async (event) => {
    requestSpeakResponse();
});

function requestSpeakResponse(){
    const body = new URLSearchParams();
    body.append('input_text', inputText);

    fetch('./ajax_chat/', {
        method: 'POST',
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            'X-CSRFToken': csrftoken
        },
        body: body
    }).then((response) => {
        // 省略
    });
}
```

このあたりのロジックは、以下のサイトを参考にさせていただきました。

[[ 初心者向け ] 1時間でChatGPTのようなAIと会話できるサイトを作ってみた](https://www.breakfreeall.net/blog/2023/02/ai-speaking-chatgpt/)

## 2.2. AI応答取得、読み上げ音声データ取得
非同期通信により送られたテキストをインプットとして、ChatGPT APIにメッセージを投げ、会話の応答を取得します。
この応答テキストを別のコンテナで起動しているVoiceVoxサーバにクエリとして渡し、応答テキストの読み上げ音声データを取得します。

取得した音声データは、base64形式に変換して、応答テキストとともにjsonデータとしてクライアント側に返却されます。

一連のWebサーバの処理を今回はDjangoを使って実装しています。

```python:views.py
class ChatView(View):
    def post(self, request, *args, **kwargs):
        input_text = request.POST.get('input_text')

        if input_text == 'undefined':
            input_text = 'すみません。もう一度お願いします。'

        res_text = input_text
        openai.api_key = "YOUR API KEY"
        response = openai.ChatCompletion.create(
            model="gpt-3.5-turbo",
            messages=[
                {"role": "system", "content": "80文字以下の日本語で会話してください。"},
                {"role": "user", "content": input_text}
            ]   
        )
        res_text = response['choices'][0]['message']['content']
        print(input_text, res_text)

        res1 = requests.post('http://voicevox:50021/audio_query',params = {'text': res_text, 'speaker': 1})
        res2 = requests.post('http://voicevox:50021/synthesis',params = {'speaker': 1},data=json.dumps(res1.json()))

        enc = base64.b64encode(res2.content)
            
        d = {'text': res_text,
            'audio': 'data:audio/wav;base64,' + enc.decode()}
        return JsonResponse(d)
```

## 2.3. 音声再生

応答テキストと音声データが到着すると、応答テキストをチャット画面に表示し、同時に音声データを再生します。

また、音声再生が始まると、音声の波形を解析するためのAudioAnalyzerを初期化するinitAudioAnalyserメソッドを呼び出します。
音声の波形解析が必要な理由は、2.4.2のリップシンク内で説明します。

```javascript:main.js
function requestSpeakResponse(){
    const body = new URLSearchParams();
    body.append('input_text', inputText);

    fetch('./ajax_chat/', {
        method: 'POST',
        headers: {
            "Content-Type": "application/x-www-form-urlencoded",
            'X-CSRFToken': csrftoken
        },
        body: body
    }).then((response) => {
        return response.json();
    }).then((response) => {
        items.push({
            kind: 'you',
            at: getAt(),
            text: response.text,
        })
        updateChat();
        avatar.speakAction();
        audio.sound.setAttribute('src', response.audio);
        audio.sound.loop = false;
        audio.sound.play();
        if (!audio.isReady) {
            audio.initAudioAnalyser();
        }

    }).catch((err) => {
        console.log(err);
    });
}
```

## 2.4. アニメーション
今回、最も苦労したのが、この処理になります。

今回、javascriptで3Dモデルを動かすためにthree.jsを使用しています。
さらにvrmという形式の3Dオブジェクトをthree.js上で動かすために、pixivが提供している[@pixiv/three-vrm](https://github.com/pixiv/three-vrm)も利用しています。

すべての処理を説明すると非常に長くなってしまうので、
1. 通常時と会話時のアニメーションの切り替え
2. リップシンク

の2点についてのみ説明します。

### 2.4.1. 通常時と会話時のアニメーションの切り替え
まず、画面が読み込まれると同時に3Dの描画環境(カメラ、光源、3Dキャラなど)がロードされます。  
それと同時に、必要となるアニメーション(待機状態、会話状態)もロードしておきます。

下記のコードの下部のloadMixamoAnimationの部分がアニメーションのロードです。

```javascript:avatar.js
    loadModel() {
        loadVRM(this.modelUrl).then((vrm) => { // vrmを読み込む

            // 省略

            loadMixamoAnimation(this.animationUrl, this.currentVRM).then((clip) => { // アニメーションを読み込む
                this.idleAction = this.currentMixer.clipAction(clip);
                this.idleAction.play(); // アニメーションをMixerに適用してplay
            });
            loadMixamoAnimation(this.animationUrl2, this.currentVRM).then((clip) => { // アニメーションを読み込む
                this.chatAction = this.currentMixer.clipAction(clip);
            });
        });
    }
```

`this.idleAction`と`this.chatAction`にそれぞれのアニメーションをセットしました。
また、`this.idleAction`については、読み込みが完了したら、即再生するようにしています。

そして、音声データがサーバから届き、音声が再生されると同時に、skipActionメソッドが起動します。
このメソッドが呼ばれると、待機アニメーションを停止し、会話アニメーションが再生されます。

```javascript:avatar.js
    speakAction(){
        this.idleAction.stop();
        this.chatAction.reset().play();
    }
```

会話アニメーションが終わると、再び待機アニメーションが起動するようにします。
このとき、即座にアニメーションを切り替えてしまうと、アニメーションどうしのつなぎ目で不自然な動きが発生してしまいます。

そのため、会話がアニメーションが終了する(次のループに入る)タイミングを検知したら、待機アニメーションへ緩やかにフェードしていく処理を追加しています。

```javascript:avatar.js
    loadModel() {
        loadVRM(this.modelUrl).then((vrm) => { // vrmを読み込む

            // 省略

            this.currentMixer.addEventListener('loop', (event) => {
                if (event.action == this.chatAction) {
                    this.idleAction.crossFadeFrom(this.chatAction, 0.5);
                    this.idleAction.play();
                }
            });

            // 省略

        });
    }
```

### 2.4.2. リップシンク
体全体のモーションとは別に、音声データの再生中は音声に合わせて口パクを行うような処理(リップシンク)を追加しました。

リップシンクの処理は、本来であれば発話内容に合わせて口の開け締めを制御するのだと思いますが、私の技術では出来そうもありませんでした。

どこかのサイトに「音量に合わせて口の開け締めをするだけで、それっぽく見える」という記事を目にしたので、今回はその処理を自分で実装しました。

```javascript
    update = () => {
        
        // 省略

        if (this.currentVRM) { // VRMが読み込まれていれば
            if (analyser && freq) {
                const volume = Math.floor(this.audio.getByteFrequencyDataAverage());

                const aa = Math.pow(Math.min(80, volume), 2) / 3200.0;
                const oh = Math.pow(Math.max(40.0 - Math.abs(volume - 40.0), 0), 2) / 1600.0;
                const ih = Math.pow(Math.max(20.0 - Math.abs(volume - 20.0), 0), 2) / 400.0;
                const happy = (aa + oh + ih) / 6.0;

                if (volume < 10) {
                    this.currentVRM.expressionManager.setValue('happy', 0.1);
                    this.currentVRM.expressionManager.setValue('aa', 0.0);
                    this.currentVRM.expressionManager.setValue('oh', 0.0);
                    this.currentVRM.expressionManager.setValue('ih', 0.0);
                } else {
                    this.currentVRM.expressionManager.setValue('happy', happy);
                    this.currentVRM.expressionManager.setValue('aa', aa);
                    this.currentVRM.expressionManager.setValue('oh', oh);
                    this.currentVRM.expressionManager.setValue('ih', ih);
                }
            }
            this.currentVRM.expressionManager.setValue('blink', this.blinkValue());
            this.currentVRM.expressionManager.update();
            this.currentVRM.update(delta); // VRMの各コンポーネントを更新
        }
        this.renderer.render(this.scene, this.camera); // 描画
    };

```

前半にある`const volume = Math.floor(this.audio.getByteFrequencyDataAverage());`で、その時点で流れている音声データの波形から音量を取得します。
2.3.でAudioAnalyzerを初期化していたのは、この処理を行うためです。

以降の処理は、取得したvolumeの値に合わせて、口の開け方を調整しているだけです。
数値の大きさなどは素人が実際のアニメーションを見ながら適当に決めたものなので、もっと良い値や処理があると思います。

以上が、プログラムの説明になります。

# 3. 終わりに
もともとの動機は2次元キャラと会話したかっただけなのに、気づけばThree.jsやVoiceVox, ChatGPT APIなど扱ったことのない技術を使わなければいけなくて、非常に苦労しました。
ただ、久しぶりにまともにアプリ開発をしたので、いい経験になりました。

まだまだ、改善すべきところは残っていますが、誰でも使えるように公開してますので、どなたかが興味を持ってくれてより良いアプリに昇華していってくれれば嬉しいです。

また、無料で使えてクオリティの高いvrmモデルやモーションがありましたら、コメント欄などに書いてもらえるとすごく嬉しいです。