Commit fa22bb85 by PLN (Algolia)

fix(lcxl3): the driver had never connected anything to SuperCollider

Resume point 1 was "run lcxl3-driver against a live SuperCollider, because it
never has been". It has now, and the run found two defects that no amount of
reading could have found — both invisible precisely because the code reported
success.

DEFECT 1 — the aconnect direction trap. reconcile() resolved its destination
with `_clients("-i")`:

    dst = next((p for p, n in _clients("-i") if "SuperCollider" in n), None)

`aconnect -i` lists ports you can read FROM. SuperCollider appears in both
directions: in0..in4 are destinations, out0..out6 are sources. So `-i` returned
SC's *output* port — 130:5 on this rig — and the driver ran

    aconnect ParVagues LCXL3:0  130:5

which writes into an output-only port and fails. The CompletedProcess was
discarded, so the failure had no way to be seen, and the next line printed
"reconcile: src -> dst" as though the link existed. Every previous reasoning
step about this driver rested on a connection that was never made.

Fixed by resolving destinations from `-o` via a new _sc_input_ports(), and by
checking the return code and PRINTING the failure. A reconciler that cannot say
"I failed" is decoration — the rig has been bitten by this shape before
(feedback_verify_the_plumbing, reference_check_the_instrument_first).

DEFECT 2 — reconcile only ever ADDED links, so it could not be correct here.
`MIDIIn.connectAll` subscribes SC to every source present at boot. Measured on
the live rig:

    14:0  Midi Through  -> 130:2
    20:0  LCXL3 1 MIDI  -> 130:3
    20:1  LCXL3 1 DAW   -> 130:4

