hapbeat-python-sdk 0.1.0__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.
- hapbeat_python_sdk-0.1.0/LICENSE +21 -0
- hapbeat_python_sdk-0.1.0/PKG-INFO +214 -0
- hapbeat_python_sdk-0.1.0/README.md +181 -0
- hapbeat_python_sdk-0.1.0/pyproject.toml +52 -0
- hapbeat_python_sdk-0.1.0/setup.cfg +4 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/__init__.py +49 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/__main__.py +8 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/cli.py +124 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/client.py +152 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/clip.py +121 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/eventmap.py +203 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/hapbeat.py +446 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/launchpad.py +659 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/osc.py +126 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/protocol.py +253 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat/wav.py +51 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/PKG-INFO +214 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/SOURCES.txt +26 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/dependency_links.txt +1 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/entry_points.txt +2 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/requires.txt +6 -0
- hapbeat_python_sdk-0.1.0/src/hapbeat_python_sdk.egg-info/top_level.txt +1 -0
- hapbeat_python_sdk-0.1.0/tests/test_clip.py +204 -0
- hapbeat_python_sdk-0.1.0/tests/test_examples.py +283 -0
- hapbeat_python_sdk-0.1.0/tests/test_hapbeat.py +96 -0
- hapbeat_python_sdk-0.1.0/tests/test_launchpad.py +180 -0
- hapbeat_python_sdk-0.1.0/tests/test_overlay_osc.py +145 -0
- hapbeat_python_sdk-0.1.0/tests/test_protocol.py +100 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hapbeat
|
|
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,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hapbeat-python-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP
|
|
5
|
+
Author-email: Hapbeat <yus988@hapbeat.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://devtools.hapbeat.com/
|
|
8
|
+
Project-URL: Documentation, https://devtools.hapbeat.com/docs/sdk-integration/
|
|
9
|
+
Project-URL: Repository, https://github.com/hapbeat/hapbeat-python-sdk
|
|
10
|
+
Project-URL: Issues, https://github.com/hapbeat/hapbeat-python-sdk/issues
|
|
11
|
+
Keywords: hapbeat,haptics,udp,osc,vr,research,psychopy
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Multimedia
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Human Machine Interfaces
|
|
24
|
+
Classifier: Topic :: System :: Hardware
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: osc
|
|
29
|
+
Requires-Dist: python-osc>=1.8; extra == "osc"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# Hapbeat Python SDK
|
|
35
|
+
|
|
36
|
+
Drive [Hapbeat](https://hapbeat.com) haptic devices from Python over Wi-Fi UDP.
|
|
37
|
+
For researchers (PsychoPy / Jupyter / ROS), media artists, and anyone
|
|
38
|
+
prototyping haptics in Python.
|
|
39
|
+
|
|
40
|
+
> **📚 Docs**: <https://devtools.hapbeat.com/docs/sdk-integration/>
|
|
41
|
+
|
|
42
|
+
A script can drive Hapbeat with a few lines. The fire side (`play` / `stop`)
|
|
43
|
+
and the tuning side (`EventMap`) are kept orthogonal and linked only by
|
|
44
|
+
event id.
|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pip install hapbeat-python-sdk # core (zero dependencies, stdlib socket only)
|
|
50
|
+
pip install "hapbeat-python-sdk[osc]" # + generic OSC bridge (TouchOSC / Max / TD)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
With **pipx** you get the `hapbeat` CLI (including the launchpad below) in an
|
|
54
|
+
isolated environment: `pipx install hapbeat-python-sdk`. Note that pipx does *not* make
|
|
55
|
+
`import hapbeat` available to your own scripts — for the `examples/` use a
|
|
56
|
+
normal `pip install` in a venv.
|
|
57
|
+
|
|
58
|
+
## Try everything from one page (launchpad)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
hapbeat launchpad # opens http://127.0.0.1:7100 in your browser
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
A single local web page to fire events, run a metronome, a breathing pacer,
|
|
65
|
+
or send Morse — start and stop them live, no per-example launching. It serves
|
|
66
|
+
a tiny stdlib HTTP server that relays button presses to the device over UDP
|
|
67
|
+
(browsers can't send UDP directly). Works great with `pipx install hapbeat-python-sdk`.
|
|
68
|
+
|
|
69
|
+
## Quick start
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
import hapbeat
|
|
73
|
+
|
|
74
|
+
hb = hapbeat.connect(app_name="MyExperiment") # opens UDP broadcast + keep-alive
|
|
75
|
+
hb.play("impact.hit", gain=0.3) # fire event "impact.hit" at gain 0.3
|
|
76
|
+
hb.play("impact.hit") # gain omitted -> kit baseline intensity
|
|
77
|
+
hb.stop("impact.hit")
|
|
78
|
+
hb.stop_all()
|
|
79
|
+
hb.close()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
or as a context manager:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
with hapbeat.connect(app_name="MyExperiment") as hb:
|
|
86
|
+
hb.play("impact.hit")
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`"impact.hit"` must be an event id present in the **kit deployed to the
|
|
90
|
+
device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
|
|
91
|
+
the *instruction*; the waveform lives in the kit on the device.
|
|
92
|
+
|
|
93
|
+
## Discovery
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
for dev in hb.discover(timeout=1.5):
|
|
97
|
+
print(dev.ip, dev.address, dev.firmware_version)
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## EventMap — the tuning side (optional)
|
|
101
|
+
|
|
102
|
+
Keep per-event default gains in one place and let `play("id")` resolve them,
|
|
103
|
+
so firing code never hard-codes intensities:
|
|
104
|
+
|
|
105
|
+
```python
|
|
106
|
+
em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
|
|
107
|
+
hb = hapbeat.connect(event_map=em)
|
|
108
|
+
hb.play("impact.hit") # uses the kit manifest's intensity for this event
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`EventMap` reads the kit manifest (schema 2.0.0) `intensity` as the baseline
|
|
112
|
+
gain. You can also build one by hand: `EventMap.from_dict({"impact.hit": 0.5})`.
|
|
113
|
+
|
|
114
|
+
### Haptic file — add targeting on top of the kit
|
|
115
|
+
|
|
116
|
+
The Studio-generated manifest holds kit content (intensity / clip), but not
|
|
117
|
+
app-side concerns like **which device/body part** an event goes to. Put those
|
|
118
|
+
in a *haptic file* (an EventMap overlay that references the kit), so `play(id)`
|
|
119
|
+
resolves the target without the caller passing it:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
// haptics.json
|
|
123
|
+
{
|
|
124
|
+
"kit": "kits/my-kit",
|
|
125
|
+
"events": {
|
|
126
|
+
"impact.hit": { "target": "player_1/chest", "gain": 0.8 },
|
|
127
|
+
"rain.loop": { "target": "*/back" }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
hb = hapbeat.connect(app_name="MyApp", haptics="haptics.json")
|
|
134
|
+
hb.play("impact.hit") # goes to player_1/chest at gain 0.8 — from the file
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Two playback modes: command and clip
|
|
138
|
+
|
|
139
|
+
The same `play(id)` call branches on the manifest:
|
|
140
|
+
|
|
141
|
+
| Manifest bucket | Mode | What happens |
|
|
142
|
+
|---|---|---|
|
|
143
|
+
| `events` | **command** | the SDK sends a PLAY; the **device** plays its installed clip |
|
|
144
|
+
| `stream_events` | **clip** | the SDK reads the WAV from the kit's `stream-clips/` and **streams** it over UDP |
|
|
145
|
+
|
|
146
|
+
Put the kit inside your project and call events by id — the per-event details
|
|
147
|
+
(intensity, loop, command vs clip, which WAV) live in the kit, not your code:
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
my-app/
|
|
151
|
+
app.py
|
|
152
|
+
kits/my-kit/
|
|
153
|
+
my-kit-manifest.json # the "haptic file" -> EventMap
|
|
154
|
+
install-clips/ # command clips (flashed to the device via Studio)
|
|
155
|
+
stream-clips/*.wav # clip-mode WAVs the SDK streams
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```python
|
|
159
|
+
import hapbeat
|
|
160
|
+
hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
|
|
161
|
+
hb.play("impact.hit") # command -> device plays its installed clip
|
|
162
|
+
hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
|
|
163
|
+
hb.stop("rain.loop") # ends the active stream
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
|
|
167
|
+
L/R amplitude conveys direction):
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
|
|
174
|
+
not resample). A full runnable example is in
|
|
175
|
+
[examples/clip_project/](examples/clip_project/).
|
|
176
|
+
|
|
177
|
+
## Generic OSC bridge
|
|
178
|
+
|
|
179
|
+
Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
|
|
180
|
+
without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
hapbeat osc-bridge --listen 7702
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
See [docs/osc.md](docs/osc.md) for the address spec.
|
|
187
|
+
|
|
188
|
+
## CLI
|
|
189
|
+
|
|
190
|
+
```bash
|
|
191
|
+
hapbeat scan # list devices on the LAN
|
|
192
|
+
hapbeat play impact.hit --gain 0.3
|
|
193
|
+
hapbeat stop-all
|
|
194
|
+
hapbeat launchpad # browser UI for all of the above
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
## Examples
|
|
198
|
+
|
|
199
|
+
Ready-to-run sample applications live in [examples/](examples/):
|
|
200
|
+
a psychophysics experiment, a breathing pacer, a haptic metronome,
|
|
201
|
+
a live trigger pad, a task-completion notifier, and a Morse transmitter.
|
|
202
|
+
Each is a single stdlib-only file — see [examples/README.md](examples/README.md).
|
|
203
|
+
|
|
204
|
+
## For AI coding agents
|
|
205
|
+
|
|
206
|
+
Working with Claude / Cursor / Copilot? Hand your agent [AGENTS.md](AGENTS.md) —
|
|
207
|
+
a single self-contained file with the SDK's model, API, project layout, and
|
|
208
|
+
pitfalls. One file is enough to get the whole picture.
|
|
209
|
+
|
|
210
|
+
> Use the Hapbeat Python SDK. Read `AGENTS.md` and follow its API and best practices.
|
|
211
|
+
|
|
212
|
+
## License
|
|
213
|
+
|
|
214
|
+
MIT © Hapbeat
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# Hapbeat Python SDK
|
|
2
|
+
|
|
3
|
+
Drive [Hapbeat](https://hapbeat.com) haptic devices from Python over Wi-Fi UDP.
|
|
4
|
+
For researchers (PsychoPy / Jupyter / ROS), media artists, and anyone
|
|
5
|
+
prototyping haptics in Python.
|
|
6
|
+
|
|
7
|
+
> **📚 Docs**: <https://devtools.hapbeat.com/docs/sdk-integration/>
|
|
8
|
+
|
|
9
|
+
A script can drive Hapbeat with a few lines. The fire side (`play` / `stop`)
|
|
10
|
+
and the tuning side (`EventMap`) are kept orthogonal and linked only by
|
|
11
|
+
event id.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install hapbeat-python-sdk # core (zero dependencies, stdlib socket only)
|
|
17
|
+
pip install "hapbeat-python-sdk[osc]" # + generic OSC bridge (TouchOSC / Max / TD)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
With **pipx** you get the `hapbeat` CLI (including the launchpad below) in an
|
|
21
|
+
isolated environment: `pipx install hapbeat-python-sdk`. Note that pipx does *not* make
|
|
22
|
+
`import hapbeat` available to your own scripts — for the `examples/` use a
|
|
23
|
+
normal `pip install` in a venv.
|
|
24
|
+
|
|
25
|
+
## Try everything from one page (launchpad)
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
hapbeat launchpad # opens http://127.0.0.1:7100 in your browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
A single local web page to fire events, run a metronome, a breathing pacer,
|
|
32
|
+
or send Morse — start and stop them live, no per-example launching. It serves
|
|
33
|
+
a tiny stdlib HTTP server that relays button presses to the device over UDP
|
|
34
|
+
(browsers can't send UDP directly). Works great with `pipx install hapbeat-python-sdk`.
|
|
35
|
+
|
|
36
|
+
## Quick start
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import hapbeat
|
|
40
|
+
|
|
41
|
+
hb = hapbeat.connect(app_name="MyExperiment") # opens UDP broadcast + keep-alive
|
|
42
|
+
hb.play("impact.hit", gain=0.3) # fire event "impact.hit" at gain 0.3
|
|
43
|
+
hb.play("impact.hit") # gain omitted -> kit baseline intensity
|
|
44
|
+
hb.stop("impact.hit")
|
|
45
|
+
hb.stop_all()
|
|
46
|
+
hb.close()
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
or as a context manager:
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
with hapbeat.connect(app_name="MyExperiment") as hb:
|
|
53
|
+
hb.play("impact.hit")
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`"impact.hit"` must be an event id present in the **kit deployed to the
|
|
57
|
+
device** (via [Hapbeat Studio](https://devtools.hapbeat.com)). The SDK sends
|
|
58
|
+
the *instruction*; the waveform lives in the kit on the device.
|
|
59
|
+
|
|
60
|
+
## Discovery
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
for dev in hb.discover(timeout=1.5):
|
|
64
|
+
print(dev.ip, dev.address, dev.firmware_version)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## EventMap — the tuning side (optional)
|
|
68
|
+
|
|
69
|
+
Keep per-event default gains in one place and let `play("id")` resolve them,
|
|
70
|
+
so firing code never hard-codes intensities:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
em = hapbeat.EventMap.from_manifest("my-kit/my-kit-manifest.json")
|
|
74
|
+
hb = hapbeat.connect(event_map=em)
|
|
75
|
+
hb.play("impact.hit") # uses the kit manifest's intensity for this event
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`EventMap` reads the kit manifest (schema 2.0.0) `intensity` as the baseline
|
|
79
|
+
gain. You can also build one by hand: `EventMap.from_dict({"impact.hit": 0.5})`.
|
|
80
|
+
|
|
81
|
+
### Haptic file — add targeting on top of the kit
|
|
82
|
+
|
|
83
|
+
The Studio-generated manifest holds kit content (intensity / clip), but not
|
|
84
|
+
app-side concerns like **which device/body part** an event goes to. Put those
|
|
85
|
+
in a *haptic file* (an EventMap overlay that references the kit), so `play(id)`
|
|
86
|
+
resolves the target without the caller passing it:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
// haptics.json
|
|
90
|
+
{
|
|
91
|
+
"kit": "kits/my-kit",
|
|
92
|
+
"events": {
|
|
93
|
+
"impact.hit": { "target": "player_1/chest", "gain": 0.8 },
|
|
94
|
+
"rain.loop": { "target": "*/back" }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
hb = hapbeat.connect(app_name="MyApp", haptics="haptics.json")
|
|
101
|
+
hb.play("impact.hit") # goes to player_1/chest at gain 0.8 — from the file
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Two playback modes: command and clip
|
|
105
|
+
|
|
106
|
+
The same `play(id)` call branches on the manifest:
|
|
107
|
+
|
|
108
|
+
| Manifest bucket | Mode | What happens |
|
|
109
|
+
|---|---|---|
|
|
110
|
+
| `events` | **command** | the SDK sends a PLAY; the **device** plays its installed clip |
|
|
111
|
+
| `stream_events` | **clip** | the SDK reads the WAV from the kit's `stream-clips/` and **streams** it over UDP |
|
|
112
|
+
|
|
113
|
+
Put the kit inside your project and call events by id — the per-event details
|
|
114
|
+
(intensity, loop, command vs clip, which WAV) live in the kit, not your code:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
my-app/
|
|
118
|
+
app.py
|
|
119
|
+
kits/my-kit/
|
|
120
|
+
my-kit-manifest.json # the "haptic file" -> EventMap
|
|
121
|
+
install-clips/ # command clips (flashed to the device via Studio)
|
|
122
|
+
stream-clips/*.wav # clip-mode WAVs the SDK streams
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
import hapbeat
|
|
127
|
+
hb = hapbeat.connect(app_name="MyApp", kit="kits/my-kit")
|
|
128
|
+
hb.play("impact.hit") # command -> device plays its installed clip
|
|
129
|
+
hb.play("rain.loop") # clip -> SDK streams stream-clips/<wav> over UDP
|
|
130
|
+
hb.stop("rain.loop") # ends the active stream
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
You can also stream an ad-hoc PCM16 buffer (e.g. a synthesized stereo cue where
|
|
134
|
+
L/R amplitude conveys direction):
|
|
135
|
+
|
|
136
|
+
```python
|
|
137
|
+
hb.stream_pcm(pcm_bytes, sample_rate=16000, channels=2)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Author clips as **16 kHz mono PCM16** (the device plays at 16 kHz; the SDK does
|
|
141
|
+
not resample). A full runnable example is in
|
|
142
|
+
[examples/clip_project/](examples/clip_project/).
|
|
143
|
+
|
|
144
|
+
## Generic OSC bridge
|
|
145
|
+
|
|
146
|
+
Any OSC tool (TouchOSC, Max/MSP, TouchDesigner, a DAW) can drive Hapbeat
|
|
147
|
+
without code. Run the bridge and send `/hapbeat/play <event_id> [target] [time] [gain]`:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
hapbeat osc-bridge --listen 7702
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
See [docs/osc.md](docs/osc.md) for the address spec.
|
|
154
|
+
|
|
155
|
+
## CLI
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
hapbeat scan # list devices on the LAN
|
|
159
|
+
hapbeat play impact.hit --gain 0.3
|
|
160
|
+
hapbeat stop-all
|
|
161
|
+
hapbeat launchpad # browser UI for all of the above
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
## Examples
|
|
165
|
+
|
|
166
|
+
Ready-to-run sample applications live in [examples/](examples/):
|
|
167
|
+
a psychophysics experiment, a breathing pacer, a haptic metronome,
|
|
168
|
+
a live trigger pad, a task-completion notifier, and a Morse transmitter.
|
|
169
|
+
Each is a single stdlib-only file — see [examples/README.md](examples/README.md).
|
|
170
|
+
|
|
171
|
+
## For AI coding agents
|
|
172
|
+
|
|
173
|
+
Working with Claude / Cursor / Copilot? Hand your agent [AGENTS.md](AGENTS.md) —
|
|
174
|
+
a single self-contained file with the SDK's model, API, project layout, and
|
|
175
|
+
pitfalls. One file is enough to get the whole picture.
|
|
176
|
+
|
|
177
|
+
> Use the Hapbeat Python SDK. Read `AGENTS.md` and follow its API and best practices.
|
|
178
|
+
|
|
179
|
+
## License
|
|
180
|
+
|
|
181
|
+
MIT © Hapbeat
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
# Distribution name (pip/pipx). The import package stays `import hapbeat`.
|
|
7
|
+
name = "hapbeat-python-sdk"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "Python SDK for driving Hapbeat haptic devices over Wi-Fi UDP"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
license = { text = "MIT" }
|
|
13
|
+
authors = [{ name = "Hapbeat", email = "yus988@hapbeat.com" }]
|
|
14
|
+
keywords = ["hapbeat", "haptics", "udp", "osc", "vr", "research", "psychopy"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Intended Audience :: Science/Research",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Multimedia",
|
|
27
|
+
"Topic :: Scientific/Engineering :: Human Machine Interfaces",
|
|
28
|
+
"Topic :: System :: Hardware",
|
|
29
|
+
]
|
|
30
|
+
# Core level-1 has zero runtime dependencies — stdlib socket only.
|
|
31
|
+
dependencies = []
|
|
32
|
+
|
|
33
|
+
[project.optional-dependencies]
|
|
34
|
+
# Generic OSC bridge (TouchOSC / Max / TouchDesigner / DAWs).
|
|
35
|
+
osc = ["python-osc>=1.8"]
|
|
36
|
+
dev = ["pytest>=8"]
|
|
37
|
+
|
|
38
|
+
[project.urls]
|
|
39
|
+
Homepage = "https://devtools.hapbeat.com/"
|
|
40
|
+
Documentation = "https://devtools.hapbeat.com/docs/sdk-integration/"
|
|
41
|
+
Repository = "https://github.com/hapbeat/hapbeat-python-sdk"
|
|
42
|
+
Issues = "https://github.com/hapbeat/hapbeat-python-sdk/issues"
|
|
43
|
+
|
|
44
|
+
[project.scripts]
|
|
45
|
+
hapbeat = "hapbeat.cli:main"
|
|
46
|
+
|
|
47
|
+
[tool.setuptools.packages.find]
|
|
48
|
+
where = ["src"]
|
|
49
|
+
|
|
50
|
+
[tool.pytest.ini_options]
|
|
51
|
+
testpaths = ["tests"]
|
|
52
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Hapbeat SDK for Python — drive Hapbeat haptic devices over Wi-Fi UDP.
|
|
2
|
+
|
|
3
|
+
Quick start::
|
|
4
|
+
|
|
5
|
+
import hapbeat
|
|
6
|
+
|
|
7
|
+
hb = hapbeat.connect(app_name="MyExperiment")
|
|
8
|
+
hb.play("impact.hit", gain=0.3)
|
|
9
|
+
hb.stop_all()
|
|
10
|
+
hb.close()
|
|
11
|
+
|
|
12
|
+
The fire side (``play``/``stop``) and the tuning side (:class:`EventMap`) are
|
|
13
|
+
kept orthogonal and linked only by event id, matching the Hapbeat Unity SDK.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from . import protocol
|
|
19
|
+
from .client import DEFAULT_BROADCAST, DEFAULT_PORT, UdpClient
|
|
20
|
+
from .clip import ClipStreamer
|
|
21
|
+
from .eventmap import EventDef, EventMap
|
|
22
|
+
from .hapbeat import Device, Hapbeat, connect
|
|
23
|
+
from .wav import WavPcm, read_wav_pcm16
|
|
24
|
+
|
|
25
|
+
try: # populated from installed package metadata
|
|
26
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
__version__ = version("hapbeat-python-sdk")
|
|
30
|
+
except PackageNotFoundError: # running from a source checkout
|
|
31
|
+
__version__ = "0.1.0+local"
|
|
32
|
+
except ImportError: # pragma: no cover
|
|
33
|
+
__version__ = "0.1.0+local"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"connect",
|
|
37
|
+
"Hapbeat",
|
|
38
|
+
"Device",
|
|
39
|
+
"EventMap",
|
|
40
|
+
"EventDef",
|
|
41
|
+
"ClipStreamer",
|
|
42
|
+
"WavPcm",
|
|
43
|
+
"read_wav_pcm16",
|
|
44
|
+
"UdpClient",
|
|
45
|
+
"protocol",
|
|
46
|
+
"DEFAULT_PORT",
|
|
47
|
+
"DEFAULT_BROADCAST",
|
|
48
|
+
"__version__",
|
|
49
|
+
]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""``hapbeat`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Examples::
|
|
4
|
+
|
|
5
|
+
hapbeat scan
|
|
6
|
+
hapbeat play impact.hit --gain 0.3
|
|
7
|
+
hapbeat stop impact.hit
|
|
8
|
+
hapbeat stop-all
|
|
9
|
+
hapbeat osc-bridge --listen 7702
|
|
10
|
+
hapbeat launchpad
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from . import __version__
|
|
19
|
+
from .eventmap import EventMap
|
|
20
|
+
from .hapbeat import connect
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _add_common(p: argparse.ArgumentParser) -> None:
|
|
24
|
+
p.add_argument("--port", type=int, default=7700, help="UDP port (default 7700)")
|
|
25
|
+
p.add_argument("--target", default="", help="device address; empty = all (broadcast)")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
parser = argparse.ArgumentParser(prog="hapbeat", description="Drive Hapbeat devices.")
|
|
30
|
+
parser.add_argument("--version", action="version", version=f"hapbeat {__version__}")
|
|
31
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
32
|
+
|
|
33
|
+
p_play = sub.add_parser("play", help="play an event by id")
|
|
34
|
+
p_play.add_argument("event_id")
|
|
35
|
+
p_play.add_argument("--gain", type=float, default=None, help="0..1 (default: kit baseline)")
|
|
36
|
+
_add_common(p_play)
|
|
37
|
+
|
|
38
|
+
p_stop = sub.add_parser("stop", help="stop one event id")
|
|
39
|
+
p_stop.add_argument("event_id")
|
|
40
|
+
_add_common(p_stop)
|
|
41
|
+
|
|
42
|
+
p_stop_all = sub.add_parser("stop-all", help="stop everything")
|
|
43
|
+
_add_common(p_stop_all)
|
|
44
|
+
|
|
45
|
+
p_ping = sub.add_parser("ping", help="broadcast a keep-alive ping")
|
|
46
|
+
_add_common(p_ping)
|
|
47
|
+
|
|
48
|
+
p_scan = sub.add_parser("scan", help="discover devices on the LAN")
|
|
49
|
+
p_scan.add_argument("--timeout", type=float, default=1.5)
|
|
50
|
+
_add_common(p_scan)
|
|
51
|
+
|
|
52
|
+
p_osc = sub.add_parser("osc-bridge", help="relay /hapbeat/* OSC to devices")
|
|
53
|
+
p_osc.add_argument("--listen", type=int, default=7702, help="OSC listen port (default 7702)")
|
|
54
|
+
p_osc.add_argument("--haptics", default=None,
|
|
55
|
+
help="haptic file (overlay) so OSC events route command/clip + use per-event targets")
|
|
56
|
+
p_osc.add_argument("--kit", default=None,
|
|
57
|
+
help="kit folder (alternative to --haptics; intensity/clip only, no targeting)")
|
|
58
|
+
_add_common(p_osc)
|
|
59
|
+
|
|
60
|
+
p_lp = sub.add_parser(
|
|
61
|
+
"launchpad",
|
|
62
|
+
help="serve a local web page to try features from the browser",
|
|
63
|
+
)
|
|
64
|
+
p_lp.add_argument("--host", default="127.0.0.1", help="HTTP bind host (default 127.0.0.1)")
|
|
65
|
+
p_lp.add_argument("--http-port", type=int, default=7100, help="HTTP port (default 7100)")
|
|
66
|
+
p_lp.add_argument("--no-open", action="store_true", help="do not open a browser")
|
|
67
|
+
_add_common(p_lp)
|
|
68
|
+
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main(argv: list[str] | None = None) -> int:
|
|
73
|
+
args = build_parser().parse_args(argv)
|
|
74
|
+
|
|
75
|
+
if args.command == "osc-bridge":
|
|
76
|
+
from .osc import OscBridge
|
|
77
|
+
|
|
78
|
+
event_map = None
|
|
79
|
+
if args.haptics:
|
|
80
|
+
event_map = EventMap.from_file(args.haptics)
|
|
81
|
+
elif args.kit:
|
|
82
|
+
event_map = EventMap.from_kit(args.kit)
|
|
83
|
+
hb = connect(port=args.port, app_name="hapbeat-osc",
|
|
84
|
+
default_target=args.target, event_map=event_map)
|
|
85
|
+
try:
|
|
86
|
+
mode = "command+clip" if event_map else "command only"
|
|
87
|
+
print(f"OSC bridge: listening on :{args.listen}, relaying to UDP {args.port} ({mode})")
|
|
88
|
+
OscBridge(hb, listen_port=args.listen).serve_forever()
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
pass
|
|
91
|
+
finally:
|
|
92
|
+
hb.close()
|
|
93
|
+
return 0
|
|
94
|
+
|
|
95
|
+
if args.command == "launchpad":
|
|
96
|
+
from .launchpad import serve
|
|
97
|
+
|
|
98
|
+
return serve(host=args.host, port=args.http_port, udp_port=args.port,
|
|
99
|
+
target=args.target, open_browser=not args.no_open)
|
|
100
|
+
|
|
101
|
+
hb = connect(port=args.port, default_target=getattr(args, "target", ""), keepalive=False)
|
|
102
|
+
try:
|
|
103
|
+
if args.command == "play":
|
|
104
|
+
ok = hb.play(args.event_id, args.gain)
|
|
105
|
+
print(f"play {args.event_id} gain={args.gain if args.gain is not None else 'baseline'} -> {'sent' if ok else 'FAILED'}")
|
|
106
|
+
elif args.command == "stop":
|
|
107
|
+
print("sent" if hb.stop(args.event_id) else "FAILED")
|
|
108
|
+
elif args.command == "stop-all":
|
|
109
|
+
print("sent" if hb.stop_all() else "FAILED")
|
|
110
|
+
elif args.command == "ping":
|
|
111
|
+
print("sent" if hb.ping() else "FAILED")
|
|
112
|
+
elif args.command == "scan":
|
|
113
|
+
devices = hb.discover(timeout=args.timeout)
|
|
114
|
+
if not devices:
|
|
115
|
+
print("no devices replied")
|
|
116
|
+
for d in devices:
|
|
117
|
+
print(f"{d.ip:<16} {d.address or '?':<20} {d.name or '?':<16} fw={d.firmware_version or '?'}")
|
|
118
|
+
finally:
|
|
119
|
+
hb.close()
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
sys.exit(main())
|