opencode-rgbify-plugin 0.1.4 → 0.1.5

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.

Potentially problematic release.


This version of opencode-rgbify-plugin might be problematic. Click here for more details.

package/README.md CHANGED
@@ -80,9 +80,21 @@ path still works.
80
80
  | `RGBIFY_STATE_DIR` | `~/.config/opencode/state` | Where `host-volume` is persisted |
81
81
  | `RGBIFY_DEBUG_LOG` | — | Append debug log path |
82
82
 
83
- Host volume is mirrored from the projector's `VOLUME` characteristic whenever
84
- it's connected and persisted to `host-volume` so it survives restarts. While
85
- the projector is off you can still adjust the host volume by editing that file.
83
+ Desktop (host) volume is controlled independently of the projector via the
84
+ embedded RGBify volume popup (below) and persisted to `host-volume` so it
85
+ survives restarts. It is **not** mirrored from the projector's `VOLUME`
86
+ characteristic.
87
+
88
+ ### Desktop volume via a slash command
89
+
90
+ Set the local desktop auralizer volume from opencode with `/rgbify volume <0-10>`
91
+ (e.g. `/rgbify volume 7`). No firmware change, no website.
92
+
93
+ - The plugin defines a `rgbify` command (in `opencode.json`'s `command` map) and
94
+ handles it in the `command.execute.before` hook: it writes `host-volume`, and
95
+ the running bridge's `watch_host_volume` task applies it live — even with the
96
+ projector off.
97
+ - `Ctrl+P` → "rgbify" also lists it like any other command.
86
98
 
87
99
  ## Development
88
100
 
@@ -1,25 +1,28 @@
1
1
  #!/usr/bin/env python3
2
2
  """BLE bridge: stream newline-delimited text from stdin to the RGBify projector.
3
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.
4
+ ACK-GATED DELIVERY: every line is written to the projector with response=True,
5
+ and the next line is NOT written until the previous one is ACKed. Lines that
6
+ arrive during the ACK window are SKIPPED (latest wins on a maxsize-1 queue), so
7
+ the last text is the only text nothing queues on the projector and nothing
8
+ plays out of order. The host auralizer plays EXACTLY the bytes the projector
9
+ receives (fed pre-write, same skips), at the measured ACK pace.
9
10
 
10
11
  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.
12
+ writes each line to the TEXT_BRIDGE characteristic and reconnects forever with
13
+ backoff so the plugin stays a silent no-op while the projector is out of range
14
+ or powered off.
15
+
16
+ The host auralizer (miniaudio) runs always and independently of the BLE
17
+ connection; when the projector is reachable both play the same line. Lines
18
+ missed while the projector is down are dropped for the projector (no replay on
19
+ reconnect) so the two stay in sync.
20
+
21
+ On a clean stop (parent death, stdin EOF, SIGTERM/SIGINT) the bridge exits
22
+ WITHOUT an explicit BLE disconnect: bluetoothd owns the shared ACL link, so the
23
+ projector stays connected and the shared-link RGBify website keeps its
24
+ connection. Only error recovery (wedged link) explicitly disconnects before
25
+ re-dialing.
23
26
 
24
27
  miniaudio bundles its own native audio lib, so the host auralizer needs no
25
28
  system deps (no PortAudio) and works cross-platform (WASAPI/CoreAudio/Pulse/ALSA).
@@ -27,10 +30,11 @@ system deps (no PortAudio) and works cross-platform (WASAPI/CoreAudio/Pulse/ALSA
27
30
  Set RGBIFY_PROJECTOR_ADDR to skip discovery and use a fixed address.
28
31
  Set RGBIFY_HOST_AURALIZER=0 to disable the host auralizer (projector unaffected).
29
32
 
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).
33
+ Host (desktop) volume is controlled independently of the projector: the opencode
34
+ TUI's RGBify volume popup writes the state file below, and the bridge watches it
35
+ and applies changes live (works even with the projector off). It is NOT mirrored
36
+ from the projector's VOLUME characteristic. Initial value comes from the file or
37
+ RGBIFY_VOLUME.
34
38
 
35
39
  stdout protocol (one line per event, for debugging):
36
40
  ok <addr> connected / a line delivered
@@ -73,7 +77,6 @@ except (ImportError, OSError):
73
77
 
74
78
  SERVICE_UUID = "8bc01404-0000-4bf4-95d1-ce27a0477183"
75
79
  TEXT_BRIDGE_UUID = "8bc01404-0009-4bf4-95d1-ce27a0477183"
76
- VOLUME_UUID = "8bc01404-0004-4bf4-95d1-ce27a0477183"
77
80
  DEVICE_NAME = "RGBify Projector"
78
81
  RECONNECT_DELAY = 2.0
79
82
  SCAN_TIMEOUT = 5.0
@@ -83,20 +86,28 @@ SCAN_TIMEOUT = 5.0
83
86
  # small instead of adding a fixed multi-second delay on every reconnect.
84
87
  CONNECT_DELAY = 1.0
85
88
 
86
- # Max time a single write may take. Writes are write-without-response
87
- # (fire-and-forget), so they normally resolve in milliseconds; this is only a
89
+ # Max time a single write ACK may take. The write is response=True (gated ACK):
90
+ # the firmware sends the ATT write response once the whole message has been
91
+ # received, so it normally resolves in tens of milliseconds; this is only a
88
92
  # guard against a wedged BlueZ DBus call so a stalled link is torn down quickly.
89
93
  WRITE_TIMEOUT = 3.0
90
94
 
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
95
  # 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).
96
+ # freq = auralizer_freq[clamp(ord(c)-32)] for EVERY char including whitespace
97
+ # whitespace is NOT a rest (a rest would click the piezo chain and gate the tone
98
+ # off mid-delta; it must play its ASCII slot continuously).
98
99
  SAMPLE_RATE = 44100
99
100
  NOTE_SEC = 1.0 / 30
101
+ # Adaptive host pacing: the firmware's REAL per-char time is slower than its
102
+ # nominal 30fps under load (measured ACKs of ~43ms/char vs 33ms nominal), so a
103
+ # fixed 33ms host note runs out before the next ACK-gated dispatch — a small
104
+ # periodic silence, only present while plugged in. The BLE loop measures
105
+ # ack_ms/char after every write and EMA-smooths it; the auralizer plays at that
106
+ # pace so host notes last exactly as long as the ACK window. Clamped to sane
107
+ # bounds; falls back to nominal when cold.
108
+ NOTE_SEC_MIN = 0.020
109
+ NOTE_SEC_MAX = 0.080
110
+ ACK_PACE_EMA = 0.3
100
111
  # Host note amplitude as a fraction of full-scale int16. The firmware drives a
101
112
  # piezo at resonance (loud); the host speaker at 0.05 was nearly inaudible, at
102
113
  # 0.4 it masked the piezo — 0.3 rebalances the mix.
@@ -150,6 +161,27 @@ AURALIZER_FREQ = [
150
161
  ]
151
162
 
152
163
 
164
+ # Whitespace is NOT a rest (a gap would click the piezo chain and gate the tone
165
+ # off mid-delta), and NOT the 5000Hz table slot (space/newline would scream).
166
+ # It plays a LOW note — clearly below the melody range, full-bodied, not the
167
+ # tinny high slots. (1970 was tried; still sounded bad. 150 = bass.)
168
+ WHITESPACE_FREQ = 150.0
169
+
170
+
171
+ def _char_freq(ch: str) -> float:
172
+ """Whitespace plays the resonant note (WHITESPACE_FREQ); every other char
173
+ maps to its ASCII slot in the log-scale table, clamped to [0,90]. No rest,
174
+ no high-pitch scream for spaces."""
175
+ if ch in " \t\n\r":
176
+ return WHITESPACE_FREQ
177
+ idx = ord(ch) - 32
178
+ if idx < 0:
179
+ idx = 0
180
+ elif idx > 90:
181
+ idx = 90
182
+ return AURALIZER_FREQ[idx]
183
+
184
+
153
185
  # TRUE PIEZO SIMULATION: the projector's sound chain is toneAC driving the
154
186
  # bare piezo disc with a SQUARE wave, and the disc's mechanical response has
155
187
  # a dominant resonance ring at ~1897 Hz (firmware RESONANT_FREQ), an upper
@@ -167,8 +199,6 @@ PIEZO_HF_HZ = 3800.0
167
199
  PIEZO_HF_Q = 1.2
168
200
  PIEZO_HF_GAIN_DB = 8.0
169
201
 
170
- _wave_cache = {}
171
-
172
202
 
173
203
  def _poly_blep(t: float, dt: float) -> float:
174
204
  """PolyBLEP correction for a naive bandlimited-violating square edge at
@@ -267,60 +297,30 @@ def _calibration_scale() -> float:
267
297
  return _calib_scale
268
298
 
269
299
 
270
- def synth_message(text: str, volume: int, note_sec: float = NOTE_SEC) -> "array.array":
271
- """Render one message as continuous PCM: cached polyBLEP squares per char,
272
- chained and run through the simulated piezo chain (LF rolloff + resonance
273
- ring). The device's own sample clock paces playback; adaptive `note_sec`
274
- matches the firmware's measured cadence."""
275
- amp = int(32767 * max(0, min(10, volume)) / 10.0)
276
- buf = array.array("h")
277
- n_samples = int(SAMPLE_RATE * note_sec)
278
- rest = array.array("h", [0]) * n_samples
279
- for ch in text:
280
- c = ord(ch)
281
- if ch in " \t\n\r" or not (32 <= c <= 122):
282
- buf.extend(rest)
283
- continue
284
- key = (ch, volume, round(note_sec, 3))
285
- sq = _wave_cache.get(key)
286
- if sq is None:
287
- sq = _square_wave(AURALIZER_FREQ[c - 32], note_sec, amp)
288
- _wave_cache[key] = sq
289
- if len(_wave_cache) > 512:
290
- _wave_cache.clear()
291
- buf.extend(sq)
292
- if not buf:
293
- return buf
294
- # Float chain: HPF → ring → HF mode (unclamped — the boosts legitimately
295
- # exceed int16; calibration scales it back). Then global scale, tail fade,
296
- # clamp.
297
- buf = _biquad(_biquad(_biquad(buf, _HPF_COEFFS), _RING_COEFFS), _HF_COEFFS)
298
- scale = _calibration_scale()
299
- fade = min(len(buf), int(SAMPLE_RATE * 0.005))
300
- out = array.array("h", bytes(2 * len(buf)))
301
- k = len(buf) - fade
302
- for j in range(len(buf)):
303
- v = buf[j] * scale
304
- if j >= k:
305
- v *= (j - k + 1) / fade
306
- if v > 32767:
307
- v = 32767
308
- elif v < -32768:
309
- v = -32768
310
- out[j] = int(v)
311
- return out
312
-
313
-
314
300
  class HostAuralizer:
315
- """Always-on host audio sink. Plays one ~33ms note at a time; a newer note
316
- interrupts (replaces) the previous one, so the last message is the only
317
- message on the host too."""
301
+ """Always-on host audio sink a REAL-TIME FREQUENCY FOLLOWER.
302
+
303
+ The miniaudio device thread continuously synthesizes a square wave at the
304
+ current frequency (straight-through phase accumulator + persistent piezo
305
+ biquad chain). The bridge loop never does synthesis, never sleeps, never
306
+ touches the volume file per message: it only hands over a list of note
307
+ frequencies (whitespace INCLUDED — every char plays its ASCII table slot,
308
+ no rests, no fades) and the audio clock drives one note per NOTE_SEC.
309
+
310
+ The tone changes frequency per character, plays continuously through the
311
+ whole line, and only goes silent once the line's notes are exhausted (the
312
+ end of a delta). A new line replaces the pending notes immediately (latest
313
+ wins — matching the projector's interrupt semantics). Note duration is
314
+ adaptive: set_pace() feeds the measured ACK ms/char so host notes last
315
+ exactly as long as the ACK window (no gap while plugged in)."""
318
316
 
319
317
  def __init__(self) -> None:
320
318
  self._device = None
321
319
  self._lock = threading.Lock()
322
- self._note = None # array.array int16 mono of the current note (latest wins)
323
- self._pos = 0 # frames consumed from _note
320
+ self._seq = None # list[float] Hz per char for the current line, or None
321
+ self._off = True # True when no tone should sound
322
+ self._note_sec = NOTE_SEC # per-char playback duration (adaptive)
323
+ self._amp = int(32767 * max(0, min(10, load_volume())) / 10.0)
324
324
  self._stop = False
325
325
 
326
326
  def start(self) -> None:
@@ -331,10 +331,6 @@ class HostAuralizer:
331
331
  output_format=miniaudio.SampleFormat.SIGNED16,
332
332
  nchannels=1,
333
333
  sample_rate=SAMPLE_RATE,
334
- # Small device buffer: play_note's latest-wins swap only takes
335
- # effect at the next buffer boundary, and the 200ms default
336
- # made the host audibly lag the projector by up to 200ms. 20ms
337
- # is under the firmware's own 33ms frame granularity.
338
334
  buffersize_msec=20,
339
335
  )
