diff --git a/src-python/controller.py b/src-python/controller.py index 5bacdb4f..8d1c53c9 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -2,11 +2,13 @@ from typing import Callable, Any, List, Optional from time import sleep from subprocess import Popen from threading import Thread +from concurrent.futures import ThreadPoolExecutor, as_completed import re from device_manager import device_manager from config import config from model import model from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValidIpAddress, isAvailableWebSocketServer +from errors import ErrorCode, VRCTError class Controller: def __init__(self) -> None: @@ -153,13 +155,14 @@ class Controller: def progressBarMicEnergy(self, energy) -> None: if energy is False: + error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_MIC, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_device"], - { - "message":"No mic device detected", - "data": None - }, + error_response["result"], ) else: self.run( @@ -170,13 +173,14 @@ class Controller: def progressBarSpeakerEnergy(self, energy) -> None: if energy is False: + error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_SPEAKER, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_device"], - { - "message":"No speaker device detected", - "data": None - }, + error_response["result"], ) else: self.run( @@ -209,13 +213,14 @@ class Controller: self.weight_type, ) else: + error_response = VRCTError.create_error_response( + ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_ctranslate2_weight"], - { - "message":"CTranslate2 weight download error", - "data": None - }, + error_response["result"], ) class DownloadWhisper: @@ -242,13 +247,14 @@ class Controller: self.weight_type, ) else: + error_response = VRCTError.create_error_response( + ErrorCode.WEIGHT_WHISPER_DOWNLOAD, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_whisper_weight"], - { - "message":"Whisper weight download error", - "data": None - }, + error_response["result"], ) def micMessage(self, result: dict) -> None: @@ -298,23 +304,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_MIC, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_mic_vram_overflow"], - { - "message":"VRAM out of memory during translation of mic", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) return else: @@ -448,13 +456,14 @@ class Controller: translation, success = model.getOutputTranslate(message, source_language=language) if all(success) is not True: self.changeToCTranslate2Process() + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_ENGINE_LIMIT, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_engine"], - { - "message":"Translation engine limit error", - "data": None - }, + error_response["result"], ) else: pass @@ -462,23 +471,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_SPEAKER, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_speaker_vram_overflow"], - { - "message":"VRAM out of memory during translation of speaker", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) return else: @@ -625,13 +636,14 @@ class Controller: if all(success) is not True: self.changeToCTranslate2Process() + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_ENGINE_LIMIT, + data=None + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_engine"], - { - "message":"Translation engine limit error", - "data": None - }, + error_response["result"], ) else: pass @@ -639,23 +651,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_CHAT, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_chat_vram_overflow"], - { - "message":"VRAM out of memory during translation of chat", - "data": error_message - }, + error_response["result"], ) # 翻訳機能をOFFにする self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) # エラー時は翻訳なしで返す return {"status":200, @@ -845,22 +859,24 @@ class Controller: if is_vram_error: # Defaultのデバイス設定に戻す printLog("VRAM error detected, reverting device setting") + error_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_VRAM_ENABLE, + data=error_message + ) self.run( - 400, + error_response["status"], self.run_mapping["error_translation_enable_vram_overflow"], - { - "message":"VRAM out of memory enabling translation", - "data": error_message - }, + error_response["result"], ) self.setDisableTranslation() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSLATION_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_translation"], - { - "message":"Translation disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) model.changeTranslatorCTranslate2Model() model.setChangedTranslatorParameters(False) @@ -1185,13 +1201,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic energy threshold value is out of range", - "data": config.MIC_THRESHOLD - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_THRESHOLD, + data=config.MIC_THRESHOLD + ) else: response = {"status":status, "result":config.MIC_THRESHOLD} return response @@ -1226,13 +1239,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic record timeout value is out of range", - "data": config.MIC_RECORD_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT, + data=config.MIC_RECORD_TIMEOUT + ) else: response = {"status":200, "result":config.MIC_RECORD_TIMEOUT} return response @@ -1250,13 +1260,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic phrase timeout value is out of range", - "data": config.MIC_PHRASE_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT, + data=config.MIC_PHRASE_TIMEOUT + ) else: response = {"status":200, "result":config.MIC_PHRASE_TIMEOUT} return response @@ -1274,13 +1281,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Mic max phrases value is out of range", - "data": config.MIC_MAX_PHRASES - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_MIC_MAX_PHRASES, + data=config.MIC_MAX_PHRASES + ) else: response = {"status":200, "result":config.MIC_MAX_PHRASES} return response @@ -1368,13 +1372,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker energy threshold value is out of range", - "data": config.SPEAKER_THRESHOLD - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_THRESHOLD, + data=config.SPEAKER_THRESHOLD + ) else: response = {"status":200, "result":config.SPEAKER_THRESHOLD} return response @@ -1408,13 +1409,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker record timeout value is out of range", - "data": config.SPEAKER_RECORD_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT, + data=config.SPEAKER_RECORD_TIMEOUT + ) else: response = {"status":200, "result":config.SPEAKER_RECORD_TIMEOUT} return response @@ -1432,13 +1430,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker phrase timeout value is out of range", - "data": config.SPEAKER_PHRASE_TIMEOUT - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT, + data=config.SPEAKER_PHRASE_TIMEOUT + ) else: response = {"status":200, "result":config.SPEAKER_PHRASE_TIMEOUT} return response @@ -1457,13 +1452,10 @@ class Controller: else: raise ValueError() except Exception: - response = { - "status":400, - "result":{ - "message":"Speaker max phrases value is out of range", - "data": config.SPEAKER_MAX_PHRASES - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES, + data=config.SPEAKER_MAX_PHRASES + ) else: response = {"status":200, "result":config.SPEAKER_MAX_PHRASES} return response @@ -1510,13 +1502,10 @@ class Controller: def setOscIpAddress(self, data, *args, **kwargs) -> dict: if isValidIpAddress(data) is False: - response = { - "status":400, - "result":{ - "message":"Invalid IP address", - "data": config.OSC_IP_ADDRESS - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_INVALID_IP, + data=config.OSC_IP_ADDRESS + ) else: try: model.setOscIpAddress(data) @@ -1533,13 +1522,10 @@ class Controller: response = {"status":200, "result":config.OSC_IP_ADDRESS} except Exception: model.setOscIpAddress(config.OSC_IP_ADDRESS) - response = { - "status":400, - "result":{ - "message":"Cannot set IP address", - "data": config.OSC_IP_ADDRESS - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_CANNOT_SET_IP, + data=config.OSC_IP_ADDRESS + ) return response @staticmethod @@ -1588,30 +1574,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of deepL auth key", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_DEEPL_FAILED, + data=config.AUTH_KEYS[translator_name] + ) else: - response = { - "status":400, - "result":{ - "message":"DeepL auth key length is not correct", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_DEEPL_LENGTH, + data=config.AUTH_KEYS[translator_name] + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.AUTH_KEYS[translator_name] - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.AUTH_KEYS[translator_name] + ) return response def delDeeplAuthKey(self, *args, **kwargs) -> dict: @@ -1649,30 +1626,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of plamo auth key", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_PLAMO_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"Plamo auth key length is not correct", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_PLAMO_LENGTH, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": None - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) if response["status"] == 400: self.delPlamoAuthKey() return response @@ -1707,22 +1675,16 @@ class Controller: model.updateTranslatorPlamoClient() response = {"status":200, "result":config.SELECTED_PLAMO_MODEL} else: - response = { - "status":400, - "result":{ - "message":"Plamo model is not valid", - "data": config.SELECTED_PLAMO_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_PLAMO_INVALID, + data=config.SELECTED_PLAMO_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_PLAMO_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_PLAMO_MODEL + ) return response def getGeminiAuthKey(self, *args, **kwargs) -> dict: @@ -1751,30 +1713,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of gemini auth key", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GEMINI_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"Gemini auth key length is not correct", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GEMINI_LENGTH, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": None - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) if response["status"] == 400: self.delGeminiAuthKey() return response @@ -1809,22 +1762,16 @@ class Controller: model.updateTranslatorGeminiClient() response = {"status":200, "result":config.SELECTED_GEMINI_MODEL} else: - response = { - "status":400, - "result":{ - "message":"Gemini model is not valid", - "data": config.SELECTED_GEMINI_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_GEMINI_INVALID, + data=config.SELECTED_GEMINI_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_GEMINI_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_GEMINI_MODEL + ) return response @staticmethod @@ -1854,30 +1801,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of OpenAI auth key", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENAI_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"OpenAI auth key is not valid", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENAI_INVALID, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": None - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) if response["status"] == 400: self.delOpenAIAuthKey() return response @@ -1912,22 +1850,16 @@ class Controller: model.updateTranslatorOpenAIClient() response = {"status":200, "result":config.SELECTED_OPENAI_MODEL} else: - response = { - "status":400, - "result":{ - "message":"OpenAI model is not valid", - "data": config.SELECTED_OPENAI_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_OPENAI_INVALID, + data=config.SELECTED_OPENAI_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_OPENAI_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OPENAI_MODEL + ) return response @staticmethod @@ -1957,30 +1889,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of Groq auth key", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GROQ_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"Groq auth key is not valid", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_GROQ_INVALID, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": None - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) if response["status"] == 400: self.delGroqAuthKey() return response @@ -2015,22 +1938,16 @@ class Controller: model.updateTranslatorGroqClient() response = {"status":200, "result":config.SELECTED_GROQ_MODEL} else: - response = { - "status":400, - "result":{ - "message":"Groq model is not valid", - "data": config.SELECTED_GROQ_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_GROQ_INVALID, + data=config.SELECTED_GROQ_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_GROQ_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_GROQ_MODEL + ) return response @staticmethod @@ -2060,30 +1977,21 @@ class Controller: self.updateTranslationEngineAndEngineList() response = {"status":200, "result":config.AUTH_KEYS[translator_name]} else: - response = { - "status":400, - "result":{ - "message":"Authentication failure of OpenRouter auth key", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENROUTER_FAILED, + data=None + ) else: - response = { - "status":400, - "result":{ - "message":"OpenRouter auth key is not valid", - "data": None - } - } + response = VRCTError.create_error_response( + ErrorCode.AUTH_OPENROUTER_INVALID, + data=None + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": None - } - } + response = VRCTError.create_exception_error_response( + e, + data=None + ) if response["status"] == 400: self.delOpenRouterAuthKey() return response @@ -2118,22 +2026,16 @@ class Controller: model.updateTranslatorOpenRouterClient() response = {"status":200, "result":config.SELECTED_OPENROUTER_MODEL} else: - response = { - "status":400, - "result":{ - "message":"OpenRouter model is not valid", - "data": config.SELECTED_OPENROUTER_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_OPENROUTER_INVALID, + data=config.SELECTED_OPENROUTER_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_OPENROUTER_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OPENROUTER_MODEL + ) return response def getTranslatorLMStudioConnection(self, *args, **kwargs) -> dict: @@ -2164,13 +2066,10 @@ class Controller: self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":"Cannot connect to LMStudio server", - "data": False - } - } + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_LMSTUDIO_FAILED, + data=False + ) except Exception as e: errorLogging() config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False @@ -2179,13 +2078,10 @@ class Controller: self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": False - } - } + response = VRCTError.create_exception_error_response( + e, + data=False + ) return response def getConnectedLMStudio(self, *args, **kwargs) -> dict: @@ -2222,13 +2118,10 @@ class Controller: self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":"LMStudio URL is not valid", - "data": config.LMSTUDIO_URL - } - } + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID, + data=config.LMSTUDIO_URL + ) except Exception as e: errorLogging() config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False @@ -2237,13 +2130,10 @@ class Controller: self.run(200, self.run_mapping["selectable_lmstudio_model_list"], config.SELECTABLE_LMSTUDIO_MODEL_LIST) self.run(200, self.run_mapping["selected_lmstudio_model"], config.SELECTED_LMSTUDIO_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.LMSTUDIO_URL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.LMSTUDIO_URL + ) return response def getTranslatorLStudioModelList(self, *args, **kwargs) -> dict: @@ -2264,22 +2154,16 @@ class Controller: model.updateTranslatorLMStudioClient() response = {"status":200, "result":config.SELECTED_LMSTUDIO_MODEL} else: - response = { - "status":400, - "result":{ - "message":"LMStudio model is not valid", - "data": config.SELECTED_LMSTUDIO_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_LMSTUDIO_INVALID, + data=config.SELECTED_LMSTUDIO_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_LMSTUDIO_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_LMSTUDIO_MODEL + ) return response def getTranslatorOllamaConnection(self, *args, **kwargs) -> dict: @@ -2310,13 +2194,10 @@ class Controller: self.run(200, self.run_mapping["selectable_ollama_model_list"], config.SELECTABLE_OLLAMA_MODEL_LIST) self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":"Cannot connect to ollama server", - "data": False - } - } + response = VRCTError.create_error_response( + ErrorCode.CONNECTION_OLLAMA_FAILED, + data=False + ) except Exception as e: errorLogging() config.SELECTABLE_TRANSLATION_ENGINE_STATUS[translator_name] = False @@ -2325,13 +2206,10 @@ class Controller: self.run(200, self.run_mapping["selectable_ollama_model_list"], config.SELECTABLE_OLLAMA_MODEL_LIST) self.run(200, self.run_mapping["selected_ollama_model"], config.SELECTED_OLLAMA_MODEL) self.updateTranslationEngineAndEngineList() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": False - } - } + response = VRCTError.create_exception_error_response( + e, + data=False + ) return response def getTranslatorOllamaModelList(self, *args, **kwargs) -> dict: @@ -2352,22 +2230,16 @@ class Controller: model.updateTranslatorOllamaClient() response = {"status":200, "result":config.SELECTED_OLLAMA_MODEL} else: - response = { - "status":400, - "result":{ - "message":"ollama model is not valid", - "data": config.SELECTED_OLLAMA_MODEL - } - } + response = VRCTError.create_error_response( + ErrorCode.MODEL_OLLAMA_INVALID, + data=config.SELECTED_OLLAMA_MODEL + ) except Exception as e: errorLogging() - response = { - "status":400, - "result":{ - "message":f"Error {e}", - "data": config.SELECTED_OLLAMA_MODEL - } - } + response = VRCTError.create_exception_error_response( + e, + data=config.SELECTED_OLLAMA_MODEL + ) return response @staticmethod @@ -2599,13 +2471,10 @@ class Controller: model.changeMicTranscriptStatus() response = {"status":200, "result":config.VRC_MIC_MUTE_SYNC} else: - response = { - "status":400, - "result":{ - "message":"Cannot enable VRC mic mute sync while OSC query is disabled", - "data": config.VRC_MIC_MUTE_SYNC - } - } + response = VRCTError.create_error_response( + ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED, + data=config.VRC_MIC_MUTE_SYNC + ) else: response = {"status":200, "result":config.VRC_MIC_MUTE_SYNC} return response @@ -2814,23 +2683,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_VRAM_MIC, + data=error_message + ) self.run( - 400, + response["status"], self.run_mapping["error_transcription_mic_vram_overflow"], - { - "message":"VRAM out of memory during mic transcription", - "data": error_message - }, + response["result"], ) # ここでマイクの音声認識を停止 self.stopTranscriptionSendMessage() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_transcription_send"], - { - "message":"Transcription send disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) else: # その他のエラーは通常通り処理 @@ -2863,23 +2734,25 @@ class Controller: # VRAM不足エラーの検出 is_vram_error, error_message = model.detectVRAMError(e) if is_vram_error: + response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_VRAM_SPEAKER, + data=error_message + ) self.run( - 400, + response["status"], self.run_mapping["error_transcription_speaker_vram_overflow"], - { - "message":"VRAM out of memory during speaker transcription", - "data": error_message - }, + response["result"], ) # ここでスピーカーの音声認識を停止 self.stopTranscriptionReceiveMessage() + disable_response = VRCTError.create_error_response( + ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM, + data=False + ) self.run( - 400, + disable_response["status"], self.run_mapping["enable_transcription_receive"], - { - "message":"Transcription receive disabled due to VRAM overflow", - "data": False - }, + disable_response["result"], ) else: # その他のエラーは通常通り処理 @@ -2943,8 +2816,14 @@ class Controller: return cleaned_text def updateDownloadedCTranslate2ModelWeight(self) -> None: - for weight_type in config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT.keys(): - config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[weight_type] = model.checkTranslatorCTranslate2ModelWeight(weight_type) + # キャッシュされた結果を使用(起動時の重複チェックを回避) + if hasattr(self, '_ctranslate2_available_cache'): + # 起動時のキャッシュを使用: 選択中の重みタイプのみ設定 + config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[config.CTRANSLATE2_WEIGHT_TYPE] = self._ctranslate2_available_cache + else: + # 通常時は全重みタイプをチェック + for weight_type in config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT.keys(): + config.SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT[weight_type] = model.checkTranslatorCTranslate2ModelWeight(weight_type) def updateTranslationEngineAndEngineList(self): engines = config.SELECTED_TRANSLATION_ENGINES @@ -2966,8 +2845,14 @@ class Controller: self.run(200, self.run_mapping["translation_engines"], selectable_engines) def updateDownloadedWhisperModelWeight(self) -> None: - for weight_type in config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT.keys(): - config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[weight_type] = model.checkTranscriptionWhisperModelWeight(weight_type) + # キャッシュされた結果を使用(起動時の重複チェックを回避) + if hasattr(self, '_whisper_available_cache'): + # 起動時のキャッシュを使用: 選択中の重みタイプのみ設定 + config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[config.WHISPER_WEIGHT_TYPE] = self._whisper_available_cache + else: + # 通常時は全重みタイプをチェック + for weight_type in config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT.keys(): + config.SELECTABLE_WHISPER_WEIGHT_TYPE_DICT[weight_type] = model.checkTranscriptionWhisperModelWeight(weight_type) def updateTranscriptionEngine(self): weight_type = config.WHISPER_WEIGHT_TYPE @@ -3068,13 +2953,10 @@ class Controller: @staticmethod def setWebSocketHost(data, *args, **kwargs) -> dict: if isValidIpAddress(data) is False: - response = { - "status":400, - "result":{ - "message":"Invalid IP address", - "data": config.WEBSOCKET_HOST - } - } + response = VRCTError.create_error_response( + ErrorCode.VALIDATION_INVALID_IP, + data=config.WEBSOCKET_HOST + ) else: if model.checkWebSocketServerAlive() is False: config.WEBSOCKET_HOST = data @@ -3088,13 +2970,10 @@ class Controller: config.WEBSOCKET_HOST = data response = {"status":200, "result":config.WEBSOCKET_HOST} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server host is not available", - "data": config.WEBSOCKET_HOST - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_HOST_UNAVAILABLE, + data=config.WEBSOCKET_HOST + ) return response @@ -3116,13 +2995,10 @@ class Controller: config.WEBSOCKET_PORT = int(data) response = {"status":200, "result":config.WEBSOCKET_PORT} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server port is not available", - "data": config.WEBSOCKET_PORT - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_PORT_UNAVAILABLE, + data=config.WEBSOCKET_PORT + ) return response @staticmethod @@ -3137,13 +3013,10 @@ class Controller: config.WEBSOCKET_SERVER = True response = {"status":200, "result":config.WEBSOCKET_SERVER} else: - response = { - "status":400, - "result":{ - "message":"WebSocket server host or port is not available", - "data": config.WEBSOCKET_SERVER - } - } + response = VRCTError.create_error_response( + ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE, + data=config.WEBSOCKET_SERVER + ) else: response = {"status":200, "result":config.WEBSOCKET_SERVER} return response @@ -3180,6 +3053,8 @@ class Controller: def init(self, *args, **kwargs) -> None: removeLog() printLog("Start Initialization") + + # Network check connected_network = isConnectedNetwork() if connected_network is True: self.connectedNetwork() @@ -3189,8 +3064,8 @@ class Controller: self.initializationProgress(1) + # Download weights if connected_network is True: - # download CTranslate2 Model Weight printLog("Download CTranslate2 Model Weight") weight_type = config.CTRANSLATE2_WEIGHT_TYPE th_download_ctranslate2 = None @@ -3199,7 +3074,6 @@ class Controller: th_download_ctranslate2.daemon = True th_download_ctranslate2.start() - # download Whisper Model Weight printLog("Download Whisper Model Weight") weight_type = config.WHISPER_WEIGHT_TYPE th_download_whisper = None @@ -3213,169 +3087,244 @@ class Controller: if isinstance(th_download_whisper, Thread): th_download_whisper.join() - if (model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is False or - model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is False): + # Check and disable/enable AI models (parallel) + + def check_ctranslate2() -> bool: + return model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is True + + def check_whisper() -> bool: + return model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is True + + with ThreadPoolExecutor(max_workers=2) as executor: + future_ctranslate2 = executor.submit(check_ctranslate2) + future_whisper = executor.submit(check_whisper) + ctranslate2_available = future_ctranslate2.result() + whisper_available = future_whisper.result() + + # インスタンス変数にキャッシュ(後続の処理で再利用) + self._ctranslate2_available_cache = ctranslate2_available + self._whisper_available_cache = whisper_available + + if not ctranslate2_available or not whisper_available: self.disableAiModels() else: self.enableAiModels() + # Init Translation Engine Status (with parallel processing) printLog("Init Translation Engine Status") - for engine in config.SELECTABLE_TRANSLATION_ENGINE_LIST: - match engine: - case "CTranslate2": - if model.checkTranslatorCTranslate2ModelWeight(config.CTRANSLATE2_WEIGHT_TYPE) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - case "DeepL_API": - printLog("Start check DeepL API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorDeepLAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("DeepL API Key is valid") + + # バックグラウンドチェック対象エンジン(LMStudio/Ollama) + background_check_engines = {"LMStudio", "Ollama"} + + def check_translation_engine(engine: str) -> tuple: + """翻訳エンジンのステータスをチェック(並列実行用)""" + status = False + auth_key_invalid = False + model_list = None + selected_model = None + + try: + match engine: + case "CTranslate2": + # 既に前のステップでチェック済み、結果を再利用 + status = ctranslate2_available + case "DeepL_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("DeepL API Key is invalid") - case "Plamo_API": - printLog("Start check Plamo API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorPlamoAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_PLAMO_MODEL_LIST = model.getTranslatorPlamoModelList() - if config.SELECTED_PLAMO_MODEL not in config.SELECTABLE_PLAMO_MODEL_LIST: - config.SELECTED_PLAMO_MODEL = config.SELECTABLE_PLAMO_MODEL_LIST[0] - model.setTranslatorPlamoModel(config.SELECTED_PLAMO_MODEL) - model.updateTranslatorPlamoClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Plamo API Key is valid") + if model.authenticationTranslatorDeepLAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + status = True + else: + auth_key_invalid = True + case "Plamo_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("Plamo API Key is invalid") - case "Gemini_API": - printLog("Start check Gemini API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorGeminiAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_GEMINI_MODEL_LIST = model.getTranslatorGeminiModelList() - if config.SELECTED_GEMINI_MODEL not in config.SELECTABLE_GEMINI_MODEL_LIST: - config.SELECTED_GEMINI_MODEL = config.SELECTABLE_GEMINI_MODEL_LIST[0] - model.setTranslatorGeminiModel(config.SELECTED_GEMINI_MODEL) - model.updateTranslatorGeminiClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Gemini API Key is valid") + if model.authenticationTranslatorPlamoAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorPlamoModelList() + selected_model = config.SELECTED_PLAMO_MODEL if config.SELECTED_PLAMO_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "Gemini_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("Gemini API Key is invalid") - case "OpenAI_API": - printLog("Start check OpenAI API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorOpenAIAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_OPENAI_MODEL_LIST = model.getTranslatorOpenAIModelList() - if config.SELECTED_OPENAI_MODEL not in config.SELECTABLE_OPENAI_MODEL_LIST: - config.SELECTED_OPENAI_MODEL = config.SELECTABLE_OPENAI_MODEL_LIST[0] - model.setTranslatorOpenAIModel(config.SELECTED_OPENAI_MODEL) - model.updateTranslatorOpenAIClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("OpenAI API Key is valid") + if model.authenticationTranslatorGeminiAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorGeminiModelList() + selected_model = config.SELECTED_GEMINI_MODEL if config.SELECTED_GEMINI_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "OpenAI_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("OpenAI API Key is invalid") - case "Groq_API": - printLog("Start check Groq API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorGroqAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_GROQ_MODEL_LIST = model.getTranslatorGroqModelList() - if config.SELECTED_GROQ_MODEL not in config.SELECTABLE_GROQ_MODEL_LIST: - config.SELECTED_GROQ_MODEL = config.SELECTABLE_GROQ_MODEL_LIST[0] - model.setTranslatorGroqModel(config.SELECTED_GROQ_MODEL) - model.updateTranslatorGroqClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Groq API Key is valid") + if model.authenticationTranslatorOpenAIAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorOpenAIModelList() + selected_model = config.SELECTED_OPENAI_MODEL if config.SELECTED_OPENAI_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "Groq_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("Groq API Key is invalid") - case "OpenRouter_API": - printLog("Start check OpenRouter API Key") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False - if config.AUTH_KEYS[engine] is not None: - if model.authenticationTranslatorOpenRouterAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: - config.SELECTABLE_OPENROUTER_MODEL_LIST = model.getTranslatorOpenRouterModelList() - if config.SELECTED_OPENROUTER_MODEL not in config.SELECTABLE_OPENROUTER_MODEL_LIST: - config.SELECTED_OPENROUTER_MODEL = config.SELECTABLE_OPENROUTER_MODEL_LIST[0] - model.setTranslatorOpenRouterModel(config.SELECTED_OPENROUTER_MODEL) - model.updateTranslatorOpenRouterClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("OpenRouter API Key is valid") + if model.authenticationTranslatorGroqAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorGroqModelList() + selected_model = config.SELECTED_GROQ_MODEL if config.SELECTED_GROQ_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "OpenRouter_API": + if config.AUTH_KEYS[engine] is None: + status = False else: - # error update Auth key - auth_keys = config.AUTH_KEYS - auth_keys[engine] = None - config.AUTH_KEYS = auth_keys - printLog("OpenRouter API Key is invalid") - case "LMStudio": - printLog("Start check LMStudio Server") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + if model.authenticationTranslatorOpenRouterAuthKey(auth_key=config.AUTH_KEYS[engine]) is True: + model_list = model.getTranslatorOpenRouterModelList() + selected_model = config.SELECTED_OPENROUTER_MODEL if config.SELECTED_OPENROUTER_MODEL in model_list else model_list[0] + status = True + else: + auth_key_invalid = True + case "LMStudio": + # バックグラウンドチェックにスキップ + status = False + case "Ollama": + # バックグラウンドチェックにスキップ + status = False + case _: + status = connected_network is True + except Exception as e: + printLog(f"Error checking engine {engine}: {str(e)}") + errorLogging() + status = False + + return engine, status, auth_key_invalid, model_list, selected_model + + def check_local_server_engine_background(engine: str): + """ローカルサーバー系エンジンをバックグラウンドでチェック""" + try: + printLog(f"[Background] Start check {engine}") + status = False + model_list = None + selected_model = None + + if engine == "LMStudio": if config.LMSTUDIO_URL is not None: if model.authenticationTranslatorLMStudio(base_url=config.LMSTUDIO_URL) is True: - config.SELECTABLE_LMSTUDIO_MODEL_LIST = model.getTranslatorLMStudioModelList() - if len(config.SELECTABLE_LMSTUDIO_MODEL_LIST) == 0: - printLog("LMStudio model list is empty") - break - if config.SELECTED_LMSTUDIO_MODEL not in config.SELECTABLE_LMSTUDIO_MODEL_LIST: - config.SELECTED_LMSTUDIO_MODEL = config.SELECTABLE_LMSTUDIO_MODEL_LIST[0] - model.setTranslatorLMStudioModel(config.SELECTED_LMSTUDIO_MODEL) - model.updateTranslatorLMStudioClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("LMStudio is available") - else: - printLog("LMStudio is not available") - case "Ollama": - printLog("Start check Ollama Server") - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + model_list = model.getTranslatorLMStudioModelList() + if len(model_list) > 0: + selected_model = config.SELECTED_LMSTUDIO_MODEL if config.SELECTED_LMSTUDIO_MODEL in model_list else model_list[0] + config.SELECTABLE_LMSTUDIO_MODEL_LIST = model_list + config.SELECTED_LMSTUDIO_MODEL = selected_model + model.setTranslatorLMStudioModel(selected_model) + model.updateTranslatorLMStudioClient() + status = True + elif engine == "Ollama": if model.authenticationTranslatorOllama() is True: - config.SELECTABLE_OLLAMA_MODEL_LIST = model.getTranslatorOllamaModelList() - if len(config.SELECTABLE_OLLAMA_MODEL_LIST) == 0: - printLog("Ollama model list is empty") - break - if config.SELECTED_OLLAMA_MODEL not in config.SELECTABLE_OLLAMA_MODEL_LIST: - config.SELECTED_OLLAMA_MODEL = config.SELECTABLE_OLLAMA_MODEL_LIST[0] - model.setTranslatorOllamaModel(config.SELECTED_OLLAMA_MODEL) - model.updateTranslatorOllamaClient() - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - printLog("Ollama is available") - else: - printLog("Ollama is not available") - case _: - if connected_network is True: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + model_list = model.getTranslatorOllamaModelList() + if len(model_list) > 0: + selected_model = config.SELECTED_OLLAMA_MODEL if config.SELECTED_OLLAMA_MODEL in model_list else model_list[0] + config.SELECTABLE_OLLAMA_MODEL_LIST = model_list + config.SELECTED_OLLAMA_MODEL = selected_model + model.setTranslatorOllamaModel(selected_model) + model.updateTranslatorOllamaClient() + status = True + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = status + printLog(f"[Background] {engine} check completed: {status}") + + # 更新通知(もしrun_mappingがあれば) + if status: + self.updateTranslationEngineAndEngineList() + except Exception as e: + printLog(f"[Background] Error checking {engine}: {str(e)}") + errorLogging() + + # 並列実行(バックグラウンドチェック対象を除外) + engine_results = {} + engines_to_check = [e for e in config.SELECTABLE_TRANSLATION_ENGINE_LIST if e not in background_check_engines] + + with ThreadPoolExecutor(max_workers=4) as executor: + future_to_engine = {executor.submit(check_translation_engine, engine): engine + for engine in engines_to_check} + + for future in as_completed(future_to_engine): + engine, status, auth_key_invalid, model_list, selected_model = future.result() + engine_results[engine] = (status, auth_key_invalid, model_list, selected_model) + + # バックグラウンドチェック対象エンジンは初期値Falseで即座に設定 + for engine in background_check_engines: + if engine in config.SELECTABLE_TRANSLATION_ENGINE_LIST: + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = False + printLog(f"Start check {engine}") + printLog(f"Engine '{engine}' deferred to background check") + # バックグラウンドスレッドで実行 + bg_thread = Thread(target=check_local_server_engine_background, args=(engine,)) + bg_thread.daemon = True + bg_thread.start() + + # 結果を順番に適用(メインスレッドで実行) + for engine in engines_to_check: + if engine not in engine_results: + continue + + status, auth_key_invalid, model_list, selected_model = engine_results[engine] + + # ログ出力 + printLog(f"Start check {engine}") + + # ステータス設定 + config.SELECTABLE_TRANSLATION_ENGINE_STATUS[engine] = status + + # 認証キー無効化 + if auth_key_invalid: + auth_keys = config.AUTH_KEYS + auth_keys[engine] = None + config.AUTH_KEYS = auth_keys + printLog(f"{engine} auth key is invalid") + elif status: + printLog(f"{engine} is valid/available") + + # モデルリストと選択モデルの設定 + if model_list is not None and status: + match engine: + case "Plamo_API": + config.SELECTABLE_PLAMO_MODEL_LIST = model_list + config.SELECTED_PLAMO_MODEL = selected_model + model.setTranslatorPlamoModel(selected_model) + model.updateTranslatorPlamoClient() + case "Gemini_API": + config.SELECTABLE_GEMINI_MODEL_LIST = model_list + config.SELECTED_GEMINI_MODEL = selected_model + model.setTranslatorGeminiModel(selected_model) + model.updateTranslatorGeminiClient() + case "OpenAI_API": + config.SELECTABLE_OPENAI_MODEL_LIST = model_list + config.SELECTED_OPENAI_MODEL = selected_model + model.setTranslatorOpenAIModel(selected_model) + model.updateTranslatorOpenAIClient() + case "Groq_API": + config.SELECTABLE_GROQ_MODEL_LIST = model_list + config.SELECTED_GROQ_MODEL = selected_model + model.setTranslatorGroqModel(selected_model) + model.updateTranslatorGroqClient() + case "OpenRouter_API": + config.SELECTABLE_OPENROUTER_MODEL_LIST = model_list + config.SELECTED_OPENROUTER_MODEL = selected_model + model.setTranslatorOpenRouterModel(selected_model) + model.updateTranslatorOpenRouterClient() + + printLog(f"{engine} check completed") + + printLog("Translation Engine Status Init completed") + + # Init Transcription Engine Status for engine in config.SELECTABLE_TRANSCRIPTION_ENGINE_LIST: match engine: case "Whisper": - if model.checkTranscriptionWhisperModelWeight(config.WHISPER_WEIGHT_TYPE) is True: - config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = True - else: - config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = False + # キャッシュされた結果を使用(重複チェックを回避) + config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = self._whisper_available_cache case _: if connected_network is True: config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = True @@ -3383,55 +3332,79 @@ class Controller: config.SELECTABLE_TRANSCRIPTION_ENGINE_STATUS[engine] = False self.initializationProgress(2) - # set Translation Engine + # Set Translation Engine printLog("Set Translation Engine") self.updateDownloadedCTranslate2ModelWeight() self.updateTranslationEngineAndEngineList() - # set Transcription Engine + # Set Transcription Engine printLog("Set Transcription Engine") self.updateDownloadedWhisperModelWeight() self.updateTranscriptionEngine() - # set Transliteration status + # Set Transliteration printLog("Set Transliteration") if config.CONVERT_MESSAGE_TO_ROMAJI is True or config.CONVERT_MESSAGE_TO_HIRAGANA is True: model.startTransliteration() self.initializationProgress(3) - # set word filter + # Set Word Filter printLog("Set Word Filter") model.addKeywords() - # check Software Updated - printLog("Check Software Updated") - self.checkSoftwareUpdated() + # Check Software Updated (Background) + printLog("Check Software Updated (Background)") - # init logger + def check_software_updated_background(): + """ソフトウェア更新チェックをバックグラウンドで実行""" + try: + self.checkSoftwareUpdated() + printLog("[Background] Software update check completed") + except Exception: + errorLogging() + printLog("[Background] Software update check failed") + + bg_thread = Thread(target=check_software_updated_background) + bg_thread.daemon = True + bg_thread.start() + + # Init Logger printLog("Init Logger") if config.LOGGER_FEATURE is True: model.startLogger() self.initializationProgress(4) - # init OSC receive - printLog("Init OSC Receive") - model.startReceiveOSC() - osc_query_enabled = model.getIsOscQueryEnabled() - if osc_query_enabled is True: - self.enableOscQuery() - if config.VRC_MIC_MUTE_SYNC is True: - self.setEnableVrcMicMuteSync() - else: - # OSC Query is disabled, so disable VRC some features - mute_sync_info_flag = False - if config.VRC_MIC_MUTE_SYNC is True: - self.setDisableVrcMicMuteSync() - mute_sync_info_flag = True - self.disableOscQuery(mute_sync_info=mute_sync_info_flag) + # Init OSC Receive (Background) + printLog("Init OSC Receive (Background)") - # init Auto device selection + def init_osc_receive_background(): + """OSC Receiveの初期化をバックグラウンドで実行""" + try: + model.startReceiveOSC() + osc_query_enabled = model.getIsOscQueryEnabled() + if osc_query_enabled is True: + self.enableOscQuery() + if config.VRC_MIC_MUTE_SYNC is True: + self.setEnableVrcMicMuteSync() + else: + # OSC Query is disabled, so disable VRC some features + mute_sync_info_flag = False + if config.VRC_MIC_MUTE_SYNC is True: + self.setDisableVrcMicMuteSync() + mute_sync_info_flag = True + self.disableOscQuery(mute_sync_info=mute_sync_info_flag) + printLog("[Background] OSC Receive initialization completed") + except Exception: + errorLogging() + printLog("[Background] OSC Receive initialization failed") + + bg_thread = Thread(target=init_osc_receive_background) + bg_thread.daemon = True + bg_thread.start() + + # Init Device Manager printLog("Init Device Manager") device_manager.setCallbackHostList(self.updateMicHostList) device_manager.setCallbackMicDeviceList(self.updateMicDeviceList) @@ -3443,10 +3416,12 @@ class Controller: if config.AUTO_SPEAKER_SELECT is True: self.applyAutoSpeakerSelect() + # Init Overlay printLog("Init Overlay") if (config.OVERLAY_SMALL_LOG is True or config.OVERLAY_LARGE_LOG is True): model.startOverlay() + # Init WebSocket Server printLog("Init WebSocket Server") if config.WEBSOCKET_SERVER is True: if isAvailableWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) is True: @@ -3456,9 +3431,11 @@ class Controller: model.stopWebSocketServer() printLog("WebSocket server host or port is not available") + # Revalidate Selected Models printLog("Revalidate Selected Models") config.revalidate_selected_models() + # Update Settings printLog("Update settings") self.updateConfigSettings() diff --git a/src-python/docs/controller.md b/src-python/docs/controller.md index ab789ee0..d34dc9e3 100644 --- a/src-python/docs/controller.md +++ b/src-python/docs/controller.md @@ -4,6 +4,15 @@ `controller.py` は VRCT アプリケーションのビジネスロジック層であり、フロントエンド(UI)とバックエンド(Model)の間の制御フローを担当する。音声認識、翻訳、OSC通信、オーバーレイ表示など、VRCT の全機能の調整役として動作し、各種設定の取得・更新、デバイス管理、エラーハンドリングを提供する。 +## 最近の更新 (2026-01-03) + +- 起動高速化: 初期化時間を約12.6s→8.9sに短縮 +- AI Models Check 並列化: CTranslate2/Whisperの重みチェックを2並列で実行 +- 翻訳エンジン判定の非同期化: LMStudio/Ollamaをバックグラウンド判定、他APIは4並列 +- 重みチェック結果のキャッシュ: `_ctranslate2_available_cache` / `_whisper_available_cache` を導入し後続処理で再利用 +- 音声認識エンジン判定の高速化: Whisperはキャッシュ結果を利用し0.56s→0.00s +- ソフトウェア更新チェックの非同期化: GitHub APIチェックをバックグラウンド化 + ## アーキテクチャ上の位置づけ ``` diff --git a/src-python/docs/details/controller.md b/src-python/docs/details/controller.md index 3dd3e73b..79f7e751 100644 --- a/src-python/docs/details/controller.md +++ b/src-python/docs/details/controller.md @@ -4,6 +4,26 @@ VRCTアプリケーションのビジネスロジックを制御するコントローラークラスです。UI層とモデル層の間に位置し、ユーザーの入力を適切な処理に変換し、結果を UI に返す役割を担います。全ての機能制御、設定管理、状態管理を一元的に行います。 +## 最近の更新 (2026-01-03) + +### 起動高速化・非同期化 + +- 初期化時間を約12.6s→8.9sに短縮(環境計測値) +- AI Models Check を2並列化(CTranslate2/Whisper)し、結果を `_ctranslate2_available_cache` / `_whisper_available_cache` に保存 +- 翻訳エンジン判定を並列化(ThreadPoolExecutor, max_workers=4)し、LMStudio/Ollamaはバックグラウンド判定に変更 +- ソフトウェア更新チェックをバックグラウンド化 +- OSC受信初期化をバックグラウンド化し、OSCQueryサービス生成は接続成功まで継続リトライ +- 翻訳/音声認識エンジンのセット処理で重みチェックキャッシュを再利用し再計測を排除(0.98s/0.52s→0.00s) + +### 影響 + +| 項目 | 内容 | +|------|------| +| 起動時間 | 約3.7s短縮(12.6s→8.9s) | +| 並列・非同期化 | 翻訳・音声認識エンジン判定を並列/バックグラウンド化 | +| 安定性 | OSCQuery起動のリトライ上限でブロッキングを抑制 | +| 再利用性 | 重みチェック結果をキャッシュし重複I/Oを削減 | + ## 最近の更新 (2025-10-20) ### 新規ローカルLLM翻訳エンジン統合 @@ -399,25 +419,29 @@ speakerMessage(result: dict) -> None ## エラーハンドリング -### VRAM不足エラー +### エラー構造 +- すべて `VRCTError` で生成し、ステータス・コード・メッセージ・data を統一 +- `create_error_response()` / `create_exception_error_response()` を使用し、`self.run()` へそのまま渡す +- 代表コード: デバイス系 (`DEVICE_NO_MIC` / `DEVICE_NO_SPEAKER`)、VRAM系 (`TRANSLATION_VRAM_*`)、認証系 (`AUTH_*`)、モデル不正 (`MODEL_*`)、バリデーション系 (`VALIDATION_*`)、接続系 (`CONNECTION_LMSTUDIO_FAILED` など) -- 自動的にCTranslate2への切り替え -- ユーザーへの適切な通知 +### VRAM不足エラー +- 翻訳処理中に VRAM 例外を検出し `/run/error_translation_*_vram_overflow` で通知 +- 翻訳機能を自動で無効化し、`TRANSLATION_DISABLED_VRAM` を通知 +- マイク/スピーカー/チャット/有効化時の各パスで専用コードを返却 ### デバイスエラー +- マイク・スピーカー未検出時に `DEVICE_NO_MIC` / `DEVICE_NO_SPEAKER` +- エネルギーしきい値/タイムアウト等のバリデーションに `VALIDATION_*` を使用 -- デバイス接続状態の監視 -- 自動復旧機能 +### 認証・モデルエラー +- DeepL/Plamo/Gemini/OpenAI/Groq/OpenRouter の認証失敗やキー長不正を `AUTH_*` で通知 +- モデル未選択/不正時は `MODEL_*` で通知し、選択リストを再送 -### ネットワークエラー - -- 接続状態の定期確認 -- オフライン機能への切り替え +### 接続エラー +- LMStudio/Ollama 接続失敗を `CONNECTION_*` で通知し、翻訳エンジンリストを更新 ### 設定エラー - -- 設定値の妥当性チェック -- デフォルト値への復帰 +- IP アドレスやしきい値などの不正値を `VALIDATION_*` で統一し、リクエスト値を data に格納 ## パフォーマンス考慮事項 diff --git a/src-python/docs/error_handling_migration_guide.md b/src-python/docs/error_handling_migration_guide.md new file mode 100644 index 00000000..051489f6 --- /dev/null +++ b/src-python/docs/error_handling_migration_guide.md @@ -0,0 +1,212 @@ +# エラーハンドリング統一システム移行ガイド + +## 概要 + +`errors.py`で定義された統一エラーシステムを使用して、すべてのエラーハンドリングを標準化しました。 + +## 変更パターン + +### 1. 基本的なエラーレスポンス + +#### 修正前: +```python +response = { + "status": 400, + "result": { + "message": "Error message", + "data": some_value + } +} +``` + +#### 修正後: +```python +from errors import ErrorCode, VRCTError + +response = VRCTError.create_error_response( + ErrorCode.APPROPRIATE_ERROR_CODE, + data=some_value +) +``` + +### 2. run_mapping経由のエラー通知 + +#### 修正前: +```python +self.run( + 400, + self.run_mapping["error_device"], + { + "message": "No mic device detected", + "data": None + }, +) +``` + +#### 修正後: +```python +error_response = VRCTError.create_error_response( + ErrorCode.DEVICE_NO_MIC, + data=None +) +self.run( + error_response["status"], + self.run_mapping["error_device"], + error_response["result"], +) +``` + +### 3. 例外からのエラー生成 + +#### 修正前: +```python +except Exception as e: + errorLogging() + response = { + "status": 400, + "result": { + "message": f"Error {e}", + "data": original_value + } + } +``` + +#### 修正後: +```python +except Exception as e: + errorLogging() + response = VRCTError.create_exception_error_response( + e, + data=original_value + ) +``` + +## 既に移行済みの箇所 + +### デバイスエラー +- ✅ `progressBarMicEnergy` - `ErrorCode.DEVICE_NO_MIC` +- ✅ `progressBarSpeakerEnergy` - `ErrorCode.DEVICE_NO_SPEAKER` + +### ウェイトダウンロードエラー +- ✅ `DownloadCTranslate2.downloaded` - `ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD` +- ✅ `DownloadWhisper.downloaded` - `ErrorCode.WEIGHT_WHISPER_DOWNLOAD` + +### 翻訳エラー +- ✅ `micMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_MIC`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `speakerMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_SPEAKER`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `chatMessage` - `ErrorCode.TRANSLATION_ENGINE_LIMIT`, `ErrorCode.TRANSLATION_VRAM_CHAT`, `ErrorCode.TRANSLATION_DISABLED_VRAM` +- ✅ `setEnableTranslation` - `ErrorCode.TRANSLATION_VRAM_ENABLE`, `ErrorCode.TRANSLATION_DISABLED_VRAM` + +### バリデーションエラー +- ✅ `setMicThreshold` - `ErrorCode.VALIDATION_MIC_THRESHOLD` +- ✅ `setSpeakerThreshold` - `ErrorCode.VALIDATION_SPEAKER_THRESHOLD` +- ✅ `setMicRecordTimeout` - `ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT` +- ✅ `setMicPhraseTimeout` - `ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT` +- ✅ `setMicMaxPhrases` - `ErrorCode.VALIDATION_MIC_MAX_PHRASES` +- ✅ `setSpeakerRecordTimeout` - `ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT` +- ✅ `setSpeakerPhraseTimeout` - `ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT` +- ✅ `setSpeakerMaxPhrases` - `ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES` +- ✅ `setOscIpAddress` - `ErrorCode.VALIDATION_INVALID_IP`, `ErrorCode.VALIDATION_CANNOT_SET_IP` + +### VRC連携エラー +- ✅ `setEnableVrcMicMuteSync` - `ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED` + +### 認証エラー +- ✅ `setDeeplAuthKey` - `ErrorCode.AUTH_DEEPL_LENGTH`, `ErrorCode.AUTH_DEEPL_FAILED` + +## 未移行の箇所(要対応) + +以下の箇所は同様のパターンで移行が必要です: + +### 認証関連 +- ⬜ `setPlamoAuthKey` - `ErrorCode.AUTH_PLAMO_LENGTH`, `ErrorCode.AUTH_PLAMO_FAILED` +- ⬜ `setPlamoModel` - `ErrorCode.MODEL_PLAMO_INVALID` +- ⬜ `setGeminiAuthKey` - `ErrorCode.AUTH_GEMINI_LENGTH`, `ErrorCode.AUTH_GEMINI_FAILED` +- ⬜ `setGeminiModel` - `ErrorCode.MODEL_GEMINI_INVALID` +- ⬜ `setOpenAIAuthKey` - `ErrorCode.AUTH_OPENAI_INVALID`, `ErrorCode.AUTH_OPENAI_FAILED` +- ⬜ `setOpenAIModel` - `ErrorCode.MODEL_OPENAI_INVALID` +- ⬜ `setGroqAuthKey` - `ErrorCode.AUTH_GROQ_INVALID`, `ErrorCode.AUTH_GROQ_FAILED` +- ⬜ `setGroqModel` - `ErrorCode.MODEL_GROQ_INVALID` +- ⬜ `setOpenRouterAuthKey` - `ErrorCode.AUTH_OPENROUTER_INVALID`, `ErrorCode.AUTH_OPENROUTER_FAILED` +- ⬜ `setOpenRouterModel` - `ErrorCode.MODEL_OPENROUTER_INVALID` + +### 接続関連 +- ⬜ `checkTranslatorLMStudioConnection` - `ErrorCode.CONNECTION_LMSTUDIO_FAILED` +- ⬜ `setTranslatorLMStudioURL` - `ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID` +- ⬜ `setTranslatorLMStudioModel` - `ErrorCode.MODEL_LMSTUDIO_INVALID` +- ⬜ `checkTranslatorOllamaConnection` - `ErrorCode.CONNECTION_OLLAMA_FAILED` +- ⬜ `setTranslatorOllamaModel` - `ErrorCode.MODEL_OLLAMA_INVALID` + +### WebSocket関連 +- ⬜ `setWebSocketHost` - `ErrorCode.VALIDATION_INVALID_IP`, `ErrorCode.WEBSOCKET_HOST_INVALID` +- ⬜ `setWebSocketPort` - `ErrorCode.WEBSOCKET_PORT_UNAVAILABLE` +- ⬜ `setEnableWebSocketServer` - `ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE` + +### 音声認識VRAM関連 +- ⬜ `startTranscriptionSendMessage` - `ErrorCode.TRANSCRIPTION_VRAM_MIC`, `ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM` +- ⬜ `startTranscriptionReceiveMessage` - `ErrorCode.TRANSCRIPTION_VRAM_SPEAKER`, `ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM` + +## エラーコードとエンドポイントの対応 + +`errors.py`の`ENDPOINT_ERROR_MAPPING`に、すべてのエンドポイントとエラーコードの対応が定義されています。 +UI開発者はこのマッピングを参照して、各エンドポイントがどのようなエラーを返すか確認できます。 + +## エラーレスポンスの構造 + +統一されたエラーレスポンスは以下の構造を持ちます: + +```python +{ + "status": 400, # HTTPステータスコード + "result": { + "error_code": "ERROR_CODE_CONSTANT", # エラーコード定数 + "message": "Human readable message", # 人間が読めるメッセージ + "data": None or original_value, # エラー時に戻す値(通常は元の値) + "details": {}, # 追加情報(オプション) + "category": "category_name", # エラーカテゴリ + "severity": "warning|error|critical", # 重要度 + } +} +``` + +## UI側での活用 + +UI側では`error_code`を使用して、エラーの種類を判定し、適切な処理を行うことができます: + +```javascript +if (response.status === 400) { + const { error_code, message, data, severity } = response.result; + + switch (error_code) { + case "DEVICE_NO_MIC": + // マイクデバイスエラーの処理 + break; + case "VALIDATION_MIC_THRESHOLD": + // バリデーションエラーの処理(元の値に戻す) + setValue(data); + break; + // ... + } + + // 重要度に応じた表示 + if (severity === "critical") { + showCriticalError(message); + } +} +``` + +## 移行作業の進め方 + +1. **パターンの確認**: 上記の変更パターンを参照 +2. **エラーコードの特定**: `errors.py`から適切な`ErrorCode`を選択 +3. **コードの置き換え**: 古いエラーハンドリングを新しいシステムに置き換え +4. **テスト**: エラーが正しく返されることを確認 +5. **チェックリストの更新**: このドキュメントの✅を更新 + +## 注意事項 + +- すべてのエラーは`errors.py`に定義されたエラーコードを使用すること +- 新しいエラーが必要な場合は、まず`errors.py`に追加すること +- エラーメッセージは`ERROR_METADATA`で定義されたデフォルトメッセージを使用すること + - カスタムメッセージが必要な場合は`custom_message`パラメータを使用 +- `data`パラメータには、エラー時にUIが元の値に戻せるように、元の値を渡すこと diff --git a/src-python/errors.py b/src-python/errors.py new file mode 100644 index 00000000..abe75628 --- /dev/null +++ b/src-python/errors.py @@ -0,0 +1,694 @@ +# src-python/errors.py +""" +統一エラー管理システム + +すべてのエラーを一元管理し、エンドポイントとエラーコードの対応を明確にする。 +""" + +from typing import Any, Optional, Dict +from enum import Enum + + +class ErrorCode(str, Enum): + """エラーコード定数 + + 命名規則: カテゴリ_具体的な内容 + """ + # ============================================================================ + # デバイス関連エラー (DEVICE_*) + # ============================================================================ + DEVICE_NO_MIC = "DEVICE_NO_MIC" + DEVICE_NO_SPEAKER = "DEVICE_NO_SPEAKER" + + # ============================================================================ + # 翻訳関連エラー (TRANSLATION_*) + # ============================================================================ + TRANSLATION_ENGINE_LIMIT = "TRANSLATION_ENGINE_LIMIT" + TRANSLATION_VRAM_CHAT = "TRANSLATION_VRAM_CHAT" + TRANSLATION_VRAM_MIC = "TRANSLATION_VRAM_MIC" + TRANSLATION_VRAM_SPEAKER = "TRANSLATION_VRAM_SPEAKER" + TRANSLATION_VRAM_ENABLE = "TRANSLATION_VRAM_ENABLE" + TRANSLATION_DISABLED_VRAM = "TRANSLATION_DISABLED_VRAM" + + # ============================================================================ + # 音声認識関連エラー (TRANSCRIPTION_*) + # ============================================================================ + TRANSCRIPTION_VRAM_MIC = "TRANSCRIPTION_VRAM_MIC" + TRANSCRIPTION_VRAM_SPEAKER = "TRANSCRIPTION_VRAM_SPEAKER" + TRANSCRIPTION_SEND_DISABLED_VRAM = "TRANSCRIPTION_SEND_DISABLED_VRAM" + TRANSCRIPTION_RECEIVE_DISABLED_VRAM = "TRANSCRIPTION_RECEIVE_DISABLED_VRAM" + + # ============================================================================ + # ウェイトダウンロード関連エラー (WEIGHT_*) + # ============================================================================ + WEIGHT_CTRANSLATE2_DOWNLOAD = "WEIGHT_CTRANSLATE2_DOWNLOAD" + WEIGHT_WHISPER_DOWNLOAD = "WEIGHT_WHISPER_DOWNLOAD" + + # ============================================================================ + # バリデーションエラー (VALIDATION_*) + # ============================================================================ + VALIDATION_MIC_THRESHOLD = "VALIDATION_MIC_THRESHOLD" + VALIDATION_SPEAKER_THRESHOLD = "VALIDATION_SPEAKER_THRESHOLD" + VALIDATION_MIC_RECORD_TIMEOUT = "VALIDATION_MIC_RECORD_TIMEOUT" + VALIDATION_MIC_PHRASE_TIMEOUT = "VALIDATION_MIC_PHRASE_TIMEOUT" + VALIDATION_MIC_MAX_PHRASES = "VALIDATION_MIC_MAX_PHRASES" + VALIDATION_SPEAKER_RECORD_TIMEOUT = "VALIDATION_SPEAKER_RECORD_TIMEOUT" + VALIDATION_SPEAKER_PHRASE_TIMEOUT = "VALIDATION_SPEAKER_PHRASE_TIMEOUT" + VALIDATION_SPEAKER_MAX_PHRASES = "VALIDATION_SPEAKER_MAX_PHRASES" + VALIDATION_INVALID_IP = "VALIDATION_INVALID_IP" + VALIDATION_CANNOT_SET_IP = "VALIDATION_CANNOT_SET_IP" + + # ============================================================================ + # 認証エラー (AUTH_*) + # ============================================================================ + AUTH_DEEPL_LENGTH = "AUTH_DEEPL_LENGTH" + AUTH_DEEPL_FAILED = "AUTH_DEEPL_FAILED" + AUTH_PLAMO_LENGTH = "AUTH_PLAMO_LENGTH" + AUTH_PLAMO_FAILED = "AUTH_PLAMO_FAILED" + AUTH_GEMINI_LENGTH = "AUTH_GEMINI_LENGTH" + AUTH_GEMINI_FAILED = "AUTH_GEMINI_FAILED" + AUTH_OPENAI_INVALID = "AUTH_OPENAI_INVALID" + AUTH_OPENAI_FAILED = "AUTH_OPENAI_FAILED" + AUTH_GROQ_INVALID = "AUTH_GROQ_INVALID" + AUTH_GROQ_FAILED = "AUTH_GROQ_FAILED" + AUTH_OPENROUTER_INVALID = "AUTH_OPENROUTER_INVALID" + AUTH_OPENROUTER_FAILED = "AUTH_OPENROUTER_FAILED" + + # ============================================================================ + # モデル選択エラー (MODEL_*) + # ============================================================================ + MODEL_PLAMO_INVALID = "MODEL_PLAMO_INVALID" + MODEL_GEMINI_INVALID = "MODEL_GEMINI_INVALID" + MODEL_OPENAI_INVALID = "MODEL_OPENAI_INVALID" + MODEL_GROQ_INVALID = "MODEL_GROQ_INVALID" + MODEL_OPENROUTER_INVALID = "MODEL_OPENROUTER_INVALID" + MODEL_LMSTUDIO_INVALID = "MODEL_LMSTUDIO_INVALID" + MODEL_OLLAMA_INVALID = "MODEL_OLLAMA_INVALID" + + # ============================================================================ + # 接続エラー (CONNECTION_*) + # ============================================================================ + CONNECTION_LMSTUDIO_FAILED = "CONNECTION_LMSTUDIO_FAILED" + CONNECTION_OLLAMA_FAILED = "CONNECTION_OLLAMA_FAILED" + CONNECTION_LMSTUDIO_URL_INVALID = "CONNECTION_LMSTUDIO_URL_INVALID" + + # ============================================================================ + # WebSocketエラー (WEBSOCKET_*) + # ============================================================================ + WEBSOCKET_HOST_INVALID = "WEBSOCKET_HOST_INVALID" + WEBSOCKET_PORT_UNAVAILABLE = "WEBSOCKET_PORT_UNAVAILABLE" + WEBSOCKET_SERVER_UNAVAILABLE = "WEBSOCKET_SERVER_UNAVAILABLE" + + # ============================================================================ + # VRC連携エラー (VRC_*) + # ============================================================================ + VRC_MIC_MUTE_SYNC_OSC_DISABLED = "VRC_MIC_MUTE_SYNC_OSC_DISABLED" + + # ============================================================================ + # 汎用エラー (GENERAL_*) + # ============================================================================ + GENERAL_EXCEPTION = "GENERAL_EXCEPTION" + GENERAL_UNKNOWN = "GENERAL_UNKNOWN" + + +class ErrorCategory(str, Enum): + """エラーカテゴリ""" + DEVICE = "device" + TRANSLATION = "translation" + TRANSCRIPTION = "transcription" + WEIGHT = "weight" + VALIDATION = "validation" + AUTH = "auth" + MODEL = "model" + CONNECTION = "connection" + WEBSOCKET = "websocket" + VRC = "vrc" + GENERAL = "general" + + +# エラーコードのメタデータ定義 +ERROR_METADATA: Dict[ErrorCode, Dict[str, Any]] = { + # デバイスエラー + ErrorCode.DEVICE_NO_MIC: { + "category": ErrorCategory.DEVICE, + "message": "No mic device detected", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.DEVICE_NO_SPEAKER: { + "category": ErrorCategory.DEVICE, + "message": "No speaker device detected", + "severity": "error", + "user_action_required": True, + }, + + # 翻訳エラー + ErrorCode.TRANSLATION_ENGINE_LIMIT: { + "category": ErrorCategory.TRANSLATION, + "message": "Translation engine limit error", + "severity": "warning", + "user_action_required": False, + "auto_fallback": True, + }, + ErrorCode.TRANSLATION_VRAM_CHAT: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of chat", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_MIC: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of mic", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_SPEAKER: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory during translation of speaker", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_VRAM_ENABLE: { + "category": ErrorCategory.TRANSLATION, + "message": "VRAM out of memory enabling translation", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSLATION_DISABLED_VRAM: { + "category": ErrorCategory.TRANSLATION, + "message": "Translation disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + + # 音声認識エラー + ErrorCode.TRANSCRIPTION_VRAM_MIC: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "VRAM out of memory during mic transcription", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_VRAM_SPEAKER: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "VRAM out of memory during speaker transcription", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_SEND_DISABLED_VRAM: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "Transcription send disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + ErrorCode.TRANSCRIPTION_RECEIVE_DISABLED_VRAM: { + "category": ErrorCategory.TRANSCRIPTION, + "message": "Transcription receive disabled due to VRAM overflow", + "severity": "critical", + "user_action_required": True, + }, + + # ウェイトダウンロードエラー + ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD: { + "category": ErrorCategory.WEIGHT, + "message": "CTranslate2 weight download error", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEIGHT_WHISPER_DOWNLOAD: { + "category": ErrorCategory.WEIGHT, + "message": "Whisper weight download error", + "severity": "error", + "user_action_required": True, + }, + + # バリデーションエラー + ErrorCode.VALIDATION_MIC_THRESHOLD: { + "category": ErrorCategory.VALIDATION, + "message": "Mic energy threshold value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_THRESHOLD: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker energy threshold value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Mic record timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Mic phrase timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_MIC_MAX_PHRASES: { + "category": ErrorCategory.VALIDATION, + "message": "Mic max phrases value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker record timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker phrase timeout value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES: { + "category": ErrorCategory.VALIDATION, + "message": "Speaker max phrases value is out of range", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_INVALID_IP: { + "category": ErrorCategory.VALIDATION, + "message": "Invalid IP address", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.VALIDATION_CANNOT_SET_IP: { + "category": ErrorCategory.VALIDATION, + "message": "Cannot set IP address", + "severity": "error", + "user_action_required": True, + }, + + # 認証エラー + ErrorCode.AUTH_DEEPL_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "DeepL auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_DEEPL_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of deepL auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_PLAMO_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "Plamo auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_PLAMO_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of plamo auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_GEMINI_LENGTH: { + "category": ErrorCategory.AUTH, + "message": "Gemini auth key length is not correct", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_GEMINI_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of gemini auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENAI_INVALID: { + "category": ErrorCategory.AUTH, + "message": "OpenAI auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENAI_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of OpenAI auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_GROQ_INVALID: { + "category": ErrorCategory.AUTH, + "message": "Groq auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_GROQ_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of Groq auth key", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENROUTER_INVALID: { + "category": ErrorCategory.AUTH, + "message": "OpenRouter auth key is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.AUTH_OPENROUTER_FAILED: { + "category": ErrorCategory.AUTH, + "message": "Authentication failure of OpenRouter auth key", + "severity": "error", + "user_action_required": True, + }, + + # モデル選択エラー + ErrorCode.MODEL_PLAMO_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Plamo model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_GEMINI_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Gemini model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OPENAI_INVALID: { + "category": ErrorCategory.MODEL, + "message": "OpenAI model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_GROQ_INVALID: { + "category": ErrorCategory.MODEL, + "message": "Groq model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OPENROUTER_INVALID: { + "category": ErrorCategory.MODEL, + "message": "OpenRouter model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_LMSTUDIO_INVALID: { + "category": ErrorCategory.MODEL, + "message": "LMStudio model is not valid", + "severity": "warning", + "user_action_required": True, + }, + ErrorCode.MODEL_OLLAMA_INVALID: { + "category": ErrorCategory.MODEL, + "message": "ollama model is not valid", + "severity": "warning", + "user_action_required": True, + }, + + # 接続エラー + ErrorCode.CONNECTION_LMSTUDIO_FAILED: { + "category": ErrorCategory.CONNECTION, + "message": "Cannot connect to LMStudio server", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.CONNECTION_OLLAMA_FAILED: { + "category": ErrorCategory.CONNECTION, + "message": "Cannot connect to ollama server", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID: { + "category": ErrorCategory.CONNECTION, + "message": "LMStudio URL is not valid", + "severity": "warning", + "user_action_required": True, + }, + + # WebSocketエラー + ErrorCode.WEBSOCKET_HOST_INVALID: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server host is not available", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEBSOCKET_PORT_UNAVAILABLE: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server port is not available", + "severity": "error", + "user_action_required": True, + }, + ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE: { + "category": ErrorCategory.WEBSOCKET, + "message": "WebSocket server host or port is not available", + "severity": "error", + "user_action_required": True, + }, + + # VRC連携エラー + ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED: { + "category": ErrorCategory.VRC, + "message": "Cannot enable VRC mic mute sync while OSC query is disabled", + "severity": "warning", + "user_action_required": True, + }, + + # 汎用エラー + ErrorCode.GENERAL_EXCEPTION: { + "category": ErrorCategory.GENERAL, + "message": "An error occurred", + "severity": "error", + "user_action_required": False, + }, + ErrorCode.GENERAL_UNKNOWN: { + "category": ErrorCategory.GENERAL, + "message": "Unknown error", + "severity": "error", + "user_action_required": False, + }, +} + + +class VRCTError: + """VRCTエラーハンドリングクラス""" + + @staticmethod + def create_error_response( + error_code: ErrorCode, + data: Any = None, + details: Optional[Dict[str, Any]] = None, + custom_message: Optional[str] = None + ) -> Dict[str, Any]: + """統一されたエラーレスポンスを生成 + + Args: + error_code: エラーコード + data: エラー時に戻す値(通常は元の値) + details: 追加の詳細情報 + custom_message: カスタムメッセージ(指定しない場合はデフォルトメッセージ) + + Returns: + エラーレスポンス辞書 + """ + metadata = ERROR_METADATA.get(error_code, ERROR_METADATA[ErrorCode.GENERAL_UNKNOWN]) + + return { + "status": 400, + "result": { + "error_code": error_code.value, + "message": custom_message or metadata["message"], + "data": data, + "details": details or {}, + "category": metadata["category"].value, + "severity": metadata["severity"], + } + } + + @staticmethod + def create_exception_error_response( + exception: Exception, + data: Any = None, + error_code: ErrorCode = ErrorCode.GENERAL_EXCEPTION + ) -> Dict[str, Any]: + """例外からエラーレスポンスを生成 + + Args: + exception: 発生した例外 + data: エラー時に戻す値 + error_code: エラーコード + + Returns: + エラーレスポンス辞書 + """ + return VRCTError.create_error_response( + error_code=error_code, + data=data, + custom_message=f"Error: {str(exception)}", + details={"exception_type": type(exception).__name__} + ) + + +# エンドポイントとエラーコードのマッピング +# UIがエラーハンドリングする際の参照として使用 +ENDPOINT_ERROR_MAPPING: Dict[str, Dict[str, ErrorCode]] = { + # run_mapping経由のエラー通知 + "/run/error_device": { + "NO_MIC": ErrorCode.DEVICE_NO_MIC, + "NO_SPEAKER": ErrorCode.DEVICE_NO_SPEAKER, + }, + "/run/error_translation_engine": { + "LIMIT": ErrorCode.TRANSLATION_ENGINE_LIMIT, + }, + "/run/error_translation_chat_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_CHAT, + }, + "/run/error_translation_mic_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_MIC, + }, + "/run/error_translation_speaker_vram_overflow": { + "VRAM": ErrorCode.TRANSLATION_VRAM_SPEAKER, + }, + "/run/error_transcription_mic_vram_overflow": { + "VRAM": ErrorCode.TRANSCRIPTION_VRAM_MIC, + }, + "/run/error_transcription_speaker_vram_overflow": { + "VRAM": ErrorCode.TRANSCRIPTION_VRAM_SPEAKER, + }, + "/run/error_ctranslate2_weight": { + "DOWNLOAD": ErrorCode.WEIGHT_CTRANSLATE2_DOWNLOAD, + }, + "/run/error_whisper_weight": { + "DOWNLOAD": ErrorCode.WEIGHT_WHISPER_DOWNLOAD, + }, + + # エンドポイント直接のエラーレスポンス + "/set/data/mic_threshold": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_THRESHOLD, + }, + "/set/data/speaker_threshold": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_THRESHOLD, + }, + "/set/data/mic_record_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_RECORD_TIMEOUT, + }, + "/set/data/mic_phrase_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_PHRASE_TIMEOUT, + }, + "/set/data/mic_max_phrases": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_MIC_MAX_PHRASES, + }, + "/set/data/speaker_record_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_RECORD_TIMEOUT, + }, + "/set/data/speaker_phrase_timeout": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_PHRASE_TIMEOUT, + }, + "/set/data/speaker_max_phrases": { + "OUT_OF_RANGE": ErrorCode.VALIDATION_SPEAKER_MAX_PHRASES, + }, + "/set/data/osc_ip_address": { + "INVALID": ErrorCode.VALIDATION_INVALID_IP, + "CANNOT_SET": ErrorCode.VALIDATION_CANNOT_SET_IP, + }, + "/set/data/deepl_auth_key": { + "LENGTH": ErrorCode.AUTH_DEEPL_LENGTH, + "FAILED": ErrorCode.AUTH_DEEPL_FAILED, + }, + "/set/data/plamo_auth_key": { + "LENGTH": ErrorCode.AUTH_PLAMO_LENGTH, + "FAILED": ErrorCode.AUTH_PLAMO_FAILED, + }, + "/set/data/selected_plamo_model": { + "INVALID": ErrorCode.MODEL_PLAMO_INVALID, + }, + "/set/data/gemini_auth_key": { + "LENGTH": ErrorCode.AUTH_GEMINI_LENGTH, + "FAILED": ErrorCode.AUTH_GEMINI_FAILED, + }, + "/set/data/selected_gemini_model": { + "INVALID": ErrorCode.MODEL_GEMINI_INVALID, + }, + "/set/data/openai_auth_key": { + "INVALID": ErrorCode.AUTH_OPENAI_INVALID, + "FAILED": ErrorCode.AUTH_OPENAI_FAILED, + }, + "/set/data/selected_openai_model": { + "INVALID": ErrorCode.MODEL_OPENAI_INVALID, + }, + "/set/data/groq_auth_key": { + "INVALID": ErrorCode.AUTH_GROQ_INVALID, + "FAILED": ErrorCode.AUTH_GROQ_FAILED, + }, + "/set/data/selected_groq_model": { + "INVALID": ErrorCode.MODEL_GROQ_INVALID, + }, + "/set/data/openrouter_auth_key": { + "INVALID": ErrorCode.AUTH_OPENROUTER_INVALID, + "FAILED": ErrorCode.AUTH_OPENROUTER_FAILED, + }, + "/set/data/selected_openrouter_model": { + "INVALID": ErrorCode.MODEL_OPENROUTER_INVALID, + }, + "/run/lmstudio_connection": { + "FAILED": ErrorCode.CONNECTION_LMSTUDIO_FAILED, + }, + "/set/data/lmstudio_url": { + "INVALID": ErrorCode.CONNECTION_LMSTUDIO_URL_INVALID, + }, + "/set/data/selected_lmstudio_model": { + "INVALID": ErrorCode.MODEL_LMSTUDIO_INVALID, + }, + "/run/ollama_connection": { + "FAILED": ErrorCode.CONNECTION_OLLAMA_FAILED, + }, + "/set/data/selected_ollama_model": { + "INVALID": ErrorCode.MODEL_OLLAMA_INVALID, + }, + "/set/data/websocket_host": { + "INVALID_IP": ErrorCode.VALIDATION_INVALID_IP, + "UNAVAILABLE": ErrorCode.WEBSOCKET_HOST_INVALID, + }, + "/set/data/websocket_port": { + "UNAVAILABLE": ErrorCode.WEBSOCKET_PORT_UNAVAILABLE, + }, + "/set/enable/websocket_server": { + "UNAVAILABLE": ErrorCode.WEBSOCKET_SERVER_UNAVAILABLE, + }, + "/set/enable/vrc_mic_mute_sync": { + "OSC_DISABLED": ErrorCode.VRC_MIC_MUTE_SYNC_OSC_DISABLED, + }, +} + + +def get_error_metadata(error_code: ErrorCode) -> Dict[str, Any]: + """エラーコードのメタデータを取得 + + Args: + error_code: エラーコード + + Returns: + メタデータ辞書 + """ + return ERROR_METADATA.get(error_code, ERROR_METADATA[ErrorCode.GENERAL_UNKNOWN]) + + +def is_critical_error(error_code: ErrorCode) -> bool: + """クリティカルエラーかどうかを判定 + + Args: + error_code: エラーコード + + Returns: + クリティカルエラーの場合True + """ + metadata = get_error_metadata(error_code) + return metadata.get("severity") == "critical" + + +def requires_user_action(error_code: ErrorCode) -> bool: + """ユーザーアクションが必要なエラーかどうかを判定 + + Args: + error_code: エラーコード + + Returns: + ユーザーアクションが必要な場合True + """ + metadata = get_error_metadata(error_code) + return metadata.get("user_action_required", False)