〈備忘録〉Socket.IOでpythonからlive2dモデルを動かす。

# はじめに

この記事では、Socket.IOライブラリを使用して、pythonから任意のlive2dmotionファイルをlive2dモデルへ適用した時の備忘録です。
説明やコードに未熟な点があるかと思いますがご容赦願いたいです。
私の環境はWindows10、vscodeです。

# １．準備
初めにCubismSdkForWebと検索しLive2dCubism公式からダウンロードし、任意の場所に展開します。
その後、Samples\TypeScript\Demoのディレクトリ（以降もこのディレクトリで実行）で
``` npm install  ```、```npm build```
を実行しパッケージダウンロードとビルドを行います。
```npm serve```
を実行します。
その後 ctri + F5　（もしくは Run　→　Start Debugging）　でWEBの画面を表示します。
下記画像のようにモデルが表示されれば成功です。
(うまくいかない場合は、```npm run build```のようにrunを加えるなどしてみてください)
画像の歯車の部分でモデルを切り替えることができます。
![スクリーンショット (159).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3511589/18fba09d-c33b-321e-6edb-54ba51c99dc0.png)


# ２．表示

デフォルトの状態では、モデルがCubism側で複数用意されており、歯車も表示されています。
ここでは、表示するモデルを1つだけにし、歯車アイコンも消していきます。

始めに歯車アイコンを非表示にします。
```CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo\src\lappdefine.ts```
へ移動し、30行目あたりに下記のコードがあるので歯車の部分を空にします。
```
// モデルの後ろにある背景の画像ファイル
export const BackImageName = 'back_class_normal.png';

// 歯車
export const GearImageName = 'icon_gear.png';

// 終了ボタン
export const PowerImageName = 'CloseNormal.png';
```

複数用意されているモデルのうち、1つのみ表示するために、同じlappendfine.tsファイルのすぐ下に
```// モデル定義---------------------------------------------
// モデルを配置したディレクトリ名の配列
// ディレクトリ名とmodel3.jsonの名前を一致させておくこと
export const ModelDir: string[] = [
  'Haru',
  'Hiyori',
  'Mark',
  'Natori',
  'Rice',
  'Mao',
  'Wanko'
];
```
とあるので、名前を1つのみ残して他はすべて消します。（以降の説明ではHiyoriを使用します。）


ここまで完了したらもう一度ビルドを実行し、変更を反映させます。
```npm run serve```を実行し、歯車アイコンが消え、Hiyoriが表示されている場合成功です。


# 3.モーションファイル
ここではmotionファイルの調整をします。
まず初めにmotionグループの追加をするために、下記のコードをlapdefine.tsファイルに追加します。
```
//Add motionGroupを追加
export const MotionGroupAdd = "Add";
```

次にmodel3.jsonファイルの調整をするため、
```\CubismSdkForWeb-5-r.1\Samples\Resources\Hiyori\Hiyori.model3.json```
へ移動します。
初期状態ではildeグループにのみmotionが定義されており、idleの中からランダムにmotionが選ばれ実行されるようになっています。
そのため、idleグループにmotionを1つのみ残し（実質の待機motion）、それ以外をaddグループに移します。
ここでは```"motions/Hiyori_m01.motion3.json"```のみをidleに残し、他をaddに以下のように"Motions"内に追加します。
```
		"Motions": {
			"Idle": [
				

				{
					"File":"motions/Hiyori_m01.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				}
			],
			"Add":[
				{	
					"motion_id":1,
					"File": "motions/Hiyori_m02.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
                {	
					"motion_id":2,
					"File": "motions/Hiyori_m03.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{
					"motion_id":3,
					"File": "motions/Hiyori_m05.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{	"motion_id":4,
					"File": "motions/Hiyori_m06.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{	"motion_id":5,
					"File": "motions/Hiyori_m07.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{	"motion_id":6,
					"File": "motions/Hiyori_m08.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{	"motion_id":7,
					"File": "motions/Hiyori_m09.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				},
				{	"motion_id":8,
					"File": "motions/Hiyori_m10.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				}
			],
			"TapBody": [
				{
					"File": "motions/Hiyori_m04.motion3.json",
					"FadeInTime": 0.5,
					"FadeOutTime": 0.5
				}
			]
		}
	
```

ここで一度ビルドとコードの実行を行い、モデルが一つのmotionのみ繰り返していることを確認します。


