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/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()
|