From 2b6611ef8e47b13fe6d21b372f8e631041c1186c Mon Sep 17 00:00:00 2001 From: misyaguziya <53165965+misyaguziya@users.noreply.github.com> Date: Thu, 9 Oct 2025 21:47:19 +0900 Subject: [PATCH] =?UTF-8?q?Model=E3=82=AF=E3=83=A9=E3=82=B9=E3=81=AE?= =?UTF-8?q?=E5=88=9D=E6=9C=9F=E5=8C=96=E3=82=92=E9=81=85=E5=BB=B6=E3=81=95?= =?UTF-8?q?=E3=81=9B=E3=82=8B=E8=A8=AD=E8=A8=88=E3=81=AB=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=81=E5=90=84=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89?= =?UTF-8?q?=E3=81=A7=E3=81=AEensure=5Finitialized()=E5=91=BC=E3=81=B3?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=81=AB=E3=82=88=E3=81=A3=E3=81=A6=E5=BF=85?= =?UTF-8?q?=E8=A6=81=E6=99=82=E3=81=AB=E3=83=AA=E3=82=BD=E3=83=BC=E3=82=B9?= =?UTF-8?q?=E3=82=92=E5=88=9D=E6=9C=9F=E5=8C=96=E3=81=99=E3=82=8B=E3=82=88?= =?UTF-8?q?=E3=81=86=E3=81=AB=E4=BF=AE=E6=AD=A3=E3=80=82=E3=81=93=E3=82=8C?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8A=E3=80=81=E3=82=A4=E3=83=B3=E3=83=9D?= =?UTF-8?q?=E3=83=BC=E3=83=88=E6=99=82=E3=81=AE=E5=89=AF=E4=BD=9C=E7=94=A8?= =?UTF-8?q?=E3=82=92=E6=8A=91=E6=AD=A2=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-python/docs/modules/model.md | 13 +++ src-python/model.py | 156 +++++++++++++++++++++++++++---- 2 files changed, 151 insertions(+), 18 deletions(-) diff --git a/src-python/docs/modules/model.md b/src-python/docs/modules/model.md index 3cb331ad..391976e9 100644 --- a/src-python/docs/modules/model.md +++ b/src-python/docs/modules/model.md @@ -67,6 +67,19 @@ model.startWebSocketServer('127.0.0.1', 2231) ## 詳細設計 +### 2025-10-09 のリファクタリング要約 + +- 遅延初期化 (lazy-init): `Model` のコンストラクタで重い初期化を行わず、`model.init()` を明示的に呼ぶか、各メソッド先頭で呼ばれる `ensure_initialized()` によって必要時に初期化する設計に変更しました。これによりインポート時の副作用(外部環境依存の初期化)が抑止されます。 + +- `threadFnc` の堅牢化: スレッドユーティリティは args/kwargs をインスタンスで保持し、内部で発生する例外を捕捉して `utils.errorLogging()` に委ねるようになりました。これによりバックグラウンドスレッドが例外で終了するリスクを減らしています。 + +- `device_manager` 呼び出しのガード: `getListMicHost()` / `getListMicDevice()` / `getMicDefaultDevice()` / `getListSpeakerDevice()` など、`device_manager` を参照する箇所は try/except で保護され、失敗時は安全なデフォルト(空リストや `"NoDevice"`)を返すようになりました。 + +- WebSocket/Overlay/Watchdog 等の起動系メソッドは `ensure_initialized()` を先頭に呼ぶようになり、遅延初期化の恩恵を受けるようになっています。 + +これらの変更は非破壊で既存の API を維持することを目的としていますが、起動フローで確実にリソースを確保したい場合はアプリ起動時に `model.init()` を呼ぶことを推奨します。 + + 目的: 各モデル(翻訳/転写/Overlay/Watchdog/OSC/WebSocket 等)のインスタンスを保持し、高レベルの操作を提供するファサード。 主要クラス/変数: diff --git a/src-python/model.py b/src-python/model.py index e78ed857..140e45b5 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -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 @@ -109,11 +138,24 @@ class Model: # 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, @@ -129,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): @@ -144,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 @@ -198,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, @@ -225,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"] @@ -250,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"] @@ -266,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): @@ -287,14 +341,17 @@ 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) -> list: + self.ensure_initialized() if hiragana is False and romaji is False: return [] @@ -315,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: @@ -349,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 @@ -416,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 @@ -518,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(): @@ -532,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() @@ -565,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() @@ -578,6 +672,7 @@ class Model: # self.mic_get_energy = None 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) @@ -609,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() @@ -619,6 +715,7 @@ class Model: self.mic_energy_recorder = None 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() @@ -702,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() @@ -714,6 +812,7 @@ class Model: # self.speaker_get_energy = None 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) @@ -743,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() @@ -753,6 +853,7 @@ class Model: self.speaker_energy_recorder = None 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): @@ -760,6 +861,7 @@ class Model: 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", @@ -772,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 @@ -807,6 +912,7 @@ class Model: self.overlay.updateUiScaling(config.OVERLAY_SMALL_LOG_SETTINGS["ui_scaling"], size) 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): @@ -814,6 +920,7 @@ class Model: 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", @@ -831,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 @@ -865,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() @@ -893,6 +1009,7 @@ class Model: def startWebSocketServer(self, host, port): """WebSocketサーバーを起動し、別スレッドで実行する""" + self.ensure_initialized() if self.websocket_server_alive is True: # サーバーが既に起動している場合は何もしない return @@ -931,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 @@ -952,6 +1070,7 @@ class Model: def checkWebSocketServerAlive(self): """WebSocketサーバーの稼働状態を確認する""" + self.ensure_initialized() return self.websocket_server_alive def websocketSendMessage(self, message_dict:dict): @@ -960,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: