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.
@@ -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())