340
336
  # PRIME the generator: miniaudio's data callback does
@@ -352,46 +348,100 @@ class HostAuralizer:
352
348
  print("ok host auralizer", flush=True)
353
349
 
354
350
  def _generator(self):
355
- # miniaudio pull model: each yield returns the number of frames the
356
- # device wants next. Serve the current note, then silence.
351
+ # Straight-through async synthesis on the audio thread. Per-sample:
352
+ # polyBLEP square @ current freq -> HPF -> ring -> HF mode, all with
353
+ # PERSISTENT filter state (a continuous tone, no clicks, no fades). The
354
+ # audio clock advances one note every NOTE_SEC; when the line's notes
355
+ # are exhausted it goes silent (end of delta). A new seq object swaps in
356
+ # at the next callback (latest wins), interrupting whatever was playing
357
+ # exactly like the firmware's text_bridge_pos=0 reset.
357
358
  required = yield b""
359
+ phase = 0.0
360
+ x1a = x2a = y1a = y2a = 0.0 # HPF state
361
+ x1b = x2b = y1b = y2b = 0.0 # ring state
362
+ x1c = x2c = y1c = y2c = 0.0 # HF state
363
+ seq = None
364
+ pos = 0
365
+ left = 0
366
+ scale = _calibration_scale()
358
367
  while not self._stop:
359
368
  with self._lock:
