メインコンテンツへスキップ

Live2DフィーちゃんとChatGPTを組み合わせるまで(と必要な技術について)

    これは何

    これ↓の経緯を書いた記事です。

    Discordの方に投げた完全版上げとくか pic.twitter.com/DZPNAidocf

    — DECL (@ehf445) March 22, 2023

    作ったまでの記録と技術の一部を公開するべく執筆しました。
    しかし技術記事にしては 中身がない 個人的なことが多いのでnoteにまとめました。
    もしかしたら動画とかにするかも?それにしても時間がかかるので一時的に。

    バックエンド(ChatGPT+CeVIO)

    バックエンドはPythonで動いています。

    ChatGPTとの接続

    まずPythonでopenaiのライブラリからChatGPTを呼び出します。ChatGPTの人気に伴い、解説記事が無数にあるので割愛します。

    文頭に改行(\n)が入ったり入らなかったりするのでreplaceで消しました。副作用で文中の改行もなくなります。
    またprompt(GPT-3.5への入力)を固定しているので繋がった文の入力ができません(なので「〇〇として振る舞って〜〜」とかもできない)。
    注意点としては以前公開されていたDavinciモデルとCompletionの呼び出しが変わっているので過去記事をみる場合は気をつけましょう。

    import openai
    openai.api_key = "APIキー"
    model_engine = "gpt-3.5-turbo"
    
    input = "入力"
    prompt = [{"role": "user", "content": input}]
    completion = openai.ChatCompletion.create(model=model_engine,
                                                  messages=prompt)
    
    message = completion.choices[0].message.content.replace("\n", "")

    CeVIOとの接続

    過去に自分が書いた記事が役に立ちます。

    他には例えばAssistantSeikaを使うとパラメータの指定や他音声合成ソフト対応が簡単にできると思います(本プログラムでは利用していません)。

    ふつう100%だと語尾が上がりがちなのでちょっと落ち着きを混ぜます。

    import win32com.client
    
    cevio = win32com.client.Dispatch(
        "CeVIO.Talk.RemoteService2.ServiceControl2")
    cevio.StartHost(False)
    talker = win32com.client.Dispatch(
        "CeVIO.Talk.RemoteService2.Talker2V40")
    talker.Cast = "フィーちゃん"
    talker.components.At(1).Value = 90  # ふつう
    talker.components.At(4).Value = 10  # 落ち着き
    temppath = 'Temp/'
    hash = hashlib.md5(message.encode()).hexdigest()
    path = hash + ".wav"
    state = talker.OutputWaveToFile(message, temppath + path)
    analyzed = emotion_analyzer.analyze(message)

    感情分析を行う

    感情分析を入れたかったのでML-Askを使います。簡単に感情分析ができます。

    from mlask import MLAsk
    
    emotion_analyzer = MLAsk()
    emotion = emotion_analyzer.analyze(message)

    ただ、ChatGPTの返答は感情分析に引っかからないことが多いので感情は出づらいです。…まあアンドロイドっぽくていいか。

    Web APIにする

    Web側と通信するためにGETでリクエストできるようにします。
    今回はfastapiとuvicornを使いました。簡単にPythonの関数をWeb APIにできるアプリケーションです。

    URIはそれぞれlocalhost:8001/chat/{入力文}で以下のようなJSONを返し、localhost:8001/voice/{音声ファイルパス}で音声ファイルを返すようにしました。音声パスはmd5でハッシュにしてあります。

    {
        'message': "こんにちは!私はAIチャットボットです。何かお困りのことがあればお気軽にお聞きください。",
        'path': "90d4553dbbc093e7b10420ef06cb04d2.wav",
        'emotion': "iya"
    }

    「お困り」で“嫌”と分析されるのはちょっと違うなーという感じだったので、後で「…ください」を“好”で上書きしています。

    同一生成ポリシーの回避

    同一生成元ポリシーは異なるオリジンから得られたリソースをブロックする仕組みです。

    GETから返却するJSONも音声ファイルもリソースの一つです。ポートが異なる場合も別のオリジンとして認識されるのでこれを回避する必要があります。具体的には"Access-Control-Allow-Origin"ヘッダを返すようにします。

    最終的にコードにするとこんな感じ。

    from fastapi import FastAPI
    from fastapi.responses import FileResponse
    from starlette.middleware.cors import CORSMiddleware
    
    # GPTへの入力から感情分析、音声合成までの関数を
    # chatgpt_emotion_buildin.gpt_to_voiceemotion(input)としている。
    import chatgpt_emotion_buildin
    
    
    app = FastAPI()
    
    # Access-Control-Allow-Originを返す
    app.add_middleware(CORSMiddleware,
                       allow_origins=["*"],
                       allow_credentials=True,
                       allow_methods=["*"],
                       allow_headers=["*"])
    
    
    @app.get("/chat/{input}")
    def gpt_to_voiceemotion(input: str):
        print("input: " + input)
        return chatgpt_emotion_buildin.gpt_to_voiceemotion(input)
    
    
    @app.get("/voice/{path}")
    def ret_voice(path: str):
        print("audio_output: " + path)
        return FileResponse("Temp/" + path)

    フロントエンド(Live2D)

    Live2D SDK

    Live2DにはSDKがあるので、これを使っていきます。

    Unity、Native(C++)、Web(TypeScript)、Java、Cocoa向けがありますが今回はWebを選定しました。
    TypeScriptはJavaScript互換なのでJavaScriptから呼べば大丈夫です。

    次はフィーちゃんLive2DをLive2D_SDKに合わせていきます。

    注意点としては、パラメータのフォーマットが違います。
    フィーちゃんLive2DはIDが大文字、アンダーバー区切り(PARAM_XXX)になっていますが、Live2D SDKは大文字始まり小文字、区切りなし(ParamXxx)で読み込むため、名前を変える必要があります。

    次に、ParamAngleXとParamAngleY、ParamAngleZの最小値最大値が違います。Live2D SDK(Live2Dの推奨値)は±30ですがフィーちゃんLive2Dでは±15です。同様にParamBodyAngleXYZの最小値最大値も違います(±10→±15)。
    Live2D CubismEditorで編集したら.moc3ファイルと.model3.jsonファイルを書き出します。

    次に表情(Expression)をつけます。
    ↓の動画はExpressionをまとめたものです。

    表情いろいろフィーちゃん pic.twitter.com/3fBskUi3w1

    — DECL (@ehf445) January 27, 2023

    Expressionは↓のような感じのjsonで書きます。

    {
    	"Type": "Live2D Expression",
    	"Parameters": [
    		{
    			"Id": "ParamEyeROpen",
    			"Value": -1,
    			"Blend": "Add"
    	},
          {
    			"Id": "ParamEyeLOpen",
    			"Value": -1,
    			"Blend": "Add"
    	},
          {
    			"Id": "ParamCheek",
    			"Value": 1,
    			"Blend": "Add"
    	}
    	]
    }

    BlendはOverrideの方が良さそうでしたが意図したように動かなかったのでAddにしています。
    このExpressionを感情と結びつけます。

    export class LAppLive2DManager {
        const expressionArray = [['yorokobi', 'Happy'], ['ikari', 'Rage'], ['aware', 'Tear'], ['kowagari', 'Blue'], 
                                 ['haji', 'Exclamation2'], ['suki', 'Happy'], ['iya', 'Tear'], ['takaburi', 'Eye1'], 
                                 ['yasuragi', 'Happy'], ['odoroki', 'Exclamation']];
        const expressionMap = new Map(expressionArray);
        public async changeEmotion(){
            let input = (<HTMLInputElement>document.getElementById('input_form')).value;
            let expression = '';
            let res = await fetch("http://127.0.0.1:8001/chat/" + input,{mode:'cors'})
                .then((response) => response.json())
                .then(function(data) {
                    return data;
                    });
            (<HTMLInputElement>document.getElementById('output_form')).textContent = res['message'];
            expression = expressionMap.get(res['emotion']);
            path = res['path'];
            this._model.startVoice("http://127.0.0.1:8001/voice/" + path);
            this._model.setExpression(expression);
            }
    }

    音の再生とリップシンク

    サンプルの方にあるwavFileHandlerを用います。Audio.playと同時にwavFileHandler.startを実行し、updateの方でwavFileHandler.getRmsで取ります。

    export class LAppModel extends CubismUserModel {
        public update(): void {
            let value = 0.0;
            this._wavFileHandler.update(deltaTimeSeconds);
            value = this._wavFileHandler.getRms();
            value = value * 10;
            this._model.addParameterValueById(this._lipSyncId, value, 0.8);
        }
    
        public startVoice(path : string): void {
            const voice = new Audio(path);
            voice.play();
            this._wavFileHandler.start(path);
        }
    }

    おまけ

    頭をなでるモーション↓の方法です。


    画像

    頭の部分だけ当たり判定つけて目を閉じます。これだけで可愛くできます。

    バックエンドとの接続

    CORSでPythonとTypeScriptを接続します。具体的にはfetchAPIでリソースとしてGETで呼び出します。

    let res = await fetch("http://127.0.0.1:8001/chat/" + input,{mode:'cors'})
        .then((response) => response.json())
        .then(function(data) {
            return data;
            });

    あとは適当にインタフェース作って…
    完成。

    動いたあああああああああ pic.twitter.com/Sfs72Ocpfe

    — DECL (@ehf445) March 21, 2023

    全体的に

    応答時間

    対話インターフェイスで一番最初に気になるのは応答時間です。(え?気にならない?)
    全体通して平均的に3~6秒で応答が帰ってきます。4秒くらいまでは違和感なく対話できるのではないでしょうか。
    参考までにWebの表示速度(LCP)では4秒がユーザの限界、と考えられています。

    内訳としてはCeVIOがほぼ固定で1秒、残りがほとんどOpenAIの応答待ち時間です。音声合成と対話AIであることを考えるとどちらも信じられないくらい早いと思います。
    入出力が長文になるとCeVIOとOpenAI双方で時間が伸びます。この場合CeVIOでも3秒くらいかかります(がOpenAIの方は10秒くらい使う)。
    例えばSpeechRecognitionを使って音声入力とかも考えはしましたがこれ以上遅くなるのも許容できないので実装しませんでした(テストもめんどうだったし…)。

    終わりに

    実はLive2Dとチャット繋げたら楽しそうだよね、というのを今年一月くらいに考えてちまちま作っていました。

    形になってきた(ボタンとフォーム足しただけ) pic.twitter.com/vNM27ABwbC

    — DECL (@ehf445) January 29, 2023

    しかし以前のdavinciモデルでは推論が遅く、また回答も微妙だったためうまくできないかなーと思っていたところにGPT-3が出てきて対応した、というのが実情だったりします。

    ChatGPTに名前聞いたら山田太郎という人が中にいるらしい pic.twitter.com/LqqCYyHx4K

    — DECL (@ehf445) January 8, 2023

    Live2D SDKとTypeScriptは初めて扱いましたがそれなりに扱いやすくて良いと思います。CubismEditorには翻弄されましたが…
    無機質なGPT-3も音と画面があるだけで有機的に、キャラクターも人間らしい応答があるだけで可愛くなることができます。
    あとはGPTに入力する事前スクリプトを改良すればさらに対話している感じが出せるのでしょうか?この辺りは改善点ですね。

    あなたへのおすすめ