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>
292 lines
12 KiB
Markdown
292 lines
12 KiB
Markdown
# steelseries-live-scale
|
||
|
||
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.
|
||
|
||
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.
|
||
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+.
|
||
|
||
## Quick start (Windows, with SteelSeries GG running)
|
||
|
||
```powershell
|
||
npm install # also compiles src\*.ts to dist\
|
||
node bin\apex7-scale.js --root C --scale major
|
||
```
|
||
|
||
`npm install` builds via the `prepare` script; after editing any `.ts` file run
|
||
`npm run build` (or `npm run typecheck` for types only).
|
||
|
||
The board dims, the C-major keys light up (`A S D F G H J K`), the root is
|
||
orange. The process stays resident and heartbeats so the lighting sticks; you
|
||
can type new scales at the prompt:
|
||
|
||
```
|
||
F# dorian
|
||
Bb minor-pentatonic
|
||
quit
|
||
```
|
||
|
||
Ctrl+C blanks the board and hands lighting back to SteelSeries GG.
|
||
|
||
### Verify the key mapping on hardware
|
||
|
||
```powershell
|
||
node bin\apex7-scale.js --key-test
|
||
```
|
||
|
||
Lights one note key at a time, in order, printing the note it should be. Walk it
|
||
against the table in the brief — `A`=C, `W`=C#, … `J`=B, `K`=C (octave up).
|
||
|
||
### Other modes
|
||
|
||
```powershell
|
||
node bin\apex7-scale.js --demo 2 # cycle scales, 2s each
|
||
node bin\apex7-scale.js --intervals 0,3,5,6,7,10 --root A # raw intervals (blues)
|
||
node bin\apex7-scale.js --once # one frame, then exit
|
||
node bin\apex7-scale.js --off # blank + deregister
|
||
node bin\apex7-scale.js --list # known scale names
|
||
node bin\apex7-scale.js --help
|
||
```
|
||
|
||
Colors: `--bg`, `--color`, `--root-color`, `--off-color`, all `#rrggbb`.
|
||
|
||
```powershell
|
||
node bin\apex7-scale.js --root D --scale minor --bg "#020208" --color "#ff00d0" --root-color "#ffffff"
|
||
```
|
||
|
||
## How the lighting works
|
||
|
||
One GameSense event (`SCALE`) with **14 handlers**, all in `context-color` mode:
|
||
|
||
| Handler | Zone | Frame key |
|
||
|---|---|---|
|
||
| background | every key *except* the 13 note keys (93 keys) | `background` |
|
||
| 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 — which is what
|
||
makes following Live mid-set cheap.
|
||
|
||
Two design notes:
|
||
|
||
- **Zones, not `bitmap`.** The brief suggested bitmap mode. Bitmap's 22×6 grid
|
||
has no documented index→key table per keyboard model, whereas HID usage codes
|
||
are exact. Painting *all* keys with the background zone solves the same
|
||
problem bitmap was suggested for: the moment a GameSense event arrives the
|
||
board enters GameSense mode and any key you don't address goes black.
|
||
- **The octave key `K`** follows pitch class 0 (same as `A`) but is only tinted
|
||
as the root when C actually is the root.
|
||
|
||
## Testing without hardware
|
||
|
||
`tools/fake-gamesense.ts` stands in for the GameSense server: it accepts the
|
||
real endpoints, resolves the bound handlers against each frame, and renders the
|
||
resulting keyboard as ANSI color in the terminal.
|
||
|
||
```bash
|
||
npm run stub -- --port 51000 # terminal 1
|
||
node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # terminal 2
|
||
```
|
||
|
||
`--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, 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
|
||
|
||
`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
|
||
|
||
The repo ships a `.maxpat` rather than a `.amxd`, because an `.amxd` has to be
|
||
born inside Live:
|
||
|
||
1. In Live, drag a **Max MIDI Effect** onto a MIDI track and click its edit
|
||
(pencil) button to open Max.
|
||
2. Open `max/scale-lighting.maxpat` in a text editor, copy all of it, then in
|
||
the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**.
|
||
Max pastes the whole patcher, `midiin`/`midiout` passthrough included.
|
||
3. **File → Save**, and save the device as `max/Ableton Scale Lighting.amxd` —
|
||
in *this* folder, so `js` and `node.script` find their scripts next to it.
|
||
4. Make sure `dist/` is built (`npm install` in the repo root). `node.script`
|
||
loads `dist/src/max/device.js`.
|
||
|
||
Add this repo's folder in Live's browser (**Add Folder…**) to load the device
|
||
from there in future sets.
|
||
|
||
### What it does
|
||
|
||
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
|
||
intervals: 0 2 4 5 7 9 11
|
||
notes: C D E F G A B
|
||
keys: A S D F G H J K
|
||
A W S E D F T G Y H U J K
|
||
* . * . * * . * . * . * *
|
||
```
|
||
|
||
The last two lines are every note key in keyboard order with a mark under the
|
||
lit ones — hold that against the table in the brief and the mapping is verified.
|
||
The device also sends `notes …`, `keys …` and `scale …` out `node.script`'s
|
||
outlet, so you can wire them to a `live.comment` if you want them on the device
|
||
face.
|
||
|
||
### Which scale it follows
|
||
|
||
Live 12 puts a scale on the Song *and* on each clip, so the device supports
|
||
both. Click the message boxes in the patcher:
|
||
|
||
- `source song` (default) — the control bar's global scale.
|
||
- `source clip` — the scale of the clip currently open in the Detail view,
|
||
falling back to the Song's when no clip is selected or the clip has no scale.
|
||
- `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
|
||
win, so a scale you edited in Live is followed exactly.
|
||
|
||
### Why the split between `js` and `node.script`
|
||
|
||
`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 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
|
||
|
||
```
|
||
bin/apex7-scale.js launcher (plain JS) — runs dist/src/cli.js
|
||
src/cli.ts argument parsing, output, the resident stdin loop
|
||
src/protocol.ts GameSense wire types (Rgb, Frame, Handler, …)
|
||
src/hid.ts USB HID usage codes; the full-board key list
|
||
src/scale.ts scale presets, root/interval parsing, pitch class -> QWERTY key
|
||
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 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: 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
|
||
tools/fake-gamesense.ts terminal simulator of the GameSense server
|
||
test/logic.test.ts unit tests for the lighting half
|
||
test/live.test.ts unit tests for the Live half, with a fake Max
|
||
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.
|
||
|
||
## The seam: `ScaleLighting`
|
||
|
||
`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';
|
||
// or, from a Node for Max script: require('<repo>/dist/src')
|
||
|
||
const lights = new ScaleLighting();
|
||
await lights.start(); // register + bind + heartbeat
|
||
await lights.showScale(0, [0,2,4,5,7,9,11]); // root_note + scale_intervals from the LOM
|
||
// ...
|
||
await lights.stop();
|
||
```
|
||
|
||
`showScale(root, intervals)` takes exactly what Live 12's `Song` object exposes
|
||
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
|
||
|
||
- **`coreProps.json not found`** — SteelSeries GG isn't running, or is installed
|
||
somewhere unusual. Expected at
|
||
`%PROGRAMDATA%\SteelSeries\SteelSeries Engine 3\coreProps.json`.
|
||
- **Nothing lights up** — check GG's Engine is enabled and that no other app
|
||
holds an exclusive lighting profile.
|
||
- **Lighting reverts after ~15s** — that's GameSense's deactivation timeout;
|
||
it only happens with `--once`, since resident mode heartbeats every 5s.
|
||
- **A key stays dark** — it may not be in `src/hid.ts`. Add its HID code there;
|
||
keys not addressed by any handler go black in GameSense mode.
|
||
- **`apex7-scale is not built yet`** — run `npm run build`.
|
||
- **The device prints nothing** — the Max console should show `scale: ready` on
|
||
load. If not, `node.script` never started: check `dist/src/max/device.js`
|
||
exists, and that the `.amxd` was saved in `max/` next to the two scripts.
|
||
- **`scale: no scale yet`** — `live.thisdevice` never banged, or Live has no
|
||
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.
|