Commit 3a91eba0 by PLN (Algolia)

feat(lcxl3): the d9-12 level rings learn what Ardour holds

The driver's own startup line said the gap out loud: "row A stays absolute:
A1-A4 are Ardour's, we hold no truth for them". So those four rings sat at a
flat rest colour while every other cell painted its value.

Two sources close it, and they are complementary rather than redundant:

1. A forwarded CC is an observation. When PLN moves A1-A4 the driver TRANSLATES
   that value on its way to Ardour, so at that instant it knows what the fader
   holds. It always recorded it in self.values; only ring_colour's
   Ardour-owned branch threw it away. Now it also marks the cc observed, and
   the ring breathes by audibility from the first touch.

2. Ardour echoes what it did NOT get from us. `<Protocol name="Generic MIDI"
   feedback="1" motorized="1" active="1">` was already on, and Ardour will not
   echo a change back to the surface that caused it — so source 1 covers the
   hand on the knob and source 2 covers the GUI, the mouse, automation and a
   session load, which is exactly where source 1 goes stale.

The feedback leg needed its own port. The driver already sends into Midi
Through (14:0) and Ardour's Generic MIDI reads from there, so listening on that
loopback would have fed the driver its own CCs back: no loop is possible now
because the send path and the echo path share nothing. And it is the one
binding here that lives in JACK rather than ALSA, because Ardour's control port
is a JACK port and only PipeWire's bridge makes ours visible there — so the
bridged name, which carries a playback index nobody should guess, is DISCOVERED
by suffix on every pass and re-asserted like every other binding on this rig.

