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/hapbeat.py ADDED
@@ -0,0 +1,446 @@
1
+ """High-level Hapbeat client — the level-1 "fire" surface.
2
+
3
+ This is the *trigger* side of the SDK: anything (a research script, a game
4
+ loop, an OSC handler, a UI callback) calls :meth:`Hapbeat.play` / ``stop`` to
5
+ drive the device. The *tuning* side (default gains per event) is kept
6
+ orthogonal in :class:`hapbeat.eventmap.EventMap` and linked only by event id —
7
+ mirroring the Unity SDK's Trigger / EventMap split.
8
+
9
+ Typical use::
10
+
11
+ import hapbeat
12
+
13
+ hb = hapbeat.connect(app_name="MyExperiment")
14
+ hb.play("impact.hit", gain=0.3)
15
+ hb.stop("impact.hit")
16
+ hb.close()
17
+
18
+ or as a context manager::
19
+
20
+ with hapbeat.connect() as hb:
21
+ hb.play("impact.hit")
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import logging
27
+ import threading
28
+ import time
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+ from typing import Optional, Union
32
+
33
+ from . import protocol
34
+ from .client import DEFAULT_BROADCAST, DEFAULT_PORT, UdpClient
35
+ from .clip import ClipStreamer
36
+ from .eventmap import EventDef, EventMap
37
+ from .wav import WavPcm, read_wav_pcm16
38
+
39
+ logger = logging.getLogger("hapbeat")
40
+
41
+
42
+ @dataclass
43
+ class Device:
44
+ """A Hapbeat discovered on the LAN (from its PONG reply)."""
45
+
46
+ ip: str
47
+ name: str = ""
48
+ address: str = ""
49
+ firmware_version: str = ""
50
+ last_seen: float = field(default_factory=time.monotonic)
51
+
52
+
53
+ def _now_us() -> int:
54
+ return int(time.time() * 1_000_000)
55
+
56
+
57
+ class Hapbeat:
58
+ """A connection to Hapbeat devices over Wi-Fi UDP broadcast."""
59
+
60
+ def __init__(
61
+ self,
62
+ *,
63
+ port: int = DEFAULT_PORT,
64
+ broadcast_addr: str = DEFAULT_BROADCAST,
65
+ app_name: str = "",
66
+ device_name: str = "",
67
+ group: int = 0,
68
+ default_target: str = "",
69
+ event_map: Optional[EventMap] = None,
70
+ keepalive: bool = True,
71
+ keepalive_interval: float = 5.0,
72
+ bind_port: int = 0,
73
+ clip_base: Optional[Union[str, Path]] = None,
74
+ stream_send_ahead: float = 0.15,
75
+ ) -> None:
76
+ self._client = UdpClient(port=port, broadcast_addr=broadcast_addr,
77
+ bind_port=bind_port)
78
+ self.app_name = app_name[: protocol.MAX_APP_NAME_LEN]
79
+ self.device_name = device_name
80
+ self.group = group
81
+ self.default_target = default_target
82
+ self.event_map = event_map
83
+ # Where clip-mode WAVs live. If unset, they resolve under the bound
84
+ # EventMap's kit_dir (kit_dir/stream-clips/<clip>).
85
+ self.clip_base: Optional[Path] = Path(clip_base) if clip_base else None
86
+ self._seq = 0
87
+ self._seq_lock = threading.Lock()
88
+ self._devices: dict[str, Device] = {}
89
+ self._devices_lock = threading.Lock()
90
+ self._keepalive = keepalive
91
+ self._keepalive_interval = keepalive_interval
92
+ self._keepalive_stop: Optional[threading.Event] = None
93
+ self._keepalive_thread: Optional[threading.Thread] = None
94
+ self._opened = False
95
+ # Clip streaming (level-2). The streamer calls this object's
96
+ # stream_begin/stream_data/stream_end (StreamSink protocol).
97
+ self._clip = ClipStreamer(self, send_ahead_sec=stream_send_ahead)
98
+ self._clip_cache: dict[str, WavPcm] = {}
99
+
100
+ # ── Lifecycle ───────────────────────────────────────────────────
101
+ def open(self) -> "Hapbeat":
102
+ """Open the transport. Idempotent: ``connect()`` and ``__enter__``
103
+ may both call it, so a second call while open is a no-op."""
104
+ if self._opened:
105
+ return self
106
+ self._opened = True
107
+ self._client.add_pong_listener(self._on_pong)
108
+ self._client.open()
109
+ if self._keepalive and self.app_name:
110
+ self._start_keepalive()
111
+ return self
112
+
113
+ def close(self) -> None:
114
+ if not self._opened:
115
+ return
116
+ self._opened = False
117
+ self._clip.stop() # end any in-flight clip stream
118
+ # Tell the device this app is leaving so the OLED clears.
119
+ if self.app_name:
120
+ try:
121
+ self.connect_status(connected=False)
122
+ except Exception: # noqa: BLE001
123
+ pass
124
+ self._stop_keepalive()
125
+ self._client.remove_pong_listener(self._on_pong)
126
+ self._client.close()
127
+
128
+ def __enter__(self) -> "Hapbeat":
129
+ return self.open()
130
+
131
+ def __exit__(self, *exc) -> None:
132
+ self.close()
133
+
134
+ # ── seq ─────────────────────────────────────────────────────────
135
+ def _next_seq(self) -> int:
136
+ with self._seq_lock:
137
+ self._seq = (self._seq + 1) & 0xFFFF
138
+ return self._seq
139
+
140
+ def _resolve_target(self, target: Optional[str]) -> str:
141
+ return self.default_target if target is None else target
142
+
143
+ def _effective_target(self, ev: Optional[EventDef], target: Optional[str]) -> str:
144
+ """Resolve the wire target: explicit call-site arg > the event's own
145
+ target (from the haptic file) > the connection default."""
146
+ if target is not None:
147
+ return target
148
+ if ev is not None and ev.target:
149
+ return ev.target
150
+ return self.default_target
151
+
152
+ # ── Fire API (level-1) ──────────────────────────────────────────
153
+ def play(
154
+ self,
155
+ event_id: str,
156
+ gain: Optional[float] = None,
157
+ *,
158
+ target: Optional[str] = None,
159
+ target_time_us: int = 0,
160
+ ) -> bool:
161
+ """Play a haptic event by id. The EventMap decides the mode.
162
+
163
+ - **command** event (manifest ``events``) → a PLAY instruction; the
164
+ device plays its installed clip.
165
+ - **clip** event (manifest ``stream_events``) → the SDK streams the
166
+ event's WAV from the kit's ``stream-clips/`` over UDP.
167
+
168
+ Either way the caller writes the same one-liner. ``gain`` is the wire
169
+ gain (0..1); when omitted, the bound :class:`EventMap` supplies the
170
+ per-event default (its manifest ``intensity``), else ``1.0``.
171
+ ``target_time_us`` applies to command mode only.
172
+ """
173
+ ev = self.event_map.get(event_id) if self.event_map else None
174
+ if gain is None:
175
+ gain = ev.intensity if ev is not None else 1.0
176
+ gain = max(0.0, min(1.0, float(gain)))
177
+ target_eff = self._effective_target(ev, target)
178
+
179
+ if ev is not None and ev.streaming:
180
+ return self.play_clip(event_id, gain, target=target_eff)
181
+
182
+ pkt = protocol.build_play(
183
+ self._next_seq(),
184
+ event_id,
185
+ target=target_eff,
186
+ target_time_us=target_time_us,
187
+ gain=gain,
188
+ )
189
+ return self._client.send(pkt)
190
+
191
+ def stop(self, event_id: str, *, target: Optional[str] = None) -> bool:
192
+ """Stop one event id. Clip events end the active stream; command
193
+ events send a STOP."""
194
+ ev = self.event_map.get(event_id) if self.event_map else None
195
+ if ev is not None and ev.streaming:
196
+ self._clip.stop()
197
+ return True
198
+ pkt = protocol.build_stop(
199
+ self._next_seq(), event_id, target=self._effective_target(ev, target)
200
+ )
201
+ return self._client.send(pkt)
202
+
203
+ def stop_all(self, *, target: Optional[str] = None) -> bool:
204
+ """Stop every event on matching devices (and any active clip stream)."""
205
+ self._clip.stop()
206
+ pkt = protocol.build_stop_all(
207
+ self._next_seq(), target=self._resolve_target(target)
208
+ )
209
+ return self._client.send(pkt)
210
+
211
+ def ping(self) -> bool:
212
+ """Broadcast a PING (keep-alive / discovery probe)."""
213
+ return self._client.send(protocol.build_ping(self._next_seq(), _now_us()))
214
+
215
+ def connect_status(self, *, connected: bool = True) -> bool:
216
+ """Announce connection state so the device OLED shows the app name."""
217
+ pkt = protocol.build_connect_status(
218
+ self._next_seq(),
219
+ connected=connected,
220
+ group=self.group,
221
+ app_name=self.app_name,
222
+ device_name=self.device_name,
223
+ )
224
+ return self._client.send(pkt)
225
+
226
+ # ── Clip streaming (level-2) ─────────────────────────────────────
227
+ def play_clip(
228
+ self,
229
+ event_id: str,
230
+ gain: Optional[float] = None,
231
+ *,
232
+ target: Optional[str] = None,
233
+ ) -> bool:
234
+ """Stream a clip-mode event's WAV to the device.
235
+
236
+ Usually you call :meth:`play`, which routes clip events here. The WAV
237
+ is read once and cached. Returns ``False`` if the clip can't be found.
238
+ """
239
+ ev = self.event_map.get(event_id) if self.event_map else None
240
+ if ev is None or not ev.clip:
241
+ logger.warning("clip event %r has no clip file", event_id)
242
+ return False
243
+ if gain is None:
244
+ gain = ev.intensity
245
+ wav = self._load_clip(event_id, ev)
246
+ if wav is None:
247
+ return False
248
+ self._clip.play(
249
+ wav.data,
250
+ sample_rate=wav.sample_rate,
251
+ channels=wav.channels,
252
+ gain=max(0.0, min(1.0, float(gain))),
253
+ target=self._effective_target(ev, target),
254
+ )
255
+ return True
256
+
257
+ def play_clip_file(
258
+ self,
259
+ path: Union[str, Path],
260
+ gain: float = 1.0,
261
+ *,
262
+ target: Optional[str] = None,
263
+ ) -> bool:
264
+ """Stream an arbitrary PCM16 WAV file (not from a manifest)."""
265
+ try:
266
+ wav = read_wav_pcm16(path)
267
+ except (OSError, ValueError) as exc:
268
+ logger.warning("cannot read clip %s: %s", path, exc)
269
+ return False
270
+ self._clip.play(
271
+ wav.data,
272
+ sample_rate=wav.sample_rate,
273
+ channels=wav.channels,
274
+ gain=max(0.0, min(1.0, float(gain))),
275
+ target=self._resolve_target(target),
276
+ )
277
+ return True
278
+
279
+ def stream_pcm(
280
+ self,
281
+ pcm: bytes,
282
+ *,
283
+ sample_rate: int = 16000,
284
+ channels: int = 1,
285
+ gain: float = 1.0,
286
+ target: Optional[str] = None,
287
+ ) -> None:
288
+ """Stream an ad-hoc PCM16 buffer (e.g. synthesized cues). ``channels=2``
289
+ with per-channel amplitude conveys L/R balance (PLAY has no pan)."""
290
+ self._clip.play(
291
+ pcm,
292
+ sample_rate=sample_rate,
293
+ channels=max(1, channels),
294
+ gain=max(0.0, min(1.0, float(gain))),
295
+ target=self._resolve_target(target),
296
+ )
297
+
298
+ def preload_clips(self) -> None:
299
+ """Decode every clip-mode event's WAV up front so the first play of
300
+ each has no load latency."""
301
+ if self.event_map is None:
302
+ return
303
+ for event_id in self.event_map.ids():
304
+ ev = self.event_map.get(event_id)
305
+ if ev is not None and ev.streaming and ev.clip:
306
+ self._load_clip(event_id, ev)
307
+
308
+ def _resolve_clip_path(self, ev: EventDef) -> Optional[Path]:
309
+ if not ev.clip:
310
+ return None
311
+ if self.clip_base is not None:
312
+ return self.clip_base / ev.clip
313
+ if self.event_map is not None and self.event_map.kit_dir is not None:
314
+ return self.event_map.kit_dir / "stream-clips" / ev.clip
315
+ logger.warning(
316
+ "cannot resolve clip %r: no clip_base and EventMap has no kit_dir "
317
+ "(load the EventMap with from_kit/from_manifest path, or pass clip_base)",
318
+ ev.clip,
319
+ )
320
+ return None
321
+
322
+ def _load_clip(self, event_id: str, ev: EventDef) -> Optional[WavPcm]:
323
+ cached = self._clip_cache.get(event_id)
324
+ if cached is not None:
325
+ return cached
326
+ path = self._resolve_clip_path(ev)
327
+ if path is None:
328
+ return None
329
+ try:
330
+ wav = read_wav_pcm16(path)
331
+ except (OSError, ValueError) as exc:
332
+ logger.warning("failed to load clip %s: %s", path, exc)
333
+ return None
334
+ self._clip_cache[event_id] = wav
335
+ return wav
336
+
337
+ # ── StreamSink (called by ClipStreamer) ─────────────────────────
338
+ def stream_begin(self, *, sample_rate: int, channels: int,
339
+ total_samples: int, gain: float, target: str) -> None:
340
+ self._client.send(protocol.build_stream_begin(
341
+ self._next_seq(), sample_rate=sample_rate, channels=channels,
342
+ fmt=protocol.AUDIO_FORMAT_PCM16, total_samples=total_samples,
343
+ gain=gain, target=target,
344
+ ))
345
+
346
+ def stream_data(self, offset: int, data: bytes) -> None:
347
+ self._client.send(protocol.build_stream_data(self._next_seq(), offset, data))
348
+
349
+ def stream_end(self) -> None:
350
+ self._client.send(protocol.build_stream_end(self._next_seq()))
351
+
352
+ # ── Discovery ───────────────────────────────────────────────────
353
+ def discover(self, timeout: float = 1.0) -> list[Device]:
354
+ """Broadcast a PING and collect devices that reply within ``timeout``."""
355
+ before = time.monotonic()
356
+ self.ping()
357
+ time.sleep(max(0.0, timeout))
358
+ with self._devices_lock:
359
+ return [d for d in self._devices.values() if d.last_seen >= before - 0.05]
360
+
361
+ @property
362
+ def devices(self) -> list[Device]:
363
+ with self._devices_lock:
364
+ return list(self._devices.values())
365
+
366
+ def _on_pong(self, pong: dict, ip: str) -> None:
367
+ with self._devices_lock:
368
+ dev = self._devices.get(ip) or Device(ip=ip)
369
+ dev.name = pong.get("device_name", dev.name)
370
+ dev.address = pong.get("address", dev.address)
371
+ dev.firmware_version = pong.get("firmware_version", dev.firmware_version)
372
+ dev.last_seen = time.monotonic()
373
+ self._devices[ip] = dev
374
+
375
+ # ── Keep-alive thread ───────────────────────────────────────────
376
+ def _start_keepalive(self) -> None:
377
+ self._keepalive_stop = threading.Event()
378
+ self.connect_status(connected=True)
379
+
380
+ def loop(stop: threading.Event) -> None:
381
+ while not stop.wait(self._keepalive_interval):
382
+ self.connect_status(connected=True)
383
+
384
+ self._keepalive_thread = threading.Thread(
385
+ target=loop, args=(self._keepalive_stop,),
386
+ name="hapbeat-keepalive", daemon=True,
387
+ )
388
+ self._keepalive_thread.start()
389
+
390
+ def _stop_keepalive(self) -> None:
391
+ if self._keepalive_stop is not None:
392
+ self._keepalive_stop.set()
393
+ if self._keepalive_thread is not None:
394
+ self._keepalive_thread.join(timeout=1.0)
395
+ self._keepalive_thread = None
396
+
397
+
398
+ def connect(
399
+ *,
400
+ port: int = DEFAULT_PORT,
401
+ broadcast_addr: str = DEFAULT_BROADCAST,
402
+ app_name: str = "",
403
+ device_name: str = "",
404
+ group: int = 0,
405
+ default_target: str = "",
406
+ event_map: Optional[EventMap] = None,
407
+ keepalive: bool = True,
408
+ bind_port: int = 0,
409
+ kit: Optional[Union[str, Path]] = None,
410
+ haptics: Optional[Union[str, Path]] = None,
411
+ clip_base: Optional[Union[str, Path]] = None,
412
+ stream_send_ahead: float = 0.15,
413
+ ) -> Hapbeat:
414
+ """Open a connection and return a ready :class:`Hapbeat`.
415
+
416
+ Equivalent to ``Hapbeat(...).open()``. The local receive socket binds an
417
+ ephemeral port by default (``bind_port=0``) so the SDK coexists with
418
+ hapbeat-helper, which owns the well-known UDP 7700 for Hapbeat Studio.
419
+ Pass ``bind_port=port`` only if you need the device's unsolicited
420
+ broadcasts (normally a daemon's job).
421
+
422
+ Loading the EventMap (pick one):
423
+ - ``haptics`` — a *haptic file* (overlay that references a kit and adds
424
+ per-event target/gain); ``EventMap.from_file``. Recommended.
425
+ - ``kit`` — a kit folder; ``EventMap.from_kit`` (intensity/clip only,
426
+ no targeting).
427
+ ``clip_base`` overrides where clip WAVs are read (default ``<kit>/stream-clips/``).
428
+ """
429
+ if event_map is None:
430
+ if haptics is not None:
431
+ event_map = EventMap.from_file(haptics)
432
+ elif kit is not None:
433
+ event_map = EventMap.from_kit(kit)
434
+ return Hapbeat(
435
+ port=port,
436
+ broadcast_addr=broadcast_addr,
437
+ app_name=app_name,
438
+ device_name=device_name,
439
+ group=group,
440
+ default_target=default_target,
441
+ event_map=event_map,
442
+ keepalive=keepalive,
443
+ bind_port=bind_port,
444
+ clip_base=clip_base,
445
+ stream_send_ahead=stream_send_ahead,
446
+ ).open()