OSCモジュールのドキュメントを更新し、使用例や注意点を追加。型注釈を強化し、エラーハンドリングを改善。OSCHandlerクラスの初期化メソッドを修正し、サービスのアドバタイズ中の例外処理を追加。テストファイルを新規作成し、OSCモジュールのインポートテストを追加。

This commit is contained in:
misyaguziya
2025-10-09 17:39:52 +09:00
parent 690a2f081b
commit 944577eaf4
3 changed files with 133 additions and 45 deletions

View File

@@ -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 経由で値の取得やチャット送信を行う。

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

View 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}")