Repository navigation
Expand file tree
/
Copy pathVRCDollyPivotManager.py
More file actions
1774 lines (1474 loc) · 77.4 KB
/
Copy pathVRCDollyPivotManager.py
File metadata and controls
1774 lines (1474 loc) · 77.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""VRCDollyPivotManager.py
VR 内で床に付けた印を中心に回る、VRChat Camera Dolly のパスを作る常駐ツール。
アバター側のレイが印までの距離を3方向から測って OSC で送ってくる。
Confirm を受けた時点の値から三辺測量で印の位置を求め、そこを軸にした
旋回パスを書き出して VRChat へ読み込ませる。
コンソールを持たず、タスクトレイに常駐する。
設定は実行ファイルと同じフォルダの config.json から読む
(カレントディレクトリではなく、実行ファイルが置かれた場所)。
"""
from __future__ import annotations
import json
import math
import os
import random
import sys
import threading
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Any, Deque, Dict, List, Optional, Tuple
from pythonosc import dispatcher as osc_dispatcher
from pythonosc import osc_server
from pythonosc import udp_client
from PIL import Image, ImageDraw
import pystray
import oscquery
# ---------------------------------------------------------------------------
# 配置とパス
# ---------------------------------------------------------------------------
def app_dir() -> Path:
"""実行ファイルが置かれているフォルダ。カレントディレクトリではない。"""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def app_name() -> str:
if getattr(sys, "frozen", False):
return Path(sys.executable).stem
return Path(__file__).stem
APP_DIR = app_dir()
APP_NAME = app_name()
CONFIG_PATH = APP_DIR / "config.json"
LOG_DIR = APP_DIR / "log"
# 生成した JSON の控え。config の output_dir とは別に、必ずここにも同じものを残す。
# 出力先を書き換えても手元に履歴が残るようにするため。
DATA_DIR = APP_DIR / "data"
# ---------------------------------------------------------------------------
# アバターと一致必須の定数
#
# 以下はアバター側にも同じ値が置いてある。仕様として固定で、config からは
# 変えられない。片方だけ書き換えると生成結果が静かにずれるため。
#
# アバター側の値を変えたときは、必ずこちらも同じ値に直してビルドし直すこと。
# 対応表は README の「アバターと一致必須の値」にある。
# ---------------------------------------------------------------------------
# プローブ3点の間隔(m)。A=(0,0) B=(baseline,0) C=(0,baseline) に置く。
# 三辺測量の分母なので、食い違うと距離が丸ごと圧縮/拡大される。
# Unity 側で Object_N/ProbeRig/ProbeB の Position X を見れば実際の値が分かる。
#
# ProbeRig には MA World Scale Object が付いていてワールドスケールが 1 に
# 固定されるので、アバタースケールを変えてもこの値は変わらない。
PROBE_BASELINE = 1.5 # CamDronePlayerProbeSetup.Baseline
HEIGHT_MIN = -1.5 # CamDroneOrbitGuideSetup.HeightMin
HEIGHT_MAX = 4.0 # CamDroneOrbitGuideSetup.HeightMax
RADIUS_MIN = 1.0 # CamDroneOrbitGuideSetup.RadiusMin
RADIUS_MAX = 25.0 # CamDroneOrbitGuideSetup.RadiusMax
TILT_MIN = -30.0 # CamDroneOrbitGuideSetup.TiltMinDeg
TILT_MAX = 30.0 # CamDroneOrbitGuideSetup.TiltMaxDeg
# 最下点の方位(メニュー上の名前は Low Point)。
# パラメータ 0〜1 を一周ではなく「右→奥→左」の半周に割り当てる。
# 手前側の半周は Tilt の符号を反転すれば出せるので、一周させる必要がない。
#
# 0% = +90 度 = 右
# 50% = 0 度 = 奥
# 100% = -90 度 = 左
#
# 増える向きは VRChat のラジアルパペットのダイヤルと同じ向きに合わせてある。
# 逆にするとパペットを右へ倒したのに最下点が左へ行く(2026-08-09 に実測)。
#
# CamDroneOrbitGuideSetup.TiltDirMinDeg / TiltDirMaxDeg と必ず同じ値にすること。
# 片方だけ直すとガイドの目印と生成される軌道がずれる。
LOW_POINT_START_DEG = 90.0
LOW_POINT_SWEEP_DEG = 180.0
SLOT_COUNT = 5 # CamDroneOrbitGuideSetup.SlotCount
# 生成する JSON へそのまま書くカメラ設定の範囲。
# スロットに属さず、全体で1組しか持たない(一度に1本しかパスを作らないため)。
ZOOM_MIN = 20.0 # CamDroneOrbitGuideSetup.ZoomMin
ZOOM_MAX = 150.0 # CamDroneOrbitGuideSetup.ZoomMax
DURATION_MIN = 0.1 # CamDroneOrbitGuideSetup.DurationMin
DURATION_MAX = 60.0 # CamDroneOrbitGuideSetup.DurationMax
SPEED_MIN = 0.1 # CamDroneOrbitGuideSetup.SpeedMin
SPEED_MAX = 15.0 # CamDroneOrbitGuideSetup.SpeedMax
FOCAL_DISTANCE_MIN = 0.0 # CamDroneOrbitGuideSetup.FocalDistanceMin
FOCAL_DISTANCE_MAX = 10.0 # CamDroneOrbitGuideSetup.FocalDistanceMax
APERTURE_MIN = 1.4 # CamDroneOrbitGuideSetup.ApertureMin
APERTURE_MAX = 32.0 # CamDroneOrbitGuideSetup.ApertureMax
# スロットごとの書き出し先パス。固定で、メニューからは変えられない。
# VRChat の Multi ストリーミングは 4 本までしか扱えないため 0〜3 に収める。
# 5 番目は 1 番目と同じ 0 を使う。
PATH_BY_SLOT = {1: 0, 2: 1, 3: 2, 4: 3, 5: 0}
AXES = ("A", "B", "C")
# ---------------------------------------------------------------------------
# 校正で決まる定数
#
# 上の群と違い、アバター側には対応する値が無い。VRChat 側の性質を実測して
# 求めたもので、アバターを変えても同じ値が使える。
# ---------------------------------------------------------------------------
# レイが当たるのは VRChat のプレイヤーコライダーの表面。そこから中心までの
# オフセット(m)。アバターではなく VRChat 側の性質なので、config で調整できる。
DEFAULT_RHO = 0.2
# 1周あたりの点数 → 周回数。総点数が 50 を超えない範囲で 50 に最も近くなる値。
#
# 総点数を減らすと揺らぎの波(倍音 5 まで使う)のサンプリングが足りなくなり、
# なめらかなはずのうねりがギザギザに化ける。24 点を下回らせないこと。
DEFAULT_LAPS = {3: 16, 4: 12, 6: 8, 8: 6, 12: 4}
# 揺らぎに使う倍音。経路全体(全周回ぶん)を 1 周期とした波を重ねる。
#
# 仕様として固定で、config からは変えられない。総点数に対して大きすぎる倍音を
# 入れると点で山を表現しきれず、なめらかなはずの波がギザギザに化けるため。
# 目安は max(倍音) <= 総点数 ÷ 5。48 点なら 9 あたりが上限になる。
#
# 実測(半径 10m / Points 12 / 4周 / Random 20%、揺らぎなしの折れ角 30 度):
# (1, 2) 1周あたり 0.50 山 折れ角 最大 30.7 度
# (2, 3, 5) 1周あたり 1.25 山 折れ角 最大 33.5 度 ← 採用
# (3, 5, 8) 1周あたり 2.00 山 折れ角 最大 38.8 度
# (5, 8, 13) 1周あたり 3.25 山 折れ角 最大 50.8 度 破綻
#
# 本数を増やすと山が打ち消し合って振れが弱まる。3本で指定値の約 78%。
#
# X と Z を独立に乱数で振ると、円周に沿う向きにも揺らぎが乗って点の間隔が乱れる。
# VRChat は点間を一定時間で補間するので、間隔のばらつきはそのままカメラ速度の
# ばらつきになる。半径10m・Points12・Random20% では間隔が 1.1〜10.2 m まで開き、
# 速度が最大 9 倍変わっていた。
#
# 半径方向だけを、隣り合う点が一緒に動く滑らかな波で揺らすとこれを避けられる。
# 倍音が整数なので経路の終端と始端がつながり、周回ごとに表情が変わる。
JITTER_HARMONICS = (2, 3, 5)
# 上下の揺らぎ。半径に対する比で与えるが、水平の 1/5 に抑える。
# 揺らぎ率に掛けるので、Random 10% で半径の 2%、20% で 4%。
#
# 水平と同じ比率(1.0)にはできない。半径は 1〜25 m と 25 倍の幅があり、
# 20% をそのまま上下へ入れると半径 25 m で ±5 m 振れて床下や頭上まで抜ける。
# Random% を手で下げても効くのは 2 倍ぶんだけで、25 倍の幅には足りない。
#
# 好みの範囲なので config で調整できる。0 にすると上下の揺らぎが無くなる。
DEFAULT_JITTER_VERTICAL_RATIO = 0.20
# 計算で決まらない値。添付サンプル(R48h20b15-2.json)の値をそのまま使う
DEFAULT_TEMPLATE: Dict[str, Any] = {
"IsLocal": True,
"FocalDistance": 1.5,
"Aperture": 15.0,
"Hue": 120.0,
"Saturation": 100.0,
"Lightness": 50.0,
"LookAtMeXOffset": 0.0,
"LookAtMeYOffset": 0.0,
"Zoom": 45.0,
"Exposure": 0.0,
"Speed": 3.0,
"Duration": 2.0,
"PathIndex": 0,
}
# メニュー値が届いていないときの既定(アバター側の既定値と同じ)。実寸。
DEFAULT_MENU = {
"Height": 1.2,
"RingHeight": 1.2,
"Radius": 2.0,
"Tilt": 0.0,
# 最下点の向き。正規化された 0〜1 のまま持つ。
# 0.5 = 奥(CamDroneOrbitGuideSetup.TiltDirDefaultDeg = 0 度に対応)
"TiltDir": 0.5,
"Points": 6,
# 揺らぎ(%)。0 / 10 / 20 のいずれかがアバターから届く
"Random": 10,
# 1 = 上から見て時計回り(右回り)、0 = 反時計回り(左回り)
"CW": 1,
}
# カメラ設定が未受信のときの既定(アバター側の既定値と同じ)。実寸。
DEFAULT_CAMERA = {
"Zoom": 45.0, # CamDroneOrbitGuideSetup.ZoomDefault
"FocalDistance": 1.5, # CamDroneOrbitGuideSetup.FocalDistanceDefault
"Aperture": 15.0, # CamDroneOrbitGuideSetup.ApertureDefault
"Duration": 2.0, # CamDroneOrbitGuideSetup.DurationDefault
"Speed": 3.0, # CamDroneOrbitGuideSetup.SpeedDefault
}
# 正規化された 0〜1 を実寸へ戻すための範囲
CAMERA_RANGE = {
"Zoom": (ZOOM_MIN, ZOOM_MAX),
"FocalDistance": (FOCAL_DISTANCE_MIN, FOCAL_DISTANCE_MAX),
"Aperture": (APERTURE_MIN, APERTURE_MAX),
"Duration": (DURATION_MIN, DURATION_MAX),
"Speed": (SPEED_MIN, SPEED_MAX),
}
# 起動時にバッファへ入れておく初期値。OSC で届くのと同じ正規化された形。
# 一度も操作されていない項目は送られてこないため、これで計算が常に成立する。
DEFAULT_MENU_NORMALIZED = {
"Height": (DEFAULT_MENU["Height"] - HEIGHT_MIN) / (HEIGHT_MAX - HEIGHT_MIN),
"RingHeight": (DEFAULT_MENU["RingHeight"] - HEIGHT_MIN) / (HEIGHT_MAX - HEIGHT_MIN),
"Radius": (DEFAULT_MENU["Radius"] - RADIUS_MIN) / (RADIUS_MAX - RADIUS_MIN),
"Tilt": (DEFAULT_MENU["Tilt"] - TILT_MIN) / (TILT_MAX - TILT_MIN),
"TiltDir": DEFAULT_MENU["TiltDir"],
"Points": float(DEFAULT_MENU["Points"]),
"Random": float(DEFAULT_MENU["Random"]),
"CW": float(DEFAULT_MENU["CW"]),
}
# Confirm 時に使う区間。押す動作で身体が動くので、押した瞬間ではなく少し前を見る
SAMPLE_WINDOW_START = 1.2 # 秒前から
SAMPLE_WINDOW_END = 0.3 # 秒前まで
# 測距値の保持件数。時間ではなく件数で切る。
# VRChat は値が変化したときにしか送らないので、静止すると補充が来ない。
# 時間で捨てると、じっとしているだけで印の位置を見失う。
# 届いた分だけ溜まるので、500 件あれば動き続けても数十秒ぶんになる。
SAMPLES_KEPT = 500
# 最後に受信してからこの秒数を過ぎたら「待機中」表示に戻す
ACTIVE_TIMEOUT = 5.0
PARAM_ROOT = "/avatar/parameters/"
# VRChat が受け付けるカメラドリーの操作。OSCQuery で公開されている実物。
# /dolly/Import s ファイルパスを渡すとその場で読み込む
# /dolly/Play T 再生
# /dolly/PlayDelayed i 指定秒後に再生
#
# 手でメニューを開いてパスを貼る操作が不要になる。座標は Import した瞬間の
# 位置と向きが基準なので、その間に体が動く余地を無くせるのが大きい。
DOLLY_IMPORT = "/dolly/Import"
DOLLY_PLAY = "/dolly/Play"
DOLLY_PLAY_DELAYED = "/dolly/PlayDelayed"
# VRChat は IsLocal のドリー座標を「水平方向だけ」目線の高さで正規化する。
# Y はメートルのまま解釈される。
#
# JSON の X,Z = 実際のメートル × REFERENCE_EYE_HEIGHT / 実際の目線の高さ
# JSON の Y = 実際のメートル(無補正)
#
# 根拠: 半径 7.00 m / 高さ 0.90 m で生成したパスを VRChat に読み込ませ、
# ワールド座標に切り替えて書き出させたものと突き合わせた(2026-08-08)。
# 48点すべての対応から「平行移動 + 向き + 水平方向の一様倍率」を最小二乗で解くと
#
# 向き 121.085953 度(48点のばらつき 3e-05 度)
# 水平 world = local × 0.56893387 残差 最大 2e-06 m
# Y world = local - 0.005000 m 全48点で完全に一定
#
# Y に倍率は掛かっていない。残る 5 mm は床の高さで、倍率とは関係しない。
#
# 目線 0.935 m で割り戻すと旋回半径がちょうど 7.00000 m になるので、
# 目線の値と指定半径の両方がこの1件で裏付けられている。
# reference_eye_height = 目線 0.935 / 0.56893387 = 1.643425
# 当初の見込み値 1.85 では水平方向が 12.6% 大きく出ていた。
#
# 目線の高さへの反比例は、アバタースケールを変えて確認済み(2026-08-08)。
# 目線 0.935 m -> k 0.56893388 -> 半径 7.00 指定に対し 7.000001(誤差 0.000%)
# 目線 1.897 m -> k 1.15436436 -> 半径 7.11 指定に対し 7.113841(誤差 0.054%)
# 0.935 で校正した値が、その 2.03 倍の目線でもそのまま通る。
# 2件目の 0.054% はファイル名が半径を小数2桁に丸めているぶん。
REFERENCE_EYE_HEIGHT = 1.643425
EYE_HEIGHT_ADDRESS = "/avatar/eyeheight"
# 出力先の既定。環境変数と ~ を展開して使うので、どのユーザー環境でもそのまま動く。
# VRChat の Camera Dolly が既定で参照する場所に合わせてある。
DEFAULT_OUTPUT_DIR = r"%USERPROFILE%\Documents\VRChat\CameraPaths"
# ---------------------------------------------------------------------------
# 設定
# ---------------------------------------------------------------------------
def resolve_path(raw: str) -> Tuple[Path, Optional[str]]:
"""環境変数と ~ を展開する。展開しきれなかった場合は理由を返す。
config.json に特定のユーザー名を書かずに済むよう、
%USERPROFILE% や ~ をそのまま書けるようにしている。
"""
expanded = os.path.expandvars(os.path.expanduser(raw.strip()))
if "%" in expanded or expanded.startswith("~"):
fallback = APP_DIR / "output"
return fallback, (f"output_dir を展開できませんでした({raw})。"
f"{fallback} を使用します")
return Path(expanded), None
class Config:
def __init__(self, data: Dict[str, Any]) -> None:
self.receive_port = int(data.get("receive_port", 9001))
self.send_port = int(data.get("send_port", 9000))
self.output_dir, self.output_dir_note = resolve_path(
str(data.get("output_dir", DEFAULT_OUTPUT_DIR)))
self.debug = bool(data.get("debug", False))
# 以下は任意。省略時は上の既定値を使う
self.host = str(data.get("host", "127.0.0.1"))
# 接続方法。oscquery なら mDNS で自分を広告し、VRChat に見つけてもらう。
# osc なら receive_port を固定で使う従来どおりの方式。
self.connection = str(data.get("connection", "oscquery")).strip().lower()
if self.connection not in ("oscquery", "osc"):
self.connection = "oscquery"
self.fallback = bool(data.get("fallback", True))
# 生成した直後に VRChat へ読み込ませる。手でパスを貼る必要が無くなり、
# Confirm から Import までに体が動く余地も無くなる。
self.auto_import = bool(data.get("auto_import", True))
# 読み込み後の再生。負の値と未設定は再生しない。
# 0 は即時、正の値はその秒数だけ待ってから再生する。
raw_delay = data.get("auto_play_delay")
self.auto_play_delay = int(raw_delay) if raw_delay is not None else None
# Confirm を押した時点の値でも1本作る。読み込ませず出力するだけで、
# 区間の中央値との差を確認するために使う。
self.write_at_press = bool(data.get("write_at_press", True))
self.rho = float(data.get("rho", DEFAULT_RHO))
# 上下の揺らぎを水平の何倍にするか。0 で上下の揺らぎ無し。
self.jitter_vertical_ratio = float(
data.get("jitter_vertical_ratio", DEFAULT_JITTER_VERTICAL_RATIO))
self.reference_eye_height = float(
data.get("reference_eye_height", REFERENCE_EYE_HEIGHT))
# 点数ごとの周回数は仕様として固定。総点数を減らすと揺らぎの波が
# 粗くなるため、外から変えられないようにしてある。
self.laps = dict(DEFAULT_LAPS)
self.template = dict(DEFAULT_TEMPLATE)
self.template.update(data.get("template", {}))
@classmethod
def load(cls) -> Tuple["Config", List[str]]:
notes: List[str] = []
if not CONFIG_PATH.exists():
notes.append(f"config.json が見つかりません: {CONFIG_PATH}")
notes.append("既定値で起動します。config.json を作成してください。")
return cls({}), notes
try:
with CONFIG_PATH.open(encoding="utf-8") as handle:
data = json.load(handle)
except Exception as exc:
notes.append(f"config.json の読み込みに失敗しました: {exc}")
notes.append("既定値で起動します。")
return cls({}), notes
notes.append(f"config.json を読み込みました: {CONFIG_PATH}")
return cls(data), notes
# ---------------------------------------------------------------------------
# ログ
# ---------------------------------------------------------------------------
class Logger:
def __init__(self, debug: bool) -> None:
self.debug_enabled = debug
self.lock = threading.Lock()
LOG_DIR.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d%H%M%S")
self.path = LOG_DIR / f"{APP_NAME}_{stamp}.log"
self.handle = self.path.open("a", encoding="utf-8")
def _write(self, level: str, message: str) -> None:
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S.%f')[:-3]}] [{level}] {message}\n"
with self.lock:
self.handle.write(line)
self.handle.flush()
def info(self, message: str) -> None:
self._write("INFO", message)
def warn(self, message: str) -> None:
self._write("WARN", message)
def error(self, message: str) -> None:
self._write("ERROR", message)
def debug(self, message: str) -> None:
if self.debug_enabled:
self._write("DEBUG", message)
def close(self) -> None:
with self.lock:
try:
self.handle.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# 受信状態
# ---------------------------------------------------------------------------
class SlotState:
"""スロット1つ分の受信状態。測距値だけ時刻付きで溜める。"""
def __init__(self) -> None:
self.samples: Dict[str, Deque[Tuple[float, float]]] = {
a: deque(maxlen=SAMPLES_KEPT) for a in AXES}
self.hits: Dict[str, Deque[Tuple[float, bool]]] = {
a: deque(maxlen=SAMPLES_KEPT) for a in AXES}
# 起動時に初期値で埋めておく。一度も操作されていない項目は
# VRChat から送られてこないため、これが無いと計算が成立しない。
self.menu: Dict[str, float] = dict(DEFAULT_MENU_NORMALIZED)
# 出どころの区別。受信したら外す
self.initial: set = set(DEFAULT_MENU_NORMALIZED)
self.confirm = False
def reset(self) -> None:
"""アバターが変わったときに呼ぶ。持っている値を全部捨てる。
アバター側のパラメータは読み込みで既定値に戻る。こちらが前の値を
持ち続けると、ガイドが示す円と生成されるパスが食い違う。測距値も
前のアバターで測ったものなので使えない。
"""
for buffer in self.samples.values():
buffer.clear()
for buffer in self.hits.values():
buffer.clear()
self.menu = dict(DEFAULT_MENU_NORMALIZED)
self.initial = set(DEFAULT_MENU_NORMALIZED)
self.confirm = False
def push_distance(self, axis: str, value: float, now: float) -> None:
self.samples[axis].append((now, value))
def push_hit(self, axis: str, value: bool, now: float) -> None:
self.hits[axis].append((now, value))
def latest(self, axis: str) -> Optional[float]:
"""最後に受け取った値。Confirm を押した時点の状態にあたる。"""
buffer = self.samples[axis]
return buffer[-1][1] if buffer else None
def latest_sample(self, axis: str) -> Optional[Tuple[float, float]]:
"""最後に受け取った値と、その時刻。"""
buffer = self.samples[axis]
return buffer[-1] if buffer else None
def latest_hit(self, axis: str) -> Optional[bool]:
buffer = self.hits[axis]
return buffer[-1][1] if buffer else None
def window_median(self, axis: str, now: float) -> Optional[float]:
values = [v for (t, v) in self.samples[axis]
if now - SAMPLE_WINDOW_START <= t <= now - SAMPLE_WINDOW_END]
if not values:
# 区間に何も無ければ諦める。ここで全件の中央値へ落ちると、
# 動いていた頃の古い値まで混ざった距離になる。
# 呼び出し側が最後の1件で代用する。
return None
values.sort()
return values[len(values) // 2]
def window_all_hit(self, axis: str, now: float) -> Optional[bool]:
values = [v for (t, v) in self.hits[axis]
if now - SAMPLE_WINDOW_START <= t <= now - SAMPLE_WINDOW_END]
if not values:
values = [v for (_, v) in self.hits[axis]]
if not values:
return None
return all(values)
def hit_counts(self, axis: str, now: float) -> Tuple[int, int]:
"""区間内で当たった件数と全件数。区間が空なら持っている全部で数える。"""
values = [v for (t, v) in self.hits[axis]
if now - SAMPLE_WINDOW_START <= t <= now - SAMPLE_WINDOW_END]
if not values:
values = [v for (_, v) in self.hits[axis]]
return sum(1 for v in values if v), len(values)
def spread(self, axis: str, now: float) -> Optional[float]:
values = [v for (t, v) in self.samples[axis]
if now - SAMPLE_WINDOW_START <= t <= now - SAMPLE_WINDOW_END]
if len(values) < 2:
return None
return max(values) - min(values)
def counts(self, axis: str, now: float) -> Tuple[int, int]:
"""(区間内の件数, バッファ全体の件数) を返す。区間が空なら直近値へ退避している。"""
total = len(self.samples[axis])
inside = sum(1 for (t, _) in self.samples[axis]
if now - SAMPLE_WINDOW_START <= t <= now - SAMPLE_WINDOW_END)
return inside, total
class State:
def __init__(self) -> None:
self.lock = threading.Lock()
self.slots = {n: SlotState() for n in range(1, SLOT_COUNT + 1)}
self.last_message_at: Optional[float] = None
self.total_messages = 0
self.generated = 0
# カメラ設定。スロットに属さないので State が持つ。正規化された 0〜1。
self.camera: Dict[str, float] = {
key: (DEFAULT_CAMERA[key] - lo) / (hi - lo)
for key, (lo, hi) in CAMERA_RANGE.items()
}
# 一度も受信していない項目。ログで出どころを分けるために持つ
self.camera_initial: set = set(CAMERA_RANGE)
# VRChat が /avatar/eyeheight で送ってくる、現在のアバターの目線の高さ。
# アバターの切り替え時とスケール変更時にしか届かない。
self.eye_height: Optional[float] = None
# OSC 送信クライアント。生成後の自動読み込みに使う
self.client: Optional[Any] = None
self.last_output: Optional[Path] = None
# 水平倍率の微調整。トレイから変更して校正し、決まったら
# config の reference_eye_height に書き戻す。
self.scale_trim = 1.0
def touch(self) -> None:
self.last_message_at = time.monotonic()
self.total_messages += 1
def is_active(self) -> bool:
if self.last_message_at is None:
return False
return time.monotonic() - self.last_message_at < ACTIVE_TIMEOUT
# ---------------------------------------------------------------------------
# 値の変換
# ---------------------------------------------------------------------------
def denorm(value: float, minimum: float, maximum: float) -> float:
return minimum + value * (maximum - minimum)
def solve_center(ra: float, rb: float, rc: float, rho: float, baseline: float) -> Tuple[float, float]:
"""3本の距離から固定点のプレイヤー基準の相対位置 (x, z) を求める。
原点は A=(0,0)、B=(baseline,0)、C=(0,baseline)。
高さは差を取る過程で消えるので不要。
"""
d = baseline
x = (ra * ra - rb * rb + 2.0 * rho * (ra - rb) + d * d) / (2.0 * d)
z = (ra * ra - rc * rc + 2.0 * rho * (ra - rc) + d * d) / (2.0 * d)
# 解いたのは胴体の位置なので、固定点の位置は符号を反転したもの
return -x, -z
def rotate_y(point: Tuple[float, float, float], degrees: float) -> Tuple[float, float, float]:
a = math.radians(degrees)
x, y, z = point
return (x * math.cos(a) + z * math.sin(a), y, -x * math.sin(a) + z * math.cos(a))
def rotate_x(point: Tuple[float, float, float], degrees: float) -> Tuple[float, float, float]:
a = math.radians(degrees)
x, y, z = point
return (x, y * math.cos(a) - z * math.sin(a), y * math.sin(a) + z * math.cos(a))
def look_rotation(camera: Tuple[float, float, float],
target: Tuple[float, float, float]) -> Tuple[float, float]:
"""カメラから注視点を見る回転を (X, Y) の度数で返す。
サンプル JSON(R48h20b15-2.json)で実測値と一致することを確認済み。
Unity は X が正で下向きなので、注視点が上にあるとき X は負になる。
"""
dx = target[0] - camera[0]
dy = target[1] - camera[1]
dz = target[2] - camera[2]
horizontal = math.hypot(dx, dz)
yaw = math.degrees(math.atan2(dx, dz)) % 360.0
pitch = -math.degrees(math.atan2(dy, horizontal)) if horizontal > 1e-9 else 0.0
return pitch, yaw
# ---------------------------------------------------------------------------
# 軌道生成
# ---------------------------------------------------------------------------
class OrbitInput:
def __init__(self) -> None:
self.center_x = 0.0
self.center_z = 0.0
self.center_height = DEFAULT_MENU["Height"]
self.ring_height = DEFAULT_MENU["RingHeight"]
self.radius = DEFAULT_MENU["Radius"]
self.tilt = DEFAULT_MENU["Tilt"]
self.tilt_dir = DEFAULT_MENU["TiltDir"]
self.points = int(DEFAULT_MENU["Points"])
self.random_percent = int(DEFAULT_MENU["Random"])
self.clockwise = bool(DEFAULT_MENU["CW"])
self.report: List[str] = [] # ログにそのまま出す行
self.defaulted: List[str] = [] # 既定値で埋めた項目
# 中心が求まらなかったときに、欠けていた軸を入れる。
# 空でなければ生成そのものを中止する。
self.center_is_self = False
def tilt_mark_bearing(tilt_dir: float) -> float:
"""ガイドの目印が向く方位。傾きの符号によらずここを指す。
アバター側の目印は TiltPivot の局所 +Z に置いてあるので、この方位が
そのまま目印の位置になる。ログもこれを出さないとガイドと食い違う。
"""
return LOW_POINT_START_DEG + tilt_dir * LOW_POINT_SWEEP_DEG
def tilt_mark_end(tilt: float) -> Optional[str]:
"""目印が円のどちら端かを返す。水平なら定まらないので None。
局所 +Z が下がるのは傾きが正のときだけ。負のときは逆に上がる。
どちらの端でも傾きは読み取れるので、位置は変えず呼び方だけ分ける。
"""
if abs(tilt) < 1e-6:
return None
return "最下点" if tilt > 0.0 else "最高点"
def camera_values(state: "State") -> Tuple[Dict[str, float], List[str]]:
"""受信済みのカメラ設定を実寸へ戻す。報告用の行も一緒に返す。"""
with state.lock:
raw = dict(state.camera)
initial = set(state.camera_initial)
values: Dict[str, float] = {}
report: List[str] = []
for key, (lo, hi) in CAMERA_RANGE.items():
values[key] = denorm(raw[key], lo, hi)
tag = "[初期]" if key in initial else "[受信]"
report.append(f" {key:<12} パペット {raw[key]*100:5.1f}% -> "
f"{values[key]:8.2f} {tag}")
return values, report
def compass_name(degrees: float) -> str:
"""プレイヤー基準の方位を日本語の向きにする。0 が正面奥、+90 が右。"""
names = ((0.0, "奥"), (45.0, "右奥"), (90.0, "右"), (135.0, "右手前"),
(180.0, "手前"), (225.0, "左手前"), (270.0, "左"), (315.0, "左奥"))
value = degrees % 360.0
return min(names, key=lambda n: abs((value - n[0] + 180.0) % 360.0 - 180.0))[1]
def jitter_wave(progress: float, phases: Tuple[float, ...]) -> float:
"""経路の進み具合 0〜1 に対する -1〜+1 の滑らかな値を返す。
倍音が整数なので progress=0 と 1 でつながる。位相は呼ぶ側が乱数で決める。
倍音の数で割っているので、指定した揺らぎ幅を超えることはない。
"""
angle = 2.0 * math.pi * progress
total = sum(math.sin(h * angle + p) for h, p in zip(JITTER_HARMONICS, phases))
return total / len(JITTER_HARMONICS)
def build_path(inputs: OrbitInput, laps: int, jitter: float,
template: Dict[str, Any], scale: float = 1.0,
vertical_ratio: float = DEFAULT_JITTER_VERTICAL_RATIO
) -> List[Dict[str, Any]]:
"""円周上に等分配置した点へ揺らぎを与え、傾きを適用して並べる。
揺らぎは半径方向と上下方向にだけ与える。円周に沿う向きへは動かさないので
点の間隔が保たれ、カメラの速度が一定に近くなる(JITTER_HARMONICS 参照)。
scale は目線の高さによる正規化の倍率。VRChat が正規化するのは水平方向
だけなので、掛けるのは X と Z のみ。Y はメートルのまま書く。
向きはメートル基準の座標から求めるので、倍率の影響を受けない。
"""
center = (inputs.center_x, inputs.center_height, inputs.center_z)
ring_origin = (inputs.center_x, inputs.ring_height, inputs.center_z)
# 最下点の方位。0% が右、50% が手前、100% が左(LOW_POINT_START_DEG 参照)
azimuth = LOW_POINT_START_DEG + inputs.tilt_dir * LOW_POINT_SWEEP_DEG
# Unity は左手系なので、上から見ると θ を増やす向きは反時計回り(左回り)。
# 時計回り(右回り)にするには θ の符号を反転する。
spin = -1.0 if inputs.clockwise else 1.0
# 半径方向と上下方向で別の位相を引く。生成のたびに違う軌道になる。
radial_phases = tuple(random.uniform(0.0, 2.0 * math.pi) for _ in JITTER_HARMONICS)
vertical_phases = tuple(random.uniform(0.0, 2.0 * math.pi) for _ in JITTER_HARMONICS)
total_points = laps * inputs.points
entries: List[Dict[str, Any]] = []
index = 0
for _lap in range(laps):
for step in range(inputs.points):
theta = spin * 2.0 * math.pi * step / inputs.points
progress = index / total_points
# 半径だけを伸び縮みさせる。角度は等分のまま動かさない
radius = inputs.radius * (1.0 + jitter * jitter_wave(progress, radial_phases))
local_x = radius * math.cos(theta)
local_z = radius * math.sin(theta)
# 上下は水平よりずっと浅く。傾きの前に混ぜると円ごと歪むので後で足す
rise = (jitter * vertical_ratio * inputs.radius
* jitter_wave(progress, vertical_phases))
# Unity の階層 TiltAzimuth(Y) -> TiltPivot(X) と同じ順序で適用する
offset = rotate_y(rotate_x((local_x, 0.0, local_z), inputs.tilt), azimuth)
position = (ring_origin[0] + offset[0],
ring_origin[1] + offset[1] + rise,
ring_origin[2] + offset[2])
pitch, yaw = look_rotation(position, center)
entry = dict(template)
entry["Position"] = {"X": round(position[0] * scale, 6),
"Y": round(position[1], 6),
"Z": round(position[2] * scale, 6)}
entry["Rotation"] = {"X": round(pitch, 6), "Y": round(yaw, 6), "Z": 0.0}
entry["Index"] = index
entries.append(entry)
index += 1
return entries
def collect_inputs(slot: SlotState, now: float, config: Config, log: Logger,
use_window: bool = True) -> OrbitInput:
"""受信済みの値を集める。足りないものは既定値で埋め、その旨を報告に残す。
use_window=False にすると、区間の中央値ではなく最後に受け取った値を使う。
Confirm を押した時点の状態にあたり、押す動作で身体が動いた分が入る。
"""
inputs = OrbitInput()
inputs.report.append("[受信状況]")
distances: Dict[str, Optional[float]] = {}
for axis in AXES:
distances[axis] = (slot.window_median(axis, now) if use_window
else slot.latest(axis))
hit = slot.window_all_hit(axis, now)
inside, total = slot.counts(axis, now)
spread = slot.spread(axis, now)
stale_age: Optional[float] = None
if distances[axis] is None:
recent = slot.latest_sample(axis)
if recent is not None:
stamp, value = recent
distances[axis] = value
stale_age = now - stamp
hit = slot.latest_hit(axis)
# 外れたときの距離は 0 で届く。そのまま三辺測量に渡すと基線から
# 決まる定数が中心として出てしまうため、使わない。
#
# ただし「1件でも外れたら不可」は厳しすぎる。動いている間は
# ときどき外れるので、一度も当たっていないときだけ捨てる。
hit_ok, hit_total = slot.hit_counts(axis, now)
if hit_total > 0 and hit_ok == 0:
distances[axis] = None
inputs.report.append(
f" Probe{axis} レイが一度も当たっていません({hit_total} 件すべて外れ)")
continue
if distances[axis] is None:
inputs.report.append(f" Probe{axis} 未受信")
continue
if stale_age is not None:
inputs.report.append(
f" Probe{axis} 距離 {distances[axis]:.4f} m Hit {hit} "
f"※ {stale_age:.0f} 秒前の最終値(動きが無いため補充されていない)")
continue
marks = []
if hit_total > 0 and hit_ok < hit_total:
marks.append(f"当たり {hit_ok}/{hit_total} 件")
if spread is not None and spread > 0.05:
marks.append(f"ばらつき {spread*1000:.0f}mm(静止していない可能性)")
inputs.report.append(
f" Probe{axis} 距離 {distances[axis]:.4f} m Hit {hit} "
f"区間 {inside}件 / 全 {total}件"
+ (" ※ " + " / ".join(marks) if marks else ""))
if all(distances[a] is not None for a in AXES):
inputs.center_x, inputs.center_z = solve_center(
distances["A"], distances["B"], distances["C"], config.rho, PROBE_BASELINE)
else:
# 測距値が無い、またはレイが当たっていない。印を置いていないか、
# 置いた印を測れていない。どちらも「自分の周りを回る」で意味が通るので、
# 中止せず自分を中心にする。
#
# 距離 0 をそのまま三辺測量へ流してはいけない。0/0/0 は解けてしまい、
# 基線から決まる (-0.75, -0.75) という、自分でも印でもない点が出る。
missing = [a for a in AXES if distances[a] is None]
inputs.center_x, inputs.center_z = 0.0, 0.0
inputs.center_is_self = True
inputs.report.append(
f" ※ 測距値({','.join(missing)})が無いため、自分を中心にします")
menu = slot.menu
def source(key: str) -> str:
if key in slot.initial:
inputs.defaulted.append(key)
return "[初期]"
return "[受信]"
def take(key: str, minimum: float, maximum: float, default_value: float,
unit: str) -> float:
# パペットの表示は % なので、突き合わせやすいよう % も併記する
if key in menu:
raw = menu[key]
value = denorm(raw, minimum, maximum)
inputs.report.append(f" {key:<12} パペット {raw*100:5.1f}% -> "
f"{value:8.2f} {unit} {source(key)}")
return value
inputs.defaulted.append(key)
inputs.report.append(f" {key:<12} パペット {'--':>5} -> "
f"{default_value:8.2f} {unit} [既定]")
return default_value
inputs.center_height = take("Height", HEIGHT_MIN, HEIGHT_MAX, DEFAULT_MENU["Height"], "m")
inputs.ring_height = take("RingHeight", HEIGHT_MIN, HEIGHT_MAX, DEFAULT_MENU["RingHeight"], "m")
inputs.radius = take("Radius", RADIUS_MIN, RADIUS_MAX, DEFAULT_MENU["Radius"], "m")
inputs.tilt = take("Tilt", TILT_MIN, TILT_MAX, DEFAULT_MENU["Tilt"], "度")
# メニュー上の名前は Low Point。OSC のアドレスは TiltDir のまま
if "TiltDir" in menu:
inputs.tilt_dir = menu["TiltDir"]
tag = source("TiltDir")
else:
inputs.tilt_dir = DEFAULT_MENU["TiltDir"]
inputs.defaulted.append("TiltDir")
tag = "[既定]"
mark = tilt_mark_bearing(inputs.tilt_dir)
end = tilt_mark_end(inputs.tilt)
where = (f"{mark:+8.2f} 度 {compass_name(mark):<4}({end})" if end
else f"{mark:+8.2f} 度 {compass_name(mark):<4}(傾き 0 で無効)")
inputs.report.append(f" {'LowPoint':<12} パペット {inputs.tilt_dir*100:5.1f}% -> "
f"{where} {tag}")
if "Points" in menu:
inputs.points = int(round(menu["Points"]))
inputs.report.append(f" {'Points':<12} {inputs.points:8d} {source('Points')}")
else:
inputs.defaulted.append("Points")
inputs.report.append(f" {'Points':<12} {int(DEFAULT_MENU['Points']):8d} [既定]")
if inputs.points not in config.laps:
inputs.report.append(f" ※ Points={inputs.points} は想定外の値です。6 として扱います")
inputs.points = 6
if "Random" in menu:
inputs.random_percent = int(round(menu["Random"]))
inputs.report.append(f" {'Random':<12} {inputs.random_percent:8d} %"
f" {source('Random')}")
else:
inputs.defaulted.append("Random")
inputs.report.append(f" {'Random':<12} {int(DEFAULT_MENU['Random']):8d} %"
f" [既定]")
if "CW" in menu:
inputs.clockwise = menu["CW"] >= 0.5
label = "右回り" if inputs.clockwise else "左回り"
inputs.report.append(f" {'CW':<12} {label:>8}"
f" {source('CW')}")
else:
inputs.defaulted.append("CW")
label = "右回り" if DEFAULT_MENU["CW"] else "左回り"
inputs.report.append(f" {'CW':<12} {label:>8} [既定]")
return inputs
# 校正用に OSC で送る高さの候補(m)。パペットでは正確な値を作れないため。
HEIGHT_PRESETS = (0.0, 0.5, 1.0, 1.2, 2.0)
def send_height(client: Any, slot_number: int, meters: float,
config: Config, log: Logger) -> None:
"""指定スロットの注視点と円の高さを、正確な値に設定する。
VRChat は OSC の入力も受け付けるので、パペットを介さずに
パラメータを直接書ける。正規化はツール側で行う。
"""
if client is None:
log.error("OSC 送信クライアントが利用できません")
return
value = (meters - HEIGHT_MIN) / (HEIGHT_MAX - HEIGHT_MIN)
value = min(max(value, 0.0), 1.0)
for key in ("Height", "RingHeight"):
address = f"{PARAM_ROOT}CamDrone/Obj{slot_number}/{key}"
try:
client.send_message(address, float(value))
except Exception as exc:
log.error(f"送信に失敗しました {address}: {exc}")
return
log.info(f"Obj{slot_number}: 高さを {meters:.2f} m に設定しました"
f"(正規化 {value:.4f} を {config.host}:{config.send_port} へ送信)")
def advertised_paths() -> List[str]:
"""OSCQuery で「これを送ってほしい」と宣言するアドレス一覧。"""
paths = [oscquery.PARAM_ROOT.rstrip("/")]
for slot in range(1, SLOT_COUNT + 1):
for axis in AXES:
base = f"{PARAM_ROOT}CamDrone/Probe{slot}_{axis}"
paths += [base + "_Hit", base + "_Distance"]
for key in ("Height", "RingHeight", "Radius", "Tilt", "TiltDir",
"Points", "Random", "CW", "Confirm"):
paths.append(f"{PARAM_ROOT}CamDrone/Obj{slot}/{key}")
for key in CAMERA_RANGE:
paths.append(f"{PARAM_ROOT}CamDrone/Camera/{key}")
paths.append(EYE_HEIGHT_ADDRESS)
return paths
def bind_server(config: Config, port: int, disp: Any) -> Tuple[Any, int]:
"""UDP で待ち受ける。port が 0 なら OS が空きを選ぶ。
実際に確保できた番号を返す。0 を渡したときは呼び出し側が知る術がない。
"""
server = osc_server.ThreadingOSCUDPServer((config.host, port), disp)
return server, server.server_address[1]
def establish_transport(config: Config, log: Logger,
disp: Any) -> Tuple[str, Optional[Any], Optional[Any], int]:
"""設定された優先順で接続方法を確立し、待ち受けまで済ませる。
待ち受けるポートは方式によって別にする。
- **oscquery** — 番号は mDNS で VRChat へ伝えるので、設定値である必要が
無い。空きポートを取る。UDP リピーターのような「特定の番号へ配る」
仕組みと番号が重ならないため、同じ配信を二重に受け取らずに済む。
- **osc** — 番号を伝える手段が無いので、設定値をそのまま使う。
両者を同じ番号にすると、リピーター経由の配信と VRChat からの直接送信が
重なる。Confirm は立ち上がりで検出しているが、2系統がずれて届くと
立ち上がりが2回成立し、1回の押下で2本生成されてしまう。
一度これで何も届かなくなり設定値へ戻したが、原因は番号ではなく
mDNS で LAN の IP を広告していたことだった(oscquery.local_ip 参照)。
VRChat は localhost からしか受け付けないため、どの番号で待っていても
届かなかった。そちらを直したので、この分け方が本来の形。
"""
order = [config.connection]
if config.fallback:
order.append("osc" if config.connection == "oscquery" else "oscquery")
for method in order:
if method == "osc":
try:
server, port = bind_server(config, config.receive_port, disp)
except OSError as exc:
log.error(f"固定ポート {config.receive_port} で待ち受けられません: {exc}")
continue
log.info(f"接続方法: OSC(UDP) 固定ポート {port}")
log.info(" VRChat 側の OSC 送信先をこのポートに合わせてください")
return "osc", None, server, port
if not oscquery.AVAILABLE:
log.warn("OSCQuery を使えません(zeroconf が見つかりません)")
continue
try:
server, port = bind_server(config, 0, disp)
except OSError as exc:
log.error(f"待ち受けポートを確保できません: {exc}")
continue
service = oscquery.Service(oscquery.SERVICE_NAME, port)
ok, note = service.start(advertised_paths())
if ok:
log.info(f"接続方法: OSCQuery — {note}")
log.info(" VRChat が自動で見つけるため、送信先の設定は不要です")
return "oscquery", service, server, port