hapbeat-python-sdk 0.1.0__py3-none-any.whl

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/osc.py ADDED
@@ -0,0 +1,126 @@
1
+ """Generic OSC -> Hapbeat bridge.
2
+
3
+ Lets any OSC-capable tool (TouchOSC, Max/MSP, TouchDesigner, a DAW, vvvv, ...)
4
+ drive Hapbeat without writing code, using the addresses defined in
5
+ ``hapbeat-contracts/specs/message-format.md`` §6::
6
+
7
+ /hapbeat/play event_id [target] [target_time_us] [gain]
8
+ /hapbeat/stop event_id [target]
9
+ /hapbeat/stop-all [target]
10
+ /hapbeat/ping
11
+
12
+ This is the *transport* form of OSC (speak directly to Hapbeat). App-specific
13
+ schemas (VRChat avatar parameters, etc.) are handled by dedicated bridges that
14
+ translate the foreign schema into these calls.
15
+
16
+ Requires the optional dependency::
17
+
18
+ pip install "hapbeat-python-sdk[osc]"
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import logging
24
+ from typing import Optional
25
+
26
+ from .hapbeat import Hapbeat
27
+
28
+ logger = logging.getLogger("hapbeat.osc")
29
+
30
+ # Layer 1 OSC port (hapbeat-contracts/specs/message-format.md §2).
31
+ DEFAULT_OSC_PORT = 7702
32
+
33
+
34
+ def _require_pythonosc():
35
+ try:
36
+ from pythonosc.dispatcher import Dispatcher
37
+ from pythonosc.osc_server import BlockingOSCUDPServer
38
+ except ImportError as exc: # pragma: no cover - import-time guard
39
+ raise ImportError(
40
+ "OSC support needs python-osc. Install with: pip install \"hapbeat-python-sdk[osc]\""
41
+ ) from exc
42
+ return Dispatcher, BlockingOSCUDPServer
43
+
44
+
45
+ class OscBridge:
46
+ """Listen for ``/hapbeat/*`` OSC messages and relay them to devices."""
47
+
48
+ def __init__(
49
+ self,
50
+ hb: Hapbeat,
51
+ listen_host: str = "0.0.0.0",
52
+ listen_port: int = DEFAULT_OSC_PORT,
53
+ ) -> None:
54
+ self.hb = hb
55
+ self.listen_host = listen_host
56
+ self.listen_port = listen_port
57
+ self._server = None
58
+
59
+ # ── Handlers (defensive about omitted trailing OSC args) ────────
60
+ # An omitted target is passed as None (not "") so the bound EventMap's
61
+ # per-event target (haptic file) applies; an explicit OSC target overrides.
62
+ def _handle_play(self, _address: str, *args) -> None:
63
+ event_id = str(args[0]) if len(args) >= 1 else ""
64
+ if not event_id:
65
+ return
66
+ target = str(args[1]) if len(args) >= 2 and args[1] != "" else None
67
+ target_time = int(args[2]) if len(args) >= 3 else 0
68
+ gain = float(args[3]) if len(args) >= 4 else None
69
+ self.hb.play(event_id, gain, target=target, target_time_us=target_time)
70
+
71
+ def _handle_stop(self, _address: str, *args) -> None:
72
+ if not args:
73
+ return
74
+ target = str(args[1]) if len(args) >= 2 and args[1] != "" else None
75
+ self.hb.stop(str(args[0]), target=target)
76
+
77
+ def _handle_stop_all(self, _address: str, *args) -> None:
78
+ target = str(args[0]) if args and args[0] != "" else None
79
+ self.hb.stop_all(target=target)
80
+
81
+ def _handle_ping(self, _address: str, *_args) -> None:
82
+ self.hb.ping()
83
+
84
+ # ── Lifecycle ───────────────────────────────────────────────────
85
+ def serve_forever(self) -> None:
86
+ """Block and relay OSC until interrupted."""
87
+ Dispatcher, BlockingOSCUDPServer = _require_pythonosc()
88
+ disp = Dispatcher()
89
+ disp.map("/hapbeat/play", self._handle_play)
90
+ disp.map("/hapbeat/stop", self._handle_stop)
91
+ disp.map("/hapbeat/stop-all", self._handle_stop_all)
92
+ disp.map("/hapbeat/ping", self._handle_ping)
93
+ self._server = BlockingOSCUDPServer((self.listen_host, self.listen_port), disp)
94
+ logger.info(
95
+ "OSC bridge listening on %s:%d -> UDP %d",
96
+ self.listen_host, self.listen_port, self.hb._client.port,
97
+ )
98
+ try:
99
+ self._server.serve_forever()
100
+ finally:
101
+ self._server.server_close()
102
+ self._server = None
103
+
104
+ def stop(self) -> None:
105
+ if self._server is not None:
106
+ self._server.shutdown()
107
+
108
+
109
+ def run_bridge(
110
+ listen_port: int = DEFAULT_OSC_PORT,
111
+ *,
112
+ udp_port: int = 7700,
113
+ app_name: str = "hapbeat-osc",
114
+ event_map=None,
115
+ ) -> None:
116
+ """Open a Hapbeat connection and serve the OSC bridge (blocking).
117
+
118
+ Pass an ``event_map`` (e.g. ``EventMap.from_file("haptics.json")``) so OSC
119
+ events route command vs clip and pick up per-event targets from the haptic
120
+ file -- otherwise every ``/hapbeat/play`` is a plain command broadcast.
121
+ """
122
+ hb = Hapbeat(port=udp_port, app_name=app_name, event_map=event_map).open()
123
+ try:
124
+ OscBridge(hb, listen_port=listen_port).serve_forever()
125
+ finally:
126
+ hb.close()
hapbeat/protocol.py ADDED
@@ -0,0 +1,253 @@
1
+ """Hapbeat Layer 1 (SDK -> device) UDP protocol.
2
+
3
+ Pure, dependency-free packet builders and parsers. This module is the
4
+ *single source of truth* for the wire format inside the Python SDK and is
5
+ the part that must stay byte-for-byte compatible with
6
+ ``hapbeat-contracts/specs/message-format.md``.
7
+
8
+ Every multi-byte field is little-endian. Command packets are capped at
9
+ 512 bytes; streaming packets at MTU.
10
+
11
+ The reference implementation in other languages:
12
+ - C#: hapbeat-unity-sdk/Runtime/HapbeatProtocol.cs
13
+ - Python: hapbeat-helper/src/hapbeat_helper/protocol.py
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import struct
19
+ from typing import Optional
20
+
21
+ # ── Header constants ────────────────────────────────────────────────
22
+ MAGIC = 0x4842 # ASCII "HB"
23
+ VERSION = 0x01
24
+ HEADER_SIZE = 8
25
+ MAX_PACKET_SIZE = 512 # command packets
26
+ MAX_STREAM_PACKET_SIZE = 1472 # 1500 MTU - 20 IP - 8 UDP
27
+
28
+ # App name shown on the device OLED is capped to the display grid width
29
+ # (contracts/specs/display-layout.md, 16 chars).
30
+ MAX_APP_NAME_LEN = 16
31
+
32
+ # ── Command types (SDK -> device) ───────────────────────────────────
33
+ CMD_PLAY = 0x01
34
+ CMD_STOP = 0x02
35
+ CMD_STOP_ALL = 0x03
36
+ CMD_PING = 0x10
37
+ CMD_CONNECT_STATUS = 0x20
38
+ CMD_STREAM_BEGIN = 0x30
39
+ CMD_STREAM_DATA = 0x31
40
+ CMD_STREAM_END = 0x32
41
+
42
+ # ── Response types (device -> SDK) ──────────────────────────────────
43
+ CMD_PONG = 0x11
44
+ CMD_ERROR = 0xFF
45
+
46
+ # ── Audio formats (streaming) ───────────────────────────────────────
47
+ AUDIO_FORMAT_PCM16 = 0
48
+ AUDIO_FORMAT_IMA_ADPCM = 1
49
+
50
+
51
+ # ── Header ──────────────────────────────────────────────────────────
52
+ def build_header(command_type: int, seq: int, payload_length: int) -> bytes:
53
+ """Build the 8-byte common header (little-endian).
54
+
55
+ Layout: magic(u16) version(u8) command_type(u8) seq(u16) payload_length(u16)
56
+ """
57
+ return struct.pack(
58
+ "<HBBHH", MAGIC, VERSION, command_type, seq & 0xFFFF, payload_length
59
+ )
60
+
61
+
62
+ def parse_header(data: bytes) -> Optional[dict]:
63
+ """Parse the common header. Returns ``None`` on bad magic/version/length."""
64
+ if len(data) < HEADER_SIZE:
65
+ return None
66
+ magic, version, cmd, seq, payload_len = struct.unpack(
67
+ "<HBBHH", data[:HEADER_SIZE]
68
+ )
69
+ if magic != MAGIC or version != VERSION:
70
+ return None
71
+ return {"command_type": cmd, "seq": seq, "payload_length": payload_len}
72
+
73
+
74
+ def build_packet(command_type: int, seq: int, payload: bytes = b"") -> bytes:
75
+ """Assemble a full command packet and enforce the 512-byte cap."""
76
+ total = HEADER_SIZE + len(payload)
77
+ if total > MAX_PACKET_SIZE:
78
+ raise ValueError(
79
+ f"packet size {total} exceeds maximum {MAX_PACKET_SIZE} bytes"
80
+ )
81
+ return build_header(command_type, seq, len(payload)) + payload
82
+
83
+
84
+ def parse_packet(data: bytes) -> Optional[tuple[int, int, bytes]]:
85
+ """Parse a packet into ``(command_type, seq, payload)`` or ``None``."""
86
+ hdr = parse_header(data)
87
+ if hdr is None:
88
+ return None
89
+ end = HEADER_SIZE + hdr["payload_length"]
90
+ if len(data) < end:
91
+ return None
92
+ return hdr["command_type"], hdr["seq"], data[HEADER_SIZE:end]
93
+
94
+
95
+ # ── Command builders ────────────────────────────────────────────────
96
+ def build_play(
97
+ seq: int,
98
+ event_id: str,
99
+ *,
100
+ target: str = "",
101
+ target_time_us: int = 0,
102
+ gain: float = 1.0,
103
+ ) -> bytes:
104
+ """PLAY (0x01).
105
+
106
+ Wire payload (see contracts/specs/device-addressing.md §5.1):
107
+ event_id(null-term) + target(null-term) + target_time(i64) + gain(f32)
108
+
109
+ ``target=""`` broadcasts to every device.
110
+ """
111
+ payload = (
112
+ event_id.encode("utf-8") + b"\x00"
113
+ + target.encode("utf-8") + b"\x00"
114
+ + struct.pack("<qf", int(target_time_us), float(gain))
115
+ )
116
+ return build_packet(CMD_PLAY, seq, payload)
117
+
118
+
119
+ def build_stop(seq: int, event_id: str, *, target: str = "") -> bytes:
120
+ """STOP (0x02). Payload: event_id(null-term) + target(null-term)."""
121
+ payload = event_id.encode("utf-8") + b"\x00" + target.encode("utf-8") + b"\x00"
122
+ return build_packet(CMD_STOP, seq, payload)
123
+
124
+
125
+ def build_stop_all(seq: int, *, target: str = "") -> bytes:
126
+ """STOP_ALL (0x03). Payload: target(null-term)."""
127
+ payload = target.encode("utf-8") + b"\x00"
128
+ return build_packet(CMD_STOP_ALL, seq, payload)
129
+
130
+
131
+ def build_ping(seq: int, timestamp_us: int) -> bytes:
132
+ """PING (0x10). Payload: timestamp(i64, microseconds)."""
133
+ return build_packet(CMD_PING, seq, struct.pack("<q", int(timestamp_us)))
134
+
135
+
136
+ def build_connect_status(
137
+ seq: int,
138
+ *,
139
+ connected: bool = True,
140
+ group: int = 0,
141
+ app_name: str = "",
142
+ device_name: str = "",
143
+ ) -> bytes:
144
+ """CONNECT_STATUS (0x20). Periodic keep-alive shown on the device OLED.
145
+
146
+ Wire payload matches the proven Unity layout
147
+ (HapbeatProtocol.cs ``BuildConnectStatusPayload``), which is what the
148
+ firmware actually parses:
149
+ connected(u8) + group(u8) + app_name(null-term) + device_name(null-term)
150
+ """
151
+ payload = (
152
+ bytes([1 if connected else 0, group & 0xFF])
153
+ + app_name.encode("utf-8") + b"\x00"
154
+ + device_name.encode("utf-8") + b"\x00"
155
+ )
156
+ return build_packet(CMD_CONNECT_STATUS, seq, payload)
157
+
158
+
159
+ # ── Streaming builders (clip-mode playback) ─────────────────────────
160
+ def build_stream_begin(
161
+ seq: int,
162
+ *,
163
+ sample_rate: int = 16000,
164
+ channels: int = 1,
165
+ fmt: int = AUDIO_FORMAT_PCM16,
166
+ total_samples: int = 0,
167
+ gain: float = 1.0,
168
+ target: str = "",
169
+ ) -> bytes:
170
+ """STREAM_BEGIN (0x30).
171
+
172
+ Default format is PCM16 — clip streaming sends uncompressed 16-bit PCM
173
+ (ADPCM is reserved but not produced by the SDK).
174
+ """
175
+ payload = struct.pack(
176
+ "<HBBIf", sample_rate, channels, fmt, total_samples, float(gain)
177
+ )
178
+ if target:
179
+ payload += target.encode("utf-8") + b"\x00"
180
+ return build_packet(CMD_STREAM_BEGIN, seq, payload)
181
+
182
+
183
+ def build_stream_data(seq: int, offset: int, data: bytes) -> bytes:
184
+ """STREAM_DATA (0x31). Uses the larger MTU-safe size cap."""
185
+ payload = struct.pack("<I", offset) + data
186
+ total = HEADER_SIZE + len(payload)
187
+ if total > MAX_STREAM_PACKET_SIZE:
188
+ raise ValueError(
189
+ f"stream packet {total} exceeds MTU cap {MAX_STREAM_PACKET_SIZE}"
190
+ )
191
+ return build_header(CMD_STREAM_DATA, seq, len(payload)) + payload
192
+
193
+
194
+ def build_stream_end(seq: int) -> bytes:
195
+ """STREAM_END (0x32). No payload."""
196
+ return build_packet(CMD_STREAM_END, seq, b"")
197
+
198
+
199
+ # ── Response parsers ────────────────────────────────────────────────
200
+ def _read_cstr(buf: bytes) -> tuple[str, bytes]:
201
+ idx = buf.find(b"\x00")
202
+ if idx < 0:
203
+ return buf.decode("utf-8", errors="replace"), b""
204
+ return buf[:idx].decode("utf-8", errors="replace"), buf[idx + 1:]
205
+
206
+
207
+ def parse_pong(data: bytes) -> Optional[dict]:
208
+ """Parse a PONG (0x11).
209
+
210
+ Bridge form is 16 bytes (timestamp, server_time). Devices append an
211
+ extended form (name / address / firmware_version, plus optional
212
+ trailing volume bytes). Returns ``None`` if not a valid PONG.
213
+ """
214
+ hdr = parse_header(data)
215
+ if hdr is None or hdr["command_type"] != CMD_PONG:
216
+ return None
217
+ payload = data[HEADER_SIZE:]
218
+ if len(payload) < 16:
219
+ return None
220
+
221
+ timestamp, server_time = struct.unpack("<qq", payload[:16])
222
+ out: dict = {
223
+ "seq": hdr["seq"],
224
+ "timestamp": timestamp,
225
+ "server_time": server_time,
226
+ }
227
+ rest = payload[16:]
228
+ if rest:
229
+ out["device_name"], rest = _read_cstr(rest)
230
+ if rest:
231
+ out["address"], rest = _read_cstr(rest)
232
+ if rest:
233
+ out["firmware_version"], rest = _read_cstr(rest)
234
+ if len(rest) >= 1:
235
+ out["volume_level"] = rest[0]
236
+ if len(rest) >= 2:
237
+ out["volume_wiper"] = rest[1]
238
+ if len(rest) >= 3:
239
+ out["volume_steps"] = rest[2]
240
+ return out
241
+
242
+
243
+ def parse_error(data: bytes) -> Optional[dict]:
244
+ """Parse an ERROR (0xFF). Payload: error_code(u16) + message(null-term)."""
245
+ parsed = parse_packet(data)
246
+ if parsed is None or parsed[0] != CMD_ERROR:
247
+ return None
248
+ payload = parsed[2]
249
+ if len(payload) < 2:
250
+ return None
251
+ (code,) = struct.unpack("<H", payload[:2])
252
+ message, _ = _read_cstr(payload[2:])
253
+ return {"error_code": code, "message": message}
hapbeat/wav.py ADDED
@@ -0,0 +1,51 @@
1
+ """Read a WAV file's PCM16 bytes for clip streaming.
2
+
3
+ The device streams uncompressed 16-bit PCM (16 kHz expected, matching the
4
+ kit-tools normalization). This thin wrapper over the stdlib :mod:`wave` module
5
+ returns the raw interleaved PCM16-LE bytes ready to hand to STREAM_DATA, plus
6
+ the sample rate and channel count for STREAM_BEGIN.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import wave
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Union
15
+
16
+
17
+ @dataclass
18
+ class WavPcm:
19
+ sample_rate: int
20
+ channels: int
21
+ data: bytes # raw interleaved PCM16 little-endian (the WAV data chunk)
22
+
23
+
24
+ def read_wav_pcm16(path: Union[str, Path]) -> WavPcm:
25
+ """Read an uncompressed 16-bit PCM WAV. Raises on other formats.
26
+
27
+ Non-16 kHz files are accepted but produce a warning -- the device plays at
28
+ 16 kHz so the pitch/duration would be off; author clips at 16 kHz.
29
+ """
30
+ with wave.open(str(path), "rb") as w:
31
+ if w.getsampwidth() != 2:
32
+ raise ValueError(
33
+ f"WAV must be 16-bit PCM (got {w.getsampwidth() * 8}-bit): {path}"
34
+ )
35
+ if w.getcomptype() != "NONE":
36
+ raise ValueError(
37
+ f"WAV must be uncompressed PCM (got {w.getcomptype()}): {path}"
38
+ )
39
+ sample_rate = w.getframerate()
40
+ channels = w.getnchannels()
41
+ data = w.readframes(w.getnframes())
42
+
43
+ if sample_rate != 16000:
44
+ import warnings
45
+
46
+ warnings.warn(
47
+ f"WAV {path} is {sample_rate} Hz; the device expects 16000 Hz "
48
+ "(pitch/duration will be off). Author clips at 16 kHz.",
49
+ stacklevel=2,
50
+ )
51
+ return WavPcm(sample_rate=sample_rate, channels=channels, data=data)
@@ -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,17 @@
1
+ hapbeat/__init__.py,sha256=6FABLnQeXm1nxqiiGLQWyDko1h5Nhr3RuF-vYEBW8n0,1268
2
+ hapbeat/__main__.py,sha256=ji2N-J5BX8XSUrihVzwQuttefiOYsu3pTzuYAsgMIUU,120
3
+ hapbeat/cli.py,sha256=FMCggde6NeWMNXbw5v939dQQ1X0kkY0HUno8TA5L3YE,4624
4
+ hapbeat/client.py,sha256=MGwTbtn6j4uAw0pDLWBDDuOi_u5nyWT3k1IiWu67TPo,5995
5
+ hapbeat/clip.py,sha256=PpVPht61HWiHolLkpCFBXdL7d_3syraaaJUwgF_2J0o,4892
6
+ hapbeat/eventmap.py,sha256=odDRXGt2CmcQsMed7NHlz0pbmQLRqxjti2T9uXxsyCg,8386
7
+ hapbeat/hapbeat.py,sha256=IqNIrKp2j17y4QLse80Wb98BSTGLJ7wGBtDKuUmdgVM,16858
8
+ hapbeat/launchpad.py,sha256=wvnIFLtrux_PzdR7TrsCqIhtOLhLJzXuOdfam7e2tQ4,26792
9
+ hapbeat/osc.py,sha256=cD0_QA6M1Km7VhXvPGYLHyWFj_V5uUQAfPG1myYvYxw,4589
10
+ hapbeat/protocol.py,sha256=WNjjgxO_RP25WF2qZDtC_p_69lUyJ9WGjAMn77i6ITY,8928
11
+ hapbeat/wav.py,sha256=zAxCWLEliCclFhiOtWlgfqra3Eua89VfwsDOCFtk3lY,1716
12
+ hapbeat_python_sdk-0.1.0.dist-info/licenses/LICENSE,sha256=iaWGp90FSWArzrtS2s0C-hGqRlguPdFt8vxkPT4SEj8,1064
13
+ hapbeat_python_sdk-0.1.0.dist-info/METADATA,sha256=3lBi5pbXGtVQdl0P2ca6FwM8G2LbWcvolv1dmtCeFhY,7278
14
+ hapbeat_python_sdk-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ hapbeat_python_sdk-0.1.0.dist-info/entry_points.txt,sha256=6GpFtyk5e9Kh3Y_lDOGPp1jQqYL5eiCK9kbz08cdiek,45
16
+ hapbeat_python_sdk-0.1.0.dist-info/top_level.txt,sha256=FLePhbdKs48thuuow2Z1_X_3V-2oxhDdeOX_bu5to8M,8
17
+ hapbeat_python_sdk-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hapbeat = hapbeat.cli:main