opencode-rgbify-plugin 0.1.3
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.
- package/README.md +97 -0
- package/bridge/__pycache__/ble_bridge.cpython-312.pyc +0 -0
- package/bridge/__pycache__/ble_bridge.cpython-313.pyc +0 -0
- package/bridge/ble_bridge.py +779 -0
- package/bridge/install.ps1 +68 -0
- package/bridge/install.sh +67 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +231 -0
- package/package.json +33 -0
- package/src/index.ts +241 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""BLE bridge: stream newline-delimited text from stdin to the RGBify projector.
|
|
3
|
+
|
|
4
|
+
Interrupt semantics end to end: every line is a new message that supersedes
|
|
5
|
+
anything still in flight. The host auralizer and the BLE write path each keep
|
|
6
|
+
only the LATEST line — a newer line interrupts (replaces) the previous one at
|
|
7
|
+
the next note/chunk boundary, so the last message is the only message. Nothing
|
|
8
|
+
is queued, delayed, or replayed.
|
|
9
|
+
|
|
10
|
+
Discovers the projector at connect time (by advertised service UUID, then name),
|
|
11
|
+
chunks each line into codepoint-safe pieces that fit within the projector's TEXT_BRIDGE
|
|
12
|
+
and writes them to the TEXT_BRIDGE characteristic. Reconnects forever
|
|
13
|
+
with backoff so the plugin stays a silent no-op while the projector is out of
|
|
14
|
+
range or powered off.
|
|
15
|
+
|
|
16
|
+
Every line is ALSO auralized on the host (miniaudio) at the firmware's native
|
|
17
|
+
cadence (one ~33ms note per char, log-scale freq table identical to the
|
|
18
|
+
firmware, whitespace = rest), so
|
|
19
|
+
sound never stops while the projector is away. The host auralizer runs always
|
|
20
|
+
and independently of the BLE connection; when the projector is reachable both
|
|
21
|
+
play the same line (best-effort sync). Lines missed while the projector is down
|
|
22
|
+
are dropped for the projector (no replay on reconnect) so the two stay in sync.
|
|
23
|
+
|
|
24
|
+
miniaudio bundles its own native audio lib, so the host auralizer needs no
|
|
25
|
+
system deps (no PortAudio) and works cross-platform (WASAPI/CoreAudio/Pulse/ALSA).
|
|
26
|
+
|
|
27
|
+
Set RGBIFY_PROJECTOR_ADDR to skip discovery and use a fixed address.
|
|
28
|
+
Set RGBIFY_HOST_AURALIZER=0 to disable the host auralizer (projector unaffected).
|
|
29
|
+
|
|
30
|
+
Host volume is mirrored from the projector's VOLUME characteristic whenever the
|
|
31
|
+
projector is connected and persisted to the state file below so it survives
|
|
32
|
+
restarts. While the projector is down you can still adjust the host volume by
|
|
33
|
+
editing that file (or set RGBIFY_VOLUME as an initial default).
|
|
34
|
+
|
|
35
|
+
stdout protocol (one line per event, for debugging):
|
|
36
|
+
ok <addr> connected / a line delivered
|
|
37
|
+
err <message> delivery failure (will retry)
|
|
38
|
+
"""
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
import math
|
|
42
|
+
import array
|
|
43
|
+
import signal
|
|
44
|
+
import threading
|
|
45
|
+
import asyncio
|
|
46
|
+
import time
|
|
47
|
+
|
|
48
|
+
from bleak import BleakClient, BleakScanner
|
|
49
|
+
|
|
50
|
+
# Optional debug trace, shared with the plugin's log (same env var) so both
|
|
51
|
+
# sides land in ONE unified timeline. Appends are line-sized and atomic.
|
|
52
|
+
DEBUG_LOG = os.environ.get("RGBIFY_DEBUG_LOG", "").strip()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def dbg(line: str) -> None:
|
|
56
|
+
if not DEBUG_LOG:
|
|
57
|
+
return
|
|
58
|
+
try:
|
|
59
|
+
with open(DEBUG_LOG, "a") as f:
|
|
60
|
+
f.write(f"{time.time() * 1000:.0f} bridge {line}\n")
|
|
61
|
+
except OSError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
64
|
+
# Host auralizer: miniaudio bundles its own native lib and is cross-platform
|
|
65
|
+
# (WASAPI/CoreAudio/PulseAudio/ALSA), so no PortAudio/system deps are needed.
|
|
66
|
+
# Optional — the projector path works without it.
|
|
67
|
+
try:
|
|
68
|
+
import miniaudio
|
|
69
|
+
MINIAUDIO_OK = True
|
|
70
|
+
except (ImportError, OSError):
|
|
71
|
+
miniaudio = None
|
|
72
|
+
MINIAUDIO_OK = False
|
|
73
|
+
|
|
74
|
+
SERVICE_UUID = "8bc01404-0000-4bf4-95d1-ce27a0477183"
|
|
75
|
+
TEXT_BRIDGE_UUID = "8bc01404-0009-4bf4-95d1-ce27a0477183"
|
|
76
|
+
VOLUME_UUID = "8bc01404-0004-4bf4-95d1-ce27a0477183"
|
|
77
|
+
DEVICE_NAME = "RGBify Projector"
|
|
78
|
+
RECONNECT_DELAY = 2.0
|
|
79
|
+
SCAN_TIMEOUT = 5.0
|
|
80
|
+
# The projector advertises as soon as it powers up, but needs a moment to
|
|
81
|
+
# finish booting before it can accept a BLE connection. On a first connect
|
|
82
|
+
# after power-up a failed attempt just retries (RECONNECT_DELAY), so keep this
|
|
83
|
+
# small instead of adding a fixed multi-second delay on every reconnect.
|
|
84
|
+
CONNECT_DELAY = 1.0
|
|
85
|
+
|
|
86
|
+
# Max time a single write ACK may take. The firmware holds the ACK until a
|
|
87
|
+
# whole message plays (~8 chars * 33ms = ~264ms), so this must be comfortably
|
|
88
|
+
# above that but still short enough that a wedged link is torn down quickly.
|
|
89
|
+
WRITE_TIMEOUT = 3.0
|
|
90
|
+
|
|
91
|
+
# Fallback VOLUME poll interval (seconds). Notifications from the device are
|
|
92
|
+
# primary, but the poll guarantees the host mirrors webapp volume changes even
|
|
93
|
+
# if a notification is lost.
|
|
94
|
+
VOL_POLL_SEC = 2.0
|
|
95
|
+
|
|
96
|
+
# Host auralizer: mirrors the firmware Auralizer (one note per frame @ 30fps,
|
|
97
|
+
# freq = -1021 + c*37 Hz for non-space chars, whitespace = rest, volume 0-10).
|
|
98
|
+
SAMPLE_RATE = 44100
|
|
99
|
+
NOTE_SEC = 1.0 / 30
|
|
100
|
+
# Adaptive host pacing: the firmware's REAL per-char time is slower than its
|
|
101
|
+
# nominal 30fps under load (measured ACKs of ~43ms/char vs 33ms nominal), so a
|
|
102
|
+
# fixed 33ms host note runs out before the next ACK-gated dispatch — a small
|
|
103
|
+
# periodic silence, only present while plugged in. The BLE loop measures
|
|
104
|
+
# ack_ms/char after every write and EMA-smooths it into this variable;
|
|
105
|
+
# host_auralize synthesizes at that pace so host buffers last exactly as long
|
|
106
|
+
# as the ACK window. Clamped to sane bounds; falls back to nominal when cold.
|
|
107
|
+
NOTE_SEC_MIN = 0.020
|
|
108
|
+
NOTE_SEC_MAX = 0.080
|
|
109
|
+
ACK_PACE_EMA = 0.3
|
|
110
|
+
# Host note amplitude as a fraction of full-scale int16. The firmware drives a
|
|
111
|
+
# piezo at resonance (loud); the host speaker at 0.05 was nearly inaudible, at
|
|
112
|
+
# 0.4 it masked the piezo — 0.3 rebalances the mix.
|
|
113
|
+
HOST_GAIN = 0.3
|
|
114
|
+
HOST_AURALIZER = os.environ.get("RGBIFY_HOST_AURALIZER", "1").strip() != "0"
|
|
115
|
+
HOST_VOLUME = int(os.environ.get("RGBIFY_VOLUME", "10").strip() or "10")
|
|
116
|
+
# Persisted host volume: mirrored from the projector's VOLUME characteristic
|
|
117
|
+
# when connected; editable at any time even when the projector is off. Lives in
|
|
118
|
+
# the global opencode config dir (the user may not be inside a project), with
|
|
119
|
+
# RGBIFY_STATE_DIR as an explicit override.
|
|
120
|
+
STATE_DIR = os.environ.get(
|
|
121
|
+
"RGBIFY_STATE_DIR",
|
|
122
|
+
os.path.join(os.path.expanduser("~"), ".config", "opencode", "state"),
|
|
123
|
+
)
|
|
124
|
+
VOLUME_FILE = os.path.join(STATE_DIR, "host-volume")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def load_volume() -> int:
|
|
128
|
+
try:
|
|
129
|
+
with open(VOLUME_FILE) as f:
|
|
130
|
+
return max(0, min(10, int(f.read().strip())))
|
|
131
|
+
except (OSError, ValueError):
|
|
132
|
+
return HOST_VOLUME
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def save_volume(value: int) -> None:
|
|
136
|
+
try:
|
|
137
|
+
os.makedirs(STATE_DIR, exist_ok=True)
|
|
138
|
+
with open(VOLUME_FILE, "w") as f:
|
|
139
|
+
f.write(str(max(0, min(10, value))))
|
|
140
|
+
except OSError:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# Mirror the firmware's log-scale auralizer lookup table exactly (firmware
|
|
145
|
+
# rgbify-projector-esp32.ino `auralizer_freq[91]`, committed 691c899):
|
|
146
|
+
# index = ord(c) - 32 for ASCII 32..122, mapping ' '=space..'z' -> 5000..100 Hz.
|
|
147
|
+
AURALIZER_FREQ = [
|
|
148
|
+
5000, 4887, 4778, 4672, 4569, 4468, 4371, 4276,
|
|
149
|
+
4183, 4093, 4004, 3918, 3834, 3752, 3671, 3593,
|
|
150
|
+
3515, 3440, 3366, 3293, 3222, 3153, 3084, 3017,
|
|
151
|
+
2951, 2886, 2823, 2760, 2698, 2638, 2578, 2520,
|
|
152
|
+
2462, 2405, 2349, 2294, 2240, 2187, 2134, 2082,
|
|
153
|
+
2031, 1980, 1931, 1881, 1833, 1785, 1738, 1691,
|
|
154
|
+
1645, 1600, 1555, 1510, 1466, 1423, 1380, 1338,
|
|
155
|
+
1296, 1255, 1214, 1173, 1133, 1094, 1055, 1016,
|
|
156
|
+
978, 940, 902, 865, 828, 792, 756, 720,
|
|
157
|
+
684, 649, 615, 580, 546, 513, 479, 446,
|
|
158
|
+
413, 381, 348, 316, 285, 253, 222, 191,
|
|
159
|
+
161, 130, 100,
|
|
160
|
+
]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# TRUE PIEZO SIMULATION: the projector's sound chain is toneAC driving the
|
|
164
|
+
# bare piezo disc with a SQUARE wave, and the disc's mechanical response has
|
|
165
|
+
# a dominant resonance ring at ~1897 Hz (firmware RESONANT_FREQ), an upper
|
|
166
|
+
# efficiency mode around 3-5 kHz (why the real device's high notes scream),
|
|
167
|
+
# and poor LF radiation below ~1 kHz. Model:
|
|
168
|
+
# square wave @ note freq ──► 12 dB/oct HPF @ PIEZO_HPF_HZ
|
|
169
|
+
# ──► peaking ring @ PIEZO_RING_HZ (Q, +dB)
|
|
170
|
+
# ──► peaking HF mode @ PIEZO_HF_HZ (Q, +dB)
|
|
171
|
+
# Constants are physics-derived starting points — tune by ear via prompts.
|
|
172
|
+
PIEZO_RING_HZ = 1897.0
|
|
173
|
+
PIEZO_RING_Q = 3.0
|
|
174
|
+
PIEZO_RING_GAIN_DB = 10.0
|
|
175
|
+
PIEZO_HPF_HZ = 800.0
|
|
176
|
+
PIEZO_HF_HZ = 3800.0
|
|
177
|
+
PIEZO_HF_Q = 1.2
|
|
178
|
+
PIEZO_HF_GAIN_DB = 8.0
|
|
179
|
+
|
|
180
|
+
_wave_cache = {}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _poly_blep(t: float, dt: float) -> float:
|
|
184
|
+
"""PolyBLEP correction for a naive bandlimited-violating square edge at
|
|
185
|
+
phase t in [0,1) with increment dt per sample."""
|
|
186
|
+
y = 0.0
|
|
187
|
+
if t < dt:
|
|
188
|
+
tt = t / dt
|
|
189
|
+
y -= tt + tt - tt * tt - 1.0
|
|
190
|
+
elif t > 1.0 - dt:
|
|
191
|
+
tt = (t - 1.0) / dt
|
|
192
|
+
y += tt * tt + tt + tt + 1.0
|
|
193
|
+
return y
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _square_wave(freq: float, note_sec: float, amp: int) -> "array.array":
|
|
197
|
+
"""Naive square wave with polyBLEP-corrected edges, ±amp int16."""
|
|
198
|
+
n = int(SAMPLE_RATE * note_sec)
|
|
199
|
+
dt = freq / SAMPLE_RATE
|
|
200
|
+
out = array.array("h")
|
|
201
|
+
p = 0.0
|
|
202
|
+
for _ in range(n):
|
|
203
|
+
naive = 1.0 if p < 0.5 else -1.0
|
|
204
|
+
v = (naive - _poly_blep(p, dt)) * amp
|
|
205
|
+
if v > 32767:
|
|
206
|
+
v = 32767
|
|
207
|
+
elif v < -32768:
|
|
208
|
+
v = -32768
|
|
209
|
+
out.append(int(v))
|
|
210
|
+
p += dt
|
|
211
|
+
if p >= 1.0:
|
|
212
|
+
p -= 1.0
|
|
213
|
+
return out
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _peaking_coeffs(f: float, q: float, gain_db: float):
|
|
217
|
+
A = 10 ** (gain_db / 40.0)
|
|
218
|
+
w0 = 2.0 * math.pi * f / SAMPLE_RATE
|
|
219
|
+
cw = math.cos(w0)
|
|
220
|
+
alpha = math.sin(w0) / (2.0 * q)
|
|
221
|
+
b0 = 1 + alpha * A
|
|
222
|
+
b1 = -2 * cw
|
|
223
|
+
b2 = 1 - alpha * A
|
|
224
|
+
a0 = 1 + alpha / A
|
|
225
|
+
a1 = -2 * cw
|
|
226
|
+
a2 = 1 - alpha / A
|
|
227
|
+
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _highpass_coeffs(f: float, q: float = 0.707):
|
|
231
|
+
w0 = 2.0 * math.pi * f / SAMPLE_RATE
|
|
232
|
+
cw = math.cos(w0)
|
|
233
|
+
alpha = math.sin(w0) / (2.0 * q)
|
|
234
|
+
b0 = (1 + cw) / 2
|
|
235
|
+
b1 = -(1 + cw)
|
|
236
|
+
b2 = (1 + cw) / 2
|
|
237
|
+
a0 = 1 + alpha
|
|
238
|
+
a1 = -2 * cw
|
|
239
|
+
a2 = 1 - alpha
|
|
240
|
+
return (b0 / a0, b1 / a0, b2 / a0, a1 / a0, a2 / a0)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _biquad(buf, coeffs) -> list:
|
|
244
|
+
"""Direct-form-I biquad over a numeric buffer. Returns FLOATS — no
|
|
245
|
+
clamping here; intermediate stages must not clip (the ring boost exceeds
|
|
246
|
+
int16 by design). Final stage converts + clamps."""
|
|
247
|
+
b0, b1, b2, a1, a2 = coeffs
|
|
248
|
+
x1 = x2 = y1 = y2 = 0.0
|
|
249
|
+
out = []
|
|
250
|
+
append = out.append
|
|
251
|
+
for x in buf:
|
|
252
|
+
y = b0 * x + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
|
253
|
+
x2 = x1
|
|
254
|
+
x1 = x
|
|
255
|
+
y2 = y1
|
|
256
|
+
y1 = y
|
|
257
|
+
append(y)
|
|
258
|
+
return out
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
_RING_COEFFS = _peaking_coeffs(PIEZO_RING_HZ, PIEZO_RING_Q, PIEZO_RING_GAIN_DB)
|
|
262
|
+
_HF_COEFFS = _peaking_coeffs(PIEZO_HF_HZ, PIEZO_HF_Q, PIEZO_HF_GAIN_DB)
|
|
263
|
+
_HPF_COEFFS = _highpass_coeffs(PIEZO_HPF_HZ)
|
|
264
|
+
_calib_scale = None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _calibration_scale() -> float:
|
|
268
|
+
"""Global level calibration: a square AT the ring frequency, through the
|
|
269
|
+
full piezo chain, defines the calibrated peak (HOST_GAIN × full scale).
|
|
270
|
+
All other notes keep their authentic relative levels around that anchor."""
|
|
271
|
+
global _calib_scale
|
|
272
|
+
if _calib_scale is None:
|
|
273
|
+
ref = _biquad(_biquad(_square_wave(PIEZO_RING_HZ, NOTE_SEC, 32767), _HPF_COEFFS), _RING_COEFFS)
|
|
274
|
+
peak = max(abs(v) for v in ref)
|
|
275
|
+
target = 32767 * HOST_GAIN
|
|
276
|
+
_calib_scale = target / max(1, peak)
|
|
277
|
+
return _calib_scale
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def synth_message(text: str, volume: int, note_sec: float = NOTE_SEC) -> "array.array":
|
|
281
|
+
"""Render one message as continuous PCM: cached polyBLEP squares per char,
|
|
282
|
+
chained and run through the simulated piezo chain (LF rolloff + resonance
|
|
283
|
+
ring). The device's own sample clock paces playback; adaptive `note_sec`
|
|
284
|
+
matches the firmware's measured cadence."""
|
|
285
|
+
amp = int(32767 * max(0, min(10, volume)) / 10.0)
|
|
286
|
+
buf = array.array("h")
|
|
287
|
+
n_samples = int(SAMPLE_RATE * note_sec)
|
|
288
|
+
rest = array.array("h", [0]) * n_samples
|
|
289
|
+
for ch in text:
|
|
290
|
+
c = ord(ch)
|
|
291
|
+
if ch in " \t\n\r" or not (32 <= c <= 122):
|
|
292
|
+
buf.extend(rest)
|
|
293
|
+
continue
|
|
294
|
+
key = (ch, volume, round(note_sec, 3))
|
|
295
|
+
sq = _wave_cache.get(key)
|
|
296
|
+
if sq is None:
|
|
297
|
+
sq = _square_wave(AURALIZER_FREQ[c - 32], note_sec, amp)
|
|
298
|
+
_wave_cache[key] = sq
|
|
299
|
+
if len(_wave_cache) > 512:
|
|
300
|
+
_wave_cache.clear()
|
|
301
|
+
buf.extend(sq)
|
|
302
|
+
if not buf:
|
|
303
|
+
return buf
|
|
304
|
+
# Float chain: HPF → ring → HF mode (unclamped — the boosts legitimately
|
|
305
|
+
# exceed int16; calibration scales it back). Then global scale, tail fade,
|
|
306
|
+
# clamp.
|
|
307
|
+
buf = _biquad(_biquad(_biquad(buf, _HPF_COEFFS), _RING_COEFFS), _HF_COEFFS)
|
|
308
|
+
scale = _calibration_scale()
|
|
309
|
+
fade = min(len(buf), int(SAMPLE_RATE * 0.005))
|
|
310
|
+
out = array.array("h", bytes(2 * len(buf)))
|
|
311
|
+
k = len(buf) - fade
|
|
312
|
+
for j in range(len(buf)):
|
|
313
|
+
v = buf[j] * scale
|
|
314
|
+
if j >= k:
|
|
315
|
+
v *= (j - k + 1) / fade
|
|
316
|
+
if v > 32767:
|
|
317
|
+
v = 32767
|
|
318
|
+
elif v < -32768:
|
|
319
|
+
v = -32768
|
|
320
|
+
out[j] = int(v)
|
|
321
|
+
return out
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class HostAuralizer:
|
|
325
|
+
"""Always-on host audio sink. Plays one ~33ms note at a time; a newer note
|
|
326
|
+
interrupts (replaces) the previous one, so the last message is the only
|
|
327
|
+
message on the host too."""
|
|
328
|
+
|
|
329
|
+
def __init__(self) -> None:
|
|
330
|
+
self._device = None
|
|
331
|
+
self._lock = threading.Lock()
|
|
332
|
+
self._note = None # array.array int16 mono of the current note (latest wins)
|
|
333
|
+
self._pos = 0 # frames consumed from _note
|
|
334
|
+
self._stop = False
|
|
335
|
+
|
|
336
|
+
def start(self) -> None:
|
|
337
|
+
if not (MINIAUDIO_OK and HOST_AURALIZER):
|
|
338
|
+
return
|
|
339
|
+
try:
|
|
340
|
+
self._device = miniaudio.PlaybackDevice(
|
|
341
|
+
output_format=miniaudio.SampleFormat.SIGNED16,
|
|
342
|
+
nchannels=1,
|
|
343
|
+
sample_rate=SAMPLE_RATE,
|
|
344
|
+
# Small device buffer: play_note's latest-wins swap only takes
|
|
345
|
+
# effect at the next buffer boundary, and the 200ms default
|
|
346
|
+
# made the host audibly lag the projector by up to 200ms. 20ms
|
|
347
|
+
# is under the firmware's own 33ms frame granularity.
|
|
348
|
+
buffersize_msec=20,
|
|
349
|
+
)
|
|
350
|
+
# PRIME the generator: miniaudio's data callback does
|
|
351
|
+
# `generator.send(framecount)`, and Python forbids a non-None
|
|
352
|
+
# send() on a just-started generator — every callback would raise
|
|
353
|
+
# TypeError and produce silence. One initial next() runs it to the
|
|
354
|
+
# first yield, after which send() is legal.
|
|
355
|
+
gen = self._generator()
|
|
356
|
+
next(gen)
|
|
357
|
+
self._device.start(gen)
|
|
358
|
+
except Exception as e:
|
|
359
|
+
self._device = None
|
|
360
|
+
print(f"err host auralizer unavailable: {e}", flush=True)
|
|
361
|
+
return
|
|
362
|
+
print("ok host auralizer", flush=True)
|
|
363
|
+
|
|
364
|
+
def _generator(self):
|
|
365
|
+
# miniaudio pull model: each yield returns the number of frames the
|
|
366
|
+
# device wants next. Serve the current note, then silence.
|
|
367
|
+
required = yield b""
|
|
368
|
+
while not self._stop:
|
|
369
|
+
with self._lock:
|
|
370
|
+
note = self._note
|
|
371
|
+
pos = self._pos
|
|
372
|
+
if note is None or pos >= len(note):
|
|
373
|
+
data = array.array("h", [0]) * required # rest
|
|
374
|
+
else:
|
|
375
|
+
take = min(len(note) - pos, required)
|
|
376
|
+
data = note[pos : pos + take]
|
|
377
|
+
with self._lock:
|
|
378
|
+
self._pos = pos + take
|
|
379
|
+
if take < required:
|
|
380
|
+
data = data + array.array("h", [0]) * (required - take)
|
|
381
|
+
with self._lock:
|
|
382
|
+
self._note = None
|
|
383
|
+
required = yield data.tobytes()
|
|
384
|
+
|
|
385
|
+
def play_note(self, note: "array.array") -> None:
|
|
386
|
+
# Latest wins: a note pushed while the previous one is playing replaces
|
|
387
|
+
# it at the next device buffer boundary.
|
|
388
|
+
with self._lock:
|
|
389
|
+
self._note = note
|
|
390
|
+
self._pos = 0
|
|
391
|
+
|
|
392
|
+
# Mirror the firmware's volume-change chirp: a short ~1970 Hz beep at the
|
|
393
|
+
# current volume, so the host confirms volume changes like the projector.
|
|
394
|
+
CHIRP_HZ = 1970
|
|
395
|
+
CHIRP_SEC = 0.030
|
|
396
|
+
|
|
397
|
+
def chirp(self) -> None:
|
|
398
|
+
if self._device is None:
|
|
399
|
+
return
|
|
400
|
+
n = int(SAMPLE_RATE * self.CHIRP_SEC)
|
|
401
|
+
peak = (max(0, min(10, load_volume())) / 10.0) * 32767 * HOST_GAIN
|
|
402
|
+
step = 2.0 * math.pi * self.CHIRP_HZ / SAMPLE_RATE
|
|
403
|
+
note = array.array("h", (int(math.sin(step * i) * peak) for i in range(n)))
|
|
404
|
+
self.play_note(note)
|
|
405
|
+
|
|
406
|
+
def stop(self) -> None:
|
|
407
|
+
self._stop = True
|
|
408
|
+
if self._device is not None:
|
|
409
|
+
try:
|
|
410
|
+
self._device.stop()
|
|
411
|
+
self._device.close()
|
|
412
|
+
except Exception:
|
|
413
|
+
pass
|
|
414
|
+
self._device = None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def discover_address(override: str):
|
|
418
|
+
if override:
|
|
419
|
+
return override
|
|
420
|
+
devices = await BleakScanner.discover(timeout=SCAN_TIMEOUT, return_adv=True)
|
|
421
|
+
for addr, (dev, adv) in devices.items():
|
|
422
|
+
if adv and SERVICE_UUID in {u.lower() for u in adv.service_uuids}:
|
|
423
|
+
return addr
|
|
424
|
+
if dev.name == DEVICE_NAME:
|
|
425
|
+
return addr
|
|
426
|
+
return None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
IDLE_CHECK_MS = 5.0
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def arm_parent_death_signal(parent_pid: int) -> None:
|
|
433
|
+
"""Linux: have the kernel SIGTERM us the instant our parent (opencode) dies,
|
|
434
|
+
no matter how (SIGKILL, destroyed terminal, crash). prctl is per-thread and
|
|
435
|
+
racy — the parent can die before we arm it — so set it on the main thread
|
|
436
|
+
and bail out immediately if the parent is already gone (the ppid changed).
|
|
437
|
+
On non-Linux or if prctl is unavailable, silently fall back to the ppid
|
|
438
|
+
watchdog in watch_parent()."""
|
|
439
|
+
if not sys.platform.startswith("linux"):
|
|
440
|
+
return
|
|
441
|
+
try:
|
|
442
|
+
import ctypes
|
|
443
|
+
|
|
444
|
+
ctypes.CDLL(None, use_errno=True).prctl(1, signal.SIGTERM) # PR_SET_PDEATHSIG
|
|
445
|
+
except Exception:
|
|
446
|
+
return
|
|
447
|
+
if os.getppid() != parent_pid:
|
|
448
|
+
os._exit(0)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
async def wait_line_or_stop(
|
|
452
|
+
q: "asyncio.Queue", stop_event: "asyncio.Event", timeout: float
|
|
453
|
+
):
|
|
454
|
+
"""Race the BLE line queue against the stop event. Returns
|
|
455
|
+
("line", text) when a line arrives, ("stop", None) the instant a stop is
|
|
456
|
+
requested (no waiting for the timeout), or ("idle", None) after `timeout`
|
|
457
|
+
with no line and no stop. Without this, a SIGTERM during the idle wait was
|
|
458
|
+
only noticed after IDLE_CHECK_MS (5s) — the disconnect lagged."""
|
|
459
|
+
line_task = asyncio.ensure_future(q.get())
|
|
460
|
+
stop_task = asyncio.ensure_future(stop_event.wait())
|
|
461
|
+
done, pending = await asyncio.wait(
|
|
462
|
+
{line_task, stop_task}, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
|
|
463
|
+
)
|
|
464
|
+
for t in pending:
|
|
465
|
+
t.cancel()
|
|
466
|
+
if stop_task in done:
|
|
467
|
+
return ("stop", None)
|
|
468
|
+
if line_task in done:
|
|
469
|
+
return ("line", line_task.result())
|
|
470
|
+
return ("idle", None)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
async def main() -> None:
|
|
474
|
+
override = os.environ.get("RGBIFY_PROJECTOR_ADDR", "").strip()
|
|
475
|
+
arm_parent_death_signal(os.getppid())
|
|
476
|
+
loop = asyncio.get_running_loop()
|
|
477
|
+
reader = asyncio.StreamReader()
|
|
478
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
479
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
|
480
|
+
|
|
481
|
+
# Graceful shutdown on every death path: SIGTERM (plugin dispose,
|
|
482
|
+
# pdeathsig), SIGHUP (terminal destroyed), stdin EOF (parent's pipe gone),
|
|
483
|
+
# or the ppid watchdog. Each sets stop_event; the tasks unwind so the
|
|
484
|
+
# BleakClient exits its `async with` and disconnects cleanly first.
|
|
485
|
+
stop_event = asyncio.Event()
|
|
486
|
+
|
|
487
|
+
def request_stop() -> None:
|
|
488
|
+
stop_event.set()
|
|
489
|
+
|
|
490
|
+
for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP):
|
|
491
|
+
try:
|
|
492
|
+
loop.add_signal_handler(sig, request_stop)
|
|
493
|
+
except (NotImplementedError, RuntimeError):
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
lines: asyncio.Queue = asyncio.Queue()
|
|
497
|
+
# Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
|
|
498
|
+
# ACK time per char after every write and EMA-smooths it here;
|
|
499
|
+
# host_auralize synthesizes at this pace so host buffers last exactly as
|
|
500
|
+
# long as the ACK window — no periodic silence while plugged in.
|
|
501
|
+
ack_pace_sec = NOTE_SEC
|
|
502
|
+
# Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
|
|
503
|
+
# most recent line, so a newer line interrupts (replaces) the previous one.
|
|
504
|
+
host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
505
|
+
ble_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
506
|
+
|
|
507
|
+
# While disconnected, no lines reach either sink — nothing is queued or
|
|
508
|
+
# replayed. Delivery starts fresh with the first line after a connect.
|
|
509
|
+
connected = False
|
|
510
|
+
|
|
511
|
+
auralizer = HostAuralizer()
|
|
512
|
+
|
|
513
|
+
async def read_stdin() -> None:
|
|
514
|
+
while True:
|
|
515
|
+
raw = await reader.readline()
|
|
516
|
+
if not raw:
|
|
517
|
+
# stdin closed = the opencode plugin that spawned us is gone.
|
|
518
|
+
dbg("stdin EOF")
|
|
519
|
+
request_stop()
|
|
520
|
+
return
|
|
521
|
+
line = raw.decode("utf-8", "replace").rstrip("\n")
|
|
522
|
+
dbg(f"in len={len(line)}")
|
|
523
|
+
await lines.put(line)
|
|
524
|
+
|
|
525
|
+
def push_latest(q: asyncio.Queue, line: str) -> None:
|
|
526
|
+
try:
|
|
527
|
+
q.put_nowait(line)
|
|
528
|
+
except asyncio.QueueFull:
|
|
529
|
+
q.get_nowait()
|
|
530
|
+
q.put_nowait(line)
|
|
531
|
+
|
|
532
|
+
async def broadcast() -> None:
|
|
533
|
+
# Fan every line out as the LATEST line. While the projector is
|
|
534
|
+
# connected, feed only ble_line — the BLE loop hands every chunk it
|
|
535
|
+
# writes back to host_line, so the host auralizer plays EXACTLY the
|
|
536
|
+
# bytes the projector receives (char-perfect sync). While disconnected,
|
|
537
|
+
# raw lines go to host_line only, so the host keeps auralizing
|
|
538
|
+
# always-on and nothing accumulates for replay on reconnect.
|
|
539
|
+
while not stop_event.is_set():
|
|
540
|
+
line = await lines.get()
|
|
541
|
+
if connected:
|
|
542
|
+
push_latest(ble_line, line)
|
|
543
|
+
else:
|
|
544
|
+
push_latest(host_line, line)
|
|
545
|
+
|
|
546
|
+
async def host_auralize() -> None:
|
|
547
|
+
# Play the whole message as ONE continuous PCM buffer. The miniaudio
|
|
548
|
+
# device's own sample clock paces it — exactly one ~33ms note per char,
|
|
549
|
+
# matching the firmware's 30fps cadence with no drift. A newer message
|
|
550
|
+
# replaces the playing one at the next device buffer boundary (latest
|
|
551
|
+
# wins), so the host always plays the same notes as the projector.
|
|
552
|
+
auralizer.start()
|
|
553
|
+
try:
|
|
554
|
+
while not stop_event.is_set():
|
|
555
|
+
kind, line = await wait_line_or_stop(
|
|
556
|
+
host_line, stop_event, IDLE_CHECK_MS
|
|
557
|
+
)
|
|
558
|
+
if kind == "stop":
|
|
559
|
+
return
|
|
560
|
+
if kind == "idle":
|
|
561
|
+
continue
|
|
562
|
+
if not line:
|
|
563
|
+
continue
|
|
564
|
+
auralizer.play_note(
|
|
565
|
+
synth_message(line, load_volume(), ack_pace_sec)
|
|
566
|
+
)
|
|
567
|
+
finally:
|
|
568
|
+
auralizer.stop()
|
|
569
|
+
|
|
570
|
+
async def watch_parent() -> None:
|
|
571
|
+
# Robust orphan guard. stdin EOF normally exits us when the opencode
|
|
572
|
+
# plugin that spawned us dies, but if another child of opencode (e.g. an
|
|
573
|
+
# MCP server) inherited the pipe's write end, EOF never arrives. Detect
|
|
574
|
+
# the parent's death directly instead: when it dies we are reparented
|
|
575
|
+
# (ppid changes), so request a graceful stop then.
|
|
576
|
+
ppid = os.getppid()
|
|
577
|
+
while not stop_event.is_set():
|
|
578
|
+
await asyncio.sleep(1)
|
|
579
|
+
if os.getppid() != ppid:
|
|
580
|
+
request_stop()
|
|
581
|
+
return
|
|
582
|
+
|
|
583
|
+
asyncio.create_task(read_stdin())
|
|
584
|
+
asyncio.create_task(broadcast())
|
|
585
|
+
asyncio.create_task(watch_parent())
|
|
586
|
+
host_task = asyncio.create_task(host_auralize())
|
|
587
|
+
|
|
588
|
+
async def ble_loop() -> None:
|
|
589
|
+
nonlocal connected, ack_pace_sec
|
|
590
|
+
while not stop_event.is_set():
|
|
591
|
+
try:
|
|
592
|
+
addr = await discover_address(override)
|
|
593
|
+
except Exception as e:
|
|
594
|
+
print(f"err scan failed: {e}", flush=True)
|
|
595
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
596
|
+
continue
|
|
597
|
+
if addr is None:
|
|
598
|
+
print("err projector not found", flush=True)
|
|
599
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
600
|
+
continue
|
|
601
|
+
# The projector advertises immediately on power-up but isn't ready to
|
|
602
|
+
# accept a connection until it finishes booting. Give it a moment.
|
|
603
|
+
await asyncio.sleep(CONNECT_DELAY)
|
|
604
|
+
try:
|
|
605
|
+
# When the projector resets/reboots, drop any queued text so
|
|
606
|
+
# stale lines buffered before the disconnect are not delivered
|
|
607
|
+
# on reconnect.
|
|
608
|
+
def on_disconnect(_client) -> None:
|
|
609
|
+
nonlocal connected
|
|
610
|
+
connected = False
|
|
611
|
+
dbg("disconnected")
|
|
612
|
+
for q in (host_line, ble_line):
|
|
613
|
+
try:
|
|
614
|
+
q.get_nowait()
|
|
615
|
+
except asyncio.QueueEmpty:
|
|
616
|
+
pass
|
|
617
|
+
print("disconnect", flush=True)
|
|
618
|
+
# NOTE: no `bluetoothctl disconnect` here. On Linux all
|
|
619
|
+
# clients (bridge AND the RGBify website) share ONE BlueZ
|
|
620
|
+
# ACL link, so a device-wide disconnect ejected the
|
|
621
|
+
# website every time a bridge went away. Stale writes
|
|
622
|
+
# can't survive anymore anyway — flow-control ACK means
|
|
623
|
+
# every write is fully played before its response.
|
|
624
|
+
|
|
625
|
+
async with BleakClient(addr, disconnected_callback=on_disconnect) as client:
|
|
626
|
+
# Clear anything that slipped in before the flag flipped, so
|
|
627
|
+
# delivery starts fresh with the first line after connect.
|
|
628
|
+
for q in (host_line, ble_line):
|
|
629
|
+
try:
|
|
630
|
+
q.get_nowait()
|
|
631
|
+
except asyncio.QueueEmpty:
|
|
632
|
+
pass
|
|
633
|
+
connected = True
|
|
634
|
+
dbg(f"connected {addr}")
|
|
635
|
+
print(f"ok {addr}", flush=True)
|
|
636
|
+
# Volume sync: read once on connect, subscribe to change
|
|
637
|
+
# notifications, AND poll as a fallback — the notification
|
|
638
|
+
# path has proven unobservable under flow-control load, so
|
|
639
|
+
# a cheap periodic read guarantees the host mirrors device
|
|
640
|
+
# volume changes (e.g. from the RGBify website).
|
|
641
|
+
vol_state = {"last": None}
|
|
642
|
+
|
|
643
|
+
def apply_volume(v: int, src: str, chirp: bool) -> None:
|
|
644
|
+
if v == vol_state["last"]:
|
|
645
|
+
return
|
|
646
|
+
vol_state["last"] = v
|
|
647
|
+
dbg(f"vol {src} {v}")
|
|
648
|
+
save_volume(v)
|
|
649
|
+
if chirp:
|
|
650
|
+
auralizer.chirp()
|
|
651
|
+
|
|
652
|
+
try:
|
|
653
|
+
value = await client.read_gatt_char(VOLUME_UUID)
|
|
654
|
+
if value:
|
|
655
|
+
vol_state["last"] = value[0]
|
|
656
|
+
save_volume(value[0])
|
|
657
|
+
dbg(f"vol connect {value[0]}")
|
|
658
|
+
except Exception as e:
|
|
659
|
+
dbg(f"vol read err {e}")
|
|
660
|
+
|
|
661
|
+
def on_volume_changed(_handle, data: bytes) -> None:
|
|
662
|
+
if data:
|
|
663
|
+
dbg(f"vol notify {data[0]}")
|
|
664
|
+
apply_volume(data[0], "notify", True)
|
|
665
|
+
|
|
666
|
+
try:
|
|
667
|
+
await client.start_notify(VOLUME_UUID, on_volume_changed)
|
|
668
|
+
dbg("vol notify subscribed")
|
|
669
|
+
except Exception as e:
|
|
670
|
+
dbg(f"vol notify FAILED: {e}")
|
|
671
|
+
|
|
672
|
+
last_vol_check = 0.0
|
|
673
|
+
|
|
674
|
+
async def poll_volume() -> None:
|
|
675
|
+
# Fallback for lost notifications: rate-limited to one
|
|
676
|
+
# read per VOL_POLL_SEC; applies silently (the device
|
|
677
|
+
# already chirped at change time).
|
|
678
|
+
nonlocal last_vol_check
|
|
679
|
+
now = time.monotonic()
|
|
680
|
+
if now - last_vol_check < VOL_POLL_SEC:
|
|
681
|
+
return
|
|
682
|
+
last_vol_check = now
|
|
683
|
+
try:
|
|
684
|
+
value = await client.read_gatt_char(VOLUME_UUID)
|
|
685
|
+
if value:
|
|
686
|
+
apply_volume(value[0], "poll", False)
|
|
687
|
+
except Exception as e:
|
|
688
|
+
dbg(f"vol poll err {e}")
|
|
689
|
+
# WHOLE-MESSAGE DELIVERY, ACK-GATED: each line (the last
|
|
690
|
+
# 8 chars of a coalesced delta burst) is written in ONE
|
|
691
|
+
# write with response=True. The firmware holds the ACK
|
|
692
|
+
# until every char has been played, so the write resolves
|
|
693
|
+
# exactly when playback finishes — nothing can buffer in
|
|
694
|
+
# either BLE stack. Lines that arrive DURING playback are
|
|
695
|
+
# NOT discarded: they chain immediately after the ACK, so
|
|
696
|
+
# notes play back-to-back for as long as text keeps
|
|
697
|
+
# streaming.
|
|
698
|
+
while True:
|
|
699
|
+
kind, text = await wait_line_or_stop(
|
|
700
|
+
ble_line, stop_event, IDLE_CHECK_MS
|
|
701
|
+
)
|
|
702
|
+
if kind == "stop":
|
|
703
|
+
return
|
|
704
|
+
if kind == "idle":
|
|
705
|
+
if not client.is_connected:
|
|
706
|
+
raise ConnectionError(
|
|
707
|
+
"projector disconnected while idle"
|
|
708
|
+
)
|
|
709
|
+
await poll_volume()
|
|
710
|
+
continue
|
|
711
|
+
if not text:
|
|
712
|
+
continue
|
|
713
|
+
# SYNC: hand the message to the host auralizer NOW, so
|
|
714
|
+
# it starts playing the same notes at the same moment
|
|
715
|
+
# the projector does — NOT after the ACK (which would
|
|
716
|
+
# put the host one message behind).
|
|
717
|
+
push_latest(host_line, text)
|
|
718
|
+
dbg(f"host play len={len(text)}")
|
|
719
|
+
t_write = time.monotonic()
|
|
720
|
+
try:
|
|
721
|
+
await asyncio.wait_for(
|
|
722
|
+
client.write_gatt_char(
|
|
723
|
+
TEXT_BRIDGE_UUID, text.encode("utf-8"),
|
|
724
|
+
response=True,
|
|
725
|
+
),
|
|
726
|
+
timeout=WRITE_TIMEOUT,
|
|
727
|
+
)
|
|
728
|
+
except asyncio.TimeoutError:
|
|
729
|
+
# Firmware wedged / connection stalled: drop the
|
|
730
|
+
# link and reconnect rather than freeze both sinks.
|
|
731
|
+
dbg("write TIMEOUT")
|
|
732
|
+
raise ConnectionError("write ACK timed out")
|
|
733
|
+
except Exception as e:
|
|
734
|
+
# Transient/failed write: move on; the next delta
|
|
735
|
+
# starts fresh.
|
|
736
|
+
dbg(f"write err {e}")
|
|
737
|
+
continue
|
|
738
|
+
ack_ms = (time.monotonic() - t_write) * 1000.0
|
|
739
|
+
dbg(f"ack {ack_ms:.0f}ms")
|
|
740
|
+
# Adaptive host pacing: learn the firmware's REAL
|
|
741
|
+
# per-char playback time from this ACK so host buffers
|
|
742
|
+
# last exactly as long as the ACK window.
|
|
743
|
+
inst = min(NOTE_SEC_MAX, max(
|
|
744
|
+
NOTE_SEC_MIN, (ack_ms / 1000.0) / max(1, len(text))
|
|
745
|
+
))
|
|
746
|
+
ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
|
|
747
|
+
print("ok", flush=True)
|
|
748
|
+
# Fallback volume poll (rate-limited to VOL_POLL_SEC):
|
|
749
|
+
# catches webapp changes even if a notification is
|
|
750
|
+
# lost. Cheap monotonic-time check when under rate.
|
|
751
|
+
await poll_volume()
|
|
752
|
+
# NO flush here: anything that arrived during playback
|
|
753
|
+
# stays queued and plays immediately on the next loop
|
|
754
|
+
# iteration — notes chain back-to-back for as long as
|
|
755
|
+
# deltas keep coming ("keep playing until the next
|
|
756
|
+
# delta arrives"). The maxsize-1 queue bounds lag at
|
|
757
|
+
# ~one extra message; when deltas stop, the last one
|
|
758
|
+
# plays out and the auralizers go quiet.
|
|
759
|
+
except Exception as e:
|
|
760
|
+
connected = False
|
|
761
|
+
dbg(f"loop err {e}")
|
|
762
|
+
print(f"err {e}", flush=True)
|
|
763
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
764
|
+
|
|
765
|
+
# Hard fallback: once a stop is requested, the graceful path normally
|
|
766
|
+
# disconnects within a few seconds (the idle wait is up to IDLE_CHECK_MS),
|
|
767
|
+
# but never let the bridge linger as an orphan.
|
|
768
|
+
async def watchdog() -> None:
|
|
769
|
+
await stop_event.wait()
|
|
770
|
+
await asyncio.sleep(10)
|
|
771
|
+
os._exit(0)
|
|
772
|
+
|
|
773
|
+
asyncio.create_task(watchdog())
|
|
774
|
+
|
|
775
|
+
await asyncio.gather(host_task, ble_loop())
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
if __name__ == "__main__":
|
|
779
|
+
asyncio.run(main())
|