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 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
@@ -0,0 +1,8 @@
1
+ """Enable ``python -m hapbeat``."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
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("{")