360
- note = self._note
361
- pos = self._pos
362
- if note is None or pos >= len(note):
363
- data = array.array("h", [0]) * required # rest
364
- else:
365
- take = min(len(note) - pos, required)
366
- data = note[pos : pos + take]
367
- with self._lock:
368
- self._pos = pos + take
369
- if take < required:
370
- data = data + array.array("h", [0]) * (required - take)
371
- with self._lock:
372
- self._note = None
373
- required = yield data.tobytes()
374
-
375
- def play_note(self, note: "array.array") -> None:
376
- # Latest wins: a note pushed while the previous one is playing replaces
377
- # it at the next device buffer boundary.
369
+ cur = self._seq
370
+ off = self._off
371
+ amp = self._amp
372
+ note_sec = self._note_sec
373
+ if cur is not seq:
374
+ seq = cur
375
+ pos = 0
376
+ left = int(SAMPLE_RATE * note_sec) if cur is not None else 0
377
+ out = array.array("h")
378
+ for _ in range(required):
379
+ if off or seq is None or pos >= len(seq):
380
+ out.append(0)
381
+ continue
382
+ freq = seq[pos]
383
+ dt = freq / SAMPLE_RATE
384
+ naive = 1.0 if phase < 0.5 else -1.0
385
+ x = (naive - _poly_blep(phase, dt)) * amp
386
+ y = (_HPF_COEFFS[0] * x + _HPF_COEFFS[1] * x1a
387
+ + _HPF_COEFFS[2] * x2a - _HPF_COEFFS[3] * y1a
388
+ - _HPF_COEFFS[4] * y2a)
389
+ x2a, x1a, y2a, y1a = x1a, x, y1a, y
390
+ x = y
391
+ y = (_RING_COEFFS[0] * x + _RING_COEFFS[1] * x1b
392
+ + _RING_COEFFS[2] * x2b - _RING_COEFFS[3] * y1b
393
+ - _RING_COEFFS[4] * y2b)
394
+ x2b, x1b, y2b, y1b = x1b, x, y1b, y
395
+ x = y
396
+ y = (_HF_COEFFS[0] * x + _HF_COEFFS[1] * x1c
397
+ + _HF_COEFFS[2] * x2c - _HF_COEFFS[3] * y1c
398
+ - _HF_COEFFS[4] * y2c)
399
+ x2c, x1c, y2c, y1c = x1c, x, y1c, y
400
+ v = y * scale
401
+ if v > 32767:
402
+ v = 32767
403
+ elif v < -32768:
404
+ v = -32768
405
+ out.append(int(v))
406
+ phase += dt
407
+ if phase >= 1.0:
408
+ phase -= 1.0
409
+ left -= 1
410
+ if left <= 0:
411
+ pos += 1
412
+ left = int(SAMPLE_RATE * note_sec)
413
+ required = yield out.tobytes()
414
+
415
+ def play_notes(self, freqs: list) -> None:
416
+ # Latest wins: a fresh note list replaces the pending one; the audio
417
+ # thread picks it up at the next buffer boundary. O(n) tiny, no I/O.
418
+ with self._lock:
419
+ self._seq = freqs
420
+ self._off = False
421
+
422
+ def note_off(self) -> None:
423
+ with self._lock:
424
+ self._off = True
425
+ self._seq = None
426
+
427
+ def set_volume(self, v: int) -> None:
428
+ with self._lock:
429
+ self._amp = int(32767 * max(0, min(10, v)) / 10.0)
430
+
431
+ def set_pace(self, note_sec: float) -> None:
432
+ # Adaptive host pacing: per-char note duration matches the measured ACK
433
+ # window so host notes last exactly as long as the projector's playback.
378
434
  with self._lock:
379
- self._note = note
380
- self._pos = 0
435
+ self._note_sec = note_sec
381
436
 
382
437
  # Mirror the firmware's volume-change chirp: a short ~1970 Hz beep at the
383
438
  # current volume, so the host confirms volume changes like the projector.
384
439
  CHIRP_HZ = 1970
385
- CHIRP_SEC = 0.030
386
440
 
387
441
  def chirp(self) -> None:
388
442
  if self._device is None:
389
443
  return
390
- n = int(SAMPLE_RATE * self.CHIRP_SEC)
391
- peak = (max(0, min(10, load_volume())) / 10.0) * 32767 * HOST_GAIN
392
- step = 2.0 * math.pi * self.CHIRP_HZ / SAMPLE_RATE
393
- note = array.array("h", (int(math.sin(step * i) * peak) for i in range(n)))
394
- self.play_note(note)
444
+ self.play_notes([self.CHIRP_HZ])
395
445
 
396
446
  def stop(self) -> None:
397
447
  self._stop = True
@@ -484,7 +534,10 @@ async def main() -> None:
484
534
  pass
485
535
 
486
536
  # Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
487
- # most recent line, so a newer line interrupts (replaces) the previous one.
537
+ # most recent line. ACK-GATED DELIVERY: while a message is in flight (awaiting
538
+ # its write ACK), newer lines replace the pending slot — intermediate texts
539
+ # are SKIPPED, only the newest survives and is written after the ACK. Nothing
540
+ # queues on the projector; the last text is the only text.
488
541
  host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
489
542
  ble_line: asyncio.Queue = asyncio.Queue(maxsize=1)
490
543
 
@@ -523,11 +576,11 @@ async def main() -> None:
523
576
  push_latest(host_line, line)
524
577
 
525
578
  async def host_auralize() -> None:
526
- # Play the whole message as ONE continuous PCM buffer. The miniaudio
527
- # device's own sample clock paces it exactly one ~33ms note per char,
528
- # matching the firmware's 30fps cadence with no drift. A newer message
529
- # replaces the playing one at the next device buffer boundary (latest
530
- # wins), so the host always plays the same notes as the projector.
579
+ # Real-time frequency follower: hand the audio thread one frequency per
580
+ # char (whitespace INCLUDED) and let the device clock drive playback.
581
+ # No synthesis, no sleep, no volume-file I/O on this loop — play_notes
582
+ # is O(n) and non-blocking, so the bridge loop stays free to read stdin
583
+ # and drive BLE (the loop never stalls, so nothing backs up to opencode).
531
584
  auralizer.start()
532
585
  try:
533
586
  while not stop_event.is_set():
@@ -540,9 +593,7 @@ async def main() -> None:
540
593
  continue
541
594
  if not line:
542
595
  continue
543
- auralizer.play_note(
544
- synth_message(line, load_volume())
545
- )
596
+ auralizer.play_notes([_char_freq(c) for c in line])
546
597
  finally:
547
598
  auralizer.stop()
548
599
 
@@ -559,12 +610,32 @@ async def main() -> None:
559
610
  request_stop()
560
611
  return
561
612
 
613
+ async def watch_host_volume() -> None:
614
+ # Desktop volume is controlled externally (the opencode TUI popup writes
615
+ # the host-volume file). Watch it so live changes apply even with the
616
+ # projector off — no BLE round-trip, no mirroring of the projector VOLUME.
617
+ last = load_volume()
618
+ auralizer.set_volume(last)
619
+ while not stop_event.is_set():
620
+ await asyncio.sleep(0.5)
621
+ cur = load_volume()
622
+ if cur != last:
623
+ last = cur
624
+ auralizer.set_volume(cur)
625
+ auralizer.chirp()
626
+ dbg(f"vol file {cur}")
627
+
562
628
  asyncio.create_task(read_stdin())
563
629
  asyncio.create_task(watch_parent())
630
+ asyncio.create_task(watch_host_volume())
564
631
  host_task = asyncio.create_task(host_auralize())
