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/launchpad.py
ADDED
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""Browser launchpad -- try Hapbeat from a single local web page.
|
|
2
|
+
|
|
3
|
+
Browsers cannot send raw UDP, so this serves a tiny local web page backed by
|
|
4
|
+
a stdlib ``http.server`` that relays button presses to the SDK
|
|
5
|
+
(``hapbeat.play`` etc.). Open one page and fire one-shots, run a metronome,
|
|
6
|
+
a breathing pacer, or send Morse -- start and stop them live, no per-example
|
|
7
|
+
launching.
|
|
8
|
+
|
|
9
|
+
Run it from the CLI::
|
|
10
|
+
|
|
11
|
+
hapbeat launchpad # opens http://127.0.0.1:7100
|
|
12
|
+
hapbeat launchpad --port 8000 --no-open
|
|
13
|
+
|
|
14
|
+
Zero dependencies (stdlib only). The continuous activities (metronome /
|
|
15
|
+
breathing / morse) are deliberately compact mirrors of the standalone
|
|
16
|
+
examples; the examples remain the place to read and copy full code.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import json
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import webbrowser
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
+
from typing import Callable, Optional
|
|
27
|
+
|
|
28
|
+
from .hapbeat import Hapbeat, connect
|
|
29
|
+
|
|
30
|
+
# Compact Morse table (a subset; the morse_text example has the full one).
|
|
31
|
+
_MORSE = {
|
|
32
|
+
"A": ".-", "B": "-...", "C": "-.-.", "D": "-..", "E": ".", "F": "..-.",
|
|
33
|
+
"G": "--.", "H": "....", "I": "..", "J": ".---", "K": "-.-", "L": ".-..",
|
|
34
|
+
"M": "--", "N": "-.", "O": "---", "P": ".--.", "Q": "--.-", "R": ".-.",
|
|
35
|
+
"S": "...", "T": "-", "U": "..-", "V": "...-", "W": ".--", "X": "-..-",
|
|
36
|
+
"Y": "-.--", "Z": "--..",
|
|
37
|
+
"0": "-----", "1": ".----", "2": "..---", "3": "...--", "4": "....-",
|
|
38
|
+
"5": ".....", "6": "-....", "7": "--...", "8": "---..", "9": "----.",
|
|
39
|
+
".": ".-.-.-", ",": "--..--", "?": "..--..", "/": "-..-.",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── helpers ──────────────────────────────────────────────────────────
|
|
44
|
+
def _num(d: dict, key: str, default: float,
|
|
45
|
+
lo: Optional[float] = None, hi: Optional[float] = None) -> float:
|
|
46
|
+
try:
|
|
47
|
+
v = float(d.get(key, default))
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
v = default
|
|
50
|
+
if lo is not None:
|
|
51
|
+
v = max(lo, v)
|
|
52
|
+
if hi is not None:
|
|
53
|
+
v = min(hi, v)
|
|
54
|
+
return v
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _sleep_until(stop: threading.Event, deadline: float) -> bool:
|
|
58
|
+
"""Sleep to an absolute perf_counter deadline; True if stopped early."""
|
|
59
|
+
while True:
|
|
60
|
+
remaining = deadline - time.perf_counter()
|
|
61
|
+
if remaining <= 0:
|
|
62
|
+
return False
|
|
63
|
+
if stop.wait(min(remaining, 0.05)):
|
|
64
|
+
return True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── single-slot background engine ────────────────────────────────────
|
|
68
|
+
class Engine:
|
|
69
|
+
"""Runs at most one continuous activity; starting a new one stops it."""
|
|
70
|
+
|
|
71
|
+
def __init__(self) -> None:
|
|
72
|
+
self._lock = threading.Lock()
|
|
73
|
+
self._stop: Optional[threading.Event] = None
|
|
74
|
+
self._thread: Optional[threading.Thread] = None
|
|
75
|
+
self.current: Optional[str] = None
|
|
76
|
+
self.info: dict = {}
|
|
77
|
+
|
|
78
|
+
def start(self, name: str, fn: Callable[[threading.Event], None],
|
|
79
|
+
info: dict) -> None:
|
|
80
|
+
self.stop()
|
|
81
|
+
stop = threading.Event()
|
|
82
|
+
with self._lock:
|
|
83
|
+
self._stop = stop
|
|
84
|
+
self.current = name
|
|
85
|
+
self.info = info
|
|
86
|
+
|
|
87
|
+
def wrapped() -> None:
|
|
88
|
+
try:
|
|
89
|
+
fn(stop)
|
|
90
|
+
finally:
|
|
91
|
+
with self._lock:
|
|
92
|
+
if self._stop is stop:
|
|
93
|
+
self.current = None
|
|
94
|
+
self.info = {}
|
|
95
|
+
|
|
96
|
+
self._thread = threading.Thread(target=wrapped, name=f"launchpad-{name}",
|
|
97
|
+
daemon=True)
|
|
98
|
+
self._thread.start()
|
|
99
|
+
|
|
100
|
+
def stop(self) -> None:
|
|
101
|
+
with self._lock:
|
|
102
|
+
stop, thread = self._stop, self._thread
|
|
103
|
+
self._stop = self._thread = None
|
|
104
|
+
self.current = None
|
|
105
|
+
self.info = {}
|
|
106
|
+
if stop is not None:
|
|
107
|
+
stop.set()
|
|
108
|
+
if thread is not None and thread is not threading.current_thread():
|
|
109
|
+
thread.join(timeout=1.0)
|
|
110
|
+
|
|
111
|
+
def status(self) -> dict:
|
|
112
|
+
with self._lock:
|
|
113
|
+
return {"running": self.current, "info": dict(self.info)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ── application (one shared Hapbeat + one Engine) ────────────────────
|
|
117
|
+
class App:
|
|
118
|
+
def __init__(self, hb: Hapbeat) -> None:
|
|
119
|
+
self.hb = hb
|
|
120
|
+
self.engine = Engine()
|
|
121
|
+
|
|
122
|
+
# one-shots
|
|
123
|
+
def devices(self) -> dict:
|
|
124
|
+
found = self.hb.discover(timeout=1.0)
|
|
125
|
+
return {"devices": [{"ip": d.ip, "address": d.address,
|
|
126
|
+
"name": d.name, "firmware": d.firmware_version}
|
|
127
|
+
for d in found]}
|
|
128
|
+
|
|
129
|
+
def play(self, p: dict) -> dict:
|
|
130
|
+
event = str(p.get("event", "")).strip()
|
|
131
|
+
if not event:
|
|
132
|
+
return {"ok": False, "error": "event id is empty"}
|
|
133
|
+
ok = self.hb.play(event, _num(p, "gain", 0.5, 0, 1),
|
|
134
|
+
target=str(p.get("target", "")))
|
|
135
|
+
return {"ok": bool(ok)}
|
|
136
|
+
|
|
137
|
+
def stop_all(self) -> dict:
|
|
138
|
+
self.engine.stop()
|
|
139
|
+
self.hb.stop_all()
|
|
140
|
+
return {"ok": True}
|
|
141
|
+
|
|
142
|
+
def status(self) -> dict:
|
|
143
|
+
return self.engine.status()
|
|
144
|
+
|
|
145
|
+
# continuous activities
|
|
146
|
+
def metronome(self, p: dict) -> dict:
|
|
147
|
+
event = str(p.get("event", "")).strip()
|
|
148
|
+
if not event:
|
|
149
|
+
return {"ok": False, "error": "event id is empty"}
|
|
150
|
+
accent_event = str(p.get("accent_event", "")).strip() or event
|
|
151
|
+
bpm = _num(p, "bpm", 100, 20, 400)
|
|
152
|
+
beats = int(_num(p, "beats", 4, 0, 32))
|
|
153
|
+
gain = _num(p, "gain", 0.5, 0, 1)
|
|
154
|
+
accent_gain = _num(p, "accent_gain", 1.0, 0, 1)
|
|
155
|
+
target = str(p.get("target", ""))
|
|
156
|
+
|
|
157
|
+
def run(stop: threading.Event) -> None:
|
|
158
|
+
interval = 60.0 / bpm
|
|
159
|
+
t = time.perf_counter() + 0.2
|
|
160
|
+
beat = 0
|
|
161
|
+
while not stop.is_set():
|
|
162
|
+
if _sleep_until(stop, t):
|
|
163
|
+
break
|
|
164
|
+
if time.perf_counter() - t > interval: # re-anchor after a stall
|
|
165
|
+
t = time.perf_counter()
|
|
166
|
+
accented = beats > 0 and beat == 0
|
|
167
|
+
self.hb.play(accent_event if accented else event,
|
|
168
|
+
gain=accent_gain if accented else gain, target=target)
|
|
169
|
+
t += interval
|
|
170
|
+
if beats > 0:
|
|
171
|
+
beat = (beat + 1) % beats
|
|
172
|
+
|
|
173
|
+
self.engine.start("metronome", run, {"bpm": bpm, "beats": beats})
|
|
174
|
+
return {"ok": True}
|
|
175
|
+
|
|
176
|
+
def breathing(self, p: dict) -> dict:
|
|
177
|
+
event = str(p.get("event", "")).strip()
|
|
178
|
+
if not event:
|
|
179
|
+
return {"ok": False, "error": "event id is empty"}
|
|
180
|
+
inhale = _num(p, "inhale", 4, 0.5, 60)
|
|
181
|
+
hold = _num(p, "hold", 0, 0, 60)
|
|
182
|
+
exhale = _num(p, "exhale", 4, 0.5, 60)
|
|
183
|
+
rest = _num(p, "rest", 0, 0, 60)
|
|
184
|
+
peak = _num(p, "peak", 0.8, 0, 1)
|
|
185
|
+
floor = _num(p, "floor", 0.15, 0, 1)
|
|
186
|
+
tick_interval = 1.0 / _num(p, "tick_rate", 2.0, 0.2, 20)
|
|
187
|
+
target = str(p.get("target", ""))
|
|
188
|
+
phases = [("IN", inhale, lambda x: floor + (peak - floor) * x),
|
|
189
|
+
("HOLD", hold, lambda x: peak),
|
|
190
|
+
("OUT", exhale, lambda x: peak - (peak - floor) * x),
|
|
191
|
+
("REST", rest, lambda x: floor)]
|
|
192
|
+
|
|
193
|
+
def run(stop: threading.Event) -> None:
|
|
194
|
+
t = time.perf_counter() + 0.3
|
|
195
|
+
while not stop.is_set():
|
|
196
|
+
for _label, duration, gain_at in phases:
|
|
197
|
+
if duration <= 0:
|
|
198
|
+
continue
|
|
199
|
+
n = max(1, round(duration / tick_interval))
|
|
200
|
+
for i in range(n):
|
|
201
|
+
if _sleep_until(stop, t + i * tick_interval):
|
|
202
|
+
return
|
|
203
|
+
x = i / (n - 1) if n > 1 else 1.0
|
|
204
|
+
self.hb.play(event, gain=gain_at(x), target=target)
|
|
205
|
+
t += duration
|
|
206
|
+
|
|
207
|
+
self.engine.start("breathing", run,
|
|
208
|
+
{"pattern": [inhale, hold, exhale, rest]})
|
|
209
|
+
return {"ok": True}
|
|
210
|
+
|
|
211
|
+
def morse(self, p: dict) -> dict:
|
|
212
|
+
event = str(p.get("event", "")).strip()
|
|
213
|
+
text = str(p.get("text", "")).strip()
|
|
214
|
+
if not event:
|
|
215
|
+
return {"ok": False, "error": "event id is empty"}
|
|
216
|
+
if not text:
|
|
217
|
+
return {"ok": False, "error": "text is empty"}
|
|
218
|
+
unit = 1.2 / _num(p, "wpm", 12, 1, 60)
|
|
219
|
+
gain = _num(p, "gain", 0.7, 0, 1)
|
|
220
|
+
target = str(p.get("target", ""))
|
|
221
|
+
|
|
222
|
+
def run(stop: threading.Event) -> None:
|
|
223
|
+
for wi, word in enumerate(text.upper().split()):
|
|
224
|
+
if wi and _sleep_until(stop, time.perf_counter() + 7 * unit):
|
|
225
|
+
return
|
|
226
|
+
sent = False
|
|
227
|
+
for ch in word:
|
|
228
|
+
code = _MORSE.get(ch)
|
|
229
|
+
if not code:
|
|
230
|
+
continue
|
|
231
|
+
if sent and stop.wait(3 * unit):
|
|
232
|
+
return
|
|
233
|
+
sent = True
|
|
234
|
+
for ei, sym in enumerate(code):
|
|
235
|
+
if ei and stop.wait(unit):
|
|
236
|
+
return
|
|
237
|
+
self.hb.play(event, gain=gain, target=target)
|
|
238
|
+
if stop.wait(unit if sym == "." else 3 * unit):
|
|
239
|
+
self.hb.stop(event)
|
|
240
|
+
return
|
|
241
|
+
self.hb.stop(event)
|
|
242
|
+
|
|
243
|
+
self.engine.start("morse", run, {"text": text})
|
|
244
|
+
return {"ok": True}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# ── HTTP handler ─────────────────────────────────────────────────────
|
|
248
|
+
class _BaseHandler(BaseHTTPRequestHandler):
|
|
249
|
+
app: App # set on the subclass by build_server
|
|
250
|
+
|
|
251
|
+
def log_message(self, *_args) -> None: # keep the console quiet
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
def _json(self, obj: dict, code: int = 200) -> None:
|
|
255
|
+
body = json.dumps(obj).encode("utf-8")
|
|
256
|
+
self.send_response(code)
|
|
257
|
+
self.send_header("Content-Type", "application/json")
|
|
258
|
+
self.send_header("Content-Length", str(len(body)))
|
|
259
|
+
self.end_headers()
|
|
260
|
+
self.wfile.write(body)
|
|
261
|
+
|
|
262
|
+
def _body(self) -> dict:
|
|
263
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
264
|
+
raw = self.rfile.read(length) if length else b""
|
|
265
|
+
try:
|
|
266
|
+
return json.loads(raw or b"{}")
|
|
267
|
+
except json.JSONDecodeError:
|
|
268
|
+
return {}
|
|
269
|
+
|
|
270
|
+
def do_GET(self) -> None: # noqa: N802
|
|
271
|
+
if self.path == "/" or self.path.startswith("/?"):
|
|
272
|
+
body = PAGE.encode("utf-8")
|
|
273
|
+
self.send_response(200)
|
|
274
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
275
|
+
self.send_header("Content-Length", str(len(body)))
|
|
276
|
+
self.end_headers()
|
|
277
|
+
self.wfile.write(body)
|
|
278
|
+
elif self.path == "/api/devices":
|
|
279
|
+
self._json(self.app.devices())
|
|
280
|
+
elif self.path == "/api/status":
|
|
281
|
+
self._json(self.app.status())
|
|
282
|
+
else:
|
|
283
|
+
self._json({"error": "not found"}, 404)
|
|
284
|
+
|
|
285
|
+
def do_POST(self) -> None: # noqa: N802
|
|
286
|
+
routes = {
|
|
287
|
+
"/api/play": self.app.play,
|
|
288
|
+
"/api/metronome": self.app.metronome,
|
|
289
|
+
"/api/breathing": self.app.breathing,
|
|
290
|
+
"/api/morse": self.app.morse,
|
|
291
|
+
}
|
|
292
|
+
if self.path in routes:
|
|
293
|
+
try:
|
|
294
|
+
self._json(routes[self.path](self._body()))
|
|
295
|
+
except Exception as exc: # noqa: BLE001 -- report, never 500-crash
|
|
296
|
+
self._json({"ok": False, "error": str(exc)}, 200)
|
|
297
|
+
elif self.path == "/api/stop":
|
|
298
|
+
self._json(self.app.stop_all())
|
|
299
|
+
elif self.path == "/api/engine/stop":
|
|
300
|
+
self.app.engine.stop()
|
|
301
|
+
self._json({"ok": True})
|
|
302
|
+
else:
|
|
303
|
+
self._json({"error": "not found"}, 404)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def build_server(app: App, host: str, port: int) -> ThreadingHTTPServer:
|
|
307
|
+
handler = type("LaunchpadHandler", (_BaseHandler,), {"app": app})
|
|
308
|
+
return ThreadingHTTPServer((host, port), handler)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def serve(host: str = "127.0.0.1", port: int = 7100, *,
|
|
312
|
+
udp_port: int = 7700, target: str = "",
|
|
313
|
+
open_browser: bool = True) -> int:
|
|
314
|
+
# bind_port=0: receive on an ephemeral port and leave UDP 7700 to
|
|
315
|
+
# hapbeat-helper, so the launchpad and Hapbeat Studio can run together.
|
|
316
|
+
hb = connect(port=udp_port, app_name="Launchpad", default_target=target,
|
|
317
|
+
bind_port=0)
|
|
318
|
+
app = App(hb)
|
|
319
|
+
server = build_server(app, host, port)
|
|
320
|
+
url = f"http://{host}:{server.server_address[1]}/"
|
|
321
|
+
print(f"Hapbeat launchpad: {url}")
|
|
322
|
+
print("Open it in a browser. Ctrl-C to stop.")
|
|
323
|
+
if open_browser:
|
|
324
|
+
try:
|
|
325
|
+
webbrowser.open(url)
|
|
326
|
+
except Exception: # noqa: BLE001
|
|
327
|
+
pass
|
|
328
|
+
try:
|
|
329
|
+
server.serve_forever()
|
|
330
|
+
except KeyboardInterrupt:
|
|
331
|
+
print("\nshutting down")
|
|
332
|
+
finally:
|
|
333
|
+
app.engine.stop()
|
|
334
|
+
server.shutdown()
|
|
335
|
+
server.server_close()
|
|
336
|
+
hb.close()
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
# ── the single page (ASCII only -- cp932 consoles) ───────────────────
|
|
341
|
+
PAGE = r"""<!doctype html>
|
|
342
|
+
<html lang="en">
|
|
343
|
+
<head>
|
|
344
|
+
<meta charset="utf-8">
|
|
345
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
346
|
+
<title>Hapbeat Launchpad</title>
|
|
347
|
+
<style>
|
|
348
|
+
:root {
|
|
349
|
+
--bg: #0e1116; --panel: #171c24; --panel2: #1d242e; --line: #2a323d;
|
|
350
|
+
--text: #e6edf3; --muted: #8b97a6; --accent: #5b8cff; --accent2: #7c5cff;
|
|
351
|
+
--ok: #3fb950; --warn: #d29922; --danger: #f85149;
|
|
352
|
+
}
|
|
353
|
+
* { box-sizing: border-box; }
|
|
354
|
+
body {
|
|
355
|
+
margin: 0; font: 15px/1.5 -apple-system, Segoe UI, Roboto, sans-serif;
|
|
356
|
+
color: var(--text);
|
|
357
|
+
background: radial-gradient(1200px 600px at 70% -10%, #1b2740 0%, var(--bg) 55%);
|
|
358
|
+
min-height: 100vh;
|
|
359
|
+
}
|
|
360
|
+
header {
|
|
361
|
+
padding: 28px 24px 8px; max-width: 1080px; margin: 0 auto;
|
|
362
|
+
}
|
|
363
|
+
header h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -0.02em; }
|
|
364
|
+
header h1 .dot {
|
|
365
|
+
background: linear-gradient(90deg, var(--accent), var(--accent2));
|
|
366
|
+
-webkit-background-clip: text; background-clip: text; color: transparent;
|
|
367
|
+
}
|
|
368
|
+
header p { margin: 0; color: var(--muted); }
|
|
369
|
+
main { max-width: 1080px; margin: 0 auto; padding: 16px 24px 60px; }
|
|
370
|
+
.bar {
|
|
371
|
+
display: flex; gap: 12px; flex-wrap: wrap; align-items: end;
|
|
372
|
+
background: var(--panel); border: 1px solid var(--line);
|
|
373
|
+
border-radius: 14px; padding: 14px 16px; margin: 14px 0 22px;
|
|
374
|
+
}
|
|
375
|
+
.field { display: flex; flex-direction: column; gap: 4px; }
|
|
376
|
+
.field label { font-size: 12px; color: var(--muted); }
|
|
377
|
+
input, select {
|
|
378
|
+
background: var(--panel2); color: var(--text);
|
|
379
|
+
border: 1px solid var(--line); border-radius: 9px; padding: 8px 10px;
|
|
380
|
+
font: inherit; min-width: 0;
|
|
381
|
+
}
|
|
382
|
+
input:focus, select:focus { outline: 2px solid var(--accent); border-color: transparent; }
|
|
383
|
+
input[type=range] { padding: 0; }
|
|
384
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
385
|
+
.grid {
|
|
386
|
+
display: grid; gap: 18px;
|
|
387
|
+
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
388
|
+
}
|
|
389
|
+
.card {
|
|
390
|
+
background: var(--panel); border: 1px solid var(--line);
|
|
391
|
+
border-radius: 16px; padding: 18px; display: flex; flex-direction: column; gap: 12px;
|
|
392
|
+
}
|
|
393
|
+
.card h2 { margin: 0; font-size: 17px; }
|
|
394
|
+
.card .desc { color: var(--muted); font-size: 13px; margin: -6px 0 2px; }
|
|
395
|
+
.row { display: flex; gap: 10px; flex-wrap: wrap; align-items: end; }
|
|
396
|
+
.row .field { flex: 1; }
|
|
397
|
+
button {
|
|
398
|
+
appearance: none; border: 1px solid transparent; border-radius: 10px;
|
|
399
|
+
padding: 10px 14px; font: inherit; font-weight: 600; cursor: pointer;
|
|
400
|
+
color: #fff; background: linear-gradient(180deg, var(--accent), #3f6fe0);
|
|
401
|
+
}
|
|
402
|
+
button:hover { filter: brightness(1.07); }
|
|
403
|
+
button:active { transform: translateY(1px); }
|
|
404
|
+
button.ghost { background: var(--panel2); color: var(--text); border-color: var(--line); }
|
|
405
|
+
button.stop { background: linear-gradient(180deg, #ff6a60, var(--danger)); }
|
|
406
|
+
button.run.active { background: linear-gradient(180deg, #ffb648, var(--warn)); }
|
|
407
|
+
.cmd {
|
|
408
|
+
display: flex; align-items: flex-start; gap: 8px; margin-top: 2px;
|
|
409
|
+
background: #0a0d12; border: 1px solid var(--line); border-radius: 9px;
|
|
410
|
+
padding: 7px 9px;
|
|
411
|
+
}
|
|
412
|
+
.cmd code {
|
|
413
|
+
flex: 1; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere;
|
|
414
|
+
font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: #9fb0c3;
|
|
415
|
+
}
|
|
416
|
+
.cmd .copy {
|
|
417
|
+
background: var(--panel2); color: var(--muted); border: 1px solid var(--line);
|
|
418
|
+
border-radius: 7px; padding: 4px 9px; font-size: 12px; font-weight: 500;
|
|
419
|
+
flex: none;
|
|
420
|
+
}
|
|
421
|
+
.stopall { margin-left: auto; }
|
|
422
|
+
.pill {
|
|
423
|
+
display: inline-flex; align-items: center; gap: 8px; font-size: 13px;
|
|
424
|
+
background: var(--panel2); border: 1px solid var(--line);
|
|
425
|
+
border-radius: 999px; padding: 6px 12px; color: var(--muted);
|
|
426
|
+
}
|
|
427
|
+
.pill .led { width: 9px; height: 9px; border-radius: 50%; background: var(--muted); }
|
|
428
|
+
.pill.online .led { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
|
|
429
|
+
.pill.offline .led { background: var(--danger); }
|
|
430
|
+
#log {
|
|
431
|
+
margin-top: 22px; background: #0a0d12; border: 1px solid var(--line);
|
|
432
|
+
border-radius: 12px; padding: 12px 14px; height: 130px; overflow: auto;
|
|
433
|
+
font-family: ui-monospace, Consolas, monospace; font-size: 12.5px; color: #9fb0c3;
|
|
434
|
+
}
|
|
435
|
+
#log .err { color: var(--danger); }
|
|
436
|
+
#log .ok { color: var(--ok); }
|
|
437
|
+
.hint { color: var(--muted); font-size: 12.5px; margin-top: 6px; }
|
|
438
|
+
a { color: var(--accent); }
|
|
439
|
+
</style>
|
|
440
|
+
</head>
|
|
441
|
+
<body>
|
|
442
|
+
<header>
|
|
443
|
+
<h1><span class="dot">Hapbeat</span> Launchpad</h1>
|
|
444
|
+
<p>One page to try the SDK. Buttons hit a local Python server that drives the
|
|
445
|
+
device over Wi-Fi UDP. Each card shows the equivalent terminal command --
|
|
446
|
+
the button runs exactly that, so you can copy it and run it in a shell.</p>
|
|
447
|
+
</header>
|
|
448
|
+
<main>
|
|
449
|
+
<div class="bar">
|
|
450
|
+
<div class="field" style="flex:2; min-width:220px;">
|
|
451
|
+
<label>Default event id (used when a card's field is blank)</label>
|
|
452
|
+
<input id="defEvent" class="mono" placeholder="e.g. impact.hit" value="">
|
|
453
|
+
</div>
|
|
454
|
+
<div class="field">
|
|
455
|
+
<label>Target (blank = all)</label>
|
|
456
|
+
<input id="target" class="mono" placeholder="*/chest" value="">
|
|
457
|
+
</div>
|
|
458
|
+
<div class="field">
|
|
459
|
+
<button class="ghost" onclick="scan()">Scan devices</button>
|
|
460
|
+
</div>
|
|
461
|
+
<span id="devpill" class="pill offline"><span class="led"></span><span id="devtext">no scan yet</span></span>
|
|
462
|
+
<button class="stop stopall" onclick="stopAll()">STOP ALL</button>
|
|
463
|
+
</div>
|
|
464
|
+
|
|
465
|
+
<div class="grid">
|
|
466
|
+
<!-- Quick play -->
|
|
467
|
+
<div class="card">
|
|
468
|
+
<h2>Quick play</h2>
|
|
469
|
+
<div class="desc">Fire a single event. Move the slider for intensity.</div>
|
|
470
|
+
<div class="row">
|
|
471
|
+
<div class="field" style="flex:2;"><label>Event id</label>
|
|
472
|
+
<input id="play_event" class="mono" placeholder="(default)"></div>
|
|
473
|
+
<div class="field"><label>Gain <span id="play_gain_v">0.50</span></label>
|
|
474
|
+
<input id="play_gain" type="range" min="0" max="1" step="0.05" value="0.5"
|
|
475
|
+
oninput="play_gain_v.textContent=(+this.value).toFixed(2)"></div>
|
|
476
|
+
</div>
|
|
477
|
+
<button onclick="play()">Play</button>
|
|
478
|
+
<div class="cmd"><code id="play_cmd"></code>
|
|
479
|
+
<button class="copy" onclick="copyCmd('play_cmd')">copy</button></div>
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<!-- Metronome -->
|
|
483
|
+
<div class="card">
|
|
484
|
+
<h2>Metronome</h2>
|
|
485
|
+
<div class="desc">Steady beat with an accented downbeat.</div>
|
|
486
|
+
<div class="row">
|
|
487
|
+
<div class="field"><label>BPM</label>
|
|
488
|
+
<input id="met_bpm" type="number" value="100" min="20" max="400"></div>
|
|
489
|
+
<div class="field"><label>Beats/bar</label>
|
|
490
|
+
<input id="met_beats" type="number" value="4" min="0" max="32"></div>
|
|
491
|
+
</div>
|
|
492
|
+
<div class="field"><label>Event id</label>
|
|
493
|
+
<input id="met_event" class="mono" placeholder="(default)"></div>
|
|
494
|
+
<button id="met_btn" class="run" onclick="toggle('metronome','met_btn',metParams)">Start</button>
|
|
495
|
+
<div class="cmd"><code id="met_cmd"></code>
|
|
496
|
+
<button class="copy" onclick="copyCmd('met_cmd')">copy</button></div>
|
|
497
|
+
</div>
|
|
498
|
+
|
|
499
|
+
<!-- Breathing -->
|
|
500
|
+
<div class="card">
|
|
501
|
+
<h2>Breathing pacer</h2>
|
|
502
|
+
<div class="desc">Intensity ramps up on inhale, down on exhale.</div>
|
|
503
|
+
<div class="row">
|
|
504
|
+
<div class="field"><label>Preset</label>
|
|
505
|
+
<select id="brk_preset" onchange="applyPreset()">
|
|
506
|
+
<option value="4,4,4,4">Box 4-4-4-4</option>
|
|
507
|
+
<option value="4,7,8,0">4-7-8</option>
|
|
508
|
+
<option value="5.5,0,5.5,0">Coherent 5.5</option>
|
|
509
|
+
</select></div>
|
|
510
|
+
<div class="field"><label>Pattern in,hold,out,rest</label>
|
|
511
|
+
<input id="brk_pattern" class="mono" value="4,4,4,4"></div>
|
|
512
|
+
</div>
|
|
513
|
+
<div class="field"><label>Event id</label>
|
|
514
|
+
<input id="brk_event" class="mono" placeholder="(default)"></div>
|
|
515
|
+
<button id="brk_btn" class="run" onclick="toggle('breathing','brk_btn',brkParams)">Start</button>
|
|
516
|
+
<div class="cmd"><code id="brk_cmd"></code>
|
|
517
|
+
<button class="copy" onclick="copyCmd('brk_cmd')">copy</button></div>
|
|
518
|
+
</div>
|
|
519
|
+
|
|
520
|
+
<!-- Morse -->
|
|
521
|
+
<div class="card">
|
|
522
|
+
<h2>Morse</h2>
|
|
523
|
+
<div class="desc">Send text as vibration. Use a looping buzz event.</div>
|
|
524
|
+
<div class="row">
|
|
525
|
+
<div class="field" style="flex:2;"><label>Text</label>
|
|
526
|
+
<input id="mor_text" value="SOS"></div>
|
|
527
|
+
<div class="field"><label>WPM</label>
|
|
528
|
+
<input id="mor_wpm" type="number" value="12" min="1" max="60"></div>
|
|
529
|
+
</div>
|
|
530
|
+
<div class="field"><label>Event id</label>
|
|
531
|
+
<input id="mor_event" class="mono" placeholder="(default)"></div>
|
|
532
|
+
<button id="mor_btn" class="run" onclick="toggle('morse','mor_btn',morParams)">Send</button>
|
|
533
|
+
<div class="cmd"><code id="mor_cmd"></code>
|
|
534
|
+
<button class="copy" onclick="copyCmd('mor_cmd')">copy</button></div>
|
|
535
|
+
</div>
|
|
536
|
+
</div>
|
|
537
|
+
|
|
538
|
+
<p class="hint">Event ids must exist in the kit deployed to the device (via
|
|
539
|
+
Hapbeat Studio). If nothing buzzes but Scan finds the device, the id is
|
|
540
|
+
probably not in the kit.</p>
|
|
541
|
+
<div id="log"></div>
|
|
542
|
+
</main>
|
|
543
|
+
|
|
544
|
+
<script>
|
|
545
|
+
const $ = id => document.getElementById(id);
|
|
546
|
+
function defEvent(){ return $('defEvent').value.trim(); }
|
|
547
|
+
function target(){ return $('target').value.trim(); }
|
|
548
|
+
function evOr(id){ const v = $(id).value.trim(); return v || defEvent(); }
|
|
549
|
+
|
|
550
|
+
function log(msg, cls){
|
|
551
|
+
const el = $('log'); const line = document.createElement('div');
|
|
552
|
+
if (cls) line.className = cls;
|
|
553
|
+
const t = new Date().toLocaleTimeString();
|
|
554
|
+
line.textContent = '[' + t + '] ' + msg; el.appendChild(line);
|
|
555
|
+
el.scrollTop = el.scrollHeight;
|
|
556
|
+
}
|
|
557
|
+
async function api(path, body){
|
|
558
|
+
try {
|
|
559
|
+
const r = await fetch(path, {method:'POST', headers:{'Content-Type':'application/json'},
|
|
560
|
+
body: JSON.stringify(body||{})});
|
|
561
|
+
return await r.json();
|
|
562
|
+
} catch(e){ log('request failed: ' + e, 'err'); return {ok:false, error:String(e)}; }
|
|
563
|
+
}
|
|
564
|
+
function note(res, what){
|
|
565
|
+
if (res && res.ok === false) log(what + ': ' + (res.error||'failed'), 'err');
|
|
566
|
+
else log(what, 'ok');
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
async function scan(){
|
|
570
|
+
log('scanning...');
|
|
571
|
+
try {
|
|
572
|
+
const r = await fetch('/api/devices'); const d = await r.json();
|
|
573
|
+
const n = (d.devices||[]).length;
|
|
574
|
+
const pill = $('devpill');
|
|
575
|
+
pill.className = 'pill ' + (n ? 'online' : 'offline');
|
|
576
|
+
$('devtext').textContent = n ? (n + ' device(s): ' +
|
|
577
|
+
d.devices.map(x => x.address || x.ip).join(', ')) : 'no device replied';
|
|
578
|
+
log(n ? ('found ' + n + ' device(s)') : 'no device replied (UDP still broadcasts)',
|
|
579
|
+
n ? 'ok' : 'err');
|
|
580
|
+
} catch(e){ log('scan failed: ' + e, 'err'); }
|
|
581
|
+
}
|
|
582
|
+
async function play(){
|
|
583
|
+
const ev = evOr('play_event');
|
|
584
|
+
if (!ev) return log('set an event id (card or default)', 'err');
|
|
585
|
+
note(await api('/api/play', {event: ev, gain: +$('play_gain').value, target: target()}),
|
|
586
|
+
'play ' + ev);
|
|
587
|
+
}
|
|
588
|
+
async function stopAll(){
|
|
589
|
+
note(await api('/api/stop'), 'stop all');
|
|
590
|
+
['met_btn','brk_btn','mor_btn'].forEach(setStart);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function metParams(){ return {event: evOr('met_event'), bpm:+$('met_bpm').value,
|
|
594
|
+
beats:+$('met_beats').value, target: target()}; }
|
|
595
|
+
function brkParams(){ const p=$('brk_pattern').value.split(',').map(Number);
|
|
596
|
+
return {event: evOr('brk_event'), inhale:p[0], hold:p[1]||0, exhale:p[2]||0,
|
|
597
|
+
rest:p[3]||0, target: target()}; }
|
|
598
|
+
function morParams(){ return {event: evOr('mor_event'), text:$('mor_text').value,
|
|
599
|
+
wpm:+$('mor_wpm').value, target: target()}; }
|
|
600
|
+
|
|
601
|
+
function applyPreset(){ $('brk_pattern').value = $('brk_preset').value; renderCmds(); }
|
|
602
|
+
|
|
603
|
+
// The equivalent terminal command per card -- exactly what the button runs.
|
|
604
|
+
function evShow(id){ return evOr(id) || '<event>'; }
|
|
605
|
+
function tgt(){ const t = target(); return t ? ' --target ' + t : ''; }
|
|
606
|
+
function renderCmds(){
|
|
607
|
+
$('play_cmd').textContent =
|
|
608
|
+
'hapbeat play ' + evShow('play_event') +
|
|
609
|
+
' --gain ' + (+$('play_gain').value).toFixed(2) + tgt();
|
|
610
|
+
$('met_cmd').textContent =
|
|
611
|
+
'python examples/metronome.py --event ' + evShow('met_event') +
|
|
612
|
+
' --bpm ' + (+$('met_bpm').value) + ' --beats ' + (+$('met_beats').value) + tgt();
|
|
613
|
+
$('brk_cmd').textContent =
|
|
614
|
+
'python examples/breathing_pacer.py --event ' + evShow('brk_event') +
|
|
615
|
+
' --pattern ' + ($('brk_pattern').value || '4,4,4,4') + tgt();
|
|
616
|
+
$('mor_cmd').textContent =
|
|
617
|
+
'python examples/morse_text.py "' + ($('mor_text').value || 'TEXT') +
|
|
618
|
+
'" --event ' + evShow('mor_event') + ' --wpm ' + (+$('mor_wpm').value) + tgt();
|
|
619
|
+
}
|
|
620
|
+
async function copyCmd(id){
|
|
621
|
+
try { await navigator.clipboard.writeText($(id).textContent);
|
|
622
|
+
log('copied: ' + $(id).textContent, 'ok'); }
|
|
623
|
+
catch(e){ log('copy failed -- select the command text manually', 'err'); }
|
|
624
|
+
}
|
|
625
|
+
document.addEventListener('input', renderCmds);
|
|
626
|
+
function setStart(id){ const b=$(id); b.classList.remove('active');
|
|
627
|
+
b.textContent = id==='mor_btn' ? 'Send' : 'Start'; }
|
|
628
|
+
function setStop(id){ const b=$(id); b.classList.add('active'); b.textContent='Stop'; }
|
|
629
|
+
|
|
630
|
+
async function toggle(kind, btnId, paramsFn){
|
|
631
|
+
const b = $(btnId);
|
|
632
|
+
if (b.classList.contains('active')){
|
|
633
|
+
note(await api('/api/engine/stop'), 'stop ' + kind);
|
|
634
|
+
setStart(btnId); return;
|
|
635
|
+
}
|
|
636
|
+
const ev = paramsFn().event;
|
|
637
|
+
if (!ev) return log('set an event id (card or default)', 'err');
|
|
638
|
+
const res = await api('/api/' + kind, paramsFn());
|
|
639
|
+
if (res.ok === false){ note(res, kind); return; }
|
|
640
|
+
['met_btn','brk_btn','mor_btn'].forEach(setStart);
|
|
641
|
+
setStop(btnId); log('start ' + kind, 'ok');
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Reflect server truth (auto-stop, morse finishing) every ~1.5s.
|
|
645
|
+
async function poll(){
|
|
646
|
+
try {
|
|
647
|
+
const r = await fetch('/api/status'); const s = await r.json();
|
|
648
|
+
const map = {metronome:'met_btn', breathing:'brk_btn', morse:'mor_btn'};
|
|
649
|
+
Object.values(map).forEach(setStart);
|
|
650
|
+
if (s.running && map[s.running]) setStop(map[s.running]);
|
|
651
|
+
} catch(e){}
|
|
652
|
+
}
|
|
653
|
+
setInterval(poll, 1500);
|
|
654
|
+
renderCmds();
|
|
655
|
+
scan();
|
|
656
|
+
</script>
|
|
657
|
+
</body>
|
|
658
|
+
</html>
|
|
659
|
+
"""
|