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:
100
README.md
100
README.md
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user