565
632
 
566
633
  async def ble_loop() -> None:
567
634
  nonlocal connected
635
+ # Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
636
+ # ACK time per char after every write and EMA-smooths it; the auralizer
637
+ # plays at this pace so host notes last exactly as long as the ACK window.
638
+ ack_pace_sec = NOTE_SEC
568
639
  while not stop_event.is_set():
569
640
  try:
570
641
  addr = await discover_address(override)
@@ -579,149 +650,138 @@ async def main() -> None:
579
650
  # The projector advertises immediately on power-up but isn't ready to
580
651
  # accept a connection until it finishes booting. Give it a moment.
581
652
  await asyncio.sleep(CONNECT_DELAY)
582
- try:
583
- # When the projector resets/reboots, drop any queued text so
584
- # stale lines buffered before the disconnect are not delivered
585
- # on reconnect.
586
- def on_disconnect(_client) -> None:
587
- nonlocal connected
588
- connected = False
589
- dbg("disconnected")
590
- for q in (host_line, ble_line):
591
- try:
592
- q.get_nowait()
593
- except asyncio.QueueEmpty:
594
- pass
595
- print("disconnect", flush=True)
596
- # NOTE: no `bluetoothctl disconnect` here. On Linux all
597
- # clients (bridge AND the RGBify website) share ONE BlueZ
598
- # ACL link, so a device-wide disconnect ejected the
599
- # website every time a bridge went away. Stale writes
600
- # can't survive anyway — the firmware interrupts on every
601
- # write, so there's nothing queued to replay.
602
-
603
- async with BleakClient(addr, disconnected_callback=on_disconnect) as client:
604
- # Clear anything that slipped in before the flag flipped, so
605
- # delivery starts fresh with the first line after connect.
606
- for q in (host_line, ble_line):
607
- try:
608
- q.get_nowait()
609
- except asyncio.QueueEmpty:
610
- pass
611
- connected = True
612
- dbg(f"connected {addr}")
613
- print(f"ok {addr}", flush=True)
614
- # Volume sync: read once on connect, subscribe to change
615
- # notifications, AND poll as a fallback — the notification
616
- # path has proven unobservable under flow-control load, so
617
- # a cheap periodic read guarantees the host mirrors device
618
- # volume changes (e.g. from the RGBify website).
619
- vol_state = {"last": None}
620
-
621
- def apply_volume(v: int, src: str, chirp: bool) -> None:
622
- if v == vol_state["last"]:
623
- return
624
- vol_state["last"] = v
625
- dbg(f"vol {src} {v}")
626
- save_volume(v)
627
- if chirp:
628
- auralizer.chirp()
629
653
 
654
+ # When the projector resets/reboots, drop any queued text so stale
655
+ # lines buffered before the disconnect are not delivered on reconnect.
656
+ def on_disconnect(_client) -> None:
657
+ nonlocal connected
658
+ connected = False
659
+ dbg("disconnected")
660
+ for q in (host_line, ble_line):
630
661
  try:
631
- value = await client.read_gatt_char(VOLUME_UUID)
632
- if value:
633
- vol_state["last"] = value[0]
634
- save_volume(value[0])
635
- dbg(f"vol connect {value[0]}")
636
- except Exception as e:
637
- dbg(f"vol read err {e}")
638
-
639
- def on_volume_changed(_handle, data: bytes) -> None:
640
- if data:
641
- dbg(f"vol notify {data[0]}")
642
- apply_volume(data[0], "notify", True)
662
+ q.get_nowait()
663
+ except asyncio.QueueEmpty:
664
+ pass
665
+ print("disconnect", flush=True)
666
+ # NOTE: no `bluetoothctl disconnect` here. On Linux all clients
667
+ # (bridge AND the RGBify website) share ONE BlueZ ACL link, so a
668
+ # device-wide disconnect ejected the website every time a bridge
669
+ # went away. Stale writes can't survive either — the firmware
670
+ # interrupts on every write.
671
+
672
+ # Explicit client, NOT `async with`: we control connect/disconnect.
673
+ # On a clean stop we exit WITHOUT client.disconnect() bluetoothd
674
+ # owns the BLE link, so an abrupt exit leaves the projector connected
675
+ # and the shared-link RGBify website keeps its connection. Only error
676
+ # recovery explicitly disconnects (a wedged link must be torn down
677
+ # before re-dialing).
678
+ client = BleakClient(addr, disconnected_callback=on_disconnect)
679
+ connected = False
680
+ try:
681
+ await client.connect()
682
+ except Exception as e:
683
+ # Failed connect: make sure nothing lingers, then re-dial.
684
+ try:
685
+ await client.disconnect()
686
+ except Exception:
687
+ pass
688
+ dbg(f"loop err {e}")
689
+ print(f"err {e}", flush=True)
690
+ await asyncio.sleep(RECONNECT_DELAY)
691
+ continue
643
692
 
693
+ try:
694
+ # Clear anything that slipped in before the flag flipped, so
695
+ # delivery starts fresh with the first line after connect.
696
+ for q in (host_line, ble_line):
644
697
  try:
