Wire the Max for Live device to the keyboard

Step 3 of the brief: the device now holds a ScaleLighting and pushes every
scale Live reports to the Apex 7, instead of only printing it.

The controller is created lazily and injected (start(max, { createLights })),
so the device stays testable with no Max and no hardware. Around the call:
lighting work is serialized on one queue so a burst of LOM changes cannot
interleave two POSTs, an unchanged scale is never re-sent, and a GameSense
that is missing or restarted is printed once rather than on every scale
change — the scale keeps being tracked so a later retry lands on the right one.

New messages: lights 0|1, retry, address <host:port>, shutdown. The patcher
gets boxes for them plus closebang -> shutdown, and the launcher blanks the
board on SIGTERM/SIGINT, so deleting the device hands lighting back to GG.

The test runner now supports async tests, since the lighting half is async.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-15 09:00:42 +00:00
parent 2f0d5841c6
commit b8d01e3f59
5 changed files with 632 additions and 67 deletions

View File

@@ -26,5 +26,23 @@ if (!fs.existsSync(compiled)) {
Max.POST_LEVELS.ERROR
);
} else {
require(compiled).start(Max);
const device = require(compiled).start(Max);
// Node for Max kills this process when the device is deleted or the Live set
// is closed. Blank the board on the way out so the Apex 7 goes back to its
// SteelSeries GG profile rather than sitting on the last scale. Best effort:
// if the signal never arrives, GameSense drops the effect ~15s after the
// heartbeat stops anyway. The patcher's `closebang` covers device deletion,
// which is the case Live does not signal.
let leaving = false;
const leave = () => {
if (leaving) return;
leaving = true;
device
.shutdown()
.catch(() => {})
.then(() => process.exit(0));
};
process.on('SIGTERM', leave);
process.on('SIGINT', leave);
}

View File

@@ -46,7 +46,7 @@
"numoutlets" : 0,
"patching_rect" : [ 20.0, 15.0, 420.0, 20.0 ],
"fontsize" : 13.0,
"text" : "Ableton Scale Lighting — step 2: read the scale, print the keys"
"text" : "Ableton Scale Lighting — the Apex 7 follows Live's scale"
}
}
@@ -58,7 +58,7 @@
"numoutlets" : 0,
"linecount" : 3,
"patching_rect" : [ 20.0, 40.0, 480.0, 47.0 ],
"text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them and prints the QWERTY keys to the Max console."
"text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them, prints the QWERTY keys to the Max console and lights them on the keyboard through GameSense."
}
}
@@ -168,6 +168,90 @@
"text" : "verbose 1"
}
}
, {
"box" : {
"id" : "obj-18",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 326.0, 185.0, 56.0, 22.0 ],
"text" : "lights 1"
}
}
, {
"box" : {
"id" : "obj-19",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 390.0, 185.0, 56.0, 22.0 ],
"text" : "lights 0"
}
}
, {
"box" : {
"id" : "obj-20",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 454.0, 185.0, 44.0, 22.0 ],
"text" : "retry"
}
}
, {
"box" : {
"id" : "obj-21",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 1,
"outlettype" : [ "bang" ],
"patching_rect" : [ 430.0, 100.0, 66.0, 22.0 ],
"text" : "closebang"
}
}
, {
"box" : {
"id" : "obj-22",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 430.0, 140.0, 66.0, 22.0 ],
"text" : "shutdown"
}
}
, {
"box" : {
"id" : "obj-23",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 20.0, 320.0, 175.0, 22.0 ],
"text" : "address 127.0.0.1:51000"
}
}
, {
"box" : {
"id" : "obj-24",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 2,
"patching_rect" : [ 204.0, 317.0, 400.0, 33.0 ],
"text" : "Only for testing against tools/fake-gamesense.ts — leave it alone and the device finds SteelSeries GG through coreProps.json. Send \"address\" with no value to go back to discovery."
}
}
, {
"box" : {
@@ -200,7 +284,7 @@
"numoutlets" : 0,
"linecount" : 2,
"patching_rect" : [ 104.0, 272.0, 400.0, 33.0 ],
"text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root."
"text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root. SteelSeries GG must be running for the keys to light."
}
}
@@ -294,6 +378,48 @@
"source" : [ "obj-11", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-18", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-19", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-20", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-22", 0 ],
"source" : [ "obj-21", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-22", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-23", 0 ]
}
}
, {
"patchline" : {