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/__init__.py
ADDED
|
@@ -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
|
+
]
|
hapbeat/__main__.py
ADDED
hapbeat/cli.py
ADDED
|
@@ -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())
|
hapbeat/client.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""UDP transport for the Hapbeat SDK.
|
|
2
|
+
|
|
3
|
+
Owns one datagram socket configured for broadcast. Sends command packets to
|
|
4
|
+
the LAN broadcast address and runs a background thread that collects PONG
|
|
5
|
+
replies so device discovery works.
|
|
6
|
+
|
|
7
|
+
Design note — port binding:
|
|
8
|
+
The standard transport is Wi-Fi UDP broadcast (see workspace CLAUDE.md).
|
|
9
|
+
To receive PONG replies we bind the well-known port 7700. If that port is
|
|
10
|
+
already taken (e.g. hapbeat-helper is running on the same machine) we fall
|
|
11
|
+
back to an ephemeral bind: sends still work and PING replies still arrive
|
|
12
|
+
at the ephemeral source port; only async broadcast pushes on 7700 are
|
|
13
|
+
missed, which level-1 does not rely on.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import socket
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
from . import protocol
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("hapbeat")
|
|
27
|
+
|
|
28
|
+
DEFAULT_PORT = 7700
|
|
29
|
+
DEFAULT_BROADCAST = "255.255.255.255"
|
|
30
|
+
|
|
31
|
+
PongCallback = Callable[[dict, str], None]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UdpClient:
|
|
35
|
+
"""Broadcast-capable UDP socket plus a PONG receive loop."""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
port: int = DEFAULT_PORT,
|
|
40
|
+
broadcast_addr: str = DEFAULT_BROADCAST,
|
|
41
|
+
bind_port: int = 0,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.port = port # destination port (the device listens here)
|
|
44
|
+
# Local receive bind. Default 0 = ephemeral: this leaves the
|
|
45
|
+
# well-known device port (7700) to the single host daemon that owns
|
|
46
|
+
# it (hapbeat-helper, serving Hapbeat Studio) so an SDK script and
|
|
47
|
+
# Studio coexist. PING replies still arrive at the ephemeral source
|
|
48
|
+
# port, so discovery keeps working. Pass ``bind_port=port`` only to
|
|
49
|
+
# also receive the device's unsolicited broadcasts (daemon use).
|
|
50
|
+
self.bind_port = bind_port
|
|
51
|
+
self.broadcast_addr = broadcast_addr
|
|
52
|
+
self._sock: Optional[socket.socket] = None
|
|
53
|
+
self._thread: Optional[threading.Thread] = None
|
|
54
|
+
self._running = False
|
|
55
|
+
self._bound_well_known = False
|
|
56
|
+
self._pong_callbacks: list[PongCallback] = []
|
|
57
|
+
|
|
58
|
+
# ── Lifecycle ───────────────────────────────────────────────────
|
|
59
|
+
def open(self) -> None:
|
|
60
|
+
if self._sock is not None:
|
|
61
|
+
return
|
|
62
|
+
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
63
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
|
64
|
+
if self.bind_port == self.port:
|
|
65
|
+
# Only when contending for the shared well-known port do we ask to
|
|
66
|
+
# reuse it; an ephemeral bind needs no reuse and must not steal a
|
|
67
|
+
# port another process owns.
|
|
68
|
+
try:
|
|
69
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
70
|
+
except OSError:
|
|
71
|
+
pass
|
|
72
|
+
try:
|
|
73
|
+
sock.bind(("0.0.0.0", self.bind_port))
|
|
74
|
+
self._bound_well_known = self.bind_port == self.port
|
|
75
|
+
except OSError as exc:
|
|
76
|
+
logger.warning(
|
|
77
|
+
"port %d busy (%s); binding ephemeral port (discovery still "
|
|
78
|
+
"works, async broadcast pushes are not received)",
|
|
79
|
+
self.bind_port,
|
|
80
|
+
exc,
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
sock.bind(("0.0.0.0", 0))
|
|
84
|
+
except OSError:
|
|
85
|
+
pass
|
|
86
|
+
self._bound_well_known = False
|
|
87
|
+
sock.settimeout(0.2)
|
|
88
|
+
self._sock = sock
|
|
89
|
+
self._running = True
|
|
90
|
+
self._thread = threading.Thread(
|
|
91
|
+
target=self._recv_loop, name="hapbeat-recv", daemon=True
|
|
92
|
+
)
|
|
93
|
+
self._thread.start()
|
|
94
|
+
|
|
95
|
+
def close(self) -> None:
|
|
96
|
+
self._running = False
|
|
97
|
+
sock, self._sock = self._sock, None
|
|
98
|
+
if sock is not None:
|
|
99
|
+
try:
|
|
100
|
+
sock.close()
|
|
101
|
+
except OSError:
|
|
102
|
+
pass
|
|
103
|
+
if self._thread is not None:
|
|
104
|
+
self._thread.join(timeout=1.0)
|
|
105
|
+
self._thread = None
|
|
106
|
+
|
|
107
|
+
# ── Listeners ───────────────────────────────────────────────────
|
|
108
|
+
def add_pong_listener(self, cb: PongCallback) -> None:
|
|
109
|
+
self._pong_callbacks.append(cb)
|
|
110
|
+
|
|
111
|
+
def remove_pong_listener(self, cb: PongCallback) -> None:
|
|
112
|
+
try:
|
|
113
|
+
self._pong_callbacks.remove(cb)
|
|
114
|
+
except ValueError:
|
|
115
|
+
pass
|
|
116
|
+
|
|
117
|
+
# ── Send ────────────────────────────────────────────────────────
|
|
118
|
+
def send(self, packet: bytes, addr: Optional[str] = None) -> bool:
|
|
119
|
+
"""Send a prebuilt packet. ``addr=None`` -> broadcast."""
|
|
120
|
+
sock = self._sock
|
|
121
|
+
if sock is None:
|
|
122
|
+
logger.error("send before open()")
|
|
123
|
+
return False
|
|
124
|
+
dst = self.broadcast_addr if not addr else addr
|
|
125
|
+
try:
|
|
126
|
+
sock.sendto(packet, (dst, self.port))
|
|
127
|
+
return True
|
|
128
|
+
except OSError as exc:
|
|
129
|
+
logger.warning("UDP send to %s:%d failed: %s", dst, self.port, exc)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
# ── Recv loop ───────────────────────────────────────────────────
|
|
133
|
+
def _recv_loop(self) -> None:
|
|
134
|
+
while self._running:
|
|
135
|
+
sock = self._sock
|
|
136
|
+
if sock is None:
|
|
137
|
+
break
|
|
138
|
+
try:
|
|
139
|
+
data, addr = sock.recvfrom(4096)
|
|
140
|
+
except socket.timeout:
|
|
141
|
+
continue
|
|
142
|
+
except OSError:
|
|
143
|
+
break
|
|
144
|
+
pong = protocol.parse_pong(data)
|
|
145
|
+
if pong is None:
|
|
146
|
+
continue
|
|
147
|
+
ip = addr[0]
|
|
148
|
+
for cb in list(self._pong_callbacks):
|
|
149
|
+
try:
|
|
150
|
+
cb(pong, ip)
|
|
151
|
+
except Exception: # noqa: BLE001 — never let a listener kill the loop
|
|
152
|
+
logger.exception("pong listener raised")
|
hapbeat/clip.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""ClipStreamer — pace a PCM16 clip out as STREAM_BEGIN / STREAM_DATA* / END.
|
|
2
|
+
|
|
3
|
+
The device ring buffer holds only ~256 ms (4096 frames @ 16 kHz), so a whole
|
|
4
|
+
clip cannot be burst at once. Data is sent in frame-aligned chunks, each
|
|
5
|
+
scheduled ~``send_ahead_sec`` before its playback time, so the device buffer
|
|
6
|
+
never overflows. STREAM_END is deferred until the clip has fully drained.
|
|
7
|
+
|
|
8
|
+
A stream is session-level (one at a time): starting a new clip cancels the
|
|
9
|
+
previous one, matching the "1 session = 1 stream" rule in the protocol. Pacing
|
|
10
|
+
runs on a daemon thread; ``stop()`` cancels it and sends STREAM_END exactly
|
|
11
|
+
once. This mirrors the web SDK's ClipStreamer (TypeScript) one-for-one.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import threading
|
|
17
|
+
import time
|
|
18
|
+
from typing import Protocol
|
|
19
|
+
|
|
20
|
+
DEFAULT_SEND_AHEAD = 0.15 # seconds (< 0.256 s device ring)
|
|
21
|
+
MAX_DATA_BYTES = 1024 # per STREAM_DATA payload (well under the 1472 cap)
|
|
22
|
+
_END_DRAIN_PAD = 0.08 # extra wait after last frame's playback before END
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class StreamSink(Protocol):
|
|
26
|
+
"""The subset of the transport a ClipStreamer needs."""
|
|
27
|
+
|
|
28
|
+
def stream_begin(self, *, sample_rate: int, channels: int,
|
|
29
|
+
total_samples: int, gain: float, target: str) -> None: ...
|
|
30
|
+
|
|
31
|
+
def stream_data(self, offset: int, data: bytes) -> None: ...
|
|
32
|
+
|
|
33
|
+
def stream_end(self) -> None: ...
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class _Session:
|
|
37
|
+
__slots__ = ("stop", "ended")
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self.stop = threading.Event()
|
|
41
|
+
self.ended = False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClipStreamer:
|
|
45
|
+
def __init__(self, sink: StreamSink, *, send_ahead_sec: float = DEFAULT_SEND_AHEAD) -> None:
|
|
46
|
+
self._sink = sink
|
|
47
|
+
self._send_ahead = send_ahead_sec
|
|
48
|
+
self._lock = threading.Lock()
|
|
49
|
+
self._active: _Session | None = None
|
|
50
|
+
self._thread: threading.Thread | None = None
|
|
51
|
+
|
|
52
|
+
# ── Public API ──────────────────────────────────────────────────
|
|
53
|
+
def play(self, pcm: bytes, *, sample_rate: int, channels: int,
|
|
54
|
+
gain: float = 1.0, target: str = "") -> None:
|
|
55
|
+
"""Stream a PCM16 byte buffer, real-time paced. Cancels any active clip."""
|
|
56
|
+
self.stop()
|
|
57
|
+
channels = max(1, channels)
|
|
58
|
+
sample_rate = sample_rate or 16000
|
|
59
|
+
bytes_per_frame = channels * 2
|
|
60
|
+
chunk_bytes = max(bytes_per_frame, MAX_DATA_BYTES - (MAX_DATA_BYTES % bytes_per_frame))
|
|
61
|
+
total_frames = len(pcm) // bytes_per_frame
|
|
62
|
+
|
|
63
|
+
session = _Session()
|
|
64
|
+
with self._lock:
|
|
65
|
+
self._active = session
|
|
66
|
+
|
|
67
|
+
# STREAM_BEGIN goes out synchronously, before the pacing thread starts.
|
|
68
|
+
self._sink.stream_begin(
|
|
69
|
+
sample_rate=sample_rate, channels=channels,
|
|
70
|
+
total_samples=total_frames, gain=gain, target=target,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
def run() -> None:
|
|
74
|
+
t0 = time.perf_counter()
|
|
75
|
+
offset = 0
|
|
76
|
+
n = len(pcm)
|
|
77
|
+
while offset < n:
|
|
78
|
+
if session.stop.is_set():
|
|
79
|
+
return # stop() owns the END in this case
|
|
80
|
+
played_sec = (offset / bytes_per_frame) / sample_rate
|
|
81
|
+
wait = t0 + played_sec - self._send_ahead - time.perf_counter()
|
|
82
|
+
if wait > 0 and session.stop.wait(wait):
|
|
83
|
+
return
|
|
84
|
+
end = min(offset + chunk_bytes, n)
|
|
85
|
+
self._sink.stream_data(offset, pcm[offset:end])
|
|
86
|
+
offset = end
|
|
87
|
+
# all data queued -- END after the clip has fully played out
|
|
88
|
+
total_sec = total_frames / sample_rate
|
|
89
|
+
end_wait = max(0.0, t0 + total_sec + _END_DRAIN_PAD - time.perf_counter())
|
|
90
|
+
session.stop.wait(end_wait)
|
|
91
|
+
self._end_session(session)
|
|
92
|
+
|
|
93
|
+
thread = threading.Thread(target=run, name="hapbeat-clip", daemon=True)
|
|
94
|
+
with self._lock:
|
|
95
|
+
self._thread = thread
|
|
96
|
+
thread.start()
|
|
97
|
+
|
|
98
|
+
def stop(self) -> None:
|
|
99
|
+
"""Cancel the active clip (if any) and tell the device to stop."""
|
|
100
|
+
with self._lock:
|
|
101
|
+
session = self._active
|
|
102
|
+
thread = self._thread
|
|
103
|
+
if session is not None:
|
|
104
|
+
self._end_session(session)
|
|
105
|
+
if thread is not None and thread is not threading.current_thread():
|
|
106
|
+
thread.join(timeout=1.0)
|
|
107
|
+
|
|
108
|
+
def is_active(self) -> bool:
|
|
109
|
+
with self._lock:
|
|
110
|
+
return self._active is not None and not self._active.ended
|
|
111
|
+
|
|
112
|
+
# ── Internals ───────────────────────────────────────────────────
|
|
113
|
+
def _end_session(self, session: _Session) -> None:
|
|
114
|
+
with self._lock:
|
|
115
|
+
if session.ended:
|
|
116
|
+
return
|
|
117
|
+
session.ended = True
|
|
118
|
+
if self._active is session:
|
|
119
|
+
self._active = None
|
|
120
|
+
session.stop.set()
|
|
121
|
+
self._sink.stream_end()
|
hapbeat/eventmap.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""EventMap — the *tuning* side of the SDK, kept orthogonal to the fire side.
|
|
2
|
+
|
|
3
|
+
Mirrors the Unity SDK's EventMap concept at level-1: a catalog that maps an
|
|
4
|
+
event id to its per-event haptic settings (default gain, loop, mode, and -- for
|
|
5
|
+
clip events -- which WAV to stream). It is linked to the fire side only by
|
|
6
|
+
event id, so *when/where* to fire (the caller) and *what/how* to play (this
|
|
7
|
+
catalog) stay mutually independent.
|
|
8
|
+
|
|
9
|
+
The canonical source is the kit manifest (schema 2.0.0, see
|
|
10
|
+
hapbeat-contracts/specs/kit-format.md). It has two event buckets:
|
|
11
|
+
|
|
12
|
+
- ``events`` — **command** mode: the device plays a clip already
|
|
13
|
+
installed on it; the SDK just sends a PLAY with the event id.
|
|
14
|
+
- ``stream_events`` — **clip** mode: the SDK reads the event's WAV from the
|
|
15
|
+
kit's ``stream-clips/`` folder and UDP-streams it to the device.
|
|
16
|
+
|
|
17
|
+
``play(event_id)`` branches on which bucket the event came from, so the caller
|
|
18
|
+
writes the same one-liner either way.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
from dataclasses import dataclass
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Optional, Union
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class EventDef:
|
|
31
|
+
"""Per-event haptic settings resolved from a kit manifest (or by hand)."""
|
|
32
|
+
|
|
33
|
+
event_id: str
|
|
34
|
+
intensity: float = 1.0
|
|
35
|
+
loop: bool = False
|
|
36
|
+
device_wiper: Optional[int] = None
|
|
37
|
+
streaming: bool = False # True => clip mode (manifest stream_events bucket)
|
|
38
|
+
clip: str = "" # WAV filename (clip mode), relative to stream-clips/
|
|
39
|
+
target: str = "" # device address (overlay/app side); "" = broadcast
|
|
40
|
+
note: str = ""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def mode(self) -> str:
|
|
44
|
+
"""``"clip"`` for stream events, ``"command"`` otherwise."""
|
|
45
|
+
return "clip" if self.streaming else "command"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class EventMap:
|
|
49
|
+
"""A catalog of event definitions keyed by event id.
|
|
50
|
+
|
|
51
|
+
``kit_dir`` (when known) is the folder that holds the manifest; clip-mode
|
|
52
|
+
WAVs are resolved relative to ``<kit_dir>/stream-clips/``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
events: Optional[dict[str, EventDef]] = None,
|
|
58
|
+
*,
|
|
59
|
+
kit_dir: Optional[Union[str, Path]] = None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._events: dict[str, EventDef] = dict(events or {})
|
|
62
|
+
self.kit_dir: Optional[Path] = Path(kit_dir) if kit_dir else None
|
|
63
|
+
|
|
64
|
+
# ── Construction ────────────────────────────────────────────────
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, gains: dict[str, float]) -> "EventMap":
|
|
67
|
+
"""Build from a simple ``{event_id: gain}`` mapping (all command mode)."""
|
|
68
|
+
return cls({k: EventDef(event_id=k, intensity=float(v)) for k, v in gains.items()})
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def from_manifest(
|
|
72
|
+
cls,
|
|
73
|
+
manifest: Union[str, Path, dict],
|
|
74
|
+
*,
|
|
75
|
+
kit_dir: Optional[Union[str, Path]] = None,
|
|
76
|
+
) -> "EventMap":
|
|
77
|
+
"""Build from a kit manifest (path, JSON string, or parsed dict).
|
|
78
|
+
|
|
79
|
+
Reads schema 2.0.0 ``events`` (command) and ``stream_events`` (clip)
|
|
80
|
+
buckets. When ``manifest`` is a file path and ``kit_dir`` is not given,
|
|
81
|
+
the manifest's parent folder becomes the kit dir (so clip WAVs resolve).
|
|
82
|
+
"""
|
|
83
|
+
src_dir: Optional[Path] = Path(kit_dir) if kit_dir else None
|
|
84
|
+
if isinstance(manifest, (str, Path)) and not _looks_like_json(manifest):
|
|
85
|
+
path = Path(manifest)
|
|
86
|
+
if src_dir is None:
|
|
87
|
+
src_dir = path.parent
|
|
88
|
+
manifest = json.loads(path.read_text(encoding="utf-8"))
|
|
89
|
+
elif isinstance(manifest, str):
|
|
90
|
+
manifest = json.loads(manifest)
|
|
91
|
+
|
|
92
|
+
events: dict[str, EventDef] = {}
|
|
93
|
+
# events first, then stream_events: for an id authored in BOTH buckets
|
|
94
|
+
# (Studio "BOTH" mode) the clip variant wins, matching the web SDK.
|
|
95
|
+
for bucket, streaming in (("events", False), ("stream_events", True)):
|
|
96
|
+
for event_id, entry in (manifest.get(bucket) or {}).items():
|
|
97
|
+
entry = entry or {}
|
|
98
|
+
params = entry.get("parameters") or {}
|
|
99
|
+
events[event_id] = EventDef(
|
|
100
|
+
event_id=event_id,
|
|
101
|
+
intensity=float(params.get("intensity", 1.0)),
|
|
102
|
+
loop=bool(params.get("loop", False)),
|
|
103
|
+
device_wiper=params.get("device_wiper"),
|
|
104
|
+
streaming=streaming,
|
|
105
|
+
clip=entry.get("clip", ""),
|
|
106
|
+
note=entry.get("note", ""),
|
|
107
|
+
)
|
|
108
|
+
return cls(events, kit_dir=src_dir)
|
|
109
|
+
|
|
110
|
+
@classmethod
|
|
111
|
+
def from_kit(cls, kit_dir: Union[str, Path]) -> "EventMap":
|
|
112
|
+
"""Build from a kit folder, discovering ``*-manifest.json`` inside it.
|
|
113
|
+
|
|
114
|
+
The recommended project layout is::
|
|
115
|
+
|
|
116
|
+
kits/<kit-name>/
|
|
117
|
+
<kit-name>-manifest.json
|
|
118
|
+
install-clips/ (command clips, deployed to the device)
|
|
119
|
+
stream-clips/ (clip-mode WAVs the SDK streams)
|
|
120
|
+
"""
|
|
121
|
+
d = Path(kit_dir)
|
|
122
|
+
candidates = sorted(d.glob("*-manifest.json")) or sorted(d.glob("manifest.json"))
|
|
123
|
+
if not candidates:
|
|
124
|
+
raise FileNotFoundError(f"no *-manifest.json found in kit folder {d}")
|
|
125
|
+
return cls.from_manifest(candidates[0], kit_dir=d)
|
|
126
|
+
|
|
127
|
+
@classmethod
|
|
128
|
+
def from_file(cls, path: Union[str, Path]) -> "EventMap":
|
|
129
|
+
"""Build from a *haptic file* — an authored overlay that references a kit.
|
|
130
|
+
|
|
131
|
+
The Studio-generated manifest holds kit content (intensity / clip /
|
|
132
|
+
mode). App-side, per-event settings that the manifest does NOT carry --
|
|
133
|
+
targeting, a gain override -- live here, so ``play(id)`` resolves them
|
|
134
|
+
without the caller passing ``target`` every time. Mirrors the Unity
|
|
135
|
+
SDK's EventMap asset (which references the kit and adds target/gain).
|
|
136
|
+
|
|
137
|
+
File format (JSON)::
|
|
138
|
+
|
|
139
|
+
{
|
|
140
|
+
"kit": "kits/my-kit", # kit folder, relative to this file
|
|
141
|
+
"events": {
|
|
142
|
+
"impact.hit": { "target": "player_1/chest", "gain": 0.8 },
|
|
143
|
+
"rain.loop": { "target": "*/back" }
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
``kit`` is optional (omit it for a pure target/gain overlay with no
|
|
148
|
+
clip resolution). Per-event keys: ``target``, ``gain`` (overrides the
|
|
149
|
+
manifest intensity; ``intensity`` is also accepted), ``loop``, ``note``.
|
|
150
|
+
"""
|
|
151
|
+
p = Path(path)
|
|
152
|
+
spec = json.loads(p.read_text(encoding="utf-8"))
|
|
153
|
+
kit = spec.get("kit")
|
|
154
|
+
if kit:
|
|
155
|
+
kit_path = Path(kit)
|
|
156
|
+
if not kit_path.is_absolute():
|
|
157
|
+
kit_path = p.parent / kit_path
|
|
158
|
+
em = cls.from_kit(kit_path)
|
|
159
|
+
else:
|
|
160
|
+
em = cls()
|
|
161
|
+
|
|
162
|
+
for event_id, raw in (spec.get("events") or {}).items():
|
|
163
|
+
o = raw or {}
|
|
164
|
+
ev = em._events.get(event_id)
|
|
165
|
+
if ev is None: # overlay-only event (not in the kit): command mode
|
|
166
|
+
ev = EventDef(event_id=event_id)
|
|
167
|
+
em._events[event_id] = ev
|
|
168
|
+
if "target" in o:
|
|
169
|
+
ev.target = str(o["target"])
|
|
170
|
+
if "gain" in o:
|
|
171
|
+
ev.intensity = float(o["gain"])
|
|
172
|
+
elif "intensity" in o:
|
|
173
|
+
ev.intensity = float(o["intensity"])
|
|
174
|
+
if "loop" in o:
|
|
175
|
+
ev.loop = bool(o["loop"])
|
|
176
|
+
if "note" in o:
|
|
177
|
+
ev.note = str(o["note"])
|
|
178
|
+
return em
|
|
179
|
+
|
|
180
|
+
# ── Lookup ──────────────────────────────────────────────────────
|
|
181
|
+
def gain_for(self, event_id: str) -> float:
|
|
182
|
+
"""Default gain for an event (its manifest intensity), or 1.0."""
|
|
183
|
+
ev = self._events.get(event_id)
|
|
184
|
+
return ev.intensity if ev is not None else 1.0
|
|
185
|
+
|
|
186
|
+
def get(self, event_id: str) -> Optional[EventDef]:
|
|
187
|
+
return self._events.get(event_id)
|
|
188
|
+
|
|
189
|
+
def add(self, event_id: str, intensity: float = 1.0, **kw) -> None:
|
|
190
|
+
self._events[event_id] = EventDef(event_id=event_id, intensity=intensity, **kw)
|
|
191
|
+
|
|
192
|
+
def ids(self) -> list[str]:
|
|
193
|
+
return list(self._events.keys())
|
|
194
|
+
|
|
195
|
+
def __contains__(self, event_id: str) -> bool:
|
|
196
|
+
return event_id in self._events
|
|
197
|
+
|
|
198
|
+
def __len__(self) -> int:
|
|
199
|
+
return len(self._events)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _looks_like_json(s: Union[str, Path]) -> bool:
|
|
203
|
+
return isinstance(s, str) and s.lstrip().startswith("{")
|