So the moment the driver published a correct, translated stream, SC was hearing
it ALONGSIDE two raw ones. This is worse than duplication: v3's DAW map
collides with the corpus's v2 numbers on 16 indices with different meanings, so
row C knob 4 would drive ^52 (right) and ^32 (row B's cell) at once — two
orbits moving when one knob turns. And a doubled CC is fatal on a latch: the
toggle fires twice and cancels, which reads from the stage as "the button does
nothing".

Added prune(), which cuts every path into SC that is not our virtual port,
re-asserted on the same timer as the connect (the binding must be re-asserted,
never assumed — feedback_stale_binding_pattern). Midi Through is cut by default
because midi-autoconnect's rule 1 pushes the surface into it, i.e. it is a
second copy of the same stream; --keep-thru opts out.

Added `--graph`, which prints what actually feeds SC and what prune would do.
That is the diagnostic whose absence let defect 1 hide: the driver's own view
of the graph was never printable, so it could never be checked against reality.

Verified on the live rig (sclang 845344 / scsynth 845718, LCXL3 on client 20):
--graph now resolves 130:0-130:4 and correctly classifies 3 WOULD CUT paths and
PipeWire-RT-Event's monitor bridge as left alone. Hardware also confirms the
translation table's row D: PLN reports fader 1 = CC5, which is V3_ROWS["D"][0].
parent 0a7475b2
...@@ -150,26 +150,107 @@ def _clients(direction: str) -> list[tuple[str, str]]: ...@@ -150,26 +150,107 @@ def _clients(direction: str) -> list[tuple[str, str]]:
return res return res
def reconcile(verbose: bool = False) -> bool: def _sc_input_ports() -> list[str]:
"""Wire our virtual port into SuperCollider's MIDI input, idempotently.""" """SuperCollider's own MIDI *destination* ports, e.g. ['130:0', '130:1', ...].
THE DIRECTION TRAP, and it bit the original reconcile() silently.
`aconnect -i` lists ports you can read FROM (sources); `aconnect -o` lists
ports you can write TO (destinations). SuperCollider appears in BOTH: its
in0..inN are destinations, its out0..outN are sources. Asking `-i` for
"SuperCollider" therefore returns out0 (130:5 here), and
`aconnect <virt> 130:5` writes into an output-only port. It fails — and
because the original code discarded the CompletedProcess it then printed
"reconcile: src -> dst" as if it had worked. The driver had never
connected anything to SC in its life.
"""
return [p for p, n in _clients("-o") if "SuperCollider" in n]
def _sources_feeding(dst: str) -> list[tuple[str, str]]:
"""[(src_port, src_client_name)] currently connected INTO dst.
Parsed off the SOURCE side ("Connecting To: ...") because that is the only
place `aconnect -l` prints an edge with both of its ends.
"""
res, cur_client, cur_name, port = [], None, "", None
for line in _aconnect_pairs().splitlines():
if line.startswith("client "):
cur_client = line.split()[1].rstrip(":")
parts = line.split("'")
cur_name = parts[1].strip() if len(parts) > 1 else ""
port = None
elif cur_client and line.strip() and line[0] in " \t":
tok = line.strip().split()
if tok and tok[0].isdigit():
port = f"{cur_client}:{tok[0]}"
elif "Connecting To:" in line and port:
for t in line.split("Connecting To:")[1].split(","):
if t.strip().split("[")[0].strip() == dst:
res.append((port, cur_name))
return res
# Clients whose traffic must NOT reach SuperCollider while we translate.
# These deliver the surface's RAW v3 numbers, which collide with the corpus's v2
# numbers on 16 indices with a DIFFERENT meaning — so leaving them connected does
# not merely duplicate, it fires the WRONG control alongside the right one. Row C
# knob 4 would drive both ^52 (correct) and ^32 (row B's cell).
RAW_CLIENTS = ("LCXL3", "Launch Control XL")
# Midi Through carries whatever midi-autoconnect's rule 1 pushes into it — the
# surface again, i.e. a second copy of the same stream. A doubled CC is survivable
# on a knob and fatal on a latch: the toggle fires twice and cancels itself, which
# reads from the stage as "the button does nothing".
THRU_CLIENTS = ("Midi Through",)
def prune(verbose: bool = False, cut_thru: bool = True) -> list[str]:
"""Cut every path into SuperCollider that is not our translated port.
WHY THIS EXISTS — found against a LIVE SuperCollider, 2026-08-29
`MIDIIn.connectAll` subscribes SC to EVERY source present at boot. On
this rig that was in2=Midi Through, in3=LCXL3 MIDI In, in4=LCXL3 DAW In.
So the moment the driver published a correct translated stream, SC heard
it ALONGSIDE two raw ones. reconcile() only ever added links, so the
driver could not have been correct here however right its translation
was. This is the half that only a live SC could show.
"""
cut = []
bad = RAW_CLIENTS + (THRU_CLIENTS if cut_thru else ())
for dst in _sc_input_ports():
for src, name in _sources_feeding(dst):
if VIRTUAL_PORT_NAME in name:
continue
if any(b in name for b in bad):
subprocess.run(["aconnect", "-d", src, dst],
capture_output=True, timeout=3)
cut.append(f"{src} '{name}' -> {dst}")
if verbose:
print(f" prune: cut {src} '{name}' -> {dst}")
return cut
def reconcile(verbose: bool = False, cut_thru: bool = True) -> bool:
"""Wire our virtual port into SC's MIDI input, and cut the raw ones."""
src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None) src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None)
if src is None: if src is None:
src = next((p for p, n in _clients("-o") if VIRTUAL_PORT_NAME in n), None) src = next((p for p, n in _clients("-o") if VIRTUAL_PORT_NAME in n), None)
dst = next((p for p, n in _clients("-i") if "SuperCollider" in n), None) dst = next(iter(_sc_input_ports()), None) # -o, NOT -i: see the note there
if not src or not dst: if not src or not dst:
if verbose: if verbose:
print(f" reconcile: src={src} dst={dst} — nothing to do yet") print(f" reconcile: src={src} dst={dst} — nothing to do yet")
return False return False
graph = _aconnect_pairs() r = subprocess.run(["aconnect", src, dst], capture_output=True, text=True, timeout=3)
if f"{dst}" in graph and src.split(":")[0] in graph: err = (r.stderr or "").strip()
pass # cheap pre-check only; aconnect is idempotent anyway ok = r.returncode == 0 or "already subscribed" in err.lower()
try: if not ok:
subprocess.run(["aconnect", src, dst], capture_output=True, timeout=3) # Report it. A reconciler that cannot say "I failed" is decoration.
if verbose: print(f" reconcile: FAILED {src} -> {dst}: {err or r.returncode}")
print(f" reconcile: {src} -> {dst}")
return True
except Exception:
return False return False
if verbose:
print(f" reconcile: {src} -> {dst}"
+ (" (already)" if "already" in err.lower() else ""))
prune(verbose=verbose, cut_thru=cut_thru)
return True
# -------------------------------------------------------------------- driver ---- # -------------------------------------------------------------------- driver ----
...@@ -203,7 +284,7 @@ class Driver: ...@@ -203,7 +284,7 @@ class Driver:
self.surface.relative(row, True) self.surface.relative(row, True)
print(" encoder rows 1-3 -> RELATIVE (deltas; values are ours to hold)") print(" encoder rows 1-3 -> RELATIVE (deltas; values are ours to hold)")
print(f"virtual port published: {VIRTUAL_PORT_NAME!r}") print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
reconcile(verbose=True) reconcile(verbose=True, cut_thru=not self.args.keep_thru)
def stop(self) -> None: def stop(self) -> None:
if self.args.relative: if self.args.relative:
...@@ -269,7 +350,8 @@ class Driver: ...@@ -269,7 +350,8 @@ class Driver:
f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}" f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
) )
if time.time() - last_recon > self.args.reconcile_secs: if time.time() - last_recon > self.args.reconcile_secs:
reconcile(verbose=self.args.verbose) reconcile(verbose=self.args.verbose,
cut_thru=not self.args.keep_thru)
last_recon = time.time() last_recon = time.time()
time.sleep(0.001) time.sleep(0.001)
...@@ -294,10 +376,29 @@ def main(argv: list[str] | None = None) -> int: ...@@ -294,10 +376,29 @@ def main(argv: list[str] | None = None) -> int:
p.add_argument("--relative", action="store_true", p.add_argument("--relative", action="store_true",
help="EXPERIMENT: switch encoder rows to relative. Their CC numbers " help="EXPERIMENT: switch encoder rows to relative. Their CC numbers "
"shift +64, so the map below no longer applies — expect drops.") "shift +64, so the map below no longer applies — expect drops.")
p.add_argument("--graph", action="store_true",
help="print what currently feeds SuperCollider's MIDI in, and exit")
p.add_argument("--keep-thru", action="store_true",
help="do NOT cut Midi Through -> SuperCollider. Only for when "
"something else legitimately feeds SC through it; by default "
"we cut it, because it is a second copy of the surface and a "
"doubled CC cancels a latching button.")
p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit") p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit")
p.add_argument("--reconcile-secs", type=float, default=5.0) p.add_argument("--reconcile-secs", type=float, default=5.0)
args = p.parse_args(argv) args = p.parse_args(argv)
if args.graph:
ports = _sc_input_ports()
if not ports:
print("SuperCollider has no ALSA-seq input ports — is sclang running?")
return 1
bad = RAW_CLIENTS + THRU_CLIENTS
for dst in ports:
for src, name in _sources_feeding(dst):
tag = ("OK (ours)" if VIRTUAL_PORT_NAME in name
else "WOULD CUT" if any(b in name for b in bad) else "left alone")
print(f" {src:<8} {name:<24} -> {dst:<8} {tag}")
return 0
if args.show_map: if args.show_map:
describe_map(build_map()) describe_map(build_map())
return 0 return 0
......
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