diff --git a/.gitignore b/.gitignore index e628117a..6ff13fd6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ weights/ error.log *.exe *.ipynb - +VRCT.zip +VRCT_cuda.zip # Added by WebUI migration # Logs diff --git a/backend.spec b/backend.spec index 33ec3cae..3933d942 100644 --- a/backend.spec +++ b/backend.spec @@ -5,7 +5,7 @@ a = Analysis( ['src-python\\mainloop.py'], pathex=[], binaries=[], - datas=[('./fonts', 'fonts/'), ('.venv/Lib/site-packages/zeroconf', 'zeroconf/'), ('.venv/Lib/site-packages/openvr', 'openvr/'), ('.venv/Lib/site-packages/pykakasi', 'pykakasi/'), ('.venv/Lib/site-packages/faster_whisper', 'faster_whisper/')], + datas=[('./fonts', 'fonts/'), ('.venv/Lib/site-packages/zeroconf', 'zeroconf/'), ('.venv/Lib/site-packages/openvr', 'openvr/'), ('.venv/Lib/site-packages/pykakasi', 'pykakasi/'), ('.venv/Lib/site-packages/faster_whisper', 'faster_whisper/'), ('.venv/Lib/site-packages/hf_xet', 'hf_xet/')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/backend_cuda.spec b/backend_cuda.spec index 34ed248f..08ba5fd7 100644 --- a/backend_cuda.spec +++ b/backend_cuda.spec @@ -5,7 +5,7 @@ a = Analysis( ['src-python\\mainloop.py'], pathex=[], binaries=[], - datas=[('./fonts', 'fonts/'), ('.venv_cuda/Lib/site-packages/zeroconf', 'zeroconf/'), ('.venv_cuda/Lib/site-packages/openvr', 'openvr/'), ('.venv_cuda/Lib/site-packages/pykakasi', 'pykakasi/'), ('.venv_cuda/Lib/site-packages/faster_whisper', 'faster_whisper/')], + datas=[('./fonts', 'fonts/'), ('.venv_cuda/Lib/site-packages/zeroconf', 'zeroconf/'), ('.venv_cuda/Lib/site-packages/openvr', 'openvr/'), ('.venv_cuda/Lib/site-packages/pykakasi', 'pykakasi/'), ('.venv_cuda/Lib/site-packages/faster_whisper', 'faster_whisper/'), ('.venv/Lib/site-packages/hf_xet', 'hf_xet/')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/build.bat b/build.bat index 89ee8cbb..3c08629c 100644 --- a/build.bat +++ b/build.bat @@ -1,2 +1,2 @@ call .venv/Scripts/activate -pyinstaller backend.spec --distpath src-tauri/bin --clean --noconfirm \ No newline at end of file +pyinstaller backend.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/build_cuda.bat b/build_cuda.bat index 73c85676..308b9174 100644 --- a/build_cuda.bat +++ b/build_cuda.bat @@ -1,2 +1,2 @@ call .venv_cuda/Scripts/activate -pyinstaller backend_cuda.spec --distpath src-tauri/bin --clean --noconfirm \ No newline at end of file +pyinstaller backend_cuda.spec --distpath src-tauri/bin --clean --noconfirm --log-level ERROR \ No newline at end of file diff --git a/install.bat b/install.bat index 5a57e817..aeb3d9b5 100644 --- a/install.bat +++ b/install.bat @@ -1,10 +1,25 @@ -python -m venv .venv -python -m venv .venv_cuda +REM .venv exists +if exist .venv ( + rmdir /s /q .venv +) +REM make .venv +python -m venv .venv + +REM install packages for .venv call .venv/Scripts/activate python.exe -m pip install --upgrade pip pip install --no-cache-dir --force-reinstall -r requirements.txt +REM if .venv_cuda exists +if exist .venv_cuda ( + rmdir /s /q .venv_cuda +) + +REM make .venv_cuda +python -m venv .venv_cuda + +REM install packages for .venv_cuda call .venv_cuda/Scripts/activate python.exe -m pip install --upgrade pip pip install --no-cache-dir --force-reinstall -r requirements_cuda.txt \ No newline at end of file diff --git a/locales/en.yml b/locales/en.yml index 9c45be98..5138f697 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -24,6 +24,9 @@ common_error: invalid_value_speaker_phrase_timeout: "It cannot be set lower than '{{speaker_record_timeout_label}}' with a value of 0 or more." invalid_value_speaker_max_phrase: "You can set a number equal to or greater than 0." +common_warning: + unable_to_use_osc_query: "The functions below have been automatically disabled because receiving OSC data is not possible due to OSC IP Address settings." + main_page: translation: "Translation" transcription_send: "Voice2Chatbox" @@ -257,6 +260,12 @@ config_page: label: "Open Config File" switch_compute_device: label: "Switch VRCT To CPU/GPU Version" + enable_websocket: + label: "Enable WebSocket Server" + websocket_host: + label: "WebSocket Host" + websocket_port: + label: "WebSocket Port" plugin_notifications: downloading: Downloading the plugin. diff --git a/locales/ja.yml b/locales/ja.yml index 8241d324..a9a6901b 100644 --- a/locales/ja.yml +++ b/locales/ja.yml @@ -24,10 +24,13 @@ common_error: invalid_value_speaker_phrase_timeout: "0 以上で 「{{speaker_record_timeout_label}}」 より小さくすることはできません。" invalid_value_speaker_max_phrase: "0 以上の数値を設定できます。" +common_warning: + unable_to_use_osc_query: "OSC IP Address の設定によりOSCデータの受信ができないため、以下の機能が自動的に無効になっています。" + main_page: translation: "翻訳" - transcription_send: "音声認識 マイク" - transcription_receive: "音声認識 スピーカー" + transcription_send: "マイク入力" + transcription_receive: "聞き取り" foreground: "最前面固定" language_settings: "言語設定" @@ -131,7 +134,7 @@ config_page: ctranslate2_compute_device: label: "AI翻訳 {{ctranslate2}} の処理デバイス" deepl_auth_key: - label: "DeepL API 認証キー" + label: "DeepL APIキーの登録" desc: "使用の際は、メイン画面にある {{translator}} をDeepL_APIに変更してください。\n※対応していない言語もあります。" open_auth_key_webpage: "DeepLアカウントページを開く" save: "保存" @@ -257,7 +260,12 @@ config_page: label: "設定ファイルを開く" switch_compute_device: label: "VRCT CPU/GPUバージョンの切り替え" - + enable_websocket: + label: "WebSocketサーバーを有効にする" + websocket_host: + label: "WebSocket Host" + websocket_port: + label: "WebSocket Port" plugin_notifications: downloading: プラグインをダウンロード中。 diff --git a/requirements.txt b/requirements.txt index 93733326..244a9238 100644 --- a/requirements.txt +++ b/requirements.txt @@ -15,6 +15,10 @@ pydub==0.25.1 psutil==5.9.8 pykakasi==2.3.0 pycaw==20240210 +websockets==15.0.1 +huggingface_hub==0.32.2 +hf-xet==1.1.2 +setuptools==80.8.0 translators @ git+https://github.com/misyaguziya/translators@5.9.2.1 SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1 tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 \ No newline at end of file diff --git a/requirements_cuda.txt b/requirements_cuda.txt index 898364b7..4dadf192 100644 --- a/requirements_cuda.txt +++ b/requirements_cuda.txt @@ -16,6 +16,10 @@ pydub==0.25.1 psutil==5.9.8 pykakasi==2.3.0 pycaw==20240210 +websockets==15.0.1 +huggingface_hub==0.32.2 +hf-xet==1.1.2 +setuptools==80.8.0 translators @ git+https://github.com/misyaguziya/translators@5.9.2.1 SpeechRecognition @ git+https://github.com/misyaguziya/custom_speech_recognition@3.10.4.1 tinyoscquery @ git+https://github.com/cyberkitsune/tinyoscquery@0.1.3 \ No newline at end of file diff --git a/src-python/config.py b/src-python/config.py index 2f8471e2..64c09440 100644 --- a/src-python/config.py +++ b/src-python/config.py @@ -954,9 +954,42 @@ class Config: self._NOTIFICATION_VRC_SFX = value self.saveConfig(inspect.currentframe().f_code.co_name, value) + @property + def WEBSOCKET_SERVER(self): + return self._WEBSOCKET_SERVER + + @WEBSOCKET_SERVER.setter + def WEBSOCKET_SERVER(self, value): + if isinstance(value, bool): + self._WEBSOCKET_SERVER = value + self.saveConfig(inspect.currentframe().f_code.co_name, value) + + @property + @json_serializable('WEBSOCKET_HOST') + def WEBSOCKET_HOST(self): + return self._WEBSOCKET_HOST + + @WEBSOCKET_HOST.setter + def WEBSOCKET_HOST(self, value): + if isinstance(value, str): + self._WEBSOCKET_HOST = value + self.saveConfig(inspect.currentframe().f_code.co_name, value) + + @property + @json_serializable('WEBSOCKET_PORT') + def WEBSOCKET_PORT(self): + return self._WEBSOCKET_PORT + + @WEBSOCKET_PORT.setter + def WEBSOCKET_PORT(self, value): + if isinstance(value, int): + self._WEBSOCKET_PORT = value + self.saveConfig(inspect.currentframe().f_code.co_name, value) + + def init_config(self): # Read Only - self._VERSION = "3.1.2" + self._VERSION = "3.2.0" if getattr(sys, 'frozen', False): self._PATH_LOCAL = os_path.dirname(sys.executable) else: @@ -1139,6 +1172,9 @@ class Config: self._LOGGER_FEATURE = False self._VRC_MIC_MUTE_SYNC = False self._NOTIFICATION_VRC_SFX = True + self._WEBSOCKET_SERVER = False + self._WEBSOCKET_HOST = "127.0.0.1" + self._WEBSOCKET_PORT = 2231 def load_config(self): if os_path.isfile(self.PATH_CONFIG) is not False: diff --git a/src-python/controller.py b/src-python/controller.py index 16c2f53f..89a07d3a 100644 --- a/src-python/controller.py +++ b/src-python/controller.py @@ -1,4 +1,4 @@ -from typing import Callable, Union, Any +from typing import Callable, Any from time import sleep from subprocess import Popen from threading import Thread @@ -6,7 +6,7 @@ import re from device_manager import device_manager from config import config from model import model -from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValidIpAddress +from utils import removeLog, printLog, errorLogging, isConnectedNetwork, isValidIpAddress, isAvailableWebSocketServer class Controller: def __init__(self) -> None: @@ -294,6 +294,19 @@ class Controller: "translation":translation, "transliteration":transliteration }) + + if model.checkWebSocketServerAlive() is True: + model.websocketSendMessage( + { + "type":"SENT", + "src_languages":config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + "dst_languages":config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + "message":message, + "translation":translation, + "transliteration":transliteration + } + ) + if config.LOGGER_FEATURE is True: if len(translation) > 0: translation = " (" + "/".join(translation) + ")" @@ -377,6 +390,19 @@ class Controller: "translation":translation, "transliteration":transliteration, }) + + if model.checkWebSocketServerAlive() is True: + model.websocketSendMessage( + { + "type":"RECEIVED", + "src_languages":config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + "dst_languages":config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + "message":message, + "translation":translation, + "transliteration":transliteration + } + ) + if config.LOGGER_FEATURE is True: if len(translation) > 0: translation = " (" + "/".join(translation) + ")" @@ -434,11 +460,23 @@ class Controller: overlay_image = model.createOverlayImageLargeLog("send", message, translation[0] if len(translation) > 0 else "") model.updateOverlayLargeLog(overlay_image) - # update textbox message log (Sent) + if model.checkWebSocketServerAlive() is True: + model.websocketSendMessage( + { + "type":"CHAT", + "src_languages":config.SELECTED_YOUR_LANGUAGES[config.SELECTED_TAB_NO], + "dst_languages":config.SELECTED_TARGET_LANGUAGES[config.SELECTED_TAB_NO], + "message":message, + "translation":translation, + "transliteration":transliteration + } + ) + + # update textbox message log (Chat) if config.LOGGER_FEATURE is True: if len(translation) > 0: translation_text = " (" + "/".join(translation) + ")" - model.logger.info(f"[SENT] {message}{translation_text}") + model.logger.info(f"[CHAT] {message}{translation_text}") return {"status":200, "result":{ @@ -1099,8 +1137,7 @@ class Controller: def getOscIpAddress(*args, **kwargs) -> dict: return {"status":200, "result":config.OSC_IP_ADDRESS} - @staticmethod - def setOscIpAddress(data, *args, **kwargs) -> dict: + def setOscIpAddress(self, data, *args, **kwargs) -> dict: if isValidIpAddress(data) is False: response = { "status":400, @@ -1113,6 +1150,11 @@ class Controller: try: model.setOscIpAddress(data) config.OSC_IP_ADDRESS = data + if model.getIsOscQueryEnabled() is True: + self.enableOscQuery() + else: + self.setDisableVrcMicMuteSync() + self.disableOscQuery() response = {"status":200, "result":config.OSC_IP_ADDRESS} except Exception: model.setOscIpAddress(config.OSC_IP_ADDRESS) @@ -1388,10 +1430,20 @@ class Controller: @staticmethod def setEnableVrcMicMuteSync(*args, **kwargs) -> dict: - config.VRC_MIC_MUTE_SYNC = True - model.setMuteSelfStatus() - model.changeMicTranscriptStatus() - return {"status":200, "result":config.VRC_MIC_MUTE_SYNC} + if model.getIsOscQueryEnabled() is True: + config.VRC_MIC_MUTE_SYNC = True + model.setMuteSelfStatus() + model.changeMicTranscriptStatus() + response = {"status":200, "result":config.VRC_MIC_MUTE_SYNC} + else: + response = { + "status":400, + "result":{ + "message":"Cannot enable VRC mic mute sync while OSC query is disabled", + "data": config.VRC_MIC_MUTE_SYNC + } + } + return response @staticmethod def setDisableVrcMicMuteSync(*args, **kwargs) -> dict: @@ -1778,9 +1830,117 @@ class Controller: model.stopWatchdog() return {"status":200, "result":True} + @staticmethod + def getWebSocketHost(*args, **kwargs) -> dict: + return {"status":200, "result":config.WEBSOCKET_HOST} + + @staticmethod + def setWebSocketHost(data, *args, **kwargs) -> dict: + if isValidIpAddress(data) is False: + response = { + "status":400, + "result":{ + "message":"Invalid IP address", + "data": config.WEBSOCKET_HOST + } + } + else: + if model.checkWebSocketServerAlive() is False: + config.WEBSOCKET_HOST = data + response = {"status":200, "result":config.WEBSOCKET_HOST} + else: + if data == config.WEBSOCKET_HOST: + response = {"status":200, "result":config.WEBSOCKET_HOST} + elif isAvailableWebSocketServer(data, config.WEBSOCKET_PORT): + model.stopWebSocketServer() + model.startWebSocketServer(data, config.WEBSOCKET_PORT) + config.WEBSOCKET_HOST = data + response = {"status":200, "result":config.WEBSOCKET_HOST} + else: + response = { + "status":400, + "result":{ + "message":"WebSocket server host is not available", + "data": config.WEBSOCKET_HOST + } + } + + return response + + @staticmethod + def getWebSocketPort(*args, **kwargs) -> dict: + return {"status":200, "result":config.WEBSOCKET_PORT} + + @staticmethod + def setWebSocketPort(data, *args, **kwargs) -> dict: + if model.checkWebSocketServerAlive() is False: + config.WEBSOCKET_PORT = int(data) + response = {"status":200, "result":config.WEBSOCKET_PORT} + else: + if int(data) == config.WEBSOCKET_PORT: + return {"status":200, "result":config.WEBSOCKET_PORT} + elif isAvailableWebSocketServer(config.WEBSOCKET_HOST, int(data)) is True: + model.stopWebSocketServer() + model.startWebSocketServer(config.WEBSOCKET_HOST, int(data)) + config.WEBSOCKET_PORT = int(data) + response = {"status":200, "result":config.WEBSOCKET_PORT} + else: + response = { + "status":400, + "result":{ + "message":"WebSocket server port is not available", + "data": config.WEBSOCKET_PORT + } + } + return response + + @staticmethod + def getWebSocketServer(*args, **kwargs) -> dict: + return {"status":200, "result":config.WEBSOCKET_SERVER} + + @staticmethod + def setEnableWebSocketServer(*args, **kwargs) -> dict: + if isAvailableWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) is True: + model.startWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) + config.WEBSOCKET_SERVER = True + response = {"status":200, "result":config.WEBSOCKET_SERVER} + else: + response = { + "status":400, + "result":{ + "message":"WebSocket server host or port is not available", + "data": config.WEBSOCKET_SERVER + } + } + return response + + @staticmethod + def setDisableWebSocketServer(*args, **kwargs) -> dict: + config.WEBSOCKET_SERVER = False + model.stopWebSocketServer() + return {"status":200, "result":config.WEBSOCKET_SERVER} + def initializationProgress(self, progress): self.run(200, self.run_mapping["initialization_progress"], progress) + def enableOscQuery(self): + self.run( + 200, + self.run_mapping["enable_osc_query"], + { + "data": True, + "disabled_functions": [] + } + ) + + def disableOscQuery(self): + self.run(200, self.run_mapping["enable_osc_query"], { + "data": False, + "disabled_functions": [ + "vrc_mic_mute_sync", + ] + }) + def init(self, *args, **kwargs) -> None: removeLog() printLog("Start Initialization") @@ -1892,6 +2052,14 @@ class Controller: # init OSC receive printLog("Init OSC Receive") model.startReceiveOSC() + osc_query_enabled = model.getIsOscQueryEnabled() + if osc_query_enabled is True: + self.enableOscQuery() + else: + # OSC Query is disabled, so disable VRC some features + config.VRC_MIC_MUTE_SYNC = False + self.disableOscQuery() + if config.VRC_MIC_MUTE_SYNC is True: self.setEnableVrcMicMuteSync() @@ -1911,6 +2079,15 @@ class Controller: if (config.OVERLAY_SMALL_LOG is True or config.OVERLAY_LARGE_LOG is True): model.startOverlay() + printLog("Init WebSocket Server") + if config.WEBSOCKET_SERVER is True: + if isAvailableWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) is True: + model.startWebSocketServer(config.WEBSOCKET_HOST, config.WEBSOCKET_PORT) + else: + config.WEBSOCKET_SERVER = False + model.stopWebSocketServer() + printLog("WebSocket server host or port is not available") + printLog("Update settings") self.updateConfigSettings() diff --git a/src-python/mainloop.py b/src-python/mainloop.py index 5c5fb744..00bc8cb5 100644 --- a/src-python/mainloop.py +++ b/src-python/mainloop.py @@ -4,8 +4,11 @@ import time from typing import Any from threading import Thread from queue import Queue -from controller import Controller -from utils import printLog, printResponse, errorLogging, encodeBase64 +import logging +from controller import Controller # noqa: E402 +from utils import printLog, printResponse, errorLogging, encodeBase64 # noqa: E402 + +logging.getLogger("huggingface_hub").setLevel(logging.ERROR) run_mapping = { "connected_network":"/run/connected_network", @@ -42,6 +45,8 @@ run_mapping = { "initialization_progress":"/run/initialization_progress", "initialization_complete":"/run/initialization_complete", + + "enable_osc_query":"/run/enable_osc_query", } def run(status:int, endpoint:str, result:Any) -> None: @@ -291,6 +296,15 @@ mapping = { "/set/enable/send_received_message_to_vrc": {"status": True, "variable":controller.setEnableSendReceivedMessageToVrc}, "/set/disable/send_received_message_to_vrc": {"status": True, "variable":controller.setDisableSendReceivedMessageToVrc}, + # WebSocket Settings + "/get/data/websocket_host": {"status": True, "variable":controller.getWebSocketHost}, + "/set/data/websocket_host": {"status": True, "variable":controller.setWebSocketHost}, + "/get/data/websocket_port": {"status": True, "variable":controller.getWebSocketPort}, + "/set/data/websocket_port": {"status": True, "variable":controller.setWebSocketPort}, + "/get/data/websocket_server": {"status": True, "variable":controller.getWebSocketServer}, + "/set/enable/websocket_server": {"status": True, "variable":controller.setEnableWebSocketServer}, + "/set/disable/websocket_server": {"status": True, "variable":controller.setDisableWebSocketServer}, + # Advanced Settings "/get/data/osc_ip_address": {"status": True, "variable":controller.getOscIpAddress}, "/set/data/osc_ip_address": {"status": True, "variable":controller.setOscIpAddress}, @@ -367,7 +381,7 @@ class Main: if status == 423: self.queue.put((endpoint, data)) else: - printLog(endpoint, {"send_data":result}) + printLog(endpoint, {"status": status, "send_data": result}) printResponse(status, endpoint, result) time.sleep(0.1) diff --git a/src-python/model.py b/src-python/model.py index 8f62aa5e..3b4e51b6 100644 --- a/src-python/model.py +++ b/src-python/model.py @@ -1,5 +1,7 @@ import copy import gc +import asyncio +import json from subprocess import Popen from os import makedirs as os_makedirs from os import path as os_path @@ -29,6 +31,7 @@ from models.transcription.transcription_whisper import checkWhisperWeight, downl from models.overlay.overlay import Overlay from models.overlay.overlay_image import OverlayImage from models.watchdog.watchdog import Watchdog +from models.websocket.websocket_server import WebSocketServer from utils import errorLogging, setupLogger class threadFnc(Thread): @@ -99,6 +102,10 @@ class Model: self.kks = kakasi() self.watchdog = Watchdog(config.WATCHDOG_TIMEOUT, config.WATCHDOG_INTERVAL) self.osc_handler = OSCHandler(config.OSC_IP_ADDRESS, config.OSC_PORT) + self.websocket_server = None + self.websocket_server_loop = False + self.websocket_server_alive = False + self.th_websocket_server = None def checkTranslatorCTranslate2ModelWeight(self, weight_type:str): return checkCTranslate2Weight(config.PATH_LOCAL, weight_type) @@ -292,11 +299,8 @@ class Model: def oscSendMessage(self, message:str): self.osc_handler.sendMessage(message=message, notification=config.NOTIFICATION_VRC_SFX) - def getMuteSelfStatus(self): - return self.osc_handler.getOSCParameterMuteSelf() - def setMuteSelfStatus(self): - self.mic_mute_status = self.getMuteSelfStatus() + self.mic_mute_status = self.osc_handler.getOSCParameterMuteSelf() def startReceiveOSC(self): def changeHandlerMute(address, osc_arguments): @@ -311,11 +315,15 @@ class Model: dict_filter_and_target = { self.osc_handler.osc_parameter_muteself: changeHandlerMute, } - self.osc_handler.receiveOscParameters(dict_filter_and_target) + self.osc_handler.setDictFilterAndTarget(dict_filter_and_target) + self.osc_handler.receiveOscParameters() def stopReceiveOSC(self): self.osc_handler.oscServerStop() + def getIsOscQueryEnabled(self): + return self.osc_handler.getIsOscQueryEnabled() + @staticmethod def checkSoftwareUpdated(): # check update @@ -503,12 +511,16 @@ class Model: def changeMicTranscriptStatus(self): if config.VRC_MIC_MUTE_SYNC is True: - if self.mic_mute_status is True: - self.pauseMicTranscript() - elif self.mic_mute_status is False: - self.resumeMicTranscript() - else: - pass + match self.mic_mute_status: + case True: + self.pauseMicTranscript() + case False: + self.resumeMicTranscript() + case None: + # mute selfの状態が不明な場合は一時停止しない + self.resumeMicTranscript() + case _: + pass else: self.resumeMicTranscript() @@ -827,4 +839,86 @@ class Model: self.th_watchdog.join() self.th_watchdog = None + def message_handler(websocket, message): + """WebSocketメッセージ受信時の処理""" + pass + + def startWebSocketServer(self, host, port): + """WebSocketサーバーを起動し、別スレッドで実行する""" + if self.websocket_server_alive is True: + # サーバーが既に起動している場合は何もしない + return + + self.websocket_server_loop = True + self.websocket_server_alive = False # 初期状態を明示 + + async def WebSocketServerMain(): + try: + self.websocket_server = WebSocketServer( + host=host, + port=port, + ) + self.websocket_server.set_message_handler(self.message_handler) + self.websocket_server.start() + self.websocket_server_alive = True + + # イベントループが終了するまで待機 + while self.websocket_server_loop: + # self.websocket_server.send("Server is running...") + await asyncio.sleep(0.5) # 応答性向上のため間隔短縮 + + except Exception: + errorLogging() + # 具体的なエラー内容をログに残す場合 + # self.logger.error(f"WebSocket server error: {str(e)}") + finally: + # 確実にサーバーを停止 + if hasattr(self, 'websocket_server') and self.websocket_server: + self.websocket_server.stop() + self.websocket_server_alive = False + + self.th_websocket_server = Thread(target=lambda: asyncio.run(WebSocketServerMain())) + self.th_websocket_server.daemon = True + self.th_websocket_server.start() + + def stopWebSocketServer(self): + """WebSocketサーバーを停止する""" + if not hasattr(self, 'th_websocket_server') or self.th_websocket_server is None: + return + + self.websocket_server_loop = False + + try: + # 一定時間待機してからタイムアウト + self.th_websocket_server.join(timeout=2.0) + + if self.th_websocket_server.is_alive(): + # タイムアウト後もスレッドが生きている場合の処理 + self.logger.warning("WebSocket server thread did not terminate properly") + except Exception: + errorLogging() + finally: + self.th_websocket_server = None + self.websocket_server = None + self.websocket_server_alive = False + + def checkWebSocketServerAlive(self): + """WebSocketサーバーの稼働状態を確認する""" + return self.websocket_server_alive + + def websocketSendMessage(self, message_dict:dict): + """ + WebSocketサーバーから全クライアントにメッセージを送信する + :param message_dict: 送信するメッセージの辞書 + :return: 送信成功したかどうか + """ + if not self.websocket_server_alive or not self.websocket_server: + return False + try: + message_json = json.dumps(message_dict) + return self.websocket_server.send(message_json) + except Exception: + errorLogging() + return False + model = Model() \ No newline at end of file diff --git a/src-python/models/osc/osc.py b/src-python/models/osc/osc.py index 758918af..ef4e1152 100644 --- a/src-python/models/osc/osc.py +++ b/src-python/models/osc/osc.py @@ -7,10 +7,22 @@ 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 -from utils import errorLogging + +try: + from utils import errorLogging +except ImportError: + def errorLogging(): + import traceback + print("Error occurred:", traceback.format_exc()) class OSCHandler: def __init__(self, ip_address="127.0.0.1", port=9000) -> None: + + if ip_address in ["127.0.0.1", "localhost"]: + self.is_osc_query_enabled = True + else: + self.is_osc_query_enabled = False + self.osc_ip_address = ip_address self.osc_port = port self.osc_parameter_muteself = "/avatar/parameters/MuteSelf" @@ -24,15 +36,28 @@ class OSCHandler: self.osc_server_ip_address = ip_address self.http_port = None self.osc_server_port = None + self.dict_filter_and_target = {} self.browser = None + def getIsOscQueryEnabled(self) -> bool: + 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 + + 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: + 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: @@ -44,6 +69,10 @@ class OSCHandler: self.udp_client.send_message(self.osc_parameter_chatbox_input, [f"{message}", True, notification]) def getOSCParameterValue(self, address:str) -> Any: + if not self.is_osc_query_enabled: + # OSCQueryが無効な場合はNoneを返す + return None + value = None try: # browserインスタンスを再利用し、毎回の生成と破棄を避ける @@ -71,19 +100,26 @@ class OSCHandler: def getOSCParameterMuteSelf(self) -> bool: return self.getOSCParameterValue(self.osc_parameter_muteself) - def receiveOscParameters(self, dict_filter_and_target:dict) -> None: + def setDictFilterAndTarget(self, dict_filter_and_target:dict) -> None: + self.dict_filter_and_target = dict_filter_and_target + + def receiveOscParameters(self) -> None: + if self.is_osc_query_enabled is False: + # OSCQueryが無効な場合は何もしない + return + self.osc_server_port = get_open_udp_port() self.http_port = get_open_tcp_port() osc_dispatcher = dispatcher.Dispatcher() - for filter, target in dict_filter_and_target.items(): + for filter, target in self.dict_filter_and_target.items(): osc_dispatcher.map(filter, target) - self.osc_server = osc_server.ThreadingOSCUDPServer((self.osc_server_ip_address, self.osc_server_port), osc_dispatcher, asyncio.get_event_loop()) + self.osc_server = osc_server.ThreadingOSCUDPServer((self.osc_server_ip_address, self.osc_server_port), osc_dispatcher) Thread(target=self.oscServerServe, daemon=True).start() while True: try: self.osc_query_service = OSCQueryService(self.osc_query_service_name, self.http_port, self.osc_server_port) - for filter, target in dict_filter_and_target.items(): + for filter, target in self.dict_filter_and_target.items(): self.osc_query_service.advertise_endpoint(filter, access=OSCAccess.READWRITE_VALUE) break except Exception: @@ -112,12 +148,26 @@ class OSCHandler: if __name__ == "__main__": handler = OSCHandler() - handler.receiveOscParameters({ - "/avatar/parameters/MuteSelf": print, + handler.setDictFilterAndTarget({ + "/avatar/parameters/MuteSelf": lambda address, *args: print(f"Received {address} with args {args}"), + "/chatbox/typing": lambda address, *args: print(f"Received {address} with args {args}"), + "/chatbox/input": lambda address, *args: print(f"Received {address} with args {args}"), }) + handler.receiveOscParameters() sleep(5) handler.sendTyping(True) sleep(1) - handler.sendMessage(message="Hello World", notification=True) - sleep(60) + handler.sendMessage(message="Hello World 1", notification=True) + sleep(10) + + print("IP address changed to 192.168.193.2") + handler.setOscIpAddress("192.168.193.2") + sleep(5) + handler.sendMessage(message="Hello World 2", notification=True) + + print("IP address changed to 127.0.0.1") + handler.setOscIpAddress("127.0.0.1") + sleep(5) + handler.sendMessage(message="Hello World 3", notification=True) + sleep(10) handler.oscServerStop() \ No newline at end of file diff --git a/src-python/models/websocket/__init__.py b/src-python/models/websocket/__init__.py new file mode 100644 index 00000000..a01d3ba6 --- /dev/null +++ b/src-python/models/websocket/__init__.py @@ -0,0 +1,4 @@ +# WebSocketサーバーモジュール +from .websocket_server import WebSocketServer + +__all__ = ["WebSocketServer"] diff --git a/src-python/models/websocket/websocket_server.py b/src-python/models/websocket/websocket_server.py new file mode 100644 index 00000000..34762e55 --- /dev/null +++ b/src-python/models/websocket/websocket_server.py @@ -0,0 +1,221 @@ +import asyncio +import threading +import websockets +from websockets.legacy.server import WebSocketServerProtocol +from typing import Callable, Set, Optional + +class WebSocketServer: + """ + WebSocketサーバーを管理するクラス。 + 主な機能: + - サーバーの起動・停止 + - クライアント接続管理 (接続/切断の追跡) + - メッセージ受信のコールバック処理 + - メッセージのブロードキャスト機能 + - GUIスレッド等からメッセージ送信するためのキュー + """ + def __init__(self, host: str='127.0.0.1', port: int=8765): + """ + サーバーのホスト名とポートを指定して初期化します。 + """ + self.host = host + self.port = port + self.clients: Set[WebSocketServerProtocol] = set() # 接続クライアント集合 + self._message_handler: Optional[Callable[['WebSocketServer', WebSocketServerProtocol, str], None]] = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + self._server: Optional[websockets.serve] = None + self._thread: Optional[threading.Thread] = None + self._send_queue: Optional[asyncio.Queue] = None # 外部スレッド向け非同期キュー + self.is_running: bool = False # サーバーの起動状態を示すフラグ + + def set_message_handler(self, handler: Callable[['WebSocketServer', WebSocketServerProtocol, str], None]): + """ + クライアントからメッセージ受信時に呼び出すコールバックを設定します。 + コールバックのシグネチャ: (server, websocket, message) -> None + """ + self._message_handler = handler + + async def _handler(self, websocket): + """ + 単一クライアントとのセッションを処理するハンドラです。 + 新規接続時にクライアントを集合に追加し、メッセージを受信してコールバックを呼び出します。 + 切断時には集合からクライアントを削除します。 + """ + # 接続クライアントを集合に追加 + self.clients.add(websocket) + try: + async for message in websocket: + # メッセージ受信時にコールバック呼び出し + if self._message_handler: + self._message_handler(self, websocket, message) + except websockets.exceptions.ConnectionClosed: + # クライアントが切断した場合 + pass + finally: + # 切断時に集合から削除 + self.clients.remove(websocket) + + async def _broadcast_async(self, message: str): + """ + すべての接続クライアントにメッセージを送信する非同期メソッド。 + """ + if not self.clients: + return + # 全クライアントへ並列に送信 + await asyncio.gather( + *[client.send(message) for client in self.clients], + return_exceptions=True + ) + + async def _send_loop(self): + """ + 内部キューからメッセージを取り出し、すべてのクライアントに送信するループ処理。 + GUIなど他スレッドから送信メッセージをキューに入れてもらい、このコルーチンで配信します。 + """ + assert self._send_queue is not None + while True: + message = await self._send_queue.get() + if message is None: + # Noneを受け取ったらシャットダウン指示とみなしてループを抜ける + break + await self._broadcast_async(message) + + def send(self, message: str): + """ + 外部スレッドからサーバーにメッセージを送信するためのメソッドです。 + イベントループ上で安全にキューにメッセージを積み、_send_loop()経由でブロードキャストします。 + """ + if self._loop and self._send_queue: + # キューにput_nowaitするコールをイベントループにスケジュール + self._loop.call_soon_threadsafe(self._send_queue.put_nowait, message) + + def broadcast(self, message: str): + """ + 外部スレッドや他コルーチンから全クライアントにメッセージを送信するユーティリティ。 + asyncio.run_coroutine_threadsafe を使ってループ上でブロードキャストを実行します。 + """ + if self._loop: + # コルーチン自体をrun_coroutine_threadsafeに渡す + asyncio.run_coroutine_threadsafe( + self._broadcast_async(message), self._loop + ) + + def start(self): + """ + サーバーを起動します。新しいスレッド上で asyncio イベントループを動かし、serve()を実行します。 + """ + if self._thread and self._thread.is_alive(): + return # 既に起動中 + # 新しいスレッドでイベントループを開始 + self._thread = threading.Thread(target=self._run_loop, daemon=True) + self._thread.start() + + def _run_loop(self): + """ + 別スレッド上で実行されるイベントループ用のメソッド。 + サーバーの起動と、送信用キューのタスク登録を行います。 + """ + # 新しいイベントループを作成してこのスレッドの現在のループとして設定 + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + async def setup_server(): + # サーバーを起動し、listenを開始 + self._server = await websockets.serve(self._handler, self.host, self.port) + # 送信キューを初期化 + self._send_queue = asyncio.Queue() + # 送信ループタスクを開始 + self._loop.create_task(self._send_loop()) + # サーバーの起動を待機 + # 設定関数を実行してサーバーを起動 + self._loop.run_until_complete(setup_server()) + self.is_running = True + # サーバーが起動したら、接続待機を開始 + # print(f"WebSocket server started on ws://{self.host}:{self.port}") + try: + # サーバーが停止するまでループを継続 + self._loop.run_forever() + finally: + # 停止指示が出たらすべての接続を閉じ、イベントループを終了 + self._loop.run_until_complete(self._shutdown()) + self._loop.close() + + async def _shutdown(self): + """ + サーバーとクライアントを安全にシャットダウンする非同期処理。 + serveオブジェクトをcloseし、wait_closed()で完全に終了を待ちます。 + さらに接続中の各WebSocketをcloseします。 + """ + # サーバーのListenを停止 + if self._server: + self._server.close() + await self._server.wait_closed() + # 接続中クライアントを順次クローズ + for ws in list(self.clients): + try: + await ws.close() + except Exception: + pass + + def stop(self): + """ + サーバーを停止します。別スレッドで動作中のイベントループに停止を指示し、スレッドを終了させます。 + """ + self.is_running = False + if self._loop: + # サーバーのlistenを停止し、ループ停止をスケジュール + self._loop.call_soon_threadsafe(self._server.close) + # None をキューに入れて_send_loopを抜けさせる + self._loop.call_soon_threadsafe(self._send_queue.put_nowait, None) + # ループ停止 + self._loop.call_soon_threadsafe(self._loop.stop) + # スレッドの終了を待つ + if self._thread: + self._thread.join() + +if __name__ == "__main__": + # テスト用の簡単なメッセージハンドラ + def message_handler(server: WebSocketServer, websocket: WebSocketServerProtocol, message: str): + print(f"Received message from {websocket.remote_address}: {message}") + server.send(f"Echo: {message}") + + def send_message(server: WebSocketServer, message: str): + server.send(message) + + # メイン処理を非同期関数に変更 + async def main(): + # サーバーを起動してメッセージハンドラを設定 + ws_server = WebSocketServer() + ws_server.set_message_handler(message_handler) + ws_server.start() + print("WebSocket server started.") + # 定期的にサーバーからメッセージを送信する例 + import threading + import time + def periodic_send(): + print("Starting periodic message sender...") + while ws_server.is_running: + time.sleep(5) + print("Sending periodic message...") + send_message(ws_server, "Periodic message") + print("Periodic message sender stopped.") + # 別スレッドで定期的にメッセージを送信 + time.sleep(5) + send_thread = threading.Thread(target=periodic_send, daemon=True) + send_thread.start() + # メインスレッドでサーバーを動かし続ける + try: + while True: + # 非同期スリープで待機 + await asyncio.sleep(1) + + except KeyboardInterrupt: + # Ctrl+Cでサーバーを停止 + print("Stopping WebSocket server...") + ws_server.stop() + + # 非同期メイン関数を実行 + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("Stopping WebSocket server...") \ No newline at end of file diff --git a/src-python/utils.py b/src-python/utils.py index de538dae..7bc0997b 100644 --- a/src-python/utils.py +++ b/src-python/utils.py @@ -8,6 +8,7 @@ from logging.handlers import RotatingFileHandler from ctranslate2 import get_supported_compute_types import requests import ipaddress +import socket def isConnectedNetwork(url="http://www.google.com", timeout=3) -> bool: try: @@ -16,6 +17,25 @@ def isConnectedNetwork(url="http://www.google.com", timeout=3) -> bool: except requests.RequestException: return False +def isAvailableWebSocketServer(host:str, port:int) -> bool: + """WebSocketサーバーのポートが使用中かどうかを確認する""" + response = True + 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 + except Exception: + errorLogging() + response = False + + return response + def isValidIpAddress(ip_address: str) -> bool: try: ipaddress.ip_address(ip_address) diff --git a/src-ui/app/_index_css/variables.css b/src-ui/app/_index_css/variables.css index 6a3c48e4..ca2beca1 100644 --- a/src-ui/app/_index_css/variables.css +++ b/src-ui/app/_index_css/variables.css @@ -25,6 +25,7 @@ --error_bc_active_color: #9c3938; --success_bc_color: #368777; --waring_color: #cb944f; + --waring_bc_color: #cf7b1b; --dark_basic_text_color: #f2f2f2; --dark_100_color: #f5f7fb; diff --git a/src-ui/app/config_page/setting_section/setting_box/_components/slider/Slider.jsx b/src-ui/app/config_page/setting_section/setting_box/_components/slider/Slider.jsx index 8f61d725..827c0253 100644 --- a/src-ui/app/config_page/setting_section/setting_box/_components/slider/Slider.jsx +++ b/src-ui/app/config_page/setting_section/setting_box/_components/slider/Slider.jsx @@ -4,44 +4,107 @@ import MUI_Slider from "@mui/material/Slider"; import clsx from "clsx"; export const Slider = (props) => { + const location = props.valueLabelDisplayLocation || "top"; + + const sliderSx = { + color: "var(--dark_700_color)", + "& .MuiSlider-thumb": { + backgroundColor: "var(--primary_600_color)", + "&:hover, &.Mui-focusVisible, &.Mui-active": { + boxShadow: `0 0 0 0.8rem var(--primary_600_color_44)`, + }, + "& .MuiSlider-valueLabel": { + position: "absolute", + backgroundColor: "var(--dark_800_color)", + width: "fit-content", + minWidth: "4.8rem", + padding: "0.4rem 0.8rem", + lineHeight: "1.15", + "& .MuiSlider-valueLabelLabel": { + fontSize: "1.4rem", + }, + ...(location === "top" && { + top: "-110%", + left: "50%", + transform: "translate(-50%, -50%) scale(0)", + transformOrigin: "bottom center", + "&.MuiSlider-valueLabelOpen": { + transform: "translate(-50%, -50%) scale(1)", + }, + "&::before": { + bottom: "0%", + left: "50%", + }, + }), + ...(location === "right" && { + top: "50%", + left: "150%", + transform: "translate(0, -50%) scale(0)", + transformOrigin: "left center", + "&.MuiSlider-valueLabelOpen": { + transform: "translate(0, -50%) scale(1)", + }, + "&::before": { + bottom: "50%", + left: "0", + }, + }), + ...(location === "left" && { + // top: "50%", + // right: "50%", + // transform: "translate(-50%, -50%) scale(0)", + // transformOrigin: "bottom center", + // "&.MuiSlider-valueLabelOpen": { + // transform: "translate(-50%, -50%) scale(1)", + // }, + // "&::before": { + // bottom: "50%", + // left: "100%", + // }, + }), + }, + }, + "& .MuiSlider-markLabel": { + fontSize: "1.4rem", + color: "var(--dark_550_color)", + whiteSpace: "nowrap", + }, + "& .MuiSlider-markLabelActive": { + color: "var(--primary_300_color)", + }, + }; + return ( -
{t("config_page.vr.y_position")}
@@ -221,8 +260,18 @@ const PositionControls = ({settings, onchangeFunction, selectFunction, ui_config
max={ui_configs.y_pos.max}
onchangeFunction={(value) => onchangeFunction("y_pos", value)}
orientation="vertical"
+ valueLabelDisplay={y_variable_display}
+ valueLabelDisplayLocation="right"
+ />
+
{t("config_page.vr.z_position")}
@@ -237,69 +286,162 @@ const PositionControls = ({settings, onchangeFunction, selectFunction, ui_config
max={ui_configs.z_pos.max}
onchangeFunction={(value) => onchangeFunction("z_pos", value)}
orientation="vertical"
+ valueLabelDisplay={z_variable_display}
+ valueLabelDisplayLocation="left"
+ />
+
{t("config_page.vr.x_rotation")}
-
{t("config_page.vr.y_rotation")}
-
{t("config_page.vr.z_rotation")}
-
{label}
); +}; + + + +const useVariableControl = (key, settings, onchangeFunction, ui_configs) => { + const [variable_display, setVariableDisplay] = useState("auto"); + + const [is_max, setIsMax] = useState(settings[key] >= ui_configs[key].max); + const [is_min, setIsMin] = useState(settings[key] <= ui_configs[key].min); + + const timerRef = useRef(); + + useEffect(() => { + return () => { + clearTimeout(timerRef.current); + }; + }, []); + + const triggerDisplay = () => { + setVariableDisplay("on"); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + setVariableDisplay("auto"); + }, 2000); + }; + + useEffect(() => { + setIsMax(settings[key] >= ui_configs[key].max); + setIsMin(settings[key] <= ui_configs[key].min); + }, [settings[key]]); + + const countUp = () => { + if (is_max) return; + const step = ui_configs[key].step; + const new_value = parseFloat((settings[key] + step).toFixed(2)); + onchangeFunction(key, new_value); + triggerDisplay(); + }; + + const countDown = () => { + if (is_min) return; + const step = ui_configs[key].step; + const new_value = parseFloat((settings[key] - step).toFixed(2)); + onchangeFunction(key, new_value); + triggerDisplay(); + }; + + return { + variable_display, + is_max, + is_min, + countUp, + countDown, + }; }; \ No newline at end of file diff --git a/src-ui/app/config_page/setting_section/setting_box/vr/Vr.module.scss b/src-ui/app/config_page/setting_section/setting_box/vr/Vr.module.scss index 35798530..9d0963e8 100644 --- a/src-ui/app/config_page/setting_section/setting_box/vr/Vr.module.scss +++ b/src-ui/app/config_page/setting_section/setting_box/vr/Vr.module.scss @@ -16,7 +16,7 @@ justify-content: center; width: 100%; max-width: 56rem; - gap: 2rem; + gap: 4rem; } .controller_type_switch { @@ -58,12 +58,13 @@ .sample_text_button_wrapper { position: absolute; - bottom: 0; - left: -74%; + bottom: -12%; + left: -80%; display: flex; justify-content: center; align-items: center; flex-direction: column; + // transform: translate(-50%, -50%); } .sample_text_button { background-color: var(--dark_850_color); @@ -121,20 +122,20 @@ } .x_position_label { position: absolute; - bottom: -4.6rem; + bottom: -5rem; right: -46%; justify-content: end; } .y_position_label { position: absolute; - top: -44%; - left: 10%; - justify-content: start; + bottom: 110%; + right: 119%; + justify-content: end; } .z_position_label { position: absolute; - top: 30%; - left: 80%; + top: 14%; + left: 110%; } .x_position_slider { @@ -155,14 +156,84 @@ .z_position_slider { position: absolute; - bottom: 61%; - left: 61%; + bottom: 80%; + left: 88%; transform: translate(50%,50%) rotate(45deg); width: 0%; height: 100%; } +%variable-button { + width: 3.8rem; + border-radius: 0.4rem; + aspect-ratio: 1.2 / 1; + background-color: var(--dark_850_color); + display: flex; + justify-content: center; + align-items: center; + font-size: 1.6rem; + cursor: pointer; + + &:hover { + background-color: var(--primary_500_color); + } + &:active { + background-color: var(--primary_600_color); + } + &.is_disabled { + pointer-events: none; + background-color: var(--dark_875_color); + & .adjust_button_triangle_svg { + color: var(--dark_800_color); + } + } +} + +@mixin variable-button-wrapper($vertical-pos, $vertical-value, $horizontal-pos, $horizontal-value, $rotate: 0deg) { + position: absolute; + #{$vertical-pos}: $vertical-value; + #{$horizontal-pos}: $horizontal-value; + display: flex; + gap: 1.6rem; + flex-direction: column; + transform: translate(-50%) rotate($rotate); +} + +.button_wrapper { + @extend %variable-button; + + &.up .adjust_button_triangle_svg { + transform: rotate(0deg); + } + &:not(.up) .adjust_button_triangle_svg { + transform: rotate(180deg); + } + &.is_disabled { + pointer-events: none; + color: var(--dark_875_color); + } +} + +.adjust_button_triangle_svg { + width: 1.8rem; + color: var(--dark_400_color); +} + + + +.y_position_button_wrapper { + @include variable-button-wrapper(top, 30%, left, -26%); +} + +.x_position_button_wrapper { + @include variable-button-wrapper(bottom, -38%, left, 46%, 90deg); +} + +.z_position_button_wrapper { + @include variable-button-wrapper(bottom, 26%, right, -4%, 45deg); +} + // .rotation_controls { @@ -175,19 +246,20 @@ .x_rotation_label { position: absolute; - top: -44%; - left: 10%; + bottom: 110%; + right: 119%; + justify-content: end; } .y_rotation_label { position: absolute; - bottom: -4.6rem; + bottom: -5rem; right: -46%; justify-content: end; } .z_rotation_label { position: absolute; - top: -10%; - right: -110%; + top: -20%; + right: -100%; } .x_rotation_slider { @@ -216,6 +288,21 @@ } + +.x_rotation_button_wrapper { + @include variable-button-wrapper(top, 30%, left, -26%); +} + +.y_rotation_button_wrapper { + @include variable-button-wrapper(bottom, -38%, left, 46%, 90deg); +} + +.z_rotation_button_wrapper { + @include variable-button-wrapper(bottom, 50%, right, -60%, -45deg); +} + + + .slider_reset_button { background-color: var(--dark_875_color); padding: 0.6rem; diff --git a/src-ui/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx b/src-ui/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx index 7bd3ae59..3f8be6e2 100644 --- a/src-ui/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx +++ b/src-ui/app/main_page/main_section/top_bar/right_side_components/RightSideComponents.jsx @@ -77,7 +77,7 @@ const OpenVrcMicMuteSyncQuickSetting = () => { return ({props.desc}
+{`- ${props.desc}`}