├── .gitignore ├── .images ├── example1.png └── settings.png ├── LICENSE ├── README.md ├── chatmaya ├── __init__.py ├── core.py ├── exec_code.py ├── info.py ├── openai_utils.py ├── prompts.py ├── settings.py └── voice.py └── install ├── install_maya2023_win.bat ├── install_maya2024_win.bat ├── install_win.bat └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.images/example1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akasaki1211/ChatGPT_Maya/260d9c8106a55a8de40238c4cdc95fc9a4ae6451/.images/example1.png -------------------------------------------------------------------------------- /.images/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akasaki1211/ChatGPT_Maya/260d9c8106a55a8de40238c4cdc95fc9a4ae6451/.images/settings.png -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hiroyuki Akasaki 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ChatMaya 2 | MayaからChatGPT API([gpt-3.5-turbo / gpt-4](https://platform.openai.com/docs/guides/chat))を呼び出し、Python/MELスクリプトを生成・実行するGUIです。 3 | [VOICEVOX ENGINE](https://github.com/VOICEVOX/voicevox_engine)が起動していると、同時に読み上げが行われます。 4 | 5 | ![example1](.images/example1.png) 6 | 7 | > テスト環境 : 8 | > * Windows 10/11 9 | > * Maya 2023 (Python3.9.7) 10 | > * Maya 2024 (Python3.10.8) 11 | 12 | ## インストール 13 | 1. [Account API Keys - OpenAI API](https://platform.openai.com/account/api-keys)よりAPI Keyを取得し、環境変数`OPENAI_API_KEY`に設定する 14 | 15 | 2. Codeをzipダウンロードし、任意の場所に解凍する。 16 | 17 | 3. Mayaを起動していない状態で、`install/install_maya20XX_win.bat`を実行する。 18 | 19 | ## マニュアルインストール 20 | 1. [Account API Keys - OpenAI API](https://platform.openai.com/account/api-keys)よりAPI Keyを取得し、環境変数`OPENAI_API_KEY`に設定する 21 | 22 | 2. Codeをzipダウンロードし、任意の場所に解凍する。 23 | 24 | 3. 必要なパッケージをインストールする 25 | * Maya本体に入れる場合 : 26 | `mayapy.exe -m pip install -U -r requirements.txt` 27 | * Users以下、Mayaバージョン固有のフォルダにインストールする場合 : 28 | `mayapy.exe -m pip install -U -r requirements.txt --target C:/Users/<ユーザー名>/Documents/maya/<バージョン>/scripts/site-packages` 29 | 30 | > 必要なパッケージは[install/requirements.txt](install/requirements.txt)に書いてありますか、大幅に仕様が違くなければバージョンは厳密に合わせる必要は無いと思います。 31 | > 参考 : [mayapy と pip を使用して Python パッケージを管理する](https://help.autodesk.com/view/MAYAUL/2023/JPN/?guid=GUID-72A245EC-CDB4-46AB-BEE0-4BBBF9791627) 32 | 33 | 4. `chatmaya`をインストールする 34 | 次のいずれかを実施: 35 | * `C:/Users/<ユーザー名>/Documents/maya/<バージョン>/scripts`に`chatmaya`フォルダをコピーする 36 | * 環境変数`PYTHONPATH`に`chatmaya`の親フォルダを追加する 37 | * `C:/Users/<ユーザー名>/Documents/maya/<バージョン>/Maya.env`に`PYTHONPATH=`を追記する 38 | 39 | ## 実行 40 | ```python 41 | import chatmaya 42 | chatmaya.run() 43 | ``` 44 | 45 | ## 使用方法 46 | * 左側下部のテキストフィールドにプロンプトを打ち込み送信ボタンを押すとAPIにリクエストが送信され返答が表示されます。 47 | * 返答はPython/MELコードとその他の部分に分解されそれぞれのフィールドに表示されます。 48 | * 返答に複数のコードブロックが書いてあった場合は、右側下部のプルダウンから選択出来るようになります。 49 | * New Chatを押すかウィンドウを閉じるまでは、会話履歴が残ります。(※概算トークン数が一定数を超えると古い履歴から削られていきます。) 50 | * ログ、設定ファイル、書いてもらったスクリプトファイルは随時、`C:\Users\<ユーザー名>\Documents\maya\ChatMaya`に出力されています。 51 | * 別途[VOICEVOX ENGINE](https://github.com/VOICEVOX/voicevox_engine)が起動していると、自動的にコードブロック以外の部分の読み上げが行われます。使用する場合はGPUモード推奨です。 52 | * Settings > Open Settings Dialog より各種設定値を変更できます。 53 | ![settings](.images/settings.png) 54 | 55 | ## アンインストール 56 | batでインストールしている場合、以下のフォルダを削除すればアンインストールされます。 57 | * ツール本体:`C:\Users\<ユーザー名>\Documents\maya\\scripts\chatmaya` 58 | * 設定/ログ:`C:\Users\<ユーザー名>\Documents\maya\ChatMaya` 59 | 60 | 追加パッケージは`pip uninstall`で個別に行うか以下のフォルダを丸ごと削除してください。 61 | * `C:\Users\<ユーザー名>\Documents\maya\\site-packages` 62 | 63 | ## リンク 64 | ### 解説, サンプル 65 | ※[beta](https://github.com/akasaki1211/ChatGPT_Maya/tree/beta)時点での解説です 66 | * [ChatGPT API を使用してMayaを(Pythonスクリプトで)操作してもらう - Qiita](https://qiita.com/akasaki1211/items/34d0f89e0ae2c6efaf48) 67 | * [サンプル(Twitter)](https://twitter.com/akasaki1211/status/1632704327340150787) 68 | 69 | ### コード参考 70 | * [ChatGPT APIを使ってAIキャラクターを作ってみる! - Qiita](https://qiita.com/sakasegawa/items/db2cff79bd14faf2c8e0) 71 | * [【Python】ChatGPT APIでウェブサイト版のように返答を逐次受け取る方法 - Qiita](https://qiita.com/Cartelet/items/cfc07fc499b6ebbc7dde) 72 | -------------------------------------------------------------------------------- /chatmaya/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import os 3 | 4 | from maya import cmds 5 | 6 | from . import info, core, prompts, openai_utils, voice, exec_code, settings 7 | from importlib import reload 8 | reload(info) 9 | reload(core) 10 | reload(prompts) 11 | reload(openai_utils) 12 | reload(voice) 13 | reload(exec_code) 14 | reload(settings) 15 | 16 | def run(): 17 | try: 18 | os.environ['OPENAI_API_KEY'] 19 | except KeyError: 20 | cmds.error(u'環境変数 OPENAI_API_KEY が設定されていません。') 21 | else: 22 | core.showUI() -------------------------------------------------------------------------------- /chatmaya/core.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | import re 4 | from uuid import uuid4 5 | from pathlib import Path 6 | from datetime import datetime 7 | from concurrent.futures import ThreadPoolExecutor 8 | import queue 9 | import keyboard 10 | import subprocess 11 | 12 | from maya import cmds, OpenMaya, OpenMayaUI 13 | from PySide2 import QtWidgets, QtCore 14 | from shiboken2 import wrapInstance 15 | 16 | from .info import ( 17 | TITLE, 18 | VERSION, 19 | ABOUT_TXT, 20 | USER_SETTINGS_DIR, 21 | USER_SETTINGS_INI, 22 | USER_SETTINGS_JSON, 23 | LOG_DIR 24 | ) 25 | from .prompts import ( 26 | SYSTEM_TEMPLATE_PY, 27 | SYSTEM_TEMPLATE_MEL, 28 | USER_TEMPLATE, 29 | FIX_TEMPLATE 30 | ) 31 | from .openai_utils import ( 32 | num_tokens_from_text, 33 | chat_completion_stream, 34 | DEFAULT_CHAT_MODEL 35 | ) 36 | from .voice import ( 37 | text2voice, 38 | play_wave 39 | ) 40 | from .exec_code import ( 41 | exec_mel, 42 | exec_py 43 | ) 44 | from .settings import Settings, SettingsData 45 | 46 | MAX_MESSAGES_TOKEN = 2500 47 | DEFAULT_GEOMETORY = (400, 300, 900, 600) 48 | 49 | def maya_main_window(): 50 | main_window_ptr = OpenMayaUI.MQtUtil.mainWindow() 51 | return wrapInstance(int(main_window_ptr), QtWidgets.QMainWindow) 52 | 53 | def showUI(): 54 | window = ChatMaya(parent=maya_main_window()) 55 | window.show() 56 | 57 | class ChatMaya(QtWidgets.QMainWindow): 58 | 59 | def __init__(self, parent=None, *args, **kwargs): 60 | super(ChatMaya, self).__init__(parent, *args, **kwargs) 61 | 62 | self._exit_flag = False 63 | 64 | # voice 65 | self.q_voice_synthesis = queue.Queue() 66 | self.q_voice_play = queue.Queue() 67 | self.voice_dir = Path.home() / 'AppData' / 'Local' / 'Temp' 68 | 69 | self.__stop_completion = False 70 | 71 | # thread 72 | self.executor = ThreadPoolExecutor(max_workers=2) 73 | self.executor.submit(self.voice_synthesis_thread) 74 | self.executor.submit(self.voice_play_thread) 75 | 76 | # settings 77 | self.script_type = "python" 78 | self.last_error = None 79 | self.leave_codeblocks = False 80 | self.max_total_token = MAX_MESSAGES_TOKEN 81 | self.init_variables() 82 | 83 | # User Prefs 84 | self.user_settings_ini = QtCore.QSettings(str(USER_SETTINGS_INI), QtCore.QSettings.IniFormat) 85 | self.user_settings_ini.setIniCodec('utf-8') 86 | self.completion_model = DEFAULT_CHAT_MODEL 87 | self.settings = Settings() 88 | self.apply_settings(self.settings.get_settings()) 89 | 90 | # Build UI 91 | self.init_ui() 92 | self.get_user_prefs() 93 | 94 | def init_variables(self, *args): 95 | self.session_id = datetime.now().strftime('session_%y%m%d_%H%M%S') 96 | self.session_log_dir = Path(LOG_DIR / self.session_id) 97 | 98 | self.messages = [self.set_system_message(self.script_type)] 99 | self.code_list = [] 100 | self.total_tokens = 0 101 | 102 | def set_system_message(self, type:str="python", *args): 103 | if type == "python": 104 | return {"role":"system", "content":SYSTEM_TEMPLATE_PY} 105 | elif type == "mel": 106 | return {"role":"system", "content":SYSTEM_TEMPLATE_MEL} 107 | 108 | def decompose_response(self, txt:str): 109 | if self.script_type == "python": 110 | pattern = r"```python([\s\S]*?)```" 111 | else: 112 | pattern = r"```mel([\s\S]*?)```" 113 | 114 | code_list = re.findall(pattern, txt) 115 | code_list = [code.strip() for code in code_list] 116 | 117 | comment = re.sub(pattern, '', txt) 118 | comment = re.sub('[\r?\n]+', '\n', comment) 119 | comment = comment.strip() 120 | 121 | return comment.strip(), code_list 122 | 123 | def new_chat(self, *args): 124 | self.init_variables() 125 | self.update_scripts() 126 | cmds.cmdScrollFieldExecuter(self.script_editor_py, e=True, clear=True) 127 | cmds.cmdScrollFieldExecuter(self.script_editor_mel, e=True, clear=True) 128 | self.fix_error_button.setEnabled(False) 129 | self.chat_history_model.removeRows(0, self.chat_history_model.rowCount()) 130 | self.statusBar().showMessage("New Chat") 131 | 132 | def generate_message(self, *args): 133 | 134 | message_text = "" 135 | sentence = "" 136 | sentence_end_chars = "。!?:" 137 | backquote_count = 0 138 | is_code_block = False 139 | 140 | self.statusBar().showMessage("Completion... (Press Esc to stop)") 141 | 142 | # prompt tokens 143 | content_list = [msg["content"] for msg in self.messages] 144 | prompt_tokens = num_tokens_from_text("".join(content_list)) 145 | 146 | if prompt_tokens > self.max_total_token: 147 | self.messages = self.shrink_messages(self.messages) 148 | 149 | content_list = [msg["content"] for msg in self.messages] 150 | prompt_tokens = num_tokens_from_text("".join(content_list)) 151 | 152 | self.total_tokens += prompt_tokens 153 | 154 | # APIコール 155 | try: 156 | options = { 157 | "temperature": self.completion_temperature, 158 | "top_p": self.completion_top_p, 159 | "presence_penalty": self.completion_presence_penalty, 160 | "frequency_penalty": self.completion_frequency_penalty, 161 | } 162 | 163 | for content in chat_completion_stream(messages=self.messages, model=self.completion_model, **options): 164 | if keyboard.is_pressed('esc'): 165 | self.__stop_completion = True 166 | break 167 | message_text += content 168 | self.chat_history_model.setData( 169 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 170 | message_text) 171 | self.chat_history_view.scrollToBottom() 172 | cmds.refresh() 173 | 174 | for char in content: 175 | sentence += char 176 | 177 | if char == "`": 178 | backquote_count += 1 179 | else: 180 | backquote_count = 0 181 | 182 | if backquote_count == 3: 183 | is_code_block = not is_code_block 184 | backquote_count = 0 185 | sentence = "" 186 | 187 | if char in sentence_end_chars: 188 | if not is_code_block: 189 | # ボイス合成キューに1文ずつ追加 190 | self.q_voice_synthesis.put(sentence.strip()) 191 | backquote_count = 0 192 | sentence = "" 193 | 194 | except Exception as e: 195 | cmds.error(str(e)) 196 | self.messages.append({'role': 'assistant', 'content': ''}) 197 | self.chat_history_model.setData( 198 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 199 | '') 200 | return 201 | 202 | self.messages.append({'role': 'assistant', 'content': message_text}) 203 | 204 | # log出力 205 | self.export_log() 206 | 207 | # Escが押されたらここで終了 208 | if self.__stop_completion: 209 | self.statusBar().showMessage("Stop Completion.") 210 | return 211 | 212 | # completion tokens 213 | completion_tokens = num_tokens_from_text(message_text) 214 | self.total_tokens += completion_tokens 215 | 216 | # 最後の文が句読点で終わっていない場合 217 | if sentence.strip(): 218 | if not is_code_block: 219 | self.q_voice_synthesis.put(sentence.strip()) 220 | 221 | # 返答を分解 222 | comment, self.code_list = self.decompose_response(message_text) 223 | 224 | # スクリプト出力 225 | self.export_scripts() 226 | 227 | # Pythonコード以外の部分を表示 228 | if not self.leave_codeblocks: 229 | self.chat_history_model.setData( 230 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 231 | comment) 232 | self.chat_history_view.scrollToBottom() 233 | 234 | # Scriptsプルダウンを更新 235 | self.update_scripts() 236 | 237 | # コードの1つ目をscript_editorに表示 238 | if self.script_type == "python": 239 | editor = self.script_editor_py 240 | else: 241 | editor = self.script_editor_mel 242 | if self.code_list: 243 | cmds.cmdScrollFieldExecuter(editor, e=True, t=self.code_list[0]) 244 | else: 245 | cmds.cmdScrollFieldExecuter(editor, e=True, clear=True) 246 | self.fix_error_button.setEnabled(False) 247 | 248 | self.statusBar().showMessage("Completion Finish. ({} prompt + {} completion = {} tokens) Total:{}".format( 249 | prompt_tokens, 250 | completion_tokens, 251 | prompt_tokens + completion_tokens, 252 | self.total_tokens 253 | )) 254 | 255 | def send_message(self): 256 | user_message = self.user_input.toPlainText() 257 | if not user_message: 258 | return 259 | 260 | self.chat_history_model.insertRow(self.chat_history_model.rowCount()) 261 | self.chat_history_model.setData( 262 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 263 | user_message) 264 | self.user_input.clear() 265 | 266 | self.chat_history_model.insertRow(self.chat_history_model.rowCount()) 267 | 268 | user_prompt = USER_TEMPLATE.format( 269 | script_type="Maya Python" if self.script_type == "python" else "MEL", 270 | questions=user_message) 271 | self.messages.append({"role": "user", "content": user_prompt}) 272 | #self.last_user_message = user_message 273 | 274 | self.__stop_completion = False 275 | self.generate_message() 276 | 277 | def send_fix_message(self): 278 | if self.last_error == 0: 279 | return 280 | 281 | prompt = FIX_TEMPLATE.format(error=self.last_error) 282 | self.messages.append({"role": "user", "content": prompt}) 283 | 284 | self.chat_history_model.insertRow(self.chat_history_model.rowCount()) 285 | self.chat_history_model.setData( 286 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 287 | prompt) 288 | self.user_input.clear() 289 | 290 | self.chat_history_model.insertRow(self.chat_history_model.rowCount()) 291 | 292 | self.__stop_completion = False 293 | self.generate_message() 294 | 295 | def regenerate_message(self): 296 | if len(self.messages) < 2: 297 | return 298 | 299 | self.chat_history_model.setData( 300 | self.chat_history_model.index(self.chat_history_model.rowCount() - 1), 301 | '') 302 | 303 | self.messages.pop(-1) 304 | 305 | self.__stop_completion = False 306 | self.generate_message() 307 | 308 | def delete_last_message(self): 309 | self.chat_history_model.removeRows(self.chat_history_model.rowCount() - 2, 2) 310 | self.messages.pop(-1) 311 | self.messages.pop(-1) 312 | self.export_log() 313 | 314 | def shrink_messages(self, messages:list) -> list: 315 | messages.pop(1) 316 | content_list = [msg["content"] for msg in messages] 317 | prompt_tokens = num_tokens_from_text("".join(content_list)) 318 | if prompt_tokens > self.max_total_token: 319 | messages = self.shrink_messages(messages) 320 | return messages 321 | 322 | def execute_script(self, *args): 323 | cmds.cmdScrollFieldReporter(self.script_reporter, e=True, clear=True) 324 | 325 | if self.script_type == "python": 326 | code = cmds.cmdScrollFieldExecuter(self.script_editor_py, q=True, text=True) 327 | result = exec_py(code) 328 | if result != 0: 329 | OpenMaya.MGlobal.displayError(result) 330 | else: 331 | code = cmds.cmdScrollFieldExecuter(self.script_editor_mel, q=True, text=True) 332 | result = exec_mel(code) 333 | 334 | self.last_error = result 335 | 336 | self.fix_error_button.setEnabled(False if result == 0 else True) 337 | 338 | # voice 339 | def voice_synthesis_thread(self): 340 | 341 | while not (self._exit_flag and self.q_voice_synthesis.empty()): 342 | try: 343 | text = self.q_voice_synthesis.get(timeout=1) 344 | except queue.Empty: 345 | continue 346 | 347 | wav_path = text2voice( 348 | text, 349 | str(uuid4()), 350 | path=self.voice_dir, 351 | speaker=self.voice_speakerid, 352 | speed=self.voice_speed, 353 | pitch=self.voice_pitch, 354 | intonation=self.voice_intonation, 355 | volume=self.voice_volume, 356 | post=self.voice_post 357 | ) 358 | 359 | if wav_path: 360 | self.q_voice_play.put(wav_path) 361 | 362 | self.q_voice_synthesis.task_done() 363 | 364 | def voice_play_thread(self): 365 | 366 | while not (self._exit_flag and self.q_voice_play.empty()): 367 | try: 368 | wav_path = self.q_voice_play.get(timeout=1) 369 | except queue.Empty: 370 | continue 371 | 372 | play_wave(wav=wav_path, delete=True) 373 | 374 | self.q_voice_play.task_done() 375 | 376 | # UserPrefs 377 | def get_user_prefs(self, *args): 378 | self.user_settings_ini.beginGroup('MainWindow') 379 | self.restoreGeometry(self.user_settings_ini.value('geometry')) 380 | self.user_settings_ini.endGroup() 381 | 382 | def save_user_prefs(self, *args): 383 | self.user_settings_ini.beginGroup('MainWindow') 384 | self.user_settings_ini.setValue('geometry', self.saveGeometry()) 385 | self.user_settings_ini.endGroup() 386 | self.user_settings_ini.sync() 387 | 388 | def reset_user_prefs(self, *args): 389 | x, y, w, h = DEFAULT_GEOMETORY 390 | self.setGeometry(x, y, w, h) 391 | 392 | def apply_settings(self, data:SettingsData, *args): 393 | self.completion_temperature = float(data.completion.temperature) 394 | self.completion_top_p = float(data.completion.top_p) 395 | self.completion_presence_penalty = float(data.completion.presence_penalty) 396 | self.completion_frequency_penalty = float(data.completion.frequency_penalty) 397 | self.voice_speakerid = int(data.voice.speakerid) 398 | self.voice_speed = float(data.voice.speed) 399 | self.voice_pitch = float(data.voice.pitch) 400 | self.voice_intonation = float(data.voice.intonation) 401 | self.voice_volume = float(data.voice.volume) 402 | self.voice_post = float(data.voice.post) 403 | 404 | def open_settings_dialog(self, *args): 405 | self.settings.update(parent=maya_main_window()) 406 | self.apply_settings(self.settings.get_settings()) 407 | 408 | # UI 409 | def init_ui(self, *args): 410 | self.reset_user_prefs() 411 | self.setWindowTitle('{0} {1}'.format(TITLE, VERSION)) 412 | 413 | # Actions 414 | reset_user_prefsAction = QtWidgets.QAction("Reset Window Pos/Size", self) 415 | reset_user_prefsAction.setShortcut("Ctrl+R") 416 | reset_user_prefsAction.triggered.connect(self.reset_user_prefs) 417 | 418 | # Exit Action 419 | exitAction = QtWidgets.QAction("Exit", self) 420 | exitAction.setShortcut("Ctrl+Q") 421 | exitAction.triggered.connect(self.close) 422 | 423 | # Settings Action 424 | settingsAction = QtWidgets.QAction('Open Settings Dialog', self) 425 | settingsAction.triggered.connect(self.open_settings_dialog) 426 | 427 | leaveCodeblocksAction = QtWidgets.QAction('Leave codeblocks in chat area', self) 428 | leaveCodeblocksAction.setCheckable(True) 429 | leaveCodeblocksAction.setChecked(self.leave_codeblocks) 430 | leaveCodeblocksAction.setStatusTip(u'コードブロックをチャット領域にも残しておく') 431 | leaveCodeblocksAction.toggled.connect(self.toggle_leave_codeblocks) 432 | 433 | # About Action 434 | aboutAction = QtWidgets.QAction('About', self) 435 | aboutAction.triggered.connect(self.about) 436 | 437 | # menu bar 438 | menuBar = self.menuBar() 439 | 440 | fileMenu = menuBar.addMenu("File") 441 | fileMenu.addAction(reset_user_prefsAction) 442 | fileMenu.addSeparator() 443 | fileMenu.addAction(exitAction) 444 | 445 | settingsMenu = menuBar.addMenu("Settings") 446 | settingsMenu.addAction(settingsAction) 447 | settingsMenu.addSeparator() 448 | settingsMenu.addAction(leaveCodeblocksAction) 449 | 450 | helpMenu = menuBar.addMenu("Help") 451 | helpMenu.addAction(aboutAction) 452 | 453 | # statusBar 454 | self.statusBar().showMessage("Ready.") 455 | 456 | # chat 457 | chat_model_cbx = QtWidgets.QComboBox() 458 | chat_model_cbx.addItems(['gpt-3.5-turbo', 'gpt-4']) 459 | chat_model_cbx.setCurrentText(DEFAULT_CHAT_MODEL) 460 | chat_model_cbx.currentTextChanged.connect(self.change_model) 461 | 462 | new_button = QtWidgets.QPushButton('New Chat') 463 | new_button.clicked.connect(self.new_chat) 464 | 465 | log_dir_button = QtWidgets.QPushButton('Log') 466 | log_dir_button.setMaximumWidth(50) 467 | log_dir_button.clicked.connect(self.open_log_dir) 468 | 469 | self.script_type_rbtn_1 = QtWidgets.QRadioButton("Python") 470 | self.script_type_rbtn_1.setChecked(True) 471 | self.script_type_rbtn_1.setMaximumWidth(80) 472 | self.script_type_rbtn_1.toggled.connect(self.toggle_script_type) 473 | 474 | self.script_type_rbtn_2 = QtWidgets.QRadioButton("MEL") 475 | self.script_type_rbtn_2.setMaximumWidth(80) 476 | 477 | hBoxLayout1 = QtWidgets.QHBoxLayout() 478 | hBoxLayout1.addWidget(new_button) 479 | hBoxLayout1.addWidget(log_dir_button) 480 | 481 | hBoxLayout3 = QtWidgets.QHBoxLayout() 482 | hBoxLayout3.addWidget(chat_model_cbx) 483 | hBoxLayout3.addWidget(self.script_type_rbtn_1) 484 | hBoxLayout3.addWidget(self.script_type_rbtn_2) 485 | 486 | # chat history 487 | self.chat_history_model = QtCore.QStringListModel() 488 | 489 | self.chat_history_view = QtWidgets.QListView() 490 | self.chat_history_view.setModel(self.chat_history_model) 491 | self.chat_history_view.setWordWrap(True) 492 | self.chat_history_view.setAlternatingRowColors(True) 493 | self.chat_history_view.setStyleSheet(""" 494 | QListView::item { border-bottom: 0px solid; padding: 5px; } 495 | QListView::item { background-color: #27272e; } 496 | QListView::item:alternate { background-color: #363842; } 497 | """) 498 | 499 | # user input 500 | self.user_input = QtWidgets.QPlainTextEdit() 501 | self.user_input.setPlaceholderText("Send a message...") 502 | self.user_input.setFixedHeight(100) 503 | 504 | send_button = QtWidgets.QPushButton("Send") 505 | send_button.clicked.connect(self.send_message) 506 | 507 | regenerate_button = QtWidgets.QPushButton("Regenerate") 508 | regenerate_button.setMaximumWidth(120) 509 | regenerate_button.clicked.connect(self.regenerate_message) 510 | 511 | delete_last_button = QtWidgets.QPushButton("Delete last") 512 | delete_last_button.setMaximumWidth(80) 513 | delete_last_button.clicked.connect(self.delete_last_message) 514 | 515 | hBoxLayout2 = QtWidgets.QHBoxLayout() 516 | hBoxLayout2.addWidget(send_button) 517 | hBoxLayout2.addWidget(regenerate_button) 518 | hBoxLayout2.addWidget(delete_last_button) 519 | 520 | vBoxLayout1 = QtWidgets.QVBoxLayout() 521 | vBoxLayout1.addLayout(hBoxLayout3) 522 | vBoxLayout1.addLayout(hBoxLayout1) 523 | vBoxLayout1.addWidget(self.chat_history_view) 524 | vBoxLayout1.addWidget(self.user_input) 525 | vBoxLayout1.addLayout(hBoxLayout2) 526 | 527 | self.script_reporter = cmds.cmdScrollFieldReporter(clr=True) 528 | script_reporter_ptr = OpenMayaUI.MQtUtil.findControl(self.script_reporter) 529 | self.script_reporter_widget = wrapInstance(int(script_reporter_ptr), QtWidgets.QWidget) 530 | self.script_reporter_widget.setMaximumSize(1000000, 120) 531 | 532 | # script editor field 533 | self.script_editor_py = cmds.cmdScrollFieldExecuter(st="python", sln=True) 534 | script_editor_ptr_py = OpenMayaUI.MQtUtil.findControl(self.script_editor_py) 535 | self.script_editor_widget_py = wrapInstance(int(script_editor_ptr_py), QtWidgets.QWidget) 536 | 537 | self.script_editor_mel = cmds.cmdScrollFieldExecuter(st="mel", sln=True) 538 | script_editor_ptr_mel = OpenMayaUI.MQtUtil.findControl(self.script_editor_mel) 539 | self.script_editor_widget_mel = wrapInstance(int(script_editor_ptr_mel), QtWidgets.QWidget) 540 | 541 | self.stacked_widget = QtWidgets.QStackedWidget() 542 | self.stacked_widget.addWidget(self.script_editor_widget_py) 543 | self.stacked_widget.addWidget(self.script_editor_widget_mel) 544 | 545 | self.choice_script = QtWidgets.QComboBox() 546 | self.choice_script.setEditable(False) 547 | self.choice_script.setMaximumWidth(80) 548 | self.choice_script.currentTextChanged.connect(self.change_script) 549 | 550 | self.execute_button = QtWidgets.QPushButton('Execute') 551 | self.execute_button.clicked.connect(self.execute_script) 552 | 553 | self.fix_error_button = QtWidgets.QPushButton('Fix Error') 554 | self.fix_error_button.setMaximumWidth(100) 555 | self.fix_error_button.setEnabled(False) 556 | self.fix_error_button.clicked.connect(self.send_fix_message) 557 | 558 | hBoxLayout2 = QtWidgets.QHBoxLayout() 559 | hBoxLayout2.addWidget(self.choice_script) 560 | hBoxLayout2.addWidget(self.execute_button) 561 | hBoxLayout2.addWidget(self.fix_error_button) 562 | 563 | vBoxLayout2 = QtWidgets.QVBoxLayout() 564 | vBoxLayout2.addWidget(self.script_reporter_widget) 565 | vBoxLayout2.addWidget(self.stacked_widget) 566 | vBoxLayout2.addLayout(hBoxLayout2) 567 | 568 | main_layout = QtWidgets.QHBoxLayout(self) 569 | main_layout.setSizeConstraint(QtWidgets.QLayout.SetMinAndMaxSize) 570 | main_layout.addLayout(vBoxLayout1) 571 | main_layout.addLayout(vBoxLayout2) 572 | 573 | widget = QtWidgets.QWidget(self) 574 | widget.setLayout(main_layout) 575 | self.setCentralWidget(widget) 576 | 577 | def change_model(self, text, *args): 578 | self.completion_model = text 579 | 580 | def toggle_leave_codeblocks(self, flag, *args): 581 | self.leave_codeblocks = flag 582 | 583 | def toggle_script_type(self, *args): 584 | if self.script_type_rbtn_1.isChecked(): 585 | self.script_type = "python" 586 | self.stacked_widget.setCurrentIndex(0) 587 | else: 588 | self.script_type = "mel" 589 | self.stacked_widget.setCurrentIndex(1) 590 | 591 | if self.messages: 592 | self.messages[0] = self.set_system_message(self.script_type) 593 | else: 594 | self.messages = [self.set_system_message(self.script_type)] 595 | 596 | def update_scripts(self, *args): 597 | self.choice_script.clear() 598 | for i in range(int(len(self.code_list))): 599 | self.choice_script.addItems(str(i+1)) 600 | 601 | def change_script(self, item, *args): 602 | if item: 603 | if self.script_type == "python": 604 | editor = self.script_editor_py 605 | else: 606 | editor = self.script_editor_mel 607 | 608 | cmds.cmdScrollFieldExecuter(editor, e=True, t=self.code_list[int(item)-1]) 609 | 610 | def about(self, *args): 611 | QtWidgets.QMessageBox.about(self, 'About ' + TITLE, ABOUT_TXT) 612 | 613 | def closeEvent(self, event): 614 | self.save_user_prefs() 615 | self._exit_flag = True 616 | self.executor.shutdown(wait=True) 617 | 618 | # export 619 | def export_log(self, *args): 620 | self.session_log_dir.mkdir(parents=True, exist_ok=True) 621 | log_file_path = Path(self.session_log_dir, 'messages.json') 622 | try: 623 | with open(log_file_path, 'w', encoding='utf-8-sig') as f: 624 | json.dump(self.messages, f, indent=4, ensure_ascii=False) 625 | except: 626 | pass 627 | 628 | def export_scripts(self, index=None, *args): 629 | export_code_list = [] 630 | if index: 631 | export_code_list = [self.code_list[index]] 632 | else: 633 | export_code_list = self.code_list 634 | 635 | file_name_prefix = datetime.now().strftime('script_%H%M%S') 636 | ext = '.py' if self.script_type == "python" else '.mel' 637 | 638 | for i, code in enumerate(export_code_list): 639 | code_file_path = Path(self.session_log_dir, '{}_{}{}'.format(file_name_prefix, str(i).zfill(2), ext)) 640 | try: 641 | with open(code_file_path, 'w', encoding='utf-8-sig') as f: 642 | f.writelines(code) 643 | except: 644 | pass 645 | 646 | def open_log_dir(self, *args): 647 | if self.session_log_dir.is_dir(): 648 | subprocess.Popen('explorer {}'.format(self.session_log_dir)) -------------------------------------------------------------------------------- /chatmaya/exec_code.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import traceback 4 | 5 | from maya import mel 6 | 7 | def exec_mel(code:str): 8 | 9 | try: 10 | mel.eval(code) 11 | return 0 12 | except Exception: 13 | exc_type, exc_value, exc_traceback = sys.exc_info() 14 | return str(exc_value) 15 | 16 | def exec_py(code:str): 17 | 18 | try: 19 | exec(code, {'__name__': '__main__'}, None) 20 | return 0 21 | except Exception: 22 | exc_type, exc_value, exc_traceback = sys.exc_info() 23 | trace = traceback.format_exception(exc_type, exc_value, exc_traceback) 24 | return "{}: {}: {}".format(exc_type.__name__, trace[-2].strip(), exc_value) -------------------------------------------------------------------------------- /chatmaya/info.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from pathlib import Path 3 | 4 | from maya import cmds 5 | 6 | TITLE = 'ChatMaya' 7 | VERSION = '1.2.1' 8 | 9 | ABOUT_TXT = """ 10 | {} {} 11 | 12 | Powerd by ChatGPT API. 13 | 14 | Supported Maya: 15 | Maya 2023 (Python3.9.7) 16 | Maya 2024 (Python3.10.8) 17 | 18 | (c) 2023 Hiroyuki Akasaki""".format(TITLE, VERSION) 19 | 20 | USER_SETTINGS_DIR = Path(cmds.internalVar(userAppDir=True)) / TITLE 21 | USER_SETTINGS_DIR.mkdir(parents=True, exist_ok=True) 22 | 23 | USER_SETTINGS_INI = Path(USER_SETTINGS_DIR / 'userSettings.ini') 24 | USER_SETTINGS_JSON = Path(USER_SETTINGS_DIR / 'userSettings.json') 25 | 26 | LOG_DIR = Path(USER_SETTINGS_DIR / "log") 27 | LOG_DIR.mkdir(parents=True, exist_ok=True) -------------------------------------------------------------------------------- /chatmaya/openai_utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from typing import List 3 | from tenacity import ( 4 | retry, 5 | retry_if_exception_type, 6 | stop_after_attempt, 7 | wait_exponential, 8 | ) 9 | 10 | import openai 11 | import tiktoken 12 | 13 | DEFAULT_CHAT_MODEL = "gpt-3.5-turbo" 14 | DEFAULT_ENCODING = "cl100k_base" 15 | 16 | MAX_ATTEMPT = 3 # リトライ回数 17 | MIN_SECONDS = 5 # 最小リトライ秒数 18 | MAX_SECONDS = 15 # 最大リトライ秒数 19 | 20 | def retry_decorator(func): 21 | return retry( 22 | #reraise=True, 23 | stop=stop_after_attempt(MAX_ATTEMPT), 24 | wait=wait_exponential(multiplier=1, min=MIN_SECONDS, max=MAX_SECONDS), 25 | retry=( 26 | retry_if_exception_type(openai.error.APIError) 27 | | retry_if_exception_type(openai.error.Timeout) 28 | | retry_if_exception_type(openai.error.RateLimitError) 29 | | retry_if_exception_type(openai.error.APIConnectionError) 30 | | retry_if_exception_type(openai.error.InvalidRequestError) 31 | | retry_if_exception_type(openai.error.AuthenticationError) 32 | | retry_if_exception_type(openai.error.ServiceUnavailableError) 33 | ) 34 | )(func) 35 | 36 | def num_tokens_from_text(text:str, encoding_name:str=DEFAULT_ENCODING) -> int: 37 | encoding = tiktoken.get_encoding(encoding_name) 38 | num_tokens = len(encoding.encode(text)) 39 | return num_tokens 40 | 41 | @retry_decorator 42 | def chat_completion_stream(messages:List, model:str=DEFAULT_CHAT_MODEL, **kwargs) -> str: 43 | 44 | result = openai.ChatCompletion.create( 45 | model=model, 46 | messages=messages, 47 | stream=True, 48 | **kwargs 49 | ) 50 | 51 | for chunk in result: 52 | if chunk: 53 | content = chunk['choices'][0]['delta'].get('content') 54 | if content: 55 | yield content -------------------------------------------------------------------------------- /chatmaya/prompts.py: -------------------------------------------------------------------------------- 1 | SYSTEM_TEMPLATE_PY = """Write a Maya Python script in response to the question. 2 | All text other than the script should be short and written in Japanese. 3 | Python code blocks should always start with ```python. 4 | Do not use packages or modules that are not installed as standard in Maya. 5 | If information is missing for writing scripts, ask questions as appropriate.""" 6 | 7 | SYSTEM_TEMPLATE_MEL = """Write a MEL script in response to the question. 8 | All text other than the script should be short and written in Japanese. 9 | MEL code blocks should always start with ```mel. 10 | If information is missing for writing scripts, ask questions as appropriate.""" 11 | 12 | USER_TEMPLATE = """Write a {script_type} script that can be executed in Maya to answer the following Questions. 13 | 日本語で答えてください。 14 | 15 | # Questions: 16 | {questions}""" 17 | 18 | FIX_TEMPLATE = """実行したら以下のようなエラーが出ました。修復してください。 19 | 20 | # Error: 21 | {error}""" -------------------------------------------------------------------------------- /chatmaya/settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import json 3 | from typing import Dict 4 | from pydantic import BaseModel 5 | 6 | from PySide2 import QtWidgets, QtCore 7 | 8 | from .info import USER_SETTINGS_JSON 9 | 10 | class CompletionSettings(BaseModel): 11 | temperature :float = 0.7 12 | top_p :float = 1.0 13 | presence_penalty :float = 0.0 14 | frequency_penalty :float = 0.0 15 | 16 | class VoiceSettings(BaseModel): 17 | speakerid :int = 47 18 | speed :float = 1.1 19 | pitch :float = 0.0 20 | intonation :float = 1.0 21 | volume :float = 1.0 22 | post :float = 0.1 23 | 24 | class SettingsData(BaseModel): 25 | completion :CompletionSettings = CompletionSettings() 26 | voice :VoiceSettings = VoiceSettings() 27 | 28 | @classmethod 29 | def from_dict(cls, dict:Dict): 30 | completion_settings = CompletionSettings( 31 | temperature = dict["completion"]["temperature"], 32 | top_p = dict["completion"]["top_p"], 33 | presence_penalty = dict["completion"]["presence_penalty"], 34 | frequency_penalty = dict["completion"]["frequency_penalty"], 35 | ) 36 | voice_settings = VoiceSettings( 37 | speakerid = dict["voice"]["speakerid"], 38 | speed = dict["voice"]["speed"], 39 | pitch = dict["voice"]["pitch"], 40 | intonation = dict["voice"]["intonation"], 41 | volume = dict["voice"]["volume"], 42 | post = dict["voice"]["post"], 43 | ) 44 | 45 | return cls( 46 | completion=completion_settings, 47 | voice=voice_settings 48 | ) 49 | 50 | def to_dict(self) -> Dict: 51 | return { 52 | "completion": { 53 | "temperature": self.completion.temperature, 54 | "top_p": self.completion.top_p, 55 | "presence_penalty": self.completion.presence_penalty, 56 | "frequency_penalty": self.completion.frequency_penalty 57 | }, 58 | "voice": { 59 | "speakerid": self.voice.speakerid, 60 | "speed": self.voice.speed, 61 | "pitch": self.voice.pitch, 62 | "intonation": self.voice.intonation, 63 | "volume": self.voice.volume, 64 | "post": self.voice.post 65 | } 66 | } 67 | 68 | class Settings(object): 69 | 70 | def __init__(self): 71 | self.filepath = USER_SETTINGS_JSON 72 | 73 | self._data = self.__import_from_file() 74 | 75 | if self._data is None: 76 | self._data = SettingsData() 77 | 78 | self.__export_file() 79 | 80 | def get_settings(self, *args): 81 | return self._data 82 | 83 | def update(self, parent=None, *args): 84 | data, accepted = SettingsDialog.set(parent=parent, data=self._data) 85 | 86 | if not accepted: 87 | return 88 | 89 | self._data = data 90 | self.__export_file() 91 | 92 | def __import_from_file(self, *args): 93 | if not self.filepath.is_file(): 94 | return 95 | 96 | try: 97 | with open(self.filepath, 'r') as f: 98 | data = json.load(f) 99 | return SettingsData.from_dict(data) 100 | except: 101 | print("Settings could not be applied. Use default settings.") 102 | return 103 | 104 | def __export_file(self, *args): 105 | try: 106 | with open(self.filepath, 'w', encoding='utf-8') as f: 107 | json.dump(self._data.to_dict(), f, indent=4) 108 | return True 109 | except: 110 | return 111 | 112 | class SettingsDialog(QtWidgets.QDialog): 113 | 114 | _data :SettingsData 115 | 116 | def __init__(self, parent=None, *args): 117 | super(SettingsDialog, self).__init__(parent, *args) 118 | 119 | def init_UI(self, *args): 120 | self.setWindowTitle('Settings') 121 | 122 | # Completion Settings group 123 | completion_group = QtWidgets.QGroupBox("ChatCompletion") 124 | completion_layout = QtWidgets.QFormLayout(completion_group) 125 | 126 | # temperature 127 | self.temperature_spinbox = QtWidgets.QDoubleSpinBox(self) 128 | self.temperature_spinbox.setRange(0.0, 2.0) 129 | self.temperature_spinbox.setSingleStep(0.1) 130 | self.temperature_spinbox.setMinimumWidth(100) 131 | completion_layout.addRow("Temperature:", self.temperature_spinbox) 132 | 133 | # top_p 134 | self.top_p_spinbox = QtWidgets.QDoubleSpinBox(self) 135 | self.top_p_spinbox.setRange(0.0, 2.0) 136 | self.top_p_spinbox.setSingleStep(0.1) 137 | self.top_p_spinbox.setMinimumWidth(100) 138 | completion_layout.addRow("Top P:", self.top_p_spinbox) 139 | 140 | # presence_penalty 141 | self.presence_penalty_spinbox = QtWidgets.QDoubleSpinBox(self) 142 | self.presence_penalty_spinbox.setRange(0.0, 2.0) 143 | self.presence_penalty_spinbox.setSingleStep(0.1) 144 | self.presence_penalty_spinbox.setMinimumWidth(100) 145 | completion_layout.addRow("Presence Penalty:", self.presence_penalty_spinbox) 146 | 147 | # frequency_penalty 148 | self.frequency_penalty_spinbox = QtWidgets.QDoubleSpinBox(self) 149 | self.frequency_penalty_spinbox.setRange(0.0, 2.0) 150 | self.frequency_penalty_spinbox.setSingleStep(0.1) 151 | self.frequency_penalty_spinbox.setMinimumWidth(100) 152 | completion_layout.addRow("Frequency Penalty:", self.frequency_penalty_spinbox) 153 | 154 | # Voice Settings group 155 | voice_group = QtWidgets.QGroupBox("VOICEVOX") 156 | voice_layout = QtWidgets.QFormLayout(voice_group) 157 | 158 | # speakerid 159 | self.speakerid_spinbox = QtWidgets.QSpinBox(self) 160 | self.speakerid_spinbox.setMinimumWidth(60) 161 | voice_layout.addRow("Speaker ID:", self.speakerid_spinbox) 162 | 163 | # speed 164 | self.speed_spinbox = QtWidgets.QDoubleSpinBox(self) 165 | self.speed_spinbox.setRange(0.5, 2.0) 166 | self.speed_spinbox.setSingleStep(0.1) 167 | self.speed_spinbox.setMinimumWidth(60) 168 | voice_layout.addRow("Speed:", self.speed_spinbox) 169 | 170 | # pitch 171 | self.pitch_spinbox = QtWidgets.QDoubleSpinBox(self) 172 | self.pitch_spinbox.setRange(-0.15, 0.15) 173 | self.pitch_spinbox.setSingleStep(0.01) 174 | self.pitch_spinbox.setMinimumWidth(60) 175 | voice_layout.addRow("Pitch:", self.pitch_spinbox) 176 | 177 | # intonation 178 | self.intonation_spinbox = QtWidgets.QDoubleSpinBox(self) 179 | self.intonation_spinbox.setRange(0.0, 2.0) 180 | self.intonation_spinbox.setSingleStep(0.1) 181 | self.intonation_spinbox.setMinimumWidth(60) 182 | voice_layout.addRow("Intonation:", self.intonation_spinbox) 183 | 184 | # volume 185 | self.volume_spinbox = QtWidgets.QDoubleSpinBox(self) 186 | self.volume_spinbox.setRange(0.0, 5.0) 187 | self.volume_spinbox.setSingleStep(0.1) 188 | self.volume_spinbox.setMinimumWidth(60) 189 | voice_layout.addRow("Volume:", self.volume_spinbox) 190 | 191 | # post 192 | self.post_spinbox = QtWidgets.QDoubleSpinBox(self) 193 | self.post_spinbox.setRange(0.0, 1.5) 194 | self.post_spinbox.setSingleStep(0.1) 195 | self.post_spinbox.setMinimumWidth(60) 196 | voice_layout.addRow("Post:", self.post_spinbox) 197 | 198 | # Buttons 199 | buttons = QtWidgets.QDialogButtonBox( 200 | QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel, 201 | QtCore.Qt.Horizontal, 202 | self 203 | ) 204 | buttons.accepted.connect(self.save_and_accept) 205 | buttons.rejected.connect(self.reject) 206 | 207 | settings_layout = QtWidgets.QHBoxLayout() 208 | settings_layout.addWidget(completion_group) 209 | settings_layout.addWidget(voice_group) 210 | 211 | main_layout = QtWidgets.QVBoxLayout(self) 212 | main_layout.addLayout(settings_layout) 213 | main_layout.addWidget(buttons) 214 | 215 | def set_UI_values(self, *args): 216 | try: 217 | self.temperature_spinbox.setValue(self._data.completion.temperature) 218 | self.top_p_spinbox.setValue(self._data.completion.top_p) 219 | self.presence_penalty_spinbox.setValue(self._data.completion.presence_penalty) 220 | self.frequency_penalty_spinbox.setValue(self._data.completion.frequency_penalty) 221 | 222 | self.speakerid_spinbox.setValue(self._data.voice.speakerid) 223 | self.speed_spinbox.setValue(self._data.voice.speed) 224 | self.pitch_spinbox.setValue(self._data.voice.pitch) 225 | self.intonation_spinbox.setValue(self._data.voice.intonation) 226 | self.volume_spinbox.setValue(self._data.voice.volume) 227 | self.post_spinbox.setValue(self._data.voice.post) 228 | except: 229 | pass 230 | 231 | def save_and_accept(self): 232 | self._data.completion.temperature = round(self.temperature_spinbox.value(), 2) 233 | self._data.completion.top_p = round(self.top_p_spinbox.value(), 2) 234 | self._data.completion.presence_penalty = round(self.presence_penalty_spinbox.value(), 2) 235 | self._data.completion.frequency_penalty = round(self.frequency_penalty_spinbox.value(), 2) 236 | self._data.voice.speakerid = self.speakerid_spinbox.value() 237 | self._data.voice.speed = round(self.speed_spinbox.value(), 2) 238 | self._data.voice.pitch = round(self.pitch_spinbox.value(), 2) 239 | self._data.voice.intonation = round(self.intonation_spinbox.value(), 2) 240 | self._data.voice.volume = round(self.volume_spinbox.value(), 2) 241 | self._data.voice.post = round(self.post_spinbox.value(), 2) 242 | 243 | self.accept() 244 | 245 | @staticmethod 246 | def set(parent=None, data:SettingsData=None, *args): 247 | if data is None: 248 | return 249 | 250 | dialog = SettingsDialog(parent) 251 | dialog._data = data 252 | dialog.init_UI() 253 | dialog.set_UI_values() 254 | result = dialog.exec_() 255 | 256 | return (dialog._data, result == QtWidgets.QDialog.Accepted) 257 | -------------------------------------------------------------------------------- /chatmaya/voice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from pathlib import Path 3 | import json 4 | import requests 5 | import wave 6 | import pyaudio 7 | import re 8 | import alkana 9 | 10 | CHUNK_SIZE = 1024 11 | BASE_URL = "http://127.0.0.1:50021" 12 | 13 | def alkana_(text:str) -> str: 14 | 15 | pattern = r'[a-zA-Z]+' 16 | words = re.findall(pattern, text) 17 | 18 | for w in words: 19 | kana = alkana.get_kana(w) 20 | if kana: 21 | text = re.sub(w, kana, text) 22 | 23 | return text 24 | 25 | def text2voice( 26 | text:str, 27 | filename:str, 28 | path:Path, 29 | speaker:int=0, 30 | volume:float=1.0, 31 | speed:float=1.0, 32 | pitch:float=0.0, 33 | intonation:float=1.0, 34 | post:float=0.0 35 | ) -> Path: 36 | 37 | path = path.resolve() 38 | path.mkdir(parents=True, exist_ok=True) 39 | 40 | text = alkana_(text) 41 | 42 | # audio_query 43 | try: 44 | res1 = requests.post(BASE_URL + "/audio_query", 45 | params={"text": text, "speaker": speaker}) 46 | res1.raise_for_status() 47 | except Exception as e: 48 | #print("Error :", e) 49 | return 50 | 51 | res1 = res1.json() 52 | res1["volumeScale"]=volume 53 | res1["speedScale"]=speed 54 | res1["pitchScale"]=pitch 55 | res1["intonationScale"]=intonation 56 | res1["postPhonemeLength"]=post 57 | 58 | # synthesis 59 | res2 = requests.post(BASE_URL + "/synthesis", 60 | params={"speaker": speaker}, 61 | data=json.dumps(res1)) 62 | 63 | audio_file = Path(path, filename + '.wav') 64 | with open(audio_file, mode="wb") as f: 65 | f.write(res2.content) 66 | 67 | return audio_file 68 | 69 | def play_wave(wav:Path, delete=False): 70 | 71 | with wave.open(str(wav), mode='r') as wf: 72 | 73 | p = pyaudio.PyAudio() 74 | stream = p.open(format=p.get_format_from_width(wf.getsampwidth()), 75 | channels=wf.getnchannels(), 76 | rate=wf.getframerate(), 77 | output=True) 78 | 79 | data = wf.readframes(CHUNK_SIZE) 80 | while data != b'': 81 | stream.write(data) 82 | data = wf.readframes(CHUNK_SIZE) 83 | 84 | stream.stop_stream() 85 | stream.close() 86 | p.terminate() 87 | 88 | if delete: 89 | wav.unlink() -------------------------------------------------------------------------------- /install/install_maya2023_win.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set MayaVersion=2023 4 | 5 | call %~dp0install_win.bat -------------------------------------------------------------------------------- /install/install_maya2024_win.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | set MayaVersion=2024 4 | 5 | call %~dp0install_win.bat 6 | -------------------------------------------------------------------------------- /install/install_win.bat: -------------------------------------------------------------------------------- 1 | set ModuleName=chatmaya 2 | 3 | set CurrentPath=%~dp0 4 | set A=%CurrentPath:~0,-2% 5 | for %%A in (%A%) do set RootPath=%%~dpA 6 | 7 | set MayaPy="%ProgramFiles%\Autodesk\Maya%MayaVersion%\bin\mayapy.exe" 8 | 9 | :: pip upgrade 10 | %MayaPy% -m pip install -U pip 11 | 12 | :: install site-packages 13 | %MayaPy% -m pip install -U -r %CurrentPath%\requirements.txt -t %UserProfile%\Documents\maya\%MayaVersion%\scripts\site-packages 14 | 15 | :: install module 16 | robocopy %RootPath%\%ModuleName% %UserProfile%\Documents\maya\%MayaVersion%\scripts\%ModuleName% /MIR -------------------------------------------------------------------------------- /install/requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/akasaki1211/ChatGPT_Maya/260d9c8106a55a8de40238c4cdc95fc9a4ae6451/install/requirements.txt --------------------------------------------------------------------------------