diff --git a/src-python/backend_test.py b/src-python/backend_test.py index d56b1622..8fea1092 100644 --- a/src-python/backend_test.py +++ b/src-python/backend_test.py @@ -35,12 +35,13 @@ class Color: class TestMainloop(): def __init__(self): self.main = main_instance - self.main.startReceiver() - self.main.startHandler() + # Start mainloop threads + self.main.start() - def stop_main(): - pass - self.main.controller.setWatchdogCallback(stop_main) + # Ensure the watchdog can stop the mainloop cleanly + def _none_watchdog(): + return None + self.main.controller.setWatchdogCallback(_none_watchdog) self.main.controller.init() # mappingのすべてのstatusをTrueにする @@ -148,15 +149,16 @@ class TestMainloop(): def test_endpoints_on_off_continuous(self): print("----ON/OFF連続テスト----") - endpoints = ["/set/enable/websocket_server", "/set/disable/websocket_server"] - # endpoints = [ - # "/set/enable/translation", - # "/set/disable/translation", - # "/set/enable/transcription_send", - # "/set/disable/transcription_send", - # "/set/enable/transcription_receive", - # "/set/disable/transcription_receive", - # ] + endpoints = [ + "/set/enable/translation", + "/set/disable/translation", + "/set/enable/transcription_send", + "/set/disable/transcription_send", + "/set/enable/transcription_receive", + "/set/disable/transcription_receive", + # "/set/enable/websocket_server", + # "/set/disable/websocket_server", + ] for i in range(1000): endpoint = random.choice(endpoints) print(f"No.{i:04} Testing endpoint: {endpoint}", flush=True) @@ -739,14 +741,27 @@ if __name__ == "__main__": # test.test_set_data_endpoints_all() # test.test_run_endpoints_all() # test.test_delete_data_endpoints_all() - test.test_endpoints_all_random() - # test.test_endpoints_on_off_continuous() + # test.test_endpoints_all_random() + test.test_endpoints_on_off_continuous() # test.test_endpoints_on_off_random() # test.test_endpoints_specific_random() # test.test_translate_all_language_pairs() test.generate_summary() except KeyboardInterrupt: print("Interrupted by user, shutting down...") + try: + main_instance.stop() + except Exception: + pass except Exception as e: traceback.print_exc() - print(f"An error occurred: {e}") \ No newline at end of file + print(f"An error occurred: {e}") + try: + main_instance.stop() + except Exception: + pass + finally: + try: + main_instance.stop() + except Exception: + pass \ No newline at end of file diff --git a/src-python/config.py b/src-python/config.py index 8ae9a925..03d87338 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -5,12 +5,36 @@ from os import path as os_path, makedirs as os_makedirs from json import load as json_load from json import dump as json_dump import threading +from typing import Optional, Dict, Any import torch -from device_manager import device_manager -from models.translation.translation_languages import translation_lang -from models.translation.translation_utils import ctranslate2_weights -from models.transcription.transcription_languages import transcription_lang -from models.transcription.transcription_whisper import _MODELS as whisper_models + +# Guard optional, potentially heavy or platform-specific imports so importing +# config.py doesn't raise in environments missing those packages. +try: + from device_manager import device_manager +except Exception: # pragma: no cover - optional runtime + device_manager = None # type: ignore + +try: + from models.translation.translation_languages import translation_lang +except Exception: # pragma: no cover - optional runtime + translation_lang = {} # type: ignore + +try: + from models.translation.translation_utils import ctranslate2_weights +except Exception: # pragma: no cover - optional runtime + ctranslate2_weights = {} # type: ignore + +try: + from models.transcription.transcription_languages import transcription_lang +except Exception: # pragma: no cover - optional runtime + transcription_lang = {} # type: ignore + +try: + from models.transcription.transcription_whisper import _MODELS as whisper_models +except Exception: # pragma: no cover - optional runtime + whisper_models = {} # type: ignore + from utils import errorLogging, validateDictStructure, getComputeDeviceList json_serializable_vars = {} @@ -21,23 +45,39 @@ def json_serializable(var_name): return decorator class Config: + """Application configuration singleton. + + Responsibilities: + - expose read-only and read-write configuration via properties + - persist selected values to JSON with debounce + Implementation notes: initialization may depend on optional subsystems; any + exceptions during init/load are captured and logged to avoid import-time + crashes. + """ + _instance = None - _config_data = {} - _timer = None - _debounce_time = 2 + _config_data: Dict[str, Any] = {} + _timer: Optional[threading.Timer] = None + _debounce_time: int = 2 def __new__(cls): if cls._instance is None: cls._instance = super(Config, cls).__new__(cls) - cls._instance.init_config() - cls._instance.load_config() + try: + cls._instance.init_config() + except Exception: + errorLogging() + try: + cls._instance.load_config() + except Exception: + errorLogging() return cls._instance - def saveConfigToFile(self): + def saveConfigToFile(self) -> None: with open(self.PATH_CONFIG, "w", encoding="utf-8") as fp: json_dump(self._config_data, fp, indent=4, ensure_ascii=False) - def saveConfig(self, key, value, immediate_save=False): + def saveConfig(self, key: str, value: Any, immediate_save: bool = False) -> None: self._config_data[key] = value if isinstance(self._timer, threading.Timer) and self._timer.is_alive(): @@ -1068,10 +1108,16 @@ class Config: self._WATCHDOG_INTERVAL = 20 self._SELECTABLE_TAB_NO_LIST = ["1", "2", "3"] - self._SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_LIST = ctranslate2_weights.keys() - self._SELECTABLE_WHISPER_WEIGHT_TYPE_LIST = whisper_models.keys() - self._SELECTABLE_TRANSLATION_ENGINE_LIST = translation_lang.keys() - self._SELECTABLE_TRANSCRIPTION_ENGINE_LIST = list(transcription_lang[list(transcription_lang.keys())[0]].values())[0].keys() + # these external mappings may be empty dicts if the optional modules failed to import + self._SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_LIST = getattr(ctranslate2_weights, 'keys', lambda: [])() + self._SELECTABLE_WHISPER_WEIGHT_TYPE_LIST = getattr(whisper_models, 'keys', lambda: [])() + self._SELECTABLE_TRANSLATION_ENGINE_LIST = getattr(translation_lang, 'keys', lambda: [])() + try: + # transcription_lang is nested dict; attempt to extract keys defensively + first_key = next(iter(transcription_lang)) + self._SELECTABLE_TRANSCRIPTION_ENGINE_LIST = list(transcription_lang[first_key].values())[0].keys() + except Exception: + self._SELECTABLE_TRANSCRIPTION_ENGINE_LIST = [] self._SELECTABLE_UI_LANGUAGE_LIST = ["en", "ja", "ko", "zh-Hant", "zh-Hans"] self._COMPUTE_MODE = "cuda" if torch.cuda.is_available() else "cpu" self._SELECTABLE_COMPUTE_DEVICE_LIST = getComputeDeviceList() @@ -1171,8 +1217,20 @@ class Config: "height": 654, } self._AUTO_MIC_SELECT = True - self._SELECTED_MIC_HOST = device_manager.getDefaultMicDevice()["host"]["name"] - self._SELECTED_MIC_DEVICE = device_manager.getDefaultMicDevice()["device"]["name"] + # device_manager may be unavailable or not initialized; use safe defaults + try: + if device_manager is not None: + # getDefaultMicDevice performs lazy init/update if needed + dm_def = device_manager.getDefaultMicDevice() + self._SELECTED_MIC_HOST = dm_def.get("host", {}).get("name", "NoHost") + self._SELECTED_MIC_DEVICE = dm_def.get("device", {}).get("name", "NoDevice") + else: + self._SELECTED_MIC_HOST = "NoHost" + self._SELECTED_MIC_DEVICE = "NoDevice" + except Exception: + errorLogging() + self._SELECTED_MIC_HOST = "NoHost" + self._SELECTED_MIC_DEVICE = "NoDevice" self._MIC_THRESHOLD = 300 self._MIC_AUTOMATIC_THRESHOLD = False self._MIC_RECORD_TIMEOUT = 3 @@ -1189,7 +1247,15 @@ class Config: self._MIC_AVG_LOGPROB = -0.8 self._MIC_NO_SPEECH_PROB = 0.6 self._AUTO_SPEAKER_SELECT = True - self._SELECTED_SPEAKER_DEVICE = device_manager.getDefaultSpeakerDevice()["device"]["name"] + try: + if device_manager is not None: + sp_def = device_manager.getDefaultSpeakerDevice() + self._SELECTED_SPEAKER_DEVICE = sp_def.get("device", {}).get("name", "NoDevice") + else: + self._SELECTED_SPEAKER_DEVICE = "NoDevice" + except Exception: + errorLogging() + self._SELECTED_SPEAKER_DEVICE = "NoDevice" self._SPEAKER_THRESHOLD = 300 self._SPEAKER_AUTOMATIC_THRESHOLD = False self._SPEAKER_RECORD_TIMEOUT = 3 diff --git a/src-python/controller.py b/src-python/controller.py index d5322134..20afc048 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -1,5 +1,4 @@ -import copy -from typing import Callable, Any +from typing import Callable, Any, List, Optional from time import sleep from subprocess import Popen from threading import Thread @@ -11,10 +10,33 @@ from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValid class Controller: def __init__(self) -> None: - self.init_mapping = {} - self.run_mapping = {} - self.run = None - self.device_access_status = True + # typed attributes to satisfy static type checkers + self.init_mapping: dict = {} + self.run_mapping: dict = {} + # initialize with a no-op callable so callers can safely call self.run + def _noop_run(status: int, endpoint: str, payload: Any = None) -> None: + return None + self.run: Callable[[int, str, Any], None] = _noop_run + self.device_access_status: bool = True + # Ensure model is initialized at controller startup so existing + # attribute-based checks (e.g. model.overlay.initialized) continue to work. + try: + model.init() + except Exception: + # In test or headless environments initialization may fail; log and continue. + errorLogging() + + def _is_overlay_available(self) -> bool: + """Safe check whether overlay is present and initialized. + + This avoids AttributeError when `model` was not fully initialized. + """ + try: + overlay = getattr(model, "overlay", None) + return overlay is not None and getattr(overlay, "initialized", False) + except Exception: + errorLogging() + return False def setInitMapping(self, init_mapping:dict) -> None: self.init_mapping = init_mapping @@ -251,7 +273,7 @@ class Controller: elif isinstance(message, str) and len(message) > 0: translation = [] - transliteration_message = [] + transliteration_message: List[Any] = [] transliteration_translation = [] if model.checkKeywords(message): self.run( @@ -356,7 +378,7 @@ class Controller: ] }) - if config.OVERLAY_LARGE_LOG is True and model.overlay.initialized is True: + if config.OVERLAY_LARGE_LOG is True and self._is_overlay_available(): if config.OVERLAY_SHOW_ONLY_TRANSLATED_MESSAGES is True: if len(translation) > 0: overlay_image = model.createOverlayImageLargeLog( @@ -407,7 +429,7 @@ class Controller: ) elif isinstance(message, str) and len(message) > 0: translation = [] - transliteration_message = [] + transliteration_message: List[Any] = [] transliteration_translation = [] if model.checkKeywords(message): self.run( @@ -484,7 +506,7 @@ class Controller: transliteration_translation = [[]] if config.ENABLE_TRANSCRIPTION_RECEIVE is True: - if config.OVERLAY_SMALL_LOG is True and model.overlay.initialized is True: + if config.OVERLAY_SMALL_LOG is True and self._is_overlay_available(): if config.OVERLAY_SHOW_ONLY_TRANSLATED_MESSAGES is True: if len(translation) > 0: overlay_image = model.createOverlayImageSmallLog( @@ -503,7 +525,7 @@ class Controller: ) model.updateOverlaySmallLog(overlay_image) - if config.OVERLAY_LARGE_LOG is True and model.overlay.initialized is True: + if config.OVERLAY_LARGE_LOG is True and self._is_overlay_available(): if config.OVERLAY_SHOW_ONLY_TRANSLATED_MESSAGES is True: if len(translation) > 0: overlay_image = model.createOverlayImageLargeLog( @@ -566,12 +588,12 @@ class Controller: translation_text = f" ({'/'.join(translation)})" if translation else "" model.logger.info(f"[RECEIVED] {message}{translation_text}") - def chatMessage(self, data) -> None: + def chatMessage(self, data) -> dict: id = data["id"] message = data["message"] if len(message) > 0: translation = [] - transliteration_message = [] + transliteration_message: List[Any] = [] transliteration_translation = [] if config.ENABLE_TRANSLATION is False: pass @@ -739,6 +761,7 @@ class Controller: self.run_mapping["software_update_info"], software_update_info, ) + return {"status":200, "result": software_update_info} @staticmethod def getComputeMode(*args, **kwargs) -> dict: @@ -800,11 +823,15 @@ class Controller: if is_vram_error: # Defaultのデバイス設定に戻す printLog("VRAM error detected, reverting device setting") + self.run( + 400, + self.run_mapping["error_translation_enable_vram_overflow"], + { + "message":"VRAM out of memory enabling translation", + "data": error_message + }, + ) self.setDisableTranslation() - config.SELECTED_TRANSLATION_COMPUTE_DEVICE = copy.deepcopy(config.SELECTABLE_COMPUTE_DEVICE_LIST[0]) - config.SELECTED_TRANSLATION_COMPUTE_TYPE = "auto" - self.run(200, self.run_mapping["selected_translation_compute_device"], config.SELECTED_TRANSLATION_COMPUTE_DEVICE) - self.run(200, self.run_mapping["selected_translation_compute_type"], config.SELECTED_TRANSLATION_COMPUTE_TYPE) self.run( 400, self.run_mapping["enable_translation"], @@ -1078,6 +1105,7 @@ class Controller: device_manager.setCallbackDefaultMicDevice(self.updateSelectedMicDevice) device_manager.setCallbackProcessAfterUpdateMicDevices(self.restartAccessMicDevices) device_manager.forceUpdateAndSetMicDevices() + device_manager.startMonitoring() def setEnableAutoMicSelect(self, *args, **kwargs) -> dict: if config.AUTO_MIC_SELECT is False: @@ -1087,6 +1115,9 @@ class Controller: @staticmethod def setDisableAutoMicSelect(*args, **kwargs) -> dict: + if config.AUTO_SPEAKER_SELECT is False: + device_manager.stopMonitoring() + if config.AUTO_MIC_SELECT is True: device_manager.clearCallbackProcessBeforeUpdateMicDevices() device_manager.clearCallbackDefaultMicDevice() @@ -1274,6 +1305,7 @@ class Controller: device_manager.setCallbackDefaultSpeakerDevice(self.updateSelectedSpeakerDevice) device_manager.setCallbackProcessAfterUpdateSpeakerDevices(self.restartAccessSpeakerDevices) device_manager.forceUpdateAndSetSpeakerDevices() + device_manager.startMonitoring() def setEnableAutoSpeakerSelect(self, *args, **kwargs) -> dict: if config.AUTO_SPEAKER_SELECT is False: @@ -1283,6 +1315,9 @@ class Controller: @staticmethod def setDisableAutoSpeakerSelect(*args, **kwargs) -> dict: + if config.AUTO_MIC_SELECT is False: + device_manager.stopMonitoring() + if config.AUTO_SPEAKER_SELECT is True: device_manager.clearCallbackProcessBeforeUpdateSpeakerDevices() device_manager.clearCallbackDefaultSpeakerDevice() @@ -2234,13 +2269,13 @@ class Controller: th_stopCheckSpeakerEnergy.join() @staticmethod - def startThreadingDownloadCtranslate2Weight(weight_type:str, callback:Callable[[float], None], end_callback:Callable[[float], None]) -> None: + def startThreadingDownloadCtranslate2Weight(weight_type:str, callback:Callable[[float], None], end_callback:Optional[Callable[..., None]] = None) -> None: th_download = Thread(target=model.downloadCTranslate2ModelWeight, args=(weight_type, callback, end_callback)) th_download.daemon = True th_download.start() @staticmethod - def startThreadingDownloadWhisperWeight(weight_type:str, callback:Callable[[float], None], end_callback:Callable[[float], None]) -> None: + def startThreadingDownloadWhisperWeight(weight_type:str, callback:Callable[[float], None], end_callback:Optional[Callable[..., None]] = None) -> None: th_download = Thread(target=model.downloadWhisperModelWeight, args=(weight_type, callback, end_callback)) th_download.daemon = True th_download.start() @@ -2258,6 +2293,7 @@ class Controller: @staticmethod def setWatchdogCallback(callback) -> dict: model.setWatchdogCallback(callback) + return {"status":200, "result":True} @staticmethod def stopWatchdog(*args, **kwargs) -> dict: diff --git a/src-python/device_manager.py b/src-python/device_manager.py index 7f741d26..6c4096a7 100644 --- a/src-python/device_manager.py +++ b/src-python/device_manager.py @@ -1,27 +1,53 @@ -from typing import Callable +from typing import Callable, Dict, List, Optional, Any from time import sleep from threading import Thread -import comtypes -from pyaudiowpatch import PyAudio, paWASAPI -from pycaw.callbacks import MMNotificationClient -from pycaw.utils import AudioUtilities + +# Optional, Windows-specific dependencies. Guard imports so module can be imported on non-Windows systems. +try: + import comtypes +except Exception: # pragma: no cover - optional runtime + comtypes = None # type: ignore + +try: + from pyaudiowpatch import PyAudio, paWASAPI +except Exception: # pragma: no cover - optional runtime + PyAudio = None # type: ignore + paWASAPI = None # type: ignore + +try: + from pycaw.callbacks import MMNotificationClient + from pycaw.utils import AudioUtilities +except Exception: # pragma: no cover - optional runtime + MMNotificationClient = object # type: ignore + AudioUtilities = None # type: ignore + from utils import errorLogging class Client(MMNotificationClient): - def __init__(self): - super().__init__() - self.loop = True + """Callback client used by pycaw to detect device changes. - def on_default_device_changed(self, flow, flow_id, role, role_id, default_device_id): + This subclass is lightweight: it flips a flag when events arrive so the + monitoring loop can break and refresh device lists. + """ + + def __init__(self) -> None: + # If MMNotificationClient is the placeholder object (non-windows), avoid calling super + try: + super().__init__() + except Exception: + pass + self.loop: bool = True + + def on_default_device_changed(self, *args: Any, **kwargs: Any) -> None: self.loop = False - def on_device_added(self, added_device_id): + def on_device_added(self, *args: Any, **kwargs: Any) -> None: self.loop = False - def on_device_removed(self, removed_device_id): + def on_device_removed(self, *args: Any, **kwargs: Any) -> None: self.loop = False - def on_device_state_changed(self, device_id, state): + def on_device_state_changed(self, *args: Any, **kwargs: Any) -> None: self.loop = False # def on_property_value_changed(self, device_id, key): @@ -33,108 +59,179 @@ class DeviceManager: def __new__(cls): if cls._instance is None: cls._instance = super(DeviceManager, cls).__new__(cls) - cls._instance.init() + # do NOT auto-init monitoring-heavy resources on import; require explicit init + # Still perform a light-weight init so that callers observing the singleton + # do not see uninitialized internal structures (which caused NoDevice to + # be seen when import order differed). + cls._instance._initialized = False + try: + # Call init() to populate internal containers. This will NOT start + # the monitoring thread (startMonitoring must be called explicitly). + cls._instance.init() + except Exception: + # Avoid import-time crashes; log and continue. + try: + errorLogging() + except Exception: + pass return cls._instance - def init(self): - self.mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]} - self.default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} - self.speaker_devices = [{"index": -1, "name": "NoDevice"}] - self.default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}} + def init(self) -> None: + """Initialize internal state. This is intentionally separate from object + creation so importing the module won't start threads or access OS + audio APIs. Call `device_manager.init()` and then + `device_manager.startMonitoring()` explicitly when ready. + """ + if getattr(self, "_initialized", False): + return - self.update() + self.mic_devices: Dict[str, List[Dict[str, Any]]] = {"NoHost": [{"index": -1, "name": "NoDevice"}]} + self.default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} + self.speaker_devices: List[Dict[str, Any]] = [{"index": -1, "name": "NoDevice"}] + self.default_speaker_device: Dict[str, Any] = {"device": {"index": -1, "name": "NoDevice"}} - self.prev_mic_host = [host for host in self.mic_devices] - self.prev_mic_devices = self.mic_devices - self.prev_default_mic_device = self.default_mic_device - self.prev_speaker_devices = self.speaker_devices - self.prev_default_speaker_device = self.default_speaker_device + # Initialize previous state trackers + self.prev_mic_host: List[str] = [host for host in self.mic_devices] + self.prev_mic_devices: Dict[str, List[Dict[str, Any]]] = self.mic_devices + self.prev_default_mic_device: Dict[str, Any] = self.default_mic_device + self.prev_speaker_devices: List[Dict[str, Any]] = self.speaker_devices + self.prev_default_speaker_device: Dict[str, Any] = self.default_speaker_device - self.update_flag_default_mic_device = False - self.update_flag_default_speaker_device = False - self.update_flag_host_list = False - self.update_flag_mic_device_list = False - self.update_flag_speaker_device_list = False + # Update flags + self.update_flag_default_mic_device: bool = False + self.update_flag_default_speaker_device: bool = False + self.update_flag_host_list: bool = False + self.update_flag_mic_device_list: bool = False + self.update_flag_speaker_device_list: bool = False - self.callback_default_mic_device = None - self.callback_default_speaker_device = None - self.callback_host_list = None - self.callback_mic_device_list = None - self.callback_speaker_device_list = None - self.callback_process_before_update_devices = None - self.callback_process_after_update_devices = None + # Callbacks + self.callback_default_mic_device: Optional[Callable[..., None]] = None + self.callback_default_speaker_device: Optional[Callable[..., None]] = None + self.callback_host_list: Optional[Callable[..., None]] = None + self.callback_mic_device_list: Optional[Callable[..., None]] = None + self.callback_speaker_device_list: Optional[Callable[..., None]] = None + self.callback_process_before_update_mic_devices: Optional[Callable[..., None]] = None + self.callback_process_after_update_mic_devices: Optional[Callable[..., None]] = None + self.callback_process_before_update_speaker_devices: Optional[Callable[..., None]] = None + self.callback_process_after_update_speaker_devices: Optional[Callable[..., None]] = None - self.monitoring_flag = False - self.startMonitoring() + # Monitoring control + self.monitoring_flag: bool = False + self.th_monitoring: Optional[Thread] = None + + self._initialized = True + + # Best-effort single update: if PyAudio is available, attempt to populate + # real device lists. Keep this short and ignore errors to avoid import-time + # failures. + try: + if PyAudio is not None: + try: + # update() is robust and will fall back to defaults if audio libs + # are missing or fail; do not let exceptions bubble up. + self.update() + except Exception: + errorLogging() + except Exception: + # defensive: if errorLogging isn't available or other issues occur, + # swallow to avoid breaking initialization + pass def update(self): - buffer_mic_devices = {} - buffer_default_mic_device = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} - buffer_speaker_devices = [] - buffer_default_speaker_device = {"device": {"index": -1, "name": "NoDevice"}} + buffer_mic_devices: Dict[str, List[Dict[str, Any]]] = {} + buffer_default_mic_device: Dict[str, Any] = {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}} + buffer_speaker_devices: List[Dict[str, Any]] = [] + buffer_default_speaker_device: Dict[str, Any] = {"device": {"index": -1, "name": "NoDevice"}} - with PyAudio() as p: - for host_index in range(p.get_host_api_count()): - host = p.get_host_api_info_by_index(host_index) - device_count = host.get('deviceCount', 0) - for device_index in range(device_count): - device = p.get_device_info_by_host_api_device_index(host_index, device_index) - if device.get("maxInputChannels", 0) > 0 and not device.get("isLoopbackDevice", True): - buffer_mic_devices.setdefault(host["name"], []).append(device) - if not buffer_mic_devices: - buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]} + if PyAudio is None: + # PyAudio not available; leave defaults in place + self.mic_devices = buffer_mic_devices or {"NoHost": [{"index": -1, "name": "NoDevice"}]} + self.default_mic_device = buffer_default_mic_device + self.speaker_devices = buffer_speaker_devices or [{"index": -1, "name": "NoDevice"}] + self.default_speaker_device = buffer_default_speaker_device + return - api_info = p.get_default_host_api_info() - default_mic_device = api_info["defaultInputDevice"] - - for host_index in range(p.get_host_api_count()): - host = p.get_host_api_info_by_index(host_index) - device_count = host.get('deviceCount', 0) - for device_index in range(device_count): - device = p.get_device_info_by_host_api_device_index(host_index, device_index) - if device["index"] == default_mic_device: - buffer_default_mic_device = {"host": host, "device": device} - break - else: - continue - break - - speaker_devices = [] - wasapi_info = p.get_host_api_info_by_type(paWASAPI) - wasapi_name = wasapi_info["name"] - for host_index in range(p.get_host_api_count()): - host = p.get_host_api_info_by_index(host_index) - if host["name"] == wasapi_name: + try: + with PyAudio() as p: + # gather input devices grouped by host + for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) device_count = host.get('deviceCount', 0) for device_index in range(device_count): device = p.get_device_info_by_host_api_device_index(host_index, device_index) - if not device.get("isLoopbackDevice", True): - for loopback in p.get_loopback_device_info_generator(): - if device["name"] in loopback["name"]: - speaker_devices.append(loopback) - speaker_devices = [dict(t) for t in {tuple(d.items()) for d in speaker_devices}] or [{"index": -1, "name": "NoDevice"}] - buffer_speaker_devices = sorted(speaker_devices, key=lambda d: d['index']) + if device.get("maxInputChannels", 0) > 0 and not device.get("isLoopbackDevice", True): + buffer_mic_devices.setdefault(host["name"], []).append(device) + if not buffer_mic_devices: + buffer_mic_devices = {"NoHost": [{"index": -1, "name": "NoDevice"}]} - wasapi_info = p.get_host_api_info_by_type(paWASAPI) - default_speaker_device_index = wasapi_info["defaultOutputDevice"] + api_info = p.get_default_host_api_info() + default_mic_device = api_info.get("defaultInputDevice", -1) - for host_index in range(p.get_host_api_count()): - host_info = p.get_host_api_info_by_index(host_index) - device_count = host_info.get('deviceCount', 0) - for device_index in range(0, device_count): - device = p.get_device_info_by_host_api_device_index(host_index, device_index) - if device["index"] == default_speaker_device_index: - default_speakers = device - if not default_speakers.get("isLoopbackDevice", True): - for loopback in p.get_loopback_device_info_generator(): - if default_speakers["name"] in loopback["name"]: - buffer_default_speaker_device = {"device": loopback} - break - break - - if buffer_default_speaker_device["device"]["name"] != "NoDevice": + for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) + device_count = host.get('deviceCount', 0) + for device_index in range(device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if device.get("index") == default_mic_device: + buffer_default_mic_device = {"host": host, "device": device} + break + else: + continue break + # collect speaker loopback devices (requires WASAPI) + speaker_devices: List[Dict[str, Any]] = [] + if paWASAPI is not None: + try: + wasapi_info = p.get_host_api_info_by_type(paWASAPI) + wasapi_name = wasapi_info.get("name") + for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) + if host.get("name") == wasapi_name: + device_count = host.get('deviceCount', 0) + for device_index in range(device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if not device.get("isLoopbackDevice", True): + for loopback in p.get_loopback_device_info_generator(): + # match by name inclusion + if device.get("name") in loopback.get("name", ""): + speaker_devices.append(loopback) + except Exception: + # WASAPI not available or failed; ignore and continue + pass + + # deduplicate and sort + speaker_devices = [dict(t) for t in {tuple(d.items()) for d in speaker_devices}] or [{"index": -1, "name": "NoDevice"}] + buffer_speaker_devices = sorted(speaker_devices, key=lambda d: d.get('index', -1)) + + # default speaker + if paWASAPI is not None: + try: + wasapi_info = p.get_host_api_info_by_type(paWASAPI) + default_speaker_device_index = wasapi_info.get("defaultOutputDevice", -1) + for host_index in range(p.get_host_api_count()): + host_info = p.get_host_api_info_by_index(host_index) + device_count = host_info.get('deviceCount', 0) + for device_index in range(0, device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if device.get("index") == default_speaker_device_index: + default_speakers = device + if not default_speakers.get("isLoopbackDevice", True): + for loopback in p.get_loopback_device_info_generator(): + if default_speakers.get("name") in loopback.get("name", ""): + buffer_default_speaker_device = {"device": loopback} + break + break + + if buffer_default_speaker_device["device"].get("name") != "NoDevice": + break + except Exception: + # best-effort; ignore failures + pass + + except Exception: + errorLogging() + self.mic_devices = buffer_mic_devices self.default_mic_device = buffer_default_mic_device self.speaker_devices = buffer_speaker_devices @@ -170,14 +267,27 @@ class DeviceManager: try: while self.monitoring_flag is True: try: - comtypes.CoInitialize() - cb = Client() - enumerator = AudioUtilities.GetDeviceEnumerator() - enumerator.RegisterEndpointNotificationCallback(cb) - while cb.loop is True: - sleep(1) - enumerator.UnregisterEndpointNotificationCallback(cb) - comtypes.CoUninitialize() + # Use COM only when available (Windows). If comtypes is not present, + # fall back to periodic polling using PyAudio only. + if comtypes is not None and AudioUtilities is not None: + try: + comtypes.CoInitialize() + cb = Client() + enumerator = AudioUtilities.GetDeviceEnumerator() + enumerator.RegisterEndpointNotificationCallback(cb) + while cb.loop is True and self.monitoring_flag is True: + sleep(1) + try: + enumerator.UnregisterEndpointNotificationCallback(cb) + except Exception: + # best-effort unregister + pass + comtypes.CoUninitialize() + except Exception: + # if COM monitoring fails, log and fall through to polling + errorLogging() + + # polling and update cycle self.runProcessBeforeUpdateMicDevices() self.runProcessBeforeUpdateSpeakerDevices() sleep(2) @@ -191,12 +301,12 @@ class DeviceManager: self.runProcessAfterUpdateSpeakerDevices() except Exception: errorLogging() - finally: - pass except Exception: errorLogging() def startMonitoring(self): + if self.monitoring_flag: + return self.monitoring_flag = True self.th_monitoring = Thread(target=self.monitoring) self.th_monitoring.daemon = True @@ -204,7 +314,12 @@ class DeviceManager: def stopMonitoring(self): self.monitoring_flag = False - self.th_monitoring.join() + if getattr(self, "th_monitoring", None) is not None: + try: + self.th_monitoring.join(timeout=5) + except Exception: + # If join fails or thread is not joinable, ignore - it's a best-effort stop + pass def setCallbackDefaultMicDevice(self, callback): self.callback_default_mic_device = callback @@ -244,7 +359,10 @@ class DeviceManager: def runProcessBeforeUpdateMicDevices(self): if isinstance(self.callback_process_before_update_mic_devices, Callable): - self.callback_process_before_update_mic_devices() + try: + self.callback_process_before_update_mic_devices() + except Exception: + errorLogging() def setCallbackProcessAfterUpdateMicDevices(self, callback): self.callback_process_after_update_mic_devices = callback @@ -254,7 +372,10 @@ class DeviceManager: def runProcessAfterUpdateMicDevices(self): if isinstance(self.callback_process_after_update_mic_devices, Callable): - self.callback_process_after_update_mic_devices() + try: + self.callback_process_after_update_mic_devices() + except Exception: + errorLogging() def setCallbackProcessBeforeUpdateSpeakerDevices(self, callback): self.callback_process_before_update_speaker_devices = callback @@ -264,7 +385,10 @@ class DeviceManager: def runProcessBeforeUpdateSpeakerDevices(self): if isinstance(self.callback_process_before_update_speaker_devices, Callable): - self.callback_process_before_update_speaker_devices() + try: + self.callback_process_before_update_speaker_devices() + except Exception: + errorLogging() def setCallbackProcessAfterUpdateSpeakerDevices(self, callback): self.callback_process_after_update_speaker_devices = callback @@ -274,7 +398,10 @@ class DeviceManager: def runProcessAfterUpdateSpeakerDevices(self): if isinstance(self.callback_process_after_update_speaker_devices, Callable): - self.callback_process_after_update_speaker_devices() + try: + self.callback_process_after_update_speaker_devices() + except Exception: + errorLogging() def noticeUpdateDevices(self): if self.update_flag_default_mic_device is True: @@ -296,35 +423,86 @@ class DeviceManager: def setMicDefaultDevice(self): if isinstance(self.callback_default_mic_device, Callable): - self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"]) + try: + self.callback_default_mic_device(self.default_mic_device["host"]["name"], self.default_mic_device["device"]["name"]) + except Exception: + errorLogging() def setSpeakerDefaultDevice(self): if isinstance(self.callback_default_speaker_device, Callable): - self.callback_default_speaker_device(self.default_speaker_device["device"]["name"]) + try: + self.callback_default_speaker_device(self.default_speaker_device["device"]["name"]) + except Exception: + errorLogging() def setMicHostList(self): if isinstance(self.callback_host_list, Callable): - self.callback_host_list() + try: + self.callback_host_list() + except Exception: + errorLogging() def setMicDeviceList(self): if isinstance(self.callback_mic_device_list, Callable): - self.callback_mic_device_list() + try: + self.callback_mic_device_list() + except Exception: + errorLogging() def setSpeakerDeviceList(self): if isinstance(self.callback_speaker_device_list, Callable): - self.callback_speaker_device_list() + try: + self.callback_speaker_device_list() + except Exception: + errorLogging() def getMicDevices(self): - return self.mic_devices + # Ensure initialized and return devices (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'mic_devices', {"NoHost": [{"index": -1, "name": "NoDevice"}]}) def getDefaultMicDevice(self): - return self.default_mic_device + # Ensure initialized and return default mic device (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'default_mic_device', {"host": {"index": -1, "name": "NoHost"}, "device": {"index": -1, "name": "NoDevice"}}) def getSpeakerDevices(self): - return self.speaker_devices + # Ensure initialized and return speaker devices (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'speaker_devices', [{"index": -1, "name": "NoDevice"}]) def getDefaultSpeakerDevice(self): - return self.default_speaker_device + # Ensure initialized and return default speaker device (safe default if still not populated) + if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass + return getattr(self, 'default_speaker_device', {"device": {"index": -1, "name": "NoDevice"}}) def forceUpdateAndSetMicDevices(self): self.update() @@ -337,13 +515,15 @@ class DeviceManager: self.setSpeakerDeviceList() self.setSpeakerDefaultDevice() +# Provide a module-level singleton. Call `device_manager.init()` explicitly to +# initialize audio resources and `device_manager.startMonitoring()` to begin +# background monitoring. This avoids side-effects during simple imports. device_manager = DeviceManager() if __name__ == "__main__": - # print("getMicDevices()", device_manager.getMicDevices()) - # print("getDefaultMicDevice()", device_manager.getDefaultMicDevice()) - # print("getSpeakerDevices()", device_manager.getSpeakerDevices()) - # print("getDefaultSpeakerDevice()", device_manager.getDefaultSpeakerDevice()) - - while True: - sleep(1) \ No newline at end of file + print("DeviceManager demo. Call device_manager.init() and device_manager.startMonitoring() to run live monitoring.") + try: + while True: + sleep(1) + except KeyboardInterrupt: + print("exiting") \ No newline at end of file diff --git a/src-python/docs/config.md b/src-python/docs/config.md new file mode 100644 index 00000000..7d7d3f6e --- /dev/null +++ b/src-python/docs/config.md @@ -0,0 +1,433 @@ +# config.py ドキュメント + +## 概要 +`config.py` は、アプリケーションの全設定を一元管理するシングルトンクラス `Config` を提供するモジュール。設定値の読み込み・保存・検証を行い、JSON ファイルへの永続化をデバウンス機能付きで実現する。 + +## 主要機能 +- シングルトンパターンによる設定の一元管理 +- JSONファイル (`config.json`) からの設定読み込みと自動保存 +- デバウンス機能による書き込み最適化(デフォルト2秒) +- 読み取り専用プロパティと読み書き可能プロパティの明確な分離 +- オプショナルモジュールのセーフガードインポート(環境依存の依存関係を安全に処理) +- プロパティセッター内での型チェックとバリデーション +- `@json_serializable` デコレータによる永続化対象プロパティの管理 + +## アーキテクチャ + +### デザインパターン +- **シングルトンパターン**: `__new__` メソッドで単一インスタンスを保証 +- **プロパティパターン**: getter/setter による型安全なアクセス制御 + +### 設定の分類 +1. **読み取り専用設定** (Read Only) + - アプリケーションバージョン、パス、URL、定数など + - プロパティのみ(setter なし) + +2. **ランタイム設定** (Read Write) + - 機能の有効/無効フラグ + - 実行時の状態管理 + - JSON保存されない一時的な設定 + +3. **永続化設定** (Save Json Data) + - ユーザー設定、デバイス選択、UI設定など + - `@json_serializable` デコレータでマーク + - `saveConfig()` 経由で自動保存 + +## 使用方法 + +### 基本的な使い方 + +```python +from config import config + +# 設定値の取得(読み取り専用) +version = config.VERSION +app_path = config.PATH_LOCAL + +# 設定値の取得(読み書き可能) +current_tab = config.SELECTED_TAB_NO +mic_threshold = config.MIC_THRESHOLD + +# 設定値の変更(自動保存される) +config.SELECTED_TAB_NO = "2" +config.MIC_THRESHOLD = 500 +config.TRANSPARENCY = 80 + +# 即座に保存する場合 +config.MAIN_WINDOW_GEOMETRY = {"x_pos": 100, "y_pos": 200, "width": 900, "height": 700} +# MESSAGE_BOX_RATIO と MAIN_WINDOW_GEOMETRY は immediate_save=True で即座に保存 +``` + +### デバウンス保存の仕組み + +```python +# 通常の設定変更: 2秒後に保存 +config.UI_LANGUAGE = "ja" +config.FONT_FAMILY = "Arial" # 前のタイマーがキャンセルされ、新たに2秒のタイマー開始 + +# 即座保存が必要な設定: デバウンスなし +config.MESSAGE_BOX_RATIO = 15 # 即座にファイル書き込み +``` + +## 動作環境・依存関係 + +### 必須依存 +- Python 3.10以上(match-case 構文使用) +- `torch`: CUDA利用可否の判定に使用 +- `threading`: デバウンスタイマー用 + +### オプション依存(セーフガード付き) +以下のモジュールはインポートに失敗しても動作する: +- `device_manager`: デバイス管理(マイク/スピーカー) +- `models.translation.translation_languages`: 翻訳言語リスト +- `models.translation.translation_utils`: CTranslate2 重みリスト +- `models.transcription.transcription_languages`: 音声認識言語リスト +- `models.transcription.transcription_whisper`: Whisper モデルリスト + +### プロジェクト内依存 +- `utils`: エラーロギング、辞書構造検証、計算デバイスリスト取得 + +## ファイル構成 + +### 主要クラス: `Config` + +#### クラス属性 +```python +_instance: Config | None # シングルトンインスタンス +_config_data: Dict[str, Any] # JSON保存用データ +_timer: Optional[threading.Timer] # デバウンスタイマー +_debounce_time: int = 2 # デバウンス時間(秒) +``` + +#### 主要メソッド + +**初期化・保存** +- `__new__(cls)`: シングルトンインスタンス生成・初期化 +- `init_config()`: デフォルト値の設定 +- `load_config()`: JSONファイルから設定読み込み +- `saveConfig(key, value, immediate_save=False)`: 設定の保存(デバウンス付き) +- `saveConfigToFile()`: JSONファイルへの即座書き込み + +**デコレータ** +- `@json_serializable(var_name)`: 永続化対象プロパティのマーク + +### 設定プロパティ一覧 + +#### 読み取り専用設定(23項目) + +| プロパティ名 | 型 | 説明 | デフォルト値 | +|------------|----|----|------------| +| `VERSION` | str | アプリケーションバージョン | "3.3.0" | +| `PATH_LOCAL` | str | アプリケーションローカルパス | 実行時決定 | +| `PATH_CONFIG` | str | 設定ファイルパス | `{PATH_LOCAL}/config.json` | +| `PATH_LOGS` | str | ログディレクトリパス | `{PATH_LOCAL}/logs` | +| `GITHUB_URL` | str | GitHub API URL | リポジトリURL | +| `UPDATER_URL` | str | アップデーターAPIの URL | アップデーターURL | +| `BOOTH_URL` | str | Booth 販売ページURL | Booth URL | +| `DOCUMENTS_URL` | str | ドキュメントURL | Notion URL | +| `DEEPL_AUTH_KEY_PAGE_URL` | str | DeepL認証キー取得ページ | DeepL URL | +| `MAX_MIC_THRESHOLD` | int | マイクしきい値の最大値 | 2000 | +| `MAX_SPEAKER_THRESHOLD` | int | スピーカーしきい値の最大値 | 4000 | +| `WATCHDOG_TIMEOUT` | int | Watchdog タイムアウト(秒) | 60 | +| `WATCHDOG_INTERVAL` | int | Watchdog チェック間隔(秒) | 20 | +| `SELECTABLE_TAB_NO_LIST` | List[str] | 選択可能タブ番号 | ["1", "2", "3"] | +| `SELECTED_TAB_TARGET_LANGUAGES_NO_LIST` | List[str] | ターゲット言語タブ番号 | ["1", "2", "3"] | +| `SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_LIST` | List[str] | CTranslate2重みタイプリスト | 動的取得 | +| `SELECTABLE_WHISPER_WEIGHT_TYPE_LIST` | List[str] | Whisper重みタイプリスト | 動的取得 | +| `SELECTABLE_TRANSLATION_ENGINE_LIST` | List[str] | 翻訳エンジンリスト | 動的取得 | +| `SELECTABLE_TRANSCRIPTION_ENGINE_LIST` | List[str] | 音声認識エンジンリスト | 動的取得 | +| `SELECTABLE_UI_LANGUAGE_LIST` | List[str] | UI言語リスト | ["en", "ja", "ko", "zh-Hant", "zh-Hans"] | +| `COMPUTE_MODE` | str | 計算モード | "cuda" or "cpu" | +| `SELECTABLE_COMPUTE_DEVICE_LIST` | List[Dict] | 選択可能な計算デバイスリスト | 動的取得 | +| `SEND_MESSAGE_BUTTON_TYPE_LIST` | List[str] | 送信ボタンタイプリスト | ["show", "hide", "show_and_disable_enter_key"] | + +#### ランタイム設定(10項目) + +| プロパティ名 | 型 | 説明 | デフォルト値 | JSON保存 | +|------------|----|----|-----------|---------| +| `ENABLE_TRANSLATION` | bool | 翻訳機能有効フラグ | False | なし | +| `ENABLE_TRANSCRIPTION_SEND` | bool | 送信音声認識有効フラグ | False | なし | +| `ENABLE_TRANSCRIPTION_RECEIVE` | bool | 受信音声認識有効フラグ | False | なし | +| `ENABLE_FOREGROUND` | bool | フォアグラウンド有効フラグ | False | なし | +| `ENABLE_CHECK_ENERGY_SEND` | bool | 送信エネルギーチェック有効 | False | なし | +| `ENABLE_CHECK_ENERGY_RECEIVE` | bool | 受信エネルギーチェック有効 | False | なし | +| `SELECTABLE_CTRANSLATE2_WEIGHT_TYPE_DICT` | Dict[str, bool] | CTranslate2重み状態辞書 | {} | なし | +| `SELECTABLE_WHISPER_WEIGHT_TYPE_DICT` | Dict[str, bool] | Whisper重み状態辞書 | {} | なし | +| `SELECTABLE_TRANSLATION_ENGINE_STATUS` | Dict[str, bool] | 翻訳エンジン状態辞書 | {} | なし | +| `SELECTABLE_TRANSCRIPTION_ENGINE_STATUS` | Dict[str, bool] | 音声認識エンジン状態辞書 | {} | なし | + +#### 永続化設定(60項目以上) + +**メインウィンドウ設定** +- `SELECTED_TAB_NO`: 選択中のタブ番号 +- `SELECTED_TRANSLATION_ENGINES`: タブごとの翻訳エンジン選択 +- `SELECTED_YOUR_LANGUAGES`: タブごとの入力言語設定 +- `SELECTED_TARGET_LANGUAGES`: タブごとのターゲット言語設定 +- `SELECTED_TRANSCRIPTION_ENGINE`: 音声認識エンジン +- `CONVERT_MESSAGE_TO_ROMAJI`: ローマ字変換有効フラグ +- `CONVERT_MESSAGE_TO_HIRAGANA`: ひらがな変換有効フラグ +- `MAIN_WINDOW_SIDEBAR_COMPACT_MODE`: サイドバーコンパクトモード +- `SEND_MESSAGE_FORMAT_PARTS`: 送信メッセージフォーマット +- `RECEIVED_MESSAGE_FORMAT_PARTS`: 受信メッセージフォーマット + +**UIウィンドウ設定** +- `TRANSPARENCY`: ウィンドウ透明度(0-100) +- `UI_SCALING`: UIスケーリング(%) +- `TEXTBOX_UI_SCALING`: テキストボックススケーリング(%) +- `MESSAGE_BOX_RATIO`: メッセージボックス比率(即座保存) +- `SEND_MESSAGE_BUTTON_TYPE`: 送信ボタンタイプ +- `SHOW_RESEND_BUTTON`: 再送信ボタン表示フラグ +- `FONT_FAMILY`: フォントファミリー +- `UI_LANGUAGE`: UI言語 +- `MAIN_WINDOW_GEOMETRY`: ウィンドウ位置・サイズ(即座保存) + +**マイク設定** +- `AUTO_MIC_SELECT`: 自動マイク選択 +- `SELECTED_MIC_HOST`: 選択されたマイクホスト +- `SELECTED_MIC_DEVICE`: 選択されたマイクデバイス +- `MIC_THRESHOLD`: マイクしきい値 +- `MIC_AUTOMATIC_THRESHOLD`: 自動しきい値調整 +- `MIC_RECORD_TIMEOUT`: 録音タイムアウト(秒) +- `MIC_PHRASE_TIMEOUT`: フレーズタイムアウト(秒) +- `MIC_MAX_PHRASES`: 最大フレーズ数 +- `MIC_WORD_FILTER`: ワードフィルターリスト +- `MIC_AVG_LOGPROB`: 平均対数確率しきい値 +- `MIC_NO_SPEECH_PROB`: 無音確率しきい値 + +**スピーカー設定** +- `AUTO_SPEAKER_SELECT`: 自動スピーカー選択 +- `SELECTED_SPEAKER_DEVICE`: 選択されたスピーカーデバイス +- `SPEAKER_THRESHOLD`: スピーカーしきい値 +- `SPEAKER_AUTOMATIC_THRESHOLD`: 自動しきい値調整 +- `SPEAKER_RECORD_TIMEOUT`: 録音タイムアウト(秒) +- `SPEAKER_PHRASE_TIMEOUT`: フレーズタイムアウト(秒) +- `SPEAKER_MAX_PHRASES`: 最大フレーズ数 +- `SPEAKER_AVG_LOGPROB`: 平均対数確率しきい値 +- `SPEAKER_NO_SPEECH_PROB`: 無音確率しきい値 + +**モデル設定** +- `SELECTED_TRANSLATION_COMPUTE_DEVICE`: 翻訳計算デバイス +- `SELECTED_TRANSCRIPTION_COMPUTE_DEVICE`: 音声認識計算デバイス +- `CTRANSLATE2_WEIGHT_TYPE`: CTranslate2重みタイプ +- `SELECTED_TRANSLATION_COMPUTE_TYPE`: 翻訳計算タイプ +- `WHISPER_WEIGHT_TYPE`: Whisper重みタイプ +- `SELECTED_TRANSCRIPTION_COMPUTE_TYPE`: 音声認識計算タイプ + +**通信設定** +- `OSC_IP_ADDRESS`: OSC IPアドレス(デフォルト: "127.0.0.1") +- `OSC_PORT`: OSCポート(デフォルト: 9000) +- `AUTH_KEYS`: 認証キー辞書(DeepL API等) +- `WEBSOCKET_HOST`: WebSocketホスト +- `WEBSOCKET_PORT`: WebSocketポート +- `WEBSOCKET_SERVER`: WebSocketサーバー有効フラグ(非永続化) + +**オーバーレイ設定** +- `OVERLAY_SMALL_LOG`: 小ログオーバーレイ有効 +- `OVERLAY_SMALL_LOG_SETTINGS`: 小ログオーバーレイ設定(位置、回転、表示時間等) +- `OVERLAY_LARGE_LOG`: 大ログオーバーレイ有効 +- `OVERLAY_LARGE_LOG_SETTINGS`: 大ログオーバーレイ設定 +- `OVERLAY_SHOW_ONLY_TRANSLATED_MESSAGES`: 翻訳メッセージのみ表示 + +**その他設定** +- `HOTKEYS`: ホットキー設定辞書(即座保存) +- `PLUGINS_STATUS`: プラグイン状態リスト(即座保存) +- `USE_EXCLUDE_WORDS`: 除外ワード機能使用フラグ +- `AUTO_CLEAR_MESSAGE_BOX`: メッセージボックス自動クリア +- `SEND_ONLY_TRANSLATED_MESSAGES`: 翻訳メッセージのみ送信 +- `SEND_MESSAGE_TO_VRC`: VRChatへメッセージ送信 +- `SEND_RECEIVED_MESSAGE_TO_VRC`: 受信メッセージをVRChatへ送信 +- `LOGGER_FEATURE`: ロガー機能有効 +- `VRC_MIC_MUTE_SYNC`: VRChatマイクミュート同期 +- `NOTIFICATION_VRC_SFX`: VRChat通知効果音 + +## 内部実装の詳細 + +### デバウンス保存の実装 + +```python +def saveConfig(self, key: str, value: Any, immediate_save: bool = False) -> None: + self._config_data[key] = value + + # 既存のタイマーをキャンセル + if isinstance(self._timer, threading.Timer) and self._timer.is_alive(): + self._timer.cancel() + + if immediate_save: + self.saveConfigToFile() + else: + # 2秒後に保存するタイマーをセット + self._timer = threading.Timer(self._debounce_time, self.saveConfigToFile) + self._timer.daemon = True + self._timer.start() +``` + +### プロパティのバリデーション例 + +```python +@SELECTED_TAB_NO.setter +def SELECTED_TAB_NO(self, value): + if isinstance(value, str): + if value in self.SELECTABLE_TAB_NO_LIST: + self._SELECTED_TAB_NO = value + self.saveConfig(inspect.currentframe().f_code.co_name, value) +``` + +各setterは以下のパターンを実装: +1. 型チェック (`isinstance`) +2. 値の範囲・有効性チェック +3. 内部変数への代入 +4. `saveConfig` 呼び出し(永続化対象の場合) + +### メッセージフォーマット構造 + +```python +{ + "message": { + "prefix": "", # メッセージ前置文字列 + "suffix": "" # メッセージ後置文字列 + }, + "separator": "\n", # メッセージと翻訳の区切り + "translation": { + "prefix": "", # 翻訳前置文字列 + "separator": "\n", # 複数翻訳の区切り + "suffix": "" # 翻訳後置文字列 + }, + "translation_first": False # 翻訳を先に表示するか +} +``` + +### オーバーレイ設定構造 + +```python +{ + "x_pos": 0.0, # X座標 + "y_pos": 0.0, # Y座標 + "z_pos": 0.0, # Z座標 + "x_rotation": 0.0, # X軸回転 + "y_rotation": 0.0, # Y軸回転 + "z_rotation": 0.0, # Z軸回転 + "display_duration": 5, # 表示時間(秒) + "fadeout_duration": 2, # フェードアウト時間(秒) + "opacity": 1.0, # 不透明度(0.0-1.0) + "ui_scaling": 1.0, # UIスケーリング + "tracker": "HMD" # トラッカー ("HMD", "LeftHand", "RightHand") +} +``` + +## エラーハンドリング + +### セーフガードインポート +```python +try: + from device_manager import device_manager +except Exception: + device_manager = None # フォールバック値 +``` + +全ての外部モジュールインポートはtry-exceptでラップされており、インポート失敗時でも `Config` クラスは正常に動作する。 + +### 初期化エラー +```python +def __new__(cls): + if cls._instance is None: + cls._instance = super(Config, cls).__new__(cls) + try: + cls._instance.init_config() + except Exception: + errorLogging() # エラーをログに記録 + try: + cls._instance.load_config() + except Exception: + errorLogging() + return cls._instance +``` + +初期化とロード処理はそれぞれ独立してエラーハンドリングされる。 + +### 設定ロード時のエラー +```python +for key, value in self._config_data.items(): + try: + setattr(self, key, value) + except Exception: + errorLogging() # 個別設定の読み込み失敗は継続 +``` + +JSONから読み込んだ設定のうち、不正な値があっても他の設定の読み込みは継続される。 + +## パフォーマンス考慮事項 + +1. **デバウンス保存**: 頻繁な設定変更時にI/Oを削減 +2. **遅延初期化**: オプションモジュールは必要時のみロード +3. **シングルトン**: 設定オブジェクトの複製を防止 +4. **デーモンスレッド**: タイマースレッドはメインスレッド終了時に自動終了 + +## セキュリティ考慮事項 + +1. **認証キー**: `AUTH_KEYS` に格納される外部APIキーは平文でJSON保存される +2. **パス検証**: IP アドレスは `isValidIpAddress` でバリデーション +3. **型安全性**: 全てのセッターで型チェック実施 + +## テスト推奨事項 + +### 単体テスト +```python +def test_config_singleton(): + config1 = Config() + config2 = Config() + assert config1 is config2 + +def test_debounce_save(): + config.UI_LANGUAGE = "ja" + time.sleep(1) + config.UI_LANGUAGE = "en" + # 2秒以内の変更は1回のみ保存される + time.sleep(2.5) + # ここで保存完了 +``` + +### バリデーションテスト +```python +def test_invalid_tab_no(): + config.SELECTED_TAB_NO = "invalid" # 無視される + assert config.SELECTED_TAB_NO != "invalid" +``` + +### オプション依存のテスト +```python +def test_missing_device_manager(): + # device_manager が None でも動作すること + assert config.SELECTABLE_COMPUTE_DEVICE_LIST is not None +``` + +## マイグレーション + +### 設定ファイルのバージョンアップ +`load_config()` は存在しないキーを無視し、`init_config()` のデフォルト値を使用する。新しいバージョンでキーが追加された場合: + +1. 既存キーはJSONから読み込まれる +2. 新規キーは `init_config()` のデフォルト値が使用される +3. 次回保存時に全てのキーがJSON に書き込まれる + +## 制限事項 + +1. **マルチプロセス**: シングルトンはプロセス単位。マルチプロセス環境では各プロセスが独立したインスタンスを持つ +2. **スレッドセーフティ**: プロパティアクセス自体はスレッドセーフではない(保存タイマーのみスレッド対応) +3. **循環参照**: `device_manager` と `config` 間の循環参照に注意 +4. **JSON制限**: JSON にシリアライズ可能な型のみ保存可能 + +## ライセンス +プロジェクトのルートディレクトリの `LICENSE` ファイルを参照 + +## 関連ドキュメント +- `controller.md`: Controller クラスの設定使用方法 +- `mainloop.md`: メインループでの設定参照 +- `仕様書.md`: 全体仕様 +- `設計書.md`: システム設計 + +## 変更履歴 + +### v3.3.0 +- 現行バージョン +- WebSocket サーバー設定追加 +- オーバーレイ設定の拡張 diff --git a/src-python/docs/controller.md b/src-python/docs/controller.md new file mode 100644 index 00000000..d27ebb08 --- /dev/null +++ b/src-python/docs/controller.md @@ -0,0 +1,1225 @@ +# controller.py 設計書 + +## 概要 + +`controller.py` は VRCT アプリケーションのビジネスロジック層であり、フロントエンド(UI)とバックエンド(Model)の間の制御フローを担当する。音声認識、翻訳、OSC通信、オーバーレイ表示など、VRCT の全機能の調整役として動作し、各種設定の取得・更新、デバイス管理、エラーハンドリングを提供する。 + +## アーキテクチャ上の位置づけ + +``` +┌─────────────┐ +│ Frontend │ (Tauri/React) +│ (UI Layer) │ +└──────┬──────┘ + │ JSON-RPC (stdin/stdout) +┌──────▼──────┐ +│ mainloop.py │ (Communication Layer) +└──────┬──────┘ + │ Function Calls +┌──────▼──────┐ +│controller.py│ ◄── このファイル +└──────┬──────┘ + │ Facade Pattern +┌──────▼──────┐ +│ model.py │ (Business Logic Facade) +└──────┬──────┘ + │ +┌──────▼──────┐ +│ Subsystems │ (transcription, translation, osc, overlay, etc.) +└─────────────┘ +``` + +## 主要コンポーネント + +### 1. Controllerクラス + +#### コンストラクタ `__init__()` + +**責務:** Controller インスタンスの初期化と依存関係のセットアップ + +**初期化処理:** +1. **マッピング辞書の初期化:** + - `init_mapping`: 初期化時に実行するエンドポイント群 + - `run_mapping`: フロントエンドへの通知用エンドポイント +2. **コールバック関数の設定:** + - `run`: フロントエンドへの通知を送信する関数(デフォルトは no-op) +3. **Model の初期化:** + - `model.init()` を呼び出し、サブシステムを準備 + - 失敗時は `errorLogging()` でログ記録して継続 +4. **デバイスアクセス状態:** + - `device_access_status`: デバイスへの排他アクセス制御用フラグ + +**型ヒント:** +```python +self.init_mapping: dict +self.run_mapping: dict +self.run: Callable[[int, str, Any], None] +self.device_access_status: bool +``` + +#### セットアップメソッド + +##### `setInitMapping(init_mapping: dict) -> None` +初期化時に実行するエンドポイントマッピングを設定。`mainloop.py` から呼び出される。 + +##### `setRunMapping(run_mapping: dict) -> None` +フロントエンド通知用のエンドポイントマッピングを設定。 + +##### `setRun(run: Callable[[int, str, Any], None]) -> None` +フロントエンドへの通知関数を設定。`mainloop.py` の `printResponse()` ラッパーが渡される。 + +#### ヘルパーメソッド + +##### `_is_overlay_available() -> bool` +オーバーレイ機能が利用可能かを安全にチェック。Model が未初期化の場合の `AttributeError` を回避。 + +**実装:** +```python +try: + overlay = getattr(model, "overlay", None) + return overlay is not None and getattr(overlay, "initialized", False) +except Exception: + errorLogging() + return False +``` + +--- + +### 2. 通知メソッド(Response Functions) + +フロントエンドに状態変化を通知するメソッド群。すべて `self.run()` を介して JSON を stdout に送信。 + +#### ネットワーク関連 + +##### `connectedNetwork() -> None` +ネットワーク接続を検出したことを通知。 + +##### `disconnectedNetwork() -> None` +ネットワーク切断を検出したことを通知。 + +#### AI モデル関連 + +##### `enableAiModels() -> None` +AI モデル(CTranslate2/Whisper)が利用可能であることを通知。 + +##### `disableAiModels() -> None` +AI モデルが利用不可(ダウンロード失敗等)であることを通知。 + +#### デバイス管理関連 + +##### `updateMicHostList() -> None` +マイクホスト一覧(MME/WASAPI等)を更新。 + +##### `updateMicDeviceList() -> None` +マイクデバイス一覧を更新。 + +##### `updateSpeakerDeviceList() -> None` +スピーカーデバイス一覧を更新。 + +##### `updateSelectedMicDevice(host: str, device: str) -> None` +選択されたマイクデバイスを通知。自動デバイス選択時に使用。 + +##### `updateSelectedSpeakerDevice(device: str) -> None` +選択されたスピーカーデバイスを通知。 + +#### エネルギーレベル通知 + +##### `progressBarMicEnergy(energy: Union[bool, int]) -> None` +マイクの音量レベルを通知。`False` の場合はデバイスエラーを送信。 + +##### `progressBarSpeakerEnergy(energy: Union[bool, int]) -> None` +スピーカーの音量レベルを通知。 + +#### 設定同期 + +##### `updateConfigSettings() -> None` +初期化完了時に全設定値をフロントエンドに送信。`init_mapping` の全エンドポイントを実行。 + +--- + +### 3. デバイス制御メソッド + +#### 再起動系 + +##### `restartAccessMicDevices() -> None` +マイクアクセスを再起動。以下の条件で各機能を開始: +- `config.ENABLE_TRANSCRIPTION_SEND` が True: 音声認識開始 +- `config.ENABLE_CHECK_ENERGY_SEND` が True: 音量監視開始 + +##### `restartAccessSpeakerDevices() -> None` +スピーカーアクセスを再起動。 + +#### 停止系 + +##### `stopAccessMicDevices() -> None` +マイク関連機能を停止。 + +##### `stopAccessSpeakerDevices() -> None` +スピーカー関連機能を停止。 + +**使用場面:** +- デバイス変更時 +- 自動デバイス選択によるデバイス切り替え時 +- アプリケーション終了時 + +--- + +### 4. メッセージ処理メソッド + +#### `micMessage(result: dict) -> None` + +**責務:** マイク音声認識結果の処理と配信 + +**処理フロー:** +1. **結果の検証:** + - `result["text"]` と `result["language"]` を取得 + - `False` の場合はデバイスエラーを通知して終了 +2. **フィルタリング:** + - `model.checkKeywords()`: 禁止ワードチェック + - `model.detectRepeatSendMessage()`: 重複メッセージチェック +3. **翻訳処理:** + - `config.ENABLE_TRANSLATION` が True の場合: + - `model.getInputTranslate()` で翻訳実行 + - 翻訳エンジンエラー時は CTranslate2 に切り替え + - VRAM不足エラー時は翻訳機能を無効化 +4. **音訳処理:** + - `config.CONVERT_MESSAGE_TO_HIRAGANA/ROMAJI` が True の場合: + - `model.convertMessageToTransliteration()` で変換 +5. **配信処理:** + - **VRChat OSC:** `config.SEND_MESSAGE_TO_VRC` が True の場合 + - `messageFormatter()` でフォーマット + - `model.oscSendMessage()` で送信 + - **UI通知:** `self.run()` で transcription_mic エンドポイントに通知 + - **オーバーレイ:** `config.OVERLAY_LARGE_LOG` が True の場合 + - `model.createOverlayImageLargeLog()` で画像生成 + - `model.updateOverlayLargeLog()` で表示更新 + - **WebSocket:** サーバーが起動中の場合 + - `model.websocketSendMessage()` でブロードキャスト + - **ログファイル:** `config.LOGGER_FEATURE` が True の場合 + +**VRAM エラーハンドリング:** +```python +try: + translation, success = model.getInputTranslate(message, source_language=language) +except Exception as e: + is_vram_error, error_message = model.detectVRAMError(e) + if is_vram_error: + # 翻訳機能を無効化 + self.setDisableTranslation() + self.run(400, self.run_mapping["error_translation_mic_vram_overflow"], {...}) + return +``` + +#### `speakerMessage(result: dict) -> None` + +**責務:** スピーカー音声認識結果の処理と配信 + +**処理フロー:** `micMessage()` と同様だが、以下の違いがある: +- **オーバーレイ:** + - Small Log: 受信メッセージ用の小さなログウィンドウ + - Large Log: 送受信両方を表示するログウィンドウ +- **OSC送信:** `config.SEND_RECEIVED_MESSAGE_TO_VRC` の設定に依存 +- **翻訳:** `model.getOutputTranslate()` を使用(受信メッセージ用) + +#### `chatMessage(data: dict) -> dict` + +**責務:** UI のチャットボックスからのメッセージ処理 + +**パラメータ:** +- `data["id"]`: メッセージ ID(UI でのレスポンスマッピング用) +- `data["message"]`: 送信メッセージ + +**特殊処理:** +- **除外ワード処理:** + - `config.USE_EXCLUDE_WORDS` が True の場合 + - `replaceExclamationsWithRandom()`: `![word]` を一時的なトークンに置換 + - 翻訳後に `restoreText()` で復元 + - 最終メッセージから `![...]` を削除 +- **同期レスポンス:** + - 他のメッセージ処理と異なり、結果を `dict` で返却 + - UI が翻訳結果を待機する必要があるため + +**レスポンス形式:** +```python +{ + "status": 200, + "result": { + "id": "msg-123", + "original": { + "message": "Hello", + "transliteration": ["he", "ro"] + }, + "translations": [ + { + "message": "こんにちは", + "transliteration": ["ko", "n", "ni", "chi", "wa"] + } + ] + } +} +``` + +--- + +### 5. メッセージフォーマット + +#### `messageFormatter(format_type: str, translation: list, message: str) -> str` + +**責務:** OSC 送信用メッセージの整形 + +**パラメータ:** +- `format_type`: "SEND" または "RECEIVED" +- `translation`: 翻訳結果のリスト +- `message`: 元のメッセージ + +**処理ロジック:** +1. フォーマット設定を取得: + - `config.SEND_MESSAGE_FORMAT_PARTS` または `config.RECEIVED_MESSAGE_FORMAT_PARTS` +2. 各部分を構築: + - `message_part`: prefix + message + suffix + - `translation_part`: prefix + separator.join(translation) + suffix +3. 組み合わせ: + - 両方存在: `translation_first` の設定に応じて順序決定 + - 翻訳のみ: translation_part のみ + - メッセージのみ: message_part のみ + +**設定例:** +```python +config.SEND_MESSAGE_FORMAT_PARTS = { + "message": {"prefix": "[", "suffix": "] "}, + "translation": {"prefix": "", "suffix": "", "separator": " / "}, + "translation_first": False, + "separator": "" +} +# 出力例: [Hello] こんにちは / 你好 +``` + +--- + +### 6. 除外ワード処理 + +#### `replaceExclamationsWithRandom(text: str) -> Tuple[str, dict]` + +**責務:** 翻訳対象外の単語を保護 + +**処理:** +1. `![word]` パターンを検出 +2. 各マッチを `$` に置換(4096から連番) +3. 置換マップを辞書で返却 + +**用途:** 固有名詞や翻訳不要な単語を保護 + +#### `restoreText(escaped_text: str, escape_dict: dict) -> str` + +**責務:** 翻訳後のテキストに元の単語を復元 + +**処理:** 正規表現で `$` を検出し、元の単語に置換(大文字小文字を無視) + +#### `removeExclamations(text: str) -> str` + +**責務:** 最終メッセージから `![...]` マーカーを削除 + +**処理:** `![word]` を `word` に置換 + +--- + +### 7. 設定取得・更新メソッド(GET/SET) + +Controller には約200個の設定項目に対する getter/setter が定義されている。以下、代表的なパターンを示す。 + +#### パターン1: 単純な設定値 + +```python +@staticmethod +def getTransparency(*args, **kwargs) -> dict: + return {"status": 200, "result": config.TRANSPARENCY} + +@staticmethod +def setTransparency(data, *args, **kwargs) -> dict: + config.TRANSPARENCY = int(data) + return {"status": 200, "result": config.TRANSPARENCY} +``` + +#### パターン2: 有効/無効の切り替え + +```python +@staticmethod +def getOverlaySmallLog(*args, **kwargs) -> dict: + return {"status": 200, "result": config.OVERLAY_SMALL_LOG} + +@staticmethod +def setEnableOverlaySmallLog(*args, **kwargs) -> dict: + if config.OVERLAY_SMALL_LOG is False: + if config.OVERLAY_LARGE_LOG is False: + model.startOverlay() # 副作用: オーバーレイシステムを起動 + config.OVERLAY_SMALL_LOG = True + return {"status": 200, "result": config.OVERLAY_SMALL_LOG} + +@staticmethod +def setDisableOverlaySmallLog(*args, **kwargs) -> dict: + if config.OVERLAY_SMALL_LOG is True: + model.clearOverlayImageSmallLog() + if config.OVERLAY_LARGE_LOG is False: + model.shutdownOverlay() # 副作用: オーバーレイシステムを停止 + config.OVERLAY_SMALL_LOG = False + return {"status": 200, "result": config.OVERLAY_SMALL_LOG} +``` + +#### パターン3: バリデーション付き設定 + +```python +@staticmethod +def setMicThreshold(data, *args, **kwargs) -> dict: + try: + data = int(data) + if 0 <= data <= config.MAX_MIC_THRESHOLD: + config.MIC_THRESHOLD = data + status = 200 + else: + raise ValueError() + except Exception: + response = { + "status": 400, + "result": { + "message": "Mic energy threshold value is out of range", + "data": config.MIC_THRESHOLD + } + } + else: + response = {"status": status, "result": config.MIC_THRESHOLD} + return response +``` + +#### パターン4: 依存関係のある設定 + +```python +def setSelectedTranslationComputeDevice(self, device: str, *args, **kwargs) -> dict: + config.SELECTED_TRANSLATION_COMPUTE_DEVICE = device + config.SELECTED_TRANSLATION_COMPUTE_TYPE = "auto" + # 依存する設定を自動更新 + self.run(200, self.run_mapping["selected_translation_compute_type"], + config.SELECTED_TRANSLATION_COMPUTE_TYPE) + # モデルの再読み込みフラグを設定 + model.setChangedTranslatorParameters(True) + return {"status": 200, "result": config.SELECTED_TRANSLATION_COMPUTE_DEVICE} +``` + +--- + +### 8. 翻訳機能制御 + +#### `setEnableTranslation(*args, **kwargs) -> dict` + +**責務:** 翻訳機能の有効化とモデルのロード + +**処理フロー:** +1. 既に有効な場合は何もしない +2. モデル未ロードまたはパラメータ変更時: + - `model.changeTranslatorCTranslate2Model()` でモデルをロード + - VRAM不足エラーの場合: + - デフォルト設定に戻す + - エラー通知を送信 + - 翻訳を無効化 +3. `config.ENABLE_TRANSLATION = True` に設定 + +**エラーハンドリング:** +```python +try: + model.changeTranslatorCTranslate2Model() +except Exception as e: + is_vram_error, error_message = model.detectVRAMError(e) + if is_vram_error: + self.run(400, self.run_mapping["error_translation_enable_vram_overflow"], {...}) + self.setDisableTranslation() +``` + +#### `setDisableTranslation(*args, **kwargs) -> dict` + +**責務:** 翻訳機能の無効化(メモリ解放) + +#### `changeToCTranslate2Process() -> None` + +**責務:** 外部翻訳APIエラー時に CTranslate2 へ切り替え + +**処理:** +1. 現在の翻訳エンジンを無効化 +2. CTranslate2 に切り替え +3. フロントエンドに通知 + +--- + +### 9. 音声認識制御 + +#### スレッド管理メソッド + +##### `startTranscriptionSendMessage() -> None` +マイク音声認識を開始。デバイスアクセスの排他制御を行う。 + +**排他制御:** +```python +while self.device_access_status is False: + sleep(1) # 他の処理がデバイスを使用中なら待機 +self.device_access_status = False # ロック取得 +try: + model.startMicTranscript(self.micMessage) +finally: + self.device_access_status = True # ロック解放 +``` + +**VRAMエラーハンドリング:** +- `model.detectVRAMError()` でエラーを検出 +- 音声認識を停止 +- フロントエンドに通知 + +##### `stopTranscriptionSendMessage() -> None` +マイク音声認識を停止。 + +##### `startThreadingTranscriptionSendMessage() -> None` +別スレッドで音声認識を開始。 + +##### `stopThreadingTranscriptionSendMessage() -> None` +別スレッドで音声認識を停止し、完了を待機(`join()`)。 + +**対応するスピーカー用メソッド:** +- `startTranscriptionReceiveMessage()` +- `stopTranscriptionReceiveMessage()` +- `startThreadingTranscriptionReceiveMessage()` +- `stopThreadingTranscriptionReceiveMessage()` + +--- + +### 10. エネルギー監視 + +#### `startCheckMicEnergy() -> None` +マイクの音量レベル監視を開始。`progressBarMicEnergy()` をコールバックとして渡す。 + +#### `stopCheckMicEnergy() -> None` +マイクの音量レベル監視を停止。 + +#### `startThreadingCheckMicEnergy() -> None` +別スレッドでエネルギー監視を開始。 + +#### `stopThreadingCheckMicEnergy() -> None` +別スレッドでエネルギー監視を停止し、完了を待機。 + +**対応するスピーカー用メソッド:** +- `startCheckSpeakerEnergy()` +- `stopCheckSpeakerEnergy()` +- `startThreadingCheckSpeakerEnergy()` +- `stopThreadingCheckSpeakerEnergy()` + +--- + +### 11. モデルウェイト管理 + +#### DownloadCTranslate2 クラス + +**責務:** CTranslate2 モデルのダウンロード進捗管理 + +**メソッド:** +- `progressBar(progress: float)`: 進捗率をフロントエンドに通知 +- `downloaded()`: ダウンロード完了時の処理 + - モデルの存在確認 + - 選択可能モデルリストに追加 + - フロントエンドに通知 + +#### DownloadWhisper クラス + +**責務:** Whisper モデルのダウンロード進捗管理(CTranslate2 と同様の構造) + +#### `downloadCtranslate2Weight(data: str, asynchronous: bool = True, *args, **kwargs) -> dict` + +**責務:** CTranslate2 モデルのダウンロード開始 + +**パラメータ:** +- `data`: モデルタイプ("tiny", "small", "medium" 等) +- `asynchronous`: 非同期ダウンロードの有効化 + +**処理:** +1. `DownloadCTranslate2` インスタンスを作成 +2. `asynchronous` が True の場合: + - `startThreadingDownloadCtranslate2Weight()` で別スレッド実行 +3. `asynchronous` が False の場合: + - `model.downloadCTranslate2ModelWeight()` で同期実行(初期化時に使用) +4. トークナイザーのダウンロード + +#### `downloadWhisperWeight(data: str, asynchronous: bool = True, *args, **kwargs) -> dict` + +**責務:** Whisper モデルのダウンロード開始(CTranslate2 と同様の構造) + +--- + +### 12. 自動デバイス選択 + +#### `applyAutoMicSelect() -> None` + +**責務:** マイクの自動選択機能を適用 + +**処理:** +1. コールバック設定: + - `device_manager.setCallbackProcessBeforeUpdateMicDevices(self.stopAccessMicDevices)` + - `device_manager.setCallbackDefaultMicDevice(self.updateSelectedMicDevice)` + - `device_manager.setCallbackProcessAfterUpdateMicDevices(self.restartAccessMicDevices)` +2. デバイス更新を強制実行: `device_manager.forceUpdateAndSetMicDevices()` +3. 監視開始: `device_manager.startMonitoring()` + +**動作フロー:** +``` +デバイス変更検出 + ↓ +stopAccessMicDevices() ← デバイス使用中の処理を停止 + ↓ +updateSelectedMicDevice() ← 新しいデフォルトデバイスを選択 + ↓ +restartAccessMicDevices() ← 新しいデバイスで処理を再開 +``` + +#### `setEnableAutoMicSelect(*args, **kwargs) -> dict` +自動マイク選択を有効化。 + +#### `setDisableAutoMicSelect(*args, **kwargs) -> dict` +自動マイク選択を無効化。両方の自動選択が無効になった場合のみ監視を停止。 + +**対応するスピーカー用メソッド:** +- `applyAutoSpeakerSelect()` +- `setEnableAutoSpeakerSelect()` +- `setDisableAutoSpeakerSelect()` + +--- + +### 13. 言語・翻訳エンジン管理 + +#### `updateTranslationEngineAndEngineList() -> None` + +**責務:** 選択された言語に応じて利用可能な翻訳エンジンを更新 + +**処理:** +1. 現在のタブの選択エンジンを取得 +2. `getTranslationEngines()` で利用可能なエンジンリストを取得 +3. 選択中のエンジンが利用不可の場合、CTranslate2 にフォールバック +4. **特殊ケース:** 入力言語と出力言語が同一の場合: + - CTranslate2 のみ利用可能(音訳のみ) +5. フロントエンドに通知 + +#### `getTranslationEngines(*args, **kwargs) -> dict` + +**責務:** 現在の言語設定で利用可能な翻訳エンジンを返却 + +**ロジック:** +1. `model.findTranslationEngines()` で言語ペアをサポートするエンジンを検索 +2. 入力言語と出力言語が同一の場合: + - CTranslate2 が有効なら ["CTranslate2"] + - それ以外は [] + +#### `setSelectedYourLanguages(select: dict, *args, **kwargs) -> dict` +入力言語を設定し、`updateTranslationEngineAndEngineList()` を呼び出す。 + +#### `setSelectedTargetLanguages(select: dict, *args, **kwargs) -> dict` +出力言語を設定し、`updateTranslationEngineAndEngineList()` を呼び出す。 + +#### `swapYourLanguageAndTargetLanguage(*args, **kwargs) -> dict` + +**責務:** 入力言語と出力言語を入れ替え + +**処理:** +1. 現在のタブの入力言語と出力言語(最初の1つ)を取得 +2. 相互に入れ替え +3. `setSelectedYourLanguages()` と `setSelectedTargetLanguages()` を呼び出し +4. 両方の結果を返却 + +--- + +### 14. 音声認識エンジン管理 + +#### `updateTranscriptionEngine() -> None` + +**責務:** Whisper モデルの利用可能状況に応じて音声認識エンジンを更新 + +**処理:** +1. 現在選択されている Whisper モデルの存在確認 +2. 利用可能なエンジンリストを取得 +3. 現在のエンジンが利用不可の場合: + - Whisper ⇔ Google で切り替え + - どちらも利用不可なら Whisper にフォールバック + +#### `updateDownloadedWhisperModelWeight() -> None` + +**責務:** ダウンロード済み Whisper モデルの一覧を更新 + +**処理:** +全てのモデルタイプについて `model.checkTranscriptionWhisperModelWeight()` で存在確認。 + +--- + +### 15. OSC 通信制御 + +#### `setOscIpAddress(data, *args, **kwargs) -> dict` + +**責務:** VRChat への送信先 IP アドレスを設定 + +**処理:** +1. `isValidIpAddress()` でバリデーション +2. `model.setOscIpAddress()` で設定を適用 +3. OSC Query の状態に応じて再初期化: + - 有効な場合: `enableOscQuery()` を呼び出し + - 無効な場合: `disableOscQuery()` を呼び出し + - マイクミュート同期が有効だった場合は無効化して通知 + +**エラーハンドリング:** +- IP アドレスが無効: status 400 +- 設定適用失敗: 元の IP に戻して status 400 + +#### `setOscPort(data, *args, **kwargs) -> dict` +OSC ポート番号を設定。 + +#### `enableOscQuery() -> None` +OSC Query 機能が有効になったことをフロントエンドに通知。 + +#### `disableOscQuery(mute_sync_info: bool = False) -> None` +OSC Query 機能が無効になったことを通知。無効化された機能リストも送信。 + +--- + +### 16. DeepL API 認証 + +#### `setDeeplAuthKey(data, *args, **kwargs) -> dict` + +**責務:** DeepL API キーを設定し、認証を実行 + +**処理:** +1. キー長のバリデーション(36 または 39 文字) +2. `model.authenticationTranslatorDeepLAuthKey()` で認証 +3. 認証成功時: + - `config.AUTH_KEYS["DeepL_API"]` に保存 + - `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["DeepL_API"]` を True に + - `updateTranslationEngineAndEngineList()` を呼び出し +4. 認証失敗時: status 400 を返却 + +#### `delDeeplAuthKey(*args, **kwargs) -> dict` + +**責務:** DeepL API キーを削除 + +**処理:** +1. `config.AUTH_KEYS["DeepL_API"]` を None に +2. `config.SELECTABLE_TRANSLATION_ENGINE_STATUS["DeepL_API"]` を False に +3. `updateTranslationEngineAndEngineList()` を呼び出し + +--- + +### 17. WebSocket サーバー制御 + +#### `setWebSocketHost(data, *args, **kwargs) -> dict` + +**責務:** WebSocket サーバーのホストアドレスを変更 + +**処理:** +1. `isValidIpAddress()` でバリデーション +2. サーバーが停止中の場合: + - 設定のみ変更 +3. サーバーが起動中の場合: + - 新しいホストが利用可能か確認(`isAvailableWebSocketServer()`) + - サーバーを停止 → 再起動 + - 利用不可の場合は status 400 + +#### `setWebSocketPort(data, *args, **kwargs) -> dict` +WebSocket サーバーのポート番号を変更(ロジックは `setWebSocketHost()` と同様)。 + +#### `setEnableWebSocketServer(*args, **kwargs) -> dict` + +**責務:** WebSocket サーバーを起動 + +**処理:** +1. 既に起動中なら何もしない +2. ホストとポートが利用可能か確認 +3. `model.startWebSocketServer()` で起動 +4. 利用不可の場合は status 400 + +#### `setDisableWebSocketServer(*args, **kwargs) -> dict` +WebSocket サーバーを停止。 + +--- + +### 18. VRChat マイクミュート同期 + +#### `setEnableVrcMicMuteSync(*args, **kwargs) -> dict` + +**責務:** VRChat のマイクミュート状態と音声認識の連動を有効化 + +**前提条件:** OSC Query が有効であること + +**処理:** +1. OSC Query が無効の場合は status 400 を返却 +2. `model.setMuteSelfStatus()`: 現在のミュート状態を取得 +3. `model.changeMicTranscriptStatus()`: ミュート状態に応じて音声認識を制御 +4. `config.VRC_MIC_MUTE_SYNC = True` + +#### `setDisableVrcMicMuteSync(*args, **kwargs) -> dict` +マイクミュート同期を無効化し、`model.changeMicTranscriptStatus()` を呼び出す。 + +--- + +### 19. Watchdog 管理 + +Watchdog は UI とバックエンド間の通信監視機能。UI からの定期的な "feed" 信号がない場合、バックエンドを強制終了する。 + +#### `startWatchdog(*args, **kwargs) -> dict` +Watchdog を起動。 + +#### `feedWatchdog(*args, **kwargs) -> dict` +Watchdog にハートビート信号を送信(UI が定期的に呼び出す)。 + +#### `setWatchdogCallback(callback) -> dict` +Watchdog タイムアウト時に呼び出すコールバック関数を設定。`mainloop.stop()` が渡される。 + +#### `stopWatchdog(*args, **kwargs) -> dict` +Watchdog を停止。 + +--- + +### 20. ソフトウェアアップデート + +#### `checkSoftwareUpdated() -> dict` + +**責務:** 最新バージョンの確認 + +**処理:** +1. `model.checkSoftwareUpdated()` でバージョン情報を取得 +2. フロントエンドに通知(`software_update_info` エンドポイント) +3. 結果を返却 + +**バージョン情報形式:** +```python +{ + "current_version": "1.2.3", + "latest_version": "1.2.4", + "update_available": True, + "download_url": "https://..." +} +``` + +#### `updateSoftware(*args, **kwargs) -> dict` + +**責務:** 通常版のアップデートを実行 + +**処理:** +1. 別スレッドで `model.updateSoftware()` を起動(ブロッキングを避けるため) +2. 即座に status 200 を返却 + +#### `updateCudaSoftware(*args, **kwargs) -> dict` + +**責務:** CUDA版のアップデートを実行 + +**処理:** `updateSoftware()` と同様だが、`model.updateCudaSoftware()` を呼び出す。 + +--- + +### 21. 初期化処理 + +#### `init(*args, **kwargs) -> None` + +**責務:** アプリケーションの完全な初期化 + +**処理フロー:** + +**1. ログのクリア** +```python +removeLog() +printLog("Start Initialization") +``` + +**2. ネットワーク接続確認** +```python +connected_network = isConnectedNetwork() +if connected_network: + self.connectedNetwork() +else: + self.disconnectedNetwork() +``` + +**3. モデルウェイトのダウンロード(進捗1/4)** +```python +self.initializationProgress(1) +if connected_network: + # CTranslate2 と Whisper を並列ダウンロード + th_download_ctranslate2 = Thread(target=self.downloadCtranslate2Weight, args=(weight_type, False)) + th_download_whisper = Thread(target=self.downloadWhisperWeight, args=(weight_type, False)) + th_download_ctranslate2.start() + th_download_whisper.start() + th_download_ctranslate2.join() + th_download_whisper.join() +``` + +**4. AI モデル状態の確認** +```python +if (model.checkTranslatorCTranslate2ModelWeight(...) is False or + model.checkTranscriptionWhisperModelWeight(...) is False): + self.disableAiModels() +else: + self.enableAiModels() +``` + +**5. 翻訳・音声認識エンジンの初期化(進捗2/4)** +```python +self.initializationProgress(2) +# 翻訳エンジン +for engine in config.SELECTABLE_TRANSLATION_ENGINE_LIST: + match engine: + case "CTranslate2": + # モデルウェイトの存在確認 + case "DeepL_API": + # API キーの認証 + case _: + # ネットワーク接続が必要なエンジン + +# 音声認識エンジン +for engine in config.SELECTABLE_TRANSCRIPTION_ENGINE_LIST: + # 同様のロジック +``` + +**6. エンジンと音訳の設定(進捗3/4)** +```python +self.updateDownloadedCTranslate2ModelWeight() +self.updateTranslationEngineAndEngineList() +self.updateDownloadedWhisperModelWeight() +self.updateTranscriptionEngine() + +if config.CONVERT_MESSAGE_TO_ROMAJI or config.CONVERT_MESSAGE_TO_HIRAGANA: + model.startTransliteration() +``` + +**7. 周辺機能の初期化(進捗4/4)** +```python +self.initializationProgress(4) +model.addKeywords() # ワードフィルター +self.checkSoftwareUpdated() # バージョンチェック +if config.LOGGER_FEATURE: + model.startLogger() # ログ記録 +model.startReceiveOSC() # OSC 受信 + +# OSC Query +osc_query_enabled = model.getIsOscQueryEnabled() +if osc_query_enabled: + self.enableOscQuery() + if config.VRC_MIC_MUTE_SYNC: + self.setEnableVrcMicMuteSync() +else: + # マイクミュート同期を無効化 + self.disableOscQuery(...) +``` + +**8. デバイス管理の初期化** +```python +device_manager.setCallbackHostList(self.updateMicHostList) +device_manager.setCallbackMicDeviceList(self.updateMicDeviceList) +device_manager.setCallbackSpeakerDeviceList(self.updateSpeakerDeviceList) + +if config.AUTO_MIC_SELECT: + self.applyAutoMicSelect() +if config.AUTO_SPEAKER_SELECT: + self.applyAutoSpeakerSelect() +``` + +**9. オーバーレイと WebSocket の起動** +```python +if config.OVERLAY_SMALL_LOG or config.OVERLAY_LARGE_LOG: + model.startOverlay() + +if config.WEBSOCKET_SERVER: + if isAvailableWebSocketServer(...): + model.startWebSocketServer(...) +``` + +**10. 設定の同期と完了** +```python +self.updateConfigSettings() # 全設定をフロントエンドに送信 +printLog("End Initialization") +self.startWatchdog() # 監視開始 +``` + +--- + +## エラーハンドリング戦略 + +### 1. VRAM不足エラー + +**検出箇所:** +- 翻訳実行時(`micMessage()`, `speakerMessage()`, `chatMessage()`) +- 翻訳機能有効化時(`setEnableTranslation()`) +- 音声認識開始時(`startTranscriptionSendMessage()`, `startTranscriptionReceiveMessage()`) + +**処理:** +1. `model.detectVRAMError(e)` で VRAM エラーを検出 +2. 該当機能を無効化 +3. フロントエンドにエラー通知 +4. ログファイルに記録 + +**自動リカバリ:** +- 翻訳機能: 無効化して継続 +- 音声認識: 停止して継続 + +### 2. デバイスアクセスエラー + +**検出箇所:** +- マイク・スピーカーのアクセス時 + +**処理:** +1. `energy` が `False` の場合 +2. `error_device` エンドポイントにエラー通知 +3. 処理を継続(他の機能は影響を受けない) + +### 3. ネットワークエラー + +**検出箇所:** +- 翻訳APIの呼び出し時 +- モデルウェイトのダウンロード時 + +**処理:** +1. 外部API エラー: `changeToCTranslate2Process()` で CTranslate2 に切り替え +2. ダウンロードエラー: エラー通知を送信、AI機能を無効化 + +### 4. 設定バリデーションエラー + +**処理:** +- status 400 とエラーメッセージを返却 +- 現在の有効な設定値を `data` フィールドに含める + +**例:** +```python +{ + "status": 400, + "result": { + "message": "Mic energy threshold value is out of range", + "data": 1000 # 現在の有効な値 + } +} +``` + +--- + +## スレッド安全性 + +### 排他制御 + +#### デバイスアクセス制御 + +**問題:** 複数の機能が同時にデバイスにアクセスすると衝突 + +**解決策:** `device_access_status` フラグによる排他制御 +```python +while self.device_access_status is False: + sleep(1) # 待機 +self.device_access_status = False # ロック取得 +try: + # デバイスアクセス処理 +finally: + self.device_access_status = True # ロック解放 +``` + +**使用箇所:** +- `startTranscriptionSendMessage()` +- `startTranscriptionReceiveMessage()` +- `startCheckMicEnergy()` +- `startCheckSpeakerEnergy()` + +### デーモンスレッド + +**すべてのワーカースレッドは `daemon = True`:** +- メインスレッド終了時に自動的に終了 +- 明示的な join は必要に応じて実行(停止処理等) + +**例:** +```python +th_startTranscriptionSendMessage = Thread(target=self.startTranscriptionSendMessage) +th_startTranscriptionSendMessage.daemon = True +th_startTranscriptionSendMessage.start() +``` + +--- + +## パフォーマンス考慮事項 + +### 1. 非同期ダウンロード + +**初期化時:** 同期ダウンロード(`asynchronous=False`) +- UI をブロックして確実にダウンロード完了を待つ + +**ユーザー操作時:** 非同期ダウンロード(`asynchronous=True`) +- 別スレッドで実行し、進捗バーで通知 + +### 2. 並列初期化 + +CTranslate2 と Whisper のダウンロードを並列実行: +```python +th_download_ctranslate2.start() +th_download_whisper.start() +th_download_ctranslate2.join() +th_download_whisper.join() +``` + +### 3. モデルの遅延ロード + +翻訳モデルは `setEnableTranslation()` が呼ばれるまでロードされない。 + +--- + +## 依存関係 + +### 外部モジュール + +```python +from typing import Callable, Any, List, Optional +from time import sleep +from subprocess import Popen +from threading import Thread +import re +``` + +### 内部モジュール + +```python +from device_manager import device_manager +from config import config +from model import model +from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValidIpAddress, isAvailableWebSocketServer +``` + +--- + +## 設定項目の分類 + +### UI関連(約20項目) +- 透明度、スケーリング、フォント、言語、ウィンドウ位置等 + +### 音声認識関連(約30項目) +- デバイス選択、閾値、タイムアウト、フィルター等 + +### 翻訳関連(約25項目) +- エンジン選択、言語ペア、モデルタイプ、計算デバイス等 + +### OSC通信関連(約15項目) +- IP アドレス、ポート、メッセージフォーマット、送信設定等 + +### オーバーレイ関連(約10項目) +- 表示設定、位置、サイズ、透明度等 + +### その他(約20項目) +- WebSocket、ログ、ホットキー、プラグイン等 + +**合計:** 約120の設定項目(getter/setter で約240メソッド) + +--- + +## 制限事項 + +### 1. グローバル状態依存 + +すべての設定が `config` モジュールのグローバル変数として管理されている。 +- **利点:** シンプルなアクセス +- **欠点:** テスタビリティの低下、並列実行時の競合リスク + +### 2. 同期レスポンスの制限 + +ほとんどのメソッドが同期的にレスポンスを返すため、重い処理(モデルロード等)は UI をブロックする可能性がある。 + +**対策:** 重い処理は別スレッドで実行し、完了通知は `self.run()` で送信 + +### 3. エラー回復の限界 + +一部のエラー(VRAM不足等)は自動回復するが、設定ファイル破損やモデルファイル破損等は手動対処が必要。 + +--- + +## テストシナリオ + +### 1. 初期化テスト + +**ケース:** +- ネットワーク接続あり・なし +- モデルウェイトあり・なし +- 不正な設定値 + +**確認項目:** +- 全エンジンの状態が正しく設定されているか +- エラーがログに記録されているか +- フロントエンドに正しい初期設定が送信されているか + +### 2. 音声認識テスト + +**ケース:** +- デバイス切り替え中に音声認識 +- VRAM不足エラーの発生 +- 重複メッセージのフィルタリング + +**確認項目:** +- 排他制御が正しく動作しているか +- エラー発生時に適切にリカバリしているか + +### 3. 翻訳テスト + +**ケース:** +- 複数の翻訳エンジンの切り替え +- API制限エラー +- 除外ワードの処理 + +**確認項目:** +- エンジン切り替えが正しく動作するか +- 除外ワードが正しく復元されるか + +### 4. 設定変更テスト + +**ケース:** +- 無効な値の設定 +- 依存関係のある設定の変更 +- 有効/無効の切り替え + +**確認項目:** +- バリデーションが正しく動作するか +- 依存する設定が自動更新されるか + +--- + +## 今後の拡張性 + +### 1. 非同期化の推進 + +`asyncio` への移行で UI ブロッキングを完全に排除。 + +### 2. 依存性注入 + +`config` と `model` を DI コンテナで管理し、テスタビリティを向上。 + +### 3. イベント駆動アーキテクチャ + +設定変更時のイベントを発火し、各サブシステムが独立して反応。 + +### 4. エラーリカバリの強化 + +- 自動再試行メカニズム +- フォールバック設定の自動適用 +- エラー発生時の部分的な機能継続 + +--- + +## 関連ファイル + +- **mainloop.py** - 通信レイヤー、リクエストルーティング +- **model.py** - ビジネスロジックのファサード +- **config.py** - 設定管理 +- **device_manager.py** - デバイス監視・自動選択 +- **utils.py** - ログとユーティリティ関数 + +--- + +## コーディング規約 + +- **PEP 8 スタイルガイド** +- **型ヒント:** `typing` モジュールを使用 +- **Docstring:** Google スタイル(一部未実装) +- **静的メソッド:** 状態を持たないメソッドは `@staticmethod` +- **エラーハンドリング:** 防御的プログラミングを徹底 + +--- + +## まとめ + +`controller.py` は VRCT の中核となるビジネスロジック制御レイヤーであり、約120の設定項目と約200のエンドポイントを管理する。フロントエンドとバックエンドの橋渡しとして、設定の取得・更新、機能の有効化・無効化、エラーハンドリング、デバイス管理など、アプリケーション全体の動作を制御する。排他制御とスレッド管理により、複数の機能が同時に動作する環境でも安定性を保っている。VRAM不足エラーや外部APIエラーに対する自動リカバリ機能により、ユーザーエクスペリエンスの向上を実現している。 diff --git a/src-python/docs/details/backend_test.md b/src-python/docs/details/backend_test.md new file mode 100644 index 00000000..af0413c0 --- /dev/null +++ b/src-python/docs/details/backend_test.md @@ -0,0 +1,95 @@ +# backend_test.py - APIエンドポイントテストモジュール + +## 概要 +VRCTアプリケーションのAPIエンドポイントを包括的にテストするためのモジュールです。メインループの各種機能をランダムアクセスでテストし、システムの安定性と堅牢性を検証します。 + +## 主要機能 + +### Color クラス +- ANSIエスケープシーケンスを使用したコンソール出力色彩管理 +- テスト結果の視覚的表示(成功・失敗・スキップ等) + +### TestMainloop クラス +- APIエンドポイントの包括的テスト実行 +- ランダムアクセステスト +- テスト結果の記録・分析 +- VRCTメインループとの統合テスト + +## 主要メソッド + +### テスト実行メソッド +- `test_endpoints_on_off_all()`: ON/OFF系エンドポイントの全テスト +- `test_set_data_endpoints_all()`: データ設定系エンドポイントの全テスト +- `test_run_endpoints_all()`: 実行系エンドポイントの全テスト +- `test_endpoints_all_random()`: 全エンドポイントのランダムアクセステスト + +### 特定機能テスト +- `test_translate_all_language_pairs()`: 全言語ペアでの翻訳テスト +- `test_endpoints_on_off_continuous()`: ON/OFF連続切り替えテスト +- `test_endpoints_specific_random()`: 特定エンドポイントのランダムテスト + +### 結果分析 +- `generate_summary()`: テスト結果のサマリー生成 +- `record_test_result()`: テスト結果の記録 + +## 使用方法 + +### 基本的な使い方 +```python +# テストインスタンスを作成 +test = TestMainloop() + +# 各種テストを実行 +test.test_endpoints_on_off_all() +test.test_set_data_endpoints_all() +test.test_run_endpoints_all() + +# テスト結果のサマリー表示 +test.generate_summary() +``` + +### ランダムテストの実行 +```python +# 全エンドポイントのランダムアクセステスト +test.test_endpoints_all_random() + +# 特定エンドポイントのランダムテスト +test.test_endpoints_specific_random() +``` + +## 依存関係 +- `mainloop`: VRCTメインループモジュール +- `random`: ランダムテストデータ生成 +- `time`: テスト間隔制御 + +## テスト対象エンドポイント + +### 制御系 +- `/set/enable/*`: 機能有効化 +- `/set/disable/*`: 機能無効化 + +### データ設定系 +- `/set/data/*`: 各種設定データの更新 + +### 実行系 +- `/run/*`: 各種機能の実行 + +### データ削除系 +- `/delete/data/*`: データの削除 + +## 注意事項 +- テスト実行前に`config.json`を削除して初期化 +- 重いAIモデルを使用するテストは実行時間に注意 +- ランダムテストは指定回数(デフォルト1000-10000回)実行される +- テスト終了時は自動的にすべての機能を無効化する + +## エラーハンドリング +- 各テストは独立して実行され、一つの失敗が全体に影響しない +- 期待されるステータスコードと実際の結果を比較 +- VRAM不足等のリソースエラーも適切にハンドリング + +## テスト結果の分類 +- **PASS**: 期待されるステータスコードと一致 +- **ERROR**: 期待されるステータスコードと不一致 +- **SKIP**: テスト実行不可(401ステータス) +- **Invalid**: 無効なエンドポイント(404ステータス) \ No newline at end of file diff --git a/src-python/docs/details/config.md b/src-python/docs/details/config.md new file mode 100644 index 00000000..fd619d77 --- /dev/null +++ b/src-python/docs/details/config.md @@ -0,0 +1,392 @@ +# config.py - 設定管理モジュール + +## 概要 + +VRCTアプリケーションの全設定を一元管理するモジュールです。シングルトンパターンを採用し、アプリケーション全体で統一された設定アクセスを提供します。JSON設定ファイルの読み書き、設定の永続化、デバウンス機能付き保存機能を提供します。 + +## 主要機能 + +### シングルトン設計 +- アプリケーション全体で単一の設定インスタンス +- スレッドセーフな設定アクセス +- 遅延初期化による軽量インポート + +### 設定永続化 +- JSON形式での設定ファイル管理 +- デバウンス機能付き自動保存 +- 設定変更の即座反映 + +### 動的設定管理 +- 実行時設定変更対応 +- デバイス情報の動的取得 +- 言語・エンジン設定の自動更新 + +### 型安全な設定アクセス +- プロパティベースのアクセス制御 +- 読み取り専用・読み書き可能設定の分離 +- デコレータによるシリアライゼーション管理 + +## クラス構造 + +### Config クラス +```python +class Config: + _instance = None # シングルトンインスタンス + _config_data: Dict[str, Any] # 設定データ + _timer: Optional[threading.Timer] # デバウンスタイマー + _debounce_time: int = 2 # デバウンス時間(秒) +``` + +## 設定カテゴリ + +### 読み取り専用設定 + +```python +@property +def VERSION(self) -> str +``` +- アプリケーションバージョン + +```python +@property +def PATH_LOCAL(self) -> str +``` +- ローカルディレクトリパス + +```python +@property +def PATH_CONFIG(self) -> str +``` +- 設定ファイルパス + +### UI・表示設定 + +```python +@property +def UI_LANGUAGE(self) -> str +``` +- UIの表示言語 + +```python +@property +def TRANSPARENCY(self) -> int +``` +- ウィンドウの透明度(0-100) + +```python +@property +def UI_SCALING(self) -> int +``` +- UIのスケーリング(50-200%) + +```python +@property +def FONT_FAMILY(self) -> str +``` +- 使用フォントファミリー + +### 翻訳設定 + +```python +@property +def ENABLE_TRANSLATION(self) -> bool +``` +- 翻訳機能の有効・無効 + +```python +@property +def SELECTED_TRANSLATION_ENGINES(self) -> Dict[str, str] +``` +- 選択されている翻訳エンジン + +```python +@property +def SELECTED_YOUR_LANGUAGES(self) -> Dict[str, Dict[str, Any]] +``` +- 送信言語設定 + +```python +@property +def SELECTED_TARGET_LANGUAGES(self) -> Dict[str, Dict[str, Any]] +``` +- 受信言語設定 + +### 音声認識設定 + +```python +@property +def ENABLE_TRANSCRIPTION_SEND(self) -> bool +``` +- 送信音声認識の有効・無効 + +```python +@property +def SELECTED_TRANSCRIPTION_ENGINE(self) -> str +``` +- 音声認識エンジン + +```python +@property +def SELECTED_MIC_DEVICE(self) -> str +``` +- 選択されたマイクデバイス + +```python +@property +def MIC_THRESHOLD(self) -> int +``` +- マイク音声しきい値 + +```python +@property +def MIC_RECORD_TIMEOUT(self) -> int +``` +- マイク録音タイムアウト(秒) + +### VR設定 + +```python +@property +def OVERLAY_SMALL_LOG(self) -> bool +``` +- 小型ログオーバーレイの有効・無効 + +```python +@property +def OVERLAY_SMALL_LOG_SETTINGS(self) -> Dict[str, Any] +``` +- 小型オーバーレイの詳細設定 + +```python +@property +def OVERLAY_LARGE_LOG_SETTINGS(self) -> Dict[str, Any] +``` +- 大型オーバーレイの詳細設定 + +### 通信設定 + +```python +@property +def OSC_IP_ADDRESS(self) -> str +``` +- OSC通信IPアドレス + +```python +@property +def OSC_PORT(self) -> int +``` +- OSC通信ポート + +```python +@property +def WEBSOCKET_HOST(self) -> str +``` +- WebSocketサーバーホスト + +```python +@property +def WEBSOCKET_PORT(self) -> int +``` +- WebSocketサーバーポート + +### 計算デバイス設定 + +```python +@property +def SELECTED_TRANSLATION_COMPUTE_DEVICE(self) -> Dict[str, Any] +``` +- 翻訳用計算デバイス + +```python +@property +def SELECTED_TRANSCRIPTION_COMPUTE_DEVICE(self) -> Dict[str, Any] +``` +- 音声認識用計算デバイス + +## 主要メソッド + +### 設定保存 + +```python +saveConfig(key: str, value: Any, immediate_save: bool = False) -> None +``` +- 設定値の保存(デバウンス付き) +- immediate_save=Trueで即座保存 + +```python +saveConfigToFile() -> None +``` +- 設定ファイルへの直接保存 + +### 初期化・設定読み込み + +```python +init_config() -> None +``` +- 設定の初期化 +- デフォルト値の設定 + +```python +load_config() -> None +``` +- 設定ファイルからの読み込み +- 存在しない場合はデフォルト設定を作成 + +## デコレータ機能 + +### @json_serializable +```python +@json_serializable("setting_name") +@property +def SETTING_NAME(self) -> Any: +``` +- 設定のJSONシリアライゼーション対象指定 +- 自動的にconfig.jsonに保存される設定を定義 + +## 使用方法 + +### 基本的な使い方 + +```python +from config import config + +# 設定値の取得 +version = config.VERSION +ui_language = config.UI_LANGUAGE +translation_enabled = config.ENABLE_TRANSLATION + +# 設定値の変更 +config.UI_LANGUAGE = "ja" +config.TRANSPARENCY = 80 +config.MIC_THRESHOLD = 1500 +``` + +### 複雑な設定の変更 + +```python +# 翻訳エンジンの設定 +engines = config.SELECTED_TRANSLATION_ENGINES +engines["1"] = "DeepL" +config.SELECTED_TRANSLATION_ENGINES = engines + +# オーバーレイ設定の変更 +overlay_settings = config.OVERLAY_SMALL_LOG_SETTINGS +overlay_settings["x_pos"] = 0.5 +overlay_settings["opacity"] = 0.8 +config.OVERLAY_SMALL_LOG_SETTINGS = overlay_settings +``` + +### 即座保存 + +```python +# 重要な設定変更時の即座保存 +config.saveConfig("ENABLE_TRANSLATION", True, immediate_save=True) +``` + +## 設定ファイル形式 + +設定は`config.json`ファイルにJSON形式で保存されます: + +```json +{ + "UI_LANGUAGE": "ja", + "TRANSPARENCY": 85, + "UI_SCALING": 100, + "ENABLE_TRANSLATION": true, + "SELECTED_TRANSLATION_ENGINES": { + "1": "DeepL", + "2": "Google", + "3": "CTranslate2" + }, + "OVERLAY_SMALL_LOG_SETTINGS": { + "x_pos": 0.0, + "y_pos": -0.4, + "z_pos": 1.0, + "opacity": 1.0, + "ui_scaling": 1.0, + "display_duration": 5, + "fadeout_duration": 1 + } +} +``` + +## デフォルト設定 + +### UI設定 +- UI言語: "en"(英語) +- 透明度: 85% +- UIスケーリング: 100% +- フォント: "Noto Sans JP" + +### 翻訳設定 +- 翻訳機能: 無効 +- デフォルトエンジン: "Google" +- 送信言語: English(US) +- 受信言語: 日本語 + +### 音声認識設定 +- 送信音声認識: 無効 +- 受信音声認識: 無効 +- 音声認識エンジン: "Google" +- マイクしきい値: 300 + +### VR設定 +- 小型オーバーレイ: 無効 +- 大型オーバーレイ: 無効 +- オーバーレイ位置: HMD正面 + +### 通信設定 +- OSC IP: "127.0.0.1" +- OSC ポート: 9000 +- WebSocket ホスト: "127.0.0.1" +- WebSocket ポート: 8765 + +## 依存関係 + +### 必須依存関係 +- `json`: 設定ファイルのシリアライゼーション +- `threading`: デバウンス機能 +- `typing`: 型注釈 + +### オプション依存関係 +- `device_manager`: デバイス情報取得 +- `torch`: CUDA計算デバイス情報 +- 各種モデルモジュール: 言語・エンジン情報 + +## エラーハンドリング + +- 設定ファイル読み込みエラーの適切な処理 +- 不正な設定値の検証・補正 +- オプション依存関係の欠如に対するフォールバック +- ファイル書き込みエラーの処理 + +## パフォーマンス特性 + +### デバウンス機能 +- 設定変更から2秒後に自動保存 +- 連続する変更の統合 +- I/O負荷の軽減 + +### 遅延初期化 +- 重い依存関係の遅延読み込み +- インポート時間の短縮 + +### メモリ効率 +- 設定データのシングルトン管理 +- 不要な複製の防止 + +## 注意事項 + +- 設定変更は即座にメモリに反映される +- ファイル保存はデバウンス機能により遅延される +- 重要な設定はimmediate_save=Trueを使用 +- オプション依存関係の欠如時はデフォルト値を使用 +- 不正な設定値は自動的に補正される +- 設定ファイルが破損した場合は新規作成される + +## セキュリティ考慮事項 + +- 設定ファイルの適切な権限管理 +- 外部入力値の検証 +- APIキー等の機密情報の適切な取り扱い +- パスインジェクション攻撃の防止 \ No newline at end of file diff --git a/src-python/docs/details/controller.md b/src-python/docs/details/controller.md new file mode 100644 index 00000000..ca1fefda --- /dev/null +++ b/src-python/docs/details/controller.md @@ -0,0 +1,349 @@ +# controller.py - VRCTコントローラーモジュール + +## 概要 + +VRCTアプリケーションのビジネスロジックを制御するコントローラークラスです。UI層とモデル層の間に位置し、ユーザーの入力を適切な処理に変換し、結果を UI に返す役割を担います。全ての機能制御、設定管理、状態管理を一元的に行います。 + +## 主要機能 + +### 機能制御 +- 翻訳機能の有効化・無効化 +- 音声認識機能の制御 +- VRオーバーレイの管理 +- WebSocketサーバーの制御 + +### 設定管理 +- アプリケーション設定の取得・更新 +- デバイス設定の管理 +- 言語・エンジン設定の制御 + +### 状態管理 +- システム状態の監視 +- エラー状態の管理 +- 初期化プロセスの制御 + +### 通信制御 +- OSC通信の管理 +- WebSocket通信の制御 +- 外部アプリケーション連携 + +## クラス構造 + +### Controller クラス +```python +class Controller: + def __init__(self) -> None +``` + +中核となるコントローラークラス + +### 内部ヘルパークラス + +#### DownloadCTranslate2 クラス +```python +class DownloadCTranslate2: + def progressBar(self, progress) -> None + def downloaded(self) -> None +``` +- 翻訳モデルのダウンロード進捗管理 + +#### DownloadWhisper クラス +```python +class DownloadWhisper: + def progressBar(self, progress) -> None + def downloaded(self) -> None +``` +- 音声認識モデルのダウンロード進捗管理 + +## 主要メソッド + +### 初期化・設定 + +```python +init() -> None +``` +- コントローラーの初期化 +- 各コンポーネントの起動 +- 初期設定の適用 + +```python +setInitMapping(init_mapping: dict) -> None +setRunMapping(run_mapping: dict) -> None +setRun(run: Callable) -> None +``` +- エンドポイント・コールバック設定 + +### 翻訳機能制御 + +```python +setEnableTranslation(data) -> dict +setDisableTranslation(data) -> dict +``` +- 翻訳機能の有効化・無効化 + +```python +setSelectedTranslationEngines(data) -> dict +getSelectedTranslationEngines(data) -> dict +``` +- 翻訳エンジンの選択・取得 + +```python +setSelectedYourLanguages(data) -> dict +setSelectedTargetLanguages(data) -> dict +``` +- 送信・受信言語の設定 + +```python +sendMessageBox(data) -> dict +``` +- メッセージの翻訳・送信処理 + +### 音声認識機能制御 + +```python +setEnableTranscriptionSend(data) -> dict +setEnableTranscriptionReceive(data) -> dict +``` +- 音声認識機能の有効化 + +```python +setSelectedTranscriptionEngine(data) -> dict +getSelectedTranscriptionEngine(data) -> dict +``` +- 音声認識エンジンの選択・取得 + +```python +setSelectedMicDevice(data) -> dict +setSelectedSpeakerDevice(data) -> dict +``` +- 音声デバイスの選択 + +```python +setMicThreshold(data) -> dict +setSpeakerThreshold(data) -> dict +``` +- 音声しきい値の設定 + +### VRオーバーレイ制御 + +```python +setEnableOverlaySmallLog(data) -> dict +setEnableOverlayLargeLog(data) -> dict +``` +- VRオーバーレイの有効化 + +```python +setOverlaySmallLogSettings(data) -> dict +setOverlayLargeLogSettings(data) -> dict +``` +- オーバーレイ設定の更新 + +### WebSocket制御 + +```python +setEnableWebSocketServer(data) -> dict +setDisableWebSocketServer(data) -> dict +``` +- WebSocketサーバーの制御 + +```python +setWebSocketHost(data) -> dict +setWebSocketPort(data) -> dict +``` +- WebSocket接続設定 + +### システム管理 + +```python +updateSoftware(data) -> dict +updateCudaSoftware(data) -> dict +``` +- ソフトウェアアップデート + +```python +downloadCtranslate2Weight(data) -> dict +downloadWhisperWeight(data) -> dict +``` +- AIモデルのダウンロード + +```python +feedWatchdog(data) -> dict +``` +- ウォッチドッグの生存シグナル送信 + +## 使用方法 + +### 基本的な使い方 + +```python +from controller import Controller + +# コントローラーの初期化 +controller = Controller() +controller.init() + +# 翻訳機能の有効化 +result = controller.setEnableTranslation(None) +print(f"翻訳機能: {result}") + +# メッセージ送信 +message_data = {"id": "123", "message": "Hello World"} +result = controller.sendMessageBox(message_data) +``` + +### エンドポイント設定 + +```python +# マッピング設定 +mapping = { + "/set/enable/translation": controller.setEnableTranslation, + "/get/data/version": controller.getVersion, +} + +# 実行関数の設定 +def run_callback(status, endpoint, result): + print(f"Status: {status}, Endpoint: {endpoint}, Result: {result}") + +controller.setRun(run_callback) +``` + +### 音声認識の設定 + +```python +# マイクデバイスの選択 +host_data = "DirectSound" +result = controller.setSelectedMicHost(host_data) + +device_data = "マイク (USB Audio Device)" +result = controller.setSelectedMicDevice(device_data) + +# 音声認識の開始 +result = controller.setEnableTranscriptionSend(None) +``` + +## レスポンス形式 + +全てのメソッドは統一されたレスポンス形式を返します: + +```python +{ + "status": int, # HTTPステータスコード(200, 400, 500等) + "result": any # 処理結果(成功時)または エラーメッセージ(失敗時) +} +``` + +### 成功レスポンス例 +```python +{ + "status": 200, + "result": "翻訳機能が有効化されました" +} +``` + +### エラーレスポンス例 +```python +{ + "status": 400, + "result": "Invalid device selection" +} +``` + +## 状態管理 + +### システム状態 +- 各機能の有効・無効状態 +- デバイスの接続状態 +- ネットワーク接続状態 + +### エラー状態 +- デバイスエラー +- 翻訳エンジンエラー +- VRAMオーバーフローエラー + +### 初期化状態 +- 段階的な初期化プロセス +- 依存関係の解決状態 + +## イベント処理 + +### 音声認識イベント + +```python +micMessage(result: dict) -> None +``` +- マイク音声認識結果の処理 +- 翻訳・フィルタリング・送信 + +```python +speakerMessage(result: dict) -> None +``` +- スピーカー音声認識結果の処理 + +### ダウンロードイベント +- 進捗通知 +- 完了通知 +- エラー通知 + +### デバイス変更イベント +- マイク・スピーカーの選択変更 +- 計算デバイスの変更 + +## 依存関係 + +### 直接依存 +- `config`: 設定管理 +- `model`: コアモデル機能 +- `device_manager`: デバイス管理 +- `utils`: ユーティリティ機能 + +### 間接依存 +- 各種モデルモジュール(翻訳、音声認識等) +- VRオーバーレイモジュール +- 通信モジュール + +## エラーハンドリング + +### VRAM不足エラー +- 自動的にCTranslate2への切り替え +- ユーザーへの適切な通知 + +### デバイスエラー +- デバイス接続状態の監視 +- 自動復旧機能 + +### ネットワークエラー +- 接続状態の定期確認 +- オフライン機能への切り替え + +### 設定エラー +- 設定値の妥当性チェック +- デフォルト値への復帰 + +## パフォーマンス考慮事項 + +### 遅延初期化 +- 必要な時点での機能初期化 +- メモリ使用量の最適化 + +### 非同期処理 +- バックグラウンドでの重い処理 +- UI の応答性維持 + +### キャッシュ機能 +- 設定値のキャッシュ +- 翻訳結果のキャッシュ + +## 注意事項 + +- すべてのメソッドは例外安全である +- 設定変更は即座に config に反映される +- 重い処理は別スレッドで実行される +- VR機能は適切な環境でのみ動作する +- ネットワーク機能はオフライン時に制限される + +## セキュリティ考慮事項 + +- 外部入力の適切な検証 +- APIキーの安全な管理 +- ファイルアクセスの制限 +- ネットワーク通信の暗号化(該当する場合) \ No newline at end of file diff --git a/src-python/docs/details/mainloop.md b/src-python/docs/details/mainloop.md new file mode 100644 index 00000000..22a086ed --- /dev/null +++ b/src-python/docs/details/mainloop.md @@ -0,0 +1,275 @@ +# mainloop.py - VRCTメインループモジュール + +## 概要 + +VRCTアプリケーションのメインイベントループを管理するモジュールです。標準入力からのJSONリクエストを処理し、適切なコントローラーメソッドを呼び出してレスポンスを返す、アプリケーションの中枢的な役割を担います。 + +## 主要機能 + +### リクエスト処理システム +- JSON形式の標準入力からのリクエスト受信 +- エンドポイントベースのルーティング +- 非同期・並列処理対応 + +### エンドポイント管理 +- RESTライクなエンドポイント構造 +- 機能別のエンドポイント分類 +- 排他制御によるスレッドセーフティ + +### 初期化システム +- アプリケーション設定の初期化 +- コンポーネント間の依存関係解決 +- 段階的な機能有効化 + +## クラス構造 + +### Main クラス +```python +class Main: + def __init__(self, controller_instance: Controller, mapping_data: dict, worker_count: int = 3) +``` + +- メインループの制御 +- ワーカースレッドプール管理 +- エンドポイント排他制御 + +## エンドポイント分類 + +### 機能制御系 +``` +/set/enable/* - 各機能の有効化 +/set/disable/* - 各機能の無効化 +``` + +### データ操作系 +``` +/get/data/* - 設定データの取得 +/set/data/* - 設定データの更新 +/delete/data/* - データの削除 +``` + +### 実行系 +``` +/run/* - 各種処理の実行 +``` + +## 主要エンドポイント + +### 翻訳機能 +- `/set/enable/translation`: 翻訳機能の有効化 +- `/set/disable/translation`: 翻訳機能の無効化 +- `/set/data/selected_translation_engines`: 翻訳エンジンの選択 +- `/run/send_message_box`: メッセージ送信 + +### 音声認識機能 +- `/set/enable/transcription_send`: 送信音声認識の有効化 +- `/set/enable/transcription_receive`: 受信音声認識の有効化 +- `/set/data/selected_transcription_engine`: 音声認識エンジン選択 + +### VR機能 +- `/set/data/overlay_small_log_settings`: 小型オーバーレイ設定 +- `/set/data/overlay_large_log_settings`: 大型オーバーレイ設定 + +### WebSocket機能 +- `/set/enable/websocket_server`: WebSocketサーバー有効化 +- `/set/data/websocket_host`: サーバーホスト設定 +- `/set/data/websocket_port`: サーバーポート設定 + +### システム管理 +- `/run/update_software`: ソフトウェアアップデート +- `/run/download_ctranslate2_weight`: 翻訳モデルダウンロード +- `/run/download_whisper_weight`: 音声認識モデルダウンロード + +## 主要メソッド + +### リクエスト処理 + +```python +receiver() -> None +``` +- 標準入力からのJSONリクエスト受信 +- パースエラーの適切な処理 + +```python +handleRequest(endpoint: str, data: Any = None) -> tuple +``` +- エンドポイント処理の実行 +- ステータスコードと結果の返却 + +```python +handler() -> None +``` +- ワーカースレッドのメイン処理 +- キューからのリクエスト取得・処理 + +### スレッド管理 + +```python +startReceiver() -> None +``` +- レシーバースレッドの起動 + +```python +startHandler() -> None +``` +- ハンドラースレッドプールの起動 + +```python +start() -> None +``` +- 全スレッドの起動 + +```python +stop(wait: float = 2.0) -> None +``` +- 全スレッドの安全な停止 + +## 使用方法 + +### 基本的な使い方 + +```python +from mainloop import main_instance + +# メインループの開始 +main_instance.start() + +# ウォッチドッグコールバックの設定 +main_instance.controller.setWatchdogCallback(main_instance.stop) + +# コントローラーの初期化 +main_instance.controller.init() +``` + +### 直接リクエスト処理 + +```python +# エンドポイントの直接呼び出し +result, status = main_instance.handleRequest("/get/data/version", None) +print(f"バージョン: {result}") + +# 翻訳機能の有効化 +result, status = main_instance.handleRequest("/set/enable/translation", None) +``` + +### 標準入力からの処理 + +```json +{ + "endpoint": "/run/send_message_box", + "data": "eyJpZCI6ICIxMjMiLCAibWVzc2FnZSI6ICJIZWxsbyBXb3JsZCJ9" +} +``` + +## リクエスト形式 + +### 入力形式 +```json +{ + "endpoint": "string", // 必須:処理対象のエンドポイント + "data": "string|null" // オプション:Base64エンコード済みデータ +} +``` + +### 出力形式 +```json +{ + "status": 200, // HTTPステータスコード + "endpoint": "string", // 処理されたエンドポイント + "result": "any" // 処理結果 +} +``` + +## ステータスコード + +- `200`: 成功 +- `400`: 不正なリクエスト +- `404`: 存在しないエンドポイント +- `423`: ロック中(機能が無効化されている) +- `500`: 内部エラー + +## 排他制御 + +### ロック機能 +- enable/disableペアは同一ロックキーを共有 +- 同一機能の同時実行を防止 +- デッドロックを回避する設計 + +### ロックキー正規化 +```python +/set/enable/translation -> /lock/set/translation +/set/disable/translation -> /lock/set/translation +``` + +## 初期化プロセス + +### 段階的初期化 +1. コントローラーの初期化 +2. デバイスマネージャーの初期化 +3. モデルの初期化 +4. 各機能の段階的有効化 + +### 初期化mapping +- `/get/data/*`エンドポイントから初期化設定を自動抽出 +- システム起動時の設定復元 + +## ログ機能 + +### プロセスログ +- 全リクエスト・レスポンスの記録 +- JSON形式での構造化ログ + +### エラーログ +- 例外の詳細記録 +- スタックトレースの保存 + +## 依存関係 + +### 直接依存 +- `controller`: ビジネスロジック制御 +- `utils`: ユーティリティ機能(ログ、エンコード等) + +### 間接依存 +- `config`: 設定管理 +- `model`: コアモデル機能 +- `device_manager`: デバイス管理 + +## 設定項目 + +### ワーカー数 +```python +DEFAULT_WORKER_COUNT = 3 # 並列処理スレッド数 +``` + +### タイムアウト +- キュー待機タイムアウト: 0.5秒 +- スレッド停止待機: 2.0秒 +- 処理安定化待機: 0.2秒 + +## エラーハンドリング + +- JSONパースエラーの適切な処理 +- エンドポイント実行エラーのキャッチ +- スレッドセーフなエラーログ記録 +- グレースフルシャットダウン + +## パフォーマンス特性 + +### スループット +- 複数ワーカーによる並列処理 +- ノンブロッキングI/O + +### レイテンシ +- キューイング遅延の最小化 +- 排他制御による一時的な遅延あり + +### メモリ使用量 +- リクエストキューのサイズ制限なし(要注意) +- スレッドプールによる固定オーバーヘッド + +## 注意事項 + +- 標準入力をブロッキングで読み取るため、パイプ経由での使用を想定 +- エンドポイント名の大文字小文字は区別される +- Base64データは自動的にデコードされる +- 長時間のブロッキング処理は他のリクエストに影響する可能性 \ No newline at end of file diff --git a/src-python/docs/details/model.md b/src-python/docs/details/model.md new file mode 100644 index 00000000..9880730c --- /dev/null +++ b/src-python/docs/details/model.md @@ -0,0 +1,292 @@ +# model.py - VRCTコアモデルクラス + +## 概要 + +VRCTアプリケーションの中核となるModelクラスを定義するモジュールです。音声認識、翻訳、VRオーバーレイ、OSC通信、WebSocketサーバーなどの主要機能を統合管理し、システム全体の動作を制御します。 + +## 主要機能 + +### シングルトンパターン +- アプリケーション全体で単一のModelインスタンスを保証 +- 遅延初期化による軽量なインポート + +### 音声認識機能 +- マイク音声のリアルタイム文字起こし +- スピーカー出力の音声認識 +- エネルギーレベル監視 +- 複数言語対応 + +### 翻訳機能 +- 複数の翻訳エンジン対応(DeepL、Google、CTranslate2等) +- 言語自動検出 +- バッチ翻訳処理 + +### VRオーバーレイ +- OpenVR統合 +- 小型・大型ログオーバーレイ +- 動的配置・透明度制御 + +### OSC通信 +- VRChatとのOSC通信 +- タイピング状態の同期 +- ミュート状態の監視 + +### WebSocketサーバー +- 外部アプリケーションとの通信 +- リアルタイムメッセージ配信 + +## クラス構造 + +### threadFnc クラス +```python +class threadFnc(Thread): + def __init__(self, fnc, end_fnc=None, daemon: bool = True, *args, **kwargs) +``` + +- 関数を繰り返し実行するスレッドラッパー +- 一時停止・再開機能 +- エラー保護機能 + +### Model クラス +```python +class Model: + def __new__(cls) # シングルトンパターン + def init(self) # 重い初期化処理 + def ensure_initialized(self) # 遅延初期化 +``` + +## 主要メソッド + +### 初期化・管理 + +```python +init() -> None +``` +- 全コンポーネントの初期化 +- 重い処理のため明示的に呼び出し + +```python +ensure_initialized() -> None +``` +- 必要時の自動初期化 +- 安全な遅延初期化 + +### 翻訳機能 + +```python +getInputTranslate(message, source_language=None) -> Tuple[List[str], List[bool]] +``` +- 入力メッセージの多言語翻訳 +- 成功フラグも同時に返却 + +```python +getOutputTranslate(message, source_language=None) -> Tuple[List[str], List[bool]] +``` +- 出力メッセージの翻訳(逆方向) + +```python +authenticationTranslatorDeepLAuthKey(auth_key) -> bool +``` +- DeepL APIキーの認証 + +### 音声認識機能 + +```python +startMicTranscript(fnc: Callable) -> None +``` +- マイク音声認識の開始 +- コールバック関数で結果を通知 + +```python +startSpeakerTranscript(fnc: Callable) -> None +``` +- スピーカー音声認識の開始 + +```python +pauseMicTranscript() -> None +resumeMicTranscript() -> None +``` +- 音声認識の一時停止・再開 + +```python +startCheckMicEnergy(fnc: Callable) -> None +startCheckSpeakerEnergy(fnc: Callable) -> None +``` +- 音声エネルギーレベルの監視 + +### VRオーバーレイ機能 + +```python +createOverlayImageSmallLog(message, your_language, translation, target_language) -> Image +``` +- 小型ログオーバーレイ画像の生成 + +```python +createOverlayImageLargeLog(message_type, message, your_language, translation, target_language) -> Image +``` +- 大型ログオーバーレイ画像の生成 + +```python +updateOverlaySmallLogSettings() -> None +updateOverlayLargeLogSettings() -> None +``` +- オーバーレイ設定の更新 + +### OSC通信機能 + +```python +oscSendMessage(message: str) -> None +``` +- VRChatへのメッセージ送信 + +```python +oscStartSendTyping() -> None +oscStopSendTyping() -> None +``` +- タイピング状態の通知 + +```python +setMuteSelfStatus() -> None +``` +- VRChatミュート状態の取得 + +### WebSocket機能 + +```python +startWebSocketServer(host: str, port: int) -> None +``` +- WebSocketサーバーの起動 + +```python +websocketSendMessage(message_dict: dict) -> bool +``` +- 全クライアントへのメッセージ送信 + +```python +checkWebSocketServerAlive() -> bool +``` +- サーバー稼働状態の確認 + +### ファイルダウンロード機能 + +```python +downloadCTranslate2ModelWeight(weight_type, callback=None, end_callback=None) +``` +- 翻訳モデルのダウンロード + +```python +downloadWhisperModelWeight(weight_type, callback=None, end_callback=None) +``` +- 音声認識モデルのダウンロード + +### ウォッチドッグ機能 + +```python +startWatchdog() -> None +feedWatchdog() -> None +setWatchdogCallback(callback: Callable) -> None +``` +- システム監視とタイムアウト処理 + +## 使用方法 + +### 基本的な使い方 + +```python +from model import model + +# 明示的な初期化(推奨) +model.init() + +# または自動初期化 +model.ensure_initialized() + +# 翻訳機能の使用 +translations, success_flags = model.getInputTranslate("Hello World") + +# 音声認識の開始 +def on_transcript_result(result): + print(f"認識結果: {result}") + +model.startMicTranscript(on_transcript_result) +``` + +### VRオーバーレイの使用 + +```python +# オーバーレイの開始 +model.startOverlay() + +# 画像の作成と更新 +img = model.createOverlayImageSmallLog( + message="Hello", + your_language="English", + translation=["こんにちは"], + target_language={"1": {"language": "Japanese", "enable": True}} +) +model.updateOverlaySmallLog(img) +``` + +### WebSocketサーバーの使用 + +```python +# サーバー起動 +model.startWebSocketServer("127.0.0.1", 8765) + +# メッセージ送信 +message = {"type": "translation", "text": "Hello", "translation": "こんにちは"} +success = model.websocketSendMessage(message) +``` + +## 依存関係 + +### 必須モジュール +- `controller`: アプリケーション制御 +- `config`: 設定管理 +- `device_manager`: デバイス管理 + +### 音声・翻訳関連 +- `models.transcription.*`: 音声認識 +- `models.translation.*`: 翻訳機能 +- `models.transliteration.*`: 音写変換 + +### VR・通信関連 +- `models.overlay.*`: VRオーバーレイ +- `models.osc.*`: OSC通信 +- `models.websocket.*`: WebSocket通信 + +### ユーティリティ +- `models.watchdog.*`: 監視機能 +- `utils`: 共通ユーティリティ +- `flashtext`: キーワードフィルタリング + +## 設定依存関係 + +多くの機能がconfigモジュールの設定に依存: + +- 音声認識設定(しきい値、タイムアウト等) +- 翻訳設定(エンジン選択、言語設定等) +- VR設定(オーバーレイ位置、透明度等) +- OSC設定(IPアドレス、ポート等) + +## エラーハンドリング + +- 初期化エラーの適切な処理 +- VRAM不足エラーの検出と対応 +- ネットワークエラーの回復機能 +- スレッドセーフティの保証 + +## 注意事項 + +- 重い初期化処理のため、明示的な初期化を推奨 +- OpenVR環境が必要(VRオーバーレイ使用時) +- CUDA環境推奨(高速な音声認識・翻訳) +- WebSocketサーバーは非同期で動作 +- 音声デバイスのアクセス権限が必要 + +## パフォーマンス考慮事項 + +- 遅延初期化によるメモリ使用量の最適化 +- スレッドプールによる並行処理 +- モデルの重複読み込み防止 +- キューイングによる非同期処理 \ No newline at end of file diff --git a/src-python/docs/details/osc.md b/src-python/docs/details/osc.md new file mode 100644 index 00000000..72ffefa3 --- /dev/null +++ b/src-python/docs/details/osc.md @@ -0,0 +1,602 @@ +# osc.py - OSC通信・OSCQueryプロトコル管理 + +## 概要 + +VRChatとの高度なOSC(Open Sound Control)通信を管理する包括的なシステムです。基本的なOSCメッセージ送信に加え、OSCQueryプロトコルによる双方向通信、パラメータ監視、自動サービス発見機能を提供します。 + +## 主要機能 + +### OSC通信機能 +- VRChatチャットボックスへのメッセージ送信 +- タイピング状態の制御 +- パラメータ値の動的取得 + +### OSCQuery対応 +- 自動サービス発見・接続 +- リアルタイムパラメータ監視 +- 双方向エンドポイント公開 + +### 堅牢性機能 +- 防御的プログラミング設計 +- 欠損ライブラリの優雅な処理 +- 自動エラー復旧機構 + +## クラス構造 + +### OSCHandler クラス + +```python +class OSCHandler: + def __init__(self, ip_address: str = "127.0.0.1", port: int = 9000) -> None: + self.is_osc_query_enabled: bool + self.osc_ip_address: str + self.osc_port: int + self.udp_client: udp_client.SimpleUDPClient + self.osc_server: Optional[osc_server.ThreadingOSCUDPServer] + self.osc_query_service: Optional[OSCQueryService] + self.browser: Optional[OSCQueryBrowser] +``` + +OSC通信の中核管理クラス + +#### 属性 +- **is_osc_query_enabled**: OSCQuery機能の有効性フラグ +- **osc_ip_address**: 送信先IPアドレス +- **osc_port**: UDP通信ポート +- **udp_client**: OSC送信クライアント +- **osc_server**: ローカルOSCサーバー +- **osc_query_service**: OSCQueryサービスインスタンス +- **browser**: OSCQueryブラウザー + +## 主要メソッド + +### メッセージ送信 + +```python +def sendMessage(self, message: str = "", notification: bool = True) -> None +``` + +VRChatチャットボックスにメッセージを送信 + +#### パラメータ +- **message**: 送信するテキストメッセージ +- **notification**: 通知フラグ(音・表示の有無) + +```python +def sendTyping(self, flag: bool = False) -> None +``` + +タイピング状態をVRChatに送信 + +#### パラメータ +- **flag**: タイピング中フラグ + +### パラメータ監視 + +```python +def getOSCParameterMuteSelf() -> Optional[bool] +``` + +VRChatのMuteSelfパラメータ値を取得 + +#### 戻り値 +- **Optional[bool]**: ミュート状態(取得失敗時はNone) + +```python +def getOSCParameterValue(self, address: str) -> Any +``` + +任意のOSCパラメータ値を取得 + +#### パラメータ +- **address**: OSCアドレス(例:"/avatar/parameters/MuteSelf") + +#### 戻り値 +- **Any**: パラメータ値(取得失敗時はNone) + +### 設定変更 + +```python +def setOscIpAddress(self, ip_address: str) -> None +``` + +送信先IPアドレスを変更し、サービスを再初期化 + +#### パラメータ +- **ip_address**: 新しいIPアドレス + +```python +def setOscPort(self, port: int) -> None +``` + +送信ポートを変更し、サービスを再初期化 + +#### パラメータ +- **port**: 新しいUDPポート番号 + +## 使用方法 + +### 基本的なメッセージ送信 + +```python +from models.osc.osc import OSCHandler + +# OSCハンドラーの初期化 +osc = OSCHandler(ip_address="127.0.0.1", port=9000) + +# チャットボックスにメッセージを送信 +osc.sendMessage("こんにちは、VRChat!", notification=True) + +# タイピング状態の制御 +osc.sendTyping(True) # タイピング開始 +# ... 実際のタイピング処理 ... +osc.sendTyping(False) # タイピング終了 + +# 再度メッセージ送信 +osc.sendMessage("翻訳完了しました", notification=False) +``` + +### リモートVRChatへの接続 + +```python +# リモートVRChatインスタンスへの接続 +remote_osc = OSCHandler(ip_address="192.168.1.100", port=9000) + +# OSCQuery機能は自動的に無効化される +print(f"OSCQuery有効: {remote_osc.getIsOscQueryEnabled()}") # False + +# 基本的なメッセージ送信は利用可能 +remote_osc.sendMessage("リモートからの翻訳結果", notification=True) +``` + +### パラメータ監視(ローカル接続時のみ) + +```python +# ローカル接続でのパラメータ監視 +local_osc = OSCHandler(ip_address="127.0.0.1", port=9000) + +if local_osc.getIsOscQueryEnabled(): + # MuteSelfパラメータの監視 + mute_status = local_osc.getOSCParameterMuteSelf() + + if mute_status is not None: + if mute_status: + print("ユーザーはミュート中です") + else: + print("ユーザーはミュート解除中です") + else: + print("MuteSelfパラメータの取得に失敗しました") + + # カスタムパラメータの監視 + custom_value = local_osc.getOSCParameterValue("/avatar/parameters/CustomParam") + if custom_value is not None: + print(f"カスタムパラメータ値: {custom_value}") +``` + +### 双方向OSC通信の設定 + +```python +def handle_mute_change(address, *args): + """ミュート状態変更のハンドラー""" + print(f"ミュート状態が変更されました: {args}") + +def handle_typing_change(address, *args): + """タイピング状態変更のハンドラー""" + print(f"タイピング状態: {args}") + +def handle_chatbox_input(address, *args): + """チャットボックス入力のハンドラー""" + print(f"チャットボックス入力: {args}") + +# OSCパラメータハンドラーの設定 +osc_handlers = { + "/avatar/parameters/MuteSelf": handle_mute_change, + "/chatbox/typing": handle_typing_change, + "/chatbox/input": handle_chatbox_input +} + +osc = OSCHandler() +osc.setDictFilterAndTarget(osc_handlers) + +# OSCサーバー開始(OSCQuery自動公開) +osc.receiveOscParameters() + +print("OSC受信サーバーが開始されました") +print("VRChatからのパラメータ変更を監視中...") + +# メッセージ送信テスト +import time +time.sleep(2) +osc.sendMessage("双方向通信テスト", notification=True) + +# 長時間実行 +time.sleep(30) + +# クリーンアップ +osc.oscServerStop() +``` + +### 動的設定変更 + +```python +# 実行時のIP・ポート変更 +osc = OSCHandler(ip_address="127.0.0.1", port=9000) + +# 初期設定でローカル接続 +osc.sendMessage("ローカル接続テスト") + +print("リモート接続に切り替え中...") +osc.setOscIpAddress("192.168.1.150") # 自動的にOSCQueryが無効化 +osc.sendMessage("リモート接続テスト") + +print("ポート変更...") +osc.setOscPort(9001) +osc.sendMessage("新しいポートでのテスト") + +print("ローカル接続に戻る...") +osc.setOscIpAddress("127.0.0.1") # OSCQueryが再度有効化 +osc.sendMessage("ローカル接続復帰テスト") +``` + +## OSCQuery詳細機能 + +### 自動サービス発見 + +```python +class VRChatMonitor: + """VRChatサービス監視クラス""" + + def __init__(self): + self.osc = OSCHandler() + self.monitoring = False + + def start_monitoring(self): + """VRChatパラメータの継続監視開始""" + + if not self.osc.getIsOscQueryEnabled(): + print("OSCQuery機能が無効です(ローカル接続のみサポート)") + return + + # OSCハンドラー設定 + handlers = { + "/avatar/parameters/MuteSelf": self.on_mute_change, + "/avatar/parameters/Voice": self.on_voice_change, + "/avatar/parameters/Viseme": self.on_viseme_change, + "/avatar/parameters/GestureLeft": self.on_gesture_left, + "/avatar/parameters/GestureRight": self.on_gesture_right + } + + self.osc.setDictFilterAndTarget(handlers) + self.osc.receiveOscParameters() + + self.monitoring = True + print("VRChatパラメータ監視を開始しました") + + def on_mute_change(self, address, *args): + print(f"ミュート状態変更: {args[0] if args else 'Unknown'}") + + def on_voice_change(self, address, *args): + print(f"音声レベル: {args[0] if args else 'Unknown'}") + + def on_viseme_change(self, address, *args): + print(f"口形変化: {args[0] if args else 'Unknown'}") + + def on_gesture_left(self, address, *args): + print(f"左手ジェスチャー: {args[0] if args else 'Unknown'}") + + def on_gesture_right(self, address, *args): + print(f"右手ジェスチャー: {args[0] if args else 'Unknown'}") + + def stop_monitoring(self): + """監視停止""" + self.osc.oscServerStop() + self.monitoring = False + print("VRChatパラメータ監視を停止しました") + +# 使用例 +monitor = VRChatMonitor() +monitor.start_monitoring() + +# 監視中に他の処理を実行 +time.sleep(60) # 1分間監視 + +monitor.stop_monitoring() +``` + +### リアルタイムパラメータ追跡 + +```python +class ParameterTracker: + """パラメータ値の追跡・履歴管理""" + + def __init__(self, osc_handler): + self.osc = osc_handler + self.parameter_history = {} + self.tracking_active = False + + def track_parameter(self, address, interval=0.1): + """指定されたパラメータを定期監視""" + + import threading + + def monitoring_loop(): + while self.tracking_active: + try: + value = self.osc.getOSCParameterValue(address) + if value is not None: + timestamp = time.time() + + if address not in self.parameter_history: + self.parameter_history[address] = [] + + # 値が変更された場合のみ記録 + if (not self.parameter_history[address] or + self.parameter_history[address][-1][1] != value): + + self.parameter_history[address].append((timestamp, value)) + print(f"{address}: {value} (時刻: {timestamp:.2f})") + + # 履歴サイズ制限(最新100件まで) + if len(self.parameter_history[address]) > 100: + self.parameter_history[address] = self.parameter_history[address][-100:] + + time.sleep(interval) + + except Exception as e: + print(f"パラメータ追跡エラー: {e}") + time.sleep(interval) + + self.tracking_active = True + thread = threading.Thread(target=monitoring_loop, daemon=True) + thread.start() + + def stop_tracking(self): + """追跡停止""" + self.tracking_active = False + + def get_parameter_history(self, address): + """パラメータの履歴取得""" + return self.parameter_history.get(address, []) + + def get_latest_value(self, address): + """最新パラメータ値取得""" + history = self.get_parameter_history(address) + return history[-1][1] if history else None + +# 使用例 +osc = OSCHandler() +tracker = ParameterTracker(osc) + +# MuteSelfパラメータの追跡開始 +tracker.track_parameter("/avatar/parameters/MuteSelf", interval=0.5) + +# しばらく監視 +time.sleep(30) + +# 結果確認 +mute_history = tracker.get_parameter_history("/avatar/parameters/MuteSelf") +print(f"MuteSelf変更履歴: {len(mute_history)}件") + +for timestamp, value in mute_history[-5:]: # 最新5件表示 + print(f" {time.ctime(timestamp)}: {value}") + +tracker.stop_tracking() +``` + +## エラーハンドリング・復旧機構 + +### 堅牢な接続管理 + +```python +class RobustOSCHandler: + """堅牢性を高めたOSCハンドラー""" + + def __init__(self, ip_address="127.0.0.1", port=9000): + self.osc = OSCHandler(ip_address, port) + self.connection_retries = 3 + self.retry_delay = 1.0 + + def safe_send_message(self, message, notification=True, max_retries=None): + """安全なメッセージ送信(リトライ機構付き)""" + + retries = max_retries or self.connection_retries + + for attempt in range(retries): + try: + self.osc.sendMessage(message, notification) + return True + + except Exception as e: + print(f"送信試行 {attempt + 1}/{retries} 失敗: {e}") + + if attempt < retries - 1: + time.sleep(self.retry_delay * (attempt + 1)) # 指数バックオフ + + # 接続再初期化を試行 + try: + self.osc.udp_client = udp_client.SimpleUDPClient( + self.osc.osc_ip_address, + self.osc.osc_port + ) + except Exception as reconnect_error: + print(f"再接続失敗: {reconnect_error}") + + print(f"メッセージ送信に失敗しました: '{message}'") + return False + + def safe_get_parameter(self, address, timeout=5.0): + """安全なパラメータ取得(タイムアウト付き)""" + + if not self.osc.getIsOscQueryEnabled(): + return None + + import threading + import queue + + result_queue = queue.Queue() + + def parameter_getter(): + try: + value = self.osc.getOSCParameterValue(address) + result_queue.put(value) + except Exception as e: + result_queue.put(e) + + # タイムアウト付きでパラメータ取得 + thread = threading.Thread(target=parameter_getter, daemon=True) + thread.start() + + try: + result = result_queue.get(timeout=timeout) + if isinstance(result, Exception): + raise result + return result + + except queue.Empty: + print(f"パラメータ取得タイムアウト: {address}") + return None + +# 使用例 +robust_osc = RobustOSCHandler() + +# 堅牢な送信 +success = robust_osc.safe_send_message("堅牢性テスト", notification=True) +print(f"送信成功: {success}") + +# 安全なパラメータ取得 +mute_value = robust_osc.safe_get_parameter("/avatar/parameters/MuteSelf", timeout=3.0) +print(f"MuteSelf値: {mute_value}") +``` + +## パフォーマンス最適化 + +### 効率的な通信管理 + +```python +class OptimizedOSCHandler: + """パフォーマンス最適化OSCハンドラー""" + + def __init__(self, ip_address="127.0.0.1", port=9000): + self.osc = OSCHandler(ip_address, port) + self.message_queue = [] + self.batch_size = 10 + self.batch_interval = 0.1 + self.last_batch_time = 0 + + def queue_message(self, message, notification=True): + """メッセージをキューに追加(バッチ送信用)""" + + self.message_queue.append((message, notification)) + + # バッチサイズまたは時間間隔でフラッシュ + current_time = time.time() + + if (len(self.message_queue) >= self.batch_size or + current_time - self.last_batch_time >= self.batch_interval): + self.flush_messages() + + def flush_messages(self): + """キューされたメッセージを一括送信""" + + if not self.message_queue: + return + + # 最新のメッセージのみ送信(重複排除) + if len(self.message_queue) > 1: + # 最後のメッセージを優先 + last_message, last_notification = self.message_queue[-1] + self.osc.sendMessage(last_message, last_notification) + else: + message, notification = self.message_queue[0] + self.osc.sendMessage(message, notification) + + # キューをクリア + self.message_queue.clear() + self.last_batch_time = time.time() + + def send_immediate(self, message, notification=True): + """即座にメッセージ送信(キューをバイパス)""" + self.flush_messages() # 既存キューを先にフラッシュ + self.osc.sendMessage(message, notification) + +# 使用例 +optimized_osc = OptimizedOSCHandler() + +# 複数のメッセージを効率的に送信 +for i in range(20): + optimized_osc.queue_message(f"バッチメッセージ {i}") + time.sleep(0.05) # 短い間隔 + +# 残りのメッセージをフラッシュ +optimized_osc.flush_messages() + +# 即座に送信が必要な重要メッセージ +optimized_osc.send_immediate("緊急メッセージ", notification=True) +``` + +## 依存関係・要件 + +### 必須依存関係 +- `pythonosc`: 基本OSC通信ライブラリ +- `threading`: 並行処理制御 +- `time`: 時間管理機能 + +### オプション依存関係 +- `tinyoscquery`: OSCQuery機能(ローカル接続時のみ) +- `utils`: エラーログ機能(フォールバック処理あり) + +### システム要件 +```python +# 最小システム要件 +requirements = { + "python_version": "3.7+", + "network": "UDP通信対応", + "vrchat_version": "OSCサポート版(2022年8月以降)", + "local_ports": "空きUDP/TCPポート(OSCQuery使用時)" +} + +# 推奨環境 +recommended = { + "network_latency": "< 10ms(ローカル接続)", + "cpu_usage": "OSCQuery使用時は追加CPU負荷", + "memory": "tinyoscquery使用時は追加メモリ" +} +``` + +## 注意事項・制限 + +### OSCQuery制限 +- ローカルホスト(127.0.0.1/localhost)接続時のみ利用可能 +- tinyoscqueryライブラリが必要 +- ファイアウォール設定によっては動作しない可能性 + +### 通信制限 +- UDPプロトコルのため送達保証なし +- VRChatのOSC受信制限(レート制限あり) +- ネットワーク環境による遅延・パケット loss + +### プラットフォーム依存 +```python +# 既知の制限事項 +limitations = { + "windows": "Windowsファイアウォールの設定が必要な場合あり", + "macos": "セキュリティ設定によるポート制限の可能性", + "linux": "一部のLinuxディストリビューションでの互換性問題", + "vrchat_platform": "PC版VRChatのみOSCサポート" +} +``` + +## 関連モジュール + +- `config.py`: OSC設定管理 +- `controller.py`: OSC機能制御インターフェース +- `model.py`: OSC機能統合 +- `utils.py`: エラーログ・ネットワークユーティリティ + +## 将来の改善点 + +- より高度なOSCQueryパラメータ監視 +- カスタムOSCプロトコル拡張 +- パフォーマンス監視・分析機能 +- 自動再接続・復旧機構の改善 +- VRChatアバター固有パラメータ対応 \ No newline at end of file diff --git a/src-python/docs/details/overlay.md b/src-python/docs/details/overlay.md new file mode 100644 index 00000000..e28382a0 --- /dev/null +++ b/src-python/docs/details/overlay.md @@ -0,0 +1,754 @@ +# overlay - VRオーバーレイ統合システム + +## 概要 + +VRChat向けのOpenVRオーバーレイシステムです。翻訳結果や字幕をVR空間内に表示する機能を提供し、HMD・コントローラー追跡、フェード効果、多言語フォント対応を統合的に管理します。 + +## 主要コンポーネント + +### overlay.py - メインオーバーレイ管理 +- OpenVRオーバーレイの生成・配置・制御 +- HMD・左手・右手への追跡設定 +- フェードイン・フェードアウト効果 + +### overlay_image.py - 画像生成・描画 +- 多言語対応テキスト画像生成 +- メッセージログ・履歴表示 +- フォント・レイアウト管理 + +### overlay_utils.py - 数学的変換ユーティリティ +- 3D座標変換行列計算 +- オイラー角・回転行列変換 +- 同次座標系変換 + +## クラス構造 + +### Overlay クラス (overlay.py) + +```python +class Overlay: + def __init__(self, settings_dict: Dict[str, Dict[str, Any]]) -> None: + self.system: Optional[Any] = None # OpenVRシステム + self.overlay: Optional[Any] = None # オーバーレイインターface + self.handle: Dict[str, Any] = {} # サイズ別ハンドル + self.settings: Dict[str, Dict[str, Any]] # サイズ別設定 + self.lastUpdate: Dict[str, float] = {} # 最終更新時刻 + self.fadeRatio: Dict[str, float] = {} # フェード比率 +``` + +VRオーバーレイの総合管理クラス + +#### 主要機能 +- OpenVRの初期化・管理 +- 複数サイズオーバーレイの同時管理 +- リアルタイムフェード効果処理 +- SteamVR接続状態監視 + +### OverlayImage クラス (overlay_image.py) + +```python +class OverlayImage: + LANGUAGES = { + "Default": "NotoSansJP-Regular.ttf", + "Japanese": "NotoSansJP-Regular.ttf", + "Korean": "NotoSansKR-Regular.ttf", + "Chinese Simplified": "NotoSansSC-Regular.ttf", + "Chinese Traditional": "NotoSansTC-Regular.ttf" + } + + def __init__(self, root_path: Optional[str] = None) -> None: + self.message_log: List[dict] = [] + self.root_path: str +``` + +テキスト画像生成・多言語フォント管理クラス + +#### 主要機能 +- 多言語フォント自動選択 +- メッセージ履歴管理 +- 動的画像生成・合成 +- UI要素のサイズ計算 + +## 主要メソッド + +### Overlay クラス + +#### 初期化・制御 + +```python +def startOverlay(self) -> None +``` + +オーバーレイシステム開始 + +```python +def shutdownOverlay(self) -> None +``` + +オーバーレイシステム終了・リソース解放 + +```python +def reStartOverlay(self) -> None +``` + +オーバーレイシステム再起動 + +#### 表示制御 + +```python +def showOverlay(self, image: Image, size: str) -> None +``` + +画像をオーバーレイに表示 + +#### パラメータ +- **image**: 表示するPIL画像 +- **size**: オーバーレイサイズ識別子 + +```python +def setOpacity(self, opacity: float, size: str) -> None +``` + +オーバーレイ透明度設定 + +#### パラメータ +- **opacity**: 透明度(0.0-1.0) +- **size**: 対象サイズ + +```python +def setTrackedDeviceRelative(self, tracker: str, size: str) -> None +``` + +追跡デバイスへのオーバーレイ配置 + +#### パラメータ +- **tracker**: 追跡デバイス("HMD", "LeftHand", "RightHand") +- **size**: オーバーレイサイズ + +### OverlayImage クラス + +#### 画像生成 + +```python +def createOverlayImage(self, message: str, language: str, ui_size: dict, + ui_settings: dict, message_log_settings: dict) -> Image +``` + +オーバーレイ用画像の生成 + +#### パラメータ +- **message**: 表示メッセージ +- **language**: 言語設定 +- **ui_size**: UIサイズ設定 +- **ui_settings**: UI表示設定 +- **message_log_settings**: ログ表示設定 + +#### 戻り値 +- **Image**: 生成されたPIL画像 + +#### 履歴管理 + +```python +def addMessageLog(self, message: str, timestamp: datetime) -> None +``` + +メッセージログに新規追加 + +#### パラメータ +- **message**: 追加するメッセージ +- **timestamp**: タイムスタンプ + +```python +def clearMessageLog(self) -> None +``` + +メッセージログのクリア + +## 使用方法 + +### 基本的なオーバーレイ表示 + +```python +from models.overlay.overlay import Overlay +from models.overlay.overlay_image import OverlayImage +from PIL import Image + +# オーバーレイ設定 +settings = { + "small": { + "width": 0.3, + "height": 0.1, + "x_pos": 0.0, + "y_pos": -0.2, + "z_pos": 1.0, + "opacity": 0.8, + "display_duration": 3.0, + "fadeout_duration": 1.0 + }, + "large": { + "width": 0.5, + "height": 0.2, + "x_pos": 0.0, + "y_pos": -0.3, + "z_pos": 1.2, + "opacity": 0.9, + "display_duration": 5.0, + "fadeout_duration": 1.5 + } +} + +# オーバーレイシステム初期化 +overlay_system = Overlay(settings) +overlay_image = OverlayImage() + +# システム開始 +overlay_system.startOverlay() + +# 翻訳結果の表示 +translation_text = "Hello, world! / こんにちは、世界!" + +# 画像生成設定 +ui_size = OverlayImage.getUiSizeSmallLog() +ui_settings = { + "font_size": 20, + "text_color": (255, 255, 255, 255), + "background_color": (0, 0, 0, 180) +} +message_log_settings = { + "enabled": True, + "max_lines": 5 +} + +# 画像生成・表示 +overlay_img = overlay_image.createOverlayImage( + message=translation_text, + language="Japanese", + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings=message_log_settings +) + +# オーバーレイに表示 +overlay_system.showOverlay(overlay_img, "small") + +# システム終了 +import time +time.sleep(10) +overlay_system.shutdownOverlay() +``` + +### HMD・コントローラー追跡設定 + +```python +# HMDに固定表示 +overlay_system.setTrackedDeviceRelative("HMD", "large") + +# 左手コントローラーに追従 +overlay_system.setTrackedDeviceRelative("LeftHand", "small") + +# 右手コントローラーに追従 +overlay_system.setTrackedDeviceRelative("RightHand", "small") + +# 位置・回転の微調整(設定変更) +overlay_system.settings["small"]["x_pos"] = 0.1 +overlay_system.settings["small"]["y_pos"] = -0.1 +overlay_system.settings["small"]["z_pos"] = 0.8 +overlay_system.settings["small"]["x_rotation"] = -30.0 +overlay_system.settings["small"]["y_rotation"] = 15.0 + +# 設定を適用 +overlay_system.setTrackedDeviceRelative("LeftHand", "small") +``` + +### フェード効果制御 + +```python +# フェード効果設定 +overlay_system.updateDisplayDuration(4.0, "large") # 4秒表示 +overlay_system.updateFadeoutDuration(2.0, "large") # 2秒でフェードアウト + +# 即座に透明度変更 +overlay_system.setOpacity(0.5, "large") # 50%透明度 + +# フェード効果を無効にして固定表示 +overlay_system.settings["small"]["fadeout_duration"] = 0 +overlay_system.setOpacity(1.0, "small") # 完全不透明で固定 +``` + +### 多言語対応表示 + +```python +# 日本語表示 +japanese_text = "これは日本語のテストです" +jp_image = overlay_image.createOverlayImage( + message=japanese_text, + language="Japanese", + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings=message_log_settings +) +overlay_system.showOverlay(jp_image, "large") + +# 韓国語表示 +korean_text = "이것은 한국어 테스트입니다" +kr_image = overlay_image.createOverlayImage( + message=korean_text, + language="Korean", + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings=message_log_settings +) +overlay_system.showOverlay(kr_image, "small") + +# 中国語(簡体字)表示 +chinese_text = "这是中文测试" +cn_image = overlay_image.createOverlayImage( + message=chinese_text, + language="Chinese Simplified", + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings=message_log_settings +) +overlay_system.showOverlay(cn_image, "large") +``` + +### メッセージログ機能 + +```python +from datetime import datetime + +# メッセージログの追加 +overlay_image.addMessageLog("最初のメッセージ", datetime.now()) +overlay_image.addMessageLog("翻訳結果: Hello -> こんにちは", datetime.now()) +overlay_image.addMessageLog("音声認識: こんにちは", datetime.now()) + +# ログ表示設定 +log_settings = { + "enabled": True, + "max_lines": 3, # 最大3行表示 + "show_timestamp": True, # タイムスタンプ表示 + "font_size": 16, + "text_color": (200, 200, 200, 255) +} + +# ログ付きオーバーレイ画像生成 +logged_image = overlay_image.createOverlayImage( + message="新しいメッセージ", + language="Japanese", + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings=log_settings +) + +overlay_system.showOverlay(logged_image, "large") + +# ログクリア +overlay_image.clearMessageLog() +``` + +## 座標系・変換システム + +### 基本座標設定 + +```python +# HMD基準座標(頭部固定表示) +def getHMDBaseMatrix() -> np.ndarray: + x_pos = 0.0 # 左右位置 + y_pos = -0.4 # 上下位置(下方向) + z_pos = 1.0 # 前後位置(前方向) + x_rotation = 0.0 # X軸回転 + y_rotation = 0.0 # Y軸回転 + z_rotation = 0.0 # Z軸回転 + +# 左手コントローラー基準座標 +def getLeftHandBaseMatrix() -> np.ndarray: + x_pos = 0.3 # 右側にオフセット + y_pos = 0.1 # 上方向にオフセット + z_pos = -0.31 # 手前にオフセット + x_rotation = -65.0 # 下向きに傾斜 + y_rotation = 165.0 # Y軸回転 + z_rotation = 115.0 # Z軸回転 + +# 右手コントローラー基準座標 +def getRightHandBaseMatrix() -> np.ndarray: + x_pos = -0.3 # 左側にオフセット + y_rotation = -165.0 # 左手と対称 + z_rotation = -115.0 # 左手と対称 +``` + +### 変換行列計算 (overlay_utils.py) + +```python +import numpy as np +from models.overlay.overlay_utils import * + +# 移動変換 +translation = (0.1, -0.2, 0.5) # x, y, z移動 +translation_matrix = calcTranslationMatrix(translation) + +# 回転変換(各軸独立) +x_rotation_matrix = calcRotationMatrixX(30.0) # X軸30度回転 +y_rotation_matrix = calcRotationMatrixY(45.0) # Y軸45度回転 +z_rotation_matrix = calcRotationMatrixZ(60.0) # Z軸60度回転 + +# オイラー角から回転行列生成 +euler_angles = (30.0, 45.0, 60.0) # X, Y, Z軸回転角度 +rotation_matrix = euler_to_rotation_matrix(euler_angles) + +# 基本行列への変換適用 +base_matrix = getHMDBaseMatrix() +translation = (0.05, -0.1, 0.2) +rotation = (10.0, -5.0, 0.0) +transformed_matrix = transform_matrix(base_matrix, translation, rotation) + +# 3x4行列を4x4同次座標に変換 +homogeneous_matrix = toHomogeneous(transformed_matrix) +``` + +### カスタム配置設定 + +```python +# カスタム位置でのオーバーレイ配置 +def createCustomOverlay(overlay_system, custom_pos, custom_rot, size): + """カスタム位置・回転でのオーバーレイ設定""" + + # 設定を動的に変更 + overlay_system.settings[size]["x_pos"] = custom_pos[0] + overlay_system.settings[size]["y_pos"] = custom_pos[1] + overlay_system.settings[size]["z_pos"] = custom_pos[2] + overlay_system.settings[size]["x_rotation"] = custom_rot[0] + overlay_system.settings[size]["y_rotation"] = custom_rot[1] + overlay_system.settings[size]["z_rotation"] = custom_rot[2] + + # 追跡デバイス設定を再適用 + overlay_system.setTrackedDeviceRelative("HMD", size) + +# 使用例:カスタム配置 +custom_position = (0.2, -0.3, 0.8) # やや右下前方 +custom_rotation = (-15.0, 10.0, 5.0) # 軽く傾斜 +createCustomOverlay(overlay_system, custom_position, custom_rotation, "large") +``` + +## 高度な機能 + +### 動的サイズ・レイアウト管理 + +```python +class AdaptiveOverlayManager: + """適応的オーバーレイ管理クラス""" + + def __init__(self, base_overlay_system, base_overlay_image): + self.overlay = base_overlay_system + self.image_gen = base_overlay_image + self.current_layout = "compact" + + def adaptLayoutToContent(self, message, language): + """コンテンツに応じたレイアウト自動調整""" + + # メッセージ長に応じてサイズ決定 + if len(message) < 50: + layout = "compact" + size_key = "small" + elif len(message) < 150: + layout = "standard" + size_key = "medium" + else: + layout = "expanded" + size_key = "large" + + # 言語に応じたフォントサイズ調整 + if language in ["Chinese Simplified", "Chinese Traditional"]: + font_scale = 1.1 # 中国語は少し大きめ + elif language == "Korean": + font_scale = 1.05 # 韓国語は微調整 + else: + font_scale = 1.0 # 日本語・その他 + + # UI設定の動的生成 + ui_size = self.getAdaptiveUiSize(layout) + ui_settings = { + "font_size": int(18 * font_scale), + "line_height": int(24 * font_scale), + "text_color": (255, 255, 255, 255), + "background_color": (0, 0, 0, 200), + "border_width": 2, + "border_color": (100, 150, 255, 255) + } + + return ui_size, ui_settings, size_key + + def getAdaptiveUiSize(self, layout): + """レイアウトに応じたUIサイズ取得""" + + layouts = { + "compact": { + "width": 400, + "height": 100, + "margin": 10, + "padding": 8 + }, + "standard": { + "width": 600, + "height": 150, + "margin": 15, + "padding": 12 + }, + "expanded": { + "width": 800, + "height": 200, + "margin": 20, + "padding": 16 + } + } + + return layouts.get(layout, layouts["standard"]) + +# 使用例 +adaptive_manager = AdaptiveOverlayManager(overlay_system, overlay_image) + +messages = [ + ("Hello!", "English"), + ("これは中程度の長さのメッセージです。翻訳結果を表示します。", "Japanese"), + ("这是一个很长的消息,用来测试自适应布局功能。当消息内容很长时,系统会自动选择更大的显示区域,并调整字体大小以确保良好的可读性。", "Chinese Simplified") +] + +for message, language in messages: + # 自動レイアウト調整 + ui_size, ui_settings, size_key = adaptive_manager.adaptLayoutToContent(message, language) + + # 画像生成・表示 + adaptive_image = overlay_image.createOverlayImage( + message=message, + language=language, + ui_size=ui_size, + ui_settings=ui_settings, + message_log_settings={"enabled": True, "max_lines": 3} + ) + + overlay_system.showOverlay(adaptive_image, size_key) + time.sleep(3) +``` + +### パフォーマンス監視・最適化 + +```python +class OverlayPerformanceMonitor: + """オーバーレイパフォーマンス監視クラス""" + + def __init__(self, overlay_system): + self.overlay = overlay_system + self.frame_times = [] + self.update_counts = {} + + def monitorFrameRate(self, duration=10.0): + """フレームレート監視""" + + start_time = time.monotonic() + frame_count = 0 + + while time.monotonic() - start_time < duration: + frame_start = time.monotonic() + + # フレーム処理(空の処理) + time.sleep(1/90) # 90Hz目標 + + frame_end = time.monotonic() + self.frame_times.append(frame_end - frame_start) + frame_count += 1 + + # 統計計算 + avg_frame_time = sum(self.frame_times) / len(self.frame_times) + avg_fps = 1.0 / avg_frame_time if avg_frame_time > 0 else 0 + + print(f"平均フレーム時間: {avg_frame_time*1000:.2f}ms") + print(f"平均FPS: {avg_fps:.1f}") + print(f"総フレーム数: {frame_count}") + + return avg_fps + + def optimizeSettings(self, target_fps=60): + """パフォーマンス目標に基づく設定最適化""" + + current_fps = self.monitorFrameRate(5.0) + + if current_fps < target_fps * 0.8: + print("パフォーマンス不足。設定を軽量化します...") + + # フェード処理間隔を延長 + for size in self.overlay.settings: + self.overlay.settings[size]["fadeout_duration"] *= 1.5 + + # 更新頻度を下げる + # (mainloopの sleep_time 調整は overlay.py 内で実装) + + elif current_fps > target_fps * 1.2: + print("パフォーマンスに余裕があります。品質を向上します...") + + # より滑らかなフェード + for size in self.overlay.settings: + self.overlay.settings[size]["fadeout_duration"] *= 0.8 + +# 使用例 +performance_monitor = OverlayPerformanceMonitor(overlay_system) +performance_monitor.monitorFrameRate(10.0) +performance_monitor.optimizeSettings(target_fps=60) +``` + +## エラーハンドリング・復旧 + +### 堅牢な接続管理 + +```python +class RobustOverlaySystem: + """堅牢性を高めたオーバーレイシステム""" + + def __init__(self, settings_dict): + self.base_overlay = Overlay(settings_dict) + self.connection_retries = 3 + self.auto_reconnect = True + + def safeStartOverlay(self, max_retries=None): + """安全なオーバーレイ開始(リトライ機構付き)""" + + retries = max_retries or self.connection_retries + + for attempt in range(retries): + try: + # SteamVR接続確認 + if not self.base_overlay.checkSteamvrRunning(): + print("SteamVRが起動していません。待機中...") + time.sleep(5) + continue + + # オーバーレイ開始 + self.base_overlay.startOverlay() + + # 初期化完了まで待機 + timeout = 10.0 + start_time = time.monotonic() + + while not self.base_overlay.initialized and time.monotonic() - start_time < timeout: + time.sleep(0.1) + + if self.base_overlay.initialized: + print("オーバーレイシステム開始完了") + return True + else: + print(f"初期化タイムアウト(試行 {attempt + 1}/{retries})") + + except Exception as e: + print(f"オーバーレイ開始エラー(試行 {attempt + 1}/{retries}): {e}") + + # 既存システムのクリーンアップ + try: + self.base_overlay.shutdownOverlay() + except Exception: + pass + + if attempt < retries - 1: + time.sleep(2 ** attempt) # 指数バックオフ + + print("オーバーレイシステムの開始に失敗しました") + return False + + def monitorConnection(self): + """接続監視・自動復旧""" + + while self.auto_reconnect: + try: + if self.base_overlay.initialized and not self.base_overlay.checkActive(): + print("OpenVR接続が切断されました。再接続を試行します...") + + self.base_overlay.shutdownOverlay() + time.sleep(2) + + if self.safeStartOverlay(): + print("オーバーレイシステムが復旧しました") + else: + print("復旧に失敗しました") + + time.sleep(1) + + except Exception as e: + print(f"接続監視エラー: {e}") + time.sleep(5) + +# 使用例 +robust_overlay = RobustOverlaySystem(settings) + +# 安全な開始 +if robust_overlay.safeStartOverlay(): + # 接続監視開始(別スレッド) + import threading + monitor_thread = threading.Thread(target=robust_overlay.monitorConnection, daemon=True) + monitor_thread.start() + + # 通常の操作 + overlay_img = overlay_image.createOverlayImage(...) + robust_overlay.base_overlay.showOverlay(overlay_img, "small") +``` + +## 依存関係・システム要件 + +### 必須依存関係 +- `openvr`: OpenVR Python バインディング +- `numpy`: 数値計算・行列演算 +- `PIL (Pillow)`: 画像処理・生成 +- `psutil`: プロセス監視 + +### システム要件 +```python +system_requirements = { + "steamvr": "SteamVR環境必須", + "openvr_runtime": "OpenVR Runtime", + "vr_headset": "対応VRヘッドセット(Oculus, Vive, Index等)", + "graphics": "VR対応GPU", + "python": "Python 3.7以上" +} + +performance_requirements = { + "cpu": "VR処理に十分なCPU性能", + "memory": "追加メモリ使用量 ~100-500MB", + "disk_space": "フォントファイル用容量 ~50MB" +} +``` + +### オプション依存関係 +- `utils.errorLogging`: エラーログ機能(フォールバック処理あり) + +## 注意事項・制限 + +### VR環境制限 +- SteamVRが起動していない場合は動作不可 +- VRヘッドセットが接続されていない場合は制限あり +- OpenVRドライバーの互換性に依存 + +### パフォーマンス制限 +- リアルタイム描画処理によるCPU・GPU負荷 +- フォントレンダリングによるメモリ使用量 +- 高解像度VRディスプレイでの描画負荷 + +### プラットフォーム制限 +```python +platform_limitations = { + "windows": "主要サポートプラットフォーム", + "linux": "SteamVR Linux版での制限あり", + "macos": "SteamVR macOS版サポート終了により制限", + "mobile_vr": "OpenVR非対応のため利用不可" +} +``` + +## 関連モジュール + +- `config.py`: オーバーレイ設定管理 +- `controller.py`: オーバーレイ制御インターフェース +- `model.py`: オーバーレイ機能統合 +- `utils.py`: エラーログ・ユーティリティ + +## 将来の改善点 + +- よりリッチなUI要素対応 +- アニメーション・エフェクト機能 +- カスタムフォント・テーマシステム +- パフォーマンス監視・自動最適化 +- 他のVRプラットフォーム対応検討 \ No newline at end of file diff --git a/src-python/docs/details/transcription_languages.md b/src-python/docs/details/transcription_languages.md new file mode 100644 index 00000000..1425593a --- /dev/null +++ b/src-python/docs/details/transcription_languages.md @@ -0,0 +1,229 @@ +# transcription_languages.py - 音声認識言語マッピング + +## 概要 + +音声認識エンジンが対応する言語コードのマッピングテーブルを提供するモジュールです。異なる音声認識エンジンの言語コード仕様の差異を吸収し、統一的なインターフェースを提供します。 + +## 主要機能 + +### 言語マッピングテーブル +- 表示用言語名から各エンジン固有の言語コードへの変換 +- 国・地域固有の言語バリエーション対応 +- 複数音声認識エンジンの統一的な言語管理 + +### 対応エンジン +- Google Speech Recognition +- OpenAI Whisper(faster-whisper) +- その他の音声認識エンジン + +## データ構造 + +### transcription_lang +```python +transcription_lang: Dict[str, List[Dict[str, str]]] +``` + +言語とその地域バリエーションのマッピング + +```python +transcription_lang = { + "English": [ + {"country": "United States", "google_language_code": "en-US"}, + {"country": "United Kingdom", "google_language_code": "en-GB"}, + {"country": "Australia", "google_language_code": "en-AU"} + ], + "Japanese": [ + {"country": "Japan", "google_language_code": "ja-JP"} + ], + "Korean": [ + {"country": "South Korea", "google_language_code": "ko-KR"} + ] +} +``` + +## 使用方法 + +### 基本的な言語コード取得 + +```python +from models.transcription.transcription_languages import transcription_lang + +# 日本語の言語コード取得 +japanese_codes = transcription_lang.get("Japanese", []) +if japanese_codes: + code = japanese_codes[0]["google_language_code"] # "ja-JP" + +# 英語の地域別言語コード取得 +english_codes = transcription_lang.get("English", []) +for region in english_codes: + print(f"{region['country']}: {region['google_language_code']}") +``` + +### 利用可能言語の一覧取得 + +```python +# 対応言語の一覧 +supported_languages = list(transcription_lang.keys()) +print(f"対応言語: {supported_languages}") + +# 言語と国の組み合わせ一覧 +language_country_pairs = [] +for lang, countries in transcription_lang.items(): + for country_data in countries: + language_country_pairs.append({ + "language": lang, + "country": country_data["country"], + "code": country_data["google_language_code"] + }) +``` + +### 翻訳システムとの連携 + +```python +# 翻訳システムで対応している言語の確認 +from models.translation.translation_languages import translation_lang + +transcription_langs = list(transcription_lang.keys()) +translation_langs = [] +for engine in translation_lang.keys(): + translation_langs.extend(translation_lang[engine]["source"].keys()) + +# 音声認識と翻訳の両方で対応している言語 +supported_langs = list(filter(lambda x: x in transcription_langs, translation_langs)) +``` + +## 主要対応言語 + +### 西欧言語 +- **English**: US, UK, Australia, Canada, India, South Africa +- **Spanish**: Spain, Mexico, Argentina, Colombia +- **French**: France, Canada, Belgium +- **German**: Germany, Austria, Switzerland +- **Italian**: Italy +- **Portuguese**: Brazil, Portugal + +### アジア言語 +- **Japanese**: Japan +- **Korean**: South Korea +- **Chinese**: China (Simplified), Taiwan (Traditional), Hong Kong +- **Thai**: Thailand +- **Vietnamese**: Vietnam + +### その他の言語 +- **Russian**: Russia +- **Arabic**: Saudi Arabia, UAE, Egypt +- **Hindi**: India +- **Dutch**: Netherlands +- **Swedish**: Sweden +- **Norwegian**: Norway + +## エンジン別言語コード形式 + +### Google Speech Recognition +- RFC 5646準拠の言語タグ形式 +- 例: "ja-JP", "en-US", "zh-CN" + +### OpenAI Whisper +- ISO 639-1言語コード(2文字) +- 例: "ja", "en", "zh" + +### その他のエンジン +- エンジン固有の形式に対応 +- マッピングテーブルによる変換 + +## 地域対応 + +### 同一言語の地域別対応 +```python +# 英語の地域バリエーション +"English": [ + {"country": "United States", "google_language_code": "en-US"}, + {"country": "United Kingdom", "google_language_code": "en-GB"}, + {"country": "Australia", "google_language_code": "en-AU"}, + {"country": "Canada", "google_language_code": "en-CA"}, + {"country": "India", "google_language_code": "en-IN"} +] +``` + +### 方言・変種対応 +```python +# 中国語の簡体字・繁体字対応 +"Chinese Simplified": [ + {"country": "China", "google_language_code": "zh-CN"} +], +"Chinese Traditional": [ + {"country": "Taiwan", "google_language_code": "zh-TW"}, + {"country": "Hong Kong", "google_language_code": "zh-HK"} +] +``` + +## 統合利用 + +### VRCTでの利用例 + +```python +def get_supported_transcription_languages(): + """音声認識対応言語の取得""" + languages = [] + for language, countries in transcription_lang.items(): + for country_data in countries: + languages.append({ + "language": language, + "country": country_data["country"], + "display_name": f"{language} ({country_data['country']})", + "code": country_data["google_language_code"] + }) + return languages +``` + +### エラーハンドリング + +```python +def get_language_code(language: str, country: str = None) -> str: + """安全な言語コード取得""" + try: + countries = transcription_lang.get(language, []) + if not countries: + return "en-US" # フォールバック + + if country: + for country_data in countries: + if country_data["country"] == country: + return country_data["google_language_code"] + + # 国指定なしまたは見つからない場合は最初の項目を返す + return countries[0]["google_language_code"] + except (KeyError, IndexError): + return "en-US" # エラー時のフォールバック +``` + +## 拡張性 + +### 新言語の追加 +```python +# 新しい言語の追加例 +transcription_lang["Turkish"] = [ + {"country": "Turkey", "google_language_code": "tr-TR"} +] +``` + +### 新エンジンへの対応 +```python +# 新しいエンジンのコードフィールドを追加 +transcription_lang["English"][0]["azure_language_code"] = "en-US" +transcription_lang["English"][0]["aws_language_code"] = "en-US" +``` + +## 注意事項 + +- 言語コードは各エンジンの仕様に依存 +- 新しいエンジン追加時は対応コードの追加が必要 +- 地域固有の音声認識精度差に注意 +- エンジンによってサポート言語が異なる場合がある + +## 関連モジュール + +- `transcription_transcriber.py`: 音声認識エンジン本体 +- `translation_languages.py`: 翻訳エンジン言語マッピング +- `config.py`: 言語設定管理 +- `controller.py`: 言語選択UI制御 \ No newline at end of file diff --git a/src-python/docs/details/transcription_recorder.md b/src-python/docs/details/transcription_recorder.md new file mode 100644 index 00000000..1ac6dddc --- /dev/null +++ b/src-python/docs/details/transcription_recorder.md @@ -0,0 +1,325 @@ +# transcription_recorder.py - 音声録音インターフェース + +## 概要 + +音声認識システムの入力となる音声データを録音するレコーダークラス群です。マイクとスピーカー出力の両方をサポートし、エネルギーレベル監視機能とともに音声データをキューに送信します。pyaudiowpatchライブラリを使用してWindowsの音声システムと統合します。 + +## 主要機能 + +### 音声録音機能 +- マイクからの音声録音 +- スピーカー出力の録音(ループバック) +- リアルタイム音声データキューイング + +### エネルギー監視 +- 音声エネルギーレベルの監視 +- 動的しきい値調整 +- 無音検出 + +### デバイス対応 +- 複数音声デバイスの対応 +- デバイス固有設定の管理 +- 自動デバイス選択 + +## クラス構造 + +### BaseRecorder クラス +```python +class BaseRecorder: + def __init__(self, source: Any, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) +``` + +基底レコーダークラス - 共通機能を提供 + +### SelectedMicRecorder クラス +```python +class SelectedMicRecorder(BaseRecorder): + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) +``` + +選択されたマイクデバイスからの録音 + +### SelectedSpeakerRecorder クラス +```python +class SelectedSpeakerRecorder(BaseRecorder): + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) +``` + +選択されたスピーカーデバイスからの録音(ループバック) + +### エネルギー監視クラス群 + +#### BaseEnergyRecorder クラス +```python +class BaseEnergyRecorder: + def __init__(self, source: Any) +``` + +エネルギーレベル監視の基底クラス + +#### SelectedMicEnergyRecorder クラス +```python +class SelectedMicEnergyRecorder(BaseEnergyRecorder): + def __init__(self, device: dict) +``` + +マイクエネルギーレベルの監視 + +#### SelectedSpeakerEnergyRecorder クラス +```python +class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder): + def __init__(self, device: dict) +``` + +スピーカーエネルギーレベルの監視 + +### 統合録音クラス群 + +#### BaseEnergyAndAudioRecorder クラス +```python +class BaseEnergyAndAudioRecorder: + def __init__(self, source: Any, energy_threshold: int, dynamic_energy_threshold: bool, + phrase_time_limit: int, phrase_timeout: int, record_timeout: int) +``` + +音声録音とエネルギー監視を統合 + +#### SelectedMicEnergyAndAudioRecorder クラス +```python +class SelectedMicEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, + phrase_time_limit: int, phrase_timeout: int = 1, record_timeout: int = 5) +``` + +マイクの音声録音とエネルギー監視を統合 + +#### SelectedSpeakerEnergyAndAudioRecorder クラス +```python +class SelectedSpeakerEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, + phrase_time_limit: int, phrase_timeout: int = 1, record_timeout: int = 5) +``` + +スピーカーの音声録音とエネルギー監視を統合 + +## 主要メソッド + +### 録音制御 + +```python +adjustForNoise() -> None +``` +- 環境ノイズに合わせたしきい値調整 +- 録音開始前の較正 + +```python +recordIntoQueue(audio_queue: Queue) -> None +``` +- 音声データの継続的キューイング +- バックグラウンドスレッドでの実行 + +```python +pause() -> None +resume() -> None +stop() -> None +``` +- 録音の一時停止・再開・停止制御 + +### エネルギー監視 + +```python +recordIntoQueue(energy_queue: Queue) -> None +``` +- エネルギーレベルのキューイング +- リアルタイム監視データの提供 + +## 使用方法 + +### 基本的なマイク録音 + +```python +from queue import Queue +from models.transcription.transcription_recorder import SelectedMicRecorder + +# デバイス設定 +mic_device = { + "name": "マイク (USB Audio Device)", + "index": 0, + "channels": 1, + "sample_rate": 16000 +} + +# 録音設定 +energy_threshold = 300 +dynamic_threshold = True +record_timeout = 5 + +# レコーダー初期化 +recorder = SelectedMicRecorder( + device=mic_device, + energy_threshold=energy_threshold, + dynamic_energy_threshold=dynamic_threshold, + record_timeout=record_timeout +) + +# 音声キューの作成 +audio_queue = Queue() + +# 録音開始 +recorder.adjustForNoise() # ノイズ調整 +recorder.recordIntoQueue(audio_queue) + +# 音声データの取得 +while True: + if not audio_queue.empty(): + audio_data = audio_queue.get() + print(f"音声データ受信: {len(audio_data)} bytes") +``` + +### スピーカー録音(ループバック) + +```python +from models.transcription.transcription_recorder import SelectedSpeakerRecorder + +# スピーカーデバイス設定 +speaker_device = { + "name": "スピーカー (USB Audio Device)", + "index": 1, + "channels": 2, + "sample_rate": 44100 +} + +# スピーカーレコーダー +recorder = SelectedSpeakerRecorder( + device=speaker_device, + energy_threshold=500, + dynamic_energy_threshold=False, + record_timeout=3 +) + +audio_queue = Queue() +recorder.recordIntoQueue(audio_queue) +``` + +### エネルギー監視 + +```python +from models.transcription.transcription_recorder import SelectedMicEnergyRecorder + +# エネルギー監視のみ +energy_recorder = SelectedMicEnergyRecorder(mic_device) +energy_queue = Queue() + +energy_recorder.recordIntoQueue(energy_queue) + +# エネルギーレベルの取得 +while True: + if not energy_queue.empty(): + energy_level = energy_queue.get() + print(f"エネルギーレベル: {energy_level}") +``` + +### 統合録音(音声+エネルギー) + +```python +from models.transcription.transcription_recorder import SelectedMicEnergyAndAudioRecorder + +# 統合レコーダー +integrated_recorder = SelectedMicEnergyAndAudioRecorder( + device=mic_device, + energy_threshold=300, + dynamic_energy_threshold=True, + phrase_time_limit=5, # フレーズ制限時間 + phrase_timeout=1, # フレーズタイムアウト + record_timeout=5 # 録音タイムアウト +) + +audio_queue = Queue() +energy_queue = Queue() + +# 両方のキューに同時出力 +integrated_recorder.recordIntoQueue(audio_queue, energy_queue) +``` + +## 設定パラメータ + +### しきい値設定 +- **energy_threshold**: 音声検出のエネルギーしきい値 +- **dynamic_energy_threshold**: 動的しきい値調整の有効・無効 + +### タイムアウト設定 +- **record_timeout**: 録音継続時間の上限 +- **phrase_timeout**: フレーズ間の無音許容時間 +- **phrase_time_limit**: 単一フレーズの最大長 + +### デバイス設定 +- **name**: デバイス名 +- **index**: デバイスインデックス +- **channels**: チャンネル数(1=モノラル、2=ステレオ) +- **sample_rate**: サンプリングレート(Hz) + +## デバイス対応 + +### マイクデバイス +- USB マイク +- 内蔵マイク +- Bluetooth マイク +- 仮想マイクデバイス + +### スピーカーデバイス(ループバック) +- USB スピーカー/ヘッドフォン +- 内蔵スピーカー +- Bluetooth スピーカー +- 仮想音声デバイス + +## エラーハンドリング + +### デバイスエラー +- デバイス接続失敗の検出 +- 適切なエラーメッセージの提供 + +### 音声フォーマットエラー +- 非対応フォーマットの検出 +- 自動フォーマット変換 + +### メモリエラー +- キューオーバーフローの防止 +- メモリ使用量の最適化 + +## パフォーマンス特性 + +### レイテンシ +- 低レイテンシ録音(~10ms) +- リアルタイム処理最適化 + +### スループット +- 連続録音対応 +- 高サンプリングレート対応 + +### メモリ使用量 +- 効率的なバッファ管理 +- キューサイズの最適化 + +## 依存関係 + +### 必須依存関係 +- `speech_recognition`: 音声認識ライブラリ +- `pyaudiowpatch`: Windows音声システム統合 +- `queue`: データキューイング + +### オプション依存関係 +- `datetime`: タイムスタンプ機能 + +## 注意事項 + +- Windows専用(pyaudiowpatchによる制限) +- 適切な音声デバイスドライバーが必要 +- 排他制御による同時デバイスアクセス制限 +- 高サンプリングレート使用時のCPU使用率上昇 + +## 関連モジュール + +- `transcription_transcriber.py`: 音声認識エンジン +- `device_manager.py`: デバイス管理 +- `config.py`: 録音設定管理 +- `model.py`: 録音制御統合 \ No newline at end of file diff --git a/src-python/docs/details/transcription_transcriber.md b/src-python/docs/details/transcription_transcriber.md new file mode 100644 index 00000000..db5111f7 --- /dev/null +++ b/src-python/docs/details/transcription_transcriber.md @@ -0,0 +1,325 @@ +# transcription_transcriber.py - 音声文字起こしエンジン + +## 概要 + +音声データを文字テキストに変換する音声認識エンジンのメインクラスです。Google Speech RecognitionとOpenAI Whisper(faster-whisper)の両方をサポートし、オンライン・オフラインの音声認識を統合的に管理します。キューベースの非同期処理により、リアルタイム音声認識を実現します。 + +## 主要機能 + +### 音声認識エンジン +- Google Speech Recognition(オンライン) +- OpenAI Whisper(faster-whisper、オフライン) +- エンジン自動切り替え機能 + +### リアルタイム処理 +- 音声キューからの継続的データ処理 +- 非同期音声認識処理 +- 結果の即座通知 + +### 多言語対応 +- 複数言語の同時認識 +- 地域固有言語コードの対応 +- 自動言語検出 + +### 音声品質制御 +- 音声品質フィルタリング +- ノイズ除去機能 +- 信頼度スコア評価 + +## クラス構造 + +### AudioTranscriber クラス +```python +class AudioTranscriber: + def __init__(self, speaker: bool, source: Any, phrase_timeout: int, max_phrases: int, + transcription_engine: str, root: Optional[str] = None, + whisper_weight_type: Optional[str] = None, device: str = "cpu", + device_index: int = 0, compute_type: str = "auto") +``` + +音声認識の中核クラス + +#### 初期化パラメータ +- **speaker**: スピーカー音声かマイク音声か +- **source**: 音声ソース +- **phrase_timeout**: フレーズタイムアウト(秒) +- **max_phrases**: 最大フレーズ数 +- **transcription_engine**: 認識エンジン("Google"/"Whisper") +- **whisper_weight_type**: Whisperモデル種類 +- **device**: 計算デバイス("cpu"/"cuda") +- **device_index**: デバイスインデックス +- **compute_type**: 計算精度タイプ + +## 主要メソッド + +### 音声認識処理 + +```python +transcribeAudioQueue(audio_queue: Queue, languages: List[str], countries: List[str], + avg_logprob: float = -0.8, no_speech_prob: float = 0.6) -> bool +``` + +音声キューからの継続的音声認識 + +#### パラメータ +- **audio_queue**: 音声データキュー +- **languages**: 認識対象言語リスト +- **countries**: 地域コードリスト +- **avg_logprob**: Whisper平均対数確率しきい値 +- **no_speech_prob**: Whisper無音判定しきい値 + +### 結果管理 + +```python +getTranscript() -> dict +``` + +最新の認識結果を取得 + +```python +updateTranscript(result: dict) -> None +``` + +認識結果の更新と通知 + +```python +clearTranscriptData() -> None +``` + +認識データのクリア + +### 音声データ処理 + +```python +processMicData() -> AudioData +``` + +マイク音声データの前処理 + +```python +processSpeakerData() -> AudioData +``` + +スピーカー音声データの前処理 + +## 使用方法 + +### 基本的な音声認識 + +```python +from queue import Queue +from models.transcription.transcription_transcriber import AudioTranscriber + +# 音声認識の初期化 +transcriber = AudioTranscriber( + speaker=False, # マイク音声 + source=mic_source, # 音声ソース + phrase_timeout=3, # 3秒のフレーズタイムアウト + max_phrases=10, # 最大10フレーズ + transcription_engine="Google", # Google音声認識 + device="cpu" +) + +# 音声キューの準備 +audio_queue = Queue() + +# 認識対象言語の設定 +languages = ["Japanese", "English"] +countries = ["Japan", "United States"] + +# 音声認識の実行 +def transcription_loop(): + while True: + success = transcriber.transcribeAudioQueue( + audio_queue, languages, countries + ) + if success: + result = transcriber.getTranscript() + print(f"認識結果: {result['text']}") + print(f"言語: {result['language']}") + +# バックグラウンドで実行 +import threading +thread = threading.Thread(target=transcription_loop) +thread.daemon = True +thread.start() +``` + +### Whisperエンジンの使用 + +```python +# Whisper音声認識の初期化 +whisper_transcriber = AudioTranscriber( + speaker=True, # スピーカー音声 + source=speaker_source, + phrase_timeout=5, + max_phrases=5, + transcription_engine="Whisper", + whisper_weight_type="base", # Whisperモデル + device="cuda", # CUDA使用 + device_index=0, + compute_type="float16" # 半精度浮動小数点 +) + +# Whisper固有パラメータでの認識 +success = whisper_transcriber.transcribeAudioQueue( + audio_queue, languages, countries, + avg_logprob=-0.5, # より厳しい品質しきい値 + no_speech_prob=0.4 # より敏感な無音検出 +) +``` + +### コールバック処理 + +```python +def on_transcription_result(result): + """認識結果のコールバック処理""" + if result["text"]: + print(f"認識成功: {result['text']}") + print(f"言語: {result['language']}") + print(f"信頼度: {result.get('confidence', 'N/A')}") + else: + print("音声認識失敗") + +# 結果通知の設定 +transcriber.transcript_changed_event.set() # イベント設定 +``` + +### エラーハンドリング付きの使用 + +```python +def safe_transcription(transcriber, audio_queue, languages, countries): + """安全な音声認識処理""" + try: + success = transcriber.transcribeAudioQueue( + audio_queue, languages, countries + ) + + if success: + result = transcriber.getTranscript() + return result + else: + return {"text": False, "language": None, "error": "認識失敗"} + + except Exception as e: + print(f"音声認識エラー: {e}") + return {"text": False, "language": None, "error": str(e)} +``` + +## 認識エンジン比較 + +### Google Speech Recognition + +#### 利点 +- 高い認識精度 +- 多言語対応 +- リアルタイム処理 +- ノイズ耐性 + +#### 制限 +- インターネット接続必須 +- API制限 +- プライバシー懸念 +- レイテンシ + +### OpenAI Whisper(faster-whisper) + +#### 利点 +- オフライン動作 +- プライバシー保護 +- 高精度 +- 多言語対応 + +#### 制限 +- 初回起動時間 +- メモリ使用量 +- CUDA推奨 +- モデルファイル必要 + +## 設定パラメータ + +### フレーズ制御 +- **phrase_timeout**: フレーズ間無音時間(秒) +- **max_phrases**: バッファ内最大フレーズ数 + +### Whisper品質設定 +- **avg_logprob**: 平均対数確率しきい値(-1.0〜0.0) +- **no_speech_prob**: 無音判定しきい値(0.0〜1.0) + +### 計算設定 +- **device**: "cpu" または "cuda" +- **compute_type**: "float32", "float16", "int8" など + +## 音声データフォーマット + +### 入力形式 +- サンプリングレート: 16kHz推奨 +- ビット深度: 16bit +- チャンネル: モノラル推奨 +- フォーマット: WAV、FLAC等 + +### 処理フロー +1. 音声キューからデータ取得 +2. 音声フォーマット正規化 +3. 音声認識エンジン実行 +4. 結果の後処理・フィルタリング +5. 最終結果の通知 + +## パフォーマンス最適化 + +### メモリ管理 +- 音声バッファの適切なサイズ設定 +- 不要な音声データの早期解放 +- Whisperモデルのメモリ効率化 + +### 計算最適化 +- CUDA使用による高速化 +- 適切な計算精度選択 +- バッチ処理の活用 + +### レイテンシ削減 +- 音声バッファサイズの最適化 +- エンジン切り替えの高速化 +- キャッシュ機能の活用 + +## エラーハンドリング + +### ネットワークエラー +- Google API接続失敗の検出 +- 自動Whisperエンジン切り替え + +### 音声品質エラー +- 低品質音声の検出・フィルタリング +- ノイズレベル監視 + +### リソースエラー +- VRAM不足の検出 +- メモリ不足時の対応 + +## 依存関係 + +### 必須依存関係 +- `speech_recognition`: Google音声認識 +- `faster_whisper`: Whisper音声認識 +- `pyaudiowpatch`: 音声入力 +- `pydub`: 音声処理 + +### オプション依存関係 +- `torch`: CUDA計算 +- `utils`: エラーログ機能 + +## 注意事項 + +- Google APIは使用制限あり +- Whisperは初回起動に時間要 +- CUDA使用時はVRAM消費に注意 +- 音声品質が認識精度に大きく影響 +- 多言語認識時は処理負荷増加 + +## 関連モジュール + +- `transcription_recorder.py`: 音声録音 +- `transcription_whisper.py`: Whisperモデル管理 +- `transcription_languages.py`: 言語コード管理 +- `config.py`: 認識設定管理 +- `model.py`: 音声認識統合制御 \ No newline at end of file diff --git a/src-python/docs/details/transcription_whisper.md b/src-python/docs/details/transcription_whisper.md new file mode 100644 index 00000000..70dcedc9 --- /dev/null +++ b/src-python/docs/details/transcription_whisper.md @@ -0,0 +1,373 @@ +# transcription_whisper.py - Whisperモデル管理 + +## 概要 + +OpenAI Whisper(faster-whisper)モデルのダウンロード、検証、読み込みを管理するユーティリティモジュールです。複数のモデルサイズをサポートし、Hugging Face Hubからの自動ダウンロード機能とファイル整合性チェック機能を提供します。 + +## 主要機能 + +### モデル管理 +- 複数Whisperモデルサイズの対応 +- Hugging Face Hubからの自動ダウンロード +- モデルファイルの整合性検証 + +### ダウンロード機能 +- 進捗表示付きダウンロード +- レジューム対応 +- エラーハンドリング + +### モデル読み込み +- 効率的なモデル初期化 +- CUDA対応 +- 計算タイプ最適化 + +## サポートモデル + +### 利用可能なモデル +```python +_MODELS = { + "tiny": "Systran/faster-whisper-tiny", # ~39MB + "base": "Systran/faster-whisper-base", # ~74MB + "small": "Systran/faster-whisper-small", # ~244MB + "medium": "Systran/faster-whisper-medium", # ~769MB + "large-v1": "Systran/faster-whisper-large-v1", # ~1.5GB + "large-v2": "Systran/faster-whisper-large-v2", # ~1.5GB + "large-v3": "Systran/faster-whisper-large-v3", # ~1.5GB + "large-v3-turbo-int8": "Zoont/faster-whisper-large-v3-turbo-int8-ct2", # ~794MB + "large-v3-turbo": "deepdml/faster-whisper-large-v3-turbo-ct2" # ~1.58GB +} +``` + +### モデル特性比較 + +#### tiny +- **サイズ**: ~39MB +- **精度**: 低 +- **速度**: 最高速 +- **用途**: リアルタイム処理、リソース制限環境 + +#### base +- **サイズ**: ~74MB +- **精度**: 中程度 +- **速度**: 高速 +- **用途**: 一般的な用途、バランス重視 + +#### small +- **サイズ**: ~244MB +- **精度**: 良好 +- **速度**: 中程度 +- **用途**: 品質重視、モバイル環境 + +#### medium +- **サイズ**: ~769MB +- **精度**: 高 +- **速度**: やや低速 +- **用途**: 高品質認識、デスクトップ環境 + +#### large系 +- **サイズ**: ~1.5GB +- **精度**: 最高 +- **速度**: 低速 +- **用途**: 最高品質、サーバー環境 + +## 主要関数 + +### ファイルダウンロード + +```python +downloadFile(url: str, path: str, func: Optional[Callable[[float], None]] = None) -> None +``` + +ファイルのストリームダウンロード + +#### パラメータ +- **url**: ダウンロードURL +- **path**: 保存先パス +- **func**: 進捗コールバック関数 + +### モデル検証 + +```python +checkWhisperWeight(root: str, weight_type: str) -> bool +``` + +Whisperモデルの利用可能性確認 + +#### パラメータ +- **root**: アプリケーションルートパス +- **weight_type**: モデルタイプ("tiny", "base"等) + +#### 戻り値 +- **bool**: モデルが利用可能かどうか + +### モデルダウンロード + +```python +downloadWhisperWeight(root: str, weight_type: str, + callback: Optional[Callable[[float], None]] = None, + end_callback: Optional[Callable[[], None]] = None) -> None +``` + +Whisperモデルのダウンロード + +#### パラメータ +- **root**: アプリケーションルートパス +- **weight_type**: ダウンロードするモデルタイプ +- **callback**: 進捗コールバック +- **end_callback**: 完了コールバック + +### モデル読み込み + +```python +getWhisperModel(root: str, weight_type: str, device: str = "cpu", + device_index: int = 0, compute_type: str = "auto") -> WhisperModel +``` + +Whisperモデルの初期化 + +#### パラメータ +- **root**: アプリケーションルートパス +- **weight_type**: 使用するモデルタイプ +- **device**: 計算デバイス("cpu"/"cuda") +- **device_index**: デバイスインデックス +- **compute_type**: 計算精度タイプ + +#### 戻り値 +- **WhisperModel**: 初期化されたWhisperモデルインスタンス + +## 使用方法 + +### モデルの確認とダウンロード + +```python +from models.transcription.transcription_whisper import checkWhisperWeight, downloadWhisperWeight + +root_path = "." +model_type = "base" + +# モデルの利用可能性確認 +if not checkWhisperWeight(root_path, model_type): + print(f"{model_type}モデルが見つかりません。ダウンロードします...") + + # 進捗コールバック + def progress_callback(progress): + print(f"ダウンロード進捗: {progress:.1%}") + + # 完了コールバック + def completion_callback(): + print("ダウンロード完了!") + + # モデルダウンロード + downloadWhisperWeight( + root=root_path, + weight_type=model_type, + callback=progress_callback, + end_callback=completion_callback + ) +else: + print(f"{model_type}モデルは利用可能です") +``` + +### モデルの読み込みと使用 + +```python +from models.transcription.transcription_whisper import getWhisperModel + +# CPUでのモデル読み込み +model = getWhisperModel( + root=".", + weight_type="base", + device="cpu" +) + +# CUDAでのモデル読み込み(GPU使用) +gpu_model = getWhisperModel( + root=".", + weight_type="small", + device="cuda", + device_index=0, + compute_type="float16" # 半精度で高速化 +) + +# 音声認識の実行 +audio_file = "audio.wav" +segments, info = model.transcribe(audio_file, language="ja") + +for segment in segments: + print(f"{segment.start:.1f}s - {segment.end:.1f}s: {segment.text}") +``` + +### エラーハンドリング付きの使用 + +```python +def safe_model_loading(root, weight_type, device="cpu"): + """安全なモデル読み込み""" + try: + # モデル存在確認 + if not checkWhisperWeight(root, weight_type): + print(f"モデル {weight_type} をダウンロード中...") + downloadWhisperWeight(root, weight_type) + + # モデル読み込み + model = getWhisperModel(root, weight_type, device) + return model + + except Exception as e: + print(f"モデル読み込みエラー: {e}") + # フォールバック: より小さなモデルを試す + if weight_type != "tiny": + return safe_model_loading(root, "tiny", device) + return None +``` + +### 進捗表示付きダウンロード + +```python +import sys + +def download_with_progress(root, weight_type): + """進捗表示付きダウンロード""" + def show_progress(progress): + bar_length = 40 + filled_length = int(bar_length * progress) + bar = '█' * filled_length + '-' * (bar_length - filled_length) + sys.stdout.write(f'\r[{bar}] {progress:.1%}') + sys.stdout.flush() + + def download_complete(): + print("\nダウンロード完了!") + + print(f"Whisper {weight_type} モデルをダウンロード中...") + downloadWhisperWeight(root, weight_type, show_progress, download_complete) +``` + +## ディレクトリ構造 + +### モデルファイル配置 +``` +root/ +└── weights/ + └── whisper/ + ├── tiny/ + │ ├── config.json + │ ├── preprocessor_config.json + │ ├── model.bin + │ ├── tokenizer.json + │ └── vocabulary.txt + ├── base/ + └── small/ +``` + +### 必要ファイル +```python +_FILENAMES = [ + "config.json", # モデル設定 + "preprocessor_config.json", # 前処理設定 + "model.bin", # モデルウェイト + "tokenizer.json", # トークナイザー + "vocabulary.txt", # 語彙ファイル + "vocabulary.json" # 語彙ファイル(JSON形式) +] +``` + +## パフォーマンス考慮事項 + +### メモリ使用量 +- **tiny**: ~100MB RAM +- **base**: ~200MB RAM +- **small**: ~500MB RAM +- **medium**: ~1.5GB RAM +- **large**: ~3GB RAM + +### VRAM使用量(CUDA使用時) +- **tiny**: ~200MB VRAM +- **base**: ~300MB VRAM +- **small**: ~600MB VRAM +- **medium**: ~1.8GB VRAM +- **large**: ~3.5GB VRAM + +### 処理速度(目安) +- **tiny**: リアルタイム処理可能 +- **base**: 1x-2x リアルタイム +- **small**: 0.5x-1x リアルタイム +- **medium**: 0.2x-0.5x リアルタイム +- **large**: 0.1x-0.3x リアルタイム + +## 計算タイプ設定 + +### 利用可能な計算タイプ +- **float32**: 最高精度、低速 +- **float16**: 高精度、中速(CUDA推奨) +- **int8**: 中精度、高速 +- **int8_float16**: 混合精度、バランス + +### 推奨設定 +```python +# CPU使用時 +compute_type = "int8" # 速度重視 + +# CUDA使用時(RTX以上) +compute_type = "float16" # 精度と速度のバランス + +# CUDA使用時(VRAM制限) +compute_type = "int8_float16" # メモリ効率重視 +``` + +## エラーハンドリング + +### ダウンロードエラー +- ネットワーク接続失敗 +- ディスク容量不足 +- 権限不足 + +### モデル読み込みエラー +- VRAM不足 +- 破損したモデルファイル +- 非対応デバイス + +### 対応策 +```python +def robust_model_loading(root, preferred_type="base"): + """堅牢なモデル読み込み""" + model_priority = ["tiny", "base", "small", "medium"] + + # 優先モデルを先頭に配置 + if preferred_type in model_priority: + model_priority.remove(preferred_type) + model_priority.insert(0, preferred_type) + + for model_type in model_priority: + try: + if checkWhisperWeight(root, model_type): + return getWhisperModel(root, model_type) + except Exception as e: + print(f"{model_type} モデル読み込み失敗: {e}") + continue + + raise RuntimeError("利用可能なWhisperモデルがありません") +``` + +## 依存関係 + +### 必須依存関係 +- `faster_whisper`: Whisperエンジン +- `requests`: ファイルダウンロード +- `utils`: ユーティリティ機能 + +### オプション依存関係 +- `torch`: CUDA計算(GPU使用時) + +## 注意事項 + +- 初回モデル読み込み時はダウンロードに時間がかかる +- 大きなモデルほど高精度だが、メモリとVRAMを大量消費 +- CUDAを使用する場合は適切なGPUドライバーが必要 +- モデルファイルの整合性チェックが重要 +- ネットワーク環境によってダウンロード時間が大きく変動 + +## 関連モジュール + +- `transcription_transcriber.py`: Whisper音声認識エンジン +- `config.py`: Whisperモデル設定管理 +- `utils.py`: 計算デバイス管理 +- `model.py`: Whisper統合制御 \ No newline at end of file diff --git a/src-python/docs/details/translation_languages.md b/src-python/docs/details/translation_languages.md new file mode 100644 index 00000000..9943e3e6 --- /dev/null +++ b/src-python/docs/details/translation_languages.md @@ -0,0 +1,342 @@ +# translation_languages.py - 翻訳言語マッピング + +## 概要 + +翻訳エンジンが対応する言語コードのマッピングテーブルを提供するモジュールです。複数の翻訳エンジン(DeepL、Google、Bing、Papago等)の言語コード仕様の差異を吸収し、統一的な翻訳言語管理を実現します。 + +## 主要機能 + +### 多エンジン対応 +- DeepL(無料版・API版) +- Google Translate +- Microsoft Translator(Bing) +- Papago Translator +- その他のWeb翻訳サービス + +### 言語コード統合管理 +- 各エンジン固有の言語コード形式を統一 +- 送信元(source)と送信先(target)言語の分離管理 +- 地域固有言語バリエーションの対応 + +## データ構造 + +### translation_lang +```python +translation_lang: Dict[str, Dict[str, Dict[str, str]]] = { + "エンジン名": { + "source": {"言語名": "言語コード", ...}, + "target": {"言語名": "言語コード", ...} + } +} +``` + +### DeepL翻訳エンジン(無料版) + +```python +translation_lang["DeepL"] = { + "source": { + "Arabic": "ar", "Bulgarian": "bg", "Czech": "cs", "Danish": "da", + "German": "de", "Greek": "el", "English": "en", "Spanish": "es", + "Estonian": "et", "Finnish": "fi", "French": "fr", "Irish": "ga", + "Croatian": "hr", "Hungarian": "hu", "Indonesian": "id", + "Icelandic": "is", "Italian": "it", "Japanese": "ja", + "Korean": "ko", "Lithuanian": "lt", "Latvian": "lv", + "Maltese": "mt", "Bokmal": "nb", "Dutch": "nl", + "Norwegian": "no", "Polish": "pl", "Portuguese": "pt", + "Romanian": "ro", "Russian": "ru", "Slovak": "sk", + "Slovenian": "sl", "Swedish": "sv", "Turkish": "tr", + "Ukrainian": "uk", "Chinese Simplified": "zh", + "Chinese Traditional": "zh" + }, + "target": {/* 同じマッピング */} +} +``` + +### DeepL API(有料版) + +```python +translation_lang["DeepL_API"] = { + "source": {/* 基本的にDeepLと同様 */}, + "target": { + "Japanese": "ja", + "English American": "en-US", # 地域別対応 + "English British": "en-GB", + "Portuguese Brazilian": "pt-BR", # ブラジル・ポルトガル語 + "Portuguese European": "pt-PT", # ヨーロッパ・ポルトガル語 + "Chinese Simplified": "zh", + "Chinese Traditional": "zh" + /* その他の言語 */ + } +} +``` + +## 主要対応言語 + +### 西欧言語 +- **English**: 英語(米国・英国バリエーション) +- **German**: ドイツ語 +- **French**: フランス語 +- **Spanish**: スペイン語 +- **Italian**: イタリア語 +- **Portuguese**: ポルトガル語(ブラジル・欧州) +- **Dutch**: オランダ語 +- **Swedish**: スウェーデン語 +- **Norwegian**: ノルウェー語 + +### 東欧・スラブ言語 +- **Russian**: ロシア語 +- **Polish**: ポーランド語 +- **Czech**: チェコ語 +- **Slovak**: スロバキア語 +- **Ukrainian**: ウクライナ語 +- **Bulgarian**: ブルガリア語 +- **Croatian**: クロアチア語 +- **Slovenian**: スロベニア語 + +### アジア言語 +- **Japanese**: 日本語 +- **Korean**: 韓国語 +- **Chinese Simplified**: 中国語(簡体字) +- **Chinese Traditional**: 中国語(繁体字) +- **Indonesian**: インドネシア語 + +### その他の言語 +- **Arabic**: アラビア語 +- **Turkish**: トルコ語 +- **Finnish**: フィンランド語 +- **Estonian**: エストニア語 +- **Latvian**: ラトビア語 +- **Lithuanian**: リトアニア語 +- **Maltese**: マルタ語 +- **Irish**: アイルランド語 + +## 使用方法 + +### 基本的な言語コード取得 + +```python +from models.translation.translation_languages import translation_lang + +# DeepLで日本語から英語への翻訳 +deepl_source = translation_lang["DeepL"]["source"]["Japanese"] # "ja" +deepl_target = translation_lang["DeepL"]["target"]["English"] # "en" + +# DeepL APIで地域固有の英語指定 +deepl_api_target = translation_lang["DeepL_API"]["target"]["English American"] # "en-US" +``` + +### 対応言語の確認 + +```python +def get_supported_languages(engine_name): + """指定エンジンの対応言語一覧取得""" + if engine_name in translation_lang: + engine_data = translation_lang[engine_name] + source_langs = list(engine_data["source"].keys()) + target_langs = list(engine_data["target"].keys()) + return { + "source": source_langs, + "target": target_langs, + "common": list(set(source_langs) & set(target_langs)) + } + return None + +# 使用例 +deepl_langs = get_supported_languages("DeepL") +print(f"DeepL対応言語数: {len(deepl_langs['common'])}") +``` + +### 言語コード変換 + +```python +def convert_language_code(language_name, from_engine, to_engine, direction="source"): + """エンジン間での言語コード変換""" + try: + # 元エンジンから言語名を確認 + from_codes = translation_lang[from_engine][direction] + to_codes = translation_lang[to_engine][direction] + + if language_name in from_codes and language_name in to_codes: + return to_codes[language_name] + return None + except KeyError: + return None + +# 使用例:DeepLからGoogle Translateへの変換 +google_code = convert_language_code("Japanese", "DeepL", "Google", "target") +``` + +### 翻訳システムでの統合利用 + +```python +class TranslationLanguageManager: + """翻訳言語管理クラス""" + + @staticmethod + def get_language_code(engine, language, direction="target"): + """安全な言語コード取得""" + try: + return translation_lang[engine][direction][language] + except KeyError: + return None + + @staticmethod + def is_language_supported(engine, language, direction="target"): + """言語サポート確認""" + try: + return language in translation_lang[engine][direction] + except KeyError: + return False + + @staticmethod + def get_compatible_engines(source_lang, target_lang): + """両言語をサポートするエンジン一覧""" + compatible = [] + for engine in translation_lang: + source_supported = TranslationLanguageManager.is_language_supported( + engine, source_lang, "source" + ) + target_supported = TranslationLanguageManager.is_language_supported( + engine, target_lang, "target" + ) + if source_supported and target_supported: + compatible.append(engine) + return compatible + +# 使用例 +manager = TranslationLanguageManager() + +# 日本語→英語をサポートするエンジン +engines = manager.get_compatible_engines("Japanese", "English") +print(f"対応エンジン: {engines}") + +# 特定エンジンでの言語コード取得 +ja_code = manager.get_language_code("DeepL", "Japanese", "source") +en_code = manager.get_language_code("DeepL", "English", "target") +``` + +## エンジン別特徴 + +### DeepL(無料版) +- **強み**: 高精度、自然な翻訳 +- **制限**: 月間使用量制限、API制限 +- **対応**: 26言語 + +### DeepL API(有料版) +- **強み**: DeepLの高精度、地域別言語対応 +- **制限**: 従量課金 +- **対応**: 地域固有言語バリエーション + +### Google Translate +- **強み**: 多言語対応、高速 +- **制限**: API制限、精度のばらつき +- **対応**: 100+言語 + +### Microsoft Translator +- **強み**: リアルタイム翻訳、音声対応 +- **制限**: APIキー必要 +- **対応**: 70+言語 + +## 地域バリエーション対応 + +### 英語の地域別対応 +```python +# DeepL APIでの英語バリエーション +"English American": "en-US", # アメリカ英語 +"English British": "en-GB", # イギリス英語 +``` + +### ポルトガル語の地域別対応 +```python +# ブラジル・ポルトガル語とヨーロッパ・ポルトガル語 +"Portuguese Brazilian": "pt-BR", +"Portuguese European": "pt-PT", +``` + +### 中国語の文字体系対応 +```python +# 簡体字・繁体字の区別 +"Chinese Simplified": "zh", # 簡体字(中国本土) +"Chinese Traditional": "zh", # 繁体字(台湾・香港) +``` + +## 拡張性 + +### 新エンジンの追加 +```python +# 新しい翻訳エンジンの追加例 +translation_lang["NewEngine"] = { + "source": { + "Japanese": "jp", + "English": "en", + "Korean": "kr" + }, + "target": { + "Japanese": "jp", + "English": "en", + "Korean": "kr" + } +} +``` + +### 新言語の追加 +```python +# 既存エンジンへの新言語追加 +translation_lang["DeepL"]["source"]["Hindi"] = "hi" +translation_lang["DeepL"]["target"]["Hindi"] = "hi" +``` + +## エラーハンドリング + +### 安全な言語コード取得 +```python +def safe_get_language_code(engine, language, direction="target", fallback="en"): + """フォールバック機能付き言語コード取得""" + try: + return translation_lang[engine][direction][language] + except KeyError: + # フォールバック言語を返す + try: + return translation_lang[engine][direction].get("English", fallback) + except KeyError: + return fallback +``` + +### 言語サポート検証 +```python +def validate_translation_pair(engine, source_lang, target_lang): + """翻訳ペアの有効性検証""" + try: + engine_data = translation_lang[engine] + source_supported = source_lang in engine_data["source"] + target_supported = target_lang in engine_data["target"] + + return { + "valid": source_supported and target_supported, + "source_supported": source_supported, + "target_supported": target_supported + } + except KeyError: + return { + "valid": False, + "source_supported": False, + "target_supported": False, + "error": f"Unknown engine: {engine}" + } +``` + +## 注意事項 + +- エンジンによって言語コード形式が異なる +- 地域バリエーションはエンジンにより対応状況が異なる +- 新しい言語追加時は全エンジンでの対応状況を確認 +- API制限や課金体系はエンジンごとに異なる +- 一部の言語ペアは翻訳精度に差がある場合がある + +## 関連モジュール + +- `translation_translator.py`: 翻訳エンジン本体 +- `translation_utils.py`: 翻訳ユーティリティ +- `transcription_languages.py`: 音声認識言語マッピング +- `config.py`: 翻訳言語設定管理 +- `controller.py`: 言語選択UI制御 \ No newline at end of file diff --git a/src-python/docs/details/translation_translator.md b/src-python/docs/details/translation_translator.md new file mode 100644 index 00000000..970385ae --- /dev/null +++ b/src-python/docs/details/translation_translator.md @@ -0,0 +1,406 @@ +# translation_translator.py - 翻訳エンジン統合クラス + +## 概要 + +複数の翻訳エンジンを統合管理する高レベル翻訳インターフェースです。DeepL、Google、Bing、Papago、CTranslate2などの多様な翻訳サービスを統一的に扱い、エラー時の自動フォールバック機能と認証管理を提供します。 + +## 主要機能 + +### 多エンジン統合 +- DeepL(無料版・API版) +- Google Translate(Webスクレイピング) +- Microsoft Translator(Bing) +- Papago Translator +- CTranslate2(ローカル翻訳) + +### 統一インターフェース +- エンジン依存を隠蔽した単一の翻訳メソッド +- 自動エラーハンドリング・フォールバック +- 認証情報の統合管理 + +### オフライン翻訳対応 +- CTranslate2による完全オフライン翻訳 +- 複数モデルサイズ(small/large)対応 +- CUDA高速化サポート + +## クラス構造 + +### Translator クラス +```python +class Translator: + def __init__(self) -> None: + self.deepl_client: Optional[DeepLClient] = None + self.ctranslate2_translator: Any = None + self.ctranslate2_tokenizer: Any = None + self.is_loaded_ctranslate2_model: bool = False + self.is_changed_translator_parameters: bool = False + self.is_enable_translators: bool = ENABLE_TRANSLATORS +``` + +翻訳機能の中核クラス + +#### 属性 +- **deepl_client**: DeepL APIクライアント +- **ctranslate2_translator**: ローカル翻訳モデル +- **ctranslate2_tokenizer**: CTranslate2トークナイザー +- **is_loaded_ctranslate2_model**: ローカルモデル読み込み状態 +- **is_enable_translators**: Web翻訳サービス利用可能フラグ + +## 主要メソッド + +### 翻訳実行 + +```python +translate(translator_name: str, source_language: str, target_language: str, + target_country: str, message: str) -> Any +``` + +統一翻訳インターフェース + +#### パラメータ +- **translator_name**: 翻訳エンジン名("DeepL", "Google", "CTranslate2"等) +- **source_language**: 送信元言語 +- **target_language**: 送信先言語 +- **target_country**: 送信先国・地域 +- **message**: 翻訳対象テキスト + +#### 戻り値 +- **str**: 翻訳結果(成功時) +- **False**: 翻訳失敗時 + +### DeepL認証管理 + +```python +authenticationDeepLAuthKey(authkey: str) -> bool +``` + +DeepL APIキーの認証と設定 + +#### パラメータ +- **authkey**: DeepL APIキー + +#### 戻り値 +- **bool**: 認証成功可否 + +### CTranslate2管理 + +```python +changeCTranslate2Model(path: str, model_type: str, device: str = "cpu", + device_index: int = 0, compute_type: str = "auto") -> None +``` + +ローカル翻訳モデルの読み込み・変更 + +#### パラメータ +- **path**: モデルファイルのベースパス +- **model_type**: モデルサイズ("small"/"large") +- **device**: 計算デバイス("cpu"/"cuda") +- **device_index**: デバイスインデックス +- **compute_type**: 計算精度タイプ + +### 状態管理 + +```python +isLoadedCTranslate2Model() -> bool +``` + +CTranslate2モデルの読み込み状態確認 + +```python +isChangedTranslatorParameters() -> bool +setChangedTranslatorParameters(is_changed: bool) -> None +``` + +翻訳設定変更フラグの管理 + +## 使用方法 + +### 基本的な翻訳 + +```python +from models.translation.translation_translator import Translator + +# 翻訳器の初期化 +translator = Translator() + +# Google翻訳の使用 +result = translator.translate( + translator_name="Google", + source_language="Japanese", + target_language="English", + target_country="United States", + message="こんにちは、世界!" +) + +if result != False: + print(f"翻訳結果: {result}") # "Hello, world!" +else: + print("翻訳に失敗しました") +``` + +### DeepL API使用 + +```python +# DeepL APIキーの設定 +api_key = "your-deepl-api-key" +auth_success = translator.authenticationDeepLAuthKey(api_key) + +if auth_success: + print("DeepL API認証成功") + + # DeepL APIで翻訳 + result = translator.translate( + translator_name="DeepL_API", + source_language="English", + target_language="Japanese", + target_country="Japan", + message="Hello, world!" + ) + print(f"DeepL翻訳: {result}") +else: + print("DeepL API認証失敗") +``` + +### ローカル翻訳(CTranslate2)の使用 + +```python +# ローカルモデルの読み込み +translator.changeCTranslate2Model( + path=".", # アプリケーションルート + model_type="small", # smallモデル使用 + device="cuda", # GPU使用 + device_index=0, + compute_type="float16" # 半精度で高速化 +) + +# モデル読み込み確認 +if translator.isLoadedCTranslate2Model(): + print("CTranslate2モデル読み込み完了") + + # ローカル翻訳実行 + result = translator.translate( + translator_name="CTranslate2", + source_language="Japanese", + target_language="English", + target_country="United States", + message="機械翻訳のテストです" + ) + print(f"ローカル翻訳: {result}") +else: + print("CTranslate2モデル読み込み失敗") +``` + +### エラーハンドリング付きの翻訳 + +```python +def safe_translate(translator, message, source_lang="Japanese", target_lang="English"): + """安全な翻訳処理""" + # 翻訳エンジンの優先順位 + engines = ["DeepL_API", "DeepL", "Google", "CTranslate2"] + + for engine in engines: + try: + result = translator.translate( + translator_name=engine, + source_language=source_lang, + target_language=target_lang, + target_country="United States", + message=message + ) + + if result != False: + print(f"{engine}で翻訳成功: {result}") + return result + else: + print(f"{engine}翻訳失敗、次のエンジンを試行") + + except Exception as e: + print(f"{engine}でエラー: {e}") + continue + + print("全ての翻訳エンジンで失敗") + return None + +# 使用例 +result = safe_translate(translator, "こんにちは") +``` + +### 翻訳設定の管理 + +```python +# 設定変更フラグの確認 +if translator.isChangedTranslatorParameters(): + print("翻訳設定が変更されています") + + # 設定変更の適用(例:モデル再読み込み) + translator.changeCTranslate2Model(".", "small", "cpu") + + # フラグのリセット + translator.setChangedTranslatorParameters(False) +``` + +## 翻訳エンジン比較 + +### DeepL API(有料) +- **精度**: 最高レベル +- **速度**: 高速 +- **制限**: API使用料、月間制限 +- **対応**: 26言語、地域別対応 + +### DeepL(無料) +- **精度**: 高品質 +- **速度**: 中程度 +- **制限**: 月間使用量制限、文字数制限 +- **対応**: 26言語 + +### Google Translate +- **精度**: 良好 +- **速度**: 高速 +- **制限**: アクセス頻度制限 +- **対応**: 100+言語 + +### CTranslate2(ローカル) +- **精度**: 中〜高(モデル依存) +- **速度**: 高速(GPU使用時) +- **制限**: なし(オフライン) +- **対応**: 主要言語ペア + +### その他(Bing, Papago等) +- **精度**: 中程度 +- **速度**: 中程度 +- **制限**: サービス依存 +- **対応**: サービス固有 + +## CTranslate2詳細 + +### 対応モデル +```python +ctranslate2_weights = { + "small": { + "url": "m2m100_418m.zip", + "directory_name": "m2m100_418m", + "tokenizer": "facebook/m2m100_418M" + }, + "large": { + "url": "m2m100_12b.zip", + "directory_name": "m2m100_12b", + "tokenizer": "facebook/m2m100_1.2b" + } +} +``` + +### パフォーマンス特性 + +#### small モデル +- **サイズ**: ~400MB +- **メモリ**: ~1GB RAM +- **VRAM**: ~500MB(CUDA使用時) +- **速度**: 高速 +- **精度**: 良好 + +#### large モデル +- **サイズ**: ~4.8GB +- **メモリ**: ~6GB RAM +- **VRAM**: ~3GB(CUDA使用時) +- **速度**: 中程度 +- **精度**: 高品質 + +### 計算タイプ設定 +```python +# CPU使用時 +compute_type = "int8" # 速度重視 + +# CUDA使用時 +compute_type = "float16" # バランス重視 +compute_type = "int8_float16" # メモリ効率重視 +``` + +## エラーハンドリング + +### ネットワークエラー +- 接続タイムアウト +- API制限超過 +- サービス一時停止 + +### 認証エラー +- 無効なAPIキー +- 期限切れアカウント +- 使用量上限到達 + +### モデルエラー +- ファイル破損 +- VRAM不足 +- 非対応言語ペア + +### 対応策 +```python +def robust_translation(translator, message, source_lang, target_lang): + """堅牢な翻訳処理""" + # オンライン翻訳を先に試行 + online_engines = ["DeepL_API", "DeepL", "Google"] + + for engine in online_engines: + try: + result = translator.translate(engine, source_lang, target_lang, "", message) + if result != False: + return result + except Exception as e: + print(f"{engine}エラー: {e}") + continue + + # オンライン翻訳が全て失敗した場合、ローカル翻訳にフォールバック + try: + if not translator.isLoadedCTranslate2Model(): + translator.changeCTranslate2Model(".", "small", "cpu") + + result = translator.translate("CTranslate2", source_lang, target_lang, "", message) + if result != False: + return result + except Exception as e: + print(f"ローカル翻訳エラー: {e}") + + return "翻訳に失敗しました" +``` + +## 依存関係 + +### 必須依存関係 +- `translation_languages`: 言語コード管理 +- `translation_utils`: CTranslate2ユーティリティ +- `utils`: エラーログ、計算デバイス管理 + +### オプション依存関係 +- `deepl`: DeepL APIライブラリ +- `translators`: Web翻訳サービスライブラリ +- `ctranslate2`: ローカル翻訳エンジン +- `transformers`: トークナイザー + +## 設定要件 + +### 環境変数 +- `DEEPL_AUTH_KEY`: DeepL APIキー(オプション) + +### ファイル配置 +``` +root/ +└── weights/ + └── ctranslate2/ + ├── m2m100_418m/ # smallモデル + └── m2m100_12b/ # largeモデル +``` + +## 注意事項 + +- Web翻訳サービスは利用制限に注意 +- CTranslate2の初回読み込みは時間がかかる +- GPU使用時はVRAM消費量に注意 +- API認証情報の適切な管理が必要 +- 長文翻訳時は分割処理を推奨 + +## 関連モジュール + +- `translation_languages.py`: 言語コードマッピング +- `translation_utils.py`: CTranslate2ユーティリティ +- `config.py`: 翻訳設定管理 +- `model.py`: 翻訳機能統合 +- `controller.py`: 翻訳制御インターフェース \ No newline at end of file diff --git a/src-python/docs/details/translation_utils.md b/src-python/docs/details/translation_utils.md new file mode 100644 index 00000000..9eab4d2b --- /dev/null +++ b/src-python/docs/details/translation_utils.md @@ -0,0 +1,438 @@ +# translation_utils.py - CTranslate2モデル管理ユーティリティ + +## 概要 + +CTranslate2によるローカル機械翻訳モデルの自動ダウンロード、展開、管理を行うユーティリティモジュールです。複数のモデルサイズ(small/large)とプラットフォーム(CPU/CUDA)に対応し、モデルファイルの完全性チェックと自動修復機能を提供します。 + +## 主要機能 + +### モデル自動管理 +- CTranslate2モデルの自動ダウンロード +- ZIP形式モデルの展開・配置 +- モデルファイルの完全性検証 +- 破損モデルの自動再取得 + +### マルチプラットフォーム対応 +- CPU版・CUDA版の両対応 +- 複数モデルサイズの管理 +- プラットフォーム別最適化 + +## 定数・設定 + +### モデル定義 + +```python +# CTranslate2重みファイル情報 +ctranslate2_weights = { + "small": { + "url": "m2m100_418m.zip", + "directory_name": "m2m100_418m", + "tokenizer": "facebook/m2m100_418M" + }, + "large": { + "url": "m2m100_12b.zip", + "directory_name": "m2m100_12b", + "tokenizer": "facebook/m2m100_1.2b" + } +} +``` + +### 設定パラメータ +- **BASE_WEIGHTS_URL**: モデル配布ベースURL +- **LOCAL_WEIGHTS_DIR**: ローカル保存ディレクトリ +- **CHUNK_SIZE**: ダウンロード時のチャンクサイズ + +## 主要機能 + +### モデルダウンロード + +```python +def downloadCTranslate2Model(model_type: str, device: str = "cpu") -> bool: + """CTranslate2モデルの自動ダウンロード""" +``` + +指定されたモデルタイプとデバイス用のモデルをダウンロード + +#### パラメータ +- **model_type**: モデルサイズ("small"/"large") +- **device**: 計算デバイス("cpu"/"cuda") + +#### 戻り値 +- **bool**: ダウンロード成功可否 + +### モデル存在確認 + +```python +def checkCTranslate2ModelExists(model_type: str, device: str = "cpu") -> bool: + """モデルファイルの存在確認""" +``` + +指定されたモデルがローカルに存在するかチェック + +#### パラメータ +- **model_type**: 確認対象モデルタイプ +- **device**: 対象デバイス + +#### 戻り値 +- **bool**: モデル存在可否 + +### モデル完全性検証 + +```python +def validateCTranslate2Model(model_type: str, device: str = "cpu") -> bool: + """モデルファイルの完全性検証""" +``` + +ダウンロード済みモデルの整合性を確認 + +#### パラメータ +- **model_type**: 検証対象モデル +- **device**: 対象デバイス + +#### 戻り値 +- **bool**: モデル正常性 + +## 使用方法 + +### 基本的なモデル管理 + +```python +from models.translation.translation_utils import * + +# smallモデル(CPU版)のダウンロード確認 +if not checkCTranslate2ModelExists("small", "cpu"): + print("smallモデルが見つかりません。ダウンロード中...") + success = downloadCTranslate2Model("small", "cpu") + + if success: + print("ダウンロード完了") + else: + print("ダウンロード失敗") +else: + print("smallモデルは既に存在します") +``` + +### GPU用モデルの準備 + +```python +# CUDA版largeモデルのセットアップ +model_type = "large" +device = "cuda" + +# 既存モデルの確認 +if checkCTranslate2ModelExists(model_type, device): + # モデルの完全性検証 + if validateCTranslate2Model(model_type, device): + print(f"{model_type}モデル({device}版)準備完了") + else: + print("モデルが破損しています。再ダウンロード中...") + # 破損モデルの再取得 + downloadCTranslate2Model(model_type, device) +else: + # 新規ダウンロード + print(f"{model_type}モデル({device}版)をダウンロード中...") + downloadCTranslate2Model(model_type, device) +``` + +### 自動モデル管理システム + +```python +def ensureModelReady(model_type="small", device="cpu", max_retries=3): + """モデルの準備を保証する関数""" + + for attempt in range(max_retries): + print(f"モデル準備 試行 {attempt + 1}/{max_retries}") + + # モデル存在確認 + if not checkCTranslate2ModelExists(model_type, device): + print("モデルが見つかりません。ダウンロード中...") + if not downloadCTranslate2Model(model_type, device): + print(f"ダウンロード失敗(試行 {attempt + 1})") + continue + + # モデル完全性確認 + if validateCTranslate2Model(model_type, device): + print("モデル準備完了") + return True + else: + print("モデルが破損しています。再取得中...") + # 破損ファイルの削除(実装依存) + # remove_corrupted_model(model_type, device) + continue + + print("モデル準備に失敗しました") + return False + +# 使用例 +if ensureModelReady("small", "cpu"): + print("翻訳システム初期化可能") +else: + print("翻訳システム初期化失敗") +``` + +### 複数モデルの一括管理 + +```python +def setupAllModels(): + """全モデルの一括セットアップ""" + + models = [ + ("small", "cpu"), + ("small", "cuda"), + ("large", "cpu"), + ("large", "cuda") + ] + + results = {} + + for model_type, device in models: + print(f"\n=== {model_type}モデル({device}版)セットアップ ===") + + # デバイス利用可能性チェック(CUDA版の場合) + if device == "cuda" and not torch.cuda.is_available(): + print("CUDA環境が利用できません。スキップします。") + results[(model_type, device)] = False + continue + + # モデル準備 + success = ensureModelReady(model_type, device) + results[(model_type, device)] = success + + if success: + print(f"✓ {model_type}({device}版)準備完了") + else: + print(f"✗ {model_type}({device}版)準備失敗") + + # 結果サマリー + print("\n=== セットアップ結果 ===") + for (model_type, device), success in results.items(): + status = "成功" if success else "失敗" + print(f"{model_type}({device}版): {status}") + + return results + +# 全モデルセットアップの実行 +setupAllModels() +``` + +## モデル仕様 + +### smallモデル(m2m100_418m) + +```python +model_info = { + "name": "m2m100_418m", + "size": "~400MB", + "parameters": "418M", + "languages": "100言語", + "tokenizer": "facebook/m2m100_418M", + "memory_requirements": { + "cpu": "~1GB RAM", + "cuda": "~500MB VRAM" + }, + "performance": { + "speed": "高速", + "quality": "良好" + } +} +``` + +#### 特徴 +- 高速処理に適している +- メモリ使用量が少ない +- リアルタイム翻訳に最適 +- 100言語ペア対応 + +### largeモデル(m2m100_12b) + +```python +model_info = { + "name": "m2m100_12b", + "size": "~4.8GB", + "parameters": "1.2B", + "languages": "100言語", + "tokenizer": "facebook/m2m100_1.2b", + "memory_requirements": { + "cpu": "~6GB RAM", + "cuda": "~3GB VRAM" + }, + "performance": { + "speed": "中程度", + "quality": "高品質" + } +} +``` + +#### 特徴 +- 高品質翻訳が可能 +- 大容量メモリが必要 +- バッチ処理に適している +- 複雑な文章に対応 + +## ファイル構造 + +### ディレクトリレイアウト +``` +weights/ +└── ctranslate2/ + ├── m2m100_418m/ # smallモデル(CPU版) + │ ├── model.bin + │ ├── vocabulary.txt + │ ├── config.json + │ └── shared_vocabulary.txt + ├── m2m100_418m_cuda/ # smallモデル(CUDA版) + │ └── [同様のファイル構成] + ├── m2m100_12b/ # largeモデル(CPU版) + │ └── [同様のファイル構成] + └── m2m100_12b_cuda/ # largeモデル(CUDA版) + └── [同様のファイル構成] +``` + +### 必須ファイル +- `model.bin`: 変換済みモデルウェイト +- `vocabulary.txt`: 語彙ファイル +- `config.json`: モデル設定ファイル +- `shared_vocabulary.txt`: 共有語彙ファイル + +## ダウンロード処理 + +### ネットワーク処理 + +```python +def downloadWithProgress(url: str, destination: str) -> bool: + """進捗表示付きダウンロード""" + try: + response = requests.get(url, stream=True) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + + with open(destination, 'wb') as file: + downloaded = 0 + for chunk in response.iter_content(chunk_size=CHUNK_SIZE): + if chunk: + file.write(chunk) + downloaded += len(chunk) + + # 進捗表示 + if total_size > 0: + progress = (downloaded / total_size) * 100 + print(f"\rダウンロード進捗: {progress:.1f}%", end="") + + print(f"\nダウンロード完了: {destination}") + return True + + except Exception as e: + print(f"\nダウンロードエラー: {e}") + return False +``` + +### 展開処理 + +```python +def extractZipModel(zip_path: str, extract_to: str) -> bool: + """ZIPファイルの展開""" + try: + with zipfile.ZipFile(zip_path, 'r') as zip_ref: + # 展開先ディレクトリの作成 + os.makedirs(extract_to, exist_ok=True) + + # ファイル展開 + zip_ref.extractall(extract_to) + + print(f"展開完了: {extract_to}") + + # 元のZIPファイルを削除(オプション) + os.remove(zip_path) + print(f"一時ファイル削除: {zip_path}") + + return True + + except Exception as e: + print(f"展開エラー: {e}") + return False +``` + +## エラーハンドリング + +### ネットワークエラー +- 接続タイムアウト +- ダウンロード中断 +- サーバーエラー + +### ファイルシステムエラー +- 容量不足 +- 権限エラー +- ファイル破損 + +### リトライ機構 + +```python +def downloadWithRetry(url: str, destination: str, max_retries: int = 3) -> bool: + """リトライ付きダウンロード""" + + for attempt in range(max_retries): + print(f"ダウンロード試行 {attempt + 1}/{max_retries}") + + try: + if downloadWithProgress(url, destination): + return True + except Exception as e: + print(f"試行 {attempt + 1} 失敗: {e}") + + # 一時ファイルの清理 + if os.path.exists(destination): + os.remove(destination) + + # 最後の試行でない場合は少し待機 + if attempt < max_retries - 1: + time.sleep(2 ** attempt) # 指数バックオフ + + print("全ての試行が失敗しました") + return False +``` + +## パフォーマンス最適化 + +### ダウンロード最適化 +- チャンク単位での分割ダウンロード +- 進捗表示による体験向上 +- 自動リトライによる信頼性確保 + +### ストレージ最適化 +- 一時ファイルの自動削除 +- 重複ファイルの検出・排除 +- 容量効率的なファイル管理 + +### メモリ最適化 +- ストリーミングダウンロード +- 大容量ファイル対応 +- メモリ使用量の制御 + +## 依存関係 + +### 必須依存関係 +- `requests`: HTTPダウンロード +- `zipfile`: アーカイブ展開 +- `os`: ファイルシステム操作 +- `pathlib`: パス操作 + +### オプション依存関係 +- `tqdm`: 進捗バー表示(実装による) +- `hashlib`: ファイル整合性検証(実装による) + +## 注意事項 + +- 初回ダウンロードは時間がかかる(モデルサイズ依存) +- 十分なストレージ容量を確保 +- ネットワーク環境によってダウンロード速度が変動 +- CUDA版は対応GPU環境が必要 +- モデルファイルのバックアップ推奨 + +## 関連モジュール + +- `translation_translator.py`: モデル利用クラス +- `translation_languages.py`: 言語コード管理 +- `config.py`: 設定管理 +- `utils.py`: 共通ユーティリティ +- `device_manager.py`: デバイス管理 \ No newline at end of file diff --git a/src-python/docs/details/transliteration_context_rules.md b/src-python/docs/details/transliteration_context_rules.md new file mode 100644 index 00000000..f98531c8 --- /dev/null +++ b/src-python/docs/details/transliteration_context_rules.md @@ -0,0 +1,397 @@ +# transliteration_context_rules.py - 文脈的転写ルールエンジン + +## 概要 + +トークン化された結果に対して文脈依存の転写ルールを適用するコンパクトなルールエンジンです。隣接するトークンの情報に基づいて読み(かな)を動的に修正し、より自然で正確な転写を実現します。 + +## 主要機能 + +### 文脈依存転写 +- 隣接トークン情報を利用した読み修正 +- 優先度ベースのルール適用順序 +- 正規表現・完全一致の両方に対応 + +### ルールエンジン +- 埋め込み型ルール定義(外部JSONファイル不要) +- 前方・後方の隣接トークン検査対応 +- インプレース変更による効率的処理 + +### 動的読み変更 +- 文脈に応じたかな読みの書き換え +- ひらがな・ヘボン式の自動クリア +- 呼び出し元での再計算トリガー + +## ルール定義構造 + +### DEFAULT_RULES + +```python +DEFAULT_RULES = { + "rules": [ + { + "name": "nan_next_tdna", # ルール名 + "target": "何", # 対象文字 + "match_mode": "equals", # マッチモード + "direction": "next", # 検査方向 + "kana_set": ["タ", "チ", "ツ"...], # 条件文字セット + "on_true": {"kana": "ナン"}, # 条件真時のアクション + "on_false": {"kana": "ナニ"} # 条件偽時のアクション + } + ] +} +``` + +### ルール要素 + +#### 基本設定 +- **name**: ルールの識別名 +- **target**: 適用対象となる文字・文字列 +- **priority**: 適用優先度(高い順に処理) +- **match_mode**: マッチングモード("equals"/"regex") + +#### 条件設定 +- **direction**: 隣接トークン検査方向("next"/"prev") +- **kana_set**: 条件判定用の文字セット +- **pattern**: 正規表現パターン(regex時) + +#### アクション設定 +- **on_true**: 条件成立時のアクション +- **on_false**: 条件不成立時のアクション +- **kana**: 設定する新しいかな読み + +## 主要関数 + +### apply_context_rules + +```python +def apply_context_rules(results: List[Dict[str, Any]], use_macron: bool = False) -> List[Dict[str, Any]] +``` + +文脈ルールをトークンリストに適用 + +#### パラメータ +- **results**: `Transliterator.split_kanji_okurigana`で生成されたトークン辞書のリスト +- **use_macron**: 互換性のためのパラメータ(ルール処理では未使用) + +#### 戻り値 +- **List[Dict[str, Any]]**: 修正されたトークンリスト(インプレース変更も実施) + +#### 必須キー +各トークン辞書は以下のキーを含む必要があります: +- **orig**: 元の文字・文字列 +- **kana**: かな読み +- **hira**: ひらがな表記 +- **hepburn**: ヘボン式ローマ字 + +## 使用方法 + +### 基本的な文脈ルール適用 + +```python +from models.transliteration.transliteration_context_rules import apply_context_rules + +# トークン化された結果(例) +results = [ + {"orig": "何", "kana": "ナニ", "hira": "なに", "hepburn": "nani"}, + {"orig": "度", "kana": "ド", "hira": "ど", "hepburn": "do"}, + {"orig": "も", "kana": "モ", "hira": "も", "hepburn": "mo"} +] + +# 文脈ルールの適用 +modified_results = apply_context_rules(results) + +# 結果確認 +for token in modified_results: + print(f"{token['orig']}: {token['kana']} -> {token['hira']} ({token['hepburn']})") + +# 期待される出力(「何度」の場合): +# 何: ナン -> (再計算必要) (再計算必要) +# 度: ド -> ど (do) +# も: モ -> も (mo) +``` + +### カスタムルールでの処理 + +```python +# 独自ルール定義の例 +custom_rules = { + "rules": [ + { + "name": "custom_rule_example", + "target": "今", + "match_mode": "equals", + "direction": "next", + "kana_set": ["バ", "ビ", "ブ", "ベ", "ボ"], + "priority": 100, + "on_true": {"kana": "イマ"}, + "on_false": {"kana": "コン"} + } + ] +} + +# 注意:現在の実装では DEFAULT_RULES が固定使用されています +# カスタムルールを使用するには関数の拡張が必要です +``` + +### 正規表現マッチングの例 + +```python +# 正規表現ルールの定義例 +regex_rule = { + "name": "kanji_pattern_rule", + "match_mode": "regex", + "pattern": r"^[一-龯]$", # 任意の漢字1文字 + "direction": "next", + "kana_set": ["ア", "イ", "ウ", "エ", "オ"], + "priority": 50, + "on_true": {"kana": "特殊読み"}, + "on_false": {"kana": "通常読み"} +} +``` + +### 転写パイプラインでの統合 + +```python +def complete_transliteration_pipeline(text): + """完全な転写パイプライン""" + + # 1. 初期分割・転写 + transliterator = Transliterator() + tokens = transliterator.split_kanji_okurigana(text) + + # 2. 文脈ルール適用 + tokens = apply_context_rules(tokens) + + # 3. 修正されたトークンの再計算 + for token in tokens: + if token.get("kana") and not token.get("hira"): + # ひらがな・ヘボン式の再計算 + token["hira"] = katakana_to_hiragana(token["kana"]) + token["hepburn"] = hiragana_to_hepburn(token["hira"]) + + return tokens + +# 使用例 +text = "何度でも挑戦する" +result = complete_transliteration_pipeline(text) + +for token in result: + print(f"{token['orig']} -> {token['kana']} -> {token['hira']} -> {token['hepburn']}") +``` + +## ルール処理ロジック + +### 処理フロー + +1. **ルール準備** + - 優先度の降順でソート + - 正規表現の事前コンパイル + +2. **トークン走査** + - 各トークンに対してルールを順次適用 + - 空の`orig`を持つトークンはスキップ + +3. **マッチング判定** + - `equals`: 完全一致判定 + - `regex`: 正規表現マッチ判定 + +4. **隣接トークン検査** + - `direction`に基づく隣接トークン特定 + - 空のトークンをスキップして有効トークンを検索 + +5. **条件評価** + - 隣接トークンの`kana`の先頭文字チェック + - `kana_set`との一致判定 + +6. **アクション実行** + - 条件に応じて`on_true`/`on_false`を選択 + - `kana`の書き換えと`hira`/`hepburn`のクリア + +### アルゴリズム詳細 + +```python +def process_token_with_rules(token_index, tokens, rules): + """単一トークンのルール処理アルゴリズム""" + + token = tokens[token_index] + orig = token.get("orig", "") + + # 空トークンはスキップ + if not orig: + return + + for rule in rules: # 優先度順 + # マッチング判定 + if not matches_rule(orig, rule): + continue + + # 隣接トークン検索 + neighbor = find_neighbor_token(token_index, tokens, rule["direction"]) + + if neighbor: + # 条件評価 + condition = evaluate_condition(neighbor, rule["kana_set"]) + + # アクション実行 + action = rule["on_true"] if condition else rule["on_false"] + apply_action(token, action) + + # 最初にマッチしたルールで処理終了 + break + +def find_neighbor_token(current_index, tokens, direction): + """隣接する有効トークンを検索""" + + if direction == "next": + for i in range(current_index + 1, len(tokens)): + if tokens[i].get("orig"): + return tokens[i] + elif direction == "prev": + for i in range(current_index - 1, -1, -1): + if tokens[i].get("orig"): + return tokens[i] + + return None +``` + +## 具体的なルール例 + +### 「何」の読み分けルール + +```python +{ + "name": "nan_next_tdna", + "target": "何", + "match_mode": "equals", + "direction": "next", + "kana_set": ["タ", "チ", "ツ", "テ", "ト", "ダ", "ヂ", "ヅ", "デ", "ド", "ナ", "ニ", "ヌ", "ネ", "ノ"], + "on_true": {"kana": "ナン"}, + "on_false": {"kana": "ナニ"} +} +``` + +#### 動作例 + +```python +# 「何度」の場合 +tokens = [ + {"orig": "何", "kana": "ナニ"}, # 初期状態 + {"orig": "度", "kana": "ド"} # 次のトークン +] + +# ルール適用後 +tokens = [ + {"orig": "何", "kana": "ナン"}, # 「ド」が kana_set に含まれるため "ナン" に変更 + {"orig": "度", "kana": "ド"} +] + +# 「何回」の場合 +tokens = [ + {"orig": "何", "kana": "ナニ"}, # 初期状態 + {"orig": "回", "kana": "カイ"} # 次のトークン +] + +# ルール適用後 +tokens = [ + {"orig": "何", "kana": "ナニ"}, # 「カイ」が kana_set に含まれないため "ナニ" のまま + {"orig": "回", "kana": "カイ"} +] +``` + +## エラーハンドリング + +### 正規表現コンパイルエラー +```python +# 不正な正規表現の安全な処理 +for rule in rules: + if rule.get("match_mode") == "regex" and rule.get("pattern"): + try: + rule["_re"] = re.compile(rule["pattern"]) + except Exception as e: + print(f"正規表現コンパイルエラー: {rule['pattern']} - {e}") + rule["_re"] = None # 無効化 +``` + +### 不正なトークン構造 +```python +# 必須キーの存在確認 +def validate_token(token): + """トークンの妥当性検証""" + required_keys = ["orig", "kana", "hira", "hepburn"] + + for key in required_keys: + if key not in token: + print(f"警告: トークンに必須キー '{key}' が不足") + token[key] = "" # デフォルト値を設定 + + return token +``` + +## パフォーマンス考慮事項 + +### 効率的な処理 +- インプレース変更によるメモリ効率 +- 優先度ソートによる早期終了 +- 正規表現の事前コンパイル + +### スケーラビリティ +- 大量トークンでの線形処理時間 +- ルール数の増加に対する適切な対応 +- キャッシュ機能の追加可能性 + +## 拡張可能性 + +### ルール形式の拡張 +```python +# より複雑なルール例(将来的な拡張) +complex_rule = { + "name": "multi_condition_rule", + "target": "言", + "conditions": [ + {"direction": "prev", "kana_set": ["オ", "コ"]}, + {"direction": "next", "kana_set": ["ハ", "バ"]} + ], + "operator": "AND", # or "OR" + "actions": { + "all_true": {"kana": "ゴン"}, + "any_true": {"kana": "ゲン"}, + "all_false": {"kana": "イ"} + } +} +``` + +### 動的ルール追加 +```python +def add_runtime_rule(new_rule): + """実行時ルール追加(拡張版)""" + # ルールの検証 + if validate_rule_format(new_rule): + DEFAULT_RULES["rules"].append(new_rule) + return True + return False +``` + +## 依存関係 + +### 必須依存関係 +- `typing`: 型ヒント +- `re`: 正規表現処理 + +### 関連モジュール +- `transliteration_transliterator.py`: メイン転写クラス +- `transliteration_kana_to_hepburn.py`: かな→ヘボン式変換 + +## 注意事項 + +- ルール適用後は`hira`と`hepburn`が空文字列になるため、呼び出し元での再計算が必要 +- 現在のルールは日本語に特化している +- ルール適用順序は優先度に依存するため、適切な設定が重要 +- 正規表現ルールはパフォーマンスに影響する可能性がある + +## 将来の改善点 + +- 外部ルールファイルの読み込み対応 +- より複雑な条件式のサポート +- ルール適用ログ・デバッグ機能 +- 言語別ルールセットの対応 +- パフォーマンス最適化とキャッシュ機能 \ No newline at end of file diff --git a/src-python/docs/details/transliteration_kana_to_hepburn.md b/src-python/docs/details/transliteration_kana_to_hepburn.md new file mode 100644 index 00000000..39e6d042 --- /dev/null +++ b/src-python/docs/details/transliteration_kana_to_hepburn.md @@ -0,0 +1,465 @@ +# transliteration_kana_to_hepburn.py - カタカナ→ヘボン式変換 + +## 概要 + +カタカナ文字列を標準的なヘボン式ローマ字に変換するモジュールです。マクロン(長音記号)対応、外来語音の変換、促音・撥音処理など、日本語のローマ字表記に必要な機能を包括的に提供します。 + +## 主要機能 + +### 標準的なヘボン式変換 +- カタカナ文字の基本ローマ字変換 +- マクロン(ā ī ū ē ō)による長音表現 +- 連続母音表記の選択的対応 + +### 特殊音処理 +- 促音(ッ)の適切な子音重複処理 +- 撥音(ン)のm/n使い分け +- 長音符(ー)の前母音延長処理 + +### 外来語対応 +- シェ(she)、チェ(che)等の組み合わせ +- ヴ音(vu, va, vi, ve, vo)の変換 +- ファ行(fa, fi, fe, fo)の処理 + +## 主要関数 + +### katakana_to_hepburn + +```python +def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str +``` + +カタカナ文字列をヘボン式ローマ字に変換 + +#### パラメータ +- **kata**: 変換対象のカタカナ文字列 +- **use_macron**: マクロン使用フラグ(True=ā ī ū ē ō、False=aa ii uu ee oo) + +#### 戻り値 +- **str**: ヘボン式ローマ字文字列(小文字) + +## 使用方法 + +### 基本的な変換 + +```python +from models.transliteration.transliteration_kana_to_hepburn import katakana_to_hepburn + +# 基本的なカタカナ変換 +result1 = katakana_to_hepburn("カタカナ") +print(result1) # "katakana" + +# 長音のマクロン表記 +result2 = katakana_to_hepburn("コンピューター", use_macron=True) +print(result2) # "konpyūtā" + +# 長音の連続母音表記 +result3 = katakana_to_hepburn("コンピューター", use_macron=False) +print(result3) # "konpyuutaa" +``` + +### 特殊音の処理 + +```python +# 促音(ッ)の処理 +result1 = katakana_to_hepburn("キャッチ") +print(result1) # "kyatchi" + +result2 = katakana_to_hepburn("マッチャ") +print(result2) # "matcha" + +# 撥音(ン)の処理 +result3 = katakana_to_hepburn("ホンバン") # ホン+バン +print(result3) # "homban" (n→m変換) + +result4 = katakana_to_hepburn("ホンテン") # ホン+テン +print(result4) # "honten" (nのまま) +``` + +### 外来語音の変換 + +```python +# 外来語特殊音 +result1 = katakana_to_hepburn("シェア") +print(result1) # "shea" + +result2 = katakana_to_hepburn("チェック") +print(result2) # "chekku" + +result3 = katakana_to_hepburn("ジェット") +print(result3) # "jetto" + +# ヴ音の処理 +result4 = katakana_to_hepburn("ヴァイオリン") +print(result4) # "vaiorin" + +result5 = katakana_to_hepburn("ヴィーナス") +print(result5) # "vīnasu" + +# ファ行の処理 +result6 = katakana_to_hepburn("ファイル") +print(result6) # "fairu" + +result7 = katakana_to_hepburn("フィルム") +print(result7) # "firumu" +``` + +### 長音処理の詳細 + +```python +# 長音符(ー)の処理 +result1 = katakana_to_hepburn("スーパー", use_macron=True) +print(result1) # "sūpā" + +result2 = katakana_to_hepburn("パーティー", use_macron=True) +print(result2) # "pātī" + +# ou → ō の変換(東京型) +result3 = katakana_to_hepburn("トウキョウ", use_macron=True) +print(result3) # "tōkyō" + +# 連続母音表記との比較 +result4 = katakana_to_hepburn("トウキョウ", use_macron=False) +print(result4) # "toukyou" +``` + +### 複雑な組み合わせ + +```python +# 拗音(ゃゅょ)の組み合わせ +test_cases = [ + ("キャンプ", "kyanpu"), + ("シュート", "shūto"), + ("チョコレート", "chokorēto"), + ("ギュウニュウ", "gyūnyū"), + ("リュックサック", "ryukkusakku"), + ("ピョンピョン", "pyonpyon") +] + +for kata, expected in test_cases: + result = katakana_to_hepburn(kata) + print(f"{kata} -> {result}") + # キャンプ -> kyanpu + # シュート -> shūto + # チョコレート -> chokorēto + # ギュウニュウ -> gyūnyū + # リュックサック -> ryukkusakku + # ピョンピョン -> pyonpyon +``` + +## 変換ルール詳細 + +### 基本音対応表 + +```python +base_mapping = { + # 清音 + 'ア':'a', 'イ':'i', 'ウ':'u', 'エ':'e', 'オ':'o', + 'カ':'ka', 'キ':'ki', 'ク':'ku', 'ケ':'ke', 'コ':'ko', + 'サ':'sa', 'シ':'shi', 'ス':'su', 'セ':'se', 'ソ':'so', + 'タ':'ta', 'チ':'chi', 'ツ':'tsu', 'テ':'te', 'ト':'to', + 'ナ':'na', 'ニ':'ni', 'ヌ':'nu', 'ネ':'ne', 'ノ':'no', + 'ハ':'ha', 'ヒ':'hi', 'フ':'fu', 'ヘ':'he', 'ホ':'ho', + 'マ':'ma', 'ミ':'mi', 'ム':'mu', 'メ':'me', 'モ':'mo', + 'ヤ':'ya', 'ユ':'yu', 'ヨ':'yo', + 'ラ':'ra', 'リ':'ri', 'ル':'ru', 'レ':'re', 'ロ':'ro', + 'ワ':'wa', 'ヲ':'wo', 'ン':'n', + + # 濁音・半濁音 + 'ガ':'ga', 'ギ':'gi', 'グ':'gu', 'ゲ':'ge', 'ゴ':'go', + 'ザ':'za', 'ジ':'ji', 'ズ':'zu', 'ゼ':'ze', 'ゾ':'zo', + 'ダ':'da', 'ヂ':'ji', 'ヅ':'zu', 'デ':'de', 'ド':'do', + 'バ':'ba', 'ビ':'bi', 'ブ':'bu', 'ベ':'be', 'ボ':'bo', + 'パ':'pa', 'ピ':'pi', 'プ':'pu', 'ペ':'pe', 'ポ':'po', + + # 特殊音 + 'ヴ':'vu' +} +``` + +### 拗音組み合わせ + +```python +digraphs_mapping = { + # キャ行 + ('キ','ャ'):'kya', ('キ','ュ'):'kyu', ('キ','ョ'):'kyo', + ('ギ','ャ'):'gya', ('ギ','ュ'):'gyu', ('ギ','ョ'):'gyo', + + # シャ行 + ('シ','ャ'):'sha', ('シ','ュ'):'shu', ('シ','ョ'):'sho', + ('ジ','ャ'):'ja', ('ジ','ュ'):'ju', ('ジ','ョ'):'jo', + + # チャ行 + ('チ','ャ'):'cha', ('チ','ュ'):'chu', ('チ','ョ'):'cho', + + # その他の拗音 + ('ニ','ャ'):'nya', ('ヒ','ャ'):'hya', ('ビ','ャ'):'bya', + ('ピ','ャ'):'pya', ('ミ','ャ'):'mya', ('リ','ャ'):'rya', + + # 外来語音(ファ行等) + ('フ','ァ'):'fa', ('フ','ィ'):'fi', ('フ','ェ'):'fe', ('フ','ォ'):'fo', + ('シ','ェ'):'she', ('チ','ェ'):'che', ('テ','ィ'):'ti', + ('ツ','ァ'):'tsa', ('ツ','ィ'):'tsi', ('ツ','ェ'):'tse', ('ツ','ォ'):'tso', + + # ヴ音組み合わせ + ('ヴ','ァ'):'va', ('ヴ','ィ'):'vi', ('ヴ','ェ'):'ve', + ('ヴ','ォ'):'vo', ('ヴ','ュ'):'vyu' +} +``` + +### マクロン変換規則 + +```python +macron_rules = { + 'aa': 'ā', # カア → kā + 'ii': 'ī', # キイ → kī + 'uu': 'ū', # クウ → kū + 'ee': 'ē', # ケエ → kē + 'oo': 'ō', # コオ → kō + 'ou': 'ō' # コウ → kō(東京型長音) +} +``` + +## 特殊処理アルゴリズム + +### 促音(ッ)処理 + +```python +def handle_sokuon(current_pos, kata_string, result_list): + """促音の処理アルゴリズム""" + + # 次の音を確認 + if current_pos + 1 < len(kata_string): + next_kana = kata_string[current_pos + 1] + + # 次の音のローマ字を取得 + next_roman = get_next_roman(next_kana, kata_string[current_pos + 1:]) + + # 子音部分を抽出して重複 + consonant = extract_initial_consonant(next_roman) + if consonant: + result_list.append(consonant[0]) # 先頭子音を重複 + + # 促音自体は消費 + return current_pos + 1 + +# 例: +# マッチャ -> ma + tcha (ッ -> t重複) -> matcha +# キャッチ -> kya + tchi (ッ -> t重複) -> kyatchi +``` + +### 撥音(ン)処理 + +```python +def handle_hatsuon(roman_string): + """撥音のm/n使い分け処理""" + + # n の後に b/p/m が続く場合は m に変換 + import re + + # パターン: n + [bmp] -> m + [bmp] + result = re.sub(r'n(?=[bmp])', 'm', roman_string) + + return result + +# 例: +# ホンバン -> honban -> homban +# サンポ -> sanpo -> sampo +# コンマ -> konma -> komma +# but: ホンテン -> honten (変更なし) +``` + +### 長音符(ー)処理 + +```python +def handle_choonpu(roman_list): + """長音符の前母音延長処理""" + + result = [] + i = 0 + + while i < len(roman_list): + if roman_list[i] == '-': # 長音符マーカー + if i > 0: + prev_char = result[-1] # 直前の文字 + if prev_char in 'aiueo': + # 前が母音なら重複(後でマクロン処理) + result.append(prev_char) + # else: 子音の場合は無視 + else: + result.append(roman_list[i]) + i += 1 + + return result + +# 例: +# スー -> su + - -> suu -> sū (マクロン処理後) +# パーティー -> pa + - + ti + - -> paatii -> pātī +``` + +## 実装例・テストケース + +### 基本テストセット + +```python +def run_basic_tests(): + """基本変換テストセット""" + + test_cases = [ + # 基本音 + ("アイウエオ", "aiueo"), + ("カキクケコ", "kakikukeko"), + ("サシスセソ", "sashisuseso"), + + # 濁音・半濁音 + ("ガギグゲゴ", "gagigugego"), + ("ザジズゼゾ", "zajizuzezo"), + ("バビブベボ", "babibubebo"), + ("パピプペポ", "papipupepo"), + + # 特殊音 + ("シャシュショ", "shashusho"), + ("チャチュチョ", "chachucho"), + ("ジャジュジョ", "jajujo"), + + # 促音 + ("アッパ", "appa"), + ("イッキ", "ikki"), + ("エッサ", "essa"), + + # 撥音 + ("アンパン", "ampan"), + ("コンマ", "komma"), + ("ホンテン", "honten") + ] + + for kata, expected in test_cases: + result = katakana_to_hepburn(kata) + assert result == expected, f"Failed: {kata} -> {result} (expected {expected})" + print(f"✓ {kata} -> {result}") + +run_basic_tests() +``` + +### 外来語テストセット + +```python +def run_foreign_word_tests(): + """外来語変換テストセット""" + + foreign_tests = [ + # ファ行 + ("ファイル", "fairu"), + ("フィルム", "firumu"), + ("フェイス", "feisu"), + ("フォント", "fonto"), + + # シェ・チェ + ("シェア", "shea"), + ("シェル", "sheru"), + ("チェック", "chekku"), + ("チェイン", "chein"), + + # ヴ音 + ("ヴァイオリン", "vaiorin"), + ("ヴィーナス", "vīnasu"), + ("ヴェール", "vēru"), + ("ヴォーカル", "vōkaru"), + + # ティ・トゥ・ドゥ + ("ティー", "tī"), + ("パーティー", "pātī"), + ("トゥー", "tū"), + ("ドゥー", "dū") + ] + + for kata, expected in foreign_tests: + result = katakana_to_hepburn(kata, use_macron=True) + print(f"✓ {kata} -> {result}") + # 実際のexpectedとの比較は実装依存 + +run_foreign_word_tests() +``` + +### 長音テストセット + +```python +def run_long_vowel_tests(): + """長音処理テストセット""" + + long_vowel_tests = [ + # マクロンあり + ("コーヒー", "kōhī", True), + ("スーパー", "sūpā", True), + ("パーティー", "pātī", True), + ("トウキョウ", "tōkyō", True), # ou -> ō + + # マクロンなし + ("コーヒー", "koohii", False), + ("スーパー", "suupaa", False), + ("パーティー", "paatii", False), + ("トウキョウ", "toukyou", False) + ] + + for kata, expected, use_macron in long_vowel_tests: + result = katakana_to_hepburn(kata, use_macron=use_macron) + print(f"✓ {kata} -> {result} (macron={use_macron})") + +run_long_vowel_tests() +``` + +## パフォーマンス考慮事項 + +### 効率的な処理 +- 単一パス処理による高速変換 +- 正規表現の最小限使用 +- 辞書ルックアップの最適化 + +### メモリ効率 +- 文字列連結の最適化 +- 不要な中間オブジェクトの削減 +- 大量テキスト処理への対応 + +## 制限事項・注意点 + +### 変換精度の制限 +- 文脈に依存する読み分けは非対応 +- 固有名詞の特殊読みは非対応 +- 方言・古語の特殊音は非対応 + +### ヘボン式の範囲 +- 標準的なヘボン式に準拠 +- 一部の外来語音は近似変換 +- 撥音の文脈依存ルールは簡略化 + +### 入力制限 +```python +# 適切な入力例 +good_inputs = ["カタカナ", "シャープ", "コンピューター"] + +# 問題のある入力例 +problematic_inputs = [ + "ひらがな", # ひらがな混在(処理されるがそのまま) + "English", # 英字混在(処理されるがそのまま) + "123数字", # 数字混在(処理されるがそのまま) + "", # 空文字列(空文字列を返す) +] + +# 混在入力の処理例 +mixed_result = katakana_to_hepburn("カタカナと英語English") +print(mixed_result) # "katakanaと英語english" +``` + +## 関連モジュール + +- `transliteration_transliterator.py`: メイン転写クラス +- `transliteration_context_rules.py`: 文脈依存ルール +- 外部のひらがな↔カタカナ変換モジュール(必要に応じて) + +## 将来の改善点 + +- 更なる外来語音への対応 +- 文脈依存の読み分け機能 +- パフォーマンス最適化 +- より詳細なヘボン式バリエーション対応 +- 音韻変化ルールの追加 \ No newline at end of file diff --git a/src-python/docs/details/transliteration_transliterator.md b/src-python/docs/details/transliteration_transliterator.md new file mode 100644 index 00000000..73dad537 --- /dev/null +++ b/src-python/docs/details/transliteration_transliterator.md @@ -0,0 +1,659 @@ +# transliteration_transliterator.py - 総合音写・転写システム + +## 概要 + +SudachiPyを利用した日本語のローマ字転写システムのメインクラスです。形態素解析、漢字・送り仮名の分離、文脈依存ルールの適用、ヘボン式変換を統合し、高精度な日本語ローマ字化を提供します。 + +## 主要機能 + +### 統合転写システム +- SudachiPyによる高精度形態素解析 +- 漢字・送り仮名の自動分離処理 +- 文脈依存読み変更ルールの適用 + +### 多層変換処理 +- カタカナ読み取得・分配 +- ひらがな自動変換 +- ヘボン式ローマ字生成 + +### 並行処理対応 +- スレッドセーフなトークナイザー利用 +- ロック機構による安全な並行実行 +- 高負荷環境での安定動作 + +## クラス構造 + +### Transliterator クラス +```python +class Transliterator: + def __init__(self) -> None: + self.tokenizer_obj: tokenizer.Tokenizer + self.mode: tokenizer.Tokenizer.SplitMode + self._tokenizer_lock: threading.Lock +``` + +日本語転写処理の中核クラス + +#### 属性 +- **tokenizer_obj**: SudachiPyトークナイザーインスタンス +- **mode**: 分割モード(SplitMode.C = 最長一致) +- **_tokenizer_lock**: 並行アクセス制御用ミューテックス + +## 主要メソッド + +### analyze + +```python +def analyze(self, text: str, use_macron: bool = False) -> List[Dict[str, Any]] +``` + +テキストを解析して転写情報を生成 + +#### パラメータ +- **text**: 解析対象の日本語テキスト +- **use_macron**: マクロン使用フラグ(長音表記方式) + +#### 戻り値 +- **List[Dict[str, Any]]**: トークン転写情報のリスト + +#### 出力辞書構造 +```python +{ + "orig": str, # 元の文字・文字列 + "kana": str, # カタカナ読み + "hira": str, # ひらがな読み + "hepburn": str # ヘボン式ローマ字 +} +``` + +### split_kanji_okurigana (静的メソッド) + +```python +@staticmethod +def split_kanji_okurigana(surface: str, reading_kana: str, use_macron: bool = True) -> List[Dict[str, str]] +``` + +単語の表層形と読みを漢字・送り仮名ブロックに分割 + +#### パラメータ +- **surface**: 表層形(漢字+ひらがな混在可能) +- **reading_kana**: 全体のカタカナ読み +- **use_macron**: ヘボン式変換でのマクロン使用 + +#### 戻り値 +- **List[Dict[str, str]]**: 分割された部分の転写情報 + +## 補助メソッド + +### is_kanji (静的メソッド) + +```python +@staticmethod +def is_kanji(ch: str) -> bool +``` + +文字が漢字かどうかを判定 + +#### パラメータ +- **ch**: 判定対象文字 + +#### 戻り値 +- **bool**: 漢字判定結果 + +### kata_to_hira (静的メソッド) + +```python +@staticmethod +def kata_to_hira(text: str) -> str +``` + +カタカナをひらがなに変換 + +#### パラメータ +- **text**: 変換対象のカタカナテキスト + +#### 戻り値 +- **str**: ひらがな変換結果 + +## 使用方法 + +### 基本的な転写処理 + +```python +from models.transliteration.transliteration_transliterator import Transliterator + +# 転写システムの初期化 +transliterator = Transliterator() + +# 基本的な文章の転写 +text = "向こうへ行く" +results = transliterator.analyze(text) + +for token in results: + print(f"{token['orig']} -> {token['kana']} -> {token['hira']} -> {token['hepburn']}") + +# 期待される出力例: +# 向こう -> ムコウ -> むこう -> mukou +# へ -> ヘ -> へ -> he +# 行く -> イク -> いく -> iku +``` + +### マクロン使用の長音処理 + +```python +# マクロンを使用した長音表記 +text = "東京に行く" +results_macron = transliterator.analyze(text, use_macron=True) +results_normal = transliterator.analyze(text, use_macron=False) + +print("=== マクロンあり ===") +for token in results_macron: + print(f"{token['orig']} -> {token['hepburn']}") + +print("=== マクロンなし ===") +for token in results_normal: + print(f"{token['orig']} -> {token['hepburn']}") + +# 期待される出力: +# === マクロンあり === +# 東京 -> tōkyō +# に -> ni +# 行く -> iku + +# === マクロンなし === +# 東京 -> toukyou +# に -> ni +# 行く -> iku +``` + +### 複雑な文章の処理 + +```python +# 漢字・ひらがな・カタカナ・英語混在文の処理 +complex_text = "パーティーで美しい花を見る" +results = transliterator.analyze(complex_text, use_macron=True) + +for token in results: + print(f"原文: '{token['orig']}'") + print(f" カナ: {token['kana']}") + print(f" ひら: {token['hira']}") + print(f" ローマ: {token['hepburn']}") + print() + +# 期待される出力: +# 原文: 'パーティー' +# カナ: パーティー +# ひら: ぱーてぃー +# ローマ: pātī +# +# 原文: 'で' +# カナ: デ +# ひら: で +# ローマ: de +# +# 原文: '美しい' +# カナ: ウツクシイ +# ひら: うつくしい +# ローマ: utsukushii +``` + +### 文脈依存ルールの効果確認 + +```python +# 文脈に依存する読み変更の例(「何」の読み分け) +test_cases = [ + "何が好き?", # 何 -> ナニ (後続が「ガ」) + "何度も挑戦", # 何 -> ナン (後続が「ド」) + "何色ありますか?" # 何 -> ナニ (後続が「イ」) +] + +for text in test_cases: + results = transliterator.analyze(text) + + print(f"入力: {text}") + + # 「何」トークンを探して読みを確認 + for token in results: + if token['orig'] == '何': + print(f"「何」の読み: {token['kana']} -> {token['hepburn']}") + break + print() + +# 期待される出力: +# 入力: 何が好き? +# 「何」の読み: ナニ -> nani +# +# 入力: 何度も挑戦 +# 「何」の読み: ナン -> nan +# +# 入力: 何色ありますか? +# 「何」の読み: ナニ -> nani +``` + +### 特殊文字・記号の処理 + +```python +# 記号・英数字混在テキストの処理 +mixed_text = "ID:12345、URL:https://example.com" +results = transliterator.analyze(mixed_text) + +for token in results: + print(f"'{token['orig']}' -> '{token['hepburn']}'") + +# 期待される出力: +# 'ID' -> 'ID' # 英字はそのまま +# ':' -> ':' # 記号はそのまま +# '12345' -> '12345' # 数字はそのまま +# '、' -> '、' # 区切り記号はそのまま +# 'URL' -> 'URL' # 英字はそのまま +``` + +## 内部処理フロー + +### 解析処理パイプライン + +```python +def analyze_pipeline_explained(self, text): + """転写処理パイプラインの詳細説明""" + + # 1. SudachiPy形態素解析 + with self._tokenizer_lock: + tokens = self.tokenizer_obj.tokenize(text, self.mode) + + results = [] + + # 2. 各トークンの処理 + for token in tokens: + surface = token.surface() # 表層形 + reading = token.reading_form() # 読み(カタカナ) + pos = token.part_of_speech() # 品詞情報 + + # 3. 記号・空白の特別処理 + if pos and pos[0] in ["記号", "補助記号", "空白"]: + reading = surface # 記号は表層形をそのまま使用 + + # 4. 表層形と読みが同じ場合(ひらがな・記号等) + if surface == reading: + results.append({ + "orig": surface, + "kana": reading, + "hira": surface, # そのまま + "hepburn": surface # そのまま + }) + continue + + # 5. 単一文字の処理 + if len(surface) == 1: + results.append({ + "orig": surface, + "kana": reading, + "hira": self.kata_to_hira(reading), + "hepburn": katakana_to_hepburn(reading, use_macron) + }) + else: + # 6. 複数文字の漢字・送り仮名分離 + parts = self.split_kanji_okurigana(surface, reading, use_macron) + results.extend(parts) + + # 7. 文脈依存ルールの適用 + try: + results = apply_context_rules(results, use_macron) or results + except Exception: + pass # ルール適用失敗時は元の結果を使用 + + # 8. ルール適用後の再計算 + for entry in results: + kana = entry.get("kana", "") + if kana: + entry["hira"] = self.kata_to_hira(kana) + entry["hepburn"] = katakana_to_hepburn(kana, use_macron) + + return results +``` + +### 漢字・送り仮名分離アルゴリズム + +```python +def split_algorithm_explained(surface, reading_kana): + """分離アルゴリズムの詳細説明""" + + # 1. 表層形のブロック分割 + blocks = [] + current_block = "" + prev_is_kanji = None + + for char in surface: + is_kanji = Transliterator.is_kanji(char) + + if prev_is_kanji is None or is_kanji == prev_is_kanji: + # 同じタイプの文字は同じブロックに + current_block += char + else: + # タイプが変わったら新しいブロック + blocks.append((prev_is_kanji, current_block)) + current_block = char + + prev_is_kanji = is_kanji + + if current_block: + blocks.append((prev_is_kanji, current_block)) + + # 例: "向こう" -> [(True, "向"), (False, "こう")] + # "行く" -> [(True, "行"), (False, "く")] + + # 2. 読みの分配 + kana_len = len(reading_kana) + + # 初期割当: 各ブロックの文字数に比例 + allocations = [len(block_text) for _, block_text in blocks] + allocated_total = sum(allocations) + remaining = kana_len - allocated_total + + # 3. 余った読みの分配(漢字ブロック優先) + if remaining > 0: + # まず漢字ブロックに分配 + for i, (is_kanji, _) in enumerate(blocks): + if remaining <= 0: + break + if is_kanji: + allocations[i] += 1 + remaining -= 1 + + # まだ余りがある場合は左から順に分配 + i = 0 + while remaining > 0 and len(blocks) > 0: + allocations[i] += 1 + remaining -= 1 + i = (i + 1) % len(blocks) + + # 4. 読みが不足している場合は右から削減 + if remaining < 0: + need_to_remove = -remaining + i = len(blocks) - 1 + + while need_to_remove > 0 and i >= 0: + can_remove = max(0, allocations[i] - 1) + remove_amount = min(can_remove, need_to_remove) + allocations[i] -= remove_amount + need_to_remove -= remove_amount + i -= 1 + + # 5. 最終的な読み分配 + pos = 0 + result = [] + + for (is_kanji, block_text), allocation in zip(blocks, allocations): + block_reading = reading_kana[pos:pos + allocation] + pos += allocation + + result.append({ + "orig": block_text, + "kana": block_reading, + "hira": Transliterator.kata_to_hira(block_reading), + "hepburn": katakana_to_hepburn(block_reading, use_macron) + }) + + return result +``` + +## 並行処理・スレッドセーフティ + +### ロック機構 + +```python +class ThreadSafeUsage: + """スレッドセーフな使用例""" + + def __init__(self): + self.transliterator = Transliterator() + + def process_texts_concurrently(self, texts): + """複数テキストの並行処理""" + import concurrent.futures + + def process_single(text): + return self.transliterator.analyze(text) + + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor: + # 内部のロック機構により安全に並行実行 + futures = [executor.submit(process_single, text) for text in texts] + results = [f.result() for f in futures] + + return results + +# 使用例 +processor = ThreadSafeUsage() +texts = ["東京に行く", "大阪で食事", "名古屋を観光", "福岡に宿泊"] +results = processor.process_texts_concurrently(texts) + +for i, result in enumerate(results): + print(f"テキスト{i+1}: {texts[i]}") + for token in result: + print(f" {token['orig']} -> {token['hepburn']}") +``` + +### パフォーマンス考慮事項 + +```python +# 大量テキスト処理のベストプラクティス +def efficient_batch_processing(texts, batch_size=100): + """効率的なバッチ処理""" + + transliterator = Transliterator() + results = [] + + for i in range(0, len(texts), batch_size): + batch = texts[i:i + batch_size] + + batch_results = [] + for text in batch: + # 各テキストを個別に処理(ロック制御あり) + result = transliterator.analyze(text) + batch_results.append(result) + + results.extend(batch_results) + + # バッチ間で少し休憩(メモリ管理) + if len(results) % 1000 == 0: + print(f"処理済み: {len(results)} テキスト") + + return results +``` + +## エラーハンドリング + +### 例外処理 + +```python +def safe_analyze(text): + """安全な解析処理""" + + transliterator = Transliterator() + + try: + results = transliterator.analyze(text) + return results, None + + except RuntimeError as e: + if "Already borrowed" in str(e): + # SudachiPyの並行アクセスエラー + print("並行アクセスエラーが発生しました。リトライします。") + return None, "RETRY_NEEDED" + else: + print(f"実行時エラー: {e}") + return None, "RUNTIME_ERROR" + + except Exception as e: + print(f"予期しないエラー: {e}") + return None, "UNKNOWN_ERROR" + +# 使用例(リトライ機構付き) +def analyze_with_retry(text, max_retries=3): + """リトライ機構付き解析""" + + for attempt in range(max_retries): + results, error = safe_analyze(text) + + if results is not None: + return results + + if error == "RETRY_NEEDED": + print(f"リトライ {attempt + 1}/{max_retries}") + import time + time.sleep(0.1 * (attempt + 1)) # 指数バックオフ + continue + else: + break + + # 全てのリトライが失敗した場合のフォールバック + print("解析に失敗しました。フォールバック処理を実行します。") + return [{"orig": text, "kana": text, "hira": text, "hepburn": text}] +``` + +## 設定・カスタマイズ + +### SudachiPy設定 + +```python +# カスタムSudachiPy設定での初期化 +class CustomTransliterator(Transliterator): + def __init__(self, dict_type="full", split_mode="C"): + """カスタム設定での初期化""" + + # 辞書タイプの選択 + dict_types = { + "small": dictionary.Dictionary.create(dict_type="small"), + "core": dictionary.Dictionary.create(dict_type="core"), + "full": dictionary.Dictionary.create(dict_type="full") + } + + self.tokenizer_obj = dict_types.get(dict_type, dict_types["full"]) + + # 分割モードの選択 + split_modes = { + "A": tokenizer.Tokenizer.SplitMode.A, # 短い単位 + "B": tokenizer.Tokenizer.SplitMode.B, # 中間単位 + "C": tokenizer.Tokenizer.SplitMode.C # 長い単位(デフォルト) + } + + self.mode = split_modes.get(split_mode, split_modes["C"]) + self._tokenizer_lock = threading.Lock() + +# 使用例 +# 短い単位での分割を使用 +small_unit_transliterator = CustomTransliterator(dict_type="core", split_mode="A") + +text = "取り敢えず検索してみる" +results = small_unit_transliterator.analyze(text) + +for token in results: + print(f"{token['orig']} -> {token['hepburn']}") +``` + +## テスト・デバッグ + +### 包括的テストセット + +```python +def run_comprehensive_tests(): + """包括的な機能テスト""" + + transliterator = Transliterator() + + test_cases = [ + # 基本的な文章 + ("向こうへ行く", "向こう", "ムコウ"), + ("美しい花", "美しい", "ウツクシイ"), + + # 文脈依存 + ("何度も", "何", "ナン"), + ("何が", "何", "ナニ"), + + # 外来語 + ("パーティー", "パーティー", "パーティー"), + ("コンピューター", "コンピューター", "コンピューター"), + + # 漢字・送り仮名 + ("取り敢えず", "取り", "トリ"), + ("見知らぬ", "見知ら", "ミシラ"), + + # 記号・英数字 + ("ID:12345", "ID", "ID"), + ("SessionIDを取得", "SessionID", "SessionID") + ] + + for text, target_orig, expected_kana in test_cases: + results = transliterator.analyze(text) + + # 対象トークンを検索 + target_token = None + for token in results: + if token['orig'] == target_orig: + target_token = token + break + + if target_token: + actual_kana = target_token['kana'] + status = "✓" if actual_kana == expected_kana else "✗" + print(f"{status} {text}: {target_orig} -> {actual_kana} (期待値: {expected_kana})") + else: + print(f"✗ {text}: トークン '{target_orig}' が見つかりません") + +run_comprehensive_tests() +``` + +## 依存関係 + +### 必須依存関係 +- `sudachipy`: 形態素解析エンジン +- `threading`: 並行制御 +- `typing`: 型ヒント + +### 内部モジュール依存 +- `transliteration_kana_to_hepburn`: ヘボン式変換 +- `transliteration_context_rules`: 文脈依存ルール + +### システム要件 +- Python 3.7以上 +- SudachiPy辞書ファイル(自動ダウンロード) +- 十分なメモリ(辞書読み込み用) + +## 注意事項・制限 + +### 処理精度の制限 +- 形態素解析結果に依存 +- 未知語・固有名詞は読み推定 +- 文脈によっては不正確な分割 + +### パフォーマンス制限 +- 初回実行時の辞書読み込み時間 +- 大量テキスト処理時のメモリ使用量 +- 並行アクセス時のロック待機 + +### 出力形式の制限 +```python +# 現在サポートしていない機能 +unsupported_features = [ + "アクセント記号(音調)", + "方言・古語の特殊読み", + "人名・地名の特殊読み", + "外国語の音写(中国語・韓国語等)", + "カスタム読み辞書", + "品詞情報の出力" +] +``` + +## 関連モジュール + +- `transliteration_kana_to_hepburn.py`: ヘボン式変換処理 +- `transliteration_context_rules.py`: 文脈依存ルール適用 +- `config.py`: システム設定管理 +- `utils.py`: ユーティリティ関数 + +## 将来の改善点 + +- カスタム読み辞書対応 +- より高精度な文脈解析 +- 他言語音写システムとの統合 +- リアルタイム処理最適化 +- 分散処理対応 \ No newline at end of file diff --git a/src-python/docs/details/utils.md b/src-python/docs/details/utils.md new file mode 100644 index 00000000..e924bb3a --- /dev/null +++ b/src-python/docs/details/utils.md @@ -0,0 +1,213 @@ +# utils.py - ユーティリティ関数モジュール + +## 概要 + +VRCTアプリケーション全体で使用される汎用的なユーティリティ関数を提供するモジュールです。データ検証、ネットワーク接続確認、計算デバイス管理、ログ機能などの共通機能を集約しています。 + +## 主要機能 + +### データ検証機能 + +- 辞書構造の型安全な検証 +- IPアドレス形式の検証 +- WebSocketサーバーの可用性確認 + +### システム情報取得 + +- 利用可能な計算デバイス(CPU/CUDA)の一覧取得 +- 最適な計算タイプの自動選択 +- デバイス固有の制約対応 + +### ログ機能 + +- 構造化ログの出力 +- ローテーションログファイル管理 +- エラーログとプロセスログの分離 + +### ネットワーク機能 + +- インターネット接続状態の確認 +- Base64エンコード/デコード処理 + +## 主要関数 + +### データ検証 + +```python +validateDictStructure(data: dict, structure: dict) -> bool +``` + +- 辞書とその期待される構造が完全に一致するかを判別 +- 入れ子構造にも対応 +- 型安全性を保証 + +### ネットワーク関連 + +```python +isConnectedNetwork(url="http://www.google.com", timeout=3) -> bool +``` + +- 指定URLへの接続可能性をチェック +- タイムアウト設定可能 + +```python +isAvailableWebSocketServer(host: str, port: int) -> bool +``` + +- WebSocketサーバーのバインド可能性を確認 + +```python +isValidIpAddress(ip_address: str) -> bool +``` + +- IPv4/IPv6アドレスの有効性を検証 + +### 計算デバイス管理 + +```python +getComputeDeviceList() -> List[Dict[str, Any]] +``` + +- 利用可能なCPU/CUDA計算デバイスの一覧を取得 +- 各デバイスの計算タイプを含む詳細情報を提供 + +```python +getBestComputeType(device: str, device_index: int) -> str +``` + +- デバイスに最適な計算タイプを自動選択 +- GPU固有の制約を考慮(GTX、RTX、Tesla、A100、Quadro等) + +### ログ機能 + +```python +setupLogger(name: str, log_file: str, level: int = logging.INFO) -> logging.Logger +``` + +- ローテーション機能付きログの設定 +- 10MBサイズでのローテーション +- UTF-8エンコード対応 + +```python +printLog(log: str, data: Any = None) -> None +``` + +- 構造化プロセスログの出力 +- JSON形式での標準出力 + +```python +printResponse(status: int, endpoint: str, result: Any = None) -> None +``` + +- APIレスポンスの構造化出力 +- シリアライゼーションエラーの安全な処理 + +```python +errorLogging() -> None +``` + +- 例外トレースバックのログ記録 +- フォールバック機能付き + +### その他のユーティリティ + +```python +encodeBase64(data: str) -> Dict[str, Any] +``` + +- Base64エンコード済みJSON文字列のデコード +- エラー処理付き + +```python +removeLog() -> None +``` + +- プロセスログファイルの初期化 + +## 使用方法 + +### 基本的な使い方 + +```python +from utils import validateDictStructure, isConnectedNetwork, printLog + +# 辞書構造の検証 +expected_structure = {"name": str, "age": int} +data = {"name": "test", "age": 25} +is_valid = validateDictStructure(data, expected_structure) + +# ネットワーク接続確認 +is_connected = isConnectedNetwork() + +# ログ出力 +printLog("処理開始", {"user_id": 123}) +``` + +### 計算デバイス管理 + +```python +from utils import getComputeDeviceList, getBestComputeType + +# 利用可能デバイス一覧 +devices = getComputeDeviceList() + +# 最適な計算タイプ選択 +compute_type = getBestComputeType("cuda", 0) +``` + +### ログ設定 + +```python +from utils import setupLogger, errorLogging + +# カスタムログの設定 +logger = setupLogger("my_module", "my_module.log") +logger.info("処理完了") + +# エラーログ記録 +try: + # 何らかの処理 + pass +except Exception: + errorLogging() +``` + +## 依存関係 + +### 必須依存関係 + +- `json`: JSON処理 +- `logging`: ログ機能 +- `requests`: HTTP通信 +- `ipaddress`: IPアドレス検証 +- `socket`: ソケット通信 + +### オプション依存関係 + +- `torch`: CUDA計算デバイス情報取得 +- `ctranslate2`: 計算タイプ情報取得 + +## デバイス別計算タイプ制約 + +### GTXシリーズ +- サポート: `float32`のみ +- 理由: 古いアーキテクチャによる制約 + +### RTX/Tesla/A100/Quadroシリーズ +- サポート: フル機能 +- 優先順位: `int8_bfloat16` > `int8_float16` > `int8` > `bfloat16` > `float16` > `int8_float32` > `float32` + +### CPU +- サポート: 全計算タイプ(ハードウェア依存) + +## エラーハンドリング + +- すべての関数は例外安全性を考慮 +- オプション依存関係の欠如に対する適切なフォールバック +- ログ機能は多段階のフェールセーフ機構を持つ + +## 注意事項 + +- 計算デバイス情報取得は初回実行時にやや時間がかかる場合がある +- ログローテーションは10MBサイズで自動実行 +- ネットワーク接続確認はデフォルト3秒のタイムアウト設定 \ No newline at end of file diff --git a/src-python/docs/details/watchdog.md b/src-python/docs/details/watchdog.md new file mode 100644 index 00000000..e574b882 --- /dev/null +++ b/src-python/docs/details/watchdog.md @@ -0,0 +1,670 @@ +# watchdog.py - 軽量監視システム + +## 概要 + +タイムアウトベースの軽量監視(ウォッチドッグ)システムです。定期的な"餌やり"(feed)により正常動作を確認し、指定時間内に餌やりがない場合にコールバック関数を実行する単純で効果的な監視機構を提供します。 + +## 主要機能 + +### タイムアウト監視 +- 最後の餌やり時刻からの経過時間監視 +- 設定可能なタイムアウト閾値 +- タイムアウト時の自動コールバック実行 + +### 柔軟な実行モード +- 単発チェック(手動呼び出し) +- バックグラウンドスレッド実行 +- カスタムチェック間隔設定 + +### 防御的設計 +- コールバック例外の隔離処理 +- スレッドセーフな制御機構 +- 適切なリソース管理 + +## クラス構造 + +### Watchdog クラス + +```python +class Watchdog: + def __init__(self, timeout: int = 60, interval: int = 20) -> None: + self.timeout: int # タイムアウト秒数 + self.interval: int # チェック間隔秒数 + self.last_feed_time: float # 最後の餌やり時刻 + self.callback: Optional[Callable] # タイムアウト時コールバック + self._thread: Optional[Thread] # バックグラウンドスレッド + self._stop_event: Optional[Event] # 停止イベント +``` + +軽量ウォッチドッグの中核クラス + +#### パラメータ +- **timeout**: 餌やりなしでタイムアウトするまでの秒数 +- **interval**: 監視チェックの推奨間隔秒数 + +## 主要メソッド + +### 基本制御 + +```python +def feed(self) -> None +``` + +ウォッチドッグに餌やりを行い、タイマーをリセット + +```python +def setCallback(self, callback: Callable[[], None]) -> None +``` + +タイムアウト時に実行するコールバック関数を設定 + +#### パラメータ +- **callback**: 引数なしの呼び出し可能オブジェクト + +### 監視実行 + +```python +def start(self) -> None +``` + +単発のウォッチドッグチェックを実行し、間隔秒数だけスリープ + +```python +def start_in_thread(self, daemon: bool = True) -> None +``` + +バックグラウンドスレッドでウォッチドッグを開始 + +#### パラメータ +- **daemon**: デーモンスレッドとして実行するかのフラグ + +```python +def stop(self, timeout: Optional[float] = None) -> None +``` + +バックグラウンドスレッドを停止 + +#### パラメータ +- **timeout**: スレッド終了待機のタイムアウト秒数 + +## 使用方法 + +### 基本的な監視システム + +```python +from models.watchdog.watchdog import Watchdog +import time + +def on_timeout(): + """タイムアウト時の処理""" + print("警告: システムの応答がありません!") + # ログ出力、アラート送信、復旧処理等 + +# ウォッチドッグの初期化 +watchdog = Watchdog(timeout=30, interval=10) # 30秒でタイムアウト、10秒間隔 +watchdog.setCallback(on_timeout) + +# バックグラウンドで監視開始 +watchdog.start_in_thread(daemon=True) + +# メインプロセスのシミュレーション +for i in range(10): + print(f"処理中... {i}") + + # 正常な処理では定期的に餌やり + if i % 3 == 0: # 3回に1回餌やり + watchdog.feed() + print("ウォッチドッグに餌やりしました") + + time.sleep(5) + +# 監視停止 +watchdog.stop() +``` + +### 手動チェックモード + +```python +# 手動でウォッチドッグをチェック +def manual_monitoring_example(): + watchdog = Watchdog(timeout=60, interval=5) + + def system_failure_handler(): + print("システム障害を検出しました") + # 復旧処理、通知等 + + watchdog.setCallback(system_failure_handler) + + # メインループ内で定期チェック + while True: + # 何らかの重要な処理 + process_critical_work() + + # 処理が正常なら餌やり + if is_system_healthy(): + watchdog.feed() + + # 監視チェック実行(5秒間隔でスリープ) + watchdog.start() + +def process_critical_work(): + """重要な処理のシミュレーション""" + time.sleep(2) + +def is_system_healthy(): + """システム正常性チェックのシミュレーション""" + import random + return random.random() > 0.2 # 80%の確率で正常 + +# manual_monitoring_example() +``` + +### プロセス監視システム + +```python +class ProcessMonitor: + """外部プロセス監視システム""" + + def __init__(self, process_name, check_interval=30): + self.process_name = process_name + self.watchdog = Watchdog(timeout=60, interval=check_interval) + self.watchdog.setCallback(self.on_process_timeout) + self.monitoring = False + + def on_process_timeout(self): + """プロセス応答タイムアウト時の処理""" + print(f"警告: プロセス {self.process_name} が応答しません") + + # プロセス存在確認 + if self.is_process_running(): + print("プロセスは実行中ですが応答なし。再起動を試行します。") + self.restart_process() + else: + print("プロセスが停止しています。再起動します。") + self.start_process() + + def is_process_running(self): + """プロセス実行状態確認""" + import psutil + for proc in psutil.process_iter(['name']): + if proc.info['name'] == self.process_name: + return True + return False + + def start_process(self): + """プロセス起動""" + print(f"プロセス {self.process_name} を起動中...") + # 実際の起動処理 + + def restart_process(self): + """プロセス再起動""" + print(f"プロセス {self.process_name} を再起動中...") + # 実際の再起動処理 + + def feed_watchdog(self): + """外部から呼び出される餌やりメソッド""" + self.watchdog.feed() + + def start_monitoring(self): + """監視開始""" + self.monitoring = True + self.watchdog.start_in_thread(daemon=True) + print(f"プロセス {self.process_name} の監視を開始しました") + + def stop_monitoring(self): + """監視停止""" + self.monitoring = False + self.watchdog.stop() + print(f"プロセス {self.process_name} の監視を停止しました") + +# 使用例 +vrchat_monitor = ProcessMonitor("VRChat.exe", check_interval=15) +vrchat_monitor.start_monitoring() + +# VRChatプロセスが正常に動作している時の餌やり +# (実際にはVRChatからのOSC通信等で判定) +for _ in range(20): + time.sleep(10) + + if vrchat_monitor.is_process_running(): + vrchat_monitor.feed_watchdog() + print("VRChat正常動作確認") + +vrchat_monitor.stop_monitoring() +``` + +### ネットワーク監視システム + +```python +class NetworkWatchdog: + """ネットワーク接続監視""" + + def __init__(self, target_host="8.8.8.8", timeout=45): + self.target_host = target_host + self.watchdog = Watchdog(timeout=timeout, interval=15) + self.watchdog.setCallback(self.on_network_timeout) + self.last_ping_success = True + + def on_network_timeout(self): + """ネットワークタイムアウト処理""" + print("ネットワーク接続に問題があります") + + # 複数ホストでの確認 + test_hosts = ["8.8.8.8", "1.1.1.1", "google.com"] + + for host in test_hosts: + if self.ping_host(host): + print(f"{host} への接続は正常です") + self.watchdog.feed() # 1つでも成功したら復旧とみなす + return + + print("すべてのホストへの接続に失敗。ネットワーク設定を確認してください。") + self.handle_network_failure() + + def ping_host(self, host): + """ホストへのping確認""" + import subprocess + import platform + + # プラットフォームに応じたpingコマンド + if platform.system().lower() == "windows": + cmd = ["ping", "-n", "1", "-w", "3000", host] + else: + cmd = ["ping", "-c", "1", "-W", "3", host] + + try: + result = subprocess.run(cmd, capture_output=True, timeout=5) + return result.returncode == 0 + except (subprocess.TimeoutExpired, Exception): + return False + + def handle_network_failure(self): + """ネットワーク障害時の処理""" + print("ネットワーク障害対応処理を実行中...") + # DNS設定リセット、ネットワークアダプター再起動等 + + def check_network_continuously(self): + """継続的なネットワーク監視""" + self.watchdog.start_in_thread(daemon=True) + + while True: + if self.ping_host(self.target_host): + if not self.last_ping_success: + print("ネットワーク接続が復旧しました") + + self.watchdog.feed() + self.last_ping_success = True + else: + if self.last_ping_success: + print("ネットワーク接続に問題が発生しました") + + self.last_ping_success = False + + time.sleep(10) + +# 使用例 +network_monitor = NetworkWatchdog(target_host="google.com", timeout=60) + +# バックグラウンドでネットワーク監視 +import threading +monitor_thread = threading.Thread(target=network_monitor.check_network_continuously, daemon=True) +monitor_thread.start() + +# メインプログラムの実行 +print("ネットワーク監視システム開始...") +time.sleep(120) # 2分間監視 + +network_monitor.watchdog.stop() +``` + +### システムリソース監視 + +```python +class SystemResourceWatchdog: + """システムリソース監視""" + + def __init__(self): + self.cpu_watchdog = Watchdog(timeout=300, interval=30) # CPU使用率監視 + self.memory_watchdog = Watchdog(timeout=180, interval=20) # メモリ監視 + + self.cpu_watchdog.setCallback(self.on_cpu_overload) + self.memory_watchdog.setCallback(self.on_memory_pressure) + + self.cpu_threshold = 80.0 # CPU使用率閾値(%) + self.memory_threshold = 85.0 # メモリ使用率閾値(%) + + def on_cpu_overload(self): + """CPU過負荷時の処理""" + print("警告: CPU使用率が長時間高い状態です") + self.optimize_cpu_usage() + + def on_memory_pressure(self): + """メモリ圧迫時の処理""" + print("警告: メモリ使用率が危険なレベルです") + self.free_memory() + + def get_cpu_usage(self): + """CPU使用率取得""" + import psutil + return psutil.cpu_percent(interval=1) + + def get_memory_usage(self): + """メモリ使用率取得""" + import psutil + return psutil.virtual_memory().percent + + def optimize_cpu_usage(self): + """CPU使用率最適化""" + print("CPU最適化処理を実行中...") + # 低優先度プロセスの特定・制限 + # 不要なバックグラウンドタスクの停止等 + + def free_memory(self): + """メモリ解放処理""" + print("メモリ解放処理を実行中...") + # ガベージコレクション実行 + import gc + gc.collect() + # キャッシュクリア等 + + def monitor_resources(self): + """リソース監視メインループ""" + self.cpu_watchdog.start_in_thread(daemon=True) + self.memory_watchdog.start_in_thread(daemon=True) + + while True: + # CPU使用率チェック + cpu_usage = self.get_cpu_usage() + if cpu_usage < self.cpu_threshold: + self.cpu_watchdog.feed() + + # メモリ使用率チェック + memory_usage = self.get_memory_usage() + if memory_usage < self.memory_threshold: + self.memory_watchdog.feed() + + print(f"CPU: {cpu_usage:.1f}%, メモリ: {memory_usage:.1f}%") + time.sleep(5) + + def stop_monitoring(self): + """監視停止""" + self.cpu_watchdog.stop() + self.memory_watchdog.stop() + +# 使用例 +resource_monitor = SystemResourceWatchdog() + +# リソース監視開始 +import threading +resource_thread = threading.Thread(target=resource_monitor.monitor_resources, daemon=True) +resource_thread.start() + +# しばらく監視 +time.sleep(60) + +resource_monitor.stop_monitoring() +``` + +## 高度な使用パターン + +### 多段階監視システム + +```python +class MultilevelWatchdog: + """多段階警告レベル対応ウォッチドッグ""" + + def __init__(self): + # 異なるタイムアウトレベル + self.warning_watchdog = Watchdog(timeout=30, interval=10) # 警告レベル + self.critical_watchdog = Watchdog(timeout=60, interval=15) # 危険レベル + self.emergency_watchdog = Watchdog(timeout=120, interval=20) # 緊急レベル + + # 各レベルのコールバック設定 + self.warning_watchdog.setCallback(self.on_warning) + self.critical_watchdog.setCallback(self.on_critical) + self.emergency_watchdog.setCallback(self.on_emergency) + + self.alert_level = "normal" + + def on_warning(self): + """警告レベルのコールバック""" + self.alert_level = "warning" + print("⚠️ 警告: システムの応答が遅くなっています") + # 軽度の対応処理 + + def on_critical(self): + """危険レベルのコールバック""" + self.alert_level = "critical" + print("🔴 危険: システムの重大な問題を検出") + # 中程度の復旧処理 + + def on_emergency(self): + """緊急レベルのコールバック""" + self.alert_level = "emergency" + print("🚨 緊急: システムが完全に応答停止") + # 強制復旧・再起動処理 + + def feed_all(self): + """すべてのウォッチドッグに餌やり""" + self.warning_watchdog.feed() + self.critical_watchdog.feed() + self.emergency_watchdog.feed() + + if self.alert_level != "normal": + print("✅ システム復旧確認") + self.alert_level = "normal" + + def start_monitoring(self): + """多段階監視開始""" + self.warning_watchdog.start_in_thread(daemon=True) + self.critical_watchdog.start_in_thread(daemon=True) + self.emergency_watchdog.start_in_thread(daemon=True) + + def stop_monitoring(self): + """監視停止""" + self.warning_watchdog.stop() + self.critical_watchdog.stop() + self.emergency_watchdog.stop() + +# 使用例 +multilevel_monitor = MultilevelWatchdog() +multilevel_monitor.start_monitoring() + +# 正常時は定期餌やり、異常時は餌やり停止で段階的警告 +for i in range(30): + time.sleep(5) + + # 時々餌やりを忘れるシミュレーション + if i % 7 != 0: # 7回に1回は餌やりしない + multilevel_monitor.feed_all() + + print(f"現在の警告レベル: {multilevel_monitor.alert_level}") + +multilevel_monitor.stop_monitoring() +``` + +## エラーハンドリング・防御機構 + +### 例外安全性 + +```python +def safe_callback_example(): + """安全なコールバック実装例""" + + def potentially_failing_callback(): + """例外を起こす可能性があるコールバック""" + print("重要な処理を実行中...") + + # 何らかの理由で例外が発生 + raise RuntimeError("処理中にエラーが発生しました") + + # ウォッチドッグは例外を隔離するため、システム全体は継続動作 + watchdog = Watchdog(timeout=10, interval=5) + watchdog.setCallback(potentially_failing_callback) + + # エラーが発生してもウォッチドッグ自体は停止しない + watchdog.start_in_thread(daemon=True) + + # 通常の処理継続 + for i in range(5): + time.sleep(3) + watchdog.feed() + print(f"メイン処理継続中: {i}") + + watchdog.stop() + print("ウォッチドッグは例外に関係なく正常に停止しました") + +safe_callback_example() +``` + +### 堅牢なスレッド制御 + +```python +class RobustWatchdog: + """より堅牢なウォッチドッグ実装""" + + def __init__(self, timeout=60, interval=20): + self.base_watchdog = Watchdog(timeout, interval) + self.restart_count = 0 + self.max_restarts = 3 + + def robust_callback(self): + """自動復旧機能付きコールバック""" + try: + print(f"問題検出(再起動回数: {self.restart_count}/{self.max_restarts})") + + if self.restart_count < self.max_restarts: + self.attempt_recovery() + self.restart_count += 1 + else: + print("最大再起動回数に達しました。管理者に連絡してください。") + self.emergency_shutdown() + + except Exception as e: + print(f"復旧処理中にエラー: {e}") + + def attempt_recovery(self): + """復旧処理の試行""" + print("自動復旧処理を実行中...") + time.sleep(2) # 復旧処理のシミュレーション + + # 復旧成功時はカウンターリセット + if self.check_system_health(): + self.restart_count = 0 + self.base_watchdog.feed() + print("復旧成功") + else: + print("復旧失敗") + + def check_system_health(self): + """システム正常性確認""" + # 実際のヘルスチェックロジック + import random + return random.random() > 0.3 # 70%成功率 + + def emergency_shutdown(self): + """緊急停止処理""" + print("緊急停止処理を実行します") + # 安全な停止処理 + + def start_robust_monitoring(self): + """堅牢監視開始""" + self.base_watchdog.setCallback(self.robust_callback) + self.base_watchdog.start_in_thread(daemon=True) + + def feed(self): + """餌やり(成功時はカウンターリセット)""" + self.base_watchdog.feed() + self.restart_count = 0 + + def stop(self): + """監視停止""" + self.base_watchdog.stop() + +# 使用例 +robust_monitor = RobustWatchdog(timeout=20, interval=8) +robust_monitor.start_robust_monitoring() + +# 不安定なシステムのシミュレーション +for i in range(15): + time.sleep(3) + + # 時々システム異常をシミュレート + if random.random() > 0.7: # 30%の確率で異常 + print("システム異常発生...") + # 餌やりしない + else: + robust_monitor.feed() + print("システム正常動作") + +robust_monitor.stop() +``` + +## 性能・リソース考慮事項 + +### 軽量設計の特徴 +- 最小限のメモリフットプリント +- 効率的なスレッド利用 +- CPU使用量の最適化 + +### 推奨設定値 +```python +# 用途別推奨設定 +usage_patterns = { + "realtime_monitoring": { + "timeout": 10, # 10秒 + "interval": 2 # 2秒間隔 + }, + "service_monitoring": { + "timeout": 60, # 1分 + "interval": 15 # 15秒間隔 + }, + "batch_processing": { + "timeout": 300, # 5分 + "interval": 60 # 1分間隔 + }, + "background_tasks": { + "timeout": 1800, # 30分 + "interval": 300 # 5分間隔 + } +} +``` + +## 依存関係・要件 + +### 必須依存関係 +- `threading`: スレッド制御 +- `time`: 時刻管理 +- 標準ライブラリのみ(外部依存なし) + +### システム要件 +- Python 3.7以上 +- マルチスレッド対応OS +- 最小限のシステムリソース + +## 注意事項・制限 + +### 設計上の制限 +- 単純なタイムアウトベース監視のみ +- 複雑な条件判定は非対応 +- ネットワーク監視等は上位層で実装 + +### 使用上の注意 +- コールバック関数は軽量に保つ +- 長時間ブロックする処理は避ける +- 適切なタイムアウト値の設定が重要 + +## 関連モジュール + +- `threading`: スレッド管理 +- `config.py`: 監視設定管理 +- `utils.py`: エラーログ・ユーティリティ +- `controller.py`: 監視制御インターフェース + +## 将来の改善点 + +- より複雑な監視条件のサポート +- 監視統計・メトリクス収集機能 +- 設定可能な復旧戦略 +- 分散監視システムとの連携 +- Webインターフェースでの監視状態表示 \ No newline at end of file diff --git a/src-python/docs/details/websocket_server.md b/src-python/docs/details/websocket_server.md new file mode 100644 index 00000000..2c1d18e9 --- /dev/null +++ b/src-python/docs/details/websocket_server.md @@ -0,0 +1,989 @@ +# websocket_server.py - WebSocket通信サーバー + +## 概要 + +非同期WebSocket通信を提供する包括的なサーバーシステムです。クライアント接続管理、メッセージ配信、外部スレッドからの安全な操作を統合し、VRCTアプリケーションとWebフロントエンド間のリアルタイム通信を実現します。 + +## 主要機能 + +### 非同期WebSocket通信 +- asyncio/websockets による高性能WebSocketサーバー +- 複数クライアント同時接続対応 +- 自動接続・切断管理 + +### メッセージング機能 +- リアルタイムメッセージ受信処理 +- 全クライアントへのブロードキャスト配信 +- カスタムメッセージハンドラー対応 + +### スレッド間通信 +- GUI等の外部スレッドからの安全なメッセージ送信 +- 非同期キューによる効率的な通信制御 +- スレッドセーフな操作保証 + +## クラス構造 + +### WebSocketServer クラス + +```python +class WebSocketServer: + def __init__(self, host: str='127.0.0.1', port: int=8765): + self.host: str # サーバーホスト + self.port: int # サーバーポート + self.clients: Set[WebSocketServerProtocol] # 接続クライアント集合 + self._message_handler: Optional[Callable] # メッセージハンドラー + self._loop: Optional[asyncio.AbstractEventLoop] # イベントループ + self._server: Optional[websockets.serve] # WebSocketサーバー + self._thread: Optional[threading.Thread] # サーバースレッド + self._send_queue: Optional[asyncio.Queue] # 送信キュー + self.is_running: bool # 動作状態フラグ +``` + +WebSocket通信の中核管理クラス + +## 主要メソッド + +### サーバー制御 + +```python +def start_server(self) -> None +``` + +WebSocketサーバーを開始(バックグラウンドスレッド) + +```python +def stop_server(self) -> None +``` + +WebSocketサーバーを停止・リソース解放 + +### メッセージハンドリング + +```python +def set_message_handler(self, handler: Callable[['WebSocketServer', WebSocketServerProtocol, str], None]) -> None +``` + +クライアントからのメッセージ受信時コールバック設定 + +#### パラメータ +- **handler**: メッセージハンドラー関数 `(server, websocket, message) -> None` + +### メッセージ送信 + +```python +def send(self, message: str) -> None +``` + +外部スレッドから安全にメッセージを全クライアントに送信 + +#### パラメータ +- **message**: 送信するメッセージ文字列 + +```python +def broadcast(self, message: str) -> None +``` + +非同期的に全クライアントにメッセージをブロードキャスト + +#### パラメータ +- **message**: ブロードキャストするメッセージ + +## 使用方法 + +### 基本的なWebSocketサーバー + +```python +from models.websocket.websocket_server import WebSocketServer +import time +import json + +# メッセージハンドラーの定義 +def on_message_received(server, websocket, message): + """クライアントからのメッセージ処理""" + print(f"クライアントからメッセージ受信: {message}") + + try: + # JSONメッセージの解析 + data = json.loads(message) + + if data.get('type') == 'translation_request': + # 翻訳要求の処理 + handle_translation_request(server, data) + elif data.get('type') == 'config_update': + # 設定更新の処理 + handle_config_update(server, data) + else: + # エコーバック + response = { + 'type': 'echo', + 'original_message': data, + 'timestamp': time.time() + } + server.broadcast(json.dumps(response)) + + except json.JSONDecodeError: + # テキストメッセージの場合 + response = f"受信しました: {message}" + server.broadcast(response) + +def handle_translation_request(server, data): + """翻訳要求の処理""" + text = data.get('text', '') + target_lang = data.get('target_language', 'English') + + # 実際の翻訳処理(ここではモック) + translated_text = f"[{target_lang}] {text}" + + response = { + 'type': 'translation_result', + 'original': text, + 'translated': translated_text, + 'target_language': target_lang + } + + server.broadcast(json.dumps(response)) + +def handle_config_update(server, data): + """設定更新の処理""" + config_key = data.get('key') + config_value = data.get('value') + + print(f"設定更新: {config_key} = {config_value}") + + response = { + 'type': 'config_updated', + 'key': config_key, + 'value': config_value, + 'status': 'success' + } + + server.broadcast(json.dumps(response)) + +# WebSocketサーバーの起動 +ws_server = WebSocketServer(host='127.0.0.1', port=8765) +ws_server.set_message_handler(on_message_received) +ws_server.start_server() + +print("WebSocketサーバーが起動しました: ws://127.0.0.1:8765") + +# 定期的なステータス送信 +for i in range(10): + status_message = { + 'type': 'status', + 'server_time': time.time(), + 'uptime': i * 5, + 'connected_clients': len(ws_server.clients) + } + + ws_server.send(json.dumps(status_message)) + time.sleep(5) + +# サーバー停止 +ws_server.stop_server() +``` + +### VRCTアプリケーション統合 + +```python +class VRCTWebSocketInterface: + """VRCT用WebSocketインターフェース""" + + def __init__(self, controller, port=8765): + self.controller = controller # VRCTコントローラー + self.ws_server = WebSocketServer(host='127.0.0.1', port=port) + self.ws_server.set_message_handler(self.handle_web_message) + + def handle_web_message(self, server, websocket, message): + """Webクライアントからのメッセージ処理""" + try: + data = json.loads(message) + command = data.get('command') + + if command == 'get_config': + self.send_config(server) + elif command == 'set_config': + self.update_config(server, data) + elif command == 'start_translation': + self.start_translation_service(server, data) + elif command == 'stop_translation': + self.stop_translation_service(server) + elif command == 'get_status': + self.send_status(server) + elif command == 'translate_text': + self.translate_text(server, data) + else: + self.send_error(server, f"未知のコマンド: {command}") + + except Exception as e: + self.send_error(server, f"メッセージ処理エラー: {e}") + + def send_config(self, server): + """設定情報をWebクライアントに送信""" + config_data = { + 'type': 'config', + 'data': { + 'source_language': self.controller.config.source_language, + 'target_language': self.controller.config.target_language, + 'translation_engine': self.controller.config.translation_engine, + 'osc_enabled': self.controller.config.osc_enabled, + 'overlay_enabled': self.controller.config.overlay_enabled + } + } + server.broadcast(json.dumps(config_data)) + + def update_config(self, server, data): + """設定更新""" + config_updates = data.get('config', {}) + + for key, value in config_updates.items(): + if hasattr(self.controller.config, key): + setattr(self.controller.config, key, value) + print(f"設定更新: {key} = {value}") + + # 更新確認を送信 + response = { + 'type': 'config_updated', + 'status': 'success', + 'updated_keys': list(config_updates.keys()) + } + server.broadcast(json.dumps(response)) + + def start_translation_service(self, server, data): + """翻訳サービス開始""" + try: + self.controller.start_translation() + + response = { + 'type': 'service_status', + 'service': 'translation', + 'status': 'started', + 'message': '翻訳サービスが開始されました' + } + server.broadcast(json.dumps(response)) + + except Exception as e: + self.send_error(server, f"翻訳サービス開始エラー: {e}") + + def stop_translation_service(self, server): + """翻訳サービス停止""" + try: + self.controller.stop_translation() + + response = { + 'type': 'service_status', + 'service': 'translation', + 'status': 'stopped', + 'message': '翻訳サービスが停止されました' + } + server.broadcast(json.dumps(response)) + + except Exception as e: + self.send_error(server, f"翻訳サービス停止エラー: {e}") + + def send_status(self, server): + """システム状態送信""" + status_data = { + 'type': 'system_status', + 'data': { + 'translation_active': self.controller.is_translation_active(), + 'osc_connected': self.controller.is_osc_connected(), + 'overlay_active': self.controller.is_overlay_active(), + 'connected_clients': len(server.clients), + 'uptime': self.controller.get_uptime(), + 'memory_usage': self.controller.get_memory_usage() + } + } + server.broadcast(json.dumps(status_data)) + + def translate_text(self, server, data): + """即座翻訳実行""" + text = data.get('text', '') + source_lang = data.get('source_language') + target_lang = data.get('target_language') + + try: + # 翻訳実行 + result = self.controller.translate_text( + text, source_lang, target_lang + ) + + response = { + 'type': 'translation_result', + 'original': text, + 'translated': result, + 'source_language': source_lang, + 'target_language': target_lang, + 'timestamp': time.time() + } + server.broadcast(json.dumps(response)) + + except Exception as e: + self.send_error(server, f"翻訳エラー: {e}") + + def send_error(self, server, error_message): + """エラーメッセージ送信""" + error_data = { + 'type': 'error', + 'message': error_message, + 'timestamp': time.time() + } + server.broadcast(json.dumps(error_data)) + + def start(self): + """WebSocketインターフェース開始""" + self.ws_server.start_server() + print(f"VRCT WebSocketインターフェース開始: ws://127.0.0.1:{self.ws_server.port}") + + def stop(self): + """WebSocketインターフェース停止""" + self.ws_server.stop_server() + print("VRCT WebSocketインターフェース停止") + + def notify_translation_result(self, original, translated, source_lang, target_lang): + """翻訳結果の通知(VRCTコントローラーから呼び出し)""" + notification = { + 'type': 'live_translation', + 'original': original, + 'translated': translated, + 'source_language': source_lang, + 'target_language': target_lang, + 'timestamp': time.time() + } + self.ws_server.send(json.dumps(notification)) + +# 使用例(VRCTアプリケーション内) +# vrct_ws_interface = VRCTWebSocketInterface(controller) +# vrct_ws_interface.start() +``` + +### リアルタイム監視ダッシュボード + +```python +class MonitoringDashboard: + """リアルタイム監視ダッシュボード""" + + def __init__(self, system_components, port=8766): + self.components = system_components + self.ws_server = WebSocketServer(host='127.0.0.1', port=port) + self.ws_server.set_message_handler(self.handle_dashboard_message) + self.monitoring_active = False + + def handle_dashboard_message(self, server, websocket, message): + """ダッシュボードからのメッセージ処理""" + try: + data = json.loads(message) + action = data.get('action') + + if action == 'start_monitoring': + self.start_monitoring(server) + elif action == 'stop_monitoring': + self.stop_monitoring(server) + elif action == 'get_metrics': + self.send_metrics(server) + elif action == 'get_logs': + self.send_logs(server, data.get('limit', 100)) + + except Exception as e: + self.send_dashboard_error(server, str(e)) + + def start_monitoring(self, server): + """監視開始""" + if not self.monitoring_active: + self.monitoring_active = True + + # 監視スレッド開始 + import threading + monitor_thread = threading.Thread( + target=self.monitoring_loop, + args=(server,), + daemon=True + ) + monitor_thread.start() + + response = { + 'type': 'monitoring_status', + 'status': 'started' + } + server.broadcast(json.dumps(response)) + + def stop_monitoring(self, server): + """監視停止""" + self.monitoring_active = False + + response = { + 'type': 'monitoring_status', + 'status': 'stopped' + } + server.broadcast(json.dumps(response)) + + def monitoring_loop(self, server): + """リアルタイム監視ループ""" + while self.monitoring_active: + try: + # システムメトリクス収集 + metrics = self.collect_metrics() + + # ダッシュボードに送信 + dashboard_data = { + 'type': 'live_metrics', + 'metrics': metrics, + 'timestamp': time.time() + } + server.broadcast(json.dumps(dashboard_data)) + + time.sleep(2) # 2秒間隔で更新 + + except Exception as e: + print(f"監視ループエラー: {e}") + time.sleep(5) + + def collect_metrics(self): + """システムメトリクス収集""" + import psutil + + metrics = { + 'system': { + 'cpu_percent': psutil.cpu_percent(), + 'memory_percent': psutil.virtual_memory().percent, + 'disk_usage': psutil.disk_usage('/').percent + }, + 'network': { + 'bytes_sent': psutil.net_io_counters().bytes_sent, + 'bytes_recv': psutil.net_io_counters().bytes_recv + }, + 'vrct': { + 'translation_count': self.components.get('translation_count', 0), + 'osc_messages_sent': self.components.get('osc_count', 0), + 'overlay_updates': self.components.get('overlay_count', 0), + 'active_connections': len(self.ws_server.clients) + } + } + + return metrics + + def send_metrics(self, server): + """メトリクス送信""" + metrics = self.collect_metrics() + + response = { + 'type': 'metrics_snapshot', + 'metrics': metrics, + 'timestamp': time.time() + } + server.broadcast(json.dumps(response)) + + def send_logs(self, server, limit): + """ログ送信""" + # ログファイルから最新のログを取得(実装例) + logs = self.get_recent_logs(limit) + + response = { + 'type': 'log_data', + 'logs': logs, + 'count': len(logs) + } + server.broadcast(json.dumps(response)) + + def get_recent_logs(self, limit): + """最新ログ取得""" + # 実際のログファイル読み込み処理 + mock_logs = [ + {'level': 'INFO', 'message': 'システム開始', 'timestamp': time.time() - 60}, + {'level': 'DEBUG', 'message': '翻訳処理完了', 'timestamp': time.time() - 30}, + {'level': 'WARNING', 'message': 'メモリ使用量増加', 'timestamp': time.time() - 10} + ] + return mock_logs[-limit:] + + def send_dashboard_error(self, server, error_message): + """ダッシュボードエラー送信""" + error_data = { + 'type': 'dashboard_error', + 'message': error_message, + 'timestamp': time.time() + } + server.broadcast(json.dumps(error_data)) + + def start_dashboard(self): + """ダッシュボード開始""" + self.ws_server.start_server() + print(f"監視ダッシュボード開始: ws://127.0.0.1:{self.ws_server.port}") + + def stop_dashboard(self): + """ダッシュボード停止""" + self.monitoring_active = False + self.ws_server.stop_server() + +# 使用例 +system_components = { + 'translation_count': 150, + 'osc_count': 75, + 'overlay_count': 200 +} + +dashboard = MonitoringDashboard(system_components) +dashboard.start_dashboard() + +# しばらく実行 +time.sleep(60) + +dashboard.stop_dashboard() +``` + +### 高度なメッセージルーティング + +```python +class WebSocketRouter: + """WebSocketメッセージルーティングシステム""" + + def __init__(self, port=8767): + self.ws_server = WebSocketServer(host='127.0.0.1', port=port) + self.ws_server.set_message_handler(self.route_message) + self.routes = {} + self.middleware = [] + self.client_subscriptions = {} + + def add_route(self, message_type, handler): + """メッセージタイプに対するハンドラー登録""" + self.routes[message_type] = handler + + def add_middleware(self, middleware_func): + """ミドルウェア追加""" + self.middleware.append(middleware_func) + + def route_message(self, server, websocket, message): + """メッセージルーティング処理""" + try: + # JSON解析 + data = json.loads(message) + message_type = data.get('type') + + # ミドルウェア実行 + for middleware in self.middleware: + data = middleware(data, websocket) + if data is None: # ミドルウェアがNoneを返した場合は処理中断 + return + + # ルーティング実行 + if message_type in self.routes: + handler = self.routes[message_type] + response = handler(data, websocket, server) + + if response: + server.broadcast(json.dumps(response)) + else: + # 未定義メッセージタイプ + error_response = { + 'type': 'error', + 'message': f'未対応メッセージタイプ: {message_type}', + 'original_type': message_type + } + websocket.send(json.dumps(error_response)) + + except json.JSONDecodeError as e: + error_response = { + 'type': 'error', + 'message': f'JSON解析エラー: {e}' + } + websocket.send(json.dumps(error_response)) + except Exception as e: + error_response = { + 'type': 'error', + 'message': f'処理エラー: {e}' + } + websocket.send(json.dumps(error_response)) + + def subscription_middleware(self, data, websocket): + """購読管理ミドルウェア""" + message_type = data.get('type') + + if message_type == 'subscribe': + # 購読登録 + topics = data.get('topics', []) + client_id = id(websocket) + self.client_subscriptions[client_id] = topics + + response = { + 'type': 'subscription_confirmed', + 'topics': topics + } + websocket.send(json.dumps(response)) + return None # 処理終了 + + elif message_type == 'unsubscribe': + # 購読解除 + client_id = id(websocket) + if client_id in self.client_subscriptions: + del self.client_subscriptions[client_id] + + response = { + 'type': 'unsubscription_confirmed' + } + websocket.send(json.dumps(response)) + return None + + return data # そのまま次の処理に進む + + def authentication_middleware(self, data, websocket): + """認証ミドルウェア""" + # 簡易認証例 + api_key = data.get('api_key') + + if api_key != 'valid_api_key_123': + error_response = { + 'type': 'authentication_error', + 'message': '無効なAPIキー' + } + websocket.send(json.dumps(error_response)) + return None + + return data + + def logging_middleware(self, data, websocket): + """ログ記録ミドルウェア""" + client_ip = websocket.remote_address[0] if websocket.remote_address else 'unknown' + message_type = data.get('type', 'unknown') + + print(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {client_ip} -> {message_type}") + + return data + + def broadcast_to_subscribers(self, topic, message_data): + """購読者へのトピック配信""" + message_data['topic'] = topic + message_json = json.dumps(message_data) + + for client_id, topics in self.client_subscriptions.items(): + if topic in topics: + # 該当クライアントを検索 + for client in self.ws_server.clients: + if id(client) == client_id: + try: + client.send(message_json) + except Exception as e: + print(f"配信エラー: {e}") + break + + def start_router(self): + """ルーター開始""" + self.ws_server.start_server() + print(f"WebSocketルーター開始: ws://127.0.0.1:{self.ws_server.port}") + + def stop_router(self): + """ルーター停止""" + self.ws_server.stop_server() + +# 使用例 +def handle_chat_message(data, websocket, server): + """チャットメッセージハンドラー""" + username = data.get('username', 'Anonymous') + message = data.get('message', '') + + response = { + 'type': 'chat_broadcast', + 'username': username, + 'message': message, + 'timestamp': time.time() + } + + return response + +def handle_translation_request(data, websocket, server): + """翻訳要求ハンドラー""" + text = data.get('text', '') + # 翻訳処理(モック) + translated = f"[翻訳] {text}" + + response = { + 'type': 'translation_response', + 'original': text, + 'translated': translated + } + + return response + +# ルーター設定 +router = WebSocketRouter() + +# ミドルウェア登録 +router.add_middleware(router.logging_middleware) +router.add_middleware(router.subscription_middleware) +# router.add_middleware(router.authentication_middleware) # 認証が必要な場合 + +# ルート登録 +router.add_route('chat_message', handle_chat_message) +router.add_route('translation_request', handle_translation_request) + +router.start_router() + +# トピック配信テスト +time.sleep(2) +router.broadcast_to_subscribers('system_updates', { + 'type': 'system_notification', + 'message': 'システム更新完了', + 'severity': 'info' +}) + +time.sleep(10) +router.stop_router() +``` + +## 高度な機能・パターン + +### 接続プール管理 + +```python +class ConnectionPoolManager: + """WebSocket接続プール管理""" + + def __init__(self): + self.pools = {} # pool_name -> set of websockets + + def assign_to_pool(self, websocket, pool_name): + """クライアントをプールに割り当て""" + if pool_name not in self.pools: + self.pools[pool_name] = set() + + self.pools[pool_name].add(websocket) + print(f"クライアントを {pool_name} プールに追加") + + def remove_from_pools(self, websocket): + """すべてのプールからクライアントを削除""" + for pool_name, pool in self.pools.items(): + if websocket in pool: + pool.discard(websocket) + print(f"クライアントを {pool_name} プールから削除") + + def broadcast_to_pool(self, pool_name, message): + """特定プールに対してブロードキャスト""" + if pool_name in self.pools: + for websocket in self.pools[pool_name].copy(): + try: + websocket.send(message) + except Exception: + # 切断されたクライアントを削除 + self.pools[pool_name].discard(websocket) + + def get_pool_stats(self): + """プール統計情報""" + stats = {} + for pool_name, pool in self.pools.items(): + stats[pool_name] = len(pool) + return stats +``` + +### メッセージ永続化・再送機能 + +```python +class PersistentMessageSystem: + """メッセージ永続化・再送システム""" + + def __init__(self, max_history=1000): + self.message_history = [] + self.max_history = max_history + self.client_last_seen = {} # client_id -> last_message_id + + def store_message(self, message_data): + """メッセージを履歴に保存""" + message_id = len(self.message_history) + stored_message = { + 'id': message_id, + 'data': message_data, + 'timestamp': time.time() + } + + self.message_history.append(stored_message) + + # 履歴サイズ制限 + if len(self.message_history) > self.max_history: + self.message_history = self.message_history[-self.max_history:] + + return message_id + + def get_missed_messages(self, client_id, last_seen_id): + """クライアントが見逃したメッセージを取得""" + missed_messages = [] + + for msg in self.message_history: + if msg['id'] > last_seen_id: + missed_messages.append(msg) + + return missed_messages + + def client_reconnected(self, websocket, client_id): + """クライアント再接続時の処理""" + last_seen = self.client_last_seen.get(client_id, -1) + missed_messages = self.get_missed_messages(client_id, last_seen) + + # 見逃したメッセージを再送 + for msg in missed_messages: + try: + recovery_data = { + 'type': 'message_recovery', + 'original_message': msg['data'], + 'message_id': msg['id'], + 'original_timestamp': msg['timestamp'] + } + websocket.send(json.dumps(recovery_data)) + except Exception as e: + print(f"メッセージ再送エラー: {e}") + + print(f"クライアント {client_id} に {len(missed_messages)} 件のメッセージを再送") + + def update_client_position(self, client_id, message_id): + """クライアントの最新メッセージ位置更新""" + self.client_last_seen[client_id] = message_id +``` + +## パフォーマンス・スケーラビリティ + +### 負荷分散・最適化 + +```python +class OptimizedWebSocketServer(WebSocketServer): + """最適化されたWebSocketサーバー""" + + def __init__(self, host='127.0.0.1', port=8765): + super().__init__(host, port) + self.message_stats = { + 'total_messages': 0, + 'messages_per_second': 0, + 'last_reset_time': time.time() + } + self.compression_enabled = True + self.batch_size = 50 + self.batch_timeout = 0.1 + + def enable_message_batching(self, batch_size=50, timeout=0.1): + """メッセージバッチング有効化""" + self.batch_size = batch_size + self.batch_timeout = timeout + + async def optimized_broadcast(self, message_batch): + """最適化されたバッチブロードキャスト""" + if not self.clients: + return + + # 圧縮対応 + if self.compression_enabled and len(message_batch) > 1: + # 複数メッセージをまとめて送信 + combined_message = json.dumps({ + 'type': 'batch', + 'messages': message_batch, + 'count': len(message_batch) + }) + else: + combined_message = json.dumps(message_batch[0]) + + # 並列送信(エラー処理付き) + send_tasks = [] + for client in self.clients.copy(): + send_tasks.append(self.safe_send(client, combined_message)) + + results = await asyncio.gather(*send_tasks, return_exceptions=True) + + # 失敗したクライアントを削除 + for i, result in enumerate(results): + if isinstance(result, Exception): + failed_client = list(self.clients)[i] + self.clients.discard(failed_client) + print(f"クライアント削除(送信失敗): {result}") + + # 統計更新 + self.update_message_stats(len(message_batch)) + + async def safe_send(self, client, message): + """安全なメッセージ送信""" + try: + await client.send(message) + except Exception as e: + raise e # gather で捕捉される + + def update_message_stats(self, message_count): + """メッセージ統計更新""" + self.message_stats['total_messages'] += message_count + + current_time = time.time() + time_diff = current_time - self.message_stats['last_reset_time'] + + if time_diff >= 1.0: # 1秒ごとに速度計算 + self.message_stats['messages_per_second'] = message_count / time_diff + self.message_stats['last_reset_time'] = current_time + + def get_performance_stats(self): + """パフォーマンス統計取得""" + return { + 'connected_clients': len(self.clients), + 'total_messages': self.message_stats['total_messages'], + 'messages_per_second': self.message_stats['messages_per_second'], + 'compression_enabled': self.compression_enabled, + 'batch_size': self.batch_size + } +``` + +## 依存関係・システム要件 + +### 必須依存関係 +- `asyncio`: 非同期処理フレームワーク +- `websockets`: WebSocketライブラリ +- `threading`: マルチスレッド制御 +- `json`: JSON形式データ処理 + +### システム要件 +```python +system_requirements = { + "python_version": "3.7以上", + "asyncio_support": "非同期処理対応", + "network_stack": "TCP/WebSocket対応", + "memory": "同時接続数に応じた十分なメモリ" +} + +performance_characteristics = { + "concurrent_connections": "数百~数千接続対応", + "message_throughput": "秒間数千メッセージ処理可能", + "latency": "低レイテンシー(ミリ秒オーダー)", + "memory_per_connection": "約1-5MB(接続当たり)" +} +``` + +### オプション依存関係 +- `ujson`: 高速JSON処理(パフォーマンス向上) +- `compression`: メッセージ圧縮(帯域節約) + +## 注意事項・制限 + +### ネットワーク制限 +- ファイアウォール設定の要確認 +- プロキシ環境での制限可能性 +- ブラウザーのWebSocket接続制限 + +### スケーラビリティ制限 +- 単一プロセスでの同時接続数制限 +- メモリ使用量の線形増加 +- CPU集約的な処理での性能劣化 + +### セキュリティ考慮事項 +```python +security_considerations = { + "authentication": "認証機構の実装推奨", + "authorization": "適切な認可制御", + "rate_limiting": "レート制限の実装", + "input_validation": "入力データの検証必須", + "cors_policy": "CORS設定の適切な構成" +} +``` + +## 関連モジュール + +- `config.py`: WebSocket設定管理 +- `controller.py`: WebSocket制御インターフェース +- `utils.py`: エラーログ・ユーティリティ +- `model.py`: WebSocket機能統合 + +## 将来の改善点 + +- Redis等を用いたメッセージブローカー連携 +- 負荷分散・クラスタリング対応 +- より高度な認証・認可システム +- WebRTC等のより高速な通信プロトコル対応 +- GraphQL over WebSocketサポート +- リアルタイム監視・分析機能の強化 \ No newline at end of file diff --git a/src-python/docs/device_manager.md b/src-python/docs/device_manager.md new file mode 100644 index 00000000..858acdc9 --- /dev/null +++ b/src-python/docs/device_manager.md @@ -0,0 +1,1427 @@ +# device_manager.py 設計書 + +## 概要 + +`device_manager.py` は VRCT アプリケーションの音声デバイス管理を担当するモジュールであり、マイクとスピーカーデバイスの検出、監視、自動選択機能を提供する。Windows の WASAPI や pycaw ライブラリを使用してリアルタイムなデバイス変更を検知し、登録されたコールバック関数を通じてアプリケーションに通知する。シングルトンパターンで実装され、遅延初期化により import 時のパフォーマンス低下を回避している。 + +## アーキテクチャ上の位置づけ + +``` +┌─────────────┐ +│controller.py│ (Business Logic Control Layer) +└──────┬──────┘ + │ Callback Registration & Query +┌──────▼──────────┐ +│device_manager.py│ ◄── このファイル +└──────┬──────────┘ + │ Device Monitoring & Enumeration +┌──────▼─────────────────────────────┐ +│ OS Audio Subsystems │ +│ - PyAudio (PortAudio wrapper) │ +│ - pyaudiowpatch (WASAPI loopback) │ +│ - pycaw (COM notifications) │ +│ - comtypes (COM initialization) │ +└────────────────────────────────────┘ +``` + +## 主要コンポーネント + +### 1. Client クラス + +**責務:** Windows の COM イベントコールバックを受け取り、デバイス変更を検知 + +**継承:** `pycaw.callbacks.MMNotificationClient` + +**設計パターン:** Observer パターンのコールバック実装 + +#### コンストラクタ `__init__()` + +**処理:** +```python +try: + super().__init__() +except Exception: + pass # 非 Windows 環境ではプレースホルダーオブジェクトのため例外を無視 +self.loop: bool = True +``` + +**`self.loop` フラグ:** +- True: デバイス変更なし、監視継続 +- False: デバイス変更検知、監視ループを中断 + +#### イベントハンドラー + +##### `on_default_device_changed(*args, **kwargs) -> None` +デフォルトデバイスが変更された時に Windows から呼び出される。 + +##### `on_device_added(*args, **kwargs) -> None` +新しいデバイスが接続された時に呼び出される。 + +##### `on_device_removed(*args, **kwargs) -> None` +デバイスが取り外された時に呼び出される。 + +##### `on_device_state_changed(*args, **kwargs) -> None` +デバイスの状態(有効/無効/存在しない等)が変更された時に呼び出される。 + +**すべてのハンドラーの動作:** +```python +self.loop = False # 監視ループに変更を通知 +``` + +**コメントアウトされたメソッド:** +```python +# def on_property_value_changed(self, device_id, key): +# self.loop = False +``` +デバイスプロパティの変更イベント。使用しない理由は不明だが、頻繁なイベント発火によるパフォーマンス低下を避けるためと推測される。 + +--- + +### 2. DeviceManager クラス + +**責務:** アプリケーション全体のデバイス管理機能を提供 + +**パターン:** シングルトン(`__new__` で制御) + +**プラットフォーム対応:** +- Windows: 完全な機能サポート(COM イベント監視、WASAPI loopback) +- 非 Windows: グレースフルデグレード(デフォルト値を返却、監視機能は制限的) + +--- + +### 3. 初期化メソッド + +#### `__new__(cls) -> DeviceManager` + +**責務:** シングルトンインスタンスの生成と軽量な初期化 + +**処理フロー:** +1. **インスタンスチェック:** + ```python + if cls._instance is None: + cls._instance = super(DeviceManager, cls).__new__(cls) + ``` +2. **軽量な初期化:** + ```python + cls._instance._initialized = False + try: + cls._instance.init() + except Exception: + try: + errorLogging() + except Exception: + pass # import 時のクラッシュを絶対に避ける + ``` +3. **既存インスタンスの返却:** + ```python + return cls._instance + ``` + +**設計思想:** +- `__new__` では重い初期化を避ける(スレッド起動、OS API アクセスなし) +- `init()` を呼び出すが、監視スレッドは起動しない +- エラー時も必ずインスタンスを返却(防御的プログラミング) + +#### `init() -> None` + +**責務:** 内部状態の初期化とデバイス情報の初回取得 + +**処理フロー:** + +**1. 初期化済みチェック:** +```python +if getattr(self, "_initialized", False): + return # 既に初期化済みなら何もしない +``` + +**2. デバイス情報の初期化(デフォルト値):** +```python +self.mic_devices: Dict[str, List[Dict[str, Any]]] = { + "NoHost": [{"index": -1, "name": "NoDevice"}] +} +self.default_mic_device: Dict[str, Any] = { + "host": {"index": -1, "name": "NoHost"}, + "device": {"index": -1, "name": "NoDevice"} +} +self.speaker_devices: List[Dict[str, Any]] = [ + {"index": -1, "name": "NoDevice"} +] +self.default_speaker_device: Dict[str, Any] = { + "device": {"index": -1, "name": "NoDevice"} +} +``` + +**3. 前回状態のトラッカー:** +```python +self.prev_mic_host: List[str] = [host for host in self.mic_devices] +self.prev_mic_devices: Dict[str, List[Dict[str, Any]]] = self.mic_devices +self.prev_default_mic_device: Dict[str, Any] = self.default_mic_device +self.prev_speaker_devices: List[Dict[str, Any]] = self.speaker_devices +self.prev_default_speaker_device: Dict[str, Any] = self.default_speaker_device +``` + +**4. 更新フラグ:** +```python +self.update_flag_default_mic_device: bool = False +self.update_flag_default_speaker_device: bool = False +self.update_flag_host_list: bool = False +self.update_flag_mic_device_list: bool = False +self.update_flag_speaker_device_list: bool = False +``` + +**5. コールバック関数:** +```python +self.callback_default_mic_device: Optional[Callable[..., None]] = None +self.callback_default_speaker_device: Optional[Callable[..., None]] = None +self.callback_host_list: Optional[Callable[..., None]] = None +self.callback_mic_device_list: Optional[Callable[..., None]] = None +self.callback_speaker_device_list: Optional[Callable[..., None]] = None +self.callback_process_before_update_mic_devices: Optional[Callable[..., None]] = None +self.callback_process_after_update_mic_devices: Optional[Callable[..., None]] = None +self.callback_process_before_update_speaker_devices: Optional[Callable[..., None]] = None +self.callback_process_after_update_speaker_devices: Optional[Callable[..., None]] = None +``` + +**6. 監視制御:** +```python +self.monitoring_flag: bool = False +self.th_monitoring: Optional[Thread] = None +``` + +**7. 初期化完了フラグ:** +```python +self._initialized = True +``` + +**8. ベストエフォートのデバイス情報取得:** +```python +try: + if PyAudio is not None: + try: + self.update() # 実デバイス情報を取得 + except Exception: + errorLogging() +except Exception: + pass # 初期化失敗でもクラッシュしない +``` + +**設計思想:** +- すべての属性をデフォルト値で初期化(未初期化エラーを回避) +- `update()` の失敗は許容(デバイスがない環境でも動作) +- エラーは記録するが、例外を外部に投げない + +--- + +### 4. デバイス情報更新メソッド + +#### `update() -> None` + +**責務:** 現在の音声デバイス一覧とデフォルトデバイスを取得 + +**処理フロー:** + +**1. バッファの初期化:** +```python +buffer_mic_devices: Dict[str, List[Dict[str, Any]]] = {} +buffer_default_mic_device: Dict[str, Any] = { + "host": {"index": -1, "name": "NoHost"}, + "device": {"index": -1, "name": "NoDevice"} +} +buffer_speaker_devices: List[Dict[str, Any]] = [] +buffer_default_speaker_device: Dict[str, Any] = { + "device": {"index": -1, "name": "NoDevice"} +} +``` + +**2. PyAudio 可用性チェック:** +```python +if PyAudio is None: + # デフォルト値のまま終了 + self.mic_devices = buffer_mic_devices or {"NoHost": [{"index": -1, "name": "NoDevice"}]} + # ... 他のデバイス情報も設定 + return +``` + +**3. マイクデバイスの収集:** +```python +with PyAudio() as p: + for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) + device_count = host.get('deviceCount', 0) + for device_index in range(device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + # 入力チャンネルがあり、ループバックではないデバイス + if device.get("maxInputChannels", 0) > 0 and not device.get("isLoopbackDevice", True): + buffer_mic_devices.setdefault(host["name"], []).append(device) +``` + +**ホスト API の例:** +- Windows: "MME", "Windows DirectSound", "Windows WASAPI" +- Linux: "ALSA", "PulseAudio" +- macOS: "Core Audio" + +**4. デフォルトマイクデバイスの取得:** +```python +api_info = p.get_default_host_api_info() +default_mic_device = api_info.get("defaultInputDevice", -1) + +for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) + device_count = host.get('deviceCount', 0) + for device_index in range(device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if device.get("index") == default_mic_device: + buffer_default_mic_device = {"host": host, "device": device} + break + else: + continue + break +``` + +**5. スピーカーループバックデバイスの収集:** +```python +speaker_devices: List[Dict[str, Any]] = [] +if paWASAPI is not None: + try: + wasapi_info = p.get_host_api_info_by_type(paWASAPI) + wasapi_name = wasapi_info.get("name") + for host_index in range(p.get_host_api_count()): + host = p.get_host_api_info_by_index(host_index) + if host.get("name") == wasapi_name: + device_count = host.get('deviceCount', 0) + for device_index in range(device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if not device.get("isLoopbackDevice", True): + # ループバックデバイスを検索 + for loopback in p.get_loopback_device_info_generator(): + if device.get("name") in loopback.get("name", ""): + speaker_devices.append(loopback) + except Exception: + pass # WASAPI が利用できない場合は無視 +``` + +**ループバックデバイスとは:** +- スピーカーから出力される音声を「録音」できる仮想デバイス +- "Stereo Mix" や "What U Hear" のような名前 +- VRChat の相手の音声を認識するために使用 + +**6. 重複排除とソート:** +```python +speaker_devices = [dict(t) for t in {tuple(d.items()) for d in speaker_devices}] or [{"index": -1, "name": "NoDevice"}] +buffer_speaker_devices = sorted(speaker_devices, key=lambda d: d.get('index', -1)) +``` + +**7. デフォルトスピーカーデバイスの取得:** +```python +if paWASAPI is not None: + try: + wasapi_info = p.get_host_api_info_by_type(paWASAPI) + default_speaker_device_index = wasapi_info.get("defaultOutputDevice", -1) + for host_index in range(p.get_host_api_count()): + host_info = p.get_host_api_info_by_index(host_index) + device_count = host_info.get('deviceCount', 0) + for device_index in range(0, device_count): + device = p.get_device_info_by_host_api_device_index(host_index, device_index) + if device.get("index") == default_speaker_device_index: + default_speakers = device + if not default_speakers.get("isLoopbackDevice", True): + for loopback in p.get_loopback_device_info_generator(): + if default_speakers.get("name") in loopback.get("name", ""): + buffer_default_speaker_device = {"device": loopback} + break + break + if buffer_default_speaker_device["device"].get("name") != "NoDevice": + break + except Exception: + pass +``` + +**8. エラーハンドリングと最終設定:** +```python +except Exception: + errorLogging() + +self.mic_devices = buffer_mic_devices +self.default_mic_device = buffer_default_mic_device +self.speaker_devices = buffer_speaker_devices +self.default_speaker_device = buffer_default_speaker_device +``` + +**デバイス情報の構造例:** +```python +# マイクデバイス +self.mic_devices = { + "Windows WASAPI": [ + {"index": 0, "name": "Microphone (Realtek)", "maxInputChannels": 2, ...}, + {"index": 3, "name": "Line In (USB Audio)", "maxInputChannels": 2, ...} + ], + "MME": [ + {"index": 10, "name": "マイク (Realtek)", "maxInputChannels": 2, ...} + ] +} + +# デフォルトマイクデバイス +self.default_mic_device = { + "host": {"index": 0, "name": "Windows WASAPI", ...}, + "device": {"index": 0, "name": "Microphone (Realtek)", ...} +} +``` + +--- + +### 5. 変更検出メソッド + +#### `checkUpdate() -> bool` + +**責務:** 前回取得したデバイス情報との差分を検出し、更新フラグを設定 + +**処理:** + +**1. デフォルトマイクデバイスの変更チェック:** +```python +if self.prev_default_mic_device["device"]["name"] != self.default_mic_device["device"]["name"]: + self.update_flag_default_mic_device = True + self.prev_default_mic_device = self.default_mic_device +``` + +**2. デフォルトスピーカーデバイスの変更チェック:** +```python +if self.prev_default_speaker_device["device"]["name"] != self.default_speaker_device["device"]["name"]: + self.update_flag_default_speaker_device = True + self.prev_default_speaker_device = self.default_speaker_device +``` + +**3. マイクホストリストの変更チェック:** +```python +if self.prev_mic_host != [host for host in self.mic_devices]: + self.update_flag_host_list = True + self.prev_mic_host = [host for host in self.mic_devices] +``` + +**4. マイクデバイスリストの変更チェック:** +```python +if ({key: [device['name'] for device in devices] for key, devices in self.prev_mic_devices.items()} != + {key: [device['name'] for device in devices] for key, devices in self.mic_devices.items()}): + self.update_flag_mic_device_list = True + self.prev_mic_devices = self.mic_devices +``` + +**比較方法:** +- デバイス名のリストのみを比較(`index` の変化は無視) +- ホストごとにグループ化して比較 + +**5. スピーカーデバイスリストの変更チェック:** +```python +if [device['name'] for device in self.prev_speaker_devices] != [device['name'] for device in self.speaker_devices]: + self.update_flag_speaker_device_list = True + self.prev_speaker_devices = self.speaker_devices +``` + +**6. 総合的な更新フラグの判定:** +```python +update_flag = ( + self.update_flag_default_mic_device or + self.update_flag_default_speaker_device or + self.update_flag_host_list or + self.update_flag_mic_device_list or + self.update_flag_speaker_device_list +) +return update_flag +``` + +**戻り値:** +- `True`: いずれかのデバイス情報が変更された +- `False`: すべてのデバイス情報が前回と同一 + +--- + +### 6. 監視メソッド + +#### `monitoring() -> None` + +**責務:** バックグラウンドでデバイス変更を監視し、変更時にコールバックを実行 + +**実行環境:** 別スレッド(`startMonitoring()` で起動) + +**処理フロー:** + +**1. 監視ループ:** +```python +try: + while self.monitoring_flag is True: + try: + # 監視処理 + except Exception: + errorLogging() +except Exception: + errorLogging() +``` + +**2. COM イベント監視(Windows のみ):** +```python +if comtypes is not None and AudioUtilities is not None: + try: + comtypes.CoInitialize() # COM の初期化 + cb = Client() + enumerator = AudioUtilities.GetDeviceEnumerator() + enumerator.RegisterEndpointNotificationCallback(cb) + + while cb.loop is True and self.monitoring_flag is True: + sleep(1) # イベント待機 + + try: + enumerator.UnregisterEndpointNotificationCallback(cb) + except Exception: + pass # ベストエフォート + comtypes.CoUninitialize() + except Exception: + errorLogging() +``` + +**COM 監視の動作:** +- `Client` クラスのイベントハンドラーがデバイス変更を検知 +- `cb.loop` が `False` になるとループを抜ける +- COM が利用できない場合はポーリングにフォールバック + +**3. ポーリングと更新サイクル:** +```python +# 更新前の処理 +self.runProcessBeforeUpdateMicDevices() +self.runProcessBeforeUpdateSpeakerDevices() + +sleep(2) # デバイス状態の安定を待つ + +# 最大10回(20秒間)ポーリング +for _ in range(10): + self.update() + if self.checkUpdate(): + break # 変更を検知したら終了 + sleep(2) + +# コールバック通知 +self.noticeUpdateDevices() + +# 更新後の処理 +self.runProcessAfterUpdateMicDevices() +self.runProcessAfterUpdateSpeakerDevices() +``` + +**ポーリング戦略:** +- 初回 2 秒待機: デバイスの接続/切断後の不安定期間を回避 +- 最大 10 回ポーリング: デバイス変更を見逃さない +- 変更検知後は即座に次の処理へ + +**4. 監視サイクルの繰り返し:** +```python +# while self.monitoring_flag is True の先頭に戻る +``` + +#### `startMonitoring() -> None` + +**責務:** 監視スレッドの起動 + +**処理:** +```python +if self.monitoring_flag: + return # 既に起動中 +self.monitoring_flag = True +self.th_monitoring = Thread(target=self.monitoring) +self.th_monitoring.daemon = True +self.th_monitoring.start() +``` + +**デーモンスレッド:** +- メインスレッド終了時に自動的に終了 +- アプリケーション終了を妨げない + +#### `stopMonitoring() -> None` + +**責務:** 監視スレッドの停止 + +**処理:** +```python +self.monitoring_flag = False +if getattr(self, "th_monitoring", None) is not None: + try: + self.th_monitoring.join(timeout=5) # 最大5秒待機 + except Exception: + pass # ベストエフォート +``` + +**タイムアウト設定:** +- 5 秒以内に終了しない場合は待機を諦める +- スレッドの join に失敗してもエラーを無視(防御的) + +--- + +### 7. コールバック管理メソッド + +#### デフォルトデバイス変更コールバック + +##### `setCallbackDefaultMicDevice(callback: Callable[..., None]) -> None` +デフォルトマイクデバイス変更時のコールバックを登録。 + +**コールバックシグネチャ:** +```python +def callback(host_name: str, device_name: str) -> None: + pass +``` + +##### `clearCallbackDefaultMicDevice() -> None` +コールバックをクリア。 + +##### `setCallbackDefaultSpeakerDevice(callback: Callable[..., None]) -> None` +デフォルトスピーカーデバイス変更時のコールバックを登録。 + +**コールバックシグネチャ:** +```python +def callback(device_name: str) -> None: + pass +``` + +##### `clearCallbackDefaultSpeakerDevice() -> None` +コールバックをクリア。 + +#### デバイスリスト変更コールバック + +##### `setCallbackHostList(callback: Callable[..., None]) -> None` +マイクホストリスト変更時のコールバックを登録。 + +##### `clearCallbackHostList() -> None` +コールバックをクリア。 + +##### `setCallbackMicDeviceList(callback: Callable[..., None]) -> None` +マイクデバイスリスト変更時のコールバックを登録。 + +##### `clearCallbackMicDeviceList() -> None` +コールバックをクリア。 + +##### `setCallbackSpeakerDeviceList(callback: Callable[..., None]) -> None` +スピーカーデバイスリスト変更時のコールバックを登録。 + +##### `clearCallbackSpeakerDeviceList() -> None` +コールバックをクリア。 + +#### 処理フックコールバック + +##### `setCallbackProcessBeforeUpdateMicDevices(callback: Callable[..., None]) -> None` +マイクデバイス更新前の処理を登録。 + +**使用例:** 音声認識を停止してデバイスを解放 + +##### `clearCallbackProcessBeforeUpdateMicDevices() -> None` +コールバックをクリア。 + +##### `setCallbackProcessAfterUpdateMicDevices(callback: Callable[..., None]) -> None` +マイクデバイス更新後の処理を登録。 + +**使用例:** 新しいデバイスで音声認識を再開 + +##### `clearCallbackProcessAfterUpdateMicDevices() -> None` +コールバックをクリア。 + +##### `setCallbackProcessBeforeUpdateSpeakerDevices(callback: Callable[..., None]) -> None` +スピーカーデバイス更新前の処理を登録。 + +##### `clearCallbackProcessBeforeUpdateSpeakerDevices() -> None` +コールバックをクリア。 + +##### `setCallbackProcessAfterUpdateSpeakerDevices(callback: Callable[..., None]) -> None` +スピーカーデバイス更新後の処理を登録。 + +##### `clearCallbackProcessAfterUpdateSpeakerDevices() -> None` +コールバックをクリア。 + +--- + +### 8. コールバック実行メソッド + +#### `runProcessBeforeUpdateMicDevices() -> None` + +**責務:** マイクデバイス更新前の処理コールバックを実行 + +**処理:** +```python +if isinstance(self.callback_process_before_update_mic_devices, Callable): + try: + self.callback_process_before_update_mic_devices() + except Exception: + errorLogging() +``` + +**型チェック:** +- `isinstance(callback, Callable)` で呼び出し可能性を確認 +- `None` の場合は何もしない + +#### `runProcessAfterUpdateMicDevices() -> None` +マイクデバイス更新後の処理コールバックを実行(同様の実装)。 + +#### `runProcessBeforeUpdateSpeakerDevices() -> None` +スピーカーデバイス更新前の処理コールバックを実行(同様の実装)。 + +#### `runProcessAfterUpdateSpeakerDevices() -> None` +スピーカーデバイス更新後の処理コールバックを実行(同様の実装)。 + +--- + +### 9. 通知メソッド + +#### `noticeUpdateDevices() -> None` + +**責務:** 更新フラグに応じて対応するコールバックを呼び出し、フラグをリセット + +**処理:** +```python +if self.update_flag_default_mic_device is True: + self.setMicDefaultDevice() +if self.update_flag_default_speaker_device is True: + self.setSpeakerDefaultDevice() +if self.update_flag_host_list is True: + self.setMicHostList() +if self.update_flag_mic_device_list is True: + self.setMicDeviceList() +if self.update_flag_speaker_device_list is True: + self.setSpeakerDeviceList() + +# すべてのフラグをリセット +self.update_flag_default_mic_device = False +self.update_flag_default_speaker_device = False +self.update_flag_host_list = False +self.update_flag_mic_device_list = False +self.update_flag_speaker_device_list = False +``` + +#### `setMicDefaultDevice() -> None` + +**責務:** デフォルトマイクデバイス変更コールバックの実行 + +**処理:** +```python +if isinstance(self.callback_default_mic_device, Callable): + try: + self.callback_default_mic_device( + self.default_mic_device["host"]["name"], + self.default_mic_device["device"]["name"] + ) + except Exception: + errorLogging() +``` + +#### `setSpeakerDefaultDevice() -> None` + +**責務:** デフォルトスピーカーデバイス変更コールバックの実行 + +**処理:** +```python +if isinstance(self.callback_default_speaker_device, Callable): + try: + self.callback_default_speaker_device( + self.default_speaker_device["device"]["name"] + ) + except Exception: + errorLogging() +``` + +#### `setMicHostList() -> None` +マイクホストリスト変更コールバックの実行(引数なし)。 + +#### `setMicDeviceList() -> None` +マイクデバイスリスト変更コールバックの実行(引数なし)。 + +#### `setSpeakerDeviceList() -> None` +スピーカーデバイスリスト変更コールバックの実行(引数なし)。 + +--- + +### 10. デバイス情報取得メソッド + +#### `getMicDevices() -> Dict[str, List[Dict[str, Any]]]` + +**責務:** マイクデバイス一覧を取得 + +**処理:** +```python +if not getattr(self, '_initialized', False): + try: + self.init() + except Exception: + try: + errorLogging() + except Exception: + pass +return getattr(self, 'mic_devices', {"NoHost": [{"index": -1, "name": "NoDevice"}]}) +``` + +**安全性:** +- 未初期化の場合は `init()` を呼び出す +- 失敗時はデフォルト値を返却 + +**戻り値の例:** +```python +{ + "Windows WASAPI": [ + {"index": 0, "name": "Microphone (Realtek)", ...}, + {"index": 3, "name": "Line In (USB Audio)", ...} + ], + "MME": [ + {"index": 10, "name": "マイク (Realtek)", ...} + ] +} +``` + +#### `getDefaultMicDevice() -> Dict[str, Any]` + +**責務:** デフォルトマイクデバイスを取得 + +**戻り値の例:** +```python +{ + "host": {"index": 0, "name": "Windows WASAPI", ...}, + "device": {"index": 0, "name": "Microphone (Realtek)", ...} +} +``` + +#### `getSpeakerDevices() -> List[Dict[str, Any]]` + +**責務:** スピーカーデバイス一覧を取得 + +**戻り値の例:** +```python +[ + {"index": 5, "name": "Stereo Mix (Realtek)", "isLoopbackDevice": True, ...}, + {"index": 7, "name": "Speakers (USB Audio) [Loopback]", ...} +] +``` + +#### `getDefaultSpeakerDevice() -> Dict[str, Any]` + +**責務:** デフォルトスピーカーデバイスを取得 + +**戻り値の例:** +```python +{ + "device": {"index": 5, "name": "Stereo Mix (Realtek)", ...} +} +``` + +--- + +### 11. 強制更新メソッド + +#### `forceUpdateAndSetMicDevices() -> None` + +**責務:** マイクデバイス情報を強制的に更新し、すべてのコールバックを実行 + +**処理:** +```python +self.update() +self.setMicHostList() +self.setMicDeviceList() +self.setMicDefaultDevice() +``` + +**使用場面:** +- 自動デバイス選択機能の初回適用時 +- ユーザーが手動で更新を要求した時 + +#### `forceUpdateAndSetSpeakerDevices() -> None` + +**責務:** スピーカーデバイス情報を強制的に更新 + +**処理:** +```python +self.update() +self.setSpeakerDeviceList() +self.setSpeakerDefaultDevice() +``` + +--- + +### 12. モジュールレベルの使用方法 + +#### シングルトンインスタンス + +```python +device_manager = DeviceManager() +``` + +**モジュールをインポートするだけで使用可能:** +```python +from device_manager import device_manager + +# デバイス情報取得 +mic_devices = device_manager.getMicDevices() +``` + +#### デモスクリプト + +```python +if __name__ == "__main__": + print("DeviceManager demo. Call device_manager.init() and device_manager.startMonitoring() to run live monitoring.") + try: + while True: + sleep(1) + except KeyboardInterrupt: + print("exiting") +``` + +**実行方法:** +```powershell +python device_manager.py +``` + +--- + +## 依存関係 + +### 外部ライブラリ + +```python +from typing import Callable, Dict, List, Optional, Any +from time import sleep +from threading import Thread +``` + +### オプショナル依存(Windows 専用) + +```python +import comtypes # COM 初期化・終了 +from pyaudiowpatch import PyAudio, paWASAPI # WASAPI loopback サポート +from pycaw.callbacks import MMNotificationClient # デバイス変更イベント +from pycaw.utils import AudioUtilities # デバイス列挙 +``` + +**非 Windows 環境での動作:** +- すべてのオプショナル依存は `try-except` でガード +- インポート失敗時は `None` または placeholder を設定 +- デフォルト値(`NoDevice`)を返す機能は維持 + +### 内部モジュール + +```python +from utils import errorLogging +``` + +--- + +## 自動デバイス選択の動作フロー + +### Controller 側の設定(例) + +```python +# controller.py の applyAutoMicSelect() メソッド + +def applyAutoMicSelect(self) -> None: + # 1. 更新前の処理: デバイス使用中の機能を停止 + device_manager.setCallbackProcessBeforeUpdateMicDevices( + self.stopAccessMicDevices + ) + + # 2. デフォルトデバイス変更時: 新しいデバイスを選択 + device_manager.setCallbackDefaultMicDevice( + self.updateSelectedMicDevice + ) + + # 3. 更新後の処理: 新しいデバイスで機能を再開 + device_manager.setCallbackProcessAfterUpdateMicDevices( + self.restartAccessMicDevices + ) + + # 4. 初回実行 + device_manager.forceUpdateAndSetMicDevices() + + # 5. 監視開始 + device_manager.startMonitoring() +``` + +### デバイス変更時のシーケンス図 + +``` +[ユーザーがヘッドセットを接続] + ↓ +[Windows がデフォルトデバイスを変更] + ↓ +[pycaw の Client.on_device_added() が呼ばれる] + ↓ +[client.loop = False に設定] + ↓ +[monitoring() の COM 監視ループが終了] + ↓ +[runProcessBeforeUpdateMicDevices() 実行] + ↓ +[controller.stopAccessMicDevices()] + - 音声認識を停止 + - デバイスを解放 + ↓ +[update() でデバイス情報を更新] + ↓ +[checkUpdate() で変更を検出] + ↓ +[noticeUpdateDevices() でコールバック呼び出し] + ↓ +[setMicDefaultDevice() 実行] + ↓ +[controller.updateSelectedMicDevice(host, device)] + - 設定を更新 + - フロントエンドに通知 + ↓ +[runProcessAfterUpdateMicDevices() 実行] + ↓ +[controller.restartAccessMicDevices()] + - 新しいデバイスで音声認識を開始 + ↓ +[COM 監視ループが再開] +``` + +--- + +## エラーハンドリング戦略 + +### 1. import 時のエラー + +**問題:** Windows 専用ライブラリが非 Windows 環境でインポートされる + +**対策:** +```python +try: + import comtypes +except Exception: + comtypes = None # type: ignore +``` + +**結果:** +- インポートエラーは発生しない +- `comtypes is None` で可用性を判定 +- 機能は制限されるがアプリケーションは動作 + +### 2. 初期化時のエラー + +**問題:** デバイス情報の取得に失敗 + +**対策:** +```python +try: + if PyAudio is not None: + try: + self.update() + except Exception: + errorLogging() +except Exception: + pass # デフォルト値のまま継続 +``` + +**結果:** +- 初期化は完了(`_initialized = True`) +- デバイス情報はデフォルト値(`NoDevice`) +- ログにエラーを記録 + +### 3. 監視スレッド内のエラー + +**問題:** デバイス更新中の予期しない例外 + +**対策:** +```python +try: + while self.monitoring_flag is True: + try: + # 監視処理 + except Exception: + errorLogging() # ログに記録して継続 +except Exception: + errorLogging() # 外側のループでもキャッチ +``` + +**結果:** +- エラーが発生しても監視は継続 +- ログにエラーを記録 +- スレッドはクラッシュしない + +### 4. コールバック実行時のエラー + +**問題:** 登録されたコールバック関数内で例外が発生 + +**対策:** +```python +if isinstance(self.callback_default_mic_device, Callable): + try: + self.callback_default_mic_device(host_name, device_name) + except Exception: + errorLogging() # ログに記録して継続 +``` + +**結果:** +- コールバックのエラーは分離される +- 他のコールバックには影響しない +- デバイス監視は継続 + +--- + +## スレッド構成 + +### メインスレッド +- アプリケーションのメインループ + +### 監視スレッド(`th_monitoring`) +- `monitoring()` メソッドを実行 +- デーモンスレッド(メインスレッド終了時に自動終了) +- `startMonitoring()` で起動 +- `stopMonitoring()` で停止 + +### スレッド同期 + +**監視フラグ:** +```python +self.monitoring_flag: bool = False +``` + +**動作:** +- `True`: 監視継続 +- `False`: 監視停止(次回ループで終了) + +**停止時の安全性:** +```python +self.monitoring_flag = False # フラグを False に +if self.th_monitoring is not None: + self.th_monitoring.join(timeout=5) # 最大5秒待機 +``` + +--- + +## パフォーマンス考慮事項 + +### 1. 遅延初期化 + +**戦略:** +- `__new__`: 軽量(インスタンス生成のみ) +- `init()`: 中程度(デバイス情報の初回取得) +- `startMonitoring()`: 重い(スレッド起動、COM 初期化) + +**利点:** +- `import device_manager` は高速 +- アプリケーション起動時のレスポンス向上 +- 使用しない機能のリソースを消費しない + +### 2. COM イベント vs ポーリング + +**COM イベント:** +- リアルタイム検知(即座に反応) +- CPU 使用率が低い(イベント待機) +- Windows 専用 + +**ポーリング:** +- 最大 20 秒の遅延(10 回 × 2 秒) +- CPU 使用率がやや高い(定期的な `update()` 呼び出し) +- クロスプラットフォーム + +**ハイブリッド方式:** +- COM が利用可能ならイベント駆動 +- COM が失敗またはポーリングにフォールバック + +### 3. デバイス情報のキャッシング + +**戦略:** +```python +self.mic_devices # キャッシュ +self.prev_mic_devices # 前回の状態 +``` + +**利点:** +- `getMicDevices()` は `update()` を呼ばない(高速) +- 変更検出が効率的(差分のみ処理) + +### 4. ポーリングの最適化 + +**初回待機(2 秒):** +```python +sleep(2) +``` +- デバイス接続後の不安定期間を回避 +- デバイスドライバーの初期化を待つ + +**最大 10 回ポーリング:** +```python +for _ in range(10): + self.update() + if self.checkUpdate(): + break # 変更検出後は即座に終了 + sleep(2) +``` +- 不要なポーリングを削減 +- 変更検出後は即座に次の処理へ + +--- + +## テストシナリオ + +### 1. 初期化テスト + +**ケース:** +- PyAudio が利用可能 +- PyAudio が利用不可(非 Windows 環境) +- デバイスが1つもない環境 + +**確認項目:** +- `_initialized` フラグが `True` になるか +- デバイス情報がデフォルト値または実デバイスで設定されているか +- エラーが適切にログされているか + +### 2. デバイス検出テスト + +**ケース:** +- 複数のホスト API(MME、WASAPI 等) +- 複数のマイクデバイス +- WASAPI ループバックデバイス + +**確認項目:** +- すべてのデバイスが検出されるか +- デフォルトデバイスが正しく識別されるか +- ループバックデバイスが正しく識別されるか + +### 3. 変更検出テスト + +**ケース:** +- デフォルトデバイスの変更 +- デバイスの接続・切断 +- ホスト API の変更 + +**確認項目:** +- 変更が正しく検出されるか +- 適切なフラグが設定されるか +- コールバックが呼び出されるか + +### 4. 監視スレッドテスト + +**ケース:** +- 監視の起動・停止 +- デバイス変更時の動作 +- エラー発生時の継続性 + +**確認項目:** +- スレッドが正しく起動・停止するか +- デバイス変更が検知されるか +- エラー発生時もスレッドが継続するか + +### 5. 自動デバイス選択テスト + +**ケース:** +- デフォルトデバイスの変更 +- デバイスの接続中に音声認識が動作中 +- コールバック内でエラーが発生 + +**確認項目:** +- デバイス変更前に処理が停止されるか +- デバイス変更後に処理が再開されるか +- エラーが分離されるか + +--- + +## 制限事項 + +### 1. Windows 依存機能 + +**問題:** COM イベント監視と WASAPI ループバックは Windows 専用 + +**影響:** +- 非 Windows 環境ではポーリングのみ +- リアルタイム性が低下 +- ループバックデバイスが利用不可 + +**緩和策:** +- グレースフルデグレード(デフォルト値を返却) +- プラットフォーム固有のコードを分離 + +### 2. デバイス名の曖昧性 + +**問題:** デバイス名に特殊文字やロケール依存の名前が含まれる + +**影響:** +- 名前による比較が不正確になる可能性 +- ループバックデバイスのマッチングが失敗する可能性 + +**緩和策:** +- `index` による識別も併用 +- 部分一致でループバックデバイスを検索 + +### 3. ポーリング遅延 + +**問題:** 最大 20 秒の遅延が発生する可能性 + +**影響:** +- デバイス変更の検知が遅れる +- ユーザー体験の低下 + +**緩和策:** +- COM イベント監視を優先使用 +- ポーリング間隔を短縮(2 秒) + +### 4. エラーの握りつぶし + +**問題:** 多くのエラーがログに記録されるのみで例外が投げられない + +**影響:** +- デバッグが困難 +- エラーの発生に気づきにくい + +**緩和策:** +- 詳細なエラーログ(`errorLogging()`) +- 重要なエラーは status を返却(future work) + +--- + +## 今後の改善案 + +### 1. クロスプラットフォーム対応の強化 + +**Linux (PulseAudio / ALSA):** +```python +# PulseAudio の D-Bus API でデバイス監視 +# ALSA の udev イベントでデバイス変更を検知 +``` + +**macOS (Core Audio):** +```python +# Core Audio の kAudioDevicePropertyDataSource 監視 +# IOKit でデバイスイベントを検知 +``` + +### 2. デバイス識別の改善 + +**問題:** 名前のみによる識別は不安定 + +**解決策:** +```python +device_id = { + "index": device["index"], + "name": device["name"], + "host": host["name"], + "unique_id": device.get("uniqueDeviceID", "") # WASAPI 固有 ID +} +``` + +### 3. 非同期化(asyncio) + +**問題:** スレッド管理の複雑性 + +**解決策:** +```python +async def monitoring_async(self): + while self.monitoring_flag: + await asyncio.sleep(2) + await self.update_async() + if self.checkUpdate(): + await self.noticeUpdateDevices_async() +``` + +**利点:** +- スレッド管理が不要 +- エラーハンドリングが統一 +- パフォーマンスの向上 + +### 4. イベントログの記録 + +**問題:** デバイス変更の履歴が残らない + +**解決策:** +```python +device_change_history = [] + +def log_device_change(event_type, device_info): + device_change_history.append({ + "timestamp": datetime.now(), + "event": event_type, + "device": device_info + }) +``` + +**利点:** +- デバッグが容易 +- ユーザーサポートの向上 + +### 5. 設定の永続化 + +**問題:** 選択されたデバイスが再起動後に失われる + +**解決策:** +```python +# config.py に保存 +config.SELECTED_MIC_DEVICE_ID = { + "host": "Windows WASAPI", + "name": "Microphone (Realtek)", + "unique_id": "{0.0.0.00000000}.{...}" +} + +# 起動時に復元 +def restore_selected_device(): + saved_id = config.SELECTED_MIC_DEVICE_ID + current_devices = device_manager.getMicDevices() + # unique_id でマッチング +``` + +--- + +## 関連ファイル + +- **controller.py** - デバイス管理のコールバックを登録 +- **model.py** - デバイス情報を使用して音声認識を開始 +- **config.py** - デバイス選択の設定を保存 +- **utils.py** - エラーロギング関数 + +--- + +## コーディング規約への準拠 + +### 命名規則 + +- クラス名: `DeviceManager`, `Client` (PascalCase) +- メソッド名: `startMonitoring`, `getMicDevices` (snake_case) +- 変数名: `mic_devices`, `default_mic_device` (snake_case) +- 定数: 使用していない(`config.py` で管理) + +### 型注釈 + +**現状:** +```python +def init(self) -> None: + self.mic_devices: Dict[str, List[Dict[str, Any]]] = {...} +``` + +**改善案:** +```python +DeviceInfo = Dict[str, Any] +DeviceList = List[DeviceInfo] +HostDeviceMap = Dict[str, DeviceList] + +def init(self) -> None: + self.mic_devices: HostDeviceMap = {...} +``` + +### Docstring + +**現状:** 一部のメソッドのみ docstring あり + +**改善案:** +```python +def getMicDevices(self) -> Dict[str, List[Dict[str, Any]]]: + """Get the list of microphone devices grouped by host API. + + Returns: + A dict mapping host names (e.g., "Windows WASAPI") to lists of device info dicts. + Each device dict contains keys like "index", "name", "maxInputChannels", etc. + If no devices are available, returns {"NoHost": [{"index": -1, "name": "NoDevice"}]}. + """ +``` + +--- + +## まとめ + +`device_manager.py` は VRCT のデバイス管理機能を提供する重要なモジュールであり、以下の特徴を持つ: + +1. **シングルトンパターン:** アプリケーション全体で1つのインスタンスのみ +2. **遅延初期化:** import 時のパフォーマンス低下を回避 +3. **プラットフォーム対応:** Windows で完全な機能、非 Windows でもグレースフルデグレード +4. **リアルタイム監視:** COM イベントとポーリングのハイブリッド方式 +5. **コールバックパターン:** 柔軟なイベント通知機構 +6. **防御的プログラミング:** エラーが発生してもクラッシュしない + +このモジュールは自動デバイス選択機能の中核として動作し、ユーザーがデバイスを切り替えた際に音声認識を自動的に再開することで、シームレスな体験を提供する。 diff --git a/src-python/docs/mainloop.md b/src-python/docs/mainloop.md new file mode 100644 index 00000000..4536d49e --- /dev/null +++ b/src-python/docs/mainloop.md @@ -0,0 +1,346 @@ +# mainloop.py 設計書 + +## 概要 + +`mainloop.py` は VRCT アプリケーションのバックエンドエントリーポイントであり、stdin/stdout を介したフロントエンド(Tauri/React UI)との通信を担当する。JSON ベースのリクエスト/レスポンスプロトコルを実装し、複数のワーカースレッドによる並列処理と排他制御を提供する。 + +## 主要コンポーネント + +### 1. グローバル変数 + +#### `run_mapping` (dict) +フロントエンドへの通知用エンドポイントマッピング。Controllerが `run()` コールバックを通じてフロントエンドに状態変化を通知する際に使用。 + +**主要なエンドポイント:** +- `/run/enable_translation` - 翻訳機能の有効/無効状態 +- `/run/transcription_mic_message` - マイク音声認識結果 +- `/run/transcription_speaker_message` - スピーカー音声認識結果 +- `/run/error_*` - 各種エラー通知 +- `/run/initialization_complete` - 初期化完了通知 + +#### `mapping` (dict) +フロントエンドからのリクエストを処理する関数マッピング。各エンドポイントに対して: +- `status`: ロック状態(True: 処理可能, False: ロック中) +- `variable`: 実行する Controller メソッド + +**エンドポイント分類:** +- `/get/data/*` - 設定値の取得(初期化時に使用) +- `/set/data/*` - 設定値の更新 +- `/set/enable/*` - 機能の有効化 +- `/set/disable/*` - 機能の無効化 +- `/run/*` - アクション実行(メッセージ送信、ダウンロード等) + +#### `init_mapping` (dict) +初期化時に実行される `/get/data/*` エンドポイントのサブセット。アプリケーション起動時に全設定値をフロントエンドに送信するために使用。 + +### 2. Mainクラス + +#### コンストラクタ `__init__(controller_instance, mapping_data, worker_count)` + +**パラメータ:** +- `controller_instance`: Controller インスタンス +- `mapping_data`: エンドポイントマッピング辞書 +- `worker_count`: ハンドラワーカースレッド数(デフォルト: 3) + +**初期化処理:** +1. リクエストキュー (`Queue[Tuple[str, Any]]`) の作成 +2. 停止イベント (`Event`) の作成 +3. エンドポイント別 Lock の生成: + - `/set/enable/xxx` と `/set/disable/xxx` を `/lock/set/xxx` に正規化 + - 同一機能の有効化/無効化リクエストが競合しないよう排他制御 + +**正規化ロジックの例:** +```python +"/set/enable/translation" → "/lock/set/translation" +"/set/disable/translation" → "/lock/set/translation" +# 両方が同じロックを共有 → 排他的に実行される +``` + +#### `receiver()` メソッド + +**責務:** stdin から JSON リクエストを読み取り、キューに投入 + +**処理フロー:** +1. `sys.stdin.readline()` でブロッキング読み取り +2. JSON パース (`json.loads()`) +3. エンドポイントとデータを抽出 +4. データが存在する場合は Base64 デコード (`encodeBase64()`) +5. ログ出力 (`printLog()`) +6. キューに投入 `self.queue.put((endpoint, data))` + +**エラー処理:** +- JSON パースエラー: ログ出力して継続 +- EOF 到達: 0.1秒待機して再試行 +- その他の例外: `errorLogging()` でトレースバック記録 + +**スレッド:** デーモンスレッド `main_receiver` として起動 + +#### `handler()` メソッド + +**責務:** キューからリクエストを取り出し、適切なロックを取得して処理 + +**処理フロー:** +1. キューから `(endpoint, data)` を取得(0.5秒タイムアウト) +2. エンドポイントを正規化キーに変換 +3. 対応する Lock を取得試行(非ブロッキング) + - 取得成功 → 処理実行 → ロック解放 + - 取得失敗 → 0.05秒待機して再キュー +4. `_call_handler(endpoint, data)` を呼び出し +5. レスポンスを stdout に出力 (`printResponse()`) + +**排他制御の意義:** +- 例: 翻訳機能の有効化中に無効化リクエストが来た場合、無効化は待機 +- 異なる機能のリクエストは並列実行可能 + +**再キューロジック:** +- status == 423 (Locked): 0.1秒待機して再キュー +- これにより、初期化中の設定変更リクエストが適切にリトライされる + +**ワーカー数:** `worker_count` 個のスレッド `main_handler_0`, `main_handler_1`, ... として起動 + +#### `_call_handler(endpoint, data)` メソッド + +**責務:** 実際のビジネスロジック実行 + +**処理フロー:** +1. `mapping` から対応するハンドラを取得 +2. エンドポイントが存在しない → status 404 +3. ハンドラの `status` が False → status 423 (Locked) +4. ハンドラの `variable` 関数を実行 → `response = handler["variable"](data)` +5. 0.2秒待機(処理安定化のため) +6. status と result を抽出して返却 + +**エラー処理:** +- 例外発生時: `errorLogging()` でトレースバック記録、status 500 を返却 + +#### `start()` / `stop(wait)` メソッド + +**start():** +- `startReceiver()` - stdin 読み取りスレッド起動 +- `startHandler()` - ハンドラワーカースレッド起動 + +**stop(wait):** +- `_stop_event.set()` - 全スレッドに停止シグナル送信 +- 各スレッドを `join(timeout=remaining)` で待機(最大 `wait` 秒) + +### 3. 初期化シーケンス + +**`if __name__ == "__main__":` ブロック:** + +1. `main_instance` 作成 +2. `startReceiver()` - stdin リスニング開始 +3. `startHandler()` - リクエスト処理開始 +4. **Watchdog 設定:** + - `controller.setWatchdogCallback(main_instance.stop)` + - Watchdog がタイムアウトした場合にプロセス全体を停止 +5. **Controller 初期化:** + - `controller.init()` + - Model の遅延初期化、デバイス列挙、ネットワーク接続チェック + - `init_mapping` のすべてのエンドポイントを実行して初期設定をフロントエンドに送信 +6. **マッピングのアンロック:** + - すべての `mapping[key]["status"]` を True に設定 + - これにより初期化中だった機能が利用可能になる +7. `main_instance.start()` - 実質的には何もしない(既に起動済み) + +## 並列処理とスレッドセーフティ + +### スレッド構成 + +| スレッド名 | 役割 | 生存期間 | +|-----------|------|---------| +| `main_receiver` | stdin からの JSON 読み取り | プロセス終了まで | +| `main_handler_0` ~ `main_handler_N` | リクエスト処理ワーカー | プロセス終了まで | + +### 同期メカニズム + +1. **キュー (`Queue`):** + - スレッドセーフな FIFO キュー + - receiver → handler への通信チャネル + +2. **エンドポイント別 Lock (`dict[str, Lock]`):** + - 同一リソースへの競合アクセスを防止 + - 正規化キーによる enable/disable ペアの統合 + +3. **停止イベント (`Event`):** + - グレースフルシャットダウン用のシグナル + +### デッドロック回避 + +- **非ブロッキング Lock 取得:** `lock.acquire(blocking=False)` +- **失敗時の再キュー:** ロック取得失敗時は即座に諦めて再キュー +- **タイムアウト付きキュー取得:** `queue.get(timeout=0.5)` で無限待機を回避 + +## プロトコル仕様 + +### リクエストフォーマット (stdin) + +```json +{ + "endpoint": "/set/data/transparency", + "data": "ODU=" // Base64 encoded: "85" +} +``` + +**フィールド:** +- `endpoint`: 実行するエンドポイント(必須) +- `data`: パラメータ(オプション、Base64 エンコード) + +### レスポンスフォーマット (stdout) + +```json +{ + "status": 200, + "endpoint": "/set/data/transparency", + "result": 85 +} +``` + +**フィールド:** +- `status`: HTTP ステータスコード相当 + - 200: 成功 + - 400: バリデーションエラー + - 404: 無効なエンドポイント + - 423: ロック中(リトライされる) + - 500: 内部エラー +- `endpoint`: リクエストされたエンドポイント +- `result`: 処理結果(型はエンドポイントに依存) + +### ログフォーマット (stdout) + +```json +{ + "status": 348, // 専用ステータスコード + "log": "setSelectedTabNo", + "data": "1" +} +``` + +## エラーハンドリング + +### 1. JSON パースエラー +- **発生箇所:** `receiver()` の `json.loads()` +- **処理:** `errorLogging()` でトレースバック記録、リクエストをスキップ + +### 2. ハンドラ実行エラー +- **発生箇所:** `_call_handler()` の `handler["variable"](data)` +- **処理:** + - `errorLogging()` でトレースバック記録 + - status 500 と "Internal error" を返却 + - プロセスは継続 + +### 3. JSON シリアライズエラー +- **発生箇所:** `printResponse()` の `json.dumps()` +- **処理:** + - エラーログに詳細を記録 + - フォールバック JSON を出力(status 500) + - プロセスは継続 + +### 4. EOF (stdin 終了) +- **発生箇所:** `receiver()` の `readline()` +- **処理:** 0.1秒待機して再試行(フロントエンドの再起動待ち) + +## パフォーマンス最適化 + +### 1. 複数ワーカースレッド +- デフォルト3スレッドで並列処理 +- CPU バウンドな処理(翻訳、文字起こし)を効率化 + +### 2. 非ブロッキングロック +- ロック競合時に即座に再キュー +- スレッドのブロッキング時間を最小化 + +### 3. 処理安定化待機 +- 各ハンドラ実行後に 0.2秒待機 +- 連続リクエストによる競合状態を回避 + +## 制限事項 + +### 1. 初期化中の制限 +- `mapping[key]["status"] = False` の間はリクエストが 423 でリトライされる +- 初期化完了まで最大数秒のレイテンシが発生 + +### 2. stdin の単方向性 +- stdin → キュー → ハンドラの一方向フロー +- 複数のフロントエンドからの同時接続は非対応 + +### 3. シリアル実行の保証 +- 同一エンドポイントのリクエストは排他的に実行されるが、 +- 異なるエンドポイントは並列実行される可能性がある +- 依存関係のある操作は呼び出し側で順序制御が必要 + +## デバッグとトラブルシューティング + +### ログファイル + +| ファイル名 | 内容 | +|-----------|------| +| `process.log` | 全リクエスト/レスポンスの記録 | +| `error.log` | 例外トレースバック | + +### デバッグ手法 + +1. **リクエストトレース:** + - `process.log` で endpoint と data を確認 + - Base64 デコードは `base64.b64decode(data).decode('utf-8')` で手動実行 + +2. **ロック競合の検出:** + - 同一エンドポイントで status 423 が頻発する場合 + - `_canonical_lock_key()` の正規化ロジックを確認 + +3. **パフォーマンス分析:** + - 各リクエストの処理時間は status 前後のタイムスタンプから算出 + - worker_count を増やして並列度を調整 + +## 今後の拡張性 + +### 1. 双方向通信 +- WebSocket への移行でリアルタイム通知を改善 +- stdin/stdout は互換性のため維持 + +### 2. 動的ワーカー数調整 +- キューの深さに応じてスレッド数を自動調整 +- CPU 負荷に応じた適応的なスケーリング + +### 3. 優先度キュー +- 重要なリクエスト(エラー通知等)を優先処理 +- `queue.PriorityQueue` への移行 + +## 関連ファイル + +- `controller.py` - ビジネスロジック実装 +- `model.py` - 機能ファサード +- `utils.py` - ログとユーティリティ +- `config.py` - 設定管理 + +## コーディング規約 + +本ファイルは以下の規約に従う: +- PEP 8 スタイルガイド +- 型ヒント (`typing` モジュール) +- Docstring は Google スタイル +- エラーハンドリングは防御的に実装 + +## テストシナリオ + +### 1. 基本動作テスト +```python +# stdin に JSON を送信 +echo '{"endpoint": "/get/data/version", "data": null}' | python mainloop.py +# 期待される出力: {"status": 200, "endpoint": "/get/data/version", "result": "1.0.0"} +``` + +### 2. 並列リクエストテスト +- 複数の設定変更リクエストを同時送信 +- すべてが正常に処理されることを確認 + +### 3. ロック競合テスト +- 翻訳の有効化と無効化を連続送信 +- 両方が排他的に実行されることを確認 + +### 4. エラー回復テスト +- 不正なJSON、無効なエンドポイント、不正なデータを送信 +- プロセスがクラッシュせずエラーレスポンスを返すことを確認 + +## まとめ + +`mainloop.py` は VRCT の中核となる通信レイヤーであり、stdin/stdout を介したフロントエンドとの JSON ベースプロトコルを実装する。複数のワーカースレッドと細粒度のロックにより、高い並列性と排他制御を両立させている。初期化シーケンスとエラーハンドリングは堅牢に設計されており、プロセスの安定稼働を保証する。 diff --git a/src-python/docs/model.md b/src-python/docs/model.md new file mode 100644 index 00000000..41e3ee3f --- /dev/null +++ b/src-python/docs/model.md @@ -0,0 +1,1277 @@ +# model.py 設計書 + +## 概要 + +`model.py` は VRCT アプリケーションのビジネスロジックファサードとして機能し、音声認識、翻訳、オーバーレイ表示、OSC通信、WebSocket通信など、すべてのサブシステムへの統一されたインターフェースを提供する。シングルトンパターンで実装され、重い初期化処理を遅延実行することで、アプリケーションの起動時間を短縮している。 + +## アーキテクチャ上の位置づけ + +``` +┌─────────────┐ +│controller.py│ (Business Logic Control Layer) +└──────┬──────┘ + │ Facade Pattern +┌──────▼──────┐ +│ model.py │ ◄── このファイル +└──────┬──────┘ + │ Aggregation & Delegation +┌──────▼────────────────────────────────┐ +│ Subsystems │ +│ - Translator │ +│ - AudioTranscriber │ +│ - Overlay / OverlayImage │ +│ - OSCHandler │ +│ - WebSocketServer │ +│ - Transliterator │ +│ - Watchdog │ +│ - DeviceManager (via device_manager) │ +└───────────────────────────────────────┘ +``` + +## 主要コンポーネント + +### 1. threadFnc クラス + +**責務:** 関数を繰り返し実行するスレッドラッパー + +**特徴:** +- デーモンスレッドとして動作 +- ループ制御(停止・一時停止・再開)機能を提供 +- 終了時のクリーンアップ関数をサポート + +**メソッド:** + +#### `__init__(fnc, end_fnc=None, daemon=True, *args, **kwargs)` + +**パラメータ:** +- `fnc`: 繰り返し実行する関数 +- `end_fnc`: スレッド終了時に実行する関数(オプション) +- `daemon`: デーモンフラグ(デフォルト: True) +- `*args, **kwargs`: `fnc` に渡す引数 + +#### `stop() -> None` +ループを停止し、スレッドを終了させる。 + +#### `pause() -> None` +ループを一時停止する(関数の実行を停止)。 + +#### `resume() -> None` +一時停止したループを再開する。 + +#### `run() -> None` +スレッドのメインループ。`self.loop` が True の間、`self.fnc()` を繰り返し呼び出す。 + +**使用例:** +```python +def print_message(): + print("Hello") + sleep(1) + +def cleanup(): + print("Thread ended") + +th = threadFnc(print_message, end_fnc=cleanup) +th.start() +# ... しばらく実行 ... +th.stop() +th.join() +``` + +--- + +### 2. Model クラス + +**責務:** アプリケーションのすべてのサブシステムへのファサードインターフェース + +**パターン:** シングルトン(`__new__` で制御) + +**初期化戦略:** 遅延初期化(Lazy Initialization) +- `__new__`: インスタンスの生成のみ(軽量) +- `init()`: 重い初期化処理(明示的な呼び出しが必要) +- `ensure_initialized()`: 初期化が必要なメソッドで自動的に呼び出される + +--- + +### 3. 初期化メソッド + +#### `__new__(cls) -> Model` + +**責務:** シングルトンインスタンスの生成 + +**処理:** +1. `cls._instance` が None の場合のみ新規インスタンスを生成 +2. `_inited` フラグを False に設定(実際の初期化は未実施) +3. 既存のインスタンスがあればそれを返却 + +**重要:** このメソッドでは重い初期化を行わない(import 時のパフォーマンス向上) + +#### `init() -> None` + +**責務:** すべてのサブシステムの初期化 + +**処理:** +1. **初期化済みチェック:** `_inited` フラグが True なら何もしない +2. **属性の初期化:** + ```python + self.logger = None + self.mic_audio_queue = None + self.mic_mute_status = None + self.previous_send_message = "" + self.previous_receive_message = "" + ``` +3. **サブシステムの初期化:** + - `Translator()`: 翻訳エンジン + - `KeywordProcessor()`: 禁止ワードフィルター + - `Overlay()`: オーバーレイシステム + - `OverlayImage()`: オーバーレイ画像生成 + - `Transliterator()`: 音訳(ひらがな・ローマ字変換) + - `Watchdog()`: プロセス監視 + - `OSCHandler()`: OSC通信 + - `WebSocketServer()`: WebSocket通信 +4. **コールバック関数の初期化:** + ```python + self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None + self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None + ``` +5. **初期化完了フラグ:** `_inited = True` + +#### `ensure_initialized() -> None` + +**責務:** 初期化が未実施の場合に `init()` を呼び出す + +**使用箇所:** 初期化が必要なすべての public メソッド + +**エラーハンドリング:** +```python +try: + self.init() +except Exception: + errorLogging() +``` + +--- + +### 4. 翻訳機能 + +#### モデルウェイト管理 + +##### `checkTranslatorCTranslate2ModelWeight(weight_type: str) -> bool` +指定されたモデルウェイトが存在するかチェック。 + +**パラメータ:** +- `weight_type`: "tiny", "small", "medium", "large" 等 + +**戻り値:** モデルが存在する場合 True + +##### `downloadCTranslate2ModelWeight(weight_type, callback=None, end_callback=None) -> bool` + +**責務:** CTranslate2 モデルウェイトのダウンロード + +**パラメータ:** +- `weight_type`: モデルタイプ +- `callback`: 進捗通知用コールバック(`progress: float` を受け取る) +- `end_callback`: 完了時のコールバック + +**実装:** `downloadCTranslate2Weight()` ユーティリティ関数に委譲 + +##### `downloadCTranslate2ModelTokenizer(weight_type) -> bool` +トークナイザーファイルのダウンロード。 + +#### 翻訳モデル制御 + +##### `changeTranslatorCTranslate2Model() -> None` + +**責務:** 翻訳モデルの変更・再ロード + +**処理:** +```python +self.translator.changeCTranslate2Model( + path=config.PATH_LOCAL, + model_type=config.CTRANSLATE2_WEIGHT_TYPE, + device=config.SELECTED_TRANSLATION_COMPUTE_DEVICE["device"], + device_index=config.SELECTED_TRANSLATION_COMPUTE_DEVICE["device_index"], + compute_type=config.SELECTED_TRANSLATION_COMPUTE_TYPE +) +``` + +**VRAMエラー:** `ValueError("VRAM_OUT_OF_MEMORY")` を送出する可能性がある + +##### `isLoadedCTranslate2Model() -> bool` +CTranslate2 モデルがロード済みかチェック。 + +##### `isChangedTranslatorParameters() -> bool` +翻訳パラメータが変更されたかチェック。 + +##### `setChangedTranslatorParameters(is_changed: bool) -> None` +翻訳パラメータ変更フラグを設定。 + +#### DeepL 認証 + +##### `authenticationTranslatorDeepLAuthKey(auth_key: str) -> bool` + +**責務:** DeepL API キーの検証 + +**処理:** `translator.authenticationDeepLAuthKey()` に委譲 + +**戻り値:** 認証成功時 True + +#### 翻訳実行 + +##### `getTranslate(translator_name, source_language, target_language, target_country, message) -> Tuple[str, bool]` + +**責務:** メッセージの翻訳 + +**パラメータ:** +- `translator_name`: "CTranslate2", "DeepL", "DeepL_API" 等 +- `source_language`: 元言語("ja", "en" 等) +- `target_language`: 翻訳先言語 +- `target_country`: 翻訳先国(方言対応用) +- `message`: 翻訳するテキスト + +**戻り値:** +- `translation`: 翻訳結果(文字列) +- `success_flag`: 成功時 True + +**エラーハンドリング:** +```python +translation = self.translator.translate(...) +if isinstance(translation, str): + success_flag = True +else: + # 翻訳失敗時のリトライロジック + while True: + # フェールセーフ処理 +``` + +##### `getInputTranslate(message, source_language=None) -> Tuple[list, list]` + +**責務:** 送信メッセージの翻訳(複数言語対応) + +**処理:** +1. `config.SELECTED_TRANSLATION_ENGINES[config.SELECTED_TAB_NO]` で翻訳エンジンを取得 +2. `config.SELECTED_TARGET_LANGUAGES` で翻訳先言語リストを取得 +3. 有効な各言語について `getTranslate()` を呼び出し + +**戻り値:** +- `translations`: 翻訳結果のリスト +- `success_flags`: 各翻訳の成功フラグのリスト + +##### `getOutputTranslate(message, source_language=None) -> Tuple[list, list]` + +**責務:** 受信メッセージの翻訳(単一言語) + +**処理:** `getInputTranslate()` と同様だが、翻訳先が自分の言語(1つ)のみ + +--- + +### 5. 音声認識機能 + +#### Whisper モデル管理 + +##### `checkTranscriptionWhisperModelWeight(weight_type: str) -> bool` +Whisper モデルウェイトの存在確認。 + +##### `downloadWhisperModelWeight(weight_type, callback=None, end_callback=None) -> bool` +Whisper モデルウェイトのダウンロード。 + +#### マイク音声認識 + +##### `startMicTranscript(fnc: Callable[[dict], None]) -> None` + +**責務:** マイク音声認識の開始 + +**パラメータ:** +- `fnc`: 認識結果を受け取るコールバック関数 + +**処理フロー:** +1. **デバイス取得:** + ```python + mic_host_name = config.SELECTED_MIC_HOST + mic_device_name = config.SELECTED_MIC_DEVICE + mic_device_list = device_manager.getMicDevices().get(mic_host_name, [...]) + selected_mic_device = [device for device in mic_device_list if device["name"] == mic_device_name] + ``` +2. **デバイス検証:** + - デバイスがない場合、`fnc({"text": False, "language": None})` を呼び出して終了 +3. **音声キューの作成:** + ```python + self.mic_audio_queue = Queue() + ``` +4. **レコーダーの初期化:** + ```python + self.mic_audio_recorder = SelectedMicEnergyAndAudioRecorder( + device=mic_device, + energy_threshold=config.MIC_THRESHOLD, + dynamic_energy_threshold=config.MIC_AUTOMATIC_THRESHOLD, + phrase_time_limit=config.MIC_RECORD_TIMEOUT, + ) + self.mic_audio_recorder.recordIntoQueue(self.mic_audio_queue, None) + ``` +5. **文字起こし器の初期化:** + ```python + self.mic_transcriber = AudioTranscriber( + speaker=False, + source=self.mic_audio_recorder.source, + phrase_timeout=config.MIC_PHRASE_TIMEOUT, + max_phrases=config.MIC_MAX_PHRASES, + transcription_engine=config.SELECTED_TRANSCRIPTION_ENGINE, + root=config.PATH_LOCAL, + whisper_weight_type=config.WHISPER_WEIGHT_TYPE, + device=config.SELECTED_TRANSCRIPTION_COMPUTE_DEVICE["device"], + device_index=config.SELECTED_TRANSCRIPTION_COMPUTE_DEVICE["device_index"], + compute_type=config.SELECTED_TRANSCRIPTION_COMPUTE_TYPE, + ) + ``` +6. **文字起こしスレッドの起動:** + ```python + def sendMicTranscript(): + # キューから音声データを取得 + # AudioTranscriber で文字起こし + # fnc() で結果を送信 + + def endMicTranscript(): + # クリーンアップ処理 + + self.mic_print_transcript = threadFnc(sendMicTranscript, end_fnc=endMicTranscript) + self.mic_print_transcript.start() + ``` +7. **ミュート状態の同期:** + ```python + self.changeMicTranscriptStatus() + ``` + +##### `resumeMicTranscript() -> None` + +**責務:** 一時停止したマイク音声認識の再開 + +**処理:** +1. 音声キューをクリア +2. レコーダーを再開: `self.mic_audio_recorder.resume()` + +##### `pauseMicTranscript() -> None` + +**責務:** マイク音声認識の一時停止 + +**処理:** `self.mic_audio_recorder.pause()` + +##### `changeMicTranscriptStatus() -> None` + +**責務:** VRChat のマイクミュート状態に応じて音声認識を制御 + +**処理:** +```python +if config.VRC_MIC_MUTE_SYNC is True: + match self.mic_mute_status: + case True: + self.pauseMicTranscript() + case False: + self.resumeMicTranscript() + case None: + self.resumeMicTranscript() # 不明な場合は一時停止しない +else: + self.resumeMicTranscript() +``` + +##### `stopMicTranscript() -> None` + +**責務:** マイク音声認識の停止とリソース解放 + +**処理:** +1. 文字起こしスレッドの停止 +2. レコーダーの再開(一時停止中の場合)と停止 +3. インスタンスの破棄 + +**VRAMエラー検出:** + +##### `detectVRAMError(error: Exception) -> Tuple[bool, Optional[str]]` + +**責務:** VRAM不足エラーの検出 + +**処理:** +```python +error_str = str(error) +if isinstance(error, ValueError) and len(error.args) > 0 and error.args[0] == "VRAM_OUT_OF_MEMORY": + return True, error_str +if "CUDA out of memory" in error_str or "CUBLAS_STATUS_ALLOC_FAILED" in error_str: + return True, error_str +return False, None +``` + +**使用箇所:** +- 翻訳実行時 +- 音声認識開始時 + +#### スピーカー音声認識 + +以下のメソッドはマイク音声認識と同様の構造: +- `startSpeakerTranscript(fnc)` +- `stopSpeakerTranscript()` + +**相違点:** +- `speaker=True` で AudioTranscriber を初期化 +- `SelectedSpeakerEnergyAndAudioRecorder` を使用 + +#### エネルギーレベル監視 + +##### `startCheckMicEnergy(fnc: Optional[Callable[[float], None]] = None) -> None` + +**責務:** マイクの音量レベル監視の開始 + +**処理:** +1. コールバック関数を設定: `self.check_mic_energy_fnc = fnc` +2. マイクデバイスを取得 +3. エネルギーレコーダーを初期化: + ```python + mic_energy_queue = Queue() + self.mic_energy_recorder = SelectedMicEnergyRecorder(mic_device) + self.mic_energy_recorder.recordIntoQueue(mic_energy_queue) + ``` +4. エネルギー送信スレッドを起動: + ```python + def sendMicEnergy(): + if not mic_energy_queue.empty(): + energy = mic_energy_queue.get() + self.check_mic_energy_fnc(energy) + sleep(0.01) + + self.mic_energy_plot_progressbar = threadFnc(sendMicEnergy) + self.mic_energy_plot_progressbar.start() + ``` + +##### `stopCheckMicEnergy() -> None` +エネルギー監視の停止とリソース解放。 + +**対応するスピーカー用メソッド:** +- `startCheckSpeakerEnergy(fnc)` +- `stopCheckSpeakerEnergy()` + +--- + +### 6. オーバーレイ機能 + +#### 画像生成 + +##### `createOverlayImageSmallLog(message, your_language, translation, target_language) -> object` + +**責務:** 小さなログウィンドウ用の画像生成 + +**パラメータ:** +- `message`: 元のメッセージ(オプション) +- `your_language`: 元の言語(オプション) +- `translation`: 翻訳結果のリスト +- `target_language`: 翻訳先言語の辞書(オプション) + +**処理:** +```python +target_language_list = [] +if isinstance(target_language, dict): + target_language_list = list(target_language.values()) +return self.overlay_image.createOverlayImageSmallLog( + message, your_language, translation, target_language_list +) +``` + +##### `createOverlayImageSmallMessage(message: str) -> object` + +**責務:** 小さなメッセージウィンドウ用の画像生成(単一言語) + +**処理:** +```python +ui_language = config.UI_LANGUAGE +convert_languages = { + "en": "Default", + "jp": "Japanese", + "ko": "Korean", + "zh-Hans": "Chinese Simplified", + "zh-Hant": "Chinese Traditional", +} +language = convert_languages.get(ui_language, "Default") +return self.overlay_image.createOverlayImageSmallLog(message, language) +``` + +##### `createOverlayImageLargeLog(message_type, message, your_language, translation, target_language=None) -> object` + +**責務:** 大きなログウィンドウ用の画像生成 + +**パラメータ:** +- `message_type`: "send" または "received" + +**処理:** `createOverlayImageSmallLog()` と同様 + +##### `createOverlayImageLargeMessage(message: str) -> object` + +**責務:** 大きなメッセージウィンドウ用の画像生成 + +**特殊処理:** +```python +overlay_image = OverlayImage(config.PATH_LOCAL) +for _ in range(2): + # 2回繰り返して画像を生成(理由は不明、バグ修正のため?) + overlay_image.createOverlayImageLargeLog("send", message, language) +return overlay_image.createOverlayImageLargeLog("send", message, language) +``` + +#### 表示制御 + +##### `clearOverlayImageSmallLog() -> None` +小さなログウィンドウをクリア。 + +##### `updateOverlaySmallLog(img: object) -> None` +小さなログウィンドウの画像を更新。 + +##### `updateOverlaySmallLogSettings() -> None` + +**責務:** 小さなログウィンドウの設定更新 + +**処理:** 設定の変更を検出し、オーバーレイに反映: +```python +size = "small" +if (self.overlay.settings[size]["x_pos"] != config.OVERLAY_SMALL_LOG_SETTINGS["x_pos"] or + # ... 他の設定項目 ...): + self.overlay.updateSettings(config.OVERLAY_SMALL_LOG_SETTINGS, size) +``` + +**設定項目:** +- 位置(x_pos, y_pos, z_pos) +- 回転(x_rotation, y_rotation, z_rotation) +- トラッカー(tracker) +- 表示時間(display_duration) +- フェードアウト時間(fadeout_duration) +- 透明度(opacity) +- UIスケーリング(ui_scaling) + +##### `clearOverlayImageLargeLog() -> None` +大きなログウィンドウをクリア。 + +##### `updateOverlayLargeLog(img: object) -> None` +大きなログウィンドウの画像を更新。 + +##### `updateOverlayLargeLogSettings() -> None` +大きなログウィンドウの設定更新(`updateOverlaySmallLogSettings()` と同様)。 + +#### オーバーレイシステム制御 + +##### `startOverlay() -> None` +オーバーレイシステムを起動(OpenVR の初期化)。 + +##### `shutdownOverlay() -> None` +オーバーレイシステムを終了(リソース解放)。 + +--- + +### 7. OSC 通信機能 + +#### 設定 + +##### `setOscIpAddress(ip_address: str) -> None` +VRChat への送信先 IP アドレスを設定。 + +##### `setOscPort(port: int) -> None` +OSC ポート番号を設定。 + +#### メッセージ送信 + +##### `oscStartSendTyping() -> None` +タイピング中の通知を送信(VRChat のチャットボックスにインジケーターが表示される)。 + +##### `oscStopSendTyping() -> None` +タイピング終了の通知を送信。 + +##### `oscSendMessage(message: str) -> None` + +**責務:** VRChat へメッセージを送信 + +**パラメータ:** +- `message`: 送信するテキスト + +**処理:** +```python +self.osc_handler.sendMessage( + message=message, + notification=config.NOTIFICATION_VRC_SFX +) +``` + +#### OSC 受信 + +##### `setMuteSelfStatus() -> None` +VRChat の現在のマイクミュート状態を取得。 + +##### `startReceiveOSC() -> None` + +**責務:** OSC パラメータの受信開始 + +**処理:** +```python +def changeHandlerMute(address, osc_arguments): + if config.ENABLE_TRANSCRIPTION_SEND is True: + self.mic_mute_status = osc_arguments[0] + self.changeMicTranscriptStatus() + +dict_filter_and_target = { + self.osc_handler.osc_parameter_muteself: changeHandlerMute, +} +self.osc_handler.setDictFilterAndTarget(dict_filter_and_target) +self.osc_handler.receiveOscParameters() +``` + +**監視パラメータ:** +- `/avatar/parameters/MuteSelf`: マイクミュート状態 + +##### `stopReceiveOSC() -> None` +OSC 受信を停止。 + +##### `getIsOscQueryEnabled() -> bool` +OSC Query 機能が有効かチェック。 + +--- + +### 8. 音訳機能 + +#### 音訳システム制御 + +##### `startTransliteration() -> None` +音訳システムを起動(`Transliterator` インスタンスを生成)。 + +##### `stopTransliteration() -> None` +音訳システムを停止(インスタンスを破棄)。 + +#### 音訳実行 + +##### `convertMessageToTransliteration(message, hiragana=True, romaji=True) -> list` + +**責務:** メッセージをひらがな・ローマ字に変換 + +**パラメータ:** +- `message`: 変換するテキスト +- `hiragana`: ひらがなを含める +- `romaji`: ローマ字を含める + +**処理:** +```python +if hiragana is False and romaji is False: + return [] + +keys_to_keep = {"orig"} +if hiragana: + keys_to_keep.add("hira") +if romaji: + keys_to_keep.add("hepburn") + +if self.transliterator is None: + self.startTransliteration() + +data_list = self.transliterator.analyze(message, use_macron=False) +filtered_list = [ + {key: value for key, value in item.items() if key in keys_to_keep} + for item in data_list +] +return filtered_list +``` + +**戻り値の例:** +```python +[ + {"orig": "こんにちは", "hira": "こんにちは", "hepburn": "konnichiwa"}, + {"orig": "世界", "hira": "せかい", "hepburn": "sekai"} +] +``` + +--- + +### 9. キーワードフィルター + +#### フィルター管理 + +##### `resetKeywordProcessor() -> None` +キーワードプロセッサをリセット(すべてのキーワードを削除)。 + +##### `addKeywords() -> None` +禁止ワードをキーワードプロセッサに追加。 + +**処理:** +```python +for f in config.MIC_WORD_FILTER: + self.keyword_processor.add_keyword(f) +``` + +#### フィルタリング + +##### `checkKeywords(message: str) -> bool` +メッセージに禁止ワードが含まれているかチェック。 + +**戻り値:** 禁止ワードが含まれている場合 True + +**実装:** +```python +return len(self.keyword_processor.extract_keywords(message)) != 0 +``` + +--- + +### 10. 重複検出 + +##### `detectRepeatSendMessage(message: str) -> bool` + +**責務:** 送信メッセージの重複検出 + +**処理:** +```python +repeat_flag = False +if self.previous_send_message == message: + repeat_flag = True +self.previous_send_message = message +return repeat_flag +``` + +##### `detectRepeatReceiveMessage(message: str) -> bool` +受信メッセージの重複検出(`detectRepeatSendMessage()` と同様)。 + +--- + +### 11. デバイス管理 + +#### マイクデバイス + +##### `getListMicHost() -> list` + +**責務:** マイクホストのリスト取得 + +**戻り値:** ["MME", "WASAPI", ...] 等 + +**処理:** +```python +try: + dm = device_manager.getMicDevices() + result = [host for host in dm.keys()] +except Exception: + errorLogging() + result = [] +return result +``` + +##### `getMicDefaultDevice() -> str` +選択されたホストのデフォルトマイクデバイス名を取得。 + +##### `getListMicDevice() -> list` +選択されたホストのマイクデバイス一覧を取得。 + +#### スピーカーデバイス + +##### `getListSpeakerDevice() -> list` +スピーカーデバイス一覧を取得。 + +**処理:** +```python +try: + sd = device_manager.getSpeakerDevices() + result = [device["name"] for device in sd] +except Exception: + errorLogging() + result = ["NoDevice"] +return result +``` + +--- + +### 12. 言語管理 + +##### `getListLanguageAndCountry() -> list` + +**責務:** 音声認識と翻訳の両方をサポートする言語・国のリスト取得 + +**処理:** +1. `transcription_lang` から音声認識サポート言語を取得 +2. `translation_lang` から翻訳サポート言語を取得 +3. 両方でサポートされている言語を抽出 +4. 各言語の国バリエーションを列挙 + +**戻り値の例:** +```python +[ + {"language": "en", "country": "US"}, + {"language": "en", "country": "UK"}, + {"language": "ja", "country": "JP"}, + # ... +] +``` + +##### `findTranslationEngines(source_lang, target_lang, engines_status) -> list` + +**責務:** 指定された言語ペアをサポートする翻訳エンジンの検索 + +**パラメータ:** +- `source_lang`: 元言語の辞書(複数の言語が有効化されている可能性) +- `target_lang`: 翻訳先言語の辞書 +- `engines_status`: 各エンジンの有効/無効状態 + +**処理:** +```python +selectable_engines = [key for key, value in engines_status.items() if value is True] +compatible_engines = [] +for engine in list(translation_lang.keys()): + languages = translation_lang.get(engine, {}).get("source", {}) + source_langs = [e["language"] for e in list(source_lang.values()) if e["enable"] is True] + target_langs = [e["language"] for e in list(target_lang.values()) if e["enable"] is True] + language_list = list(languages.keys()) + + if all(e in language_list for e in source_langs) and all(e in language_list for e in target_langs): + if engine in selectable_engines: + compatible_engines.append(engine) + +return compatible_engines +``` + +--- + +### 13. ロギング + +##### `startLogger() -> None` + +**責務:** ファイルロギングの開始 + +**処理:** +```python +os_makedirs(config.PATH_LOGS, exist_ok=True) +file_name = os_path.join(config.PATH_LOGS, f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log") +self.logger = setupLogger("log", file_name) +self.logger.disabled = False +``` + +**ログファイル名の例:** `2023-10-13_15-30-45.log` + +##### `stopLogger() -> None` +ファイルロギングの停止。 + +--- + +### 14. ソフトウェアアップデート + +##### `checkSoftwareUpdated() -> dict` + +**責務:** 最新バージョンの確認 + +**処理:** +```python +update_flag = False +version = "" +try: + # GitHub API 等から最新バージョン情報を取得 + # packaging.version.parse でバージョン比較 +except Exception: + errorLogging() +return { + "is_update_available": update_flag, + "new_version": version, +} +``` + +##### `updateSoftware() -> None` + +**責務:** 通常版のアップデート実行 + +**処理:** +1. アップデーターをダウンロード(最大5回リトライ) +2. `Popen()` でアップデーターを起動 +3. 現在のプロセスを終了 + +##### `updateCudaSoftware() -> None` +CUDA版のアップデート実行(`--cuda` オプション付きでアップデーターを起動)。 + +--- + +### 15. Watchdog 機能 + +##### `startWatchdog() -> None` + +**責務:** Watchdog 監視スレッドの起動 + +**処理:** +```python +self.th_watchdog = threadFnc(self.watchdog.start) +self.th_watchdog.daemon = True +self.th_watchdog.start() +``` + +##### `feedWatchdog() -> None` +Watchdog にハートビート信号を送信(タイムアウトをリセット)。 + +##### `setWatchdogCallback(callback: Callable) -> None` +Watchdog タイムアウト時のコールバック関数を設定。 + +##### `stopWatchdog() -> None` +Watchdog を停止し、スレッドの終了を待機。 + +--- + +### 16. WebSocket サーバー + +#### サーバー制御 + +##### `startWebSocketServer(host: str, port: int) -> None` + +**責務:** WebSocket サーバーの起動 + +**処理:** +1. 既に起動中なら何もしない +2. `websocket_server_loop = True` に設定 +3. 別スレッドで asyncio イベントループを実行: + ```python + async def WebSocketServerMain(): + self.websocket_server = WebSocketServer(host, port) + self.websocket_server_alive = True + await self.websocket_server.start() + # ループ終了まで待機 + self.websocket_server_alive = False + + self.th_websocket_server = Thread(target=lambda: asyncio.run(WebSocketServerMain())) + self.th_websocket_server.daemon = True + self.th_websocket_server.start() + ``` + +##### `stopWebSocketServer() -> None` + +**責務:** WebSocket サーバーの停止 + +**処理:** +1. `websocket_server_loop = False` に設定 +2. サーバーの停止を要求 +3. スレッドの終了を待機(タイムアウト付き) + +**エラーハンドリング:** +```python +try: + # サーバー停止処理 +except Exception: + errorLogging() +finally: + self.th_websocket_server = None + self.websocket_server = None + self.websocket_server_alive = False +``` + +##### `checkWebSocketServerAlive() -> bool` +WebSocket サーバーの稼働状態を確認。 + +#### メッセージ送信 + +##### `websocketSendMessage(message_dict: dict) -> bool` + +**責務:** すべての接続クライアントにメッセージをブロードキャスト + +**パラメータ:** +- `message_dict`: 送信する辞書(JSON にシリアライズされる) + +**処理:** +```python +if not self.websocket_server_alive or not self.websocket_server: + return False +try: + self.websocket_server.broadcast(message_dict) + return True +except Exception: + errorLogging() + return False +``` + +--- + +## 依存関係 + +### 外部ライブラリ + +```python +from subprocess import Popen +from os import makedirs as os_makedirs +from os import path as os_path +from datetime import datetime +from time import sleep +from queue import Queue +from threading import Thread +from requests import get as requests_get +from typing import Callable, Optional, cast +from packaging.version import parse +from flashtext import KeywordProcessor +``` + +### 内部モジュール + +```python +from device_manager import device_manager +from config import config +from models.translation.translation_translator import Translator +from models.osc.osc import OSCHandler +from models.transcription.transcription_recorder import SelectedMicEnergyAndAudioRecorder, SelectedSpeakerEnergyAndAudioRecorder +from models.transcription.transcription_recorder import SelectedMicEnergyRecorder, SelectedSpeakerEnergyRecorder +from models.transcription.transcription_transcriber import AudioTranscriber +from models.translation.translation_languages import translation_lang +from models.transcription.transcription_languages import transcription_lang +from models.translation.translation_utils import checkCTranslate2Weight, downloadCTranslate2Weight, downloadCTranslate2Tokenizer +from models.transcription.transcription_whisper import checkWhisperWeight, downloadWhisperWeight +from models.transliteration.transliteration_transliterator import Transliterator +from models.overlay.overlay import Overlay +from models.overlay.overlay_image import OverlayImage +from models.watchdog.watchdog import Watchdog +from models.websocket.websocket_server import WebSocketServer +from utils import errorLogging, setupLogger +``` + +--- + +## スレッド構成 + +### メインスレッド +- アプリケーションのメインループ(`mainloop.py` が管理) + +### Model 管理のスレッド + +#### 音声認識スレッド +- `mic_print_transcript`: マイク音声認識結果の処理 +- `speaker_print_transcript`: スピーカー音声認識結果の処理 + +#### エネルギー監視スレッド +- `mic_energy_plot_progressbar`: マイクの音量レベル監視 +- `speaker_energy_plot_progressbar`: スピーカーの音量レベル監視 + +#### その他のスレッド +- `th_watchdog`: Watchdog 監視 +- `th_websocket_server`: WebSocket サーバー(asyncio イベントループ) + +### サブシステム管理のスレッド +- `device_manager.th_monitoring`: デバイス変更監視 +- `mic_audio_recorder.th_record`: マイク音声録音 +- `speaker_audio_recorder.th_record`: スピーカー音声録音 +- `osc_handler.th_receive`: OSC パラメータ受信 + +--- + +## エラーハンドリング + +### VRAM不足エラー + +**検出:** +```python +is_vram_error, error_message = self.detectVRAMError(e) +``` + +**対応:** +1. エラーを `ValueError("VRAM_OUT_OF_MEMORY")` として送出 +2. Controller 側でキャッチして機能を無効化 +3. ユーザーに通知 + +### デバイスアクセスエラー + +**検出:** +- デバイスが見つからない場合: `NoDevice` +- アクセス失敗時: コールバックに `False` を渡す + +**対応:** +1. エラーをログに記録 +2. Controller に通知 +3. 処理を継続(他の機能に影響なし) + +### ネットワークエラー + +**検出:** +- 翻訳API呼び出し失敗 +- モデルウェイトダウンロード失敗 + +**対応:** +1. リトライロジック(翻訳の場合) +2. フォールバック(CTranslate2 への切り替え) +3. エラー通知 + +--- + +## パフォーマンス最適化 + +### 1. 遅延初期化 + +重い初期化処理を `init()` に分離し、必要になるまで実行しない。 + +**利点:** +- アプリケーションの起動時間を短縮 +- 未使用の機能のリソースを消費しない + +### 2. シングルトンパターン + +Model クラスはアプリケーション全体で1つのインスタンスのみ存在。 + +**利点:** +- メモリ使用量の削減 +- 状態の一貫性 + +### 3. スレッドによる並列処理 + +音声認識、エネルギー監視、WebSocket サーバーなど、ブロッキング処理を別スレッドで実行。 + +**利点:** +- UI のレスポンス性向上 +- 複数機能の同時実行 + +--- + +## テストシナリオ + +### 1. 初期化テスト + +**ケース:** +- 初回初期化 +- 既に初期化済みの場合 +- 初期化失敗時 + +**確認項目:** +- `_inited` フラグが正しく設定されているか +- すべてのサブシステムが初期化されているか +- エラーが適切にログされているか + +### 2. 音声認識テスト + +**ケース:** +- デバイスがない場合 +- 音声認識開始・停止・一時停止・再開 +- VRAMエラーの発生 + +**確認項目:** +- コールバックが正しく呼び出されているか +- スレッドが適切に管理されているか +- エラーが検出されているか + +### 3. 翻訳テスト + +**ケース:** +- 単一言語翻訳 +- 複数言語翻訳 +- 翻訳エンジンの切り替え +- API エラー + +**確認項目:** +- 翻訳結果が正しいか +- エラー時のフォールバックが動作するか + +### 4. オーバーレイテスト + +**ケース:** +- 画像生成 +- 設定更新 +- オーバーレイの起動・停止 + +**確認項目:** +- 画像が正しく生成されるか +- 設定変更が反映されるか + +--- + +## 制限事項 + +### 1. シングルトンの制約 + +**問題:** テストやマルチインスタンスが困難 + +**影響:** +- ユニットテストでモックが難しい +- 複数の VRChat インスタンスへの対応が不可能 + +### 2. グローバル状態依存 + +**問題:** `config` モジュールへの強い依存 + +**影響:** +- テスタビリティの低下 +- 設定変更の追跡が困難 + +### 3. エラーハンドリングの不完全性 + +**問題:** 一部のエラーは握りつぶされる + +**影響:** +- デバッグが困難 +- ユーザーへの適切なエラー通知が不足 + +### 4. スレッドの管理複雑性 + +**問題:** 多数のスレッドとその状態管理 + +**影響:** +- デッドロックのリスク +- リソースリークの可能性 + +--- + +## 今後の改善案 + +### 1. 依存性注入(DI)の導入 + +```python +class Model: + def __init__(self, config, device_manager, translator, ...): + self.config = config + self.device_manager = device_manager + self.translator = translator + # ... +``` + +**利点:** +- テスタビリティの向上 +- モジュール間の疎結合 + +### 2. 非同期化(asyncio) + +```python +async def startMicTranscript(self, callback): + async for result in self.mic_transcriber.transcribe(): + await callback(result) +``` + +**利点:** +- スレッド管理の簡素化 +- パフォーマンスの向上 + +### 3. イベント駆動アーキテクチャ + +```python +class Model: + def __init__(self): + self.event_bus = EventBus() + + def on_transcription_result(self, result): + self.event_bus.emit("transcription_result", result) +``` + +**利点:** +- モジュール間の疎結合 +- 拡張性の向上 + +### 4. エラーハンドリングの統一 + +```python +class ModelError(Exception): + pass + +class VRAMError(ModelError): + pass + +class DeviceError(ModelError): + pass +``` + +**利点:** +- エラーの分類と処理の統一 +- エラー情報の追跡 + +--- + +## 関連ファイル + +- **controller.py** - ビジネスロジック制御レイヤー +- **config.py** - 設定管理 +- **device_manager.py** - デバイス監視・自動選択 +- **mainloop.py** - 通信レイヤー +- **utils.py** - ログとユーティリティ関数 +- **models/** - サブシステムの実装 + +--- + +## まとめ + +`model.py` は VRCT のすべてのサブシステムへの統一されたファサードインターフェースを提供し、音声認識、翻訳、オーバーレイ、OSC通信、WebSocket通信など、複雑な機能を簡潔なAPIで公開する。シングルトンパターンと遅延初期化により、リソースの効率的な利用を実現している。スレッドを活用した並列処理により、複数の機能を同時に実行しながらUIのレスポンス性を維持している。VRAMエラーやデバイスエラーに対する適切なハンドリングにより、ユーザーエクスペリエンスを向上させている。 diff --git a/src-python/docs/utils.md b/src-python/docs/utils.md new file mode 100644 index 00000000..1abb554b --- /dev/null +++ b/src-python/docs/utils.md @@ -0,0 +1,940 @@ +# utils.py ドキュメント + +## 概要 +`utils.py` は VRCT アプリケーション全体で使用される汎用ユーティリティ関数とロギング機能を提供するモジュール。辞書構造の検証、ネットワーク接続確認、計算デバイス管理、Base64エンコーディング、構造化ログ出力など、複数のサブシステムで共有される基盤機能を集約している。 + +## 主要機能 +- 辞書構造の厳密な検証 +- ネットワーク接続状態の診断 +- WebSocketサーバーのアドレス可用性チェック +- IPアドレスのバリデーション +- CUDA/CPU計算デバイスの検出と最適化 +- 構造化ログ出力(process.log, error.log) +- Base64エンコード/デコード +- ログファイルのローテーション管理 + +## アーキテクチャ上の位置づけ + +``` +┌─────────────────┐ +│ All Modules │ (controller, model, device_manager, etc.) +└────────┬────────┘ + │ Import +┌────────▼────────┐ +│ utils.py │ ◄── このファイル +└─────────────────┘ + │ +┌────────▼────────┐ +│ External Deps │ (torch, ctranslate2, requests, ipaddress) +└─────────────────┘ +``` + +全てのモジュールから参照される共通基盤として機能し、循環参照を避けるため他の内部モジュールへの依存を持たない。 + +## 依存関係 + +### 標準ライブラリ +```python +import base64 +import json +import traceback +import logging +from logging.handlers import RotatingFileHandler +from typing import Any, List, Dict, Optional +``` + +### サードパーティライブラリ(オプション依存) +```python +import torch # GPU検出用(インポート失敗時はNoneにフォールバック) +from ctranslate2 import get_supported_compute_types # 計算タイプ取得用 +import requests # ネットワーク接続確認用 +import ipaddress # IPアドレス検証用 +import socket # WebSocketサーバー可用性チェック用 +``` + +**セーフガードインポート:** +```python +try: + import torch +except Exception: + torch = None # type: ignore + +try: + from ctranslate2 import get_supported_compute_types +except Exception: + def get_supported_compute_types(device: str, device_index: int) -> List[str]: + return [] +``` + +オプション依存が満たされない環境でもモジュールは正常にロード可能。 + +## 関数リファレンス + +### 1. 辞書構造検証 + +#### `validateDictStructure(data: dict, structure: dict) -> bool` + +**責務:** 辞書の構造と型が期待される仕様と完全に一致するかを検証 + +**アルゴリズム:** +1. 両方が辞書型であることを確認 +2. キーの数と名前が完全一致するかチェック +3. 各キーの値について: + - 期待値が辞書の場合: 再帰的に検証(多重入れ子対応) + - 期待値が型オブジェクトの場合: `isinstance()` で型チェック + +**引数:** +- `data` (dict): 検証対象の辞書 +- `structure` (dict): 期待される構造定義 + - 値には型(str, int, bool等)または入れ子の辞書を指定 + +**返り値:** +- `True`: 構造が完全に一致 +- `False`: 不一致(キー不足、余分なキー、型不一致等) + +**使用例:** +```python +# 単純な構造検証 +data = {"name": "Alice", "age": 30} +structure = {"name": str, "age": int} +assert validateDictStructure(data, structure) is True + +# 入れ子構造の検証 +data = { + "user": { + "id": 123, + "profile": {"name": "Bob", "active": True} + } +} +structure = { + "user": { + "id": int, + "profile": {"name": str, "active": bool} + } +} +assert validateDictStructure(data, structure) is True + +# 不一致の検出 +data = {"name": "Alice", "extra_key": "value"} +structure = {"name": str, "age": int} +assert validateDictStructure(data, structure) is False # キーが不一致 +``` + +**使用場面:** +- フロントエンドからのリクエストペイロード検証 +- 設定ファイルのスキーマ検証 +- API レスポンスの構造確認 + +--- + +### 2. ネットワーク診断 + +#### `isConnectedNetwork(url: str = "http://www.google.com", timeout: int = 3) -> bool` + +**責務:** インターネット接続の可用性を高速チェック + +**処理:** +1. 指定URLに HTTP GET リクエストを送信 +2. `timeout` 秒以内に 200 OK レスポンスを受信したら接続あり +3. タイムアウトまたは例外発生時は接続なし + +**引数:** +- `url` (str): 接続確認先URL(デフォルト: Google) +- `timeout` (int): タイムアウト時間(秒) + +**返り値:** +- `True`: ネットワーク接続あり +- `False`: ネットワーク接続なし + +**使用例:** +```python +if isConnectedNetwork(): + # モデルウェイトをダウンロード + downloadModelWeights() +else: + # オフラインモードで動作 + useLocalModels() +``` + +**注意事項:** +- ファイアウォールやプロキシ環境では正しく動作しない場合がある +- 初期化時の1回のみチェックを推奨(頻繁な呼び出しは避ける) + +--- + +#### `isAvailableWebSocketServer(host: str, port: int) -> bool` + +**責務:** 指定したホスト/ポートでWebSocketサーバーが起動可能かを確認 + +**処理:** +1. TCP ソケットを作成 +2. `SO_REUSEADDR` オプションを設定 +3. `bind()` を試行 +4. 成功 → アドレス利用可能、失敗 → アドレス使用中 + +**引数:** +- `host` (str): バインドするIPアドレス +- `port` (int): バインドするポート番号 + +**返り値:** +- `True`: アドレスが利用可能 +- `False`: アドレスが使用中 + +**使用例:** +```python +if isAvailableWebSocketServer("127.0.0.1", 8080): + startWebSocketServer("127.0.0.1", 8080) +else: + print("Port 8080 is already in use") +``` + +**注意事項:** +- `SO_REUSEADDR` により、TIME_WAIT 状態のアドレスも利用可能と判定される +- 管理者権限が必要なポート(1024未満)では失敗する場合がある + +--- + +#### `isValidIpAddress(ip_address: str) -> bool` + +**責務:** IPv4/IPv6アドレスの妥当性を検証 + +**処理:** +- `ipaddress.ip_address()` でパース +- 成功 → 有効なIPアドレス、失敗 → 無効 + +**引数:** +- `ip_address` (str): 検証対象のIPアドレス文字列 + +**返り値:** +- `True`: 有効なIPアドレス +- `False`: 無効なIPアドレス + +**使用例:** +```python +assert isValidIpAddress("127.0.0.1") is True +assert isValidIpAddress("2001:db8::1") is True +assert isValidIpAddress("invalid") is False +``` + +**サポート形式:** +- IPv4: "192.168.1.1", "127.0.0.1" +- IPv6: "2001:db8::1", "fe80::1" + +--- + +### 3. 計算デバイス管理 + +#### `getComputeDeviceList() -> List[Dict[str, Any]]` + +**責務:** 利用可能な計算デバイス(CPU/GPU)とサポートされる計算タイプを列挙 + +**返り値構造:** +```python +[ + { + "device": "cpu", + "device_index": 0, + "device_name": "cpu", + "compute_types": ["auto", "float32", "int8", ...] + }, + { + "device": "cuda", + "device_index": 0, + "device_name": "NVIDIA GeForce RTX 3090", + "compute_types": ["auto", "int8_bfloat16", "int8_float16", ...] + }, + ... +] +``` + +**処理フロー:** +1. CPU デバイスを常に追加(最低限の計算環境を保証) +2. PyTorch と CUDA が利用可能な場合: + - 全GPUデバイスを列挙 + - 各GPUの計算タイプを `get_supported_compute_types()` で取得 + - GPU アーキテクチャに応じて計算タイプを制限: + - **GTX シリーズ**: `int8_bfloat16`, `bfloat16`, `float16`, `int8` を除外 + - **RTX, Tesla, A100, Quadro**: 全計算タイプをサポート + - **その他**: `float32` のみ + +**GPU別の計算タイプ制限:** +```python +if "GTX" in gpu_device_name: + unsupported_types = {"int8_bfloat16", "bfloat16", "float16", "int8"} + gpu_compute_types = [t for t in gpu_compute_types if t not in unsupported_types] +elif not any(keyword in gpu_device_name for keyword in ["RTX", "Tesla", "A100", "Quadro"]): + gpu_compute_types = ["float32"] +``` + +**使用例:** +```python +devices = getComputeDeviceList() +for device in devices: + print(f"{device['device_name']}: {', '.join(device['compute_types'])}") + +# 出力例: +# cpu: auto, float32, int8 +# NVIDIA GeForce RTX 3090: auto, int8_bfloat16, int8_float16, int8, bfloat16, float16, int8_float32, float32 +``` + +**エラーハンドリング:** +- GPU検出中の例外は `errorLogging()` でログ記録し、CPU デバイスのみ返却 + +--- + +#### `getBestComputeType(device: str, device_index: int) -> str` + +**責務:** デバイスアーキテクチャに最適な計算タイプを自動選択 + +**優先順位:** +```python +preferred_types = { + "default": [ + "int8_bfloat16", # 最も効率的(対応GPUのみ) + "int8_float16", # 2番目に効率的 + "int8", # 整数演算高速化 + "bfloat16", # 混合精度 + "float16", # 半精度浮動小数点 + "int8_float32", # 互換性重視 + "float32" # フォールバック + ], + "GTX": ["float32"], # GTXシリーズは制限あり + "RTX": ["int8_bfloat16", "int8_float16", ...], + "Tesla": [...], + "A100": [...], + "Quadro": [...] +} +``` + +**処理フロー:** +1. `get_supported_compute_types()` で利用可能な計算タイプを取得 +2. デバイス名に基づいて優先リストを選択 +3. 優先順に計算タイプをチェックし、最初に利用可能なものを返却 +4. 全て利用不可の場合は `"float32"` を返却(安全なフォールバック) + +**引数:** +- `device` (str): "cpu" または "cuda" +- `device_index` (int): GPUデバイスのインデックス(CPUの場合は0) + +**返り値:** +- 最適な計算タイプ文字列(例: "int8_bfloat16", "float32") + +**使用例:** +```python +best_type = getBestComputeType("cuda", 0) +model.load_model(compute_type=best_type) +``` + +**計算タイプの特性:** + +| 計算タイプ | メモリ使用量 | 速度 | 精度 | 対応GPU | +|----------|------------|------|------|--------| +| int8_bfloat16 | 最小 | 最速 | 高 | RTX 30xx以降 | +| int8_float16 | 最小 | 最速 | 高 | RTX 20xx以降 | +| int8 | 小 | 高速 | 中 | 多くのGPU | +| bfloat16 | 中 | 高速 | 高 | RTX 30xx以降 | +| float16 | 中 | 高速 | 高 | RTX 20xx以降 | +| float32 | 大 | 標準 | 最高 | 全GPU/CPU | + +--- + +### 4. エンコーディング + +#### `encodeBase64(data: str) -> Dict[str, Any]` + +**責務:** Base64エンコードされたJSON文字列をデコードしてパース + +**処理:** +1. Base64デコード +2. UTF-8文字列に変換 +3. JSON パース +4. 失敗時は空の辞書を返却 + +**引数:** +- `data` (str): Base64エンコードされたJSON文字列 + +**返り値:** +- パース成功: JSON オブジェクト +- パース失敗: `{}`(空の辞書) + +**使用例:** +```python +# エンコード例(参考) +import base64 +import json +payload = {"message": "Hello", "id": 123} +encoded = base64.b64encode(json.dumps(payload).encode('utf-8')).decode('utf-8') + +# デコード +decoded = encodeBase64(encoded) +assert decoded == {"message": "Hello", "id": 123} +``` + +**エラーハンドリング:** +- 不正なBase64文字列 +- 不正なJSON形式 +- 文字エンコーディングエラー + +全て `errorLogging()` でログ記録し、空の辞書を返却。 + +**注意事項:** +- 関数名が `encodeBase64` だが、実際には**デコード**を行う(命名の歴史的経緯) +- セキュリティ: Base64は暗号化ではないため、機密情報の保護には使用しない + +--- + +### 5. ロギング + +#### `removeLog() -> None` + +**責務:** プロセスログファイル(process.log)を初期化 + +**処理:** +- `process.log` を空の内容で上書き +- ファイルが存在しない場合は新規作成 + +**使用例:** +```python +# アプリケーション起動時にログをクリア +removeLog() +printLog("Application started") +``` + +**エラーハンドリング:** +- ファイル書き込み失敗時は `errorLogging()` でエラーログに記録 + +--- + +#### `setupLogger(name: str, log_file: str, level: int = logging.INFO) -> logging.Logger` + +**責務:** ローテーション機能付きロガーインスタンスを生成 + +**設定:** +- **最大ログサイズ**: 10MB +- **バックアップ数**: 1(最大2ファイル) +- **ローテーション動作**: 10MB到達時に `.1` バックアップを作成し、新規ログを開始 +- **エンコーディング**: UTF-8 +- **遅延書き込み**: `delay=True`(最初の書き込み時にファイルを開く) + +**引数:** +- `name` (str): ロガー名(例: "process", "error") +- `log_file` (str): ログファイルパス +- `level` (int): ログレベル(デフォルト: `logging.INFO`) + +**返り値:** +- 設定済み `logging.Logger` インスタンス + +**ログフォーマット:** +``` +%(asctime)s - %(name)s - %(levelname)s - %(message)s +``` + +**出力例:** +``` +2025-10-13 14:30:45,123 - process - INFO - Application started +2025-10-13 14:30:46,456 - error - ERROR - Connection failed +``` + +**重複ハンドラー防止:** +```python +if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == getattr(file_handler, 'baseFilename', None) for h in logger.handlers): + logger.addHandler(file_handler) +``` + +同じファイルへの重複ハンドラー追加を防止し、複数回呼び出されても安全。 + +--- + +#### `printLog(log: str, data: Any = None) -> None` + +**責務:** 構造化プロセスログの出力 + +**出力先:** +1. `process.log` ファイル +2. 標準出力(JSON形式) + +**出力形式:** +```python +{ + "status": 348, # プロセスログ専用ステータス + "log": "User action performed", + "data": "additional context" +} +``` + +**引数:** +- `log` (str): ログメッセージ +- `data` (Any): 追加のコンテキスト情報(オプション) + +**使用例:** +```python +printLog("Model loading started", {"model_type": "whisper", "weight": "medium"}) +# 出力(stdout): +# {"status": 348, "log": "Model loading started", "data": "{'model_type': 'whisper', 'weight': 'medium'}"} +``` + +**実装の詳細:** +```python +global process_logger +if process_logger is None: + process_logger = setupLogger("process", "process.log", logging.INFO) + +response = { + "status": 348, + "log": log, + "data": str(data), +} +process_logger.info(response) +serialized = json.dumps(response) +print(serialized, flush=True) +``` + +**注意事項:** +- `data` は `str()` で文字列化されるため、複雑なオブジェクトは読みにくくなる可能性がある +- `flush=True` により即座に出力(バッファリングを無効化) + +--- + +#### `printResponse(status: int, endpoint: str, result: Any = None) -> None` + +**責務:** 構造化APIレスポンスの出力 + +**出力先:** +1. `process.log` ファイル +2. 標準出力(JSON形式) + +**出力形式:** +```python +{ + "status": 200, + "endpoint": "/get/config/version", + "result": {"version": "3.3.0"} +} +``` + +**引数:** +- `status` (int): HTTPステータスコード風のステータス番号 +- `endpoint` (str): エンドポイント識別子 +- `result` (Any): レスポンスペイロード(オプション) + +**使用例:** +```python +printResponse(200, "/set/config/language", {"language": "ja"}) +printResponse(400, "/set/config/threshold", {"error": "Value out of range"}) +``` + +**JSONシリアライズエラーハンドリング:** +```python +try: + serialized_response = json.dumps(response) +except Exception as e: + errorLogging() # 完全なトレースバックをログ + process_logger.error(f"Problematic response object: {response}") + process_logger.error(f"Exception during json.dumps: {e}") + # フォールバックエラーペイロード + error_json = json.dumps({ + "status": 500, + "endpoint": endpoint, + "result": {"error": "Failed to serialize response", "details": str(e)}, + }) + print(error_json, flush=True) +else: + print(serialized_response, flush=True) +``` + +**シリアライズ不可能なオブジェクトの例:** +- `datetime` オブジェクト +- カスタムクラスインスタンス +- 循環参照を持つ辞書 + +**対策:** +- `result` を構築する際に JSON シリアライズ可能な型のみ使用 +- 必要に応じて `str()` や専用のシリアライザーで変換 + +--- + +#### `errorLogging() -> None` + +**責務:** 現在の例外トレースバックをエラーログに記録 + +**処理:** +1. `error.log` ファイルにトレースバックを出力 +2. ロガー初期化失敗時は標準出力にフォールバック + +**使用例:** +```python +try: + risky_operation() +except Exception: + errorLogging() # トレースバックをerror.logに記録 + # 必要に応じて追加処理 +``` + +**出力例(error.log):** +``` +2025-10-13 14:35:12,789 - error - ERROR - Traceback (most recent call last): + File "model.py", line 123, in loadModel + model.load() + File "ctranslate2/model.py", line 456, in load + raise RuntimeError("CUDA out of memory") +RuntimeError: CUDA out of memory +``` + +**注意事項:** +- **例外コンテキスト内でのみ呼び出し可能**(`traceback.format_exc()` を使用) +- 例外をキャッチせずに呼び出すと空のトレースバックが記録される + +**ベストプラクティス:** +```python +try: + dangerous_function() +except SpecificException as e: + errorLogging() # 詳細をログ + # ユーザーフレンドリーなエラー処理 + printResponse(400, endpoint, {"error": "Operation failed"}) +except Exception: + errorLogging() # 予期しないエラーもログ + raise # 上位へ伝播 +``` + +--- + +## グローバル変数 + +### `process_logger: Optional[logging.Logger] = None` +プロセスログ用のグローバルロガーインスタンス。初回 `printLog()` または `printResponse()` 呼び出し時に初期化される。 + +### `error_logger: Optional[logging.Logger] = None` +エラーログ用のグローバルロガーインスタンス。初回 `errorLogging()` 呼び出し時に初期化される。 + +**遅延初期化の理由:** +- モジュールインポート時のオーバーヘッド削減 +- ファイルシステムへの不要なアクセスを回避 + +--- + +## エラーハンドリング戦略 + +### 1. 防御的プログラミング +全てのユーティリティ関数は例外を内部で処理し、呼び出し元に例外を伝播しない: + +```python +def isConnectedNetwork(url="http://www.google.com", timeout=3) -> bool: + try: + response = requests.get(url, timeout=timeout) + return response.status_code == 200 + except requests.RequestException: + return False # 例外をキャッチして安全な値を返却 +``` + +### 2. フォールバック値 +- `encodeBase64()`: パース失敗時は `{}` +- `getComputeDeviceList()`: GPU検出失敗時はCPUのみ +- `getBestComputeType()`: 全て失敗時は `"float32"` + +### 3. ログ記録 +全てのエラーは `errorLogging()` でトレースバックを記録し、デバッグを容易にする。 + +--- + +## パフォーマンス考慮事項 + +### 1. ネットワーク接続チェック +`isConnectedNetwork()` はブロッキング操作(最大3秒)のため、起動時の1回のみ実行を推奨: + +```python +# 良い例 +if isConnectedNetwork(): + downloadModels() + +# 悪い例(UI フリーズの原因) +while True: + if isConnectedNetwork(): # 毎回3秒待機 + processData() +``` + +### 2. ログローテーション +10MB のログファイルローテーションにより、ディスク容量を制御(最大20MB)。 + +### 3. グローバルロガーの遅延初期化 +ロガーは初回使用時に初期化されるため、インポート時のオーバーヘッドを最小化。 + +--- + +## 使用パターン + +### パターン1: ネットワーク依存機能の初期化 +```python +def initialize_online_features(): + if not isConnectedNetwork(): + printLog("Offline mode: skipping model download") + return + + printLog("Online mode: downloading models") + downloadModels() +``` + +### パターン2: デバイス自動選択 +```python +devices = getComputeDeviceList() +if len(devices) > 1: + # GPU利用可能 + best_device = devices[1] # 最初のGPU + best_type = getBestComputeType(best_device["device"], best_device["device_index"]) + printLog(f"Using GPU: {best_device['device_name']}", {"compute_type": best_type}) +else: + # CPUのみ + printLog("No GPU detected, using CPU") + best_type = "float32" +``` + +### パターン3: 構造化リクエスト検証 +```python +def handle_request(payload): + expected_structure = { + "action": str, + "data": { + "id": int, + "value": str + } + } + + if not validateDictStructure(payload, expected_structure): + printResponse(400, "/handle_request", {"error": "Invalid request structure"}) + return + + # 処理続行 + printLog("Valid request received", payload) +``` + +### パターン4: WebSocketサーバー起動 +```python +def start_websocket(host, port): + if not isValidIpAddress(host): + printResponse(400, "/websocket/start", {"error": "Invalid IP address"}) + return + + if not isAvailableWebSocketServer(host, port): + printResponse(400, "/websocket/start", {"error": f"Port {port} is in use"}) + return + + # サーバー起動 + printLog(f"Starting WebSocket server", {"host": host, "port": port}) + startServer(host, port) +``` + +--- + +## テスト推奨事項 + +### 単体テスト例 + +**辞書構造検証:** +```python +def test_validate_dict_structure_simple(): + data = {"name": "Alice", "age": 30} + structure = {"name": str, "age": int} + assert validateDictStructure(data, structure) is True + +def test_validate_dict_structure_nested(): + data = {"user": {"id": 1, "active": True}} + structure = {"user": {"id": int, "active": bool}} + assert validateDictStructure(data, structure) is True + +def test_validate_dict_structure_invalid(): + data = {"name": "Alice"} + structure = {"name": str, "age": int} # 'age'キーが不足 + assert validateDictStructure(data, structure) is False +``` + +**ネットワーク診断:** +```python +def test_network_connection(): + # 実際のネットワーク接続をテスト + result = isConnectedNetwork() + assert isinstance(result, bool) + +def test_network_timeout(): + # タイムアウト動作を確認 + result = isConnectedNetwork(url="http://192.0.2.1", timeout=1) + assert result is False +``` + +**計算デバイス:** +```python +def test_get_compute_device_list(): + devices = getComputeDeviceList() + assert len(devices) >= 1 # 最低限CPUが含まれる + assert devices[0]["device"] == "cpu" + +def test_get_best_compute_type(): + compute_type = getBestComputeType("cpu", 0) + assert compute_type in ["float32", "int8"] +``` + +**ロギング:** +```python +def test_print_log(capsys): + printLog("Test message", {"key": "value"}) + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["status"] == 348 + assert output["log"] == "Test message" + +def test_print_response(capsys): + printResponse(200, "/test", {"result": "success"}) + captured = capsys.readouterr() + output = json.loads(captured.out) + assert output["status"] == 200 + assert output["endpoint"] == "/test" +``` + +--- + +## セキュリティ考慮事項 + +### 1. IPアドレス検証 +`isValidIpAddress()` はフォーマット検証のみで、プライベートアドレス範囲のチェックは行わない: + +```python +# セキュリティを強化する場合 +import ipaddress + +def is_public_ip(ip_str): + if not isValidIpAddress(ip_str): + return False + ip = ipaddress.ip_address(ip_str) + return not (ip.is_private or ip.is_loopback or ip.is_reserved) +``` + +### 2. Base64デコード +`encodeBase64()` は入力検証を行わないため、信頼できないソースからのデータには注意: + +```python +# 安全な使用例 +if source_is_trusted: + data = encodeBase64(base64_string) +else: + # 追加の検証を実施 + pass +``` + +### 3. ログファイルへの機密情報記録 +ログに機密情報(API キー、パスワード等)が含まれないよう注意: + +```python +# 悪い例 +printLog("API key loaded", api_key) + +# 良い例 +printLog("API key loaded", "***REDACTED***") +``` + +--- + +## 制限事項 + +1. **プラットフォーム依存性:** + - GPU検出は CUDA 環境でのみ動作(ROCm/Metal非対応) + +2. **ネットワークチェックの制限:** + - ファイアウォール、プロキシ環境で誤判定の可能性 + - IPv6専用環境での動作は未検証 + +3. **ログファイルのスレッドセーフティ:** + - `RotatingFileHandler` は基本的にスレッドセーフだが、高負荷時のローテーション中にログ損失の可能性 + +4. **計算タイプの最適化:** + - `getBestComputeType()` の優先順位は一般的な推奨値であり、特定のモデルやタスクでは最適でない場合がある + +--- + +## 依存モジュールとの関係 + +### controller.py +- デバイス管理の設定変更時にデバイスリスト取得 +- エラー時のログ記録 +- ネットワーク接続確認 + +### model.py +- 計算デバイスとタイプの決定 +- エラー時のトレースバック記録 + +### config.py +- 起動時のネットワーク接続確認 +- 計算デバイスリストの提供 + +### mainloop.py +- リクエスト/レスポンスの構造化ログ出力 +- エラー時のトレースバック記録 + +--- + +## 今後の拡張性 + +### 1. 非同期ネットワークチェック +```python +import asyncio +import aiohttp + +async def isConnectedNetworkAsync(url="http://www.google.com", timeout=3) -> bool: + try: + async with aiohttp.ClientSession() as session: + async with session.get(url, timeout=aiohttp.ClientTimeout(total=timeout)) as response: + return response.status == 200 + except Exception: + return False +``` + +### 2. 構造化ログの拡張 +```python +def printStructuredLog(level: str, message: str, context: dict = None): + """ + より詳細な構造化ログ出力 + - timestamp + - level + - message + - context (key-value pairs) + - stack trace (error時) + """ + pass +``` + +### 3. メトリクス収集 +```python +def recordMetric(metric_name: str, value: float, tags: dict = None): + """ + パフォーマンスメトリクスの記録 + - function execution time + - memory usage + - GPU utilization + """ + pass +``` + +--- + +## 関連ドキュメント +- `controller.md`: Controller での utils 関数使用例 +- `config.md`: Config での計算デバイス管理 +- `model.md`: Model でのエラーハンドリング +- `コーディングルール.md`: ロギングとエラーハンドリングの規約 + +--- + +## ライセンス +プロジェクトのルートディレクトリの `LICENSE` ファイルを参照 + +--- + +## まとめ + +`utils.py` は VRCT プロジェクトの基盤インフラストラクチャとして、以下の重要な責務を担う: + +1. **安全性**: 全ての関数が例外を内部処理し、安全なフォールバック値を提供 +2. **可観測性**: 構造化ログとローテーション機能により、問題の診断を容易化 +3. **互換性**: オプション依存のセーフガードにより、様々な環境で動作 +4. **最適化**: GPU アーキテクチャに応じた計算タイプの自動選択 +5. **検証**: 辞書構造、IPアドレス、ネットワーク接続の厳密なバリデーション + +全てのサブシステムから依存される中核モジュールとして、高い信頼性と保守性を維持している。 diff --git a/src-python/docs/コーディングルール.md b/src-python/docs/コーディングルール.md new file mode 100644 index 00000000..4d6d2dd2 --- /dev/null +++ b/src-python/docs/コーディングルール.md @@ -0,0 +1,169 @@ +# VRCT backend — コーディングルール + +目的: +- 可読性と保守性を保ちながら既存スタイルを尊重する。 +- 漸進的に型注釈を導入し、mypy と ruff のチェックに合わせる。 +- 自動化(CI / pre-commit)へ導出しやすくする。 + +注意: 既存の命名・構造(関数名・クラス名・変数名・run mapping のキー等)はコード上の互換性のためそのまま維持します。以下は新規実装やリファクタ時に従うべきルールです。 + +## 目次 +- 命名規則 +- モジュール・パッケージ構成 +- インポート +- 型注釈と mypy 方針 +- ドキュメンテーション / docstrings +- エラーハンドリングとロギング +- 非同期 / スレッド / キューの扱い +- テストと CI +- リファクタ・互換性の観点 + +--- + +## 命名規則 +- モジュール名: 小文字、アンダースコアで区切る(例: `overlay_utils.py`)。既存ファイルに従う。 +- パッケージ名: 小文字(`models`, `websocket` など)。 +- クラス名: CapWords (PascalCase)。既存クラス(`Controller`, `Model`, `Overlay`)に従う。 +- 関数・メソッド名: snake_case。 +- 変数名: snake_case。短い一時変数は `i`, `j`, `buf` 等の伝統的な省略形を可とするが、意味ある名前を優先する。 +- 定数: UPPER_SNAKE_CASE(`config.py` の定数に合わせる)。 +- run_mapping のキー: 現在は短い key(例: `transcription_mic`)を内部で使い `run_mapping` に `/run/...` を置いている。この慣習は維持する。Controller 内で `self.run_mapping[...]` を直接参照する実装は許容される。 + +## モジュール・パッケージ構成 +- 各サブ領域(ocr, overlay, transcription, translation, websocket 等)は `models/` 下に整理済みのため、同様の粒度で新機能は追加する。 +- パッケージは必ず `__init__.py` を置く(static analysis / mypy のため)。空の `__init__.py` でも可。これにより相対インポートが安定する。 + +## インポート +- 標準ライブラリ、サードパーティ、ローカルの順でインポートをまとめる。 +- ローカルモジュールを参照する場合は相対 import を使ってもよいが、プロジェクト全体を PYTHONPATH に入れてテスト/静的解析できるようにすること。 +- 例: +``` +import os +import json + +import numpy as np + +from . import overlay_utils +``` + +## 型注釈と mypy 方針 +- 戦略: Relax + incremental annotations(漸進的型付け)。以下を守る。 + - 新規コードは可能な限り型注釈を追加する(関数シグネチャ・返り値)。 + - 既存の大きな関数は段階的に注釈する。まずモジュール境界(public API)のシグネチャに注釈を入れる。内部の細かい変数は後回し。 + - CI では初期段階で mypy を `--ignore-missing-imports --allow-untyped-defs --allow-redefinition` のように緩めて実行する。段階的に `--check-untyped-defs` を有効化していく。 + - 型の `Any` を多用しない。どうしても必要な場合は `# type: ignore[assignment]` を付けて理由をコメントに残す。 + +## docstrings / コメント +- 重要な public 関数・メソッドとクラスに短い docstring を追加する(目的・引数・返り値の要約)。Google/Numpy スタイルのどちらかに統一する必要はないが、プロジェクト内で混乱しないよう短く統一すること。 +- 実装トリッキーな箇所には `# NOTE:` や `# FIXME:` コメントを残し、必要なら issue を紐付ける(例: `# NOTE: keep in sync with mainloop.run_mapping`)。 + +## エラーハンドリングとロギング +- 例外をキャッチするときは有用なコンテキストをログに残す(`errorLogging()` のようなユーティリティを使う)。 +- broad except: を使う場合は最低限 `errorLogging()` を呼び、必要なら `raise` して上位へ伝播する。 + +## 非同期 / スレッド / キューの扱い +- スレッドは `threading.Thread` を使っている箇所があるため、スレッド間通信は `queue.Queue` ベースで実装すること。 +- スレッドを生成する関数は `start_` プレフィックス(例: `start_transcription_thread`)のように命名すると分かりやすい。 + +## テスト・CI +- まずは軽量な CI ワークフローを入れる: + - ruff check + - mypy (relaxed) + - 自動テスト(将来的に pytest を追加) +- pre-commit フックの導入を推奨: ruff auto-fix と isort (import 整理) を採用できる。 + +## リファクタ・互換性 +- 既存 public API(stdin/stdout の endpoint 仕様や `run_mapping` のキー、Controller のメソッド名)は後方互換を優先する。変更が必要な場合は CHANGELOG に明示する。 + +## 小さなコーディング規約チェックリスト(PR テンプレ) +- 新しい public メソッドには docstring を付けたか +- 既存命名規則に従っているか(snake_case / PascalCase / UPPER_SNAKE_CASE) +- 型アノテーションをシグネチャに追加したか(可能な限り) +- 直接 stdout に JSON を print する箇所は `printResponse` 等ユーティリティ経由か確認する + +--- + +このドキュメントは現状のスタイルを尊重して最小限の規則を与えることを目的としています。次のステップを希望する場合: +- CI に ruff/mypy を組み込む PR を作成 +- pre-commit 用の設定ファイル(`.pre-commit-config.yaml`)を追加して自動整形を導入 +- 型注釈・テストのためのタスク分割(優先順位をつけた TODO) + +要望があれば、これをベースに `.pre-commit-config.yaml`、`pyproject.toml` の ruff 設定、あるいは CI ワークフローの雛形(GitHub Actions)を作成します。 + +## Copilot と共同作業するための具体例とテンプレート +以下は Copilot に推奨プロンプトを投げやすく、また PR 作成時に便利なテンプレート類です。コピー&ペーストして使用してください。 + +### 関数テンプレート(型注釈 + docstring) +```python +from typing import Any, Dict, Optional + +def example_handler(endpoint: str, data: Any) -> Dict[str, Any]: + """Handle an example endpoint. + + Args: + endpoint: incoming endpoint string (e.g. '/get/data/version') + data: request payload (None for many GETs) + + Returns: + A dict suitable for printResponse(status, endpoint, result) + """ + # implementation... + result = {"status": 200, "endpoint": endpoint, "result": data} + return result +``` + +### Controller の run 発行パターン(推奨) +Controller 内で run を呼ぶときは `self.run(self.run_mapping["key"], payload)` の形を維持してください。Copilot に尋ねるときは「この run key に対応する payload の形は?」と聞くとペイロード例を生成しやすいです。 + +### Docstring 例(Google スタイル) +```python +def set_selected_tab_no(tab_no: int) -> Dict[str, Any]: + """Set the current tab. + + Args: + tab_no: index of tab to select + + Returns: + A response dict with status and new tab number + """ + ... +``` + +### PR チェックリスト(拡張版) +- コーディング規則に従っているか +- 新しい public API の docstring があるか +- 型注釈を最小限追加しているか(特に関数シグネチャ) +- ruff check が通るか +- mypy(relaxed)で重大な型エラーが出ていないか +- docs (必要箇所) を更新したか(API 変更があれば) + +### 推奨 `.pre-commit-config.yaml`(例) +```yaml +repos: + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.14.0 + hooks: + - id: ruff + args: ["--fix"] + - repo: https://github.com/PyCQA/isort + rev: 5.12.0 + hooks: + - id: isort + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.18.2 + hooks: + - id: mypy + args: ["--ignore-missing-imports", "--allow-untyped-defs", "--allow-redefinition"] +``` + +### 推奨 ruff 設定(pyproject.toml への最小設定例) +```toml +[tool.ruff] +line-length = 88 +extend-ignore = ["E203"] +select = ["E", "F", "W", "C90"] +``` + +--- + +更新が必要なら私が `.pre-commit-config.yaml`、`pyproject.toml`、および CI ワークフロー (GitHub Actions) の雛形を作成してコミットまでできます。どれを優先しますか? diff --git a/src-python/docs/仕様書.md b/src-python/docs/仕様書.md new file mode 100644 index 00000000..1ccd8ea2 --- /dev/null +++ b/src-python/docs/仕様書.md @@ -0,0 +1,58 @@ +# 仕様書 + +概要 +- プロジェクト名: VRCT (VR Chat Translator) +- 目的: マイク入力とスピーカー出力をリアルタイムに文字起こし・翻訳し、VR オーバーレイや OSC/WebSocket 経由で外部に送出するバックエンドロジック。 +- 言語: Python + +対象ユーザー +- VR 環境でリアルタイム翻訳・文字起こしを利用したいエンドユーザー +- フロントエンド(GUI)や VR クライアント(OSC)と連携するアプリケーション開発者 + +主要機能(機能要件) +1. 音声の取り込み・文字起こし + - マイク(送信)およびスピーカー(受信)から音声を取得し、ローカル Whisper(faster-whisper)または外部サービスによりテキスト化する。 + - 音声エネルギー(音量)監視を行い、閾値ベースで検出する。 + +2. 翻訳 + - DeepL / DeepL API / 各クラウド翻訳 / ローカル CTranslate2 モデルの複数バックエンドをサポート。 + - 複数出力言語への一括翻訳、翻訳エンジンのフォールバック(CTranslate2 など)。 + - 翻訳モデルのダウンロードと管理機能。 + +3. 表示・通知 + - OpenVR オーバーレイ(small/large)用の画像生成と更新。 + - OSC による VR へのメッセージ送信(typing/通知等)。 + - WebSocket サーバーを介した外部クライアントへの JSON ブロードキャスト。 + +4. 入出力インターフェース + - stdin ラインベースの JSON コマンド受信(mainloop が実装)。 + - stdout に対して構造化された JSON レスポンスを出力(printResponse/printLog)。 + +5. 設定・永続化 + - JSON ベースの設定ファイルを使用(`config.py` による読み書きとデバウンス保存)。 + +6. ロギングと監視 + - プロセスログ(process.log)とエラーログ(error.log)をローテーションで管理。 + - ウォッチドッグ機構で定期的に死活チェック・コールバック。 + +非機能要件 +- プラットフォーム: 主に Windows(Audio 周りは WASAPI を利用)を想定。クロスプラットフォームでの import 安全性を考慮。 +- 可用性: 外部依存(PyAudio, CUDA, ctranslate2 等)が無い環境でも安全にインポートでき、機能劣化しつつ動作する。 +- パフォーマンス: ローカルモデル利用時は GPU を利用して計算性能を確保。compute type 選択ロジックを実装。 +- セキュリティ: 外部への API キー(DeepL など)は設定で扱い、コード上では平文保持を避ける(設定ファイルに保存)。 + +運用フロー +- 起動: stdin でコマンドを受け付ける mainloop を実行。必要な初期化は遅延実行(lazy init)を採用。 +- モデル重ダウンロード: CTranslate2/Whisper 重みは `weights/` 配下にダウンロードし、チェックサム等で整合性確認。 +- 障害時: 例外は utils.errorLogging() でトレースを error.log に出力。重要機能はフォールバック実装。 + +インターフェース(抜粋) +- stdin(JSON): {"endpoint": "/set/..." | "/get/..." | "/run/...", "data": } +- stdout(JSON): 標準化されたレスポンスを printResponse/printLog が出力(status, endpoint, result など)。 + +依存関係(オプション含む) +- 必須(実装時想定): requests, packaging, flashtext, pillow, pyaudiowpatch, speech_recognition +- ローカル推奨: faster-whisper, ctranslate2, torch(GPU 利用時) +- Windows 固有(音声ループバック): pycaw, comtypes + +参考: 実装上の安全設計として optional な import は try/except でガードしており、存在しない依存があっても import 時にクラッシュしない。 diff --git a/src-python/docs/設計書.md b/src-python/docs/設計書.md new file mode 100644 index 00000000..2b37c101 --- /dev/null +++ b/src-python/docs/設計書.md @@ -0,0 +1,57 @@ +# 設計書 + +概要 +- 本設計書はアプリケーションのアーキテクチャ、主要コンポーネント、並列化モデル、エラー処理ポリシー、設定の保存方針を記述する。 + +アーキテクチャ概要 +- 層構造 + - mainloop: stdin ベースのコマンド受け取り、ワーカー(複数スレッド)で実行。 + - controller: GUI/フロントエンドからの操作とモデルの仲介。`Controller` がビジネスロジックを実行。 + - model: 実際の機能(翻訳、文字起こし、オーバーレイ、OSC、WebSocket、デバイス管理)を提供するファサード的シングルトン。 + - models/*: 翻訳、文字起こしなどのドメイン別実装(Translator, AudioTranscriber, Overlay, WebSocketServer ...)。 + - device_manager: 音声デバイス検出・監視(Windows の場合は WASAPI/pycaw を利用)。 + - utils: 共通ユーティリティ(ロギング、ネットワークチェック、compute device 列挙など)。 + +初期化ポリシー +- 重い初期化(GPU モデルロード、OpenVR 初期化など)は import 時に行わず、`model.init()` か要求時の `ensure_initialized()` にて遅延実行。 +- `DeviceManager` は import 時に軽量な init を行い、監視スレッドは `startMonitoring()` で開始する。 + +並列化・同期モデル +- mainloop.Main は 1 つの受信スレッド(stdin 읽取り)と N 個のハンドラワーカースレッドを持つ。 +- 各リクエストはキューに入れられ、handler() により処理される。 +- 有効/無効の切替(/set/enable/**, /set/disable/**)は同一リソースを競合しないよう正規化キーで Lock を割り当てる。 +- モデル内部では threadFnc(Thread ラッパ)で周期的な送信処理や監視処理を実装。 +- Audio 録音や文字起こしは専用の Queue を用い、Producer(Recorder)と Consumer(AudioTranscriber)を分離。 + +エラー処理 +- すべての外周呼び出しは try/except で保護し、`utils.errorLogging()` によってトレースバックを error.log に出力する。 +- JSON シリアライズに失敗した場合はフォールバック JSON を stdout に出力してプロセスを止めない。 +- VRAM 関連のエラーは model.detectVRAMError() で判定し、該当する機能(翻訳等)を無効化してユーザーに通知する。 + +設定管理 +- `config.py` が単一の Config シングルトンを持ち、変更はデバウンスして JSON ファイルへ保存。 +- GUI からの操作は Controller が受け取り、Config を更新する。 + +ログ +- `utils.setupLogger` によりローテートファイルハンドラを使ったログを実装(process.log / error.log)。 +- stdout には構造化ログを出力してフロントエンドと通信する。 + +インターフェース一覧(抜粋) +- STDIN/STDOUT プロトコル: mainloop の JSON 入出力(詳細は `mainloop.py` の mapping を参照) +- OSC: `models.osc.OSCHandler` が OSC 送受信と OSCQuery を管理 +- WebSocket: `models.websocket.WebSocketServer` がクライアント管理とメッセージブロードキャストを担う + +スレッド図(要点) +- main_thread: メイン(stdin 読み取り、キュー投入) +- handler_threads: キューから取り出し処理 +- device_manager.th_monitoring: デバイス監視 +- model.mic_print_transcript / speaker_print_transcript: 音声 -> 翻訳結果送出のループ +- websocket_server_thread: WebSocket サーバの asyncio ループを別スレッドで実行 + +拡張性・互換性設計 +- 依存性は try/except でガードして optional 機能として扱う(例: faster-whisper が無くても import は成功する)。 +- 翻訳エンジンは backend 名で抽象化され、Translator クラスにより統一インターフェースを提供。 + +運用上の考慮 +- 大きなモデルファイル(Whisper, CTranslate2)をダウンロードする仕組みを持ち、進捗を GUI に報告する。 +- GPU 計算タイプは utils.getBestComputeType で選択し、不適切な設定を検出した場合はフォールバック。 diff --git a/src-python/docs/詳細設計書.md b/src-python/docs/詳細設計書.md new file mode 100644 index 00000000..11b539ee --- /dev/null +++ b/src-python/docs/詳細設計書.md @@ -0,0 +1,66 @@ +# 詳細設計書 + +この文書は主要クラス・関数の詳細、データ構造、例外ケース、スレッドの振る舞いを記載する。 + +目次 +- Model +- Controller +- Main (mainloop) +- DeviceManager +- Utils +- モデルの重みダウンロードと整合性 + +## Model +- シングルトン: `model = Model()` +- 遅延初期化: `init()` と `ensure_initialized()` を備え、init は重いリソース(Overlay, Translator, Watchdog, OSC ハンドラ等)を構築する。 +- 主な責務 + - 翻訳/文字起こし関連の起動停止ラッパ + - Overlay/OSChandler/WebSocket の操作 + - キーワード検出(flashtext)と重複検出 + - VRAM エラー検出とフォールバック +- 重要属性(抜粋) + - `translator` : Translator インスタンス + - `overlay` / `overlay_image` : Overlay 系 + - `mic_*`, `speaker_*` : 録音、トランスクリプタ、energy recorder + - `watchdog` : Watchdog + - `osc_handler`, `websocket_server` +- スレッド制御 + - threadFnc を用いて周期処理を回す。stop/pause/resume が可能。 + +## Controller +- GUI からの要求を受け、Model を操作して結果を run() コールバックへ返す。 +- 各種設定変更 (/set/ や /get/ エンドポイント) を実装。 +- 翻訳/文字起こし/オーバーレイ連携ロジックを持ち、メッセージ整形(messageFormatter)や OSC の送信を行う。 +- ダウンロード作業は別スレッドで行い、進捗を run_mapping を通して通知。 + +## Main (mainloop.Main) +- stdin を readline() で受け取り JSON を parse、endpoint と data をキューへ投入。 +- worker_count 個の handler スレッドが queue を取り出し `_call_handler` を実行。 +- endpoint ロック正規化: `/set/enable/...` と `/set/disable/...` は同じ正規化キー `/lock/set/...` を共有して排他制御。 +- エラーレスポンスの標準化と再試行ロジック(status==423 は再キュー化)。 + +## DeviceManager +- シングルトン。初期化は軽量で、`init()` により内部構造をセット、実デバイスは `update()` で取得。 +- Windows 環境では COM イベント (pycaw/MMNotificationClient) を用いた検出か、PyAudio によるポーリングでデバイス一覧を構成。 +- コールバック設計: 変更検出時に Controller のコールバックを呼び出して UI 更新を促す。 + +## Utils +- `validateDictStructure(data, structure)` : JSON 構造検証。 +- `getComputeDeviceList()` / `getBestComputeType()` : CPU/CUDA を列挙し、推奨 compute_type を返す。 +- `setupLogger()` / `printLog()` / `printResponse()` / `errorLogging()` : ログ、標準出力の整形、エラー記録。 +- ネットワーク/ソケット/IP アドレス検査ユーティリティ。 + +## モデル重みダウンロード +- `models.translation.translation_utils` と `models.transcription.transcription_whisper` にダウンロード/チェック関数があり、チェックサムやファイル存在を検証する。 +- GUI からの要求は Controller により非同期スレッドで実行され、進捗コールバックが run_mapping を介してフロントエンドに渡る。 + +## エッジケース / 例外処理 +- 外部 API のレート制限や認証エラーは呼び出し元に 400 系のレスポンスを返し、必要であればフォールバック実装(CTranslate2 への切替)を行う。 +- 大きなモデル実行時の VRAM エラーは検出し、当該機能を無効化してユーザへ通知する。 +- 音声デバイスが存在しない場合は NoDevice を返し、UI 側で扱う。 + +## テスト観点 +- メッセージ受信/送信のエンドツーエンド: stdin -> handler -> Controller -> Model -> printResponse の流れ。 +- デバイス挙動: DeviceManager.update() がデバイス一覧を取得できるか(PyAudio 経由)。 +- モデルダウンロード: ダウンロード成功・失敗、チェックサム検証。 +- ログ/エラー: errorLogging() による例外トレースが error.log に記録されるか。 diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 7fd9fcc2..13efe44c 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -1,9 +1,9 @@ import sys import json import time -from typing import Any -from threading import Thread -from queue import Queue +from typing import Any, Tuple +from threading import Thread, Event, Lock +from queue import Queue, Empty import logging from controller import Controller # noqa: E402 from utils import printLog, printResponse, errorLogging, encodeBase64 # noqa: E402 @@ -357,31 +357,72 @@ mapping = { init_mapping = {key:value for key, value in mapping.items() if key.startswith("/get/data/")} controller.setInitMapping(init_mapping) +DEFAULT_WORKER_COUNT = 3 # 必要なら増やす + class Main: - def __init__(self, controller_instance, mapping_data) -> None: - self.queue = Queue() - self.main_loop = True + def __init__(self, controller_instance: Controller, mapping_data: dict, worker_count: int = DEFAULT_WORKER_COUNT) -> None: + self.queue: Queue[Tuple[str, Any]] = Queue() + self._stop_event: Event = Event() self.controller = controller_instance self.mapping = mapping_data + self._threads: list[Thread] = [] + self._worker_count = worker_count + + # エンドポイントごとの排他制御用 Lock を作成 + # enable/disable ペアは同じロックキーに正規化する + def _canonical_lock_key(endpoint: str) -> str: + if not isinstance(endpoint, str): + return str(endpoint) + if endpoint.startswith("/set/enable/"): + return "/lock/set/" + endpoint[len("/set/enable/"):] + if endpoint.startswith("/set/disable/"): + return "/lock/set/" + endpoint[len("/set/disable/"):] + return endpoint + + # mapping に含まれるすべてのエンドポイントを走査して正規化キー集合を作る + lock_keys = set() + for key in self.mapping.keys(): + lock_keys.add(_canonical_lock_key(key)) + + # 正規化キーごとに Lock を割り当てる + self._endpoint_locks: dict[str, Lock] = {k: Lock() for k in lock_keys} + + # 正規化関数をインスタンスに保存 + self._canonical_lock_key = _canonical_lock_key def receiver(self) -> None: - while True: - received_data = sys.stdin.readline().strip() - received_data = json.loads(received_data) + """Read lines from stdin, parse JSON and enqueue requests. - if received_data: - endpoint = received_data.get("endpoint", None) - data = received_data.get("data", None) - data = encodeBase64(data) if data is not None else None - printLog(endpoint, {"receive_data": data}) - self.queue.put((endpoint, data)) + Uses blocking readline but honors stop via _stop_event checked between reads. + """ + while not self._stop_event.is_set(): + try: + line = sys.stdin.readline() + if not line: + # EOF reached; sleep briefly and re-check stop event + time.sleep(0.1) + continue + received_data = json.loads(line.strip()) + + if received_data: + endpoint = received_data.get("endpoint") + data = received_data.get("data") + data = encodeBase64(data) if data is not None else None + printLog(endpoint, {"receive_data": data}) + self.queue.put((endpoint, data)) + except json.JSONDecodeError: + # malformed input; log and continue + errorLogging() + except Exception: + errorLogging() def startReceiver(self) -> None: - th_receiver = Thread(target=self.receiver) + th_receiver = Thread(target=self.receiver, name="main_receiver") th_receiver.daemon = True th_receiver.start() + self._threads.append(th_receiver) - def handleRequest(self, endpoint, data=None) -> tuple: + def handleRequest(self, endpoint: str, data: Any = None) -> tuple: result = None # デフォルト値を設定 status = 500 # デフォルト値を設定 @@ -395,45 +436,91 @@ class Main: else: try: response = handler["variable"](data) - status = response.get("status", None) - result = response.get("result", None) - time.sleep(0.2) # 処理の安定化のために少し待機 - except Exception as e: + status = response.get("status") + result = response.get("result") + time.sleep(0.2) # 処理の安定化のために少し待機 + except Exception: errorLogging() - result = str(e) + result = "Internal error" status = 500 return result, status - def handler(self) -> None: - while True: - if not self.queue.empty(): - try: - endpoint, data = self.queue.get() - result, status = self.handleRequest(endpoint, data) - except Exception as e: - errorLogging() - result = str(e) - status = 500 + def _call_handler(self, endpoint: str, data: Any = None) -> tuple: + result = None + status = 500 + handler = self.mapping.get(endpoint) + if handler is None: + response = "Invalid endpoint" + status = 404 + else: + try: + response = handler["variable"](data) + status = response.get("status", 500) + result = response.get("result", None) + time.sleep(0.2) + except Exception: + errorLogging() + result = "Internal error" + status = 500 + return result, status - if status == 423: + def handler(self) -> None: + while not self._stop_event.is_set(): + try: + endpoint, data = self.queue.get(timeout=0.5) + except Empty: + continue + + # endpoint をロック用の正規化キーに変換してロックを取得 + lock_key = self._canonical_lock_key(endpoint) + lock = self._endpoint_locks.get(lock_key) + + if lock is not None: + acquired = lock.acquire(blocking=False) + if not acquired: + # 同一機能で既に処理中 -> 少し待って再キュー + time.sleep(0.05) self.queue.put((endpoint, data)) - else: - printLog(endpoint, {"status": status, "send_data": result}) - printResponse(status, endpoint, result) - time.sleep(0.1) + continue + try: + result, status = self._call_handler(endpoint, data) + finally: + lock.release() + else: + result, status = self._call_handler(endpoint, data) + + if status == 423: + time.sleep(0.1) + self.queue.put((endpoint, data)) + else: + printLog(endpoint, {"status": status, "send_data": result}) + printResponse(status, endpoint, result) def startHandler(self) -> None: - th_handler = Thread(target=self.handler) - th_handler.daemon = True - th_handler.start() + for i in range(max(1, self._worker_count)): + th_handler = Thread(target=self.handler, name=f"main_handler_{i}") + th_handler.daemon = True + th_handler.start() + self._threads.append(th_handler) def start(self) -> None: - while self.main_loop: - time.sleep(1) + """Start receiver and handler threads.""" + self.startReceiver() + self.startHandler() - def stop(self) -> None: - self.main_loop = False + def stop(self, wait: float = 2.0) -> None: + """Signal threads to stop and wait for them to finish. + + Args: + wait: maximum seconds to wait for threads to join. + """ + self._stop_event.set() + # give threads a chance to exit + start = time.time() + for th in self._threads: + remaining = max(0.0, wait - (time.time() - start)) + th.join(timeout=remaining) # 外部から参照可能なインスタンスを提供 main_instance = Main(controller_instance=controller, mapping_data=mapping) diff --git a/src-python/model.py b/src-python/model.py index bbf43604..140e45b5 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -10,7 +10,7 @@ from time import sleep from queue import Queue from threading import Thread from requests import get as requests_get -from typing import Callable +from typing import Callable, Optional, cast from packaging.version import parse from flashtext import KeywordProcessor @@ -35,30 +35,47 @@ from models.websocket.websocket_server import WebSocketServer from utils import errorLogging, setupLogger class threadFnc(Thread): - def __init__(self, fnc, end_fnc=None, daemon=True, *args, **kwargs): - super(threadFnc, self).__init__(daemon=daemon, target=fnc, *args, **kwargs) + """A tiny Thread wrapper that repeatedly calls a function. + + Usage: threadFnc(fnc, end_fnc=None, daemon=True, *args, **kwargs) + The target function will be called repeatedly inside run(). + """ + def __init__(self, fnc, end_fnc=None, daemon: bool = True, *args, **kwargs): + # Do not pass target to super; manage call explicitly so we can + # store args/kwargs on the instance for later use. + super(threadFnc, self).__init__(daemon=daemon) self.fnc = fnc self.end_fnc = end_fnc self.loop = True self._pause = False + self._args = args + self._kwargs = kwargs - def stop(self): + def stop(self) -> None: self.loop = False - def pause(self): + def pause(self) -> None: self._pause = True - def resume(self): + def resume(self) -> None: self._pause = False - def run(self): - while self.loop: - self.fnc(*self._args, **self._kwargs) - while self._pause: - sleep(0.1) - - if callable(self.end_fnc): - self.end_fnc() + def run(self) -> None: + try: + while self.loop: + try: + self.fnc(*self._args, **self._kwargs) + except Exception: + # Protect the thread from terminating on user exceptions + errorLogging() + while self._pause: + sleep(0.1) + finally: + if callable(self.end_fnc): + try: + self.end_fnc() + except Exception: + errorLogging() return class Model: @@ -67,10 +84,22 @@ class Model: def __new__(cls): if cls._instance is None: cls._instance = super(Model, cls).__new__(cls) - cls._instance.init() + # Do NOT call init() here to avoid heavy import-time work. + # Callers should call `model.init()` explicitly or rely on + # `ensure_initialized()` which will lazy-initialize on demand. + cls._instance._inited = False return cls._instance def init(self): + """Perform full initialization of resources. + + This method performs heavy construction (models, overlay, threads) + and is intentionally not called at import time. Call explicitly + or let `ensure_initialized()` call it lazily. + """ + if getattr(self, '_inited', False): + return + self.logger = None self.th_check_device = None self.mic_print_transcript = None @@ -106,11 +135,27 @@ class Model: self.websocket_server_loop = False self.websocket_server_alive = False self.th_websocket_server = None + # default no-op callbacks for energy check functions + self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None + self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None + self._inited = True + + def ensure_initialized(self) -> None: + """Ensure the model has been initialized. This is safe to call from + public methods that require initialized resources. + """ + if not getattr(self, '_inited', False): + try: + self.init() + except Exception: + # Log and continue; callers should handle missing features. + errorLogging() def checkTranslatorCTranslate2ModelWeight(self, weight_type:str): return checkCTranslate2Weight(config.PATH_LOCAL, weight_type) def changeTranslatorCTranslate2Model(self): + self.ensure_initialized() self.translator.changeCTranslate2Model( path=config.PATH_LOCAL, model_type=config.CTRANSLATE2_WEIGHT_TYPE, @@ -126,12 +171,15 @@ class Model: return downloadCTranslate2Tokenizer(config.PATH_LOCAL, weight_type) def isLoadedCTranslate2Model(self): + self.ensure_initialized() return self.translator.isLoadedCTranslate2Model() def isChangedTranslatorParameters(self): + self.ensure_initialized() return self.translator.isChangedTranslatorParameters() def setChangedTranslatorParameters(self, is_changed): + self.ensure_initialized() self.translator.setChangedTranslatorParameters(is_changed) def checkTranscriptionWhisperModelWeight(self, weight_type:str): @@ -141,20 +189,24 @@ class Model: return downloadWhisperWeight(config.PATH_LOCAL, weight_type, callback, end_callback) def resetKeywordProcessor(self): + self.ensure_initialized() del self.keyword_processor self.keyword_processor = KeywordProcessor() def authenticationTranslatorDeepLAuthKey(self, auth_key): + self.ensure_initialized() result = self.translator.authenticationDeepLAuthKey(auth_key) return result def startLogger(self): + self.ensure_initialized() os_makedirs(config.PATH_LOGS, exist_ok=True) file_name = os_path.join(config.PATH_LOGS, f"{datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.log") self.logger = setupLogger("log", file_name) self.logger.disabled = False def stopLogger(self): + self.ensure_initialized() self.logger.disabled = True self.logger = None @@ -195,6 +247,7 @@ class Model: return compatible_engines def getTranslate(self, translator_name, source_language, target_language, target_country, message): + self.ensure_initialized() success_flag = False translation = self.translator.translate( translator_name=translator_name, @@ -222,6 +275,7 @@ class Model: return translation, success_flag def getInputTranslate(self, message, source_language=None): + self.ensure_initialized() translator_name=config.SELECTED_TRANSLATION_ENGINES[config.SELECTED_TAB_NO] if source_language is None: source_language=config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO]["1"]["language"] @@ -247,6 +301,7 @@ class Model: return translations, success_flags def getOutputTranslate(self, message, source_language=None): + self.ensure_initialized() translator_name=config.SELECTED_TRANSLATION_ENGINES[config.SELECTED_TAB_NO] if source_language is None: source_language=config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO]["1"]["language"] @@ -263,10 +318,12 @@ class Model: return [translation], [success_flag] def addKeywords(self): + self.ensure_initialized() for f in config.MIC_WORD_FILTER: self.keyword_processor.add_keyword(f) def checkKeywords(self, message): + self.ensure_initialized() return len(self.keyword_processor.extract_keywords(message)) != 0 def detectRepeatSendMessage(self, message): @@ -284,16 +341,19 @@ class Model: return repeat_flag def startTransliteration(self): + self.ensure_initialized() if self.transliterator is None: self.transliterator = Transliterator() def stopTransliteration(self): + self.ensure_initialized() if self.transliterator is not None: self.transliterator = None - def convertMessageToTransliteration(self, message: str, hiragana: bool=True, romaji: bool=True) -> str: + def convertMessageToTransliteration(self, message: str, hiragana: bool=True, romaji: bool=True) -> list: + self.ensure_initialized() if hiragana is False and romaji is False: - return message + return [] keys_to_keep = {"orig"} if hiragana: @@ -312,24 +372,31 @@ class Model: return filtered_list def setOscIpAddress(self, ip_address): + self.ensure_initialized() self.osc_handler.setOscIpAddress(ip_address) def setOscPort(self, port): + self.ensure_initialized() self.osc_handler.setOscPort(port) def oscStartSendTyping(self): + self.ensure_initialized() self.osc_handler.sendTyping(flag=True) def oscStopSendTyping(self): + self.ensure_initialized() self.osc_handler.sendTyping(flag=False) def oscSendMessage(self, message:str): + self.ensure_initialized() self.osc_handler.sendMessage(message=message, notification=config.NOTIFICATION_VRC_SFX) def setMuteSelfStatus(self): + self.ensure_initialized() self.mic_mute_status = self.osc_handler.getOSCParameterMuteSelf() def startReceiveOSC(self): + self.ensure_initialized() def changeHandlerMute(address, osc_arguments): if config.ENABLE_TRANSCRIPTION_SEND is True: if osc_arguments is True and self.mic_mute_status is False: @@ -346,9 +413,11 @@ class Model: self.osc_handler.receiveOscParameters() def stopReceiveOSC(self): + self.ensure_initialized() self.osc_handler.oscServerStop() def getIsOscQueryEnabled(self): + self.ensure_initialized() return self.osc_handler.getIsOscQueryEnabled() @staticmethod @@ -413,22 +482,47 @@ class Model: Popen([program_name, "--cuda"], cwd=current_directory) def getListMicHost(self): - result = [host for host in device_manager.getMicDevices().keys()] + self.ensure_initialized() + try: + dm = device_manager.getMicDevices() + result = [host for host in dm.keys()] + except Exception: + errorLogging() + result = [] return result def getMicDefaultDevice(self): - result = device_manager.getMicDevices().get(config.SELECTED_MIC_HOST, [{"name": "NoDevice"}])[0]["name"] + self.ensure_initialized() + try: + dm = device_manager.getMicDevices() + result = dm.get(config.SELECTED_MIC_HOST, [{"name": "NoDevice"}])[0]["name"] + except Exception: + errorLogging() + result = "NoDevice" return result def getListMicDevice(self): - result = [device["name"] for device in device_manager.getMicDevices().get(config.SELECTED_MIC_HOST, [{"name": "NoDevice"}])] + self.ensure_initialized() + try: + dm = device_manager.getMicDevices() + result = [device["name"] for device in dm.get(config.SELECTED_MIC_HOST, [{"name": "NoDevice"}])] + except Exception: + errorLogging() + result = ["NoDevice"] return result def getListSpeakerDevice(self): - result = [device["name"] for device in device_manager.getSpeakerDevices()] + self.ensure_initialized() + try: + sd = device_manager.getSpeakerDevices() + result = [device["name"] for device in sd] + except Exception: + errorLogging() + result = ["NoDevice"] return result def startMicTranscript(self, fnc): + self.ensure_initialized() mic_host_name = config.SELECTED_MIC_HOST mic_device_name = config.SELECTED_MIC_DEVICE @@ -515,6 +609,7 @@ class Model: self.changeMicTranscriptStatus() def resumeMicTranscript(self): + self.ensure_initialized() # キューをクリア if isinstance(self.mic_audio_queue, Queue): while not self.mic_audio_queue.empty(): @@ -529,6 +624,7 @@ class Model: self.mic_audio_recorder.resume() def pauseMicTranscript(self): + self.ensure_initialized() # 文字起こしを一時停止 # if isinstance(self.mic_print_transcript, threadFnc): # self.mic_print_transcript.pause() @@ -562,6 +658,7 @@ class Model: self.resumeMicTranscript() def stopMicTranscript(self): + self.ensure_initialized() if isinstance(self.mic_print_transcript, threadFnc): self.mic_print_transcript.stop() self.mic_print_transcript.join() @@ -574,9 +671,11 @@ class Model: # self.mic_get_energy.stop() # self.mic_get_energy = None - def startCheckMicEnergy(self, fnc:Callable[[float], None]=None) -> None: - if isinstance(fnc, Callable): - self.check_mic_energy_fnc = fnc + def startCheckMicEnergy(self, fnc:Optional[Callable[[float], None]]=None) -> None: + self.ensure_initialized() + # fnc may be None or a callable. Use cast after checking for None to satisfy type checker. + if fnc is not None: + self.check_mic_energy_fnc = cast(Callable[[float], None], fnc) mic_host_name = config.SELECTED_MIC_HOST mic_device_name = config.SELECTED_MIC_DEVICE @@ -596,7 +695,7 @@ class Model: errorLogging() sleep(0.01) - mic_energy_queue = Queue() + mic_energy_queue: Queue = Queue() mic_device = selected_mic_device[0] self.mic_energy_recorder = SelectedMicEnergyRecorder(mic_device) self.mic_energy_recorder.recordIntoQueue(mic_energy_queue) @@ -605,6 +704,7 @@ class Model: self.mic_energy_plot_progressbar.start() def stopCheckMicEnergy(self): + self.ensure_initialized() if isinstance(self.mic_energy_plot_progressbar, threadFnc): self.mic_energy_plot_progressbar.stop() self.mic_energy_plot_progressbar.join() @@ -614,17 +714,19 @@ class Model: self.mic_energy_recorder.stop() self.mic_energy_recorder = None - def startSpeakerTranscript(self, fnc): + def startSpeakerTranscript(self, fnc:Optional[Callable[[dict], None]]=None) -> None: + self.ensure_initialized() speaker_device_name = config.SELECTED_SPEAKER_DEVICE speaker_device_list = device_manager.getSpeakerDevices() selected_speaker_device = [device for device in speaker_device_list if device["name"] == speaker_device_name] if len(selected_speaker_device) == 0 or speaker_device_name == "NoDevice": - fnc({"text": False, "language": None}) + # fnc may be None; only call if callable + if callable(fnc): + fnc({"text": False, "language": None}) else: - speaker_audio_queue = Queue() - # speaker_energy_queue = Queue() + speaker_audio_queue: Queue = Queue() speaker_device = selected_speaker_device[0] record_timeout = config.SPEAKER_RECORD_TIMEOUT phrase_timeout = config.SPEAKER_PHRASE_TIMEOUT @@ -697,6 +799,7 @@ class Model: # self.speaker_get_energy.start() def stopSpeakerTranscript(self): + self.ensure_initialized() if isinstance(self.speaker_print_transcript, threadFnc): self.speaker_print_transcript.stop() self.speaker_print_transcript.join() @@ -708,9 +811,11 @@ class Model: # self.speaker_get_energy.stop() # self.speaker_get_energy = None - def startCheckSpeakerEnergy(self, fnc:Callable[[float], None]=None) -> None: - if isinstance(fnc, Callable): - self.check_speaker_energy_fnc = fnc + def startCheckSpeakerEnergy(self, fnc:Optional[Callable[[float], None]]=None) -> None: + self.ensure_initialized() + # Accept None as default and assign safely with cast after None-check + if fnc is not None: + self.check_speaker_energy_fnc = cast(Callable[[float], None], fnc) speaker_device_name = config.SELECTED_SPEAKER_DEVICE speaker_device_list = device_manager.getSpeakerDevices() @@ -720,7 +825,7 @@ class Model: self.check_speaker_energy_fnc(False) else: def sendSpeakerEnergy(): - if speaker_energy_queue.empty() is False: + if not speaker_energy_queue.empty(): energy = speaker_energy_queue.get() try: self.check_speaker_energy_fnc(energy) @@ -728,7 +833,7 @@ class Model: errorLogging() sleep(0.01) - speaker_energy_queue = Queue() + speaker_energy_queue: Queue = Queue() speaker_device = selected_speaker_device[0] self.speaker_energy_recorder = SelectedSpeakerEnergyRecorder(speaker_device) self.speaker_energy_recorder.recordIntoQueue(speaker_energy_queue) @@ -737,6 +842,7 @@ class Model: self.speaker_energy_plot_progressbar.start() def stopCheckSpeakerEnergy(self): + self.ensure_initialized() if isinstance(self.speaker_energy_plot_progressbar, threadFnc): self.speaker_energy_plot_progressbar.stop() self.speaker_energy_plot_progressbar.join() @@ -746,11 +852,16 @@ class Model: self.speaker_energy_recorder.stop() self.speaker_energy_recorder = None - def createOverlayImageSmallLog(self, message:str, your_language:str, translation:list, target_language:dict): - target_language = [data["language"] for data in target_language.values() if data["enable"] is True] - return self.overlay_image.createOverlayImageSmallLog(message, your_language, translation, target_language) + def createOverlayImageSmallLog(self, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict]) -> object: + self.ensure_initialized() + # target_language may be provided as dict or None + target_language_list = [] + if isinstance(target_language, dict): + target_language_list = [data["language"] for data in target_language.values() if data.get("enable") is True] + return self.overlay_image.createOverlayImageSmallLog(message, your_language, translation, target_language_list) def createOverlayImageSmallMessage(self, message): + self.ensure_initialized() ui_language = config.UI_LANGUAGE convert_languages = { "en": "Default", @@ -763,12 +874,15 @@ class Model: return self.overlay_image.createOverlayImageSmallLog(message, language) def clearOverlayImageSmallLog(self): + self.ensure_initialized() self.overlay.clearImage("small") def updateOverlaySmallLog(self, img): + self.ensure_initialized() self.overlay.updateImage(img, "small") def updateOverlaySmallLogSettings(self): + self.ensure_initialized() size = "small" if (self.overlay.settings[size]["x_pos"] != config.OVERLAY_SMALL_LOG_SETTINGS["x_pos"] or @@ -797,11 +911,16 @@ class Model: if (self.overlay.settings[size]["ui_scaling"] != config.OVERLAY_SMALL_LOG_SETTINGS["ui_scaling"]): self.overlay.updateUiScaling(config.OVERLAY_SMALL_LOG_SETTINGS["ui_scaling"], size) - def createOverlayImageLargeLog(self, message_type:str, message:str, your_language:str, translation:list, target_language:dict): - target_language = [data["language"] for data in target_language.values() if data["enable"] is True] - return self.overlay_image.createOverlayImageLargeLog(message_type, message, your_language, translation, target_language) + def createOverlayImageLargeLog(self, message_type:str, message:Optional[str], your_language:Optional[str], translation:list, target_language:Optional[dict]=None): + self.ensure_initialized() + # normalize target_language dict -> list of language strings + target_language_list = [] + if isinstance(target_language, dict): + target_language_list = [data["language"] for data in target_language.values() if data.get("enable") is True] + return self.overlay_image.createOverlayImageLargeLog(message_type, message, your_language, translation, target_language_list) def createOverlayImageLargeMessage(self, message): + self.ensure_initialized() ui_language = config.UI_LANGUAGE convert_languages = { "en": "Default", @@ -819,12 +938,15 @@ class Model: return overlay_image.createOverlayImageLargeLog("send", message, language) def clearOverlayImageLargeLog(self): + self.ensure_initialized() self.overlay.clearImage("large") def updateOverlayLargeLog(self, img): + self.ensure_initialized() self.overlay.updateImage(img, "large") def updateOverlayLargeLogSettings(self): + self.ensure_initialized() size = "large" if (self.overlay.settings[size]["x_pos"] != config.OVERLAY_LARGE_LOG_SETTINGS["x_pos"] or self.overlay.settings[size]["y_pos"] != config.OVERLAY_LARGE_LOG_SETTINGS["y_pos"] or @@ -853,23 +975,29 @@ class Model: self.overlay.updateUiScaling(config.OVERLAY_LARGE_LOG_SETTINGS["ui_scaling"] * 0.25, size) def startOverlay(self): + self.ensure_initialized() self.overlay.startOverlay() def shutdownOverlay(self): + self.ensure_initialized() self.overlay.shutdownOverlay() def startWatchdog(self): + self.ensure_initialized() self.th_watchdog = threadFnc(self.watchdog.start) self.th_watchdog.daemon = True self.th_watchdog.start() def feedWatchdog(self): + self.ensure_initialized() self.watchdog.feed() def setWatchdogCallback(self, callback): + self.ensure_initialized() self.watchdog.setCallback(callback) def stopWatchdog(self): + self.ensure_initialized() if isinstance(self.th_watchdog, threadFnc): self.th_watchdog.stop() self.th_watchdog.join() @@ -881,6 +1009,7 @@ class Model: def startWebSocketServer(self, host, port): """WebSocketサーバーを起動し、別スレッドで実行する""" + self.ensure_initialized() if self.websocket_server_alive is True: # サーバーが既に起動している場合は何もしない return @@ -919,6 +1048,7 @@ class Model: def stopWebSocketServer(self): """WebSocketサーバーを停止する""" + self.ensure_initialized() if not hasattr(self, 'th_websocket_server') or self.th_websocket_server is None: return @@ -940,6 +1070,7 @@ class Model: def checkWebSocketServerAlive(self): """WebSocketサーバーの稼働状態を確認する""" + self.ensure_initialized() return self.websocket_server_alive def websocketSendMessage(self, message_dict:dict): @@ -948,6 +1079,7 @@ class Model: :param message_dict: 送信するメッセージの辞書 :return: 送信成功したかどうか """ + self.ensure_initialized() if not self.websocket_server_alive or not self.websocket_server: return False try: diff --git a/src-python/models/__init__.py b/src-python/models/__init__.py new file mode 100644 index 00000000..4d9d80d5 --- /dev/null +++ b/src-python/models/__init__.py @@ -0,0 +1,5 @@ +"""models package init for static analysis and packaging.""" + +__all__ = [ + # subpackages are discovered implicitly +] diff --git a/src-python/models/osc/osc.py b/src-python/models/osc/osc.py index ce64623b..99f304be 100644 --- a/src-python/models/osc/osc.py +++ b/src-python/models/osc/osc.py @@ -1,82 +1,116 @@ +"""OSC helpers and a thin OSCQuery-enabled server wrapper. + +This module provides `OSCHandler`, a convenience wrapper used by the +application to send OSC messages and expose OSCQuery endpoints when the +target address is localhost. The implementation is defensive: missing +utilities are handled gracefully and logging helpers are used where +available. +""" + import time -from typing import Any +from typing import Any, Callable, Dict, Optional from time import sleep from threading import Thread from pythonosc import udp_client, dispatcher, osc_server -from tinyoscquery.queryservice import OSCQueryService -from tinyoscquery.query import OSCQueryBrowser, OSCQueryClient -from tinyoscquery.utility import get_open_udp_port, get_open_tcp_port -from tinyoscquery.shared.node import OSCAccess +try: + from tinyoscquery.queryservice import OSCQueryService + from tinyoscquery.query import OSCQueryBrowser, OSCQueryClient + from tinyoscquery.utility import get_open_udp_port, get_open_tcp_port + from tinyoscquery.shared.node import OSCAccess +except Exception: + # tinyoscquery is optional for non-local usage; functionality that + # depends on it will be disabled if it's missing. + OSCQueryService = None # type: ignore + OSCQueryBrowser = None # type: ignore + OSCQueryClient = None # type: ignore + def get_open_udp_port() -> int: # type: ignore + return 0 + + def get_open_tcp_port() -> int: # type: ignore + return 0 + OSCAccess = None # type: ignore try: from utils import errorLogging -except ImportError: - def errorLogging(): +except Exception: + def errorLogging() -> None: import traceback print("Error occurred:", traceback.format_exc()) class OSCHandler: - def __init__(self, ip_address="127.0.0.1", port=9000) -> None: + """Thin wrapper managing OSC send/receive and optional OSCQuery advertising. - if ip_address in ["127.0.0.1", "localhost"]: - self.is_osc_query_enabled = True - else: - self.is_osc_query_enabled = False + Args: + ip_address: OSC server client target / bind address + port: UDP port to send to + """ - self.osc_ip_address = ip_address - self.osc_port = port - self.osc_parameter_muteself = "/avatar/parameters/MuteSelf" - self.osc_parameter_chatbox_typing = "/chatbox/typing" - self.osc_parameter_chatbox_input = "/chatbox/input" + def __init__(self, ip_address: str = "127.0.0.1", port: int = 9000) -> None: + + self.is_osc_query_enabled: bool = ip_address in ["127.0.0.1", "localhost"] + + self.osc_ip_address: str = ip_address + self.osc_port: int = port + self.osc_parameter_muteself: str = "/avatar/parameters/MuteSelf" + self.osc_parameter_chatbox_typing: str = "/chatbox/typing" + self.osc_parameter_chatbox_input: str = "/chatbox/input" self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port) - self.osc_server_name = "VRChat-Client" + self.osc_server_name: str = "VRChat-Client" self.osc_server = None self.osc_query_service = None - self.osc_query_service_name = "VRCT" - self.osc_server_ip_address = ip_address - self.http_port = None - self.osc_server_port = None - self.dict_filter_and_target = {} + self.osc_query_service_name: str = "VRCT" + self.osc_server_ip_address: str = ip_address + self.http_port: Optional[int] = None + self.osc_server_port: Optional[int] = None + self.dict_filter_and_target: Dict[str, Callable] = {} self.browser = None def getIsOscQueryEnabled(self) -> bool: + """Return whether OSCQuery support is enabled (local addresses only).""" return self.is_osc_query_enabled - def setOscIpAddress(self, ip_address:str) -> None: - if ip_address in ["127.0.0.1", "localhost"]: - self.is_osc_query_enabled = True - else: - self.is_osc_query_enabled = False + def setOscIpAddress(self, ip_address: str) -> None: + """Change the OSC target IP address and reinitialize services.""" + self.is_osc_query_enabled = ip_address in ["127.0.0.1", "localhost"] self.oscServerStop() self.osc_ip_address = ip_address self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port) self.receiveOscParameters() - def setOscPort(self, port:int) -> None: + def setOscPort(self, port: int) -> None: + """Change the OSC UDP port used for sending and reinitialize services.""" self.oscServerStop() self.osc_port = port self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port) self.receiveOscParameters() # send OSC message typing - def sendTyping(self, flag:bool=False) -> None: + def sendTyping(self, flag: bool = False) -> None: + """Send /chatbox/typing with a boolean flag.""" self.udp_client.send_message(self.osc_parameter_chatbox_typing, [flag]) # send OSC message - def sendMessage(self, message:str="", notification:bool=True) -> None: + def sendMessage(self, message: str = "", notification: bool = True) -> None: + """Send /chatbox/input if message is non-empty. + + The second argument historically was a boolean flag for clearing; we keep + compatibility by sending [message, True, notification]. + """ if len(message) > 0: self.udp_client.send_message(self.osc_parameter_chatbox_input, [f"{message}", True, notification]) - def getOSCParameterValue(self, address:str) -> Any: + def getOSCParameterValue(self, address: str) -> Any: if not self.is_osc_query_enabled: # OSCQueryが無効な場合はNoneを返す return None - - value = None + value: Any = None try: # browserインスタンスを再利用し、毎回の生成と破棄を避ける if self.browser is None: + # OSCQueryBrowser may not be available; guard + if OSCQueryBrowser is None: + return None self.browser = OSCQueryBrowser() sleep(1) # 初回のみスリープ @@ -84,7 +118,17 @@ class OSCHandler: if service is not None: osc_query_client = OSCQueryClient(service) mute_self_node = osc_query_client.query_node(address) - value = mute_self_node.value[0] + # mute_self_node may be None when the node is not present on the + # remote OSCQuery service. Also mute_self_node.value may be None + # or an empty list. Guard against those cases to avoid + # AttributeError: 'NoneType' object has no attribute 'value' + if mute_self_node is None: + return None + # prefer explicit checks rather than relying on exceptions + node_value = getattr(mute_self_node, 'value', None) + if not node_value: + return None + value = node_value[0] except Exception: errorLogging() # エラー発生時にbrowserをリセットして次回再初期化 @@ -99,15 +143,22 @@ class OSCHandler: self.browser = None return value - def getOSCParameterMuteSelf(self) -> bool: + def getOSCParameterMuteSelf(self) -> Optional[bool]: + """Return the value of the MuteSelf parameter when available, else None.""" return self.getOSCParameterValue(self.osc_parameter_muteself) - def setDictFilterAndTarget(self, dict_filter_and_target:dict) -> None: + def setDictFilterAndTarget(self, dict_filter_and_target: Dict[str, Callable]) -> None: + """Set the mapping from OSC address filters to handler callables.""" self.dict_filter_and_target = dict_filter_and_target def receiveOscParameters(self) -> None: - if self.is_osc_query_enabled is False: - # OSCQueryが無効な場合は何もしない + """Start a local OSC server and advertise OSCQuery endpoints when supported. + + If `tinyoscquery` is not available or OSCQuery is disabled, this is a + no-op. + """ + if not self.is_osc_query_enabled or OSCQueryService is None: + # OSCQuery が無効またはライブラリが無い場合は何もしない return self.osc_server_port = get_open_udp_port() @@ -120,28 +171,39 @@ class OSCHandler: while True: try: - # osc_server_name + UTC timestampでユニークなサービス名を生成 + # osc_server_name + UTC timestamp でユニークなサービス名を生成 service_name = f"{self.osc_query_service_name}:{int(time.time())}" self.osc_query_service = OSCQueryService(service_name, self.http_port, self.osc_server_port) for filter, target in self.dict_filter_and_target.items(): - self.osc_query_service.advertise_endpoint(filter, access=OSCAccess.READWRITE_VALUE) + # OSCAccess may be None when tinyoscquery is not present; guard + if OSCAccess is not None: + self.osc_query_service.advertise_endpoint(filter, access=OSCAccess.READWRITE_VALUE) break except Exception: errorLogging() sleep(1) def oscServerServe(self) -> None: + """Run the OSC server loop with a longer poll interval to reduce CPU.""" # ポーリング間隔を長くして(2秒から10秒に)CPUの使用率を削減 - self.osc_server.serve_forever(10) + if self.osc_server is not None: + self.osc_server.serve_forever(10) def oscServerStop(self) -> None: + """Stop and clean up any running OSC server and OSCQuery service.""" if isinstance(self.osc_server, osc_server.ThreadingOSCUDPServer): - self.osc_server.shutdown() + try: + self.osc_server.shutdown() + except Exception: + pass self.osc_server = None - if isinstance(self.osc_query_service, OSCQueryService): - self.osc_query_service.http_server.shutdown() + if OSCQueryService is not None and isinstance(self.osc_query_service, OSCQueryService): + try: + self.osc_query_service.http_server.shutdown() + except Exception: + pass self.osc_query_service = None - # browserがある場合はクリーンアップ + # browser がある場合はクリーンアップ if self.browser is not None: try: if hasattr(self.browser, 'zc') and self.browser.zc is not None: diff --git a/src-python/models/overlay/__init__.py b/src-python/models/overlay/__init__.py new file mode 100644 index 00000000..33f43c7b --- /dev/null +++ b/src-python/models/overlay/__init__.py @@ -0,0 +1,5 @@ +"""models.overlay package init for static analysis.""" + +from . import overlay_utils # re-export helper for ease-of-use in tooling + +__all__ = ["overlay_utils"] diff --git a/src-python/models/overlay/overlay.py b/src-python/models/overlay/overlay.py index 92c24ab9..4305e77d 100644 --- a/src-python/models/overlay/overlay.py +++ b/src-python/models/overlay/overlay.py @@ -3,6 +3,8 @@ import ctypes import time from psutil import process_iter from threading import Thread +from typing import Any, Dict, Optional, Sequence + import openvr import numpy as np from PIL import Image @@ -18,14 +20,26 @@ try: except ImportError: import overlay_utils as utils -def mat34Id(array): +def mat34Id(array: Sequence[Sequence[float]]) -> Any: + """Convert a 3x4 nested sequence into an openvr.HmdMatrix34_t instance. + + Args: + array: 3x4 numeric sequence + + Returns: + openvr HmdMatrix34_t compatible object + """ arr = openvr.HmdMatrix34_t() for i in range(3): for j in range(4): arr[i][j] = array[i][j] return arr -def getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation): +def getBaseMatrix(x_pos: float, y_pos: float, z_pos: float, x_rotation: float, y_rotation: float, z_rotation: float) -> np.ndarray: + """Create a 3x4 base matrix for an overlay given position and Euler rotations. + + Returns a numpy array of shape (3,4). + """ arr = np.zeros((3, 4)) rot = utils.euler_to_rotation_matrix((x_rotation, y_rotation, z_rotation)) @@ -38,7 +52,7 @@ def getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation): arr[2][3] = - z_pos return arr -def getHMDBaseMatrix(): +def getHMDBaseMatrix() -> np.ndarray: x_pos = 0.0 y_pos = -0.4 z_pos = 1.0 @@ -48,7 +62,7 @@ def getHMDBaseMatrix(): arr = getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation) return arr -def getLeftHandBaseMatrix(): +def getLeftHandBaseMatrix() -> np.ndarray: x_pos = 0.3 y_pos = 0.1 z_pos = -0.31 @@ -58,7 +72,7 @@ def getLeftHandBaseMatrix(): arr = getBaseMatrix(x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation) return arr -def getRightHandBaseMatrix(): +def getRightHandBaseMatrix() -> np.ndarray: x_pos = -0.3 y_pos = 0.1 z_pos = -0.31 @@ -69,24 +83,25 @@ def getRightHandBaseMatrix(): return arr class Overlay: - def __init__(self, settings_dict): - self.system = None - self.overlay = None - self.handle = None - self.init_process = False - self.initialized = False - self.loop = False - self.thread_overlay = None + """Manage OpenVR overlays for multiple sizes (e.g. 'small'/'large').""" + def __init__(self, settings_dict: Dict[str, Dict[str, Any]]) -> None: + self.system: Optional[Any] = None + self.overlay: Optional[Any] = None + self.handle: Dict[str, Any] = {} + self.init_process: bool = False + self.initialized: bool = False + self.loop: bool = False + self.thread_overlay: Optional[Thread] = None - self.settings = {} - self.lastUpdate = {} - self.fadeRatio = {} + self.settings: Dict[str, Dict[str, Any]] = {} + self.lastUpdate: Dict[str, float] = {} + self.fadeRatio: Dict[str, float] = {} for key, value in settings_dict.items(): self.settings[key] = value self.lastUpdate[key] = time.monotonic() - self.fadeRatio[key] = 1 + self.fadeRatio[key] = 1.0 - def init(self): + def init(self) -> None: try: self.system = openvr.init(openvr.VRApplication_Background) self.overlay = openvr.IVROverlay() @@ -119,7 +134,7 @@ class Overlay: except Exception: errorLogging() - def updateImage(self, img, size): + def updateImage(self, img: Image.Image, size: str) -> None: if self.initialized is True: width, height = img.size img = img.tobytes() @@ -139,7 +154,7 @@ class Overlay: self.updateOpacity(self.settings[size]["opacity"], size) self.lastUpdate[size] = time.monotonic() - def clearImage(self, size): + def clearImage(self, size: str) -> None: if self.initialized is True: self.updateImage(Image.new("RGBA", (1, 1), (0, 0, 0, 0)), size) @@ -151,7 +166,7 @@ class Overlay: r, g, b = col self.overlay.setOverlayColor(self.handle[size], r, g, b) - def updateOpacity(self, opacity, size, with_fade=False): + def updateOpacity(self, opacity: float, size: str, with_fade: bool = False) -> None: self.settings[size]["opacity"] = opacity if self.initialized is True: @@ -161,12 +176,12 @@ class Overlay: else: self.overlay.setOverlayAlpha(self.handle[size], self.settings[size]["opacity"]) - def updateUiScaling(self, ui_scaling, size): + def updateUiScaling(self, ui_scaling: float, size: str) -> None: self.settings[size]["ui_scaling"] = ui_scaling if self.initialized is True: self.overlay.setOverlayWidthInMeters(self.handle[size], self.settings[size]["ui_scaling"]) - def updatePosition(self, x_pos, y_pos, z_pos, x_rotation, y_rotation, z_rotation, tracker, size): + def updatePosition(self, x_pos: float, y_pos: float, z_pos: float, x_rotation: float, y_rotation: float, z_rotation: float, tracker: str, size: str) -> None: """ x_pos, y_pos, z_pos are floats representing the position of overlay x_rotation, y_rotation, z_rotation are floats representing the rotation of overlay @@ -208,13 +223,13 @@ class Overlay: transform ) - def updateDisplayDuration(self, display_duration, size): + def updateDisplayDuration(self, display_duration: float, size: str) -> None: self.settings[size]["display_duration"] = display_duration - def updateFadeoutDuration(self, fadeout_duration, size): + def updateFadeoutDuration(self, fadeout_duration: float, size: str) -> None: self.settings[size]["fadeout_duration"] = fadeout_duration - def checkActive(self): + def checkActive(self) -> bool: try: if self.system is not None and self.initialized is True: new_event = openvr.VREvent_t() @@ -226,7 +241,7 @@ class Overlay: errorLogging() return False - def evaluateOpacityFade(self, size): + def evaluateOpacityFade(self, size: str) -> None: currentTime = time.monotonic() if (currentTime - self.lastUpdate[size]) > self.settings[size]["display_duration"]: timeThroughInterval = currentTime - self.lastUpdate[size] - self.settings[size]["display_duration"] @@ -235,13 +250,13 @@ class Overlay: self.fadeRatio[size] = 0 self.overlay.setOverlayAlpha(self.handle[size], self.fadeRatio[size] * self.settings[size]["opacity"]) - def update(self, size): + def update(self, size: str) -> None: if self.settings[size]["fadeout_duration"] != 0: self.evaluateOpacityFade(size) else: self.updateOpacity(self.settings[size]["opacity"], size) - def mainloop(self): + def mainloop(self) -> None: self.loop = True while self.checkActive() is True and self.loop is True: startTime = time.monotonic() @@ -251,21 +266,21 @@ class Overlay: if sleepTime > 0: time.sleep(sleepTime) - def main(self): + def main(self) -> None: while self.checkSteamvrRunning() is False: time.sleep(10) self.init() if self.initialized is True: self.mainloop() - def startOverlay(self): + def startOverlay(self) -> None: if self.initialized is False and self.init_process is False: self.init_process = True self.thread_overlay = Thread(target=self.main) self.thread_overlay.daemon = True self.thread_overlay.start() - def shutdownOverlay(self): + def shutdownOverlay(self) -> None: if self.initialized is True and self.init_process is False: if isinstance(self.thread_overlay, Thread): self.loop = False @@ -281,7 +296,7 @@ class Overlay: self.system = None self.initialized = False - def reStartOverlay(self): + def reStartOverlay(self) -> None: self.shutdownOverlay() self.startOverlay() diff --git a/src-python/models/overlay/overlay_image.py b/src-python/models/overlay/overlay_image.py index 708ad11c..21520278 100644 --- a/src-python/models/overlay/overlay_image.py +++ b/src-python/models/overlay/overlay_image.py @@ -1,6 +1,6 @@ from os import path as os_path from datetime import datetime -from typing import Tuple +from typing import Tuple, List, Optional from PIL import Image, ImageDraw, ImageFont try: from utils import errorLogging @@ -18,8 +18,14 @@ class OverlayImage: "Chinese Traditional": "NotoSansTC-Regular.ttf", } - def __init__(self, root_path: str=None): - self.message_log = [] + def __init__(self, root_path: Optional[str] = None) -> None: + """Overlay image helper. + + Args: + root_path: optional project root to resolve bundled fonts. If omitted, + defaults to repository `fonts` directory. + """ + self.message_log: List[dict] = [] if root_path is None: self.root_path = os_path.join(os_path.dirname(__file__), "..", "..", "..", "fonts") else: @@ -58,7 +64,7 @@ class OverlayImage: } return colors - def createTextboxSmallLog(self, text:str, language:str, text_color:tuple, base_width:int, base_height:int, font_size:int) -> Image: + def createTextboxSmallLog(self, text: str, language: str, text_color: Tuple[int, int, int], base_width: int, base_height: int, font_size: int) -> Image: font_family = self.LANGUAGES.get(language, self.LANGUAGES["Default"]) img = Image.new("RGBA", (base_width, base_height), (0, 0, 0, 0)) draw = ImageDraw.Draw(img) @@ -92,7 +98,7 @@ class OverlayImage: draw.text((text_x, text_y), text, text_color, anchor="mm", stroke_width=0, font=font, align="center") return img - def createOverlayImageSmallLog(self, message: str, your_language: str, translation: list = [], target_language: list = []) -> Image: + def createOverlayImageSmallLog(self, message: str, your_language: str, translation: List[str] = [], target_language: List[str] = []) -> Image: # UI設定を取得 ui_size = self.getUiSizeSmallLog() width, height, font_size = ui_size["width"], ui_size["height"], ui_size["font_size"] @@ -162,7 +168,7 @@ class OverlayImage: "text_color_time": (120, 120, 120) } - def createTextImageLargeLog(self, message_type:str, size:str, text:str, language:str) -> Image: + def createTextImageLargeLog(self, message_type: str, size: str, text: str, language: str) -> Image: ui_size = self.getUiSizeLargeLog() font_size = ui_size["font_size_large"] if size == "large" else ui_size["font_size_small"] text_color = self.getUiColorLargeLog()[f"text_color_{size}"] @@ -200,7 +206,7 @@ class OverlayImage: draw.multiline_text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font, align=align) return img - def createTextImageMessageType(self, message_type:str, date_time:str) -> Image: + def createTextImageMessageType(self, message_type: str, date_time: str) -> Image: ui_size = self.getUiSizeLargeLog() font_size = ui_size["font_size_small"] ui_padding = ui_size["padding"] @@ -242,7 +248,7 @@ class OverlayImage: draw.text((text_x, text_y), text, text_color, anchor=anchor, stroke_width=0, font=font) return img - def createTextboxLargeLog(self, message_type: str, message: str = None, your_language: str = None, translation: list = [], target_language: list = [], date_time: str = None) -> Image: + def createTextboxLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = [], date_time: Optional[str] = None) -> Image: # テキスト画像のリストを作成 images = [self.createTextImageMessageType(message_type, date_time)] @@ -272,7 +278,7 @@ class OverlayImage: return combined_img - def createOverlayImageLargeLog(self, message_type:str, message:str=None, your_language:str=None, translation:list=[], target_language:list=[]) -> Image: + def createOverlayImageLargeLog(self, message_type: str, message: Optional[str] = None, your_language: Optional[str] = None, translation: List[str] = [], target_language: List[str] = []) -> Image: ui_color = self.getUiColorLargeLog() background_color = ui_color["background_color"] background_outline_color = ui_color["background_outline_color"] diff --git a/src-python/models/overlay/overlay_utils.py b/src-python/models/overlay/overlay_utils.py index 0a379dd0..8807d638 100644 --- a/src-python/models/overlay/overlay_utils.py +++ b/src-python/models/overlay/overlay_utils.py @@ -1,11 +1,29 @@ import numpy as np +from typing import Sequence -def toHomogeneous(matrix): + +def toHomogeneous(matrix: np.ndarray) -> np.ndarray: + """Convert a 3x4 base matrix to a 4x4 homogeneous matrix. + + Args: + matrix: 3x4 numpy array + + Returns: + 4x4 numpy array with last row [0, 0, 0, 1] + """ homogeneous_matrix = np.vstack([matrix, [0, 0, 0, 1]]) return homogeneous_matrix # 移動行列を生成する関数 -def calcTranslationMatrix(translation): +def calcTranslationMatrix(translation: Sequence[float]) -> np.ndarray: + """Create a 4x4 translation matrix from a 3-element translation. + + Args: + translation: (tx, ty, tz) + + Returns: + 4x4 numpy translation matrix + """ tx, ty, tz = translation return np.array([ [1, 0, 0, tx], @@ -15,9 +33,10 @@ def calcTranslationMatrix(translation): ]) # X軸周りの回転行列を生成する関数 -def calcRotationMatrixX(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixX(angle: float) -> np.ndarray: + """Rotation matrix around X axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [1, 0, 0, 0], [0, c, -s, 0], @@ -26,9 +45,10 @@ def calcRotationMatrixX(angle): ]) # Y軸周りの回転行列を生成する関数 -def calcRotationMatrixY(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixY(angle: float) -> np.ndarray: + """Rotation matrix around Y axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [c, 0, s, 0], [0, 1, 0, 0], @@ -37,9 +57,10 @@ def calcRotationMatrixY(angle): ]) # Z軸周りの回転行列を生成する関数 -def calcRotationMatrixZ(angle): - c = np.cos(np.pi/180*angle) - s = np.sin(np.pi/180*angle) +def calcRotationMatrixZ(angle: float) -> np.ndarray: + """Rotation matrix around Z axis for given angle in degrees.""" + c = np.cos(np.pi / 180 * angle) + s = np.sin(np.pi / 180 * angle) return np.array([ [c, -s, 0, 0], [s, c, 0, 0], @@ -48,7 +69,17 @@ def calcRotationMatrixZ(angle): ]) # 3x4行列の座標を基準として回転や移動を行う関数 -def transform_matrix(base_matrix, translation, rotation): +def transform_matrix(base_matrix: np.ndarray, translation: Sequence[float], rotation: Sequence[float]) -> np.ndarray: + """Apply translation and Euler rotations to a 3x4 base matrix. + + Args: + base_matrix: 3x4 base transform matrix + translation: (tx, ty, tz) + rotation: (x_deg, y_deg, z_deg) + + Returns: + Transformed 3x4 matrix (numpy.ndarray) + """ homogeneous_base_matrix = toHomogeneous(base_matrix) translation_matrix = calcTranslationMatrix(translation) rotation_matrix_x = calcRotationMatrixX(rotation[0]) @@ -60,10 +91,18 @@ def transform_matrix(base_matrix, translation, rotation): result_matrix = np.dot(homogeneous_base_matrix, transformation_matrix) return result_matrix[:3, :] -def euler_to_rotation_matrix(angles): +def euler_to_rotation_matrix(angles: Sequence[float]) -> np.ndarray: + """Convert Euler angles in degrees to a 3x3 rotation matrix. + + Args: + angles: (x_deg, y_deg, z_deg) + + Returns: + 3x3 rotation matrix + """ phi = angles[0] * np.pi / 180 theta = angles[1] * np.pi / 180 - psi = angles[2]* np.pi / 180 + psi = angles[2] * np.pi / 180 R_x = np.array([[1, 0, 0], [0, np.cos(phi), -np.sin(phi)], [0, np.sin(phi), np.cos(phi)]]) diff --git a/src-python/models/transcription/transcription_languages.py b/src-python/models/transcription/transcription_languages.py index 12625df7..6240e056 100644 --- a/src-python/models/transcription/transcription_languages.py +++ b/src-python/models/transcription/transcription_languages.py @@ -1,3 +1,8 @@ +"""Language table used by transcription components. + +Maps a display language and country to engine-specific language codes. +""" + transcription_lang = { "Afrikaans":{ "South Africa":{ diff --git a/src-python/models/transcription/transcription_recorder.py b/src-python/models/transcription/transcription_recorder.py index f30c071f..30eb946e 100644 --- a/src-python/models/transcription/transcription_recorder.py +++ b/src-python/models/transcription/transcription_recorder.py @@ -1,10 +1,18 @@ +"""Recorders that wrap speech_recognition microphone interfaces. + +These classes provide small adapters that push raw audio bytes into queues. +They intentionally keep a thin API so the rest of the system can mock them +in tests. +""" + +from typing import Any from speech_recognition import Recognizer, Microphone from pyaudiowpatch import get_sample_size, paInt16 from datetime import datetime -from queue import Queue + class BaseRecorder: - def __init__(self, source, energy_threshold, dynamic_energy_threshold, record_timeout): + def __init__(self, source: Any, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: self.recorder = Recognizer() self.recorder.energy_threshold = energy_threshold self.recorder.dynamic_energy_threshold = dynamic_energy_threshold @@ -16,39 +24,65 @@ class BaseRecorder: self.source = source - def adjustForNoise(self): + def adjustForNoise(self) -> None: with self.source: self.recorder.adjust_for_ambient_noise(self.source) - def recordIntoQueue(self, audio_queue): + def recordIntoQueue(self, audio_queue: Any) -> None: def record_callback(_, audio): audio_queue.put((audio.get_raw_data(), datetime.now())) self.stop, self.pause, self.resume = self.recorder.listen_in_background(self.source, record_callback, phrase_time_limit=self.record_timeout) + class SelectedMicRecorder(BaseRecorder): - def __init__(self, device, energy_threshold, dynamic_energy_threshold, record_timeout): - source=Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: + # Safely construct Microphone source. If device dict is missing expected keys + # or index is out-of-range for the platform, fallback to default device (None) + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + # invalid index -> fallback + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + # Best-effort fallback: use system default microphone + try: + source = Microphone() + except Exception: + raise super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout) # self.adjustForNoise() -class SelectedSpeakerRecorder(BaseRecorder): - def __init__(self, device, energy_threshold, dynamic_energy_threshold, record_timeout): - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - chunk_size=get_sample_size(paInt16), - channels=device["maxInputChannels"] - ) +class SelectedSpeakerRecorder(BaseRecorder): + def __init__(self, device: dict, energy_threshold: int, dynamic_energy_threshold: bool, record_timeout: int) -> None: + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + chunk_size=get_sample_size(paInt16), + channels=channels + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__(source=source, energy_threshold=energy_threshold, dynamic_energy_threshold=dynamic_energy_threshold, record_timeout=record_timeout) # self.adjustForNoise() class BaseEnergyRecorder: - def __init__(self, source): + def __init__(self, source: Any) -> None: self.recorder = Recognizer() self.recorder.energy_threshold = 0 self.recorder.dynamic_energy_threshold = False @@ -60,38 +94,68 @@ class BaseEnergyRecorder: self.source = source - def adjustForNoise(self): + def adjustForNoise(self) -> None: with self.source: self.recorder.adjust_for_ambient_noise(self.source) - def recordIntoQueue(self, energy_queue): + def recordIntoQueue(self, energy_queue: Any) -> None: def recordCallback(_, energy): energy_queue.put(energy) self.stop, self.pause, self.resume = self.recorder.listen_energy_in_background(self.source, recordCallback) + class SelectedMicEnergyRecorder(BaseEnergyRecorder): - def __init__(self, device): - source=Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + def __init__(self, device: dict) -> None: + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + try: + source = Microphone() + except Exception: + raise super().__init__(source=source) # self.adjustForNoise() -class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder): - def __init__(self, device): - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - channels=device["maxInputChannels"] - ) +class SelectedSpeakerEnergyRecorder(BaseEnergyRecorder): + def __init__(self, device: dict) -> None: + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + channels=channels + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__(source=source) # self.adjustForNoise() class BaseEnergyAndAudioRecorder: - def __init__(self, source, energy_threshold, dynamic_energy_threshold, phrase_time_limit, phrase_timeout, record_timeout): + def __init__( + self, + source: Any, + energy_threshold: int, + dynamic_energy_threshold: bool, + phrase_time_limit: int, + phrase_timeout: int, + record_timeout: int, + ) -> None: self.recorder = Recognizer() self.recorder.energy_threshold = energy_threshold self.recorder.dynamic_energy_threshold = dynamic_energy_threshold @@ -105,11 +169,11 @@ class BaseEnergyAndAudioRecorder: self.source = source - def adjustForNoise(self): + def adjustForNoise(self) -> None: with self.source: self.recorder.adjust_for_ambient_noise(self.source) - def recordIntoQueue(self, audio_queue, energy_queue=None): + def recordIntoQueue(self, audio_queue: Any, energy_queue: Any = None) -> None: def audioRecordCallback(_, audio): audio_queue.put((audio.get_raw_data(), datetime.now())) @@ -122,14 +186,34 @@ class BaseEnergyAndAudioRecorder: phrase_time_limit=self.phrase_time_limit, callback_energy=energyRecordCallback if energy_queue is not None else None, phrase_timeout=self.phrase_timeout, - record_timeout=self.record_timeout) + record_timeout=self.record_timeout, + ) + class SelectedMicEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): - def __init__(self, device, energy_threshold, dynamic_energy_threshold, phrase_time_limit, phrase_timeout:int=1, record_timeout:int=5): - source=Microphone( - device_index=device['index'], - sample_rate=int(device["defaultSampleRate"]), - ) + def __init__( + self, + device: dict, + energy_threshold: int, + dynamic_energy_threshold: bool, + phrase_time_limit: int, + phrase_timeout: int = 1, + record_timeout: int = 5, + ) -> None: + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone( + device_index=device_index, + sample_rate=sample_rate, + ) + except Exception: + try: + source = Microphone() + except Exception: + raise super().__init__( source=source, energy_threshold=energy_threshold, @@ -140,15 +224,35 @@ class SelectedMicEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): ) # self.adjustForNoise() -class SelectedSpeakerEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): - def __init__(self, device, energy_threshold, dynamic_energy_threshold, phrase_time_limit, phrase_timeout:int=1, record_timeout:int=5): - source = Microphone(speaker=True, - device_index= device["index"], - sample_rate=int(device["defaultSampleRate"]), - chunk_size=get_sample_size(paInt16), - channels=device["maxInputChannels"] - ) +class SelectedSpeakerEnergyAndAudioRecorder(BaseEnergyAndAudioRecorder): + def __init__( + self, + device: dict, + energy_threshold: int, + dynamic_energy_threshold: bool, + phrase_time_limit: int, + phrase_timeout: int = 1, + record_timeout: int = 5, + ) -> None: + + try: + device_index = int(device.get('index', -1)) + sample_rate = int(device.get("defaultSampleRate", 16000)) + channels = int(device.get("maxInputChannels", 1)) + if device_index < 0: + raise ValueError("invalid device index") + source = Microphone(speaker=True, + device_index=device_index, + sample_rate=sample_rate, + chunk_size=get_sample_size(paInt16), + channels=channels, + ) + except Exception: + try: + source = Microphone(speaker=True) + except Exception: + raise super().__init__( source=source, energy_threshold=energy_threshold, diff --git a/src-python/models/transcription/transcription_transcriber.py b/src-python/models/transcription/transcription_transcriber.py index 9d874b30..15a9aa51 100644 --- a/src-python/models/transcription/transcription_transcriber.py +++ b/src-python/models/transcription/transcription_transcriber.py @@ -1,7 +1,14 @@ +"""Runtime transcriber that wraps Google SpeechRecognition and faster-whisper. + +This class focuses on converting incoming raw audio buffers into text using +either the Google web recognizer (online) or a local Whisper model (offline). +""" + import time from io import BytesIO from threading import Event import wave +from typing import Any, Callable, Dict, List, Optional, Tuple from speech_recognition import Recognizer, AudioData, AudioFile from speech_recognition.exceptions import UnknownValueError from datetime import timedelta @@ -20,38 +27,71 @@ warnings.simplefilter('ignore', RuntimeWarning) PHRASE_TIMEOUT = 3 MAX_PHRASES = 10 + class AudioTranscriber: - def __init__(self, speaker, source, phrase_timeout, max_phrases, transcription_engine, root=None, whisper_weight_type=None, device="cpu", device_index=0, compute_type="auto"): + """Convert queued audio buffers into transcripts. + + Public attributes set by the constructor: + - speaker: bool + - phrase_timeout: int + - max_phrases: int + + Methods are intentionally permissive about input types to match the + existing codebase; this wrapper adds typing for clarity. + """ + + def __init__( + self, + speaker: bool, + source: Any, + phrase_timeout: int, + max_phrases: int, + transcription_engine: str, + root: Optional[str] = None, + whisper_weight_type: Optional[str] = None, + device: str = "cpu", + device_index: int = 0, + compute_type: str = "auto", + ) -> None: self.speaker = speaker self.phrase_timeout = phrase_timeout self.max_phrases = max_phrases - self.transcript_data = [] + self.transcript_data: List[Dict[str, Any]] = [] self.transcript_changed_event = Event() self.audio_recognizer = Recognizer() self.transcription_engine = "Google" self.whisper_model = None - self.audio_sources = { - "sample_rate": source.SAMPLE_RATE, - "sample_width": source.SAMPLE_WIDTH, - "channels": source.channels, - "last_sample": bytes(), - "last_spoken": None, - "new_phrase": True, - "process_data_func": self.processSpeakerData if speaker else self.processSpeakerData + self.audio_sources: Dict[str, Any] = { + "sample_rate": source.SAMPLE_RATE, + "sample_width": source.SAMPLE_WIDTH, + "channels": source.channels, + "last_sample": bytes(), + "last_spoken": None, + "new_phrase": True, + "process_data_func": self.processSpeakerData if speaker else self.processSpeakerData, } if transcription_engine == "Whisper" and checkWhisperWeight(root, whisper_weight_type) is True: - self.whisper_model = getWhisperModel(root, whisper_weight_type, device=device, device_index=device_index, compute_type=compute_type) + self.whisper_model = getWhisperModel( + root, whisper_weight_type, device=device, device_index=device_index, compute_type=compute_type + ) self.transcription_engine = "Whisper" - def transcribeAudioQueue(self, audio_queue, languages, countries, avg_logprob=-0.8, no_speech_prob=0.6): + def transcribeAudioQueue( + self, + audio_queue: Any, + languages: List[str], + countries: List[str], + avg_logprob: float = -0.8, + no_speech_prob: float = 0.6, + ) -> bool: if audio_queue.empty(): time.sleep(0.01) return False audio, time_spoken = audio_queue.get() self.updateLastSampleAndPhraseStatus(audio, time_spoken) - confidences = [{"confidence": 0, "text": "", "language": None}] + confidences: List[Dict[str, Any]] = [{"confidence": 0, "text": "", "language": None}] try: audio_data = self.audio_sources["process_data_func"]() match self.transcription_engine: @@ -67,13 +107,19 @@ class AudioTranscriber: except Exception: pass case "Whisper": - audio_data = np.frombuffer(audio_data.get_raw_data(convert_rate=16000, convert_width=2), np.int16).flatten().astype(np.float32) / 32768.0 + audio_data = np.frombuffer( + audio_data.get_raw_data(convert_rate=16000, convert_width=2), np.int16 + ).flatten().astype(np.float32) / 32768.0 if isinstance(audio_data, torch.Tensor): audio_data = audio_data.detach().numpy() for language, country in zip(languages, countries): text = "" - source_language = transcription_lang[language][country][self.transcription_engine] if len(languages) == 1 else None + source_language = ( + transcription_lang[language][country][self.transcription_engine] + if len(languages) == 1 + else None + ) segments, info = self.whisper_model.transcribe( audio_data, beam_size=5, @@ -85,13 +131,15 @@ class AudioTranscriber: without_timestamps=True, task="transcribe", vad_filter=False, - ) + ) for s in segments: if s.avg_logprob < avg_logprob or s.no_speech_prob > no_speech_prob: continue text += s.text confidences.append({"confidence": info.language_probability, "text": text, "language": language}) - if (len(languages) == 1) or (transcription_lang[language][country][self.transcription_engine] == info.language): + if (len(languages) == 1) or ( + transcription_lang[language][country][self.transcription_engine] == info.language + ): break except UnknownValueError: @@ -106,7 +154,7 @@ class AudioTranscriber: self.updateTranscript(result) return True - def updateLastSampleAndPhraseStatus(self, data, time_spoken): + def updateLastSampleAndPhraseStatus(self, data: bytes, time_spoken) -> None: source_info = self.audio_sources if source_info["last_spoken"] and time_spoken - source_info["last_spoken"] > timedelta(seconds=self.phrase_timeout): source_info["last_sample"] = bytes() @@ -117,11 +165,13 @@ class AudioTranscriber: source_info["last_sample"] += data source_info["last_spoken"] = time_spoken - def processMicData(self): - audio_data = AudioData(self.audio_sources["last_sample"], self.audio_sources["sample_rate"], self.audio_sources["sample_width"]) + def processMicData(self) -> AudioData: + audio_data = AudioData( + self.audio_sources["last_sample"], self.audio_sources["sample_rate"], self.audio_sources["sample_width"] + ) return audio_data - def processSpeakerData(self): + def processSpeakerData(self) -> AudioData: temp_file = BytesIO() with wave.open(temp_file, 'wb') as wf: wf.setnchannels(self.audio_sources["channels"]) @@ -141,7 +191,7 @@ class AudioTranscriber: audio = self.audio_recognizer.record(source) return audio - def updateTranscript(self, result): + def updateTranscript(self, result: dict) -> None: source_info = self.audio_sources transcript = self.transcript_data @@ -152,14 +202,14 @@ class AudioTranscriber: else: transcript[0] = result - def getTranscript(self): + def getTranscript(self) -> dict: if len(self.transcript_data) > 0: result = self.transcript_data.pop(-1) else: result = {"confidence": 0, "text": "", "language": None} return result - def clearTranscriptData(self): + def clearTranscriptData(self) -> None: self.transcript_data.clear() self.audio_sources["last_sample"] = bytes() self.audio_sources["new_phrase"] = True \ No newline at end of file diff --git a/src-python/models/transcription/transcription_whisper.py b/src-python/models/transcription/transcription_whisper.py index 5f61a121..ccfd2260 100644 --- a/src-python/models/transcription/transcription_whisper.py +++ b/src-python/models/transcription/transcription_whisper.py @@ -1,6 +1,17 @@ +"""Helpers for downloading and loading Whisper (faster-whisper) models. + +This module exposes small utilities used by the transcription subsystem: +- downloadFile: stream-download a file with optional progress callback +- checkWhisperWeight: quick local availability check +- downloadWhisperWeight: download model artifacts from HF hub +- getWhisperModel: construct and return a WhisperModel instance + +The functions are defensive: failures are caught and reported by the caller. +""" + from os import path as os_path, makedirs as os_makedirs from requests import get as requests_get -from typing import Callable +from typing import Callable, Optional import huggingface_hub from faster_whisper import WhisperModel import logging @@ -30,24 +41,36 @@ _FILENAMES = [ "vocabulary.json", ] -def downloadFile(url, path, func=None): +def downloadFile(url: str, path: str, func: Optional[Callable[[float], None]] = None) -> None: + """Download a file from `url` to `path`. + + Args: + url: remote URL to download from + path: local filepath to write + func: optional callback(progress: float) called with a 0.0-1.0 progress + """ try: res = requests_get(url, stream=True) res.raise_for_status() file_size = int(res.headers.get('content-length', 0)) total_chunk = 0 with open(os_path.join(path), 'wb') as file: - for chunk in res.iter_content(chunk_size=1024*2000): + for chunk in res.iter_content(chunk_size=1024 * 2000): file.write(chunk) - if isinstance(func, Callable): + if callable(func) and file_size: total_chunk += len(chunk) - func(total_chunk/file_size) + func(total_chunk / file_size) except Exception: + # Silent failure here; caller may re-check or log pass -def checkWhisperWeight(root, weight_type): +def checkWhisperWeight(root: str, weight_type: str) -> bool: + """Return True if a Whisper model for `weight_type` is loadable from disk. + + This attempts to construct a local `WhisperModel` with local_files_only=True + to verify required files exist and are compatible. + """ path = os_path.join(root, "weights", "whisper", weight_type) - result = False try: WhisperModel( path, @@ -58,23 +81,47 @@ def checkWhisperWeight(root, weight_type): num_workers=1, local_files_only=True, ) - result = True + return True except Exception: - pass - return result + return False -def downloadWhisperWeight(root, weight_type, callback=None, end_callback=None): +def downloadWhisperWeight( + root: str, + weight_type: str, + callback: Optional[Callable[[float], None]] = None, + end_callback: Optional[Callable[[], None]] = None, +) -> None: + """Ensure Whisper weight files are present locally; download them if missing. + + Args: + root: project root where `weights/whisper` lives + weight_type: key from `_MODELS` (eg. "tiny", "base") + callback: progress callback for the main model file + end_callback: called when download completes + """ path = os_path.join(root, "weights", "whisper", weight_type) os_makedirs(path, exist_ok=True) - if checkWhisperWeight(root, weight_type) is False: + if not checkWhisperWeight(root, weight_type): for filename in _FILENAMES: file_path = os_path.join(path, filename) url = huggingface_hub.hf_hub_url(_MODELS[weight_type], filename) downloadFile(url, file_path, func=callback if filename == "model.bin" else None) - if isinstance(end_callback, Callable): + if callable(end_callback): end_callback() -def getWhisperModel(root, weight_type, device="cpu", device_index=0, compute_type="auto"): +def getWhisperModel( + root: str, + weight_type: str, + device: str = "cpu", + device_index: int = 0, + compute_type: str = "auto", +) -> WhisperModel: + """Return a `WhisperModel` instance loaded from local weights. + + Raises: + ValueError: when VRAM shortage is detected (wrapped from RuntimeError) + Exception: other loading errors are propagated. + """ path = os_path.join(root, "weights", "whisper", weight_type) if compute_type == "auto": compute_type = getBestComputeType(device, device_index) @@ -90,11 +137,10 @@ def getWhisperModel(root, weight_type, device="cpu", device_index=0, compute_typ ) return model except RuntimeError as e: - # VRAM不足エラーの検出 + # Detect VRAM out-of-memory-like errors and raise a clear ValueError error_message = str(e) if "CUDA out of memory" in error_message or "CUBLAS_STATUS_ALLOC_FAILED" in error_message: raise ValueError("VRAM_OUT_OF_MEMORY", error_message) - # その他のエラーは通常通り再送出 raise if __name__ == "__main__": diff --git a/src-python/models/translation/translation_languages.py b/src-python/models/translation/translation_languages.py index 2a660e17..804b2921 100644 --- a/src-python/models/translation/translation_languages.py +++ b/src-python/models/translation/translation_languages.py @@ -1,4 +1,13 @@ -translation_lang = {} +"""Language code mappings for supported translation backends. + +Provides `translation_lang` mapping keyed by backend name with `source` and +`target` maps used by `Translator.getLanguageCode`. +""" + +from typing import Dict + +translation_lang: Dict[str, Dict[str, Dict[str, str]]] = {} + dict_deepl_languages = { "Arabic":"ar", "Bulgarian":"bg", @@ -37,10 +46,7 @@ dict_deepl_languages = { "Chinese Simplified":"zh", "Chinese Traditional":"zh" } -translation_lang["DeepL"] = { - "source":dict_deepl_languages, - "target":dict_deepl_languages, -} +translation_lang["DeepL"] = {"source": dict_deepl_languages, "target": dict_deepl_languages} dict_deepl_api_source_languages = { "Japanese":"ja", @@ -109,10 +115,7 @@ dict_deepl_api_target_languages = { "Chinese Simplified":"zh", "Chinese Traditional":"zh" } -translation_lang["DeepL_API"] = { - "source": dict_deepl_api_source_languages, - "target": dict_deepl_api_target_languages, -} +translation_lang["DeepL_API"] = {"source": dict_deepl_api_source_languages, "target": dict_deepl_api_target_languages} dict_google_languages = { "Japanese":"ja", @@ -179,10 +182,7 @@ dict_google_languages = { # "Basque":"eu", "Irish":"ga" } -translation_lang["Google"] = { - "source":dict_google_languages, - "target":dict_google_languages, -} +translation_lang["Google"] = {"source": dict_google_languages, "target": dict_google_languages} dict_bing_languages = { "Japanese":"ja", @@ -247,10 +247,7 @@ dict_bing_languages = { "Punjabi":"pa", "Irish":"ga" } -translation_lang["Bing"] = { - "source":dict_bing_languages, - "target":dict_bing_languages, -} +translation_lang["Bing"] = {"source": dict_bing_languages, "target": dict_bing_languages} dict_papago_languages = { "German": "de", @@ -270,10 +267,7 @@ dict_papago_languages = { "Chinese Traditional":"zh-TW", } -translation_lang["Papago"] = { - "source":dict_papago_languages, - "target":dict_papago_languages, -} +translation_lang["Papago"] = {"source": dict_papago_languages, "target": dict_papago_languages} dict_ctranslate2_languages = { "English": "en", @@ -378,7 +372,4 @@ dict_ctranslate2_languages = { "Sundanese": "su" } -translation_lang["CTranslate2"] = { - "source":dict_ctranslate2_languages, - "target":dict_ctranslate2_languages, -} \ No newline at end of file +translation_lang["CTranslate2"] = {"source": dict_ctranslate2_languages, "target": dict_ctranslate2_languages} \ No newline at end of file diff --git a/src-python/models/translation/translation_translator.py b/src-python/models/translation/translation_translator.py index a12b326e..faab0327 100644 --- a/src-python/models/translation/translation_translator.py +++ b/src-python/models/translation/translation_translator.py @@ -4,6 +4,7 @@ try: from translators import translate_text as other_web_Translator ENABLE_TRANSLATORS = True except Exception: + other_web_Translator = None # type: ignore ENABLE_TRANSLATORS = False from .translation_languages import translation_lang @@ -14,22 +15,37 @@ import transformers from utils import errorLogging, getBestComputeType import warnings +from typing import Any, Optional, Tuple + warnings.filterwarnings("ignore") -# Translator -class Translator(): - def __init__(self): - self.deepl_client = None - self.ctranslate2_translator = None - self.ctranslate2_tokenizer = None - self.is_loaded_ctranslate2_model = False - self.is_changed_translator_parameters = False - self.is_enable_translators = ENABLE_TRANSLATORS - def authenticationDeepLAuthKey(self, authkey): +class Translator: + """High-level translator facade. + + This class wraps multiple backends (DeepL, DeepL API, Google, Bing, Papago, + and CTranslate2 local models). Optional dependencies may be unavailable at + runtime; methods degrade gracefully and return False or an empty string on + failure (kept compatible with existing behavior). + """ + + def __init__(self) -> None: + self.deepl_client: Optional[DeepLClient] = None + self.ctranslate2_translator: Any = None + self.ctranslate2_tokenizer: Any = None + self.is_loaded_ctranslate2_model: bool = False + self.is_changed_translator_parameters: bool = False + self.is_enable_translators: bool = ENABLE_TRANSLATORS + + def authenticationDeepLAuthKey(self, authkey: str) -> bool: + """Authenticate DeepL API with the provided key. + + Returns True on success, False on failure. + """ result = True try: self.deepl_client = DeepLClient(authkey) + # quick smoke test self.deepl_client.translate_text(" ", target_lang="EN-US") except Exception: errorLogging() @@ -37,7 +53,12 @@ class Translator(): result = False return result - def changeCTranslate2Model(self, path, model_type, device="cpu", device_index=0, compute_type="auto"): + def changeCTranslate2Model(self, path: str, model_type: str, device: str = "cpu", device_index: int = 0, compute_type: str = "auto") -> None: + """Load a CTranslate2 model from weights. + + This sets internal translator/tokenizer objects and flips + ``is_loaded_ctranslate2_model`` on success. + """ self.is_loaded_ctranslate2_model = False directory_name = ctranslate2_weights[model_type]["directory_name"] tokenizer = ctranslate2_weights[model_type]["tokenizer"] @@ -52,7 +73,7 @@ class Translator(): device_index=device_index, compute_type=compute_type, inter_threads=1, - intra_threads=4 + intra_threads=4, ) try: self.ctranslate2_tokenizer = transformers.AutoTokenizer.from_pretrained(tokenizer, cache_dir=tokenizer_path) @@ -62,17 +83,21 @@ class Translator(): self.ctranslate2_tokenizer = transformers.AutoTokenizer.from_pretrained(tokenizer, cache_dir=tokenizer_path) self.is_loaded_ctranslate2_model = True - def isLoadedCTranslate2Model(self): + def isLoadedCTranslate2Model(self) -> bool: return self.is_loaded_ctranslate2_model - def isChangedTranslatorParameters(self): + def isChangedTranslatorParameters(self) -> bool: return self.is_changed_translator_parameters - def setChangedTranslatorParameters(self, is_changed): + def setChangedTranslatorParameters(self, is_changed: bool) -> None: self.is_changed_translator_parameters = is_changed - def translateCTranslate2(self, message, source_language, target_language): - result = False + def translateCTranslate2(self, message: str, source_language: str, target_language: str) -> Any: + """Translate using a loaded CTranslate2 model. + + Returns a string on success or False on failure (keeps legacy behavior). + """ + result: Any = False if self.is_loaded_ctranslate2_model is True: try: self.ctranslate2_tokenizer.src_lang = source_language @@ -86,7 +111,11 @@ class Translator(): return result @staticmethod - def getLanguageCode(translator_name, target_country, source_language, target_language): + def getLanguageCode(translator_name: str, target_country: str, source_language: str, target_language: str) -> Tuple[str, str]: + """Resolve a friendly language name to translator-specific codes. + + Returns (source_code, target_code). + """ match translator_name: case "DeepL_API": if target_language == "English": @@ -101,66 +130,63 @@ class Translator(): target_language = "Portuguese Brazilian" case _: pass - source_language=translation_lang[translator_name]["source"][source_language] - target_language=translation_lang[translator_name]["target"][target_language] + source_language = translation_lang[translator_name]["source"][source_language] + target_language = translation_lang[translator_name]["target"][target_language] return source_language, target_language - def translate(self, translator_name, source_language, target_language, target_country, message): + def translate(self, translator_name: str, source_language: str, target_language: str, target_country: str, message: str) -> Any: + """Translate `message` using the named translator backend. + + Returns translated string on success, or False on failure. When + source_language == target_language the original message is returned. + """ try: if source_language == target_language: return message - result = "" + result: Any = "" source_language, target_language = self.getLanguageCode(translator_name, target_country, source_language, target_language) match translator_name: case "DeepL": - if self.is_enable_translators is True: + if self.is_enable_translators is True and other_web_Translator is not None: result = other_web_Translator( query_text=message, translator="deepl", from_language=source_language, to_language=target_language, - ) + ) case "DeepL_API": if self.is_enable_translators is True: if self.deepl_client is None: result = False else: - result = self.deepl_client.translate_text( - message, - source_lang=source_language, - target_lang=target_language, - ).text + result = self.deepl_client.translate_text(message, source_lang=source_language, target_lang=target_language).text case "Google": - if self.is_enable_translators is True: + if self.is_enable_translators is True and other_web_Translator is not None: result = other_web_Translator( query_text=message, translator="google", from_language=source_language, to_language=target_language, - ) + ) case "Bing": - if self.is_enable_translators is True: + if self.is_enable_translators is True and other_web_Translator is not None: result = other_web_Translator( query_text=message, translator="bing", from_language=source_language, to_language=target_language, - ) + ) case "Papago": - if self.is_enable_translators is True: + if self.is_enable_translators is True and other_web_Translator is not None: result = other_web_Translator( query_text=message, translator="papago", from_language=source_language, to_language=target_language, - ) - case "CTranslate2": - result = self.translateCTranslate2( - message=message, - source_language=source_language, - target_language=target_language, ) + case "CTranslate2": + result = self.translateCTranslate2(message=message, source_language=source_language, target_language=target_language) except Exception: errorLogging() result = False diff --git a/src-python/models/translation/translation_utils.py b/src-python/models/translation/translation_utils.py index 457a65f1..003da354 100644 --- a/src-python/models/translation/translation_utils.py +++ b/src-python/models/translation/translation_utils.py @@ -3,13 +3,22 @@ from zipfile import ZipFile from os import path as os_path from os import makedirs as os_makedirs from requests import get as requests_get -from typing import Callable +from typing import Callable, Optional import hashlib import transformers from utils import errorLogging + +"""Utilities for downloading and verifying CTranslate2 weights and tokenizers. + +This module provides a small, dependency-light set of helpers used by the +translation layer. It purposely keeps behavior resilient: network errors are +logged (via utils.errorLogging) and the functions return/complete without +raising, which matches the repository's defensive style. +""" + ctranslate2_weights = { - "small": { # M2M-100 418M-parameter model + "small": { "url": "https://github.com/misyaguziya/VRCT-weights/releases/download/v1.0/m2m100_418m.zip", "directory_name": "m2m100_418m", "tokenizer": "facebook/m2m100_418M", @@ -17,9 +26,9 @@ ctranslate2_weights = { "model.bin": "e7c26a9abb5260abd0268fbe3040714070dec254a990b4d7fd3f74c5230e3acb", "sentencepiece.model": "d8f7c76ed2a5e0822be39f0a4f95a55eb19c78f4593ce609e2edbc2aea4d380a", "shared_vocabulary.txt": "bd440aa21b8ca3453fc792a0018a1f3fe68b3464aadddd4d16a4b72f73c86d8c", - } + }, }, - "large": { # M2M-100 1.2B-parameter model + "large": { "url": "https://github.com/misyaguziya/VRCT-weights/releases/download/v1.0/m2m100_12b.zip", "directory_name": "m2m100_12b", "tokenizer": "facebook/m2m100_1.2b", @@ -27,77 +36,107 @@ ctranslate2_weights = { "model.bin": "abb7bf4ba7e5e016b6e3ed480c752459b2f783ac8fca372e7587675e5bf3a919", "sentencepiece.model": "d8f7c76ed2a5e0822be39f0a4f95a55eb19c78f4593ce609e2edbc2aea4d380a", "shared_vocabulary.txt": "bd440aa21b8ca3453fc792a0018a1f3fe68b3464aadddd4d16a4b72f73c86d8c", - } + }, }, } -def calculate_file_hash(file_path, block_size=65536): + +def calculate_file_hash(file_path: str, block_size: int = 65536) -> str: hash_object = hashlib.sha256() - - with open(file_path, 'rb') as file: - for block in iter(lambda: file.read(block_size), b''): + with open(file_path, "rb") as f: + for block in iter(lambda: f.read(block_size), b""): hash_object.update(block) - return hash_object.hexdigest() -def checkCTranslate2Weight(root, weight_type="small"): - weight_directory_name = ctranslate2_weights[weight_type]["directory_name"] - hash_data = ctranslate2_weights[weight_type]["hash"] - files = [ - "model.bin", - "sentencepiece.model", - "shared_vocabulary.txt" - ] - path = os_path.join(root, "weights", "ctranslate2") - # check already downloaded - already_downloaded = False - if all(os_path.exists(os_path.join(path, weight_directory_name, file)) for file in files): - # check hash - for file in files: - original_hash = hash_data[file] - current_hash = calculate_file_hash(os_path.join(path, weight_directory_name, file)) - if original_hash != current_hash: - break - already_downloaded = True - return already_downloaded +def checkCTranslate2Weight(root: str, weight_type: str = "small") -> bool: + """Return True if the requested weight files exist and match their hashes. -def downloadCTranslate2Weight(root, weight_type="small", callback=None, end_callback=None): - url = ctranslate2_weights[weight_type]["url"] - filename = "weight.zip" - path = os_path.join(root, "weights", "ctranslate2") - os_makedirs(path, exist_ok=True) - - if checkCTranslate2Weight(root, weight_type) is False: + This function intentionally avoids raising: callers use the boolean to + decide whether to (re)download weights. + """ + weight_info = ctranslate2_weights.get(weight_type) + if weight_info is None: + return False + weight_directory_name = weight_info["directory_name"] + hash_data = weight_info["hash"] + files = ["model.bin", "sentencepiece.model", "shared_vocabulary.txt"] + base_path = os_path.join(root, "weights", "ctranslate2") + # quick existence check + for f in files: + p = os_path.join(base_path, weight_directory_name, f) + if not os_path.exists(p): + return False + # verify hashes + for f in files: + p = os_path.join(base_path, weight_directory_name, f) try: - with tempfile.TemporaryDirectory() as tmp_path: - res = requests_get(url, stream=True) - file_size = int(res.headers.get('content-length', 0)) - total_chunk = 0 - with open(os_path.join(tmp_path, filename), 'wb') as file: - for chunk in res.iter_content(chunk_size=1024*2000): - file.write(chunk) - if isinstance(callback, Callable): - total_chunk += len(chunk) - callback(total_chunk/file_size) - - with ZipFile(os_path.join(tmp_path, filename)) as zf: - zf.extractall(path) + if calculate_file_hash(p) != hash_data[f]: + return False except Exception: errorLogging() + return False + return True - if isinstance(end_callback, Callable): - end_callback() -def downloadCTranslate2Tokenizer(path, weight_type="small"): - directory_name = ctranslate2_weights[weight_type]["directory_name"] - tokenizer = ctranslate2_weights[weight_type]["tokenizer"] - tokenizer_path = os_path.join(path, "weights", "ctranslate2", directory_name, "tokenizer") +def downloadCTranslate2Weight(root: str, weight_type: str = "small", callback: Optional[Callable[[float], None]] = None, end_callback: Optional[Callable[[], None]] = None) -> None: + """Download and extract ctranslate2 weights for the given type. + callback receives a float between 0 and 1 for progress when available. + end_callback is invoked after success or failure to allow caller cleanup. + """ + weight_info = ctranslate2_weights.get(weight_type) + if weight_info is None: + return + url = weight_info["url"] + filename = "weight.zip" + dst_path = os_path.join(root, "weights", "ctranslate2") + os_makedirs(dst_path, exist_ok=True) + if checkCTranslate2Weight(root, weight_type): + if callable(end_callback): + end_callback() + return try: - os_makedirs(tokenizer_path, exist_ok=True) - transformers.AutoTokenizer.from_pretrained(tokenizer, cache_dir=tokenizer_path) + with tempfile.TemporaryDirectory() as tmp_path: + res = requests_get(url, stream=True, timeout=30) + total = int(res.headers.get("content-length", 0) or 0) + written = 0 + out_path = os_path.join(tmp_path, filename) + with open(out_path, "wb") as out: + for chunk in res.iter_content(chunk_size=1024 * 1024): + if not chunk: + continue + out.write(chunk) + written += len(chunk) + if callable(callback) and total: + try: + callback(written / total) + except Exception: + errorLogging() + with ZipFile(out_path) as zf: + zf.extractall(dst_path) except Exception: errorLogging() - tokenizer_path = os_path.join("./weights", "ctranslate2", directory_name, "tokenizer") - transformers.AutoTokenizer.from_pretrained(tokenizer, cache_dir=tokenizer_path) \ No newline at end of file + finally: + if callable(end_callback): + end_callback() + + +def downloadCTranslate2Tokenizer(root: str, weight_type: str = "small") -> None: + """Ensure a tokenizer for the requested weight is available (cached). + + This will attempt to download the tokenizer via Hugging Face's transformers + and cache it under the weights directory. It logs failures instead of + raising to keep runtime resilient during startup. + """ + weight_info = ctranslate2_weights.get(weight_type) + if weight_info is None: + return + directory_name = weight_info["directory_name"] + tokenizer_name = weight_info["tokenizer"] + tokenizer_cache = os_path.join(root, "weights", "ctranslate2", directory_name, "tokenizer") + try: + os_makedirs(tokenizer_cache, exist_ok=True) + transformers.AutoTokenizer.from_pretrained(tokenizer_name, cache_dir=tokenizer_cache) + except Exception: + errorLogging() \ No newline at end of file diff --git a/src-python/models/transliteration/transliteration_context_rules.py b/src-python/models/transliteration/transliteration_context_rules.py index d0b5d339..35c25ec1 100644 --- a/src-python/models/transliteration/transliteration_context_rules.py +++ b/src-python/models/transliteration/transliteration_context_rules.py @@ -1,4 +1,4 @@ -from typing import List, Dict +from typing import List, Dict, Any import re """Contextual transliteration rules for tokenized results. @@ -33,7 +33,7 @@ DEFAULT_RULES = { -def apply_context_rules(results: List[Dict], use_macron: bool = False) -> List[Dict]: +def apply_context_rules(results: List[Dict[str, Any]], use_macron: bool = False) -> List[Dict[str, Any]]: """Apply contextual rewrite rules to `results`. Parameters @@ -50,7 +50,7 @@ def apply_context_rules(results: List[Dict], use_macron: bool = False) -> List[D """ # prepare rules: sort by priority (desc) and precompile regex where provided - raw_rules = DEFAULT_RULES.get("rules", []) + raw_rules: List[Dict[str, Any]] = DEFAULT_RULES.get("rules", []) rules = sorted(raw_rules, key=lambda r: r.get("priority", 0), reverse=True) for r in rules: if r.get("match_mode") == "regex" and r.get("pattern"): diff --git a/src-python/models/transliteration/transliteration_kana_to_hepburn.py b/src-python/models/transliteration/transliteration_kana_to_hepburn.py index e7ba04c2..d8c2b016 100644 --- a/src-python/models/transliteration/transliteration_kana_to_hepburn.py +++ b/src-python/models/transliteration/transliteration_kana_to_hepburn.py @@ -1,5 +1,7 @@ # katakana_to_hepburn.py # カタカナ -> ヘボン式ローマ字(パッケージ不要) +from typing import List + def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str: """ @@ -8,7 +10,7 @@ def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str: use_macron=False のときは単純に連続母音を残す(例: ou, oo)。 """ # 基本音の対応(主要なカタカナ) - base = { + base: dict = { 'ア':'a','イ':'i','ウ':'u','エ':'e','オ':'o', 'カ':'ka','キ':'ki','ク':'ku','ケ':'ke','コ':'ko', 'サ':'sa','シ':'shi','ス':'su','セ':'se','ソ':'so', @@ -31,7 +33,7 @@ def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str: } # 拡張:子音 + 小ャユョ の組合せ(主要なもの) - digraphs = { + digraphs: dict = { ('キ','ャ'):'kya', ('キ','ュ'):'kyu', ('キ','ョ'):'kyo', ('ギ','ャ'):'gya', ('ギ','ュ'):'gyu', ('ギ','ョ'):'gyo', ('シ','ャ'):'sha', ('シ','ュ'):'shu', ('シ','ョ'):'sho', @@ -49,8 +51,8 @@ def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str: # F-sounds (ファ フィ フェ フォ) ('フ','ァ'):'fa', ('フ','ィ'):'fi', ('フ','ェ'):'fe', ('フ','ォ'):'fo', # シェ チェ ティ etc. - ('シ','ェ'):'she', ('チ','ェ'):'che', - ('テ','ィ'):'ti', ('ト','ゥ'):'tu', ('ド','ゥ'):'du', + ('シ','ェ'):'she', ('チ','ェ'):'che', + ('テ','ィ'):'ti', ('ウ','ァ'):'wa', ('ウ','ィ'):'wi', ('ウ','ェ'):'we', ('ウ','ォ'):'wo', # その他外来語によくある組合せ ('ス','ィ'):'si', ('ズ','ィ'):'zi', ('ツ','ァ'):'tsa', ('ツ','ィ'):'tsi', ('ツ','ェ'):'tse', ('ツ','ォ'):'tso', @@ -78,7 +80,7 @@ def katakana_to_hepburn(kata: str, use_macron: bool = True) -> str: return rom # 母音がないなら全部 # 変換メイン - res = [] + res: List[str] = [] i = 0 kata = kata.strip() length = len(kata) diff --git a/src-python/models/transliteration/transliteration_transliterator.py b/src-python/models/transliteration/transliteration_transliterator.py index e25b3be4..44464348 100644 --- a/src-python/models/transliteration/transliteration_transliterator.py +++ b/src-python/models/transliteration/transliteration_transliterator.py @@ -1,5 +1,7 @@ from sudachipy import tokenizer from sudachipy import dictionary +from typing import List, Dict, Any +import threading try: from .transliteration_kana_to_hepburn import katakana_to_hepburn except ImportError: @@ -10,9 +12,12 @@ except ImportError: from transliteration_context_rules import apply_context_rules class Transliterator: - def __init__(self): + def __init__(self) -> None: self.tokenizer_obj = dictionary.Dictionary(dict_type="full").create() self.mode = tokenizer.Tokenizer.SplitMode.C + # Lock to prevent concurrent access to sudachipy tokenizer which may + # internally use Rust/PyO3 borrow semantics and raise "Already borrowed". + self._tokenizer_lock = threading.Lock() @staticmethod def is_kanji(ch: str) -> bool: @@ -26,7 +31,7 @@ class Transliterator: ) @staticmethod - def split_kanji_okurigana(surface: str, reading_kana: str, use_macron: bool = True): + def split_kanji_okurigana(surface: str, reading_kana: str, use_macron: bool = True) -> List[Dict[str, str]]: """Split a single surface word and its kana reading into parts. Inputs: @@ -45,7 +50,7 @@ class Transliterator: constructed list. """ - result = [] + result: List[Dict[str, str]] = [] # 表層を「漢字ブロック」と「非漢字ブロック」に分割 buf = "" @@ -113,7 +118,7 @@ class Transliterator: return result - def analyze(self, text: str, use_macron: bool = False): + def analyze(self, text: str, use_macron: bool = False) -> List[Dict[str, Any]]: """Tokenize ``text`` and produce per-subunit reading information. Returns a list of dicts for each token/sub-part with keys: @@ -131,9 +136,12 @@ class Transliterator: results. """ - tokens = self.tokenizer_obj.tokenize(text, self.mode) + # Tokenizer may raise RuntimeError: Already borrowed when called + # concurrently. Protect the call with a lock to serialize access. + with self._tokenizer_lock: + tokens = self.tokenizer_obj.tokenize(text, self.mode) - results = [] + results: List[Dict[str, Any]] = [] for t in tokens: surface = t.surface() reading = t.reading_form() diff --git a/src-python/models/watchdog/watchdog.py b/src-python/models/watchdog/watchdog.py index 73803e10..5976005d 100644 --- a/src-python/models/watchdog/watchdog.py +++ b/src-python/models/watchdog/watchdog.py @@ -1,20 +1,104 @@ -from typing import Callable +from typing import Callable, Optional import time +from threading import Thread, Event + class Watchdog: - def __init__(self, timeout:int=60, interval:int=20): + """A lightweight watchdog utility. + + This class provides a minimal watchdog which records the last "feed" + timestamp and can invoke a user-supplied callback when the timeout + is exceeded. The design is intentionally simple: callers are expected + to either call `start()` periodically (e.g. from a loop) or extend the + class to run `start()` in a background thread. + + Args: + timeout: seconds without feed after which the callback is invoked + interval: suggested sleep interval (seconds) for callers that poll + """ + + def __init__(self, timeout: int = 60, interval: int = 20) -> None: self.timeout = timeout self.interval = interval self.last_feed_time = time.time() + self.callback: Optional[Callable[[], None]] = None + # Background thread control + self._thread: Optional[Thread] = None + self._stop_event: Optional[Event] = None - def feed(self): + def feed(self) -> None: + """Refresh the watchdog timer (set last feed time to now).""" self.last_feed_time = time.time() - def setCallback(self, callback): + def setCallback(self, callback: Callable[[], None]) -> None: + """Register a zero-argument callback invoked on timeout.""" self.callback = callback - def start(self): - if time.time() - self.last_feed_time > self.timeout: - if isinstance(self.callback, Callable): - self.callback() - time.sleep(self.interval) \ No newline at end of file + def start(self) -> None: + """Perform a single watchdog check and optionally sleep `interval` seconds. + + The method checks if the duration since the last feed exceeds + `timeout`. If so and a callback is registered, the callback is called. + + Note: `start()` does not run in the background by itself; callers + should call it repeatedly (or run it inside a thread) if continuous + monitoring is required. + """ + now = time.time() + if now - self.last_feed_time > self.timeout: + if callable(self.callback): + try: + self.callback() + except Exception: + # Do not let callback exceptions propagate out of watchdog + import traceback + traceback.print_exc() + time.sleep(self.interval) + + def _run_loop(self) -> None: + """Internal run loop used by `start_in_thread`. + + It repeatedly calls `start()` until `_stop_event` is set. The + implementation relies on `start()` sleeping for `self.interval`. + """ + # Defensive: ensure stop_event exists + if self._stop_event is None: + return + while not self._stop_event.is_set(): + self.start() + + def start_in_thread(self, daemon: bool = True) -> None: + """Start the watchdog in a background thread. + + If the watchdog is already running, this is a no-op. The created + thread will repeatedly call `start()` until `stop()` is invoked. + + Args: + daemon: if True, thread is a daemon thread (won't block process exit) + """ + if self._thread is not None and self._thread.is_alive(): + return + self._stop_event = Event() + self._thread = Thread(target=self._run_loop, daemon=daemon) + self._thread.start() + + def stop(self, timeout: Optional[float] = None) -> None: + """Stop background thread started by `start_in_thread`. + + If no background thread is running this is a no-op. + + Args: + timeout: optional timeout to wait for thread join (seconds). If + None, join will block until the thread exits. + """ + if self._stop_event is None or self._thread is None: + return + # signal stop and wait for thread to finish + self._stop_event.set() + self._thread.join(timeout=timeout) + # cleanup + if self._thread.is_alive(): + # thread did not stop within timeout; leave objects for another stop() + return + self._thread = None + self._stop_event = None \ No newline at end of file diff --git a/src-python/utils.py b/src-python/utils.py index fab62d51..1e250e87 100644 --- a/src-python/utils.py +++ b/src-python/utils.py @@ -1,12 +1,22 @@ import base64 -from typing import Any +from typing import Any, List, Dict, Optional import json import traceback import logging from logging.handlers import RotatingFileHandler -import torch -from ctranslate2 import get_supported_compute_types +try: + import torch +except Exception: + torch = None # type: ignore + +try: + from ctranslate2 import get_supported_compute_types +except Exception: + # Fallback: if ctranslate2 is not installed, provide a safe stub. + def get_supported_compute_types(device: str, device_index: int) -> List[str]: + return [] + import requests import ipaddress import socket @@ -47,40 +57,45 @@ def validateDictStructure(data: dict, structure: dict) -> bool: return True def isConnectedNetwork(url="http://www.google.com", timeout=3) -> bool: + """Quick network connectivity check by requesting `url`. + + Returns True when a 200 response is returned within `timeout` seconds. + """ try: response = requests.get(url, timeout=timeout) return response.status_code == 200 except requests.RequestException: return False -def isAvailableWebSocketServer(host:str, port:int) -> bool: - """WebSocketサーバーのポートが使用中かどうかを確認する""" - response = True +def isAvailableWebSocketServer(host: str, port: int) -> bool: + """Return True if the given host/port appear available for binding. + + Note: This attempts to bind a TCP socket to the address. If bind + succeeds the function returns True (meaning the address was available). + """ try: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as chk: - try: - # SO_REUSEADDRを設定してソケットの再利用を許可 - chk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - chk.bind((host, port)) - # シャットダウン前にリッスン状態にする必要はない - chk.close() - except Exception: - response = False + chk.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + chk.bind((host, port)) + return True except Exception: - errorLogging() - response = False - - return response + return False def isValidIpAddress(ip_address: str) -> bool: + """Return True if `ip_address` is a valid IPv4/IPv6 address.""" try: ipaddress.ip_address(ip_address) return True except ValueError: return False -def getComputeDeviceList() -> dict: - compute_types = [ +def getComputeDeviceList() -> List[Dict[str, Any]]: + """Return a list of available compute devices and supported compute types. + + The returned list contains dicts describing CPU and (if available) + CUDA devices. This function is defensive to missing optional packages. + """ + compute_types: List[Dict[str, Any]] = [ { "device": "cpu", "device_index": 0, @@ -89,32 +104,47 @@ def getComputeDeviceList() -> dict: } ] - if torch.cuda.is_available(): - for device_index in range(torch.cuda.device_count()): - gpu_device_name = torch.cuda.get_device_name(device_index) - gpu_compute_types = ["auto"] + list(get_supported_compute_types("cuda", device_index)) + try: + if torch is not None and hasattr(torch, "cuda") and torch.cuda.is_available(): + for device_index in range(torch.cuda.device_count()): + gpu_device_name = torch.cuda.get_device_name(device_index) + gpu_compute_types = ["auto"] + list(get_supported_compute_types("cuda", device_index)) - # デバイスごとの計算タイプの制限 - if "GTX" in gpu_device_name: - unsupported_types = {"int8_bfloat16", "bfloat16", "float16", "int8"} - gpu_compute_types = [t for t in gpu_compute_types if t not in unsupported_types] - elif not any(keyword in gpu_device_name for keyword in ["RTX", "Tesla", "A100", "Quadro"]): - gpu_compute_types = ["float32"] + # デバイスごとの計算タイプの制限 + if "GTX" in gpu_device_name: + unsupported_types = {"int8_bfloat16", "bfloat16", "float16", "int8"} + gpu_compute_types = [t for t in gpu_compute_types if t not in unsupported_types] + elif not any(keyword in gpu_device_name for keyword in ["RTX", "Tesla", "A100", "Quadro"]): + gpu_compute_types = ["float32"] - compute_types.append( - { - "device": "cuda", - "device_index": device_index, - "device_name": gpu_device_name, - "compute_types": gpu_compute_types, - } - ) + compute_types.append( + { + "device": "cuda", + "device_index": device_index, + "device_name": gpu_device_name, + "compute_types": gpu_compute_types, + } + ) + except Exception: + # If querying GPU devices fails, return at least the CPU entry + errorLogging() return compute_types def getBestComputeType(device: str, device_index: int) -> str: - compute_types = set(get_supported_compute_types(device, device_index)) - device_name = "cpu" if device == "cpu" else torch.cuda.get_device_name(device_index) + """Pick the best available compute type for a device. + + Falls back to "float32" when no preferred type is available. + """ + try: + compute_types = set(get_supported_compute_types(device, device_index)) + except Exception: + compute_types = set() + + try: + device_name = "cpu" if device == "cpu" else (torch.cuda.get_device_name(device_index) if torch is not None else "") + except Exception: + device_name = "" # デバイスごとの優先計算タイプ preferred_types = { @@ -141,14 +171,26 @@ def getBestComputeType(device: str, device_index: int) -> str: return "float32" -def encodeBase64(data:str) -> dict: - return json.loads(base64.b64decode(data).decode('utf-8')) +def encodeBase64(data: str) -> Dict[str, Any]: + """Decode a base64-encoded JSON string and return the parsed object. -def removeLog(): - with open('process.log', 'w', encoding="utf-8") as f: - f.write("") + Returns an empty dict on failure. + """ + try: + return json.loads(base64.b64decode(data).decode('utf-8')) + except Exception: + errorLogging() + return {} -def setupLogger(name, log_file, level=logging.INFO): +def removeLog() -> None: + """Truncate the process log file (process.log) if present.""" + try: + with open('process.log', 'w', encoding="utf-8") as f: + f.write("") + except Exception: + errorLogging() + +def setupLogger(name: str, log_file: str, level: int = logging.INFO) -> logging.Logger: """ 特定の名前とログファイルを持つロガーを設定します。 """ @@ -174,13 +216,17 @@ def setupLogger(name, log_file, level=logging.INFO): formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') file_handler.setFormatter(formatter) - # ロガーにハンドラーを追加 - logger.addHandler(file_handler) + # ロガーにハンドラーを追加(重複追加を避ける) + if not any(isinstance(h, RotatingFileHandler) and getattr(h, 'baseFilename', None) == getattr(file_handler, 'baseFilename', None) for h in logger.handlers): + logger.addHandler(file_handler) return logger -process_logger = None -def printLog(log:str, data:Any=None) -> None: +process_logger: Optional[logging.Logger] = None + + +def printLog(log: str, data: Any = None) -> None: + """Log and print a structured process log message.""" global process_logger if process_logger is None: process_logger = setupLogger("process", "process.log", logging.INFO) @@ -191,10 +237,14 @@ def printLog(log:str, data:Any=None) -> None: "data": str(data), } process_logger.info(response) - response = json.dumps(response) - print(response, flush=True) + serialized = json.dumps(response) + print(serialized, flush=True) -def printResponse(status:int, endpoint:str, result:Any=None) -> None: +def printResponse(status: int, endpoint: str, result: Any = None) -> None: + """Log and print a structured response object. + + If JSON serialization fails, record the error and emit a generic error payload. + """ global process_logger if process_logger is None: process_logger = setupLogger("process", "process.log", logging.INFO) @@ -208,28 +258,37 @@ def printResponse(status:int, endpoint:str, result:Any=None) -> None: try: serialized_response = json.dumps(response) - except OSError as e: - errorLogging() # Log the full traceback of the OSError - process_logger.error(f"Problematic response object before json.dumps: {response}") - process_logger.error(f"OSError during json.dumps: {e}") - # Optionally, print a generic error JSON to stdout if needed, or re-raise - # For now, we'll print a simple error message to stdout as a fallback + except Exception as e: + errorLogging() # Log the full traceback of the exception + try: + process_logger.error(f"Problematic response object before json.dumps: {response}") + process_logger.error(f"Exception during json.dumps: {e}") + except Exception: + pass + # Fallback generic error payload error_json = json.dumps({ "status": 500, "endpoint": endpoint, - "result": {"error": "Failed to serialize response due to OSError", "details": str(e)} + "result": {"error": "Failed to serialize response", "details": str(e)}, }) print(error_json, flush=True) else: print(serialized_response, flush=True) -error_logger = None +error_logger: Optional[logging.Logger] = None + + def errorLogging() -> None: + """Log the current exception traceback to the error logger.""" global error_logger if error_logger is None: error_logger = setupLogger("error", "error.log", logging.ERROR) - error_logger.error(traceback.format_exc()) + try: + error_logger.error(traceback.format_exc()) + except Exception: + # As a last resort, print the traceback to stdout + print(traceback.format_exc(), flush=True) if __name__ == "__main__": print(getComputeDeviceList()) \ No newline at end of file