# 4 motionを呼び出す関数を定義する
ここでは、先ほど作成したAddモーショングループから、motionファイルを呼び出すための関数周りを実装していきます。
まず初めにpythonのコードを書きます。以下がコードです。
Flaskライブラリを使用するので、適宜インストールしてください。
```
from flask import Flask
from flask_socketio import SocketIO
import time
import threading
import random

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")  # CORSを設定する場合

def background_task():
    """バックグラウンドで定期的に実行されるタスク"""
    while True:
        time.sleep(5)  # 5秒おきに実行
        motion_id = random.randint(0,8)
        socketio.emit('motion_event', {'motion_id': motion_id})  # クライアントにmotion_idを送信
        print(f"Sent motion_id: {motion_id}")

@app.route('/')
def index():
    print("Flask SocketIO Server")
    return "Flask SocketIO Server"

if __name__ == '__main__':
    threading.Thread(target=background_task).start()  # バックグラウンドタスクをスレッドで開始
    socketio.run(app, debug=True, host='127.0.0.1', port=3000)  

```

コードを配置する箇所は、cubismSdkForWebと同じディレクトリにpythonディレクトリを作成し、そこへコードを置きました。
ここではsocketioライブラリを使用してフロント・バックエンド間の非同期の通信を行っています。pythonはport 3000,後述するhtmlファイルをport5000で実行し、それをsocketioでつなげています。
コード内では5秒おきに0~8の数字をフロントへ送っていますが、これはAddモーショングループにあるmotionパスが9個あり、それをインデックスで選び出すようにフロントやtypescriptで処理しているためです。


#### 次はhtmlファイルを調整します。
まず、```CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo\index.html```へ移動します。
htmlファイルを以下のように書き換えます。
```
<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>TypeScript HTML App</title>
  <style>
    html, body {
        margin: 0;
        overflow: hidden;
    }
    canvas {
        width: 100vw;
        height: 100vh;
        display: block;
    }
  </style>
  <!-- Live2DCubismCore script -->
  <script src="./Core/live2dcubismcore.js"></script>
  <!-- Build script -->
  <script src="./src/main.ts" type="module"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.0/socket.io.js"></script>
</head>
<body>
  <script  type="text/javascript">
    document.addEventListener('DOMContentLoaded', (event) => {
      const socket = io('http://127.0.0.1:3000');  // サーバーへの接続を開始
      socket.on('connect', () => {
        console.log('Connected to the server!');
      });
      socket.on('motion_event', (data) => {
        console.log('Received motion_id:', data.motion_id);
        if (window.laplive2dmanagerts.decideMotion) {
          window.laplive2dmanagerts.decideMotion(data.motion_id);
          console.log("decideMotion called with motionId:", data.motion_id);
          setTimeout(() => {
            if (window.laplive2dmanagerts.defaultMotion) {
              const defaultMotionId = 0; // Default motion_id
              window.laplive2dmanagerts.defaultMotion(defaultMotionId);
              console.log("defaultMotion called with motionId:", defaultMotionId);
            } else {
              console.error("defaultMotion function not available.");
            }
          }, 2000);
        } else {
          console.error("decideMotion function not available.");
        }
      });
    });
  </script>
</body>
</html>

```

### 次はいよいよtypescriptファイルを調整します。
```CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo\src\lapplive2dmanager.ts```
へ移動します。
laplive2dmanagerファイルへ下記の関数を追加します。
```
public async decideMotion(motion_id: number): Promise <void> {
    for(let i: number = 0; i < this._models.getSize(); i++)
      {
        this._models.at(i).startMotion(LAppDefine.MotionGroupAdd, motion_id, LAppDefine.PriorityForce);
      }
  }
  public async defaultMotion(motion_id: number): Promise <void> {
    motion_id = 0;
    for(let i: number = 0; i < this._models.getSize(); i++)
      {
        this._models.at(i).startMotion(LAppDefine.MotionGroupIdle, motion_id, LAppDefine.PriorityForce);
      }
  }
```

ここではlapmodelがコード内でmodelとしてインスタンス化されているので、modelからlapmodelファイルのstartMotion関数を呼び出すことで、任意のmotionファイルが適用されるようにしています。

