OSCモジュールのドキュメントを更新し、使用例や注意点を追加。型注釈を強化し、エラーハンドリングを改善。OSCHandlerクラスの初期化メソッドを修正し、サービスのアドバタイズ中の例外処理を追加。テストファイルを新規作成し、OSCモジュールのインポートテストを追加。
This commit is contained in:
@@ -1,3 +1,33 @@
|
|||||||
|
## OSC モジュール (models.osc)
|
||||||
|
|
||||||
|
このドキュメントは `models/osc/osc.py` の使い方と注意点を簡潔にまとめたものです。
|
||||||
|
|
||||||
|
### 概要
|
||||||
|
- `OSCHandler` クラスは OSC メッセージの送信 (/chatbox/input, /chatbox/typing 等) と、
|
||||||
|
ローカル環境では OSCQuery でエンドポイントを公開するための薄いラッパーを提供します。
|
||||||
|
|
||||||
|
### 依存関係
|
||||||
|
- `python-osc` — UDP クライアント/サーバ
|
||||||
|
- `tinyoscquery` — OSCQuery を利用する場合に必要(オプショナル)
|
||||||
|
|
||||||
|
### 使い方(例)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from models.osc.osc import OSCHandler
|
||||||
|
|
||||||
|
handler = OSCHandler(ip_address="127.0.0.1", port=9000)
|
||||||
|
handler.setDictFilterAndTarget({
|
||||||
|
"/chatbox/input": lambda addr, *args: print(args),
|
||||||
|
})
|
||||||
|
handler.receiveOscParameters()
|
||||||
|
handler.sendTyping(True)
|
||||||
|
handler.sendMessage("Hello")
|
||||||
|
handler.oscServerStop()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意点
|
||||||
|
- `tinyoscquery` がインストールされていない場合、OSCQuery 関連機能は無効になりますが、送信(UDP クライアント)は動作します。
|
||||||
|
- サービスのアドバタイズ中に例外が発生した場合、内部でリトライします。
|
||||||
# models/osc — 詳細設計
|
# models/osc — 詳細設計
|
||||||
|
|
||||||
目的: VRChat 等と OSC / OSCQuery 経由で値の取得やチャット送信を行う。
|
目的: VRChat 等と OSC / OSCQuery 経由で値の取得やチャット送信を行う。
|
||||||
|
|||||||
@@ -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
|
import time
|
||||||
from typing import Any
|
from typing import Any, Callable, Dict, Optional
|
||||||
from time import sleep
|
from time import sleep
|
||||||
from threading import Thread
|
from threading import Thread
|
||||||
from pythonosc import udp_client, dispatcher, osc_server
|
from pythonosc import udp_client, dispatcher, osc_server
|
||||||
from tinyoscquery.queryservice import OSCQueryService
|
try:
|
||||||
from tinyoscquery.query import OSCQueryBrowser, OSCQueryClient
|
from tinyoscquery.queryservice import OSCQueryService
|
||||||
from tinyoscquery.utility import get_open_udp_port, get_open_tcp_port
|
from tinyoscquery.query import OSCQueryBrowser, OSCQueryClient
|
||||||
from tinyoscquery.shared.node import OSCAccess
|
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:
|
try:
|
||||||
from utils import errorLogging
|
from utils import errorLogging
|
||||||
except ImportError:
|
except Exception:
|
||||||
def errorLogging():
|
def errorLogging() -> None:
|
||||||
import traceback
|
import traceback
|
||||||
print("Error occurred:", traceback.format_exc())
|
print("Error occurred:", traceback.format_exc())
|
||||||
|
|
||||||
class OSCHandler:
|
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"]:
|
Args:
|
||||||
self.is_osc_query_enabled = True
|
ip_address: OSC server client target / bind address
|
||||||
else:
|
port: UDP port to send to
|
||||||
self.is_osc_query_enabled = False
|
"""
|
||||||
|
|
||||||
self.osc_ip_address = ip_address
|
def __init__(self, ip_address: str = "127.0.0.1", port: int = 9000) -> None:
|
||||||
self.osc_port = port
|
|
||||||
self.osc_parameter_muteself = "/avatar/parameters/MuteSelf"
|
self.is_osc_query_enabled: bool = ip_address in ["127.0.0.1", "localhost"]
|
||||||
self.osc_parameter_chatbox_typing = "/chatbox/typing"
|
|
||||||
self.osc_parameter_chatbox_input = "/chatbox/input"
|
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.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_server = None
|
||||||
self.osc_query_service = None
|
self.osc_query_service = None
|
||||||
self.osc_query_service_name = "VRCT"
|
self.osc_query_service_name: str = "VRCT"
|
||||||
self.osc_server_ip_address = ip_address
|
self.osc_server_ip_address: str = ip_address
|
||||||
self.http_port = None
|
self.http_port: Optional[int] = None
|
||||||
self.osc_server_port = None
|
self.osc_server_port: Optional[int] = None
|
||||||
self.dict_filter_and_target = {}
|
self.dict_filter_and_target: Dict[str, Callable] = {}
|
||||||
self.browser = None
|
self.browser = None
|
||||||
|
|
||||||
def getIsOscQueryEnabled(self) -> bool:
|
def getIsOscQueryEnabled(self) -> bool:
|
||||||
|
"""Return whether OSCQuery support is enabled (local addresses only)."""
|
||||||
return self.is_osc_query_enabled
|
return self.is_osc_query_enabled
|
||||||
|
|
||||||
def setOscIpAddress(self, ip_address:str) -> None:
|
def setOscIpAddress(self, ip_address: str) -> None:
|
||||||
if ip_address in ["127.0.0.1", "localhost"]:
|
"""Change the OSC target IP address and reinitialize services."""
|
||||||
self.is_osc_query_enabled = True
|
self.is_osc_query_enabled = ip_address in ["127.0.0.1", "localhost"]
|
||||||
else:
|
|
||||||
self.is_osc_query_enabled = False
|
|
||||||
|
|
||||||
self.oscServerStop()
|
self.oscServerStop()
|
||||||
self.osc_ip_address = ip_address
|
self.osc_ip_address = ip_address
|
||||||
self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port)
|
self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port)
|
||||||
self.receiveOscParameters()
|
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.oscServerStop()
|
||||||
self.osc_port = port
|
self.osc_port = port
|
||||||
self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port)
|
self.udp_client = udp_client.SimpleUDPClient(self.osc_ip_address, self.osc_port)
|
||||||
self.receiveOscParameters()
|
self.receiveOscParameters()
|
||||||
|
|
||||||
# send OSC message typing
|
# 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])
|
self.udp_client.send_message(self.osc_parameter_chatbox_typing, [flag])
|
||||||
|
|
||||||
# send OSC message
|
# 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:
|
if len(message) > 0:
|
||||||
self.udp_client.send_message(self.osc_parameter_chatbox_input, [f"{message}", True, notification])
|
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:
|
if not self.is_osc_query_enabled:
|
||||||
# OSCQueryが無効な場合はNoneを返す
|
# OSCQueryが無効な場合はNoneを返す
|
||||||
return None
|
return None
|
||||||
|
value: Any = None
|
||||||
value = None
|
|
||||||
try:
|
try:
|
||||||
# browserインスタンスを再利用し、毎回の生成と破棄を避ける
|
# browserインスタンスを再利用し、毎回の生成と破棄を避ける
|
||||||
if self.browser is None:
|
if self.browser is None:
|
||||||
|
# OSCQueryBrowser may not be available; guard
|
||||||
|
if OSCQueryBrowser is None:
|
||||||
|
return None
|
||||||
self.browser = OSCQueryBrowser()
|
self.browser = OSCQueryBrowser()
|
||||||
sleep(1) # 初回のみスリープ
|
sleep(1) # 初回のみスリープ
|
||||||
|
|
||||||
@@ -99,15 +133,22 @@ class OSCHandler:
|
|||||||
self.browser = None
|
self.browser = None
|
||||||
return value
|
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)
|
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
|
self.dict_filter_and_target = dict_filter_and_target
|
||||||
|
|
||||||
def receiveOscParameters(self) -> None:
|
def receiveOscParameters(self) -> None:
|
||||||
if self.is_osc_query_enabled is False:
|
"""Start a local OSC server and advertise OSCQuery endpoints when supported.
|
||||||
# OSCQueryが無効な場合は何もしない
|
|
||||||
|
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
|
return
|
||||||
|
|
||||||
self.osc_server_port = get_open_udp_port()
|
self.osc_server_port = get_open_udp_port()
|
||||||
@@ -120,28 +161,39 @@ class OSCHandler:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# osc_server_name + UTC timestampでユニークなサービス名を生成
|
# osc_server_name + UTC timestamp でユニークなサービス名を生成
|
||||||
service_name = f"{self.osc_query_service_name}:{int(time.time())}"
|
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)
|
self.osc_query_service = OSCQueryService(service_name, self.http_port, self.osc_server_port)
|
||||||
for filter, target in self.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)
|
# 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
|
break
|
||||||
except Exception:
|
except Exception:
|
||||||
errorLogging()
|
errorLogging()
|
||||||
sleep(1)
|
sleep(1)
|
||||||
|
|
||||||
def oscServerServe(self) -> None:
|
def oscServerServe(self) -> None:
|
||||||
|
"""Run the OSC server loop with a longer poll interval to reduce CPU."""
|
||||||
# ポーリング間隔を長くして(2秒から10秒に)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:
|
def oscServerStop(self) -> None:
|
||||||
|
"""Stop and clean up any running OSC server and OSCQuery service."""
|
||||||
if isinstance(self.osc_server, osc_server.ThreadingOSCUDPServer):
|
if isinstance(self.osc_server, osc_server.ThreadingOSCUDPServer):
|
||||||
self.osc_server.shutdown()
|
try:
|
||||||
|
self.osc_server.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.osc_server = None
|
self.osc_server = None
|
||||||
if isinstance(self.osc_query_service, OSCQueryService):
|
if OSCQueryService is not None and isinstance(self.osc_query_service, OSCQueryService):
|
||||||
self.osc_query_service.http_server.shutdown()
|
try:
|
||||||
|
self.osc_query_service.http_server.shutdown()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
self.osc_query_service = None
|
self.osc_query_service = None
|
||||||
# browserがある場合はクリーンアップ
|
# browser がある場合はクリーンアップ
|
||||||
if self.browser is not None:
|
if self.browser is not None:
|
||||||
try:
|
try:
|
||||||
if hasattr(self.browser, 'zc') and self.browser.zc is not None:
|
if hasattr(self.browser, 'zc') and self.browser.zc is not None:
|
||||||
|
|||||||
6
src-python/tests/test_osc_imports.py
Normal file
6
src-python/tests/test_osc_imports.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def test_import_osc_module():
|
||||||
|
try:
|
||||||
|
import importlib
|
||||||
|
importlib.import_module('models.osc.osc')
|
||||||
|
except Exception as e:
|
||||||
|
raise AssertionError(f"Failed importing models.osc.osc: {e}")
|
||||||
Reference in New Issue
Block a user