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.

@@ -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,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 firmware holds the ACK until a
87
- # whole message plays (~8 chars * 33ms = ~264ms), so this must be comfortably
88
- # above that but still short enough that a wedged link is torn down quickly.
89
+ # 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 = -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
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 into this variable;
105
- # host_auralize synthesizes at that pace so host buffers last exactly as long
106
- # as the ACK window. Clamped to sane bounds; falls back to nominal when cold.
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. Plays one ~33ms note at a time; a newer note
326
- interrupts (replaces) the previous one, so the last message is the only
327
- message on the host too."""
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._note = None # array.array int16 mono of the current note (latest wins)
333
- 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)
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
- # miniaudio pull model: each yield returns the number of frames the
366
- # 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.
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
- note = self._note
371
- pos = self._pos
372
- if note is None or pos >= len(note):
373
- data = array.array("h", [0]) * required # rest
374
- else:
375
- take = min(len(note) - pos, required)
376
- data = note[pos : pos + take]
377
- with self._lock:
378
- self._pos = pos + take
379
- if take < required:
380
- data = data + array.array("h", [0]) * (required - take)
381
- with self._lock:
382
- self._note = None
383
- required = yield data.tobytes()
384
-
385
- def play_note(self, note: "array.array") -> None:
386
- # Latest wins: a note pushed while the previous one is playing replaces
387
- # it at the next device buffer boundary.
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._note = note
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
- n = int(SAMPLE_RATE * self.CHIRP_SEC)
401
- peak = (max(0, min(10, load_volume())) / 10.0) * 32767 * HOST_GAIN
402
- step = 2.0 * math.pi * self.CHIRP_HZ / SAMPLE_RATE
403
- note = array.array("h", (int(math.sin(step * i) * peak) for i in range(n)))
404
- self.play_note(note)
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, 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.
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
- # Play the whole message as ONE continuous PCM buffer. The miniaudio
548
- # device's own sample clock paces it exactly one ~33ms note per char,
549
- # matching the firmware's 30fps cadence with no drift. A newer message
550
- # replaces the playing one at the next device buffer boundary (latest
551
- # wins), so the host always plays the same notes as the projector.
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.play_note(
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, ack_pace_sec
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
- value = await client.read_gatt_char(VOLUME_UUID)
654
- if value:
655
- vol_state["last"] = value[0]
656
- save_volume(value[0])
657
- dbg(f"vol connect {value[0]}")
658
- except Exception as e:
659
- dbg(f"vol read err {e}")
660
-
661
- def on_volume_changed(_handle, data: bytes) -> None:
662
- if data:
663
- dbg(f"vol notify {data[0]}")
664
- apply_volume(data[0], "notify", True)
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
- await client.start_notify(VOLUME_UUID, on_volume_changed)
668
- dbg("vol notify subscribed")
669
- except Exception as e:
670
- dbg(f"vol notify FAILED: {e}")
671
-
672
- last_vol_check = 0.0
673
-
674
- async def poll_volume() -> None:
675
- # Fallback for lost notifications: rate-limited to one
676
- # read per VOL_POLL_SEC; applies silently (the device
677
- # already chirped at change time).
678
- nonlocal last_vol_check
679
- now = time.monotonic()
680
- if now - last_vol_check < VOL_POLL_SEC:
681
- return
682
- last_vol_check = now
683
- try:
684
- value = await client.read_gatt_char(VOLUME_UUID)
685
- if value:
686
- apply_volume(value[0], "poll", False)
687
- except Exception as e:
688
- dbg(f"vol poll err {e}")
689
- # WHOLE-MESSAGE DELIVERY, ACK-GATED: each line (the last
690
- # 8 chars of a coalesced delta burst) is written in ONE
691
- # write with response=True. The firmware holds the ACK
692
- # until every char has been played, so the write resolves
693
- # exactly when playback finishes — nothing can buffer in
694
- # either BLE stack. Lines that arrive DURING playback are
695
- # NOT discarded: they chain immediately after the ACK, so
696
- # notes play back-to-back for as long as text keeps
697
- # streaming.
698
- while True:
699
- kind, text = await wait_line_or_stop(
700
- ble_line, stop_event, IDLE_CHECK_MS
701
- )
702
- if kind == "stop":
703
- return
704
- if kind == "idle":
705
- if not client.is_connected:
706
- raise ConnectionError(
707
- "projector disconnected while idle"
708
- )
709
- await poll_volume()
710
- continue
711
- if not text:
712
- continue
713
- # SYNC: hand the message to the host auralizer NOW, so
714
- # it starts playing the same notes at the same moment
715
- # the projector does — NOT after the ACK (which would
716
- # put the host one message behind).
717
- push_latest(host_line, text)
718
- dbg(f"host play len={len(text)}")
719
- t_write = time.monotonic()
720
- try:
721
- await asyncio.wait_for(
722
- client.write_gatt_char(
723
- TEXT_BRIDGE_UUID, text.encode("utf-8"),
724
- response=True,
725
- ),
726
- timeout=WRITE_TIMEOUT,
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
- except asyncio.TimeoutError:
729
- # Firmware wedged / connection stalled: drop the
730
- # link and reconnect rather than freeze both sinks.
731
- dbg("write TIMEOUT")
732
- raise ConnectionError("write ACK timed out")
733
- except Exception as e:
734
- # Transient/failed write: move on; the next delta
735
- # starts fresh.
736
- dbg(f"write err {e}")
737
- continue
738
- ack_ms = (time.monotonic() - t_write) * 1000.0
739
- dbg(f"ack {ack_ms:.0f}ms")
740
- # Adaptive host pacing: learn the firmware's REAL
741
- # per-char playback time from this ACK so host buffers
742
- # last exactly as long as the ACK window.
743
- inst = min(NOTE_SEC_MAX, max(
744
- NOTE_SEC_MIN, (ack_ms / 1000.0) / max(1, len(text))
745
- ))
746
- ack_pace_sec += ACK_PACE_EMA * (inst - ack_pace_sec)
747
- print("ok", flush=True)
748
- # Fallback volume poll (rate-limited to VOL_POLL_SEC):
749
- # catches webapp changes even if a notification is
750
- # lost. Cheap monotonic-time check when under rate.
751
- await poll_volume()
752
- # NO flush here: anything that arrived during playback
753
- # stays queued and plays immediately on the next loop
754
- # iteration — notes chain back-to-back for as long as
755
- # deltas keep coming ("keep playing until the next
756
- # delta arrives"). The maxsize-1 queue bounds lag at
757
- # ~one extra message; when deltas stop, the last one
758
- # plays out and the auralizers go quiet.
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 normally
766
- # disconnects within a few seconds (the idle wait is up to IDLE_CHECK_MS),
767
- # but never let the bridge linger as an orphan.
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
- 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)
776
804
 
777
805
 
778
806
  if __name__ == "__main__":