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

100
README.md
View File

@@ -3,15 +3,16 @@
Light the notes of a musical scale on a **SteelSeries Apex 7** using the
GameSense SDK, mapped to Ableton Live's computer MIDI keyboard layout.
Two of the four build steps in
Three of the four build steps in
[`apex7-ableton-scale-lighting.md`](./apex7-ableton-scale-lighting.md) are done:
1. **Done** — a standalone CLI that lights a scale via GameSense. You pass the
scale on the command line; Live is not involved.
2. **Done** — a Max for Live device that reads the scale from Live's Object
Model and prints the computed key set to the Max console. No lighting yet.
3. Next — wire the two together so the board follows Live.
4. Then — polish.
Model and prints the computed key set to the Max console.
3. **Done** — the two are wired together: change the scale in Live and the
keyboard follows.
4. Next — polish (colors on the device face, handling scale mode being off).
TypeScript, no runtime dependencies. Node 16+.
@@ -73,8 +74,8 @@ One GameSense event (`SCALE`) with **14 handlers**, all in `context-color` mode:
| 13 × note key | one key each, by HID code | `note-a``note-k` |
Because every handler pulls its color from the event's `frame`, the handlers are
bound **once** at startup and a scale change is a single POST. That is what
step 3 needs when the scale changes mid-set.
bound **once** at startup and a scale change is a single POST — which is what
makes following Live mid-set cheap.
Two design notes:
@@ -100,19 +101,26 @@ node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # t
`--address` (or `GAMESENSE_ADDRESS`) skips `coreProps.json` discovery entirely,
so this works on Linux too. `--dry-run` prints the payloads without sending.
The M4L device can be pointed at the stub too, with the `address` message box in
the patcher — useful for watching what Live's scale changes actually send.
Unit tests for the scale math, key mapping, handler shape and frame colors —
plus the LOM parsing and the device's message handling, driven through a fake
Max:
plus the LOM parsing, the device's message handling and its lighting behaviour,
driven through a fake Max and a fake controller:
```bash
npm test
```
## The Max for Live device (step 2)
## The Max for Live device
`max/` holds a device that watches Live's scale and prints the keys it computes.
It does not light anything yet — that is step 3. Its job is to prove the LOM
half of the chain against the mapping table in the brief.
`max/` holds a device that watches Live's scale, prints the keys it computes,
and lights them on the Apex 7. With it loaded and SteelSeries GG running, the
whole chain from the brief is live:
```
Live 12 → js (LiveAPI) → Node for Max → GameSense → Apex 7
```
### Installing it
@@ -132,10 +140,14 @@ born inside Live:
Add this repo's folder in Live's browser (**Add Folder…**) to load the device
from there in future sets.
### What it prints
### What it does
Open the Max console (Cmd/Ctrl-Shift-M). Change the scale in Live's control bar
and each change prints:
Change the scale in Live's control bar and the board follows: scale notes in
blue, the root in orange, everything else near-off. The keyboard is only touched
when the scale actually changes, and the device heartbeats in between so the
lighting sticks.
The same change prints to the Max console (Cmd/Ctrl-Shift-M):
```
C Major — from the song
@@ -163,6 +175,25 @@ both. Click the message boxes in the patcher:
- `refresh` — re-read and re-send.
- `debug 1` / `verbose 1` — log every LOM read / every commit, not just changes.
### Controlling the lighting
The device takes the board as soon as Live reports a scale. The other message
boxes in the patcher:
- `lights 0` — stop driving the keyboard and hand it back to SteelSeries GG.
Live is still followed, so `lights 1` picks up at the current scale.
- `retry` — re-connect to GameSense and re-send. Use it after starting GG, or
after GG restarts.
- `address 127.0.0.1:51000` — talk to `tools/fake-gamesense.ts` instead of the
real thing. Send `address` with no value to go back to `coreProps.json`
discovery.
- `shutdown` — blank the board now. `closebang` sends this for you when the
device is deleted or the set is closed.
If GameSense is not there the failure is printed once, not on every scale
change, and the device keeps tracking Live so that a later `retry` lands on the
right scale. `node.script`'s outlet also reports `lighting 1` / `lighting 0`.
`scale_intervals` only exists from Live 12.1. On earlier versions the device
resolves Live's `scale_name` against a table of Live's built-in scales
(`LIVE_SCALE_INTERVALS` in `src/live.ts`); when Live does send intervals, they
@@ -173,9 +204,9 @@ win, so a scale you edited in Live is followed exactly.
`LiveAPI` exists only inside Max's `js`/`v8` objects — Node for Max cannot see
it. So `max/scale-observer.js` (plain ES5, the one uncompiled file in the repo)
observes the LOM and forwards raw values as flat messages, and
`src/max/device.ts` resolves them. That keeps every decision in TypeScript, and
means step 3 only has to call `ScaleLighting` from a place that already has the
scale.
`src/max/device.ts` resolves them and calls `ScaleLighting`. That keeps every
decision in TypeScript: the `js` object only reads the LOM, and the HTTP half
never has to know about Live.
## Layout
@@ -188,9 +219,9 @@ src/scale.ts scale presets, root/interval parsing, pitch class -> QWE
src/gamesense.ts GameSense REST client (coreProps discovery, heartbeat, cleanup)
src/lighting.ts handler + frame construction
src/errors.ts `catch (err: unknown)` helpers
src/index.ts ScaleLighting — the API step 3 will call from Node for Max
src/index.ts ScaleLighting — the API the M4L device drives
src/live.ts LOM values -> a resolved scale, and how to print it
src/max/device.ts the Node for Max device: messages in, key set out
src/max/device.ts the Node for Max device: Live's scale in, lighting out
max/scale-lighting.maxpat the M4L patcher (paste into a device created in Live)
max/scale-observer.js the `js` object that observes the Live Object Model (ES5)
max/scale-device.js `node.script` launcher — runs dist/src/max/device.js
@@ -203,14 +234,10 @@ dist/ compiled CommonJS + .d.ts (gitignored)
`tsconfig.json` emits CommonJS at ES2020, because Node for Max loads CJS on a
Node version we do not control.
## What step 3 has to do
## The seam: `ScaleLighting`
Both halves now exist and neither knows about the other. Step 3 is joining them
inside `src/max/device.ts`: it already resolves a `LiveScale` on every `commit`,
so it needs to hold a `ScaleLighting`, start it on load, call
`showScale(scale.root, scale.intervals)` where it currently prints, and stop it
on `notifydeleted`. `src/index.ts` is that seam — no CLI concerns in it, and it
ships type declarations alongside the compiled JS:
`src/index.ts` is what joins the two halves, and it is usable on its own — no
CLI concerns in it, and it ships type declarations alongside the compiled JS:
```ts
import { ScaleLighting } from 'steelseries-live-scale';
@@ -224,7 +251,15 @@ await lights.stop();
```
`showScale(root, intervals)` takes exactly what Live 12's `Song` object exposes
as `root_note` and `scale_intervals`.
as `root_note` and `scale_intervals`, which is why the device can hand it the
resolved scale untouched.
`src/max/device.ts` wraps that with the things a device in a running set needs:
lighting work is serialized behind a 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 a printed message rather than a dead device. Both
Max and the controller are injected (`start(max, { createLights })`), so
`test/live.test.ts` drives the whole device with no Max and no hardware.
## Troubleshooting
@@ -245,3 +280,12 @@ as `root_note` and `scale_intervals`.
scale set. Click `refresh` in the patcher.
- **Nothing changes when you pick a clip** — the device follows the Song by
default; click `source clip`.
- **The console shows the scale but the board does not** — the lighting half
failed; the reason is printed once, right after the first scale. Start
SteelSeries GG and click `retry`. `status` re-prints the last failure.
- **The board keeps the last scale after you delete the device** — the blackout
is best effort (`closebang`, then a signal to the Node process). GameSense
drops the effect ~15s after the heartbeat stops either way; `node
bin/apex7-scale.js --off` blanks it immediately.
- **Two things fighting over the board** — the device and a running
`bin/apex7-scale.js` register the same game. Quit the CLI.