Commit cd5c46bb by PLN (Algolia)

docs(tasks): archive #71/#72/#53 — the LED lag was a representation bug, and it…

docs(tasks): archive #71/#72/#53 — the LED lag was a representation bug, and it had a twin in the Bridge
parent 9647e446
......@@ -571,3 +571,106 @@ hardware — plus `value_ramp()`, PLN's six-step convention, and 8 regression te
findings above are now its open questions) and #71 (latency: the LEDs work, but lag 1-2 s
behind fast fader moves, because every changed LED costs a `subprocess` fork+exec inside
the reader loop).
---
## #71 — LCXL LEDs lag 1-2s behind fast fader moves
**Description.** First night the LED feedback ran on real hardware, PLN's verdict was
"its great tbh i see also filters and effects working as expected :) but! its slow to
track if i move all fders quickly i see the animations lagging a sec or two behind".
**Done.** `128de62`. A `Painter` thread now owns the wire; the aseqdump reader only touches
the model. Dedupe on COLOUR, coalesce per frame, batch all dirty indices into one SysEx,
and cache port resolution for 2 s (dropped on any send failure). Added `--bench`, and
`tools/at/tests/test_lcxl_latency.py` (12 tests).
**Learnings.**
- *The bug was not what it looked like.* It looked like a slow device or a slow transport.
It was that we deduped on the CC **value** while colour is a **step function** of value —
six steps in `value_ramp`, seven in `filter_colour`. A 128-event fader sweep can change
the board at most 6 times; we were asking the wire for ~20x the work that could possibly
be visible. Fixing the *representation* mattered more than fixing the I/O.
- *Three forks per LED.* Every `send()` re-resolved the port with `aconnect -o` AND
`amidi -l` before writing — ~1.7 ms each, measured. The uncached lookup was a deliberate
earlier fix (a replug had moved the device 20:0 → 24:0), so the cache had to keep that
property: cache for 2 s, and invalidate **on evidence** (a failed write) rather than on a
timer. Real cost fell 7.70 ms → 2.72 ms per write.
- *A pipe queues, it does not drop.* All the I/O ran inside `for line in proc.stdout`, so a
write in flight blocked reading the next MIDI event. Nothing was lost — everything simply
arrived later, without bound. That unboundedness is why PLN perceived "a second or two"
rather than a fixed delay. Latency that grows is a different diagnosis from latency that
is constant.
- *Numbers, replaying the actual complaint.* 8 knobs + 8 faders swept together at 500
events/s, transport modelled at its measured cost: legacy 744 wire msgs, 3 s of input
taking 11.23 s (**+8.23 s overrun**); coalesced 48 msgs, +0.00 s, p50 7.9 ms / p99
20.2 ms. Overrun *is* the visible lag — how far behind his hands the board finishes.
- *Rate-limit as a floor, not a tick.* Limiting the GAP between writes (rather than
painting on a fixed clock) keeps an isolated button press instant while still bounding
the burst rate. A fixed tick would have added latency to the common case.
- *The test that matters is the correctness one.* Fast-and-wrong beats nothing, so the
suite decodes every SysEx the coalescer emitted through `mock-lcxl` and asserts the board
that LANDS equals a full `build_frame` recomputation.
**Deps.** Followed #58 (which only became possible once LEDs were verified on hardware at
all). Surfaced #72. Its `Painter` became the template for the Bridge fix.
---
## #72 — Bridge MIDI stream dropped the NEWEST event
**Description.** Not reported by anyone — found by sweeping the rest of the gear for #71's
bug class, on the theory that a bug shaped like that rarely lives alone.
**Done.** `1c9df10`. Drop-OLDEST on a full subscriber queue; new `midistream.coalesce()`;
SSE batched into one write+flush per frame. Extracted `_fanout` for testability. 8 new
tests, 32 green in `tools/bridge`.
**Learnings.**
- *`except queue.Full: pass` is a silent lie.* It keeps 512 stale events and discards the
one that just happened. For MIDI **state** the newest event IS the truth, so a dashboard
tab that stalled for ~1.3 s at 400 CC/s would show frozen values for the rest of the set
with nothing logged. Exactly #71's signature: nothing breaks, it just quietly stops
telling you the truth.
- *State vs events is the whole design.* Continuous controls (CC, pitchbend, aftertouch)
may be superseded by a newer value; notes may never be dropped. A fast monitor that loses
a note is strictly worse than a slow one — so the tests assert every note survives a
flood of 100 controller messages.
- *Preserve chronology.* A superseded value is overwritten **where it stood**, not moved to
the end, so the monitor still reads as a timeline.
- *Wire format untouched.* Batching emits one `data:` line per event as before, so the
browser needed no change — a perf fix with a zero-diff client.
- *Green tests on `parse_line` proved nothing about the queue behind it.* Had to extract a
seam to test the drop policy at all. Same lesson as `feedback_verify_the_plumbing`.
**Deps.** Discovered by #71's sweep. Feeds #25/#42 (the Bridge as cockpit).
---
## #53 — Make master gain fixed pre-set, not live-MIDI (CC77)
**Description.** `midiGGlobal = orDef 0.769 "^77" * 1.3` in BootTidal.hs put a global gain
on one physical fader.
**Done.** `9647e44`. Now `midiGGlobal = 1.0`. Verified with `tools/check-boot.sh`: helper
block typechecks against tidal-1.9.5 under `ghc -fno-code`, the #55 seed block typechecks,
and all 13 g* helpers (incl. `midiGdef`, the one this edit could plausibly break) still
yield events against an EMPTY control map.
**Learnings.**
- *Two independent reasons, and the second was the payoff.* Safety: brushing fader 1 in the
dark attenuated every midiG-using stream at once, silently. But the unlock was
ergonomic — Tidal reading `^77` is what kept the whole surface one lane off. Killing it
freed fader 1 and made fader N → dN possible (#46). A safety fix that also buys a feature
is worth looking for.
- *Make the refactor provably inaudible.* The old untouched default evaluated to
`0.769 * 1.3 = 0.9997`, so 1.0 moves every track by 0.003 dB. Choosing the constant to
match the old default meant no track needed re-levelling.
- *Do not delete a definition the corpus calls.* PLN believed he no longer used `midiG`, and
behaviourally he was right — with the global term gone it reduces to plain `gain (range
lo hi cc)`. But `midiG'` appears in 167 files and `midiG` in 12, and deleting a referenced
definition is a Haskell compile error, which takes the whole `let` block down and silences
the entire track. Retiring the *name* is #73, post-gig, and the ORDER matters: rewrite the
call sites first, delete last. Reversed, it is a 179-track mute-bomb.
**Deps.** Unblocked #46. Spawned #73.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment