Commit bbda3e94 by PLN (Algolia)

fix(rig): the OLED re-arm reprinted its own stale cache forever

A hard DJF swing froze the readout: the 20 Hz door dropped events without
parking one, and the 0.9 s re-arm then replayed the cached strings, so a
value the glass never caught was reprinted indefinitely — pinned at 127 the
'end stop' continue skipped the display entirely and 5.6kHz (value ~108)
stayed on screen for good. The re-arm now recomputes from self.values, so any
dropped event heals within 0.9 s whatever ate it; the door parks instead of
dropping; the flush takes the freshest value. Momentary family_mute lines
still replay — mutes_held() is transient state, not durable.
parent b513d793
...@@ -1453,12 +1453,28 @@ class Driver: ...@@ -1453,12 +1453,28 @@ class Driver:
""" """
now = time.time() now = time.time()
if now - self._last_touch < 0.05: if now - self._last_touch < 0.05:
# The throttle stays — what changes is that it no longer DISCARDS.
# This door used to `return` outright, unlike the 10 Hz cap in
# _touch_apply which parks: during a fast sweep the event it ate
# could be the LAST one, and then the cached lines were stale with
# nothing left to correct them. Park the newest (v2, value) in the
# same slot _touch_apply uses and let tick_overlay flush it once
# the write window reopens, so a swallowed trailing value costs
# one tick, not the 0.9 s heal.
self._oled_pending = (v2, value)
return return
self._last_touch = now self._last_touch = now
self._touch_apply(v2, value, now) self._touch_apply(v2, value, now)
def _touch_apply(self, v2: int, value: int, now: float) -> None: def _lines_for(self, v2: int, value: int) -> tuple[str, str, str]:
"""Compute a touch's lines and get them onto the glass (or not).""" """The three overlay lines a control's value means, right now.
Lifted out of _touch_apply so the 0.9 s re-arm in tick_overlay can
DERIVE lines from current state instead of replaying its own cache.
One copy of the branch logic, so the touch path and the heal path
cannot drift apart — a duplicated branch here would heal to a
different screen than the touch drew, which is worse than not healing.
"""
role, _who = self.grid.CC_ROLE.get(v2, ("", 0)) role, _who = self.grid.CC_ROLE.get(v2, ("", 0))
if self.tempo and v2 == self.tempo[0]: if self.tempo and v2 == self.tempo[0]:
# The tempo knob gets BPM, a landmark arrow, and — while a mute is # The tempo knob gets BPM, a landmark arrow, and — while a mute is
...@@ -1472,6 +1488,11 @@ class Driver: ...@@ -1472,6 +1488,11 @@ class Driver:
lines = self.lang.mute_lines(self.mutes_held()) lines = self.lang.mute_lines(self.mutes_held())
else: else:
lines = self.lang.touch_lines(v2, value, self.ctx.get(v2)) lines = self.lang.touch_lines(v2, value, self.ctx.get(v2))
return lines
def _touch_apply(self, v2: int, value: int, now: float) -> None:
"""Compute a touch's lines and get them onto the glass (or not)."""
lines = self._lines_for(v2, value)
# If the firmware owns this control's screen, sending our overlay is the # If the firmware owns this control's screen, sending our overlay is the
# ownership battle itself — two writers, one 128x64 panel, and the one # ownership battle itself — two writers, one 128x64 panel, and the one
# with the hardware event loop wins. Keep the ghost honest and stand down. # with the hardware event loop wins. Keep the ghost honest and stand down.
...@@ -1530,13 +1551,20 @@ class Driver: ...@@ -1530,13 +1551,20 @@ class Driver:
_oled_up_at never goes stale. _oled_up_at never goes stale.
Also flushes a rate-capped sweep's final value: the pending (v2, value) Also flushes a rate-capped sweep's final value: the pending (v2, value)
parked by _touch_apply is re-derived from current state, so what lands parked by either throttle (oled_touch's 20 Hz door or _touch_apply's
is the newest, not the queued. 10 Hz cap) is re-derived from current state, so what lands is the
newest, not the queued. And the re-arm itself is self-healing — see
the recompute note below the staleness gates.
""" """
if self._oled_pending is not None and now - self._oled_write_at >= 0.1: if self._oled_pending is not None and now - self._oled_write_at >= 0.1:
v2, value = self._oled_pending v2, value = self._oled_pending
self._oled_pending = None self._oled_pending = None
self._touch_apply(v2, value, now) # The parked value is only a note of WHICH control to re-render;
# the value to render is whatever self.values holds now, which may
# be several counts further along the sweep. (The docstring above
# always claimed this re-derivation — now it does it. `value` is
# kept as the fallback for a control we hold no state for.)
self._touch_apply(v2, self.values.get(v2, value), now)
if getattr(self, "_last_cc", None) is None: if getattr(self, "_last_cc", None) is None:
return return
if self._oled_owner != "driver" or not any(self._oled_lines): if self._oled_owner != "driver" or not any(self._oled_lines):
...@@ -1546,7 +1574,43 @@ class Driver: ...@@ -1546,7 +1574,43 @@ class Driver:
# Forced re-summon, unconditionally: the firmware may have already # Forced re-summon, unconditionally: the firmware may have already
# timed the overlay out, and the dedup cache only knows what WE last # timed the overlay out, and the dedup cache only knows what WE last
# sent, not what the glass still shows. # sent, not what the glass still shows.
self._overlay_send(self._oled_lines, now, force=True) #
# And the re-arm RECOMPUTES rather than replaying self._oled_lines.
# Replaying is what turned a single dropped event into a permanent lie
# (PLN, 2026-09-22): a hard DJF swing left "HPF 5.6kHz" on the glass
# and the re-arm reprinted it forever. The readout is not quantised —
# 127 is HPF 8.0kHz and each count moves it ~0.13kHz — so a frozen
# string can only mean the DISPLAYED value stopped tracking the real
# one. Three gates can eat a sweep's last event (this file's two rate
# caps, and run()'s "pinned at an end stop" continue, which skips
# oled_touch entirely), and a cache-replaying re-arm heals none of
# them. Deriving from self.values heals all three within 0.9 s,
# whichever one did the eating.
#
# The asymmetry is about what kind of state the lines are made of:
# DURABLE state RECOMPUTES, MOMENTARY state REPLAYS. A DJF/fx/fader
# value and the tempo still are what they were, and self.values holds
# them. A family_mute overlay is drawn from mutes_held(), which is the
# set of buttons DOWN — at rest nothing is held, so recomputing would
# wipe the mute readout with an empty one. For a momentary role the
# cached lines are the only record of the gesture; keep replaying them.
cc = self._last_cc
role, _who = self.grid.CC_ROLE.get(cc, ("", 0))
lines = self._oled_lines
if role != "family_mute":
cur = self.values.get(cc)
if cur is not None:
lines = self._lines_for(cc, cur)
if role == "family_filter" \
and lines[1] in self.lang.ZERO_FRAMES \
and self._oled_lines[1] in self.lang.ZERO_FRAMES:
# Both the recompute and the cache say this DJF is at its
# zero, so they do not disagree about the VALUE — only
# about which of tick_zero_anim's frames is up. Keep the
# live frame: recomputing to frame 0 every 0.9 s would
# restart a 4-frame 3 fps cycle that never finishes.
lines = (lines[0], self._oled_lines[1], lines[2])
self._overlay_send(lines, now, force=True)
# ---------------------------------------------------------- the ghost ---- # ---------------------------------------------------------- the ghost ----
# "can you pull state of oled?" — the protocol has NO display readback, so # "can you pull state of oled?" — the protocol has NO display readback, so
......
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