opencode-rgbify-plugin 0.1.4 → 0.2.0
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 +15 -3
- package/bridge/ble_bridge.py +330 -255
- package/dist/index.js +139 -32
- package/package.json +1 -1
- package/src/index.ts +141 -31
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
package/bridge/ble_bridge.py
CHANGED
|
@@ -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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
the
|
|
8
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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.
|
|
87
|
-
#
|
|
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 = -
|
|
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,31 @@ 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
|
+
# Non-ASCII chars (CJK, emoji, …) all overflow to slot 90 (100 Hz), so
|
|
182
|
+
# every Chinese char sounded identical. Map the code point into the
|
|
183
|
+
# log-scale table deterministically (skipping the 5000 Hz scream slot)
|
|
184
|
+
# so distinct chars get distinct, audibly-different pitches.
|
|
185
|
+
idx = (ord(ch) - 91) % (len(AURALIZER_FREQ) - 1) + 1
|
|
186
|
+
return AURALIZER_FREQ[idx]
|
|
187
|
+
|
|
188
|
+
|
|
153
189
|
# TRUE PIEZO SIMULATION: the projector's sound chain is toneAC driving the
|
|
154
190
|
# bare piezo disc with a SQUARE wave, and the disc's mechanical response has
|
|
155
191
|
# a dominant resonance ring at ~1897 Hz (firmware RESONANT_FREQ), an upper
|
|
@@ -167,8 +203,6 @@ PIEZO_HF_HZ = 3800.0
|
|
|
167
203
|
PIEZO_HF_Q = 1.2
|
|
168
204
|
PIEZO_HF_GAIN_DB = 8.0
|
|
169
205
|
|
|
170
|
-
_wave_cache = {}
|
|
171
|
-
|
|
172
206
|
|
|
173
207
|
def _poly_blep(t: float, dt: float) -> float:
|
|
174
208
|
"""PolyBLEP correction for a naive bandlimited-violating square edge at
|
|
@@ -267,60 +301,30 @@ def _calibration_scale() -> float:
|
|
|
267
301
|
return _calib_scale
|
|
268
302
|
|
|
269
303
|
|
|
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
304
|
class HostAuralizer:
|
|
315
|
-
"""Always-on host audio sink
|
|
316
|
-
|
|
317
|
-
|
|
305
|
+
"""Always-on host audio sink — a REAL-TIME FREQUENCY FOLLOWER.
|
|
306
|
+
|
|
307
|
+
The miniaudio device thread continuously synthesizes a square wave at the
|
|
308
|
+
current frequency (straight-through phase accumulator + persistent piezo
|
|
309
|
+
biquad chain). The bridge loop never does synthesis, never sleeps, never
|
|
310
|
+
touches the volume file per message: it only hands over a list of note
|
|
311
|
+
frequencies (whitespace INCLUDED — every char plays its ASCII table slot,
|
|
312
|
+
no rests, no fades) and the audio clock drives one note per NOTE_SEC.
|
|
313
|
+
|
|
314
|
+
The tone changes frequency per character, plays continuously through the
|
|
315
|
+
whole line, and only goes silent once the line's notes are exhausted (the
|
|
316
|
+
end of a delta). A new line replaces the pending notes immediately (latest
|
|
317
|
+
wins — matching the projector's interrupt semantics). Note duration is
|
|
318
|
+
adaptive: set_pace() feeds the measured ACK ms/char so host notes last
|
|
319
|
+
exactly as long as the ACK window (no gap while plugged in)."""
|
|
318
320
|
|
|
319
321
|
def __init__(self) -> None:
|
|
320
322
|
self._device = None
|
|
321
323
|
self._lock = threading.Lock()
|
|
322
|
-
self.
|
|
323
|
-
self.
|
|
324
|
+
self._seq = None # list[float] Hz per char for the current line, or None
|
|
325
|
+
self._off = True # True when no tone should sound
|
|
326
|
+
self._note_sec = NOTE_SEC # per-char playback duration (adaptive)
|
|
327
|
+
self._amp = int(32767 * max(0, min(10, load_volume())) / 10.0)
|
|
324
328
|
self._stop = False
|
|
325
329
|
|
|
326
330
|
def start(self) -> None:
|
|
@@ -331,10 +335,6 @@ class HostAuralizer:
|
|
|
331
335
|
output_format=miniaudio.SampleFormat.SIGNED16,
|
|
332
336
|
nchannels=1,
|
|
333
337
|
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
338
|
buffersize_msec=20,
|
|
339
339
|
)
|
|
340
340
|
# PRIME the generator: miniaudio's data callback does
|
|
@@ -352,46 +352,100 @@ class HostAuralizer:
|
|
|
352
352
|
print("ok host auralizer", flush=True)
|
|
353
353
|
|
|
354
354
|
def _generator(self):
|
|
355
|
-
#
|
|
356
|
-
#
|
|
355
|
+
# Straight-through async synthesis on the audio thread. Per-sample:
|
|
356
|
+
# polyBLEP square @ current freq -> HPF -> ring -> HF mode, all with
|
|
357
|
+
# PERSISTENT filter state (a continuous tone, no clicks, no fades). The
|
|
358
|
+
# audio clock advances one note every NOTE_SEC; when the line's notes
|
|
359
|
+
# are exhausted it goes silent (end of delta). A new seq object swaps in
|
|
360
|
+
# at the next callback (latest wins), interrupting whatever was playing
|
|
361
|
+
# exactly like the firmware's text_bridge_pos=0 reset.
|
|
357
362
|
required = yield b""
|
|
363
|
+
phase = 0.0
|
|
364
|
+
x1a = x2a = y1a = y2a = 0.0 # HPF state
|
|
365
|
+
x1b = x2b = y1b = y2b = 0.0 # ring state
|
|
366
|
+
x1c = x2c = y1c = y2c = 0.0 # HF state
|
|
367
|
+
seq = None
|
|
368
|
+
pos = 0
|
|
369
|
+
left = 0
|
|
370
|
+
scale = _calibration_scale()
|
|
358
371
|
while not self._stop:
|
|
359
372
|
with self._lock:
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
373
|
+
cur = self._seq
|
|
374
|
+
off = self._off
|
|
375
|
+
amp = self._amp
|
|
376
|
+
note_sec = self._note_sec
|
|
377
|
+
if cur is not seq:
|
|
378
|
+
seq = cur
|
|
379
|
+
pos = 0
|
|
380
|
+
left = int(SAMPLE_RATE * note_sec) if cur is not None else 0
|
|
381
|
+
out = array.array("h")
|
|
382
|
+
for _ in range(required):
|
|
383
|
+
if off or seq is None or pos >= len(seq):
|
|
384
|
+
out.append(0)
|
|
385
|
+
continue
|
|
386
|
+
freq = seq[pos]
|
|
387
|
+
dt = freq / SAMPLE_RATE
|
|
388
|
+
naive = 1.0 if phase < 0.5 else -1.0
|
|
389
|
+
x = (naive - _poly_blep(phase, dt)) * amp
|
|
390
|
+
y = (_HPF_COEFFS[0] * x + _HPF_COEFFS[1] * x1a
|
|
391
|
+
+ _HPF_COEFFS[2] * x2a - _HPF_COEFFS[3] * y1a
|
|
392
|
+
- _HPF_COEFFS[4] * y2a)
|
|
393
|
+
x2a, x1a, y2a, y1a = x1a, x, y1a, y
|
|
394
|
+
x = y
|
|
395
|
+
y = (_RING_COEFFS[0] * x + _RING_COEFFS[1] * x1b
|
|
396
|
+
+ _RING_COEFFS[2] * x2b - _RING_COEFFS[3] * y1b
|
|
397
|
+
- _RING_COEFFS[4] * y2b)
|
|
398
|
+
x2b, x1b, y2b, y1b = x1b, x, y1b, y
|
|
399
|
+
x = y
|
|
400
|
+
y = (_HF_COEFFS[0] * x + _HF_COEFFS[1] * x1c
|
|
401
|
+
+ _HF_COEFFS[2] * x2c - _HF_COEFFS[3] * y1c
|
|
402
|
+
- _HF_COEFFS[4] * y2c)
|
|
403
|
+
x2c, x1c, y2c, y1c = x1c, x, y1c, y
|
|
404
|
+
v = y * scale
|
|
405
|
+
if v > 32767:
|
|
406
|
+
v = 32767
|
|
407
|
+
elif v < -32768:
|
|
408
|
+
v = -32768
|
|
409
|
+
out.append(int(v))
|
|
410
|
+
phase += dt
|
|
411
|
+
if phase >= 1.0:
|
|
412
|
+
phase -= 1.0
|
|
413
|
+
left -= 1
|
|
414
|
+
if left <= 0:
|
|
415
|
+
pos += 1
|
|
416
|
+
left = int(SAMPLE_RATE * note_sec)
|
|
417
|
+
required = yield out.tobytes()
|
|
418
|
+
|
|
419
|
+
def play_notes(self, freqs: list) -> None:
|
|
420
|
+
# Latest wins: a fresh note list replaces the pending one; the audio
|
|
421
|
+
# thread picks it up at the next buffer boundary. O(n) tiny, no I/O.
|
|
422
|
+
with self._lock:
|
|
423
|
+
self._seq = freqs
|
|
424
|
+
self._off = False
|
|
425
|
+
|
|
426
|
+
def note_off(self) -> None:
|
|
427
|
+
with self._lock:
|
|
428
|
+
self._off = True
|
|
429
|
+
self._seq = None
|
|
430
|
+
|
|
431
|
+
def set_volume(self, v: int) -> None:
|
|
432
|
+
with self._lock:
|
|
433
|
+
self._amp = int(32767 * max(0, min(10, v)) / 10.0)
|
|
434
|
+
|
|
435
|
+
def set_pace(self, note_sec: float) -> None:
|
|
436
|
+
# Adaptive host pacing: per-char note duration matches the measured ACK
|
|
437
|
+
# window so host notes last exactly as long as the projector's playback.
|
|
378
438
|
with self._lock:
|
|
379
|
-
self.
|
|
380
|
-
self._pos = 0
|
|
439
|
+
self._note_sec = note_sec
|
|
381
440
|
|
|
382
441
|
# Mirror the firmware's volume-change chirp: a short ~1970 Hz beep at the
|
|
383
442
|
# current volume, so the host confirms volume changes like the projector.
|
|
384
443
|
CHIRP_HZ = 1970
|
|
385
|
-
CHIRP_SEC = 0.030
|
|
386
444
|
|
|
387
445
|
def chirp(self) -> None:
|
|
388
446
|
if self._device is None:
|
|
389
447
|
return
|
|
390
|
-
|
|
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)
|
|
448
|
+
self.play_notes([self.CHIRP_HZ])
|
|
395
449
|
|
|
396
450
|
def stop(self) -> None:
|
|
397
451
|
self._stop = True
|
|
@@ -484,7 +538,10 @@ async def main() -> None:
|
|
|
484
538
|
pass
|
|
485
539
|
|
|
486
540
|
# Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
|
|
487
|
-
# most recent line
|
|
541
|
+
# most recent line. ACK-GATED DELIVERY: while a message is in flight (awaiting
|
|
542
|
+
# its write ACK), newer lines replace the pending slot — intermediate texts
|
|
543
|
+
# are SKIPPED, only the newest survives and is written after the ACK. Nothing
|
|
544
|
+
# queues on the projector; the last text is the only text.
|
|
488
545
|
host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
489
546
|
ble_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
490
547
|
|
|
@@ -523,11 +580,11 @@ async def main() -> None:
|
|
|
523
580
|
push_latest(host_line, line)
|
|
524
581
|
|
|
525
582
|
async def host_auralize() -> None:
|
|
526
|
-
#
|
|
527
|
-
#
|
|
528
|
-
#
|
|
529
|
-
#
|
|
530
|
-
#
|
|
583
|
+
# Real-time frequency follower: hand the audio thread one frequency per
|
|
584
|
+
# char (whitespace INCLUDED) and let the device clock drive playback.
|
|
585
|
+
# No synthesis, no sleep, no volume-file I/O on this loop — play_notes
|
|
586
|
+
# is O(n) and non-blocking, so the bridge loop stays free to read stdin
|
|
587
|
+
# and drive BLE (the loop never stalls, so nothing backs up to opencode).
|
|
531
588
|
auralizer.start()
|
|
532
589
|
try:
|
|
533
590
|
while not stop_event.is_set():
|
|
@@ -540,9 +597,7 @@ async def main() -> None:
|
|
|
540
597
|
continue
|
|
541
598
|
if not line:
|
|
542
599
|
continue
|
|
543
|
-
auralizer.
|
|
544
|
-
synth_message(line, load_volume())
|
|
545
|
-
)
|
|
600
|
+
auralizer.play_notes([_char_freq(c) for c in line])
|
|
546
601
|
finally:
|
|
547
602
|
auralizer.stop()
|
|
548
603
|
|
|
@@ -559,12 +614,32 @@ async def main() -> None:
|
|
|
559
614
|
request_stop()
|
|
560
615
|
return
|
|
561
616
|
|
|
617
|
+
async def watch_host_volume() -> None:
|
|
618
|
+
# Desktop volume is controlled externally (the opencode TUI popup writes
|
|
619
|
+
# the host-volume file). Watch it so live changes apply even with the
|
|
620
|
+
# projector off — no BLE round-trip, no mirroring of the projector VOLUME.
|
|
621
|
+
last = load_volume()
|
|
622
|
+
auralizer.set_volume(last)
|
|
623
|
+
while not stop_event.is_set():
|
|
624
|
+
await asyncio.sleep(0.5)
|
|
625
|
+
cur = load_volume()
|
|
626
|
+
if cur != last:
|
|
627
|
+
last = cur
|
|
628
|
+
auralizer.set_volume(cur)
|
|
629
|
+
auralizer.chirp()
|
|
630
|
+
dbg(f"vol file {cur}")
|
|
631
|
+
|
|
562
632
|
asyncio.create_task(read_stdin())
|
|
563
633
|
asyncio.create_task(watch_parent())
|
|
634
|
+
asyncio.create_task(watch_host_volume())
|
|
564
635
|
host_task = asyncio.create_task(host_auralize())
|
|
565
636
|
|
|
566
637
|
async def ble_loop() -> None:
|
|
567
638
|
nonlocal connected
|
|
639
|
+
# Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
|
|
640
|
+
# ACK time per char after every write and EMA-smooths it; the auralizer
|
|
641
|
+
# plays at this pace so host notes last exactly as long as the ACK window.
|
|
642
|
+
ack_pace_sec = NOTE_SEC
|
|
568
643
|
while not stop_event.is_set():
|
|
569
644
|
try:
|
|
570
645
|
addr = await discover_address(override)
|
|
@@ -579,149 +654,138 @@ async def main() -> None:
|
|
|
579
654
|
# The projector advertises immediately on power-up but isn't ready to
|
|
580
655
|
# accept a connection until it finishes booting. Give it a moment.
|
|
581
656
|
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
657
|
|
|
658
|
+
# When the projector resets/reboots, drop any queued text so stale
|
|
659
|
+
# lines buffered before the disconnect are not delivered on reconnect.
|
|
660
|
+
def on_disconnect(_client) -> None:
|
|
661
|
+
nonlocal connected
|
|
662
|
+
connected = False
|
|
663
|
+
dbg("disconnected")
|
|
664
|
+
for q in (host_line, ble_line):
|
|
630
665
|
try:
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
666
|
+
q.get_nowait()
|
|
667
|
+
except asyncio.QueueEmpty:
|
|
668
|
+
pass
|
|
669
|
+
print("disconnect", flush=True)
|
|
670
|
+
# NOTE: no `bluetoothctl disconnect` here. On Linux all clients
|
|
671
|
+
# (bridge AND the RGBify website) share ONE BlueZ ACL link, so a
|
|
672
|
+
# device-wide disconnect ejected the website every time a bridge
|
|
673
|
+
# went away. Stale writes can't survive either — the firmware
|
|
674
|
+
# interrupts on every write.
|
|
675
|
+
|
|
676
|
+
# Explicit client, NOT `async with`: we control connect/disconnect.
|
|
677
|
+
# On a clean stop we exit WITHOUT client.disconnect() — bluetoothd
|
|
678
|
+
# owns the BLE link, so an abrupt exit leaves the projector connected
|
|
679
|
+
# and the shared-link RGBify website keeps its connection. Only error
|
|
680
|
+
# recovery explicitly disconnects (a wedged link must be torn down
|
|
681
|
+
# before re-dialing).
|
|
682
|
+
client = BleakClient(addr, disconnected_callback=on_disconnect)
|
|
683
|
+
connected = False
|
|
684
|
+
try:
|
|
685
|
+
await client.connect()
|
|
686
|
+
except Exception as e:
|
|
687
|
+
# Failed connect: make sure nothing lingers, then re-dial.
|
|
688
|
+
try:
|
|
689
|
+
await client.disconnect()
|
|
690
|
+
except Exception:
|
|
691
|
+
pass
|
|
692
|
+
dbg(f"loop err {e}")
|
|
693
|
+
print(f"err {e}", flush=True)
|
|
694
|
+
await asyncio.sleep(RECONNECT_DELAY)
|
|
695
|
+
continue
|
|
643
696
|
|
|
697
|
+
try:
|
|
698
|
+
# Clear anything that slipped in before the flag flipped, so
|
|
699
|
+
# delivery starts fresh with the first line after connect.
|
|
700
|
+
for q in (host_line, ble_line):
|
|
644
701
|
try:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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,
|
|
702
|
+
q.get_nowait()
|
|
703
|
+
except asyncio.QueueEmpty:
|
|
704
|
+
pass
|
|
705
|
+
connected = True
|
|
706
|
+
dbg(f"connected {addr}")
|
|
707
|
+
print(f"ok {addr}", flush=True)
|
|
708
|
+
# Desktop volume is NOT mirrored from the projector. It is
|
|
709
|
+
# controlled externally (TUI popup -> host-volume file, watched
|
|
710
|
+
# by watch_host_volume below) and works with the projector off.
|
|
711
|
+
# No connect-time GATT read, so the first message is not gated
|
|
712
|
+
# behind a volume round-trip.
|
|
713
|
+
# WHOLE-MESSAGE DELIVERY, ACK-GATED: each line (the last 8 chars
|
|
714
|
+
# of a coalesced delta burst) is written in ONE write with
|
|
715
|
+
# response=True. The write resolves only once the firmware ACKs
|
|
716
|
+
# it, so the round-trip serializes delivery: the next line is NOT
|
|
717
|
+
# written until the previous is ACKed. Lines that arrive DURING
|
|
718
|
+
# the ACK window are SKIPPED (latest wins on the maxsize-1
|
|
719
|
+
# ble_line), so the last text is the only text and nothing queues
|
|
720
|
+
# on the projector.
|
|
721
|
+
while True:
|
|
722
|
+
kind, text = await wait_line_or_stop(
|
|
723
|
+
ble_line, stop_event, IDLE_CHECK_MS
|
|
724
|
+
)
|
|
725
|
+
if kind == "stop":
|
|
726
|
+
# Clean stop: leave the BLE link to bluetoothd — do NOT
|
|
727
|
+
# disconnect, so the shared-link website stays connected.
|
|
728
|
+
connected = False
|
|
729
|
+
return
|
|
730
|
+
if kind == "idle":
|
|
731
|
+
if not client.is_connected:
|
|
732
|
+
raise ConnectionError(
|
|
733
|
+
"projector disconnected while idle"
|
|
700
734
|
)
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
735
|
+
continue
|
|
736
|
+
if not text:
|
|
737
|
+
continue
|
|
738
|
+
# SYNC: hand the message to the host auralizer NOW, so
|
|
739
|
+
# it starts the same notes at the same moment the
|
|
740
|
+
# projector does.
|
|
741
|
+
push_latest(host_line, text)
|
|
742
|
+
dbg(f"host play len={len(text)}")
|
|
743
|
+
t_write = time.monotonic()
|
|
744
|
+
try:
|
|
745
|
+
await asyncio.wait_for(
|
|
746
|
+
client.write_gatt_char(
|
|
747
|
+
TEXT_BRIDGE_UUID, text.encode("utf-8"),
|
|
748
|
+
response=True,
|
|
749
|
+
),
|
|
750
|
+
timeout=WRITE_TIMEOUT,
|
|
751
|
+
)
|
|
752
|
+
except asyncio.TimeoutError:
|
|
753
|
+
# Firmware wedged / connection stalled: drop the link and
|
|
754
|
+
# reconnect rather than freeze both sinks.
|
|
755
|
+
dbg("write TIMEOUT")
|
|
756
|
+
raise ConnectionError("write ACK timed out")
|
|
757
|
+
except Exception as e:
|
|
758
|
+
# Transient/failed write: move on; the next delta
|
|
759
|
+
# starts fresh.
|
|
760
|
+
dbg(f"write err {e}")
|
|
761
|
+
continue
|
|
762
|
+
ack_s = time.monotonic() - t_write
|
|
763
|
+
dbg(f"ack {ack_s * 1000:.0f}ms")
|
|
764
|
+
# Adaptive host pacing: learn the firmware's REAL per-char
|
|
765
|
+
# playback time from this ACK so host notes last exactly as
|
|
766
|
+
# long as the ACK window.
|
|
767
|
+
inst = min(NOTE_SEC_MAX, max(
|
|
768
|
+
NOTE_SEC_MIN, ack_s / max(1, len(text))
|
|
769
|
+
))
|
|
770
|
+
ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
|
|
771
|
+
auralizer.set_pace(ack_pace_sec)
|
|
772
|
+
print("ok", flush=True)
|
|
716
773
|
except Exception as e:
|
|
774
|
+
# Error recovery (wedged link, write timeout, disconnect while
|
|
775
|
+
# idle): explicitly disconnect so a clean re-dial is possible.
|
|
776
|
+
try:
|
|
777
|
+
await client.disconnect()
|
|
778
|
+
except Exception:
|
|
779
|
+
pass
|
|
717
780
|
connected = False
|
|
718
781
|
dbg(f"loop err {e}")
|
|
719
782
|
print(f"err {e}", flush=True)
|
|
720
783
|
await asyncio.sleep(RECONNECT_DELAY)
|
|
721
784
|
|
|
722
|
-
# Hard fallback: once a stop is requested, the graceful path
|
|
723
|
-
#
|
|
724
|
-
#
|
|
785
|
+
# Hard fallback: once a stop is requested, the graceful path exits within a
|
|
786
|
+
# few seconds (the idle wait is up to IDLE_CHECK_MS), but never let the
|
|
787
|
+
# bridge linger as an orphan. Note this abrupt exit also leaves the BLE link
|
|
788
|
+
# to bluetoothd (no disconnect) — exactly what we want on shutdown.
|
|
725
789
|
async def watchdog() -> None:
|
|
726
790
|
await stop_event.wait()
|
|
727
791
|
await asyncio.sleep(10)
|
|
@@ -729,7 +793,18 @@ async def main() -> None:
|
|
|
729
793
|
|
|
730
794
|
asyncio.create_task(watchdog())
|
|
731
795
|
|
|
732
|
-
|
|
796
|
+
try:
|
|
797
|
+
await asyncio.gather(host_task, ble_loop())
|
|
798
|
+
finally:
|
|
799
|
+
# NEVER return from main: asyncio.run would shut the event loop down,
|
|
800
|
+
# cancelling bleak's _disconnect_monitor task, whose CancelledError
|
|
801
|
+
# handler sends a BlueZ device "Disconnect" — dropping the shared ACL
|
|
802
|
+
# link and ejecting the RGBify website. os._exit bypasses all Python
|
|
803
|
+
# cleanup; the OS closes our socket and bluetoothd keeps the projector
|
|
804
|
+
# connected ("let the OS handle BT connections"). On macOS/Windows the
|
|
805
|
+
# OS tears the link down on process exit regardless — no spurious
|
|
806
|
+
# disconnect either way.
|
|
807
|
+
os._exit(0)
|
|
733
808
|
|
|
734
809
|
|
|
735
810
|
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
|
-
|
|
21
|
-
|
|
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,
|
|
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
|
|
81
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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,
|
|
185
|
-
|
|
186
|
-
if (
|
|
187
|
-
|
|
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
|
|
241
|
-
//
|
|
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
|
|
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
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
|
-
|
|
24
|
-
|
|
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,
|
|
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
|
|
85
|
-
//
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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:
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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,
|
|
192
|
-
|
|
193
|
-
if (
|
|
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
|
|
249
|
-
//
|
|
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
|
|
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
|