645
- await client.start_notify(VOLUME_UUID, on_volume_changed)
646
- dbg("vol notify subscribed")
647
- except Exception as e:
648
- dbg(f"vol notify FAILED: {e}")
649
-
650
- last_vol_check = 0.0
651
-
652
- async def poll_volume() -> None:
653
- # Fallback for lost notifications: rate-limited to one
654
- # read per VOL_POLL_SEC; applies silently (the device
655
- # already chirped at change time).
656
- nonlocal last_vol_check
657
- now = time.monotonic()
658
- if now - last_vol_check < VOL_POLL_SEC:
659
- return
660
- last_vol_check = now
661
- try:
662
- value = await client.read_gatt_char(VOLUME_UUID)
663
- if value:
664
- apply_volume(value[0], "poll", False)
665
- except Exception as e:
666
- dbg(f"vol poll err {e}")
667
- # FIRE-AND-FORGET DELIVERY: each line (the last 8 chars of
668
- # a delta) is written with response=False. The firmware's
669
- # TEXT_BRIDGE onWrite resets its playback position on every
670
- # write, so the newest message interrupts the previous one
671
- # immediately — no ACK to wait for, no serialization, no
672
- # backlog on either side.
673
- while True:
674
- kind, text = await wait_line_or_stop(
675
- ble_line, stop_event, IDLE_CHECK_MS
676
- )
677
- if kind == "stop":
678
- return
679
- if kind == "idle":
680
- if not client.is_connected:
681
- raise ConnectionError(
682
- "projector disconnected while idle"
683
- )
684
- await poll_volume()
685
- continue
686
- if not text:
687
- continue
688
- # SYNC: hand the message to the host auralizer NOW, so
689
- # it starts the same notes at the same moment the
690
- # projector does.
691
- push_latest(host_line, text)
692
- dbg(f"host play len={len(text)}")
693
- try:
694
- await asyncio.wait_for(
695
- client.write_gatt_char(
696
- TEXT_BRIDGE_UUID, text.encode("utf-8"),
697
- response=False,
698
- ),
699
- timeout=WRITE_TIMEOUT,
698
+ q.get_nowait()
699
+ except asyncio.QueueEmpty:
700
+ pass
701
+ connected = True
702
+ dbg(f"connected {addr}")
703
+ print(f"ok {addr}", flush=True)
704
+ # Desktop volume is NOT mirrored from the projector. It is
705
+ # controlled externally (TUI popup -> host-volume file, watched
706
+ # by watch_host_volume below) and works with the projector off.
707
+ # No connect-time GATT read, so the first message is not gated
708
+ # behind a volume round-trip.
709
+ # WHOLE-MESSAGE DELIVERY, ACK-GATED: each line (the last 8 chars
710
+ # of a coalesced delta burst) is written in ONE write with
711
+ # response=True. The write resolves only once the firmware ACKs
712
+ # it, so the round-trip serializes delivery: the next line is NOT
713
+ # written until the previous is ACKed. Lines that arrive DURING
714
+ # the ACK window are SKIPPED (latest wins on the maxsize-1
715
+ # ble_line), so the last text is the only text and nothing queues
716
+ # on the projector.
717
+ while True:
718
+ kind, text = await wait_line_or_stop(
719
+ ble_line, stop_event, IDLE_CHECK_MS
720
+ )
721
+ if kind == "stop":
722
+ # Clean stop: leave the BLE link to bluetoothd — do NOT
723
+ # disconnect, so the shared-link website stays connected.
724
+ connected = False
725
+ return
726
+ if kind == "idle":
727
+ if not client.is_connected:
728
+ raise ConnectionError(
729
+ "projector disconnected while idle"
700
730
  )
701
- except asyncio.TimeoutError:
702
- # BlueZ wedged: drop the link and reconnect rather
703
- # than freeze both sinks.
704
- dbg("write TIMEOUT")
705
- raise ConnectionError("write timed out")
706
- except Exception as e:
707
- # Transient/failed write: move on; the next delta
708
- # starts fresh.
709
- dbg(f"write err {e}")
710
- continue
711
- print("ok", flush=True)
712
- # Fallback volume poll (rate-limited to VOL_POLL_SEC):
713
- # catches webapp changes even if a notification is
714
- # lost. Cheap monotonic-time check when under rate.
715
- await poll_volume()
731
+ continue
732
+ if not text:
733
+ continue
734
+ # SYNC: hand the message to the host auralizer NOW, so
735
+ # it starts the same notes at the same moment the
736
+ # projector does.
737
+ push_latest(host_line, text)
738
+ dbg(f"host play len={len(text)}")
739
+ t_write = time.monotonic()
740
+ try:
741
+ await asyncio.wait_for(
742
+ client.write_gatt_char(
743
+ TEXT_BRIDGE_UUID, text.encode("utf-8"),
744
+ response=True,
745
+ ),
746
+ timeout=WRITE_TIMEOUT,
747
+ )
748
+ except asyncio.TimeoutError:
749
+ # Firmware wedged / connection stalled: drop the link and
750
+ # reconnect rather than freeze both sinks.
751
+ dbg("write TIMEOUT")
752
+ raise ConnectionError("write ACK timed out")
753
+ except Exception as e:
754
+ # Transient/failed write: move on; the next delta
755
+ # starts fresh.
756
+ dbg(f"write err {e}")
757
+ continue
758
+ ack_s = time.monotonic() - t_write
759
+ dbg(f"ack {ack_s * 1000:.0f}ms")
760
+ # Adaptive host pacing: learn the firmware's REAL per-char
761
+ # playback time from this ACK so host notes last exactly as
762
+ # long as the ACK window.
763
+ inst = min(NOTE_SEC_MAX, max(
764
+ NOTE_SEC_MIN, ack_s / max(1, len(text))
765
+ ))
766
+ ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
767
+ auralizer.set_pace(ack_pace_sec)
768
+ print("ok", flush=True)
716
769
  except Exception as e:
770
+ # Error recovery (wedged link, write timeout, disconnect while
771
+ # idle): explicitly disconnect so a clean re-dial is possible.
772
+ try:
773
+ await client.disconnect()
774
+ except Exception:
775
+ pass
717
776
  connected = False
718
777
  dbg(f"loop err {e}")
719
778
  print(f"err {e}", flush=True)
720
779
  await asyncio.sleep(RECONNECT_DELAY)
721
780
 
722
- # Hard fallback: once a stop is requested, the graceful path normally
723
- # disconnects within a few seconds (the idle wait is up to IDLE_CHECK_MS),
724
- # but never let the bridge linger as an orphan.
781
+ # Hard fallback: once a stop is requested, the graceful path exits within a
782
+ # few seconds (the idle wait is up to IDLE_CHECK_MS), but never let the
783
+ # bridge linger as an orphan. Note this abrupt exit also leaves the BLE link
784
+ # to bluetoothd (no disconnect) — exactly what we want on shutdown.
725
785
  async def watchdog() -> None:
726
786
  await stop_event.wait()
727
787
  await asyncio.sleep(10)
@@ -729,7 +789,18 @@ async def main() -> None:
729
789
 
730
790
  asyncio.create_task(watchdog())
731
791
 
732
- await asyncio.gather(host_task, ble_loop())
792
+ try:
793
+ await asyncio.gather(host_task, ble_loop())
794
+ finally:
795
+ # NEVER return from main: asyncio.run would shut the event loop down,
796
+ # cancelling bleak's _disconnect_monitor task, whose CancelledError
797
+ # handler sends a BlueZ device "Disconnect" — dropping the shared ACL
798
+ # link and ejecting the RGBify website. os._exit bypasses all Python
799
+ # cleanup; the OS closes our socket and bluetoothd keeps the projector
800
+ # connected ("let the OS handle BT connections"). On macOS/Windows the
801
+ # OS tears the link down on process exit regardless — no spurious
802
+ # disconnect either way.
803
+ os._exit(0)
733
804
 
734
805
 
735
806
  if __name__ == "__main__":
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { spawn } from "bun";
2
- import { existsSync, appendFileSync } from "node:fs";
2
+ import { existsSync, appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import path from "node:path";
5
+ import os from "node:os";
5
6
  const here = path.dirname(fileURLToPath(import.meta.url));
6
7
  const PLUGIN_ROOT = path.join(here, "..");
7
8
  const BRIDGE = path.join(here, "..", "bridge", "ble_bridge.py");
@@ -17,14 +18,36 @@ const VENV_PYTHON = IS_WINDOWS
17
18
  ? path.join(here, "..", ".venv", "Scripts", "python.exe")
18
19
  : path.join(here, "..", ".venv", "bin", "python");
19
20
  const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG;
20
- function debug(line) {
21
- if (!DEBUG_LOG)
21
+ // Debug logging must never run synchronously on opencode's stream fiber: the
22
+ // plugin's `event` hook is invoked inline in the awaited event listener chain
23
+ // (see mem:opencode/delta-control-flow), so a per-delta appendFileSync would
24
+ // stall the whole LLM stream. Batch lines and flush on a timer instead.
25
+ const debugLines = [];
26
+ let debugTimer = null;
27
+ function flushDebug() {
28
+ if (debugTimer !== null) {
29
+ clearTimeout(debugTimer);
30
+ debugTimer = null;
31
+ }
32
+ if (!DEBUG_LOG || debugLines.length === 0)
22
33
  return;
34
+ const batch = debugLines.splice(0);
23
35
  try {
24
- appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`);
36
+ appendFileSync(DEBUG_LOG, batch.join(""));
25
37
  }
26
38
  catch { }
27
39
  }
40
+ function debug(line) {
41
+ if (!DEBUG_LOG)
42
+ return;
43
+ debugLines.push(`${Date.now()} ${line}\n`);
44
+ if (debugTimer === null) {
45
+ debugTimer = setTimeout(() => {
46
+ debugTimer = null;
47
+ flushDebug();
48
+ }, 50);
49
+ }
50
+ }
28
51
  // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
29
52
  // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
30
53
  // the user, no root/admin. A shared promise guards against concurrent send()s
@@ -63,6 +86,32 @@ async function bootstrapPython() {
63
86
  function isEnabled() {
64
87
  return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true";
65
88
  }
89
+ // Desktop volume is a plain shared file the bridge's `watch_host_volume` task
90
+ // polls and applies live (works with the projector off). The /rgbify slash
91
+ // command just writes it. Path defaults to the opencode global state dir.
92
+ function stateDir() {
93
+ return (process.env.RGBIFY_STATE_DIR ||
94
+ path.join(os.homedir(), ".config", "opencode", "state"));
95
+ }
96
+ const VOLUME_FILE = path.join(stateDir(), "host-volume");
97
+ function readHostVolume() {
98
+ try {
99
+ const v = parseInt(readFileSync(VOLUME_FILE, "utf8").trim(), 10);
100
+ return Number.isFinite(v) ? Math.max(0, Math.min(10, v)) : 10;
101
+ }
102
+ catch {
103
+ return 10;
104
+ }
105
+ }
106
+ function writeHostVolume(v) {
107
+ try {
108
+ mkdirSync(stateDir(), { recursive: true });
109
+ writeFileSync(VOLUME_FILE, String(Math.max(0, Math.min(10, Math.round(v)))));
110
+ }
111
+ catch {
112
+ // Non-fatal: volume just won't persist.
113
+ }
114
+ }
66
115
  // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
67
116
  // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
68
117
  // wedge. History: sanitize() existed for the scrolling-display era, where tags
@@ -77,29 +126,27 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
77
126
  let procPromise = null;
78
127
  const seenEventTypes = new Set();
79
128
  // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
80
- // stdin. pull() waits for the newest line and enqueues it; a newer line
81
- // replaces an un-consumed one (latest wins, no backlog). There is NO
82
- // synchronous FileSink write/flush on opencode's event loop — that was the
129
+ // stdin. pull() waits for a queued line and enqueues the oldest first (FIFO);
130
+ // every line is delivered in order nothing is dropped or replaced. There is
131
+ // NO synchronous FileSink write/flush on opencode's event loop — that was the
83
132
  // freeze (blocking the TUI whenever the pipe backed up under load).
84
133
  let enqueueLine = null;
85
134
  let pendingLine = null;
86
135
  function makeStdinStream() {
87
- let latest = null;
136
+ const lines = [];
88
137
  let wake = null;
89
138
  let closed = false;
90
139
  const enc = new TextEncoder();
91
140
  const stream = new ReadableStream({
92
141
  async pull(controller) {
93
- while (latest === null && !closed) {
142
+ while (lines.length === 0 && !closed) {
94
143
  await new Promise((resolve) => {
95
144
  wake = resolve;
96
145
  });
97
146
  }
98
147
  if (closed)
99
148
  return;
100
- const line = latest;
101
- latest = null;
102
- controller.enqueue(enc.encode(line + "\n"));
149
+ controller.enqueue(enc.encode(lines.shift() + "\n"));
103
150
  },
104
151
  cancel() {
105
152
  closed = true;
@@ -113,7 +160,7 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
113
160
  enqueueLine = (line) => {
114
161
  if (closed)
115
162
  return;
116
- latest = line;
163
+ lines.push(line);
117
164
  if (wake) {
118
165
  const w = wake;
119
166
  wake = null;
@@ -150,7 +197,7 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
150
197
  enqueueLine = null;
151
198
  pendingLine = null;
152
199
  });
153
- // Flush any line that arrived before the bridge was up (latest only).
200
+ // Flush any line that arrived before the bridge was up (single pending slot).
154
201
  if (pendingLine !== null) {
155
202
  enqueueLine?.(pendingLine);
156
203
  pendingLine = null;
@@ -159,13 +206,27 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
159
206
  })();
160
207
  return procPromise;
161
208
  }
162
- // Delivery: fire-and-forget. Each delta's tail goes straight to the bridge
163
- // no coalescing, no accumulation, no timer. The bridge keeps only the latest
164
- // line, so the last delta is the only delta (no backlog). Keep SMALL: the
165
- // bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
166
- // so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
167
- // Firmware cap is MAX_TEXT=256.
168
- const TAIL_CHARS = 4;
209
+ // Delivery: raw delta text is COALESCED into full-length messages. opencode
210
+ // emits deltas in bursts of tiny fragments (~4 chars); per-delta sends would
211
+ // produce fragmented notes under the bridge's ACK gate. Instead, accumulate
212
+ // text and emit a full TAIL_CHARS message whenever the buffer fills — or
213
+ // after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
214
+ // when printing stops. The bridge writes one line per ACK (gated), so during
215
+ // continuous printing the notes play back-to-back. Firmware cap is MAX_TEXT.
216
+ const TAIL_CHARS = 8;
217
+ const FLUSH_MS = 150;
218
+ let buf = "";
219
+ let flushTimer = null;
220
+ function flushBuf() {
221
+ if (flushTimer) {
222
+ clearTimeout(flushTimer);
223
+ flushTimer = null;
224
+ }
225
+ const line = buf.slice(-TAIL_CHARS);
226
+ buf = "";
227
+ if (line)
228
+ writeLine(line);
229
+ }
169
230
  function writeLine(line) {
170
231
  debug(`send len=${line.length}`);
171
232
  if (enqueueLine) {
@@ -174,17 +235,26 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
174
235
  else {
175
236
  pendingLine = line;
176
237
  }
177
- startBridge().catch(async (err) => {
178
- await client.app.log({
179
- body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
238
+ // Keep the hot path free of spawn work: the bridge is started once at
239
+ // plugin init (and respawned only after a death clears procPromise), so a
240
+ // steady stream of deltas never re-enters startBridge().
241
+ if (procPromise === null) {
242
+ startBridge().catch(async (err) => {
243
+ await client.app.log({
244
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
245
+ });
180
246
  });
181
- });
247
+ }
182
248
  }
183
249
  function send(text) {
184
- // Raw text, tail-capped — no sanitization (see the note above).
185
- const line = text.slice(-TAIL_CHARS);
186
- if (line)
187
- writeLine(line);
250
+ // Raw text, coalesced — no sanitization (see the note above).
251
+ buf += text;
252
+ if (buf.length >= TAIL_CHARS) {
253
+ flushBuf();
254
+ return;
255
+ }
256
+ if (!flushTimer)
257
+ flushTimer = setTimeout(flushBuf, FLUSH_MS);
188
258
  }
189
259
  startBridge().catch(async (err) => {
190
260
  await client.app.log({
@@ -223,6 +293,36 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
223
293
  return;
224
294
  }
225
295
  },
296
+ // /rgbify volume <0-10>: the server plugin handles it directly (no model
297
+ // needed to act) — writes host-volume, which the bridge's watch_host_volume
298
+ // task applies live. The command still flows through the normal command
299
+ // pipeline, so we replace the parts with a crisp confirmation for the model
300
+ // to echo. In-place mutation: opencode passes the `parts` array by reference
301
+ // (plugin.trigger), so clearing + pushing takes effect on the prompt.
302
+ "command.execute.before": async (input, output) => {
303
+ if (input.command !== "rgbify")
304
+ return;
305
+ const args = (input.arguments || "").trim();
306
+ const base = {
307
+ id: `rgbify-${Date.now()}`,
308
+ sessionID: input.sessionID,
309
+ messageID: input.sessionID,
310
+ };
311
+ const m = args.match(/^volume\s+([0-9]+)$/);
312
+ output.parts.length = 0;
313
+ if (m) {
314
+ const v = Math.max(0, Math.min(10, parseInt(m[1], 10)));
315
+ writeHostVolume(v);
316
+ output.parts.push({ ...base, type: "text", text: `RGBify desktop volume set to ${v}.` });
317
+ }
318
+ else {
319
+ output.parts.push({
320
+ ...base,
321
+ type: "text",
322
+ text: `RGBify desktop volume is ${readHostVolume()}. Usage: /rgbify volume <0-10>.`,
323
+ });
324
+ }
325
+ },
226
326
  "chat.message": async (_input, output) => {
227
327
  for (const part of output.parts) {
228
328
  if (part.type !== "text")
@@ -237,12 +337,19 @@ export const RGBifyProjectorPlugin = async ({ client }) => {
237
337
  send("tool out");
238
338
  },
239
339
  // When opencode shuts down, kill the bridge so it doesn't linger as an
240
- // orphan holding the projector connection. Closing its stdin would also do
241
- // it (the bridge exits on EOF), but kill is immediate and explicit.
340
+ // orphan. The bridge exits WITHOUT a BLE disconnect (bluetoothd keeps the
341
+ // shared link), so the RGBify website stays connected. Closing stdin would
342
+ // also do it (the bridge exits on EOF), but kill is immediate and explicit.
242
343
  dispose: async () => {
243
- // Drop any pending line — the session is over.
344
+ // Drop any pending coalesced text — the session is over.
345
+ if (flushTimer) {
346
+ clearTimeout(flushTimer);
347
+ flushTimer = null;
348
+ }
349
+ buf = "";
244
350
  enqueueLine = null;
245
351
  pendingLine = null;
352
+ flushDebug();
246
353
  if (procPromise) {
247
354
  try {
248
355
  const proc = await procPromise;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-rgbify-plugin",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "opencode plugin: stream chat text deltas to an RGBify 8x8 projector over BLE",
5
5
  "keywords": [
6
6
  "opencode",
package/src/index.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { spawn } from "bun"
3
- import { existsSync, appendFileSync } from "node:fs"
3
+ import { existsSync, appendFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
4
4
  import { fileURLToPath } from "node:url"
5
5
  import path from "node:path"
6
+ import os from "node:os"
6
7
 
7
8
  const here = path.dirname(fileURLToPath(import.meta.url))
8
9
  const PLUGIN_ROOT = path.join(here, "..")
@@ -20,13 +21,36 @@ const VENV_PYTHON = IS_WINDOWS
20
21
  : path.join(here, "..", ".venv", "bin", "python")
21
22
  const DEBUG_LOG = process.env.RGBIFY_DEBUG_LOG
22
23
 
23
- function debug(line: string) {
24
- if (!DEBUG_LOG) return
24
+ // Debug logging must never run synchronously on opencode's stream fiber: the
25
+ // plugin's `event` hook is invoked inline in the awaited event listener chain
26
+ // (see mem:opencode/delta-control-flow), so a per-delta appendFileSync would
27
+ // stall the whole LLM stream. Batch lines and flush on a timer instead.
28
+ const debugLines: string[] = []
29
+ let debugTimer: ReturnType<typeof setTimeout> | null = null
30
+
31
+ function flushDebug() {
32
+ if (debugTimer !== null) {
33
+ clearTimeout(debugTimer)
34
+ debugTimer = null
35
+ }
36
+ if (!DEBUG_LOG || debugLines.length === 0) return
37
+ const batch = debugLines.splice(0)
25
38
  try {
26
- appendFileSync(DEBUG_LOG, `${Date.now()} ${line}\n`)
39
+ appendFileSync(DEBUG_LOG, batch.join(""))
27
40
  } catch {}
28
41
  }
29
42
 
43
+ function debug(line: string) {
44
+ if (!DEBUG_LOG) return
45
+ debugLines.push(`${Date.now()} ${line}\n`)
46
+ if (debugTimer === null) {
47
+ debugTimer = setTimeout(() => {
48
+ debugTimer = null
49
+ flushDebug()
50
+ }, 50)
51
+ }
52
+ }
53
+
30
54
  // Self-bootstrap: on first use, if the bridge venv doesn't exist, run the
31
55
  // platform installer to install EVERYTHING (uv, Python, bleak, miniaudio) as
32
56
  // the user, no root/admin. A shared promise guards against concurrent send()s
@@ -65,6 +89,36 @@ function isEnabled(): boolean {
65
89
  return process.env.RGBIFY_DISABLE !== "1" && process.env.RGBIFY_DISABLE !== "true"
66
90
  }
67
91
 
92
+ // Desktop volume is a plain shared file the bridge's `watch_host_volume` task
93
+ // polls and applies live (works with the projector off). The /rgbify slash
94
+ // command just writes it. Path defaults to the opencode global state dir.
95
+ function stateDir(): string {
96
+ return (
97
+ process.env.RGBIFY_STATE_DIR ||
98
+ path.join(os.homedir(), ".config", "opencode", "state")
99
+ )
100
+ }
101
+
102
+ const VOLUME_FILE = path.join(stateDir(), "host-volume")
103
+
104
+ function readHostVolume(): number {
105
+ try {
106
+ const v = parseInt(readFileSync(VOLUME_FILE, "utf8").trim(), 10)
107
+ return Number.isFinite(v) ? Math.max(0, Math.min(10, v)) : 10
108
+ } catch {
109
+ return 10
110
+ }
111
+ }
112
+
113
+ function writeHostVolume(v: number): void {
114
+ try {
115
+ mkdirSync(stateDir(), { recursive: true })
116
+ writeFileSync(VOLUME_FILE, String(Math.max(0, Math.min(10, Math.round(v)))))
117
+ } catch {
118
+ // Non-fatal: volume just won't persist.
119
+ }
120
+ }
121
+
68
122
  // NO sanitization — raw delta text goes straight to the bridge. Both auralizers
69
123
  // tolerate any byte (out-of-range chars play as rests), so nothing can crash or
70
124
  // wedge. History: sanitize() existed for the scrolling-display era, where tags
@@ -81,29 +135,27 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
81
135
  const seenEventTypes = new Set<string>()
82
136
 
83
137
  // One-way, non-blocking delivery: a ReadableStream fed into the bridge's
84
- // stdin. pull() waits for the newest line and enqueues it; a newer line
85
- // replaces an un-consumed one (latest wins, no backlog). There is NO
86
- // synchronous FileSink write/flush on opencode's event loop — that was the
138
+ // stdin. pull() waits for a queued line and enqueues the oldest first (FIFO);
139
+ // every line is delivered in order nothing is dropped or replaced. There is
140
+ // NO synchronous FileSink write/flush on opencode's event loop — that was the
87
141
  // freeze (blocking the TUI whenever the pipe backed up under load).
88
142
  let enqueueLine: ((line: string) => void) | null = null
89
143
  let pendingLine: string | null = null
90
144
 
91
145
  function makeStdinStream(): ReadableStream<Uint8Array> {
92
- let latest: string | null = null
146
+ const lines: string[] = []
93
147
  let wake: (() => void) | null = null
94
148
  let closed = false
95
149
  const enc = new TextEncoder()
96
150
  const stream = new ReadableStream<Uint8Array>({
97
151
  async pull(controller) {
98
- while (latest === null && !closed) {
152
+ while (lines.length === 0 && !closed) {
99
153
  await new Promise<void>((resolve) => {
100
154
  wake = resolve
101
155
  })
102
156
  }
103
157
  if (closed) return
104
- const line = latest
105
- latest = null
106
- controller.enqueue(enc.encode(line + "\n"))
158
+ controller.enqueue(enc.encode(lines.shift() + "\n"))
107
159
  },
108
160
  cancel() {
109
161
  closed = true
@@ -116,7 +168,7 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
116
168
  })
117
169
  enqueueLine = (line: string) => {
118
170
  if (closed) return
119
- latest = line
171
+ lines.push(line)
120
172
  if (wake) {
121
173
  const w = wake
122
174
  wake = null
@@ -155,7 +207,7 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
155
207
  enqueueLine = null
156
208
  pendingLine = null
157
209
  })
158
- // Flush any line that arrived before the bridge was up (latest only).
210
+ // Flush any line that arrived before the bridge was up (single pending slot).
159
211
  if (pendingLine !== null) {
160
212
  enqueueLine?.(pendingLine)
161
213
  pendingLine = null
@@ -165,13 +217,27 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
165
217
  return procPromise
166
218
  }
167
219
 
168
- // Delivery: fire-and-forget. Each delta's tail goes straight to the bridge
169
- // no coalescing, no accumulation, no timer. The bridge keeps only the latest
170
- // line, so the last delta is the only delta (no backlog). Keep SMALL: the
171
- // bridge synthesizes host audio synchronously on its event loop at ~1ms/char,
172
- // so a large tail stalls the loop and makes audio choppy. 4 = ~4ms, cleanest.
173
- // Firmware cap is MAX_TEXT=256.
174
- const TAIL_CHARS = 4
220
+ // Delivery: raw delta text is COALESCED into full-length messages. opencode
221
+ // emits deltas in bursts of tiny fragments (~4 chars); per-delta sends would
222
+ // produce fragmented notes under the bridge's ACK gate. Instead, accumulate
223
+ // text and emit a full TAIL_CHARS message whenever the buffer fills — or
224
+ // after FLUSH_MS of quiet, so tails aren't lost and sound stops promptly
225
+ // when printing stops. The bridge writes one line per ACK (gated), so during
226
+ // continuous printing the notes play back-to-back. Firmware cap is MAX_TEXT.
227
+ const TAIL_CHARS = 8
228
+ const FLUSH_MS = 150
229
+ let buf = ""
230
+ let flushTimer: ReturnType<typeof setTimeout> | null = null
231
+
232
+ function flushBuf() {
233
+ if (flushTimer) {
234
+ clearTimeout(flushTimer)
235
+ flushTimer = null
236
+ }
237
+ const line = buf.slice(-TAIL_CHARS)
238
+ buf = ""
239
+ if (line) writeLine(line)
240
+ }
175
241
 
176
242
  function writeLine(line: string) {
177
243
  debug(`send len=${line.length}`)
@@ -180,17 +246,26 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
180
246
  } else {
181
247
  pendingLine = line
182
248
  }
183
- startBridge().catch(async (err) => {
184
- await client.app.log({
185
- body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
249
+ // Keep the hot path free of spawn work: the bridge is started once at
250
+ // plugin init (and respawned only after a death clears procPromise), so a
251
+ // steady stream of deltas never re-enters startBridge().
252
+ if (procPromise === null) {
253
+ startBridge().catch(async (err) => {
254
+ await client.app.log({
255
+ body: { service: "rgbify", level: "warn", message: `bridge unavailable: ${err}` },
256
+ })
186
257
  })
187
- })
258
+ }
188
259
  }
189
260
 
190
261
  function send(text: string) {
191
- // Raw text, tail-capped — no sanitization (see the note above).
192
- const line = text.slice(-TAIL_CHARS)
193
- if (line) writeLine(line)
262
+ // Raw text, coalesced — no sanitization (see the note above).
263
+ buf += text
264
+ if (buf.length >= TAIL_CHARS) {
265
+ flushBuf()
266
+ return
267
+ }
268
+ if (!flushTimer) flushTimer = setTimeout(flushBuf, FLUSH_MS)
194
269
  }
195
270
 
196
271
  startBridge().catch(async (err) => {
@@ -232,6 +307,34 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
232
307
  return
233
308
  }
234
309
  },
310
+ // /rgbify volume <0-10>: the server plugin handles it directly (no model
311
+ // needed to act) — writes host-volume, which the bridge's watch_host_volume
312
+ // task applies live. The command still flows through the normal command
313
+ // pipeline, so we replace the parts with a crisp confirmation for the model
314
+ // to echo. In-place mutation: opencode passes the `parts` array by reference
315
+ // (plugin.trigger), so clearing + pushing takes effect on the prompt.
316
+ "command.execute.before": async (input, output) => {
317
+ if (input.command !== "rgbify") return
318
+ const args = (input.arguments || "").trim()
319
+ const base = {
320
+ id: `rgbify-${Date.now()}`,
321
+ sessionID: input.sessionID,
322
+ messageID: input.sessionID,
323
+ }
324
+ const m = args.match(/^volume\s+([0-9]+)$/)
325
+ output.parts.length = 0
326
+ if (m) {
327
+ const v = Math.max(0, Math.min(10, parseInt(m[1], 10)))
328
+ writeHostVolume(v)
329
+ output.parts.push({ ...base, type: "text", text: `RGBify desktop volume set to ${v}.` })
330
+ } else {
331
+ output.parts.push({
332
+ ...base,
333
+ type: "text",
334
+ text: `RGBify desktop volume is ${readHostVolume()}. Usage: /rgbify volume <0-10>.`,
335
+ })
336
+ }
337
+ },
235
338
  "chat.message": async (_input, output) => {
236
339
  for (const part of output.parts) {
237
340
  if (part.type !== "text") continue
@@ -245,12 +348,19 @@ export const RGBifyProjectorPlugin: Plugin = async ({ client }) => {
245
348
  send("tool out")
246
349
  },
247
350
  // When opencode shuts down, kill the bridge so it doesn't linger as an
248
- // orphan holding the projector connection. Closing its stdin would also do
249
- // it (the bridge exits on EOF), but kill is immediate and explicit.
351
+ // orphan. The bridge exits WITHOUT a BLE disconnect (bluetoothd keeps the
352
+ // shared link), so the RGBify website stays connected. Closing stdin would
353
+ // also do it (the bridge exits on EOF), but kill is immediate and explicit.
250
354
  dispose: async () => {
251
- // Drop any pending line — the session is over.
355
+ // Drop any pending coalesced text — the session is over.
356
+ if (flushTimer) {
357
+ clearTimeout(flushTimer)
358
+ flushTimer = null
359
+ }
360
+ buf = ""
252
361
  enqueueLine = null
253
362
  pendingLine = null
363
+ flushDebug()
254
364
  if (procPromise) {
255
365
  try {
256
366
  const proc = await procPromise