Commit c628415e by PLN (Algolia)

feat(surface): translate the LCXL3 instead of renumbering the corpus a second time

The v3 and the 169 tracks disagree about what a CC means, and only one of them
is cheap to change.

Paint is DAW-mode-only. Tested rather than believed, in three steps with PLN
watching the surface: magenta on the MIDI port in standalone did nothing; the
same paint on the DAW port with DAW mode off did nothing; `daw on` followed by
the same bytes lit all 24 rings and 16 buttons. So the guide's "only available
once DAW mode is enabled" is a hardware fact, not an artefact of how the
document is organised.

But DAW mode's CC map is fixed, and it overlaps v2's with DIFFERENT meanings.
The map was read off the hardware, one control per row, not off the PDF's
low-resolution diagram: fader1 -> cc5 ch16, A1 -> 13, B1 -> 21, C1 -> 29,
E1 -> 37 ch1, F1 -> 45 ch1, shift -> 63 ch7.

    row   v2 (lcxl_grid)   v3 DAW
    A     13-20            13-20     identical
    B     29-36            21-28
    C     49-56            29-36     <- v2's row B numbers
    D     77-84            5-12
    E     41-44, 57-60     37-44
    F     73-76, 89-92     45-52

Sixteen indices collide. The one that decides it: v3 #41 is button E5, while v2
#41 is button E1 — the kick gate the whole #94 migration just aligned 169 files
onto. Renumbering would move the kick gate four buttons to the right, silently,
with nothing erroring. That is the same failure class b5ad8b6c was written to
end, so the corpus keeps its numbering and software absorbs the difference.

Three things make this cheap rather than clever:

SuperCollider's binding is `MIDIFunc.cc({...})` with no cc, channel or src
filter — only the number matters, so a pure renumber is sufficient and no
channel juggling is needed.

Every encoder row is still in ABSOLUTE mode (queried: rows 1/2/3 all read 0),
so a v3 encoder already behaves like a v2 pot. Relative mode needs an
integrator that owns the value — genuinely better, since it kills pot-pickup
outright, and it is where paint-by-value belongs — but it is a separate change
and `--relative` is left as an experiment that honestly warns the map no longer
applies.

The v2 numbers are DERIVED from lcxl_grid.ROW_CCS positionally rather than
retyped, so if the authored table moves, the translation follows.

Publishing a virtual port rather than resolving SuperCollider's once: SC runs
MIDIIn.connectAll a single time at boot, so a port that appears later is never
connected, and a binding resolved once is the rig's most repeated failure mode.
The driver re-asserts the aconnect link on a timer, the same shape as the two
reconciler units.

