diff --git a/locales/en.yml b/locales/en.yml index 1b08a31b..ab281794 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -264,6 +264,9 @@ config_page: right_hand: "Right Hand" overlay_show_only_translated_messages: label: "Show Only Translated Messages" + voice_typing_mode: + label: "Voice Typing Mode" + desc: "Automatically pastes transcribed text into the active input field.\n*Utilizes standard Windows clipboard functionality." others: section_label_sounds: "Sounds" diff --git a/locales/ja.yml b/locales/ja.yml index 51ae19f4..9fa47b64 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -264,6 +264,9 @@ config_page: right_hand: "右手" overlay_show_only_translated_messages: label: "翻訳後のメッセージのみ表示する" + voice_typing_mode: + label: "音声タイピングモード" + desc: "文字起こしされたテキストを、アクティブな入力欄へと自動的に貼り付けます。\n※Windows標準のクリップボード機能を利用しています。" others: section_label_sounds: "サウンド" diff --git a/locales/ko.yml b/locales/ko.yml index bc07d6c3..b4ccc401 100644 --- a/locales/ko.yml +++ b/locales/ko.yml @@ -264,6 +264,9 @@ config_page: right_hand: "오른손" overlay_show_only_translated_messages: label: "번역된 메시지만 표시" + voice_typing_mode: + label: + desc: others: section_label_sounds: "사운드" diff --git a/locales/zh-Hans.yml b/locales/zh-Hans.yml index c3403912..dd33585b 100644 --- a/locales/zh-Hans.yml +++ b/locales/zh-Hans.yml @@ -264,6 +264,9 @@ config_page: right_hand: "右手" overlay_show_only_translated_messages: label: "仅显示翻译后的消息" + voice_typing_mode: + label: + desc: others: section_label_sounds: "声音" diff --git a/locales/zh-Hant.yml b/locales/zh-Hant.yml index 8362972b..db73f559 100644 --- a/locales/zh-Hant.yml +++ b/locales/zh-Hant.yml @@ -264,6 +264,9 @@ config_page: right_hand: "右手" overlay_show_only_translated_messages: label: "僅顯示翻譯後的訊息" + voice_typing_mode: + label: + desc: others: section_label_sounds: "音效" diff --git a/src-python/config.py b/src-python/config.py index db28f83b..fc747db9 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -759,6 +759,9 @@ class Config: SELECTED_TRANSLATION_COMPUTE_DEVICE = ValidatedProperty('SELECTED_TRANSLATION_COMPUTE_DEVICE', _compute_device_validator) SELECTED_TRANSCRIPTION_COMPUTE_DEVICE = ValidatedProperty('SELECTED_TRANSCRIPTION_COMPUTE_DEVICE', _compute_device_validator) + # -- Clipboard control --- + ENABLE_CLIPBOARD = ManagedProperty('ENABLE_CLIPBOARD', type_=bool) + def init_config(self): # Read Only self._VERSION = "3.3.2" @@ -1025,11 +1028,11 @@ class Config: self._WEBSOCKET_SERVER = False self._WEBSOCKET_HOST = "127.0.0.1" self._WEBSOCKET_PORT = 2231 - - ## Telemetry - self._ENABLE_TELEMETRY = True # デフォルト有効 + self._ENABLE_CLIPBOARD = False + self._ENABLE_TELEMETRY = True def load_config(self): + self._config_data = {} if os_path.isfile(self.PATH_CONFIG) is not False: with open(self.PATH_CONFIG, 'r', encoding="utf-8") as fp: if fp.readable() and fp.seek(0, 2) > 0: @@ -1054,6 +1057,7 @@ class Config: continue except Exception: errorLogging() + self.saveConfigToFile() def revalidate_selected_models(self): diff --git a/src-python/controller.py b/src-python/controller.py index 372a0292..c3faef0a 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -422,6 +422,11 @@ class Controller: ) model.updateOverlayLargeLog(overlay_image) + if config.ENABLE_CLIPBOARD is True: + clipboard_message = self.messageFormatter("SEND", translation, message) + model.setCopyToClipboard(clipboard_message) + model.setPasteFromClipboard() + if model.checkWebSocketServerAlive() is True: model.websocketSendMessage( { @@ -774,6 +779,11 @@ class Controller: ) model.updateOverlayLargeLog(overlay_image) + if config.ENABLE_CLIPBOARD is True: + clipboard_message = self.messageFormatter("SEND", translation, message) + model.setCopyToClipboard(clipboard_message) + model.setPasteFromClipboard() + if model.checkWebSocketServerAlive() is True: model.websocketSendMessage( { @@ -3068,6 +3078,23 @@ class Controller: model.stopWebSocketServer() return {"status":200, "result":config.WEBSOCKET_SERVER} + # Clipboard control + @staticmethod + def getClipboard(*args, **kwargs) -> dict: + return {"status":200, "result":config.ENABLE_CLIPBOARD} + + @staticmethod + def setEnableClipboard(*args, **kwargs) -> dict: + if config.ENABLE_CLIPBOARD is False: + config.ENABLE_CLIPBOARD = True + return {"status":200, "result":config.ENABLE_CLIPBOARD} + + @staticmethod + def setDisableClipboard(*args, **kwargs) -> dict: + if config.ENABLE_CLIPBOARD is True: + config.ENABLE_CLIPBOARD = False + return {"status":200, "result":config.ENABLE_CLIPBOARD} + def initializationProgress(self, progress): self.run(200, self.run_mapping["initialization_progress"], progress) diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 8ad4ee66..a8067431 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -407,6 +407,11 @@ mapping = { "/set/enable/websocket_server": {"status": True, "variable":controller.setEnableWebSocketServer}, "/set/disable/websocket_server": {"status": True, "variable":controller.setDisableWebSocketServer}, + # Clipboard Settings + "/get/data/clipboard": {"status": True, "variable":controller.getClipboard}, + "/set/enable/clipboard": {"status": True, "variable":controller.setEnableClipboard}, + "/set/disable/clipboard": {"status": True, "variable":controller.setDisableClipboard}, + # Advanced Settings "/get/data/osc_ip_address": {"status": True, "variable":controller.getOscIpAddress}, "/set/data/osc_ip_address": {"status": True, "variable":controller.setOscIpAddress}, diff --git a/src-python/model.py b/src-python/model.py index 0012e077..0632396c 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -32,6 +32,7 @@ from models.overlay.overlay import Overlay from models.overlay.overlay_image import OverlayImage from models.watchdog.watchdog import Watchdog from models.websocket.websocket_server import WebSocketServer +from models.clipboard.clipboard import Clipboard from models.telemetry import Telemetry from utils import errorLogging, setupLogger @@ -141,8 +142,7 @@ class Model: # default no-op callbacks for energy check functions self.check_mic_energy_fnc: Callable[[float], None] = lambda v: None self.check_speaker_energy_fnc: Callable[[float], None] = lambda v: None - - # Telemetry 初期化(Model 内でインスタンスを保持) + self.clipboard = Clipboard() self.telemetry = Telemetry() self._inited = True @@ -1296,6 +1296,29 @@ class Model: errorLogging() return False + def setCopyToClipboard(self, text:str) -> bool: + self.ensure_initialized() + try: + if isinstance(self.clipboard, Clipboard): + self.clipboard.copy(text) + return True + else: + return False + except Exception: + errorLogging() + return False + + def setPasteFromClipboard(self) -> bool: + self.ensure_initialized() + try: + if isinstance(self.clipboard, Clipboard): + return self.clipboard.paste() + else: + return False + except Exception: + errorLogging() + return False + def telemetryInit(self, enabled: bool, app_version: str): """Model 内で Telemetry を初期化""" self.telemetry.init(enabled=enabled, app_version=app_version) diff --git a/src-python/models/clipboard/clipboard.py b/src-python/models/clipboard/clipboard.py new file mode 100644 index 00000000..a26b536d --- /dev/null +++ b/src-python/models/clipboard/clipboard.py @@ -0,0 +1,275 @@ +import sys +import time +import os +import threading +from subprocess import Popen, PIPE +from psutil import process_iter +import openvr + +try: + from utils import printLog +except ImportError: + def printLog(data, *args, **kwargs): + print(data, *args, **kwargs) + +def checkSteamvrRunning() -> bool: + _proc_name = "vrmonitor.exe" if os.name == "nt" else "vrmonitor" + return _proc_name in (p.name() for p in process_iter()) + +# Windows-specific imports via ctypes will be used when focusing windows +if sys.platform == 'win32': + import ctypes + import ctypes.wintypes as wintypes + user32 = ctypes.WinDLL('user32', use_last_error=True) + + def find_windows_by_title_substring(substring: str): + HWND = wintypes.HWND + callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, HWND, wintypes.LPARAM) + found = [] + + def _cb(hwnd, lParam): + length = user32.GetWindowTextLengthW(hwnd) + if length == 0: + return True + buf = ctypes.create_unicode_buffer(length + 1) + # fill buffer with window title + user32.GetWindowTextW(hwnd, buf, length + 1) + title = buf.value + if substring.lower() in title.lower(): + found.append(hwnd) + return True + + user32.EnumWindows(callback_type(_cb), 0) + return found + + def find_windows_by_process_name(proc_name: str): + # iterate windows and match process id to name + HWND = wintypes.HWND + callback_type = ctypes.WINFUNCTYPE(wintypes.BOOL, HWND, wintypes.LPARAM) + found = [] + + def _cb(hwnd, lParam): + pid = wintypes.DWORD() + user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + try: + import psutil + except Exception: + return True + try: + p = psutil.Process(pid.value) + if p.name().lower() == proc_name.lower(): + found.append(hwnd) + except Exception: + pass + return True + + user32.EnumWindows(callback_type(_cb), 0) + return found + + def focus_window(hwnd) -> bool: + try: + # Restore and set foreground + SW_RESTORE = 9 + user32.ShowWindow(hwnd, SW_RESTORE) + res = user32.SetForegroundWindow(hwnd) + return bool(res) + except Exception: + return False + +def copy_to_clipboard_windows(text: str) -> bool: + try: + p = Popen(['clip'], stdin=PIPE, shell=False) + # Write as UTF-16LE with BOM so Windows clipboard receives correct Unicode + bom_utf16le = b"\xff\xfe" + p.communicate(bom_utf16le + text.encode('utf-16le')) + return True + except Exception: + return False + +def copy_to_clipboard_pyperclip(text: str) -> bool: + try: + import pyperclip + pyperclip.copy(text) + return True + except Exception: + return False + +def copy_to_clipboard_tk(text: str) -> bool: + try: + import tkinter as tk + r = tk.Tk() + r.withdraw() + r.clipboard_clear() + r.clipboard_append(text) + r.update() + r.destroy() + return True + except Exception: + return False + +def copy_to_clipboard(text: str) -> bool: + if sys.platform == 'win32': + if copy_to_clipboard_windows(text): + return True + if copy_to_clipboard_pyperclip(text): + return True + if copy_to_clipboard_tk(text): + return True + return False + +def paste_via_pyautogui(countdown: int = 0) -> bool: + try: + import pyautogui + except Exception: + printLog('pyautogui not installed. Install with: pip install pyautogui') + return False + + for i in range(countdown, 0, -1): + print(i, end=' ', flush=True) + time.sleep(1) + + try: + # pyautogui.hotkey is a safe cross-platform way to send keys + pyautogui.hotkey('ctrl', 'v') + return True + except Exception as e: + printLog(f'pyautogui failed to send hotkey: {e}') + return False + + +class Clipboard: + def __init__(self): + self.is_enabled = True + self._vr_monitor_thread = None + self._stop_monitoring = False + self.app_name = None + + self._initialize() + + def _initialize(self): + """Initialize clipboard by starting VR monitor thread.""" + self._stop_monitoring = False + self._vr_monitor_thread = threading.Thread(target=self._monitor_steamvr, daemon=True) + self._vr_monitor_thread.start() + self.app_name = None + printLog("Clipboard initialized. Waiting for SteamVR.") + + def _monitor_steamvr(self): + """Monitor SteamVR startup in background thread.""" + printLog("Clipboard: VR monitor thread started.") + while not self._stop_monitoring: + if checkSteamvrRunning(): + printLog("Clipboard: SteamVR detected. Setting up app info.") + self._setup_vr_app_name() + break + time.sleep(10) + printLog("Clipboard: VR monitor thread ended.") + + def _setup_vr_app_name(self): + """Setup VR application name from OpenVR.""" + try: + openvr.init(openvr.VRApplication_Background) + apps = openvr.VRApplications() + + app_count = apps.getApplicationCount() + running_apps = [] + + for i in range(app_count): + key = apps.getApplicationKeyByIndex(i) + name = apps.getApplicationPropertyString( + key, + openvr.VRApplicationProperty_Name_String + ) + running_apps.append((key, name)) + + self.app_name = None + for key, name in running_apps: + if key.startswith("steam.app"): + self.app_name = name + break + openvr.shutdown() + except Exception as e: + printLog(f"Clipboard: Error setting up VR app name: {e}") + self.app_name = None + + def enable(self): + """Enable clipboard functionality. Reinitialize the class.""" + printLog("Clipboard: Enabling clipboard functionality.") + self.is_enabled = True + self._initialize() + + def disable(self): + """Disable clipboard functionality. Stop VR monitoring.""" + printLog("Clipboard: Disabling clipboard functionality.") + self.is_enabled = False + self._stop_monitoring = True + if self._vr_monitor_thread is not None and self._vr_monitor_thread.is_alive(): + self._vr_monitor_thread.join(timeout=1) + self._vr_monitor_thread = None + + def copy(self, message: str) -> bool: + """Copy `message` to clipboard. + + Args: + message: Text to copy. + + Returns: + True if copy succeeded, False otherwise. + """ + if not self.is_enabled: + return False + return copy_to_clipboard(message) + + def paste(self, window_name: str|None = None, countdown: int = 0) -> bool: + """Focus a window identified by `window_name`, then paste via Ctrl+V. + + Args: + window_name: Window title substring or process name to find and focus (Windows only). Required. + countdown: Seconds to wait before sending paste key. + + Returns: + True if paste command was sent, False otherwise. + """ + if not self.is_enabled: + return False + + window_name = window_name if window_name is not None else self.app_name + + # If window_name is provided, attempt to focus it (Windows only). + # If window_name is None, skip focusing and paste into the currently focused window. + if window_name is not None and sys.platform == 'win32': + printLog(f"paste: attempting to focus window matching '{window_name}'") + focused = False + + # try title substring match first + wins = find_windows_by_title_substring(window_name) + printLog(f"paste: found {wins} windows matching title substring '{window_name}'") + for hwnd in wins: + if focus_window(hwnd): + focused = True + break + + # if not found by title, try treating window_name as process name + if not focused: + wins = find_windows_by_process_name(window_name) + printLog(f"paste: found {wins} windows matching process name '{window_name}'") + for hwnd in wins: + if focus_window(hwnd): + focused = True + break + + if not focused: + printLog(f"copy_and_paste: no window found matching '{window_name}'") + return False + + # small delay to allow focus to settle + time.sleep(0.2) + + # paste + pasted = paste_via_pyautogui(countdown) + return bool(pasted) + +if __name__ == '__main__': + clipboard = Clipboard() + clipboard.copy("Sample text to copy to clipboard.") + clipboard.paste(window_name=None, countdown=3) \ No newline at end of file diff --git a/src-ui/logics/configs/config_page_setter/ui_config_setter.js b/src-ui/logics/configs/config_page_setter/ui_config_setter.js index 0ffb7a89..ae69cc6e 100644 --- a/src-ui/logics/configs/config_page_setter/ui_config_setter.js +++ b/src-ui/logics/configs/config_page_setter/ui_config_setter.js @@ -611,6 +611,14 @@ export const SETTINGS_ARRAY = [ logics_template_id: "toggle_enable_disable", base_endpoint_name: "overlay_show_only_translated_messages", }, + { + Category: "Vr", + Base_Name: "VoiceTypingMode", + default_value: false, + ui_template_id: "toggle", + logics_template_id: "toggle_enable_disable", + base_endpoint_name: "clipboard", + }, // Others { diff --git a/src-ui/views/app/config_page/setting_section/setting_box/vr/Vr.jsx b/src-ui/views/app/config_page/setting_section/setting_box/vr/Vr.jsx index f8e6173c..dd746045 100644 --- a/src-ui/views/app/config_page/setting_section/setting_box/vr/Vr.jsx +++ b/src-ui/views/app/config_page/setting_section/setting_box/vr/Vr.jsx @@ -524,7 +524,12 @@ const OtherControls = ({settings, onchangeFunction, ui_configs}) => { const CommonSettingsContainer = () => { const { t } = useI18n(); - const { currentOverlayShowOnlyTranslatedMessages, toggleOverlayShowOnlyTranslatedMessages } = useVr(); + const { + currentOverlayShowOnlyTranslatedMessages, + toggleOverlayShowOnlyTranslatedMessages, + currentVoiceTypingMode, + toggleVoiceTypingMode, + } = useVr(); return (
@@ -534,6 +539,12 @@ const CommonSettingsContainer = () => { variable={currentOverlayShowOnlyTranslatedMessages} toggleFunction={toggleOverlayShowOnlyTranslatedMessages} /> +
); };