opencode-rgbify-plugin 0.1.3 → 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 +25 -11
- package/bridge/ble_bridge.py +334 -306
- package/dist/index.js +160 -28
- package/package.json +21 -1
- package/src/index.ts +159 -27
- package/bridge/__pycache__/ble_bridge.cpython-312.pyc +0 -0
- package/bridge/__pycache__/ble_bridge.cpython-313.pyc +0 -0
package/bridge/ble_bridge.py
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,27 +86,25 @@ 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 ACK may take. The
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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
|
|
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
|
|
100
101
|
# Adaptive host pacing: the firmware's REAL per-char time is slower than its
|
|
101
102
|
# nominal 30fps under load (measured ACKs of ~43ms/char vs 33ms nominal), so a
|
|
102
103
|
# fixed 33ms host note runs out before the next ACK-gated dispatch — a small
|
|
103
104
|
# periodic silence, only present while plugged in. The BLE loop measures
|
|
104
|
-
# ack_ms/char after every write and EMA-smooths it
|
|
105
|
-
#
|
|
106
|
-
#
|
|
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.
|
|
107
108
|
NOTE_SEC_MIN = 0.020
|
|
108
109
|
NOTE_SEC_MAX = 0.080
|
|
109
110
|
ACK_PACE_EMA = 0.3
|
|
@@ -160,6 +161,27 @@ AURALIZER_FREQ = [
|
|
|
160
161
|
]
|
|
161
162
|
|
|
162
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
|
+
|
|
163
185
|
# TRUE PIEZO SIMULATION: the projector's sound chain is toneAC driving the
|
|
164
186
|
# bare piezo disc with a SQUARE wave, and the disc's mechanical response has
|
|
165
187
|
# a dominant resonance ring at ~1897 Hz (firmware RESONANT_FREQ), an upper
|
|
@@ -177,8 +199,6 @@ PIEZO_HF_HZ = 3800.0
|
|
|
177
199
|
PIEZO_HF_Q = 1.2
|
|
178
200
|
PIEZO_HF_GAIN_DB = 8.0
|
|
179
201
|
|
|
180
|
-
_wave_cache = {}
|
|
181
|
-
|
|
182
202
|
|
|
183
203
|
def _poly_blep(t: float, dt: float) -> float:
|
|
184
204
|
"""PolyBLEP correction for a naive bandlimited-violating square edge at
|
|
@@ -277,60 +297,30 @@ def _calibration_scale() -> float:
|
|
|
277
297
|
return _calib_scale
|
|
278
298
|
|
|
279
299
|
|
|
280
|
-
def synth_message(text: str, volume: int, note_sec: float = NOTE_SEC) -> "array.array":
|
|
281
|
-
"""Render one message as continuous PCM: cached polyBLEP squares per char,
|
|
282
|
-
chained and run through the simulated piezo chain (LF rolloff + resonance
|
|
283
|
-
ring). The device's own sample clock paces playback; adaptive `note_sec`
|
|
284
|
-
matches the firmware's measured cadence."""
|
|
285
|
-
amp = int(32767 * max(0, min(10, volume)) / 10.0)
|
|
286
|
-
buf = array.array("h")
|
|
287
|
-
n_samples = int(SAMPLE_RATE * note_sec)
|
|
288
|
-
rest = array.array("h", [0]) * n_samples
|
|
289
|
-
for ch in text:
|
|
290
|
-
c = ord(ch)
|
|
291
|
-
if ch in " \t\n\r" or not (32 <= c <= 122):
|
|
292
|
-
buf.extend(rest)
|
|
293
|
-
continue
|
|
294
|
-
key = (ch, volume, round(note_sec, 3))
|
|
295
|
-
sq = _wave_cache.get(key)
|
|
296
|
-
if sq is None:
|
|
297
|
-
sq = _square_wave(AURALIZER_FREQ[c - 32], note_sec, amp)
|
|
298
|
-
_wave_cache[key] = sq
|
|
299
|
-
if len(_wave_cache) > 512:
|
|
300
|
-
_wave_cache.clear()
|
|
301
|
-
buf.extend(sq)
|
|
302
|
-
if not buf:
|
|
303
|
-
return buf
|
|
304
|
-
# Float chain: HPF → ring → HF mode (unclamped — the boosts legitimately
|
|
305
|
-
# exceed int16; calibration scales it back). Then global scale, tail fade,
|
|
306
|
-
# clamp.
|
|
307
|
-
buf = _biquad(_biquad(_biquad(buf, _HPF_COEFFS), _RING_COEFFS), _HF_COEFFS)
|
|
308
|
-
scale = _calibration_scale()
|
|
309
|
-
fade = min(len(buf), int(SAMPLE_RATE * 0.005))
|
|
310
|
-
out = array.array("h", bytes(2 * len(buf)))
|
|
311
|
-
k = len(buf) - fade
|
|
312
|
-
for j in range(len(buf)):
|
|
313
|
-
v = buf[j] * scale
|
|
314
|
-
if j >= k:
|
|
315
|
-
v *= (j - k + 1) / fade
|
|
316
|
-
if v > 32767:
|
|
317
|
-
v = 32767
|
|
318
|
-
elif v < -32768:
|
|
319
|
-
v = -32768
|
|
320
|
-
out[j] = int(v)
|
|
321
|
-
return out
|
|
322
|
-
|
|
323
|
-
|
|
324
300
|
class HostAuralizer:
|
|
325
|
-
"""Always-on host audio sink
|
|
326
|
-
|
|
327
|
-
|
|
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)."""
|
|
328
316
|
|
|
329
317
|
def __init__(self) -> None:
|
|
330
318
|
self._device = None
|
|
331
319
|
self._lock = threading.Lock()
|
|
332
|
-
self.
|
|
333
|
-
self.
|
|
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)
|
|
334
324
|
self._stop = False
|
|
335
325
|
|
|
336
326
|
def start(self) -> None:
|
|
@@ -341,10 +331,6 @@ class HostAuralizer:
|
|
|
341
331
|
output_format=miniaudio.SampleFormat.SIGNED16,
|
|
342
332
|
nchannels=1,
|
|
343
333
|
sample_rate=SAMPLE_RATE,
|
|
344
|
-
# Small device buffer: play_note's latest-wins swap only takes
|
|
345
|
-
# effect at the next buffer boundary, and the 200ms default
|
|
346
|
-
# made the host audibly lag the projector by up to 200ms. 20ms
|
|
347
|
-
# is under the firmware's own 33ms frame granularity.
|
|
348
334
|
buffersize_msec=20,
|
|
349
335
|
)
|
|
350
336
|
# PRIME the generator: miniaudio's data callback does
|
|
@@ -362,46 +348,100 @@ class HostAuralizer:
|
|
|
362
348
|
print("ok host auralizer", flush=True)
|
|
363
349
|
|
|
364
350
|
def _generator(self):
|
|
365
|
-
#
|
|
366
|
-
#
|
|
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.
|
|
367
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()
|
|
368
367
|
while not self._stop:
|
|
369
368
|
with self._lock:
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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.
|
|
388
434
|
with self._lock:
|
|
389
|
-
self.
|
|
390
|
-
self._pos = 0
|
|
435
|
+
self._note_sec = note_sec
|
|
391
436
|
|
|
392
437
|
# Mirror the firmware's volume-change chirp: a short ~1970 Hz beep at the
|
|
393
438
|
# current volume, so the host confirms volume changes like the projector.
|
|
394
439
|
CHIRP_HZ = 1970
|
|
395
|
-
CHIRP_SEC = 0.030
|
|
396
440
|
|
|
397
441
|
def chirp(self) -> None:
|
|
398
442
|
if self._device is None:
|
|
399
443
|
return
|
|
400
|
-
|
|
401
|
-
peak = (max(0, min(10, load_volume())) / 10.0) * 32767 * HOST_GAIN
|
|
402
|
-
step = 2.0 * math.pi * self.CHIRP_HZ / SAMPLE_RATE
|
|
403
|
-
note = array.array("h", (int(math.sin(step * i) * peak) for i in range(n)))
|
|
404
|
-
self.play_note(note)
|
|
444
|
+
self.play_notes([self.CHIRP_HZ])
|
|
405
445
|
|
|
406
446
|
def stop(self) -> None:
|
|
407
447
|
self._stop = True
|
|
@@ -493,14 +533,11 @@ async def main() -> None:
|
|
|
493
533
|
except (NotImplementedError, RuntimeError):
|
|
494
534
|
pass
|
|
495
535
|
|
|
496
|
-
lines: asyncio.Queue = asyncio.Queue()
|
|
497
|
-
# Adaptive host pacing (see NOTE_SEC_* above): the BLE loop measures the
|
|
498
|
-
# ACK time per char after every write and EMA-smooths it here;
|
|
499
|
-
# host_auralize synthesizes at this pace so host buffers last exactly as
|
|
500
|
-
# long as the ACK window — no periodic silence while plugged in.
|
|
501
|
-
ack_pace_sec = NOTE_SEC
|
|
502
536
|
# Latest-line slots (maxsize 1, replace-on-full): each sink keeps only the
|
|
503
|
-
# most recent line
|
|
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.
|
|
504
541
|
host_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
505
542
|
ble_line: asyncio.Queue = asyncio.Queue(maxsize=1)
|
|
506
543
|
|
|
@@ -510,7 +547,20 @@ async def main() -> None:
|
|
|
510
547
|
|
|
511
548
|
auralizer = HostAuralizer()
|
|
512
549
|
|
|
550
|
+
def push_latest(q: asyncio.Queue, line: str) -> None:
|
|
551
|
+
try:
|
|
552
|
+
q.put_nowait(line)
|
|
553
|
+
except asyncio.QueueFull:
|
|
554
|
+
q.get_nowait()
|
|
555
|
+
q.put_nowait(line)
|
|
556
|
+
|
|
513
557
|
async def read_stdin() -> None:
|
|
558
|
+
# Latest-line fan-out, no intermediary queue. While connected, a line
|
|
559
|
+
# goes to ble_line only — the BLE loop hands every written chunk back to
|
|
560
|
+
# host_line so the host plays EXACTLY the bytes the projector receives
|
|
561
|
+
# (char-perfect sync). While disconnected, lines go to host_line only,
|
|
562
|
+
# so the host keeps auralizing always-on and nothing accumulates for
|
|
563
|
+
# replay on reconnect.
|
|
514
564
|
while True:
|
|
515
565
|
raw = await reader.readline()
|
|
516
566
|
if not raw:
|
|
@@ -520,35 +570,17 @@ async def main() -> None:
|
|
|
520
570
|
return
|
|
521
571
|
line = raw.decode("utf-8", "replace").rstrip("\n")
|
|
522
572
|
dbg(f"in len={len(line)}")
|
|
523
|
-
await lines.put(line)
|
|
524
|
-
|
|
525
|
-
def push_latest(q: asyncio.Queue, line: str) -> None:
|
|
526
|
-
try:
|
|
527
|
-
q.put_nowait(line)
|
|
528
|
-
except asyncio.QueueFull:
|
|
529
|
-
q.get_nowait()
|
|
530
|
-
q.put_nowait(line)
|
|
531
|
-
|
|
532
|
-
async def broadcast() -> None:
|
|
533
|
-
# Fan every line out as the LATEST line. While the projector is
|
|
534
|
-
# connected, feed only ble_line — the BLE loop hands every chunk it
|
|
535
|
-
# writes back to host_line, so the host auralizer plays EXACTLY the
|
|
536
|
-
# bytes the projector receives (char-perfect sync). While disconnected,
|
|
537
|
-
# raw lines go to host_line only, so the host keeps auralizing
|
|
538
|
-
# always-on and nothing accumulates for replay on reconnect.
|
|
539
|
-
while not stop_event.is_set():
|
|
540
|
-
line = await lines.get()
|
|
541
573
|
if connected:
|
|
542
574
|
push_latest(ble_line, line)
|
|
543
575
|
else:
|
|
544
576
|
push_latest(host_line, line)
|
|
545
577
|
|
|
546
578
|
async def host_auralize() -> None:
|
|
547
|
-
#
|
|
548
|
-
#
|
|
549
|
-
#
|
|
550
|
-
#
|
|
551
|
-
#
|
|
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).
|
|
552
584
|
auralizer.start()
|
|
553
585
|
try:
|
|
554
586
|
while not stop_event.is_set():
|
|
@@ -561,9 +593,7 @@ async def main() -> None:
|
|
|
561
593
|
continue
|
|
562
594
|
if not line:
|
|
563
595
|
continue
|
|
564
|
-
auralizer.
|
|
565
|
-
synth_message(line, load_volume(), ack_pace_sec)
|
|
566
|
-
)
|
|
596
|
+
auralizer.play_notes([_char_freq(c) for c in line])
|
|
567
597
|
finally:
|
|
568
598
|
auralizer.stop()
|
|
569
599
|
|
|
@@ -580,13 +610,32 @@ async def main() -> None:
|
|
|
580
610
|
request_stop()
|
|
581
611
|
return
|
|
582
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
|
+
|
|
583
628
|
asyncio.create_task(read_stdin())
|
|
584
|
-
asyncio.create_task(broadcast())
|
|
585
629
|
asyncio.create_task(watch_parent())
|
|
630
|
+
asyncio.create_task(watch_host_volume())
|
|
586
631
|
host_task = asyncio.create_task(host_auralize())
|
|
587
632
|
|
|
588
633
|
async def ble_loop() -> None:
|
|
589
|
-
nonlocal connected
|
|
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
|
|
590
639
|
while not stop_event.is_set():
|
|
591
640
|
try:
|
|
592
641
|
addr = await discover_address(override)
|
|
@@ -601,170 +650,138 @@ async def main() -> None:
|
|
|
601
650
|
# The projector advertises immediately on power-up but isn't ready to
|
|
602
651
|
# accept a connection until it finishes booting. Give it a moment.
|
|
603
652
|
await asyncio.sleep(CONNECT_DELAY)
|
|
604
|
-
try:
|
|
605
|
-
# When the projector resets/reboots, drop any queued text so
|
|
606
|
-
# stale lines buffered before the disconnect are not delivered
|
|
607
|
-
# on reconnect.
|
|
608
|
-
def on_disconnect(_client) -> None:
|
|
609
|
-
nonlocal connected
|
|
610
|
-
connected = False
|
|
611
|
-
dbg("disconnected")
|
|
612
|
-
for q in (host_line, ble_line):
|
|
613
|
-
try:
|
|
614
|
-
q.get_nowait()
|
|
615
|
-
except asyncio.QueueEmpty:
|
|
616
|
-
pass
|
|
617
|
-
print("disconnect", flush=True)
|
|
618
|
-
# NOTE: no `bluetoothctl disconnect` here. On Linux all
|
|
619
|
-
# clients (bridge AND the RGBify website) share ONE BlueZ
|
|
620
|
-
# ACL link, so a device-wide disconnect ejected the
|
|
621
|
-
# website every time a bridge went away. Stale writes
|
|
622
|
-
# can't survive anymore anyway — flow-control ACK means
|
|
623
|
-
# every write is fully played before its response.
|
|
624
|
-
|
|
625
|
-
async with BleakClient(addr, disconnected_callback=on_disconnect) as client:
|
|
626
|
-
# Clear anything that slipped in before the flag flipped, so
|
|
627
|
-
# delivery starts fresh with the first line after connect.
|
|
628
|
-
for q in (host_line, ble_line):
|
|
629
|
-
try:
|
|
630
|
-
q.get_nowait()
|
|
631
|
-
except asyncio.QueueEmpty:
|
|
632
|
-
pass
|
|
633
|
-
connected = True
|
|
634
|
-
dbg(f"connected {addr}")
|
|
635
|
-
print(f"ok {addr}", flush=True)
|
|
636
|
-
# Volume sync: read once on connect, subscribe to change
|
|
637
|
-
# notifications, AND poll as a fallback — the notification
|
|
638
|
-
# path has proven unobservable under flow-control load, so
|
|
639
|
-
# a cheap periodic read guarantees the host mirrors device
|
|
640
|
-
# volume changes (e.g. from the RGBify website).
|
|
641
|
-
vol_state = {"last": None}
|
|
642
|
-
|
|
643
|
-
def apply_volume(v: int, src: str, chirp: bool) -> None:
|
|
644
|
-
if v == vol_state["last"]:
|
|
645
|
-
return
|
|
646
|
-
vol_state["last"] = v
|
|
647
|
-
dbg(f"vol {src} {v}")
|
|
648
|
-
save_volume(v)
|
|
649
|
-
if chirp:
|
|
650
|
-
auralizer.chirp()
|
|
651
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):
|
|
652
661
|
try:
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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
|
|
665
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):
|
|
666
697
|
try:
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
kind, text = await wait_line_or_stop(
|
|
700
|
-
ble_line, stop_event, IDLE_CHECK_MS
|
|
701
|
-
)
|
|
702
|
-
if kind == "stop":
|
|
703
|
-
return
|
|
704
|
-
if kind == "idle":
|
|
705
|
-
if not client.is_connected:
|
|
706
|
-
raise ConnectionError(
|
|
707
|
-
"projector disconnected while idle"
|
|
708
|
-
)
|
|
709
|
-
await poll_volume()
|
|
710
|
-
continue
|
|
711
|
-
if not text:
|
|
712
|
-
continue
|
|
713
|
-
# SYNC: hand the message to the host auralizer NOW, so
|
|
714
|
-
# it starts playing the same notes at the same moment
|
|
715
|
-
# the projector does — NOT after the ACK (which would
|
|
716
|
-
# put the host one message behind).
|
|
717
|
-
push_latest(host_line, text)
|
|
718
|
-
dbg(f"host play len={len(text)}")
|
|
719
|
-
t_write = time.monotonic()
|
|
720
|
-
try:
|
|
721
|
-
await asyncio.wait_for(
|
|
722
|
-
client.write_gatt_char(
|
|
723
|
-
TEXT_BRIDGE_UUID, text.encode("utf-8"),
|
|
724
|
-
response=True,
|
|
725
|
-
),
|
|
726
|
-
timeout=WRITE_TIMEOUT,
|
|
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"
|
|
727
730
|
)
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
#
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
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)
|
|
759
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
|
|
760
776
|
connected = False
|
|
761
777
|
dbg(f"loop err {e}")
|
|
762
778
|
print(f"err {e}", flush=True)
|
|
763
779
|
await asyncio.sleep(RECONNECT_DELAY)
|
|
764
780
|
|
|
765
|
-
# Hard fallback: once a stop is requested, the graceful path
|
|
766
|
-
#
|
|
767
|
-
#
|
|
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.
|
|
768
785
|
async def watchdog() -> None:
|
|
769
786
|
await stop_event.wait()
|
|
770
787
|
await asyncio.sleep(10)
|
|
@@ -772,7 +789,18 @@ async def main() -> None:
|
|
|
772
789
|
|
|
773
790
|
asyncio.create_task(watchdog())
|
|
774
791
|
|
|
775
|
-
|
|
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)
|
|
776
804
|
|
|
777
805
|
|
|
778
806
|
if __name__ == "__main__":
|