Verified on the live rig, Ardour closed then reopened: the port publishes
(client 132 'ParVagues LCXL3 FB'), the bridge exposes it, the reconciler finds
and links it unaided, and it re-links within 5 s of a driver restart. Absent
Ardour it stays silent and returns False, costing nothing but the rest colour.
parent 07bb4230
......@@ -72,6 +72,12 @@ except ImportError: # pragma: no cover
GRID = None
VIRTUAL_PORT_NAME = "ParVagues LCXL3"
# The leg Ardour uses to TELL US where its faders are. A SECOND port on purpose:
# the driver already sends into Midi Through (14:0) and Ardour's Generic MIDI
# reads from there, so listening on that same loopback would feed the driver its
# own CCs back. One dedicated destination, no loop by construction.
FEEDBACK_PORT_NAME = "ParVagues LCXL3 FB"
ARDOUR_FB_SOURCE = "ardour:MIDI Control Out"
# In relative mode the guide shifts each encoder's CC by +64. We arm rows B and
# C ONLY (21-36 -> 85-100). Row A stays ABSOLUTE deliberately: A1-A4 are
......@@ -732,6 +738,80 @@ def prune(protect: set[str], verbose: bool = False,
return cut
def _jack_ports(pattern: str = "") -> list[str]:
"""JACK port names containing `pattern`. Via pw-jack: this rig's JACK IS
PipeWire, and a bare jack_lsp resolves jackd2's absent server instead."""
try:
r = subprocess.run(["pw-jack", "jack_lsp"], capture_output=True,
text=True, timeout=6)
except (OSError, subprocess.SubprocessError):
return []
if r.returncode != 0:
return []
return [ln.strip() for ln in (r.stdout or "").splitlines()
if pattern in ln and ln.strip()]
def _jack_conns(port: str) -> set[str]:
"""What `port` is connected to. `jack_lsp -c` prints a port flush-left and
each of its connections indented — parsed, never eyeballed: reading that
indentation by grep is how you get a confidently wrong port report."""
try:
r = subprocess.run(["pw-jack", "jack_lsp", "-c"], capture_output=True,
text=True, timeout=8)
except (OSError, subprocess.SubprocessError):
return set()
cur, out = None, set()
for ln in (r.stdout or "").splitlines():
if not ln.strip():
continue
if ln[0].isspace():
if cur == port:
out.add(ln.strip())
else:
cur = ln.strip()
return out
def reconcile_feedback(verbose: bool = False) -> bool:
"""Hold the leg on which Ardour reports its fader gains.
With `midi-feedback` on, Ardour echoes CC on ARDOUR_FB_SOURCE using the
bindings it already learned — 13-16 for the d9-12 level KNOBS, 77-84 for the
d1-8 faders. Those are the very numbers this driver speaks, so the level
rings can paint AUDIBILITY instead of guessing at it, with no second
protocol and no new dependency.
This leg lives in JACK, unlike every other binding here, because Ardour's
control port is a JACK port and only PipeWire's bridge makes our ALSA one
visible there. The bridged name carries a playback index that must never be
guessed, so the destination is DISCOVERED by suffix on every pass — and
re-asserted, like all bindings on this rig, rather than assumed.
Silent and False when Ardour is down: the rig plays fine without this, and
a missing feed must cost nothing but the flat rest colour on four rings.
"""
dst = next(iter(_jack_ports(FEEDBACK_PORT_NAME)), None)
if dst is None:
if verbose:
print(f" feedback: '{FEEDBACK_PORT_NAME}' not visible in JACK yet")
return False
if not _jack_ports(ARDOUR_FB_SOURCE):
if verbose:
print(" feedback: Ardour is down (no MIDI Control Out) — nothing to hold")
return False
if dst in _jack_conns(ARDOUR_FB_SOURCE):
if verbose:
print(" feedback: already linked")
return True
r = subprocess.run(["pw-jack", "jack_connect", ARDOUR_FB_SOURCE, dst],
capture_output=True, text=True, timeout=6)
ok = r.returncode == 0 or dst in _jack_conns(ARDOUR_FB_SOURCE)
print(f" feedback: {'linked' if ok else 'FAILED'} {ARDOUR_FB_SOURCE} -> {dst}"
+ ("" if ok else f": {(r.stderr or '').strip() or r.returncode}"))
return ok
def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool:
"""Wire our virtual port into SC's MIDI input, then cut the raw ones.
......@@ -777,6 +857,14 @@ class Driver:
f"lcxl3-driver: could not create the virtual port ({e}).\n"
"python-rtmidi with the ALSA backend is required."
)
# Input, not output, and separate from self.virt (which is send-only).
# A failure here must never kill the driver: the rig plays without the
# feed, it just cannot colour the four Ardour-owned rings by truth.
self.fb = None
try:
self.fb = mido.open_input(FEEDBACK_PORT_NAME, virtual=True)
except Exception as e:
print(f" feedback: no port ({e}) — level rings stay at rest colour")
self.out_channel = args.channel - 1
self._stop = False
......@@ -1177,6 +1265,32 @@ class Driver:
role, _ = self.grid.CC_ROLE.get(v2, ("", 0))
return 64 if role == "family_filter" else 0
def drain_feedback(self) -> None:
"""Ardour telling us where its faders are. STATE ONLY, never re-emitted.
The one inbound stream that must not be forwarded: it came FROM Ardour,
so sending it on would close a control loop. It also deliberately skips
self.latch and oled_touch — PLN's hands did not move anything, so
nothing may flash under them.
Only ARDOUR_CCS are taken. Ardour echoes what it learned, and anything
else arriving here would be a binding we do not understand; recording it
as a control value would be inventing state.
"""
if self.fb is None or self.lang is None:
return
for msg in self.fb.iter_pending():
if msg.type != "control_change":
continue
if msg.control not in self.grid.ARDOUR_CCS:
continue
self.values[msg.control] = msg.value
self.ardour_seen.add(msg.control)
self.paint_cell(msg.control)
if self.args.verbose:
print(f" ardour -> v2 #{msg.control:<3} = {msg.value} "
f"({self.grid.label(msg.control)})")
def paint_cell(self, v2: int) -> None:
v3 = self.v2_to_v3.get(v2)
if v3 is None or 5 <= v3 <= 12:
......@@ -1437,6 +1551,8 @@ class Driver:
pass
try:
self.virt.close()
if self.fb is not None:
self.fb.close()
except Exception:
pass
self.surface.close()
......@@ -1556,6 +1672,21 @@ class Driver:
self.stats["out"] += 1
if self.lang:
self.values[v2] = msg.value
if v2 in self.grid.ARDOUR_CCS:
# We just SENT this value to Ardour, so at this instant
# we do know what the fader holds — and knowing it is
# what lets the d9-12 level rings breathe by audibility
# instead of sitting at the rest colour forever.
#
# Deliberately not the same claim as "we own it". It can
# go stale the moment something else moves that fader
# (the GUI, a mouse, automation, a session load), which
# is precisely the hole reconcile_feedback() plugs:
# Ardour echoes changes it did not get from us. It will
# not echo THIS one — a surface does not get told what
# it just said — so the two sources are complementary
# rather than redundant, and neither alone is enough.
self.ardour_seen.add(v2)
self.paint_cell(v2)
self.oled_touch(v2, msg.value)
if self.args.verbose:
......@@ -1563,6 +1694,7 @@ class Driver:
f" {L.control_name(msg.control):<16} "
f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
)
self.drain_feedback()
now = time.time()
if now - last_recon > self.args.reconcile_secs:
# The BOARD link first, then the SC link. Deliberately on the
......@@ -1573,6 +1705,7 @@ class Driver:
# reported healthy all through the replug that killed the LEDs.
if board_link_alive() is not True:
self.relink_board()
reconcile_feedback(verbose=self.args.verbose)
reconcile(verbose=self.args.verbose,
cut_thru=not self.args.keep_thru)
last_recon = now
......
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