midi-autoconnect had the same stale-name bug: it hardcoded "Launch Control XL",
which matches neither v3 port, and since it routes aconnect's complaints to
/dev/null the failure was completely silent — Ardour stopped hearing the surface
with every check green. It now resolves the source, preferring the driver's
translated port over the raw device, and echoes what it picked once per change
rather than every two seconds.
parent 8069657f
#!/usr/bin/env python3
r"""lcxl3-driver — make the LCXL3 speak the corpus's language.
THE PROBLEM IT SOLVES
The v3 hardware and the 169-track corpus disagree about what a CC means, and
only one of them is cheap to change.
Paint (RGB + the OLED) works ONLY in DAW mode — tested, not assumed: painting
on the MIDI port in standalone did nothing, painting on the DAW port with DAW
mode off did nothing, and the same paint after `daw on` lit the whole surface.
But DAW mode's CC map is FIXED, and it overlaps v2's with different meanings:
row v2 CC (lcxl_grid) v3 DAW CC
A 13-20 13-20 <- identical
B 29-36 21-28
C 49-56 29-36 <- v2's row B numbers!
D 77-84 5-12
E 41-44, 57-60 37-44
F 73-76, 89-92 45-52
Renumbering the corpus to match would move controls silently — row C knobs
would answer to row B's numbers — which is the exact failure class the #94
migration spent 169 files fixing. So we translate in software instead, and the
corpus keeps the numbering it already agrees on.
Also confirmed on the hardware: in DAW mode the surface reports on the DAW
interface ONLY. Nothing arrives on the MIDI port. That is why the rig went
deaf when the v3 arrived.
WHY ABSOLUTE MODE, FOR NOW
The endless encoders CAN send relative deltas, but relative mode is per-row
opt-in and every row currently reads 0 (queried, not assumed). In absolute
mode a v3 encoder behaves exactly like a v2 pot, so playability is a pure
renumbering job. Relative mode needs an integrator that OWNS the value — a
real improvement (it kills pot-pickup for good) but a separate change, and it
is where paint-by-value belongs. See `--relative` for the experiment.
WHY IT DERIVES THE MAP RATHER THAN HOLDING ONE
`lcxl_grid.py` is the one authored table (feedback_one_parser_per_concept).
This module reads v2 numbers OUT of it positionally, so if the grid changes,
the translation follows instead of drifting.
WHY A VIRTUAL PORT AND A RECONCILER
SuperCollider runs `MIDIIn.connectAll` once at boot, so a port that appears
later is not connected. And a port resolved once is a stale binding waiting to
happen (feedback_stale_binding_pattern — the rig's most repeated failure). So
we publish a stable virtual port and re-assert the aconnect link on a timer,
the same shape as tools/midi-autoconnect.sh and tools/tidal-ardour-autoroute.sh.
Requires: sudo pacman -S python-mido python-rtmidi
"""
from __future__ import annotations
import argparse
import pathlib
import signal
import subprocess
import sys
import time
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
import lcxl3 as L # noqa: E402
try:
import lcxl_grid as GRID # noqa: E402
except ImportError: # pragma: no cover
GRID = None
VIRTUAL_PORT_NAME = "ParVagues LCXL3"
# v3 DAW-mode indices per physical row, verified against the hardware by moving
# one control per row and reading what arrived (fader1->cc5 ch16, A1->13 ch16,
# B1->21 ch16, C1->29 ch16, E1->37 ch1, F1->45 ch1).
V3_ROWS: dict[str, list[int]] = {
"A": list(range(13, 21)),
"B": list(range(21, 29)),
"C": list(range(29, 37)),
"D": list(range(5, 13)),
"E": list(range(37, 45)),
"F": list(range(45, 53)),
}
def build_map() -> dict[int, int]:
"""v3 DAW CC -> v2 CC, positionally, straight out of the authored grid."""
if GRID is None:
sys.exit("lcxl3-driver: cannot import lcxl_grid — run me from the repo's tools/")
out: dict[int, int] = {}
for row, v3ccs in V3_ROWS.items():
v2ccs = GRID.ROW_CCS.get(row)
if not v2ccs:
sys.exit(f"lcxl3-driver: lcxl_grid has no row {row!r}")
if len(v2ccs) != len(v3ccs):
sys.exit(
f"lcxl3-driver: row {row} has {len(v2ccs)} v2 cells but "
f"{len(v3ccs)} v3 indices — the grid and the hardware disagree, "
"which is exactly what must not be papered over"
)
for v3, v2 in zip(v3ccs, v2ccs):
out[v3] = v2
return out
def describe_map(m: dict[int, int]) -> None:
print(f"{'row':<4} {'v3 DAW CC':<22} -> v2 CC")
for row, v3ccs in V3_ROWS.items():
v2 = [m[c] for c in v3ccs]
same = " (unchanged)" if v2 == v3ccs else ""
print(f"{row:<4} {str(v3ccs[0]) + '-' + str(v3ccs[-1]):<22} -> "
f"{','.join(str(x) for x in v2)}{same}")
collisions = sorted(set(m) & set(m.values()) - {c for c in m if m[c] == c})
if collisions:
print(f"\nNOTE: {len(collisions)} v3 index/es also exist as v2 numbers with a")
print("DIFFERENT meaning — translating (not renumbering) is what keeps that safe:")
for c in collisions:
print(f" v3 #{c} means {L.control_name(c)}, but v2 #{c} is a different cell")
# ------------------------------------------------------------- aconnect glue ----
def _aconnect_pairs() -> str:
try:
return subprocess.run(
["aconnect", "-l"], capture_output=True, text=True, timeout=3
).stdout
except Exception:
return ""
def _clients(direction: str) -> list[tuple[str, str]]:
"""[(client:port, name)] from `aconnect -i|-o`."""
try:
out = subprocess.run(
["aconnect", direction], capture_output=True, text=True, timeout=3
).stdout
except Exception:
return []
res, cur = [], None
for line in out.splitlines():
if line.startswith("client "):
parts = line.split("'")
cur = (line.split()[1].rstrip(":"), parts[1].strip() if len(parts) > 1 else "")
elif cur and line.strip() and line[0] in " \t":
tok = line.strip().split()
if tok and tok[0].isdigit():
res.append((f"{cur[0]}:{tok[0]}", cur[1]))
return res
def reconcile(verbose: bool = False) -> bool:
"""Wire our virtual port into SuperCollider's MIDI input, idempotently."""
src = next((p for p, n in _clients("-i") if VIRTUAL_PORT_NAME in n), None)
if src is 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)
if not src or not dst:
if verbose:
print(f" reconcile: src={src} dst={dst} — nothing to do yet")
return False
graph = _aconnect_pairs()
if f"{dst}" in graph and src.split(":")[0] in graph:
pass # cheap pre-check only; aconnect is idempotent anyway
try:
subprocess.run(["aconnect", src, dst], capture_output=True, timeout=3)
if verbose:
print(f" reconcile: {src} -> {dst}")
return True
except Exception:
return False
# -------------------------------------------------------------------- driver ----
class Driver:
def __init__(self, args):
self.args = args
self.map = build_map()
self.stats = {"in": 0, "out": 0, "dropped": 0}
self.unmapped: dict[tuple[int, int], int] = {}
mido = L._mido()
self.surface = L.Surface("daw", need_input=True)
try:
self.virt = mido.open_output(VIRTUAL_PORT_NAME, virtual=True)
except Exception as e:
sys.exit(
f"lcxl3-driver: could not create the virtual port ({e}).\n"
"python-rtmidi with the ALSA backend is required."
)
self.out_channel = args.channel - 1
self._stop = False
def start(self) -> None:
print(f"enabling DAW mode (the only mode that paints, and the only one")
print(f" that reports the fixed map we translate from)")
self.surface.daw_mode(True)
time.sleep(0.2)
if self.args.relative:
for row in (1, 2, 3):
self.surface.relative(row, True)
print(" encoder rows 1-3 -> RELATIVE (deltas; values are ours to hold)")
print(f"virtual port published: {VIRTUAL_PORT_NAME!r}")
reconcile(verbose=True)
def stop(self) -> None:
if self.args.relative:
for row in (1, 2, 3):
try:
self.surface.relative(row, False)
except Exception:
pass
if not self.args.stay:
try:
self.surface.daw_mode(False)
print("\nDAW mode -> OFF (the guide says a host should exit on quit)")
except Exception:
pass
try:
self.virt.close()
except Exception:
pass
self.surface.close()
def run(self) -> int:
mido = L._mido()
self.start()
def onsig(_s, _f):
self._stop = True
signal.signal(signal.SIGINT, onsig)
signal.signal(signal.SIGTERM, onsig)
print("\ntranslating — ctrl-c to stop\n")
last_recon = time.time()
while not self._stop:
for msg in self.surface.inp.iter_pending():
self.stats["in"] += 1
if msg.type != "control_change":
continue
# Feature-control traffic (ch7) is state, not a musical control.
if msg.channel == L.CH_FEATURE:
if self.args.verbose:
print(f" [feature] {L._describe(msg)}")
continue
v2 = self.map.get(msg.control)
if v2 is None:
key = (msg.channel, msg.control)
self.unmapped[key] = self.unmapped.get(key, 0) + 1
self.stats["dropped"] += 1
if self.args.verbose:
print(f" [unmapped] ch{msg.channel + 1} #{msg.control}")
continue
self.virt.send(
mido.Message(
"control_change",
channel=self.out_channel,
control=v2,
value=msg.value,
)
)
self.stats["out"] += 1
if self.args.verbose:
print(
f" {L.control_name(msg.control):<16} "
f"v3 #{msg.control:<3} -> v2 #{v2:<3} = {msg.value}"
)
if time.time() - last_recon > self.args.reconcile_secs:
reconcile(verbose=self.args.verbose)
last_recon = time.time()
time.sleep(0.001)
self.stop()
print(f"\nin={self.stats['in']} translated={self.stats['out']} "
f"dropped={self.stats['dropped']}")
if self.unmapped:
print("unmapped controls seen (these are the surface's extra buttons —")
print("Page/Track/Record/Play/Solo/Mute, unused by the corpus today):")
for (ch, cc), n in sorted(self.unmapped.items(), key=lambda kv: -kv[1]):
print(f" ch{ch + 1} #{cc} x{n}")
return 0
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(prog="lcxl3-driver", description=__doc__.splitlines()[0])
p.add_argument("--show-map", action="store_true", help="print the translation and exit")
p.add_argument("--verbose", "-v", action="store_true", help="log every translation")
p.add_argument("--channel", type=int, default=1,
help="output MIDI channel, 1-based (default 1; SC ignores it, "
"Ardour may not)")
p.add_argument("--relative", action="store_true",
help="EXPERIMENT: switch encoder rows to relative. Their CC numbers "
"shift +64, so the map below no longer applies — expect drops.")
p.add_argument("--stay", action="store_true", help="leave DAW mode on at exit")
p.add_argument("--reconcile-secs", type=float, default=5.0)
args = p.parse_args(argv)
if args.show_map:
describe_map(build_map())
return 0
return Driver(args).run()
if __name__ == "__main__":
sys.exit(main())
[Unit] [Unit]
Description=Enforce fixed ALSA-seq MIDI wiring (Launch Control XL <-> Midi Through -> aseqdump) Description=Enforce fixed ALSA-seq MIDI wiring (control surface -> Midi Through, return leg cut)
# Run alongside the sound/PipeWire session. # Run alongside the sound/PipeWire session.
After=pipewire.service After=pipewire.service
Wants=pipewire.service Wants=pipewire.service
......
...@@ -44,11 +44,25 @@ ...@@ -44,11 +44,25 @@
set -u set -u
LCXL="Launch Control XL" # The source is RESOLVED, not hardcoded, because the device name changed with
# the hardware: v2 advertises "Launch Control XL", v3 advertises "LCXL3 1".
# The old hardcoded name matched neither of the v3 ports, and because connect()
# sends aconnect's complaints to /dev/null, the failure was perfectly silent —
# Ardour simply stopped hearing the surface on 2026-08-28 with every check green.
#
# Preference order matters:
# 1. "ParVagues LCXL3" — the lcxl3-driver's virtual port. In DAW mode the v3
# reports on its DAW interface with a FIXED CC map that disagrees with the
# corpus, so the driver's translated, v2-numbered stream is the one Ardour
# learned its 12 CCs from. Prefer it whenever it exists.
# 2. "LCXL3" — the raw v3, for when the driver is not running.
# 3. "Launch Control XL" — v2, still correct if the old surface is plugged in.
LCXL_CANDIDATES=("ParVagues LCXL3" "LCXL3" "Launch Control XL")
THRU="Midi Through" THRU="Midi Through"
LCXL_PORT=0 # "first channel" LCXL_PORT=0 # "first channel"
THRU_PORT=0 THRU_PORT=0
INTERVAL="${MIDI_AUTOCONNECT_INTERVAL:-2}" INTERVAL="${MIDI_AUTOCONNECT_INTERVAL:-2}"
LAST_SRC=""
connect() { # $1 = source, $2 = dest ; silent unless it's a real failure connect() { # $1 = source, $2 = dest ; silent unless it's a real failure
aconnect "$1" "$2" 2>/dev/null aconnect "$1" "$2" 2>/dev/null
...@@ -58,9 +72,35 @@ disconnect() { # $1 = source, $2 = dest ; silent when there was nothing to cut ...@@ -58,9 +72,35 @@ disconnect() { # $1 = source, $2 = dest ; silent when there was nothing to cut
aconnect -d "$1" "$2" 2>/dev/null aconnect -d "$1" "$2" 2>/dev/null
} }
# Echo the resolved source ONCE per change, never every INTERVAL: a line every
# 2s is not a log, it is a way to make a real change invisible.
resolve_lcxl() {
local names; names="$(aconnect -i 2>/dev/null; aconnect -o 2>/dev/null)"
local cand
for cand in "${LCXL_CANDIDATES[@]}"; do
if printf '%s' "$names" | grep -qF "$cand"; then
printf '%s' "$cand"
return 0
fi
done
return 1
}
reconcile() { reconcile() {
connect "$LCXL:$LCXL_PORT" "$THRU:$THRU_PORT" # rule 1: controls out local src
disconnect "$THRU:$THRU_PORT" "$LCXL:$LCXL_PORT" # rule 2: kill the loop if ! src="$(resolve_lcxl)"; then
if [ "$LAST_SRC" != "__none__" ]; then
echo "midi-autoconnect: no surface present (looked for: ${LCXL_CANDIDATES[*]})"
LAST_SRC="__none__"
fi
return 0
fi
if [ "$src" != "$LAST_SRC" ]; then
echo "midi-autoconnect: surface = '$src'"
LAST_SRC="$src"
fi
connect "$src:$LCXL_PORT" "$THRU:$THRU_PORT" # rule 1: controls out
disconnect "$THRU:$THRU_PORT" "$src:$LCXL_PORT" # rule 2: kill the loop
} }
echo "midi-autoconnect: reconciling every ${INTERVAL}s" echo "midi-autoconnect: reconciling every ${INTERVAL}s"
......
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