Merge branch 'refacta_backend' into develop

This commit is contained in:
misyaguziya
2025-10-14 12:14:28 +09:00
54 changed files with 16317 additions and 667 deletions

View File

@@ -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}")
print(f"An error occurred: {e}")
try:
main_instance.stop()
except Exception:
pass
finally:
try:
main_instance.stop()
except Exception:
pass

View File

@@ -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

View File

@@ -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:

View File

@@ -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)
print("DeviceManager demo. Call device_manager.init() and device_manager.startMonitoring() to run live monitoring.")
try:
while True:
sleep(1)
except KeyboardInterrupt:
print("exiting")

433
src-python/docs/config.md Normal file
View File

@@ -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 サーバー設定追加
- オーバーレイ設定の拡張

File diff suppressed because it is too large Load Diff

View File

@@ -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ステータス

View File

@@ -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"
- 送信言語: EnglishUS
- 受信言語: 日本語
### 音声認識設定
- 送信音声認識: 無効
- 受信音声認識: 無効
- 音声認識エンジン: "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キー等の機密情報の適切な取り扱い
- パスインジェクション攻撃の防止

View File

@@ -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キーの安全な管理
- ファイルアクセスの制限
- ネットワーク通信の暗号化(該当する場合)

View File

@@ -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データは自動的にデコードされる
- 長時間のブロッキング処理は他のリクエストに影響する可能性

View File

@@ -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サーバーは非同期で動作
- 音声デバイスのアクセス権限が必要
## パフォーマンス考慮事項
- 遅延初期化によるメモリ使用量の最適化
- スレッドプールによる並行処理
- モデルの重複読み込み防止
- キューイングによる非同期処理

View File

@@ -0,0 +1,602 @@
# osc.py - OSC通信・OSCQueryプロトコル管理
## 概要
VRChatとの高度なOSCOpen 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アバター固有パラメータ対応

View File

@@ -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プラットフォーム対応検討

View File

@@ -0,0 +1,229 @@
# transcription_languages.py - 音声認識言語マッピング
## 概要
音声認識エンジンが対応する言語コードのマッピングテーブルを提供するモジュールです。異なる音声認識エンジンの言語コード仕様の差異を吸収し、統一的なインターフェースを提供します。
## 主要機能
### 言語マッピングテーブル
- 表示用言語名から各エンジン固有の言語コードへの変換
- 国・地域固有の言語バリエーション対応
- 複数音声認識エンジンの統一的な言語管理
### 対応エンジン
- Google Speech Recognition
- OpenAI Whisperfaster-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制御

View File

@@ -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`: 録音制御統合

View File

@@ -0,0 +1,325 @@
# transcription_transcriber.py - 音声文字起こしエンジン
## 概要
音声データを文字テキストに変換する音声認識エンジンのメインクラスです。Google Speech RecognitionとOpenAI Whisperfaster-whisperの両方をサポートし、オンライン・オフラインの音声認識を統合的に管理します。キューベースの非同期処理により、リアルタイム音声認識を実現します。
## 主要機能
### 音声認識エンジン
- Google Speech Recognitionオンライン
- OpenAI Whisperfaster-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 Whisperfaster-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`: 音声認識統合制御

View File

@@ -0,0 +1,373 @@
# transcription_whisper.py - Whisperモデル管理
## 概要
OpenAI Whisperfaster-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統合制御

View File

@@ -0,0 +1,342 @@
# translation_languages.py - 翻訳言語マッピング
## 概要
翻訳エンジンが対応する言語コードのマッピングテーブルを提供するモジュールです。複数の翻訳エンジンDeepL、Google、Bing、Papago等の言語コード仕様の差異を吸収し、統一的な翻訳言語管理を実現します。
## 主要機能
### 多エンジン対応
- DeepL無料版・API版
- Google Translate
- Microsoft TranslatorBing
- 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制御

View File

@@ -0,0 +1,406 @@
# translation_translator.py - 翻訳エンジン統合クラス
## 概要
複数の翻訳エンジンを統合管理する高レベル翻訳インターフェースです。DeepL、Google、Bing、Papago、CTranslate2などの多様な翻訳サービスを統一的に扱い、エラー時の自動フォールバック機能と認証管理を提供します。
## 主要機能
### 多エンジン統合
- DeepL無料版・API版
- Google TranslateWebスクレイピング
- Microsoft TranslatorBing
- 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**: ~500MBCUDA使用時
- **速度**: 高速
- **精度**: 良好
#### large モデル
- **サイズ**: ~4.8GB
- **メモリ**: ~6GB RAM
- **VRAM**: ~3GBCUDA使用時
- **速度**: 中程度
- **精度**: 高品質
### 計算タイプ設定
```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`: 翻訳制御インターフェース

View File

@@ -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`: デバイス管理

View File

@@ -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`が空文字列になるため、呼び出し元での再計算が必要
- 現在のルールは日本語に特化している
- ルール適用順序は優先度に依存するため、適切な設定が重要
- 正規表現ルールはパフォーマンスに影響する可能性がある
## 将来の改善点
- 外部ルールファイルの読み込み対応
- より複雑な条件式のサポート
- ルール適用ログ・デバッグ機能
- 言語別ルールセットの対応
- パフォーマンス最適化とキャッシュ機能

