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/__init__.py +49 -0
- hapbeat/__main__.py +8 -0
- hapbeat/cli.py +124 -0
- hapbeat/client.py +152 -0
- hapbeat/clip.py +121 -0
- hapbeat/eventmap.py +203 -0
- hapbeat/hapbeat.py +446 -0
- hapbeat/launchpad.py +659 -0
- hapbeat/osc.py +126 -0
- hapbeat/protocol.py +253 -0
- hapbeat/wav.py +51 -0
- hapbeat_python_sdk-0.1.0.dist-info/METADATA +214 -0
- hapbeat_python_sdk-0.1.0.dist-info/RECORD +17 -0
- hapbeat_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- hapbeat_python_sdk-0.1.0.dist-info/entry_points.txt +2 -0
- hapbeat_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- hapbeat_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
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,,
|