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.
@@ -0,0 +1,5 @@
1
+ # Default ignored files
2
+ /shelf/
3
+ /workspace.xml
4
+ # Editor-based HTTP Client requests
5
+ /httpRequests/
@@ -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,6 @@
1
+ <component name="InspectionProjectProfileManager">
2
+ <settings>
3
+ <option name="USE_PROJECT_PROFILE" value="false" />
4
+ <version value="1.0" />
5
+ </settings>
6
+ </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,5 @@
1
+ from .models import EyeData, FaceData, Gaze, HeadData
2
+ from .receiver import CympleOSC
3
+
4
+ __all__ = ["CympleOSC", "EyeData", "FaceData", "HeadData", "Gaze"]
5
+ __version__ = "0.1.2"
@@ -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
@@ -0,0 +1,8 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.8"
4
+
5
+ [[package]]
6
+ name = "pycympleosc"
7
+ version = "0.1.2"
8
+ source = { editable = "." }