View File

@@ -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"),
# ティ・トゥ・ドゥ
("ティー", ""),
("パーティー", "pātī"),
("トゥー", ""),
("ドゥー", "")
]
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`: 文脈依存ルール
- 外部のひらがな↔カタカナ変換モジュール(必要に応じて)
## 将来の改善点
- 更なる外来語音への対応
- 文脈依存の読み分け機能
- パフォーマンス最適化
- より詳細なヘボン式バリエーション対応
- 音韻変化ルールの追加

View File

@@ -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 = "ID12345、URLhttps://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 = [
# 基本的な文章
("向こうへ行く", "向こう", "ムコウ"),
("美しい花", "美しい", "ウツクシイ"),
# 文脈依存
("何度も", "", "ナン"),
("何が", "", "ナニ"),
# 外来語
("パーティー", "パーティー", "パーティー"),
("コンピューター", "コンピューター", "コンピューター"),
# 漢字・送り仮名
("取り敢えず", "取り", "トリ"),
("見知らぬ", "見知ら", "ミシラ"),
# 記号・英数字
("ID12345", "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`: ユーティリティ関数
## 将来の改善点
- カスタム読み辞書対応
- より高精度な文脈解析
- 他言語音写システムとの統合
- リアルタイム処理最適化
- 分散処理対応

View File

@@ -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秒のタイムアウト設定

View File

@@ -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インターフェースでの監視状態表示

View File

@@ -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サポート
- リアルタイム監視・分析機能の強化

File diff suppressed because it is too large Load Diff

346
src-python/docs/mainloop.md Normal file
View File

@@ -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 ベースプロトコルを実装する。複数のワーカースレッドと細粒度のロックにより、高い並列性と排他制御を両立させている。初期化シーケンスとエラーハンドリングは堅牢に設計されており、プロセスの安定稼働を保証する。

1277
src-python/docs/model.md Normal file

File diff suppressed because it is too large Load Diff

940
src-python/docs/utils.md Normal file
View File

@@ -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アドレス、ネットワーク接続の厳密なバリデーション
全てのサブシステムから依存される中核モジュールとして、高い信頼性と保守性を維持している。

View File

@@ -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 APIstdin/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 が通るか
- mypyrelaxedで重大な型エラーが出ていないか
- 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) の雛形を作成してコミットまでできます。どれを優先しますか?

View File

@@ -0,0 +1,58 @@
# 仕様書
概要
- プロジェクト名: VRCT (VR Chat Translator)
- 目的: マイク入力とスピーカー出力をリアルタイムに文字起こし・翻訳し、VR オーバーレイや OSC/WebSocket 経由で外部に送出するバックエンドロジック。
- 言語: Python
対象ユーザー
- VR 環境でリアルタイム翻訳・文字起こしを利用したいエンドユーザー
- フロントエンドGUIや VR クライアントOSCと連携するアプリケーション開発者
主要機能(機能要件)
1. 音声の取り込み・文字起こし
- マイク(送信)およびスピーカー(受信)から音声を取得し、ローカル Whisperfaster-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をローテーションで管理。
- ウォッチドッグ機構で定期的に死活チェック・コールバック。
非機能要件
- プラットフォーム: 主に WindowsAudio 周りは 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": <base64(JSON)|any>}
- stdout(JSON): 標準化されたレスポンスを printResponse/printLog が出力status, endpoint, result など)。
依存関係(オプション含む)
- 必須(実装時想定): requests, packaging, flashtext, pillow, pyaudiowpatch, speech_recognition
- ローカル推奨: faster-whisper, ctranslate2, torchGPU 利用時)
- Windows 固有(音声ループバック): pycaw, comtypes
参考: 実装上の安全設計として optional な import は try/except でガードしており、存在しない依存があっても import 時にクラッシュしない。

View File

@@ -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 を割り当てる。
- モデル内部では threadFncThread ラッパ)で周期的な送信処理や監視処理を実装。
- Audio 録音や文字起こしは専用の Queue を用い、ProducerRecorderと ConsumerAudioTranscriberを分離。
エラー処理
- すべての外周呼び出しは 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 で選択し、不適切な設定を検出した場合はフォールバック。

View File

@@ -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 に記録されるか。

View File

@@ -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)

View File

@@ -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:

View File

@@ -0,0 +1,5 @@
"""models package init for static analysis and packaging."""
__all__ = [
# subpackages are discovered implicitly
]

View File

@@ -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:

View File

@@ -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"]

View File

@@ -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()

View File

@@ -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"]

View File

@@ -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)]])

View File

@@ -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":{

View File

@@ -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,

View File

@@ -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

View File

@@ -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__":

View File

@@ -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,
}
translation_lang["CTranslate2"] = {"source": dict_ctranslate2_languages, "target": dict_ctranslate2_languages}

View File

@@ -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

View File

@@ -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)
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()

View File

@@ -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"):

View File

@@ -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)

View File

@@ -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()

View File

@@ -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)
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

View File

@@ -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())