また、このlaplive2dmanagerファイルのコンストラクタへ下記を追記します。
```
window.laplive2dmanagerts = {};
    window.laplive2dmanagerts.decideMotion = function(motion_id) { 
      LAppLive2DManager.getInstance().decideMotion(motion_id); 
    };
    window.laplive2dmanagerts.defaultMotion = function(motion_id) { 
      LAppLive2DManager.getInstance().defaultMotion(motion_id); 
    };
```
ここでは、laplive2dmanagerをwindowオブジェクトにし、グローバル変数化することで、他ファイルからも参照できるようにします。
具体的には先ほどのhtmlファイルからlaplive2dmanager内の関数を呼び出すためです。

### ここまでくればあともう少しです。
次は、ts.configファイルなどの設定ファイルをいじります。
まず、windowオブジェクトを適用するための、windowファイルを作成します。
```CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo\window.d.ts```へwindow.d.tsファイルを作成し、下記のコードを書き込みます。
```
// window.d.ts
export {};

declare global {
  interface Window {
    laplive2dmanagerts: {
      decideMotion?: (motionId: number) => void;
      defaultMotion?: (motionId: number) => void;
    };
  }
}

```
次に上記のwindow.d.tsファイルをプログラムに認識させるため、ts.config.jsonを下記のように調整します。

ディレクトリパス：
```CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo\tsconfig.json```

```
{
  "compilerOptions": {
    "target": "es6",
    "moduleResolution": "node",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "forceConsistentCasingInFileNames": true,
    "outDir": "./dist",
    "removeComments": true,
    "sourceMap": true,
    "baseUrl": "./",
    "paths": {
      "@framework/*": ["../../../Framework/src/*"]
    },
    "noImplicitAny": true,
    "useUnknownInCatchVariables": true
  },
  "include": [
    "src/**/*.ts",
    "../../../Core/*.ts",
    "window.d.ts"
  ],
  "exclude": [
    "node_modules",
    "dist"
  ]
}

```
ここまでくれば完成です。実際に動かしましょう。

＊pythonコードでは、htmlファイルから呼び出しているlaplive2dmanagerに定義したdecideMotion関数を、５秒間隔で呼び出しています。そして、decideMotion関数が終わり次第defaulutMotion関数を呼び出し、これがループしています。
では最初に、コード実行時に呼び出されているmotionはどこから呼び出されているかというと、おそらくlapmodelファイルのstartMotionかstartRandomMotion関数がどこかで呼ばれ、その際にidleモーショングループからmotionが選ばれていると思うので、idleモーショングループに待機モーションと同じパスを残して置くのが良いかもです。

# 5.実行

いよいよ、コードを実行します。
コードを実行する前に
```live2d\CubismSdkForWeb-5-r.1\Samples\TypeScript\Demo```
でビルドを行ってください。

初めにpythonのコードを動かします。私はvscodeなので、右上の再生ボタンみたいな三角ボタンで実行しました。
port 3000で起動し、Sent motion_id: 6　のようにmotion_idという変数と数値が繰り返し表示されていれば成功です。

次にhtmlファイルを実行します。
実行方法は最初の時と同じで、
```npm serve```もしくは```npm run serve```を実行します。
その後 ctri + F5　（もしくは Run　→　Start Debugging）　でWEBの画面を表示します。

その時に、live2dのmodelの動きが一定間隔で切り替われば、成功です。
こうして、pythonから任意のmotionファイルを指定して、live2dモデルに適用することができました。


# 最後に
解説は上記で終わりです。


私がpythonからlive2dモデルを制御しようと考えたのにはAItuberという存在があります。
これは端的に言えばVtuberの中がAIに置き換わった存在です。
私はAItuberや、もっといえば創作の中に登場するゴーレムや人形、AIのように、人間と遜色ない会話をする人工物に興味があり、そのステップの一つとして、LLM（大規模言語モデル）を制御するpythonとフロントエンドをつなげ、live2dモデルの動きをLLMの出力に合わせて変化させたいなと思っています。
メラビアンの法則のように、「言語情報7％」「聴覚情報38％」「視覚情報55％」が人と人がコミュニケーションを図る際に影響を与えているとされ、それであれば、人間と遜色ない会話をできるAIを作るなら、LLMに注力するだけでなく、live2dモデルの所作にも気を配る必要があると思い、実装してみました。
今後はLLMの出力を感情分析にかけ、その感情分析結果を利用してmoition_idのように、任意のmotionを再生し、出力とモデルの所作が一致するチャットボットを作成したいと考えています。
LLMのファインチューニングやRAG,voicevoxのAPI,リップシンクなどやれることはまだまだあるので着実に取り組み、また備忘録としてQiitaにまとめたいです。


すでに夏の気配すら感じられる内定が無いこのごろ。