pycympleosc 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pycympleosc-0.1.2/.idea/.gitignore +5 -0
- pycympleosc-0.1.2/.idea/PyCympleOSC.iml +11 -0
- pycympleosc-0.1.2/.idea/inspectionProfiles/Project_Default.xml +55 -0
- pycympleosc-0.1.2/.idea/inspectionProfiles/profiles_settings.xml +6 -0
- pycympleosc-0.1.2/.idea/misc.xml +7 -0
- pycympleosc-0.1.2/.idea/modules.xml +8 -0
- pycympleosc-0.1.2/.idea/workspace.xml +118 -0
- pycympleosc-0.1.2/LICENSE +21 -0
- pycympleosc-0.1.2/PKG-INFO +95 -0
- pycympleosc-0.1.2/README.md +72 -0
- pycympleosc-0.1.2/examples/basic.py +18 -0
- pycympleosc-0.1.2/examples/callbacks.py +19 -0
- pycympleosc-0.1.2/publish.bat +49 -0
- pycympleosc-0.1.2/pyproject.toml +33 -0
- pycympleosc-0.1.2/src/pycympleosc/__init__.py +5 -0
- pycympleosc-0.1.2/src/pycympleosc/decoder.py +132 -0
- pycympleosc-0.1.2/src/pycympleosc/models.py +171 -0
- pycympleosc-0.1.2/src/pycympleosc/receiver.py +239 -0
- pycympleosc-0.1.2/uv.lock +8 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<module type="PYTHON_MODULE" version="4">
|
|
3
|
+
<component name="NewModuleRootManager">
|
|
4
|
+
<content url="file://$MODULE_DIR$">
|
|
5
|
+
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
|
|
6
|
+
<excludeFolder url="file://$MODULE_DIR$/.venv" />
|
|
7
|
+
</content>
|
|
8
|
+
<orderEntry type="jdk" jdkName="uv (PyCympleOSC)" jdkType="Python SDK" />
|
|
9
|
+
<orderEntry type="sourceFolder" forTests="false" />
|
|
10
|
+
</component>
|
|
11
|
+
</module>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
<component name="InspectionProjectProfileManager">
|
|
2
|
+
<profile version="1.0">
|
|
3
|
+
<option name="myName" value="Project Default" />
|
|
4
|
+
<inspection_tool class="D" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
|
5
|
+
<inspection_tool class="PyPackageRequirementsInspection" enabled="false" level="WARNING" enabled_by_default="false">
|
|
6
|
+
<option name="ignoredPackages">
|
|
7
|
+
<list>
|
|
8
|
+
<option value="babel" />
|
|
9
|
+
<option value="contourpy" />
|
|
10
|
+
<option value="cycler" />
|
|
11
|
+
<option value="dill" />
|
|
12
|
+
<option value="docopt" />
|
|
13
|
+
<option value="fonttools" />
|
|
14
|
+
<option value="git-filter-repo" />
|
|
15
|
+
<option value="i18n" />
|
|
16
|
+
<option value="kiwisolver" />
|
|
17
|
+
<option value="llvmlite" />
|
|
18
|
+
<option value="markdown-it-py" />
|
|
19
|
+
<option value="matplotlib" />
|
|
20
|
+
<option value="mdurl" />
|
|
21
|
+
<option value="MouseInfo" />
|
|
22
|
+
<option value="Nuitka" />
|
|
23
|
+
<option value="numba" />
|
|
24
|
+
<option value="opencv-python" />
|
|
25
|
+
<option value="pipreqs" />
|
|
26
|
+
<option value="psutil" />
|
|
27
|
+
<option value="py" />
|
|
28
|
+
<option value="PyAutoGUI" />
|
|
29
|
+
<option value="pyfluidsynth" />
|
|
30
|
+
<option value="pygame" />
|
|
31
|
+
<option value="pygame-ce" />
|
|
32
|
+
<option value="pygame_gui" />
|
|
33
|
+
<option value="PyGetWindow" />
|
|
34
|
+
<option value="Pygments" />
|
|
35
|
+
<option value="PyMsgBox" />
|
|
36
|
+
<option value="pymunk" />
|
|
37
|
+
<option value="pynput" />
|
|
38
|
+
<option value="pyparsing" />
|
|
39
|
+
<option value="pyperclip" />
|
|
40
|
+
<option value="PyRect" />
|
|
41
|
+
<option value="PyScreeze" />
|
|
42
|
+
<option value="python-dateutil" />
|
|
43
|
+
<option value="python-i18n" />
|
|
44
|
+
<option value="pytweening" />
|
|
45
|
+
<option value="pywin32" />
|
|
46
|
+
<option value="rich" />
|
|
47
|
+
<option value="six" />
|
|
48
|
+
<option value="taichi" />
|
|
49
|
+
<option value="typing-inspection" />
|
|
50
|
+
<option value="yarg" />
|
|
51
|
+
</list>
|
|
52
|
+
</option>
|
|
53
|
+
</inspection_tool>
|
|
54
|
+
</profile>
|
|
55
|
+
</component>
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="Black">
|
|
4
|
+
<option name="sdkName" value="uv (PyCympleOSC)" />
|
|
5
|
+
</component>
|
|
6
|
+
<component name="ProjectRootManager" version="2" project-jdk-name="uv (PyCympleOSC)" project-jdk-type="Python SDK" />
|
|
7
|
+
</project>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ProjectModuleManager">
|
|
4
|
+
<modules>
|
|
5
|
+
<module fileurl="file://$PROJECT_DIR$/.idea/PyCympleOSC.iml" filepath="$PROJECT_DIR$/.idea/PyCympleOSC.iml" />
|
|
6
|
+
</modules>
|
|
7
|
+
</component>
|
|
8
|
+
</project>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<project version="4">
|
|
3
|
+
<component name="ChangeListManager">
|
|
4
|
+
<list default="true" id="14a90eaf-43dd-4a85-98bf-b8539fea42ad" name="Changes" comment="" />
|
|
5
|
+
<option name="SHOW_DIALOG" value="false" />
|
|
6
|
+
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
|
7
|
+
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
|
8
|
+
<option name="LAST_RESOLUTION" value="IGNORE" />
|
|
9
|
+
</component>
|
|
10
|
+
<component name="ProjectColorInfo"><![CDATA[{
|
|
11
|
+
"associatedIndex": 2,
|
|
12
|
+
"fromUser": false
|
|
13
|
+
}]]></component>
|
|
14
|
+
<component name="ProjectId" id="3IDb5bElt6VTgfnAqvYdamF96xM" />
|
|
15
|
+
<component name="ProjectViewState">
|
|
16
|
+
<option name="hideEmptyMiddlePackages" value="true" />
|
|
17
|
+
<option name="showLibraryContents" value="true" />
|
|
18
|
+
</component>
|
|
19
|
+
<component name="PropertiesComponent"><![CDATA[{
|
|
20
|
+
"keyToString": {
|
|
21
|
+
"Batch.publish.executor": "Run",
|
|
22
|
+
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
|
23
|
+
"Python.basic.executor": "Run",
|
|
24
|
+
"Python.callbacks.executor": "Run",
|
|
25
|
+
"RunOnceActivity.ShowReadmeOnStart": "true",
|
|
26
|
+
"last_opened_file_path": "A:/DEV/PyCympleOSC"
|
|
27
|
+
}
|
|
28
|
+
}]]></component>
|
|
29
|
+
<component name="RunManager" selected="Batch.publish">
|
|
30
|
+
<configuration name="publish" type="BatchConfigurationType" factoryName="Batch" temporary="true">
|
|
31
|
+
<module name="PyCympleOSC" />
|
|
32
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
33
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
|
34
|
+
<option name="PARENT_ENVS" value="true" />
|
|
35
|
+
<envs>
|
|
36
|
+
<env name="PYPI_TOKEN" value="pypi-AgEIcHlwaS5vcmcCJDEzODgyNmU2LWNjMzItNDI1ZC05ZDgyLTA0MzZjYWZhODdhOQACKlszLCI1MjU5YjZkNi01MWY0LTQyOGMtYWMzMi1mZGRiMzdkMjljYTQiXQAABiDV1PdD-E2sKESuSSYgGyrf_Qd092UoqZ_Z7zIehqahNQ" />
|
|
37
|
+
</envs>
|
|
38
|
+
<option name="SCRIPT_NAME" value="publish.bat" />
|
|
39
|
+
<option name="PARAMETERS" value="" />
|
|
40
|
+
<method v="2" />
|
|
41
|
+
</configuration>
|
|
42
|
+
<configuration name="basic" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
|
|
43
|
+
<module name="PyCympleOSC" />
|
|
44
|
+
<option name="ENV_FILES" value="" />
|
|
45
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
46
|
+
<option name="PARENT_ENVS" value="true" />
|
|
47
|
+
<envs>
|
|
48
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
49
|
+
</envs>
|
|
50
|
+
<option name="SDK_HOME" value="" />
|
|
51
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/examples" />
|
|
52
|
+
<option name="IS_MODULE_SDK" value="true" />
|
|
53
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
54
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
55
|
+
<option name="RUN_TOOL" value="" />
|
|
56
|
+
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/examples/basic.py" />
|
|
57
|
+
<option name="PARAMETERS" value="" />
|
|
58
|
+
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
59
|
+
<option name="EMULATE_TERMINAL" value="false" />
|
|
60
|
+
<option name="MODULE_MODE" value="false" />
|
|
61
|
+
<option name="REDIRECT_INPUT" value="false" />
|
|
62
|
+
<option name="INPUT_FILE" value="" />
|
|
63
|
+
<method v="2" />
|
|
64
|
+
</configuration>
|
|
65
|
+
<configuration name="callbacks" type="PythonConfigurationType" factoryName="Python" temporary="true" nameIsGenerated="true">
|
|
66
|
+
<module name="PyCympleOSC" />
|
|
67
|
+
<option name="ENV_FILES" value="" />
|
|
68
|
+
<option name="INTERPRETER_OPTIONS" value="" />
|
|
69
|
+
<option name="PARENT_ENVS" value="true" />
|
|
70
|
+
<envs>
|
|
71
|
+
<env name="PYTHONUNBUFFERED" value="1" />
|
|
72
|
+
</envs>
|
|
73
|
+
<option name="SDK_HOME" value="" />
|
|
74
|
+
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/examples" />
|
|
75
|
+
<option name="IS_MODULE_SDK" value="true" />
|
|
76
|
+
<option name="ADD_CONTENT_ROOTS" value="true" />
|
|
77
|
+
<option name="ADD_SOURCE_ROOTS" value="true" />
|
|
78
|
+
<option name="RUN_TOOL" value="" />
|
|
79
|
+
<option name="SCRIPT_NAME" value="$PROJECT_DIR$/examples/callbacks.py" />
|
|
80
|
+
<option name="PARAMETERS" value="" />
|
|
81
|
+
<option name="SHOW_COMMAND_LINE" value="false" />
|
|
82
|
+
<option name="EMULATE_TERMINAL" value="false" />
|
|
83
|
+
<option name="MODULE_MODE" value="false" />
|
|
84
|
+
<option name="REDIRECT_INPUT" value="false" />
|
|
85
|
+
<option name="INPUT_FILE" value="" />
|
|
86
|
+
<method v="2" />
|
|
87
|
+
</configuration>
|
|
88
|
+
<list>
|
|
89
|
+
<item itemvalue="Batch.publish" />
|
|
90
|
+
<item itemvalue="Python.basic" />
|
|
91
|
+
<item itemvalue="Python.callbacks" />
|
|
92
|
+
</list>
|
|
93
|
+
<recent_temporary>
|
|
94
|
+
<list>
|
|
95
|
+
<item itemvalue="Batch.publish" />
|
|
96
|
+
<item itemvalue="Python.callbacks" />
|
|
97
|
+
<item itemvalue="Python.basic" />
|
|
98
|
+
</list>
|
|
99
|
+
</recent_temporary>
|
|
100
|
+
</component>
|
|
101
|
+
<component name="SharedIndexes">
|
|
102
|
+
<attachedChunks>
|
|
103
|
+
<set>
|
|
104
|
+
<option value="bundled-python-sdk-1cd77e80b48f-6d6dccd035ac-com.jetbrains.pycharm.pro.sharedIndexes.bundled-PY-253.32098.74" />
|
|
105
|
+
</set>
|
|
106
|
+
</attachedChunks>
|
|
107
|
+
</component>
|
|
108
|
+
<component name="TaskManager">
|
|
109
|
+
<task active="true" id="Default" summary="Default task">
|
|
110
|
+
<changelist id="14a90eaf-43dd-4a85-98bf-b8539fea42ad" name="Changes" comment="" />
|
|
111
|
+
<created>1787301903983</created>
|
|
112
|
+
<option name="number" value="Default" />
|
|
113
|
+
<option name="presentableId" value="Default" />
|
|
114
|
+
<updated>1787301903983</updated>
|
|
115
|
+
</task>
|
|
116
|
+
<servers />
|
|
117
|
+
</component>
|
|
118
|
+
</project>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zarrakun
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pycympleosc
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Python receiver for Cymple Eye and Face tracker OSC data
|
|
5
|
+
Project-URL: Homepage, https://github.com/Dominocs/Project_Cymple
|
|
6
|
+
Project-URL: Repository, https://github.com/Dominocs/Project_Cymple
|
|
7
|
+
Author: Zarrakun
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: cymple,eye-tracking,face-tracking,osc,vrcft,vrchat
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering
|
|
21
|
+
Requires-Python: >=3.8
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# PyCympleOSC
|
|
25
|
+
|
|
26
|
+
Python receiver for Cymple Eye and Face tracker OSC data.
|
|
27
|
+
|
|
28
|
+
No dependencies, pure stdlib OSC decoder. Handles both address forms
|
|
29
|
+
`/EyeLeftX` and `/avatar/parameters/EyeLeftX`.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install pycympleosc
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from pycympleosc import CympleOSC
|
|
41
|
+
|
|
42
|
+
osc = CympleOSC(port=9000)
|
|
43
|
+
osc.start()
|
|
44
|
+
|
|
45
|
+
# polling
|
|
46
|
+
eye = osc.eye
|
|
47
|
+
if eye.available:
|
|
48
|
+
print(eye.left.x, eye.left.y, eye.right.x, eye.right.y)
|
|
49
|
+
|
|
50
|
+
print(osc.face["JawOpen"])
|
|
51
|
+
print(osc.head.yaw)
|
|
52
|
+
|
|
53
|
+
osc.stop()
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Callbacks:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
osc = CympleOSC()
|
|
60
|
+
|
|
61
|
+
@osc.on_eye
|
|
62
|
+
def handle_eye(eye):
|
|
63
|
+
print(eye.left, eye.right)
|
|
64
|
+
|
|
65
|
+
@osc.on_face
|
|
66
|
+
def handle_face(face):
|
|
67
|
+
print(face.jaw_open)
|
|
68
|
+
|
|
69
|
+
@osc.on_raw
|
|
70
|
+
def handle_raw(addr, args):
|
|
71
|
+
print(addr, args)
|
|
72
|
+
|
|
73
|
+
osc.start()
|
|
74
|
+
input("press enter to stop\n")
|
|
75
|
+
osc.stop()
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Context manager:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
with CympleOSC(port=9000) as osc:
|
|
82
|
+
while True:
|
|
83
|
+
print(osc.values)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Multi-port (if eye and face use different ports):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
osc = CympleOSC(ports=[9000, 9001])
|
|
90
|
+
osc.start()
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## VRChat / Cymple
|
|
94
|
+
|
|
95
|
+
Set Cymple OSC target to `127.0.0.1:9000` (or the port you listen on).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# PyCympleOSC
|
|
2
|
+
|
|
3
|
+
Python receiver for Cymple Eye and Face tracker OSC data.
|
|
4
|
+
|
|
5
|
+
No dependencies, pure stdlib OSC decoder. Handles both address forms
|
|
6
|
+
`/EyeLeftX` and `/avatar/parameters/EyeLeftX`.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install pycympleosc
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Usage
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from pycympleosc import CympleOSC
|
|
18
|
+
|
|
19
|
+
osc = CympleOSC(port=9000)
|
|
20
|
+
osc.start()
|
|
21
|
+
|
|
22
|
+
# polling
|
|
23
|
+
eye = osc.eye
|
|
24
|
+
if eye.available:
|
|
25
|
+
print(eye.left.x, eye.left.y, eye.right.x, eye.right.y)
|
|
26
|
+
|
|
27
|
+
print(osc.face["JawOpen"])
|
|
28
|
+
print(osc.head.yaw)
|
|
29
|
+
|
|
30
|
+
osc.stop()
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Callbacks:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
osc = CympleOSC()
|
|
37
|
+
|
|
38
|
+
@osc.on_eye
|
|
39
|
+
def handle_eye(eye):
|
|
40
|
+
print(eye.left, eye.right)
|
|
41
|
+
|
|
42
|
+
@osc.on_face
|
|
43
|
+
def handle_face(face):
|
|
44
|
+
print(face.jaw_open)
|
|
45
|
+
|
|
46
|
+
@osc.on_raw
|
|
47
|
+
def handle_raw(addr, args):
|
|
48
|
+
print(addr, args)
|
|
49
|
+
|
|
50
|
+
osc.start()
|
|
51
|
+
input("press enter to stop\n")
|
|
52
|
+
osc.stop()
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Context manager:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
with CympleOSC(port=9000) as osc:
|
|
59
|
+
while True:
|
|
60
|
+
print(osc.values)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Multi-port (if eye and face use different ports):
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
osc = CympleOSC(ports=[9000, 9001])
|
|
67
|
+
osc.start()
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## VRChat / Cymple
|
|
71
|
+
|
|
72
|
+
Set Cymple OSC target to `127.0.0.1:9000` (or the port you listen on).
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from pycympleosc import CympleOSC
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
osc = CympleOSC(port=9000)
|
|
5
|
+
osc.start()
|
|
6
|
+
|
|
7
|
+
try:
|
|
8
|
+
while True:
|
|
9
|
+
eye = osc.eye
|
|
10
|
+
if eye.available:
|
|
11
|
+
print(f"left={eye.left} right={eye.right}")
|
|
12
|
+
face = osc.face
|
|
13
|
+
if face.available:
|
|
14
|
+
print(f"JawOpen={face.get('JawOpen')} Smile={face.get('MouthSmileLeft')}")
|
|
15
|
+
print(f"head={osc.head} raw={len(osc.values)} values")
|
|
16
|
+
time.sleep(0.2)
|
|
17
|
+
except KeyboardInterrupt:
|
|
18
|
+
osc.stop()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pycympleosc import CympleOSC
|
|
2
|
+
|
|
3
|
+
osc = CympleOSC(port=9000)
|
|
4
|
+
|
|
5
|
+
@osc.on_eye
|
|
6
|
+
def eye_cb(eye):
|
|
7
|
+
print("eye", eye.left, eye.right)
|
|
8
|
+
|
|
9
|
+
@osc.on_face
|
|
10
|
+
def face_cb(face):
|
|
11
|
+
print("face", face.get("JawOpen"))
|
|
12
|
+
|
|
13
|
+
@osc.on_raw
|
|
14
|
+
def raw_cb(addr, args):
|
|
15
|
+
print(addr, args)
|
|
16
|
+
|
|
17
|
+
osc.start()
|
|
18
|
+
input("press enter to stop\n")
|
|
19
|
+
osc.stop()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
@echo off
|
|
2
|
+
setlocal
|
|
3
|
+
cd /d %~dp0
|
|
4
|
+
set PY=python
|
|
5
|
+
where %PY% >nul 2>&1 || set PY=C:\Users\Zarrakun\AppData\Local\Programs\Python\Python315\python.exe
|
|
6
|
+
|
|
7
|
+
echo Cleaning...
|
|
8
|
+
if exist dist rmdir /s /q dist
|
|
9
|
+
if exist build rmdir /s /q build
|
|
10
|
+
for /d %%i in (src\*.egg-info) do rmdir /s /q "%%i" 2>nul
|
|
11
|
+
for /d %%i in (*.egg-info) do rmdir /s /q "%%i" 2>nul
|
|
12
|
+
|
|
13
|
+
%PY% -m pip install --upgrade build twine >nul
|
|
14
|
+
if errorlevel 1 exit /b 1
|
|
15
|
+
|
|
16
|
+
echo Building...
|
|
17
|
+
%PY% -m build
|
|
18
|
+
if errorlevel 1 exit /b 1
|
|
19
|
+
|
|
20
|
+
echo.
|
|
21
|
+
echo === PyPI upload ===
|
|
22
|
+
echo Create token at https://pypi.org/manage/account/token/
|
|
23
|
+
echo Use: set PYPI_TOKEN=pypi-... before running, or paste when prompted
|
|
24
|
+
echo Username is always __token__
|
|
25
|
+
echo.
|
|
26
|
+
|
|
27
|
+
set REPO=
|
|
28
|
+
if /i "%1"=="test" set REPO=--repository testpypi
|
|
29
|
+
if /i "%1"=="test" echo [TESTPYPI mode]
|
|
30
|
+
|
|
31
|
+
if defined PYPI_TOKEN (
|
|
32
|
+
echo Using PYPI_TOKEN env var
|
|
33
|
+
%PY% -m twine upload %REPO% --username __token__ --password "%PYPI_TOKEN%" dist\*
|
|
34
|
+
) else if defined TWINE_PASSWORD (
|
|
35
|
+
%PY% -m twine upload %REPO% --username __token__ --password "%TWINE_PASSWORD%" dist\*
|
|
36
|
+
) else (
|
|
37
|
+
echo No token in env. Twine will prompt interactively.
|
|
38
|
+
echo When asked for username enter: __token__
|
|
39
|
+
echo When asked for password paste your token
|
|
40
|
+
echo.
|
|
41
|
+
%PY% -m twine upload %REPO% --username __token__ dist\*
|
|
42
|
+
)
|
|
43
|
+
if errorlevel 1 (
|
|
44
|
+
echo Upload failed. Check token and try again.
|
|
45
|
+
pause
|
|
46
|
+
exit /b 1
|
|
47
|
+
)
|
|
48
|
+
echo Done.
|
|
49
|
+
pause
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pycympleosc"
|
|
7
|
+
version = "0.1.2"
|
|
8
|
+
description = "Python receiver for Cymple Eye and Face tracker OSC data"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "Zarrakun"}]
|
|
13
|
+
keywords = ["cymple", "osc", "vrchat", "eye-tracking", "face-tracking", "vrcft"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Programming Language :: Python :: 3.8",
|
|
20
|
+
"Programming Language :: Python :: 3.9",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Topic :: Scientific/Engineering",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.urls]
|
|
29
|
+
Homepage = "https://github.com/Dominocs/Project_Cymple"
|
|
30
|
+
Repository = "https://github.com/Dominocs/Project_Cymple"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/pycympleosc"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, List, Tuple
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class OscError(ValueError):
|
|
9
|
+
pass
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class Message:
|
|
14
|
+
address: str
|
|
15
|
+
args: Tuple[Any, ...]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _align(v: int) -> int:
|
|
19
|
+
return (v + 3) & ~3
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_string(data: bytes, pos: int, end: int) -> Tuple[str, int]:
|
|
23
|
+
nul = data.find(b"\x00", pos, end)
|
|
24
|
+
if nul < 0:
|
|
25
|
+
raise OscError("unterminated string")
|
|
26
|
+
return data[pos:nul].decode("utf-8", errors="replace"), _align(nul + 1)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _read_blob(data: bytes, pos: int, end: int) -> Tuple[bytes, int]:
|
|
30
|
+
if pos + 4 > end:
|
|
31
|
+
raise OscError("truncated blob header")
|
|
32
|
+
(size,) = struct.unpack_from(">i", data, pos)
|
|
33
|
+
start = pos + 4
|
|
34
|
+
if size < 0 or start + size > end:
|
|
35
|
+
raise OscError("truncated blob")
|
|
36
|
+
return bytes(data[start:start + size]), _align(start + size)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_args(data: bytes, pos: int, end: int, tags: str) -> Tuple[Tuple[Any, ...], int]:
|
|
40
|
+
out: List[Any] = []
|
|
41
|
+
for tag in tags[1:]:
|
|
42
|
+
if tag == "i":
|
|
43
|
+
if pos + 4 > end:
|
|
44
|
+
raise OscError("truncated int")
|
|
45
|
+
(v,) = struct.unpack_from(">i", data, pos)
|
|
46
|
+
pos += 4
|
|
47
|
+
elif tag == "f":
|
|
48
|
+
if pos + 4 > end:
|
|
49
|
+
raise OscError("truncated float")
|
|
50
|
+
(v,) = struct.unpack_from(">f", data, pos)
|
|
51
|
+
pos += 4
|
|
52
|
+
elif tag == "h":
|
|
53
|
+
if pos + 8 > end:
|
|
54
|
+
raise OscError("truncated int64")
|
|
55
|
+
(v,) = struct.unpack_from(">q", data, pos)
|
|
56
|
+
pos += 8
|
|
57
|
+
elif tag == "t":
|
|
58
|
+
if pos + 8 > end:
|
|
59
|
+
raise OscError("truncated timetag")
|
|
60
|
+
(v,) = struct.unpack_from(">Q", data, pos)
|
|
61
|
+
pos += 8
|
|
62
|
+
elif tag == "d":
|
|
63
|
+
if pos + 8 > end:
|
|
64
|
+
raise OscError("truncated double")
|
|
65
|
+
(v,) = struct.unpack_from(">d", data, pos)
|
|
66
|
+
pos += 8
|
|
67
|
+
elif tag in ("r", "m"):
|
|
68
|
+
if pos + 4 > end:
|
|
69
|
+
raise OscError("truncated")
|
|
70
|
+
(v,) = struct.unpack_from(">I", data, pos)
|
|
71
|
+
pos += 4
|
|
72
|
+
elif tag == "c":
|
|
73
|
+
if pos + 4 > end:
|
|
74
|
+
raise OscError("truncated char")
|
|
75
|
+
(code,) = struct.unpack_from(">i", data, pos)
|
|
76
|
+
pos += 4
|
|
77
|
+
try:
|
|
78
|
+
v = chr(code)
|
|
79
|
+
except ValueError:
|
|
80
|
+
v = code
|
|
81
|
+
elif tag in ("s", "S"):
|
|
82
|
+
v, pos = _read_string(data, pos, end)
|
|
83
|
+
elif tag == "b":
|
|
84
|
+
v, pos = _read_blob(data, pos, end)
|
|
85
|
+
elif tag == "T":
|
|
86
|
+
v = True
|
|
87
|
+
elif tag == "F":
|
|
88
|
+
v = False
|
|
89
|
+
elif tag == "N":
|
|
90
|
+
v = None
|
|
91
|
+
elif tag in ("I", "[", "]"):
|
|
92
|
+
continue
|
|
93
|
+
else:
|
|
94
|
+
raise OscError(f"unknown tag {tag!r}")
|
|
95
|
+
out.append(v)
|
|
96
|
+
return tuple(out), pos
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _decode(data: bytes, pos: int, end: int, out: List[Message], depth: int) -> None:
|
|
100
|
+
if depth > 8:
|
|
101
|
+
raise OscError("bundle too deep")
|
|
102
|
+
head, pos = _read_string(data, pos, end)
|
|
103
|
+
if head == "#bundle":
|
|
104
|
+
if pos + 8 > end:
|
|
105
|
+
raise OscError("truncated bundle timetag")
|
|
106
|
+
pos += 8
|
|
107
|
+
while pos < end:
|
|
108
|
+
if pos + 4 > end:
|
|
109
|
+
raise OscError("truncated bundle size")
|
|
110
|
+
(size,) = struct.unpack_from(">i", data, pos)
|
|
111
|
+
pos += 4
|
|
112
|
+
if size < 0 or pos + size > end:
|
|
113
|
+
raise OscError("bad bundle element")
|
|
114
|
+
_decode(data, pos, pos + size, out, depth + 1)
|
|
115
|
+
pos += size
|
|
116
|
+
return
|
|
117
|
+
if not head.startswith("/"):
|
|
118
|
+
raise OscError(f"bad address {head!r}")
|
|
119
|
+
tags = ","
|
|
120
|
+
if pos < end:
|
|
121
|
+
maybe, nxt = _read_string(data, pos, end)
|
|
122
|
+
if maybe.startswith(","):
|
|
123
|
+
tags = maybe
|
|
124
|
+
pos = nxt
|
|
125
|
+
args, _ = _read_args(data, pos, end, tags)
|
|
126
|
+
out.append(Message(head, args))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def decode(data: bytes) -> List[Message]:
|
|
130
|
+
out: List[Message] = []
|
|
131
|
+
_decode(data, 0, len(data), out, 0)
|
|
132
|
+
return out
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class Gaze:
|
|
9
|
+
x: float
|
|
10
|
+
y: float
|
|
11
|
+
closed: float = 0.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_float(raw: Dict[str, Any], *keys: str) -> Optional[float]:
|
|
15
|
+
for k in keys:
|
|
16
|
+
v = raw.get(k)
|
|
17
|
+
if isinstance(v, (int, float)) and not isinstance(v, bool):
|
|
18
|
+
return float(v)
|
|
19
|
+
return None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _get_vector(raw: Dict[str, Any], key: str) -> Optional[tuple]:
|
|
23
|
+
v = raw.get(key)
|
|
24
|
+
if isinstance(v, (list, tuple)) and len(v) >= 2:
|
|
25
|
+
try:
|
|
26
|
+
return tuple(float(x) for x in v)
|
|
27
|
+
except Exception:
|
|
28
|
+
return None
|
|
29
|
+
return None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EyeData:
|
|
33
|
+
def __init__(self, raw: Dict[str, Any], ts: float = 0.0):
|
|
34
|
+
self.raw = {k: v for k, v in raw.items() if k.startswith("Eye") or k.startswith("Pupil") or k.startswith("tracking/eye")}
|
|
35
|
+
self.ts = ts
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def available(self) -> bool:
|
|
39
|
+
return bool(self.raw)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def left(self) -> Optional[Gaze]:
|
|
43
|
+
x = _get_float(self.raw, "EyeLeftX", "EyeX")
|
|
44
|
+
y = _get_float(self.raw, "EyeLeftY", "EyeY")
|
|
45
|
+
if x is None:
|
|
46
|
+
vec = _get_vector(self.raw, "tracking/eye/LeftRightPitchYaw")
|
|
47
|
+
if vec and len(vec) >= 4:
|
|
48
|
+
x = max(-1.0, min(1.0, vec[1] / 0.6))
|
|
49
|
+
else:
|
|
50
|
+
c = _get_vector(self.raw, "tracking/eye/CenterPitchYaw")
|
|
51
|
+
if c and len(c) >= 2:
|
|
52
|
+
x = max(-1.0, min(1.0, c[1] / 0.6))
|
|
53
|
+
else:
|
|
54
|
+
x = None
|
|
55
|
+
if y is None:
|
|
56
|
+
vec = _get_vector(self.raw, "tracking/eye/LeftRightPitchYaw")
|
|
57
|
+
if vec and len(vec) >= 4:
|
|
58
|
+
y = max(-1.0, min(1.0, vec[0] / 0.6))
|
|
59
|
+
else:
|
|
60
|
+
c = _get_vector(self.raw, "tracking/eye/CenterPitchYaw")
|
|
61
|
+
if c and len(c) >= 2:
|
|
62
|
+
y = max(-1.0, min(1.0, c[0] / 0.6))
|
|
63
|
+
else:
|
|
64
|
+
y = None
|
|
65
|
+
if x is None or y is None:
|
|
66
|
+
return None
|
|
67
|
+
c = _get_float(self.raw, "EyeClosedLeft", "EyeClosed", "tracking/eye/EyesClosedAmount") or 0.0
|
|
68
|
+
return Gaze(float(x), float(y), float(c))
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def right(self) -> Optional[Gaze]:
|
|
72
|
+
x = _get_float(self.raw, "EyeRightX", "EyeX")
|
|
73
|
+
y = _get_float(self.raw, "EyeRightY", "EyeY")
|
|
74
|
+
if x is None:
|
|
75
|
+
vec = _get_vector(self.raw, "tracking/eye/LeftRightPitchYaw")
|
|
76
|
+
if vec and len(vec) >= 4:
|
|
77
|
+
x = max(-1.0, min(1.0, vec[3] / 0.6))
|
|
78
|
+
else:
|
|
79
|
+
c = _get_vector(self.raw, "tracking/eye/CenterPitchYaw")
|
|
80
|
+
if c and len(c) >= 2:
|
|
81
|
+
x = max(-1.0, min(1.0, c[1] / 0.6))
|
|
82
|
+
else:
|
|
83
|
+
x = None
|
|
84
|
+
if y is None:
|
|
85
|
+
vec = _get_vector(self.raw, "tracking/eye/LeftRightPitchYaw")
|
|
86
|
+
if vec and len(vec) >= 4:
|
|
87
|
+
y = max(-1.0, min(1.0, vec[2] / 0.6))
|
|
88
|
+
else:
|
|
89
|
+
c = _get_vector(self.raw, "tracking/eye/CenterPitchYaw")
|
|
90
|
+
if c and len(c) >= 2:
|
|
91
|
+
y = max(-1.0, min(1.0, c[0] / 0.6))
|
|
92
|
+
else:
|
|
93
|
+
y = None
|
|
94
|
+
if x is None or y is None:
|
|
95
|
+
return None
|
|
96
|
+
c = _get_float(self.raw, "EyeClosedRight", "EyeClosed", "tracking/eye/EyesClosedAmount") or 0.0
|
|
97
|
+
return Gaze(float(x), float(y), float(c))
|
|
98
|
+
|
|
99
|
+
def get(self, name: str, default=None):
|
|
100
|
+
return self.raw.get(name, default)
|
|
101
|
+
|
|
102
|
+
def __getitem__(self, key: str):
|
|
103
|
+
return self.raw[key]
|
|
104
|
+
|
|
105
|
+
def __contains__(self, key: str):
|
|
106
|
+
return key in self.raw
|
|
107
|
+
|
|
108
|
+
def __repr__(self):
|
|
109
|
+
return f"EyeData({self.raw})"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FaceData:
|
|
113
|
+
def __init__(self, raw: Dict[str, Any], ts: float = 0.0):
|
|
114
|
+
self.raw = {k: v for k, v in raw.items() if not (k.startswith("Eye") or k.startswith("Pupil") or k.startswith("tracking/eye") or k.startswith("Head/"))}
|
|
115
|
+
self.ts = ts
|
|
116
|
+
|
|
117
|
+
@property
|
|
118
|
+
def available(self) -> bool:
|
|
119
|
+
return bool(self.raw)
|
|
120
|
+
|
|
121
|
+
def get(self, name: str, default=None):
|
|
122
|
+
return self.raw.get(name, default)
|
|
123
|
+
|
|
124
|
+
def __getitem__(self, key: str):
|
|
125
|
+
return self.raw[key]
|
|
126
|
+
|
|
127
|
+
def __contains__(self, key: str):
|
|
128
|
+
return key in self.raw
|
|
129
|
+
|
|
130
|
+
def __getattr__(self, name: str):
|
|
131
|
+
if name.startswith("_"):
|
|
132
|
+
raise AttributeError(name)
|
|
133
|
+
if name in self.raw:
|
|
134
|
+
return self.raw[name]
|
|
135
|
+
parts = name.split("_")
|
|
136
|
+
cand = "".join(p.capitalize() for p in parts)
|
|
137
|
+
if cand in self.raw:
|
|
138
|
+
return self.raw[cand]
|
|
139
|
+
raise AttributeError(name)
|
|
140
|
+
|
|
141
|
+
def __repr__(self):
|
|
142
|
+
return f"FaceData({len(self.raw)} values)"
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(slots=True)
|
|
146
|
+
class HeadData:
|
|
147
|
+
pitch: Optional[float] = None
|
|
148
|
+
yaw: Optional[float] = None
|
|
149
|
+
roll: Optional[float] = None
|
|
150
|
+
x: Optional[float] = None
|
|
151
|
+
y: Optional[float] = None
|
|
152
|
+
z: Optional[float] = None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def build_eye(raw: Dict[str, Any], ts: float) -> EyeData:
|
|
156
|
+
return EyeData(raw, ts)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def build_face(raw: Dict[str, Any], ts: float) -> FaceData:
|
|
160
|
+
return FaceData(raw, ts)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def build_head(raw: Dict[str, Any]) -> HeadData:
|
|
164
|
+
return HeadData(
|
|
165
|
+
pitch=_get_float(raw, "Head/Pitch"),
|
|
166
|
+
yaw=_get_float(raw, "Head/Yaw"),
|
|
167
|
+
roll=_get_float(raw, "Head/Roll"),
|
|
168
|
+
x=_get_float(raw, "Head/PosX"),
|
|
169
|
+
y=_get_float(raw, "Head/PosY"),
|
|
170
|
+
z=_get_float(raw, "Head/PosZ"),
|
|
171
|
+
)
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import socket
|
|
4
|
+
import struct
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from .decoder import Message, OscError, decode
|
|
11
|
+
from .models import EyeData, FaceData, HeadData, build_eye, build_face, build_head
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _normalize(addr: str) -> str:
|
|
15
|
+
if addr.startswith("/avatar/parameters/"):
|
|
16
|
+
return "/" + addr[len("/avatar/parameters/") :]
|
|
17
|
+
return addr
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CympleOSC:
|
|
21
|
+
def __init__(self, host: str = "0.0.0.0", port: int = 9001, ports: Optional[List[int]] = None, normalize_avatar: bool = True):
|
|
22
|
+
self.host = host
|
|
23
|
+
self.ports = ports if ports is not None else [port]
|
|
24
|
+
self.normalize_avatar = normalize_avatar
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
self._values: Dict[str, Any] = {}
|
|
27
|
+
self._counts: Dict[str, int] = {}
|
|
28
|
+
self._updated: Dict[str, float] = {}
|
|
29
|
+
self._packets = 0
|
|
30
|
+
self._messages = 0
|
|
31
|
+
self._bytes = 0
|
|
32
|
+
self._errors = 0
|
|
33
|
+
self._last_mono = 0.0
|
|
34
|
+
self._last_sender = ""
|
|
35
|
+
self._packet_log: deque[Tuple[float, int, int]] = deque(maxlen=2048)
|
|
36
|
+
self._sockets: List[socket.socket] = []
|
|
37
|
+
self._thread: Optional[threading.Thread] = None
|
|
38
|
+
self._running = False
|
|
39
|
+
self._eye_cbs: List[Callable[[EyeData], None]] = []
|
|
40
|
+
self._face_cbs: List[Callable[[FaceData], None]] = []
|
|
41
|
+
self._raw_cbs: List[Callable[[str, Tuple[Any, ...]], None]] = []
|
|
42
|
+
self._any_cbs: List[Callable[[], None]] = []
|
|
43
|
+
|
|
44
|
+
def start(self) -> None:
|
|
45
|
+
if self._running:
|
|
46
|
+
return
|
|
47
|
+
self._sockets = []
|
|
48
|
+
for p in self.ports:
|
|
49
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
50
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
51
|
+
s.bind((self.host, p))
|
|
52
|
+
s.settimeout(0.2)
|
|
53
|
+
self._sockets.append(s)
|
|
54
|
+
self._running = True
|
|
55
|
+
self._thread = threading.Thread(target=self._loop, daemon=True)
|
|
56
|
+
self._thread.start()
|
|
57
|
+
|
|
58
|
+
def stop(self) -> None:
|
|
59
|
+
self._running = False
|
|
60
|
+
if self._thread:
|
|
61
|
+
self._thread.join(timeout=1.5)
|
|
62
|
+
self._thread = None
|
|
63
|
+
for s in self._sockets:
|
|
64
|
+
try:
|
|
65
|
+
s.close()
|
|
66
|
+
except OSError:
|
|
67
|
+
pass
|
|
68
|
+
self._sockets = []
|
|
69
|
+
|
|
70
|
+
def close(self) -> None:
|
|
71
|
+
self.stop()
|
|
72
|
+
|
|
73
|
+
def __enter__(self) -> "CympleOSC":
|
|
74
|
+
self.start()
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
def __exit__(self, *a) -> None:
|
|
78
|
+
self.stop()
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def is_running(self) -> bool:
|
|
82
|
+
return self._running
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def values(self) -> Dict[str, Any]:
|
|
86
|
+
with self._lock:
|
|
87
|
+
return dict(self._values)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def eye(self) -> EyeData:
|
|
91
|
+
with self._lock:
|
|
92
|
+
raw = dict(self._values)
|
|
93
|
+
ts = self._last_mono
|
|
94
|
+
return build_eye(raw, ts)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def face(self) -> FaceData:
|
|
98
|
+
with self._lock:
|
|
99
|
+
raw = dict(self._values)
|
|
100
|
+
ts = self._last_mono
|
|
101
|
+
return build_face(raw, ts)
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def head(self) -> HeadData:
|
|
105
|
+
with self._lock:
|
|
106
|
+
raw = dict(self._values)
|
|
107
|
+
return build_head(raw)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def stats(self) -> Dict[str, Any]:
|
|
111
|
+
with self._lock:
|
|
112
|
+
return {
|
|
113
|
+
"packets": self._packets,
|
|
114
|
+
"messages": self._messages,
|
|
115
|
+
"bytes": self._bytes,
|
|
116
|
+
"errors": self._errors,
|
|
117
|
+
"last_sender": self._last_sender,
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
def on_eye(self, fn: Callable[[EyeData], None]) -> Callable[[EyeData], None]:
|
|
121
|
+
self._eye_cbs.append(fn)
|
|
122
|
+
return fn
|
|
123
|
+
|
|
124
|
+
def on_face(self, fn: Callable[[FaceData], None]) -> Callable[[FaceData], None]:
|
|
125
|
+
self._face_cbs.append(fn)
|
|
126
|
+
return fn
|
|
127
|
+
|
|
128
|
+
def on_raw(self, fn: Callable[[str, Tuple[Any, ...]], None]) -> Callable[[str, Tuple[Any, ...]], None]:
|
|
129
|
+
self._raw_cbs.append(fn)
|
|
130
|
+
return fn
|
|
131
|
+
|
|
132
|
+
def on_update(self, fn: Callable[[], None]) -> Callable[[], None]:
|
|
133
|
+
self._any_cbs.append(fn)
|
|
134
|
+
return fn
|
|
135
|
+
|
|
136
|
+
def _loop(self) -> None:
|
|
137
|
+
import select
|
|
138
|
+
|
|
139
|
+
while self._running:
|
|
140
|
+
if not self._sockets:
|
|
141
|
+
time.sleep(0.05)
|
|
142
|
+
continue
|
|
143
|
+
try:
|
|
144
|
+
readable, _, _ = select.select(self._sockets, [], [], 0.2)
|
|
145
|
+
except OSError:
|
|
146
|
+
break
|
|
147
|
+
for sock in readable:
|
|
148
|
+
try:
|
|
149
|
+
data, addr = sock.recvfrom(65536)
|
|
150
|
+
except OSError:
|
|
151
|
+
continue
|
|
152
|
+
self._handle(data, addr)
|
|
153
|
+
|
|
154
|
+
def _handle(self, data: bytes, addr: Tuple[str, int]) -> None:
|
|
155
|
+
mono = time.monotonic()
|
|
156
|
+
try:
|
|
157
|
+
msgs = decode(data)
|
|
158
|
+
except (OscError, struct.error):
|
|
159
|
+
with self._lock:
|
|
160
|
+
self._packets += 1
|
|
161
|
+
self._bytes += len(data)
|
|
162
|
+
self._errors += 1
|
|
163
|
+
return
|
|
164
|
+
|
|
165
|
+
eye_updated = False
|
|
166
|
+
face_updated = False
|
|
167
|
+
|
|
168
|
+
with self._lock:
|
|
169
|
+
self._packets += 1
|
|
170
|
+
self._messages += len(msgs)
|
|
171
|
+
self._bytes += len(data)
|
|
172
|
+
self._last_mono = mono
|
|
173
|
+
self._last_sender = f"{addr[0]}:{addr[1]}"
|
|
174
|
+
self._packet_log.append((mono, len(msgs), len(data)))
|
|
175
|
+
for m in msgs:
|
|
176
|
+
norm = _normalize(m.address) if self.normalize_avatar else m.address
|
|
177
|
+
key = norm.lstrip("/")
|
|
178
|
+
if not m.args:
|
|
179
|
+
continue
|
|
180
|
+
if len(m.args) == 1:
|
|
181
|
+
v = m.args[0]
|
|
182
|
+
if isinstance(v, bool):
|
|
183
|
+
continue
|
|
184
|
+
if isinstance(v, (int, float)):
|
|
185
|
+
self._values[key] = float(v)
|
|
186
|
+
else:
|
|
187
|
+
self._values[key] = v
|
|
188
|
+
else:
|
|
189
|
+
# store vector as tuple of floats if all numbers
|
|
190
|
+
if all(isinstance(x, (int, float)) and not isinstance(x, bool) for x in m.args):
|
|
191
|
+
self._values[key] = tuple(float(x) for x in m.args)
|
|
192
|
+
else:
|
|
193
|
+
self._values[key] = m.args
|
|
194
|
+
self._counts[key] = self._counts.get(key, 0) + 1
|
|
195
|
+
self._updated[key] = mono
|
|
196
|
+
if key.startswith("Eye") or key.startswith("Pupil") or key.startswith("tracking/eye"):
|
|
197
|
+
eye_updated = True
|
|
198
|
+
elif key.startswith("Head/"):
|
|
199
|
+
face_updated = True # head considered face update
|
|
200
|
+
else:
|
|
201
|
+
face_updated = True
|
|
202
|
+
for cb in self._raw_cbs:
|
|
203
|
+
try:
|
|
204
|
+
cb(norm, m.args)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
|
|
208
|
+
if eye_updated:
|
|
209
|
+
eye = self.eye
|
|
210
|
+
for cb in self._eye_cbs:
|
|
211
|
+
try:
|
|
212
|
+
cb(eye)
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
if face_updated or eye_updated:
|
|
216
|
+
for cb in self._any_cbs:
|
|
217
|
+
try:
|
|
218
|
+
cb()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
|
221
|
+
if face_updated:
|
|
222
|
+
face = self.face
|
|
223
|
+
for cb in self._face_cbs:
|
|
224
|
+
try:
|
|
225
|
+
cb(face)
|
|
226
|
+
except Exception:
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
def packet_rate(self, window: float = 2.0) -> float:
|
|
230
|
+
now = time.monotonic()
|
|
231
|
+
with self._lock:
|
|
232
|
+
recent = [t for t in self._packet_log if now - t[0] <= window]
|
|
233
|
+
return len(recent) / window if window else 0.0
|
|
234
|
+
|
|
235
|
+
def message_rate(self, window: float = 2.0) -> float:
|
|
236
|
+
now = time.monotonic()
|
|
237
|
+
with self._lock:
|
|
238
|
+
recent = [t for t in self._packet_log if now - t[0] <= window]
|
|
239
|
+
return sum(t[1] for t in recent) / window if window else 0.0
|