The device is a pure observer — it reads the LOM and talks to GameSense and never looks at a note — so it does not have to sit in the path of anything you play. But a Max MIDI Effect without midiin -> midiout swallows MIDI instead of passing it on, so the shipped device routes every note you play through Max's scheduler on its way to your instrument. Pointless cost for this device. max/scale-lighting-audio.maxpat is the same patcher as an Audio Effect, declaring no I/O at all: no midiin/midiout, no plugin~/plugout~. Park it on a dedicated empty Audio track and it is provably out of every signal path. The two patchers differ by exactly midiin, midiout and the patchline between them — @watch 1 and everything else are deliberately left identical on both sides, so an A/B measures the passthrough and nothing else. Both register the same GameSense game, so they have to be loaded one at a time; that and a way to actually measure the difference (compare note onsets across recorded takes, not the CPU meter) are in the README. The patcher test now runs over both files, and two new tests pin the distinction: the MIDI variant must wire midiin to midiout, and the audio variant must declare none of those four objects — the kind of thing a later edit would otherwise silently undo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
15 KiB
Markdown
344 lines
15 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
|
||
```
|
||
|
||
### Two variants
|
||
|
||
The device is pure observer — it reads the LOM and talks to GameSense, and it
|
||
never looks at a note. So it does not have to sit in the path of anything you
|
||
play. Two patchers ship, identical apart from that:
|
||
|
||
| Patcher | Live device type | Passthrough |
|
||
| --- | --- | --- |
|
||
| `max/scale-lighting.maxpat` | Max **MIDI** Effect | `midiin` → `midiout` |
|
||
| `max/scale-lighting-audio.maxpat` | Max **Audio** Effect | none |
|
||
|
||
The MIDI variant needs the passthrough: a Max MIDI Effect without `midiin` →
|
||
`midiout` swallows MIDI instead of passing it on. That means when it sits on the
|
||
track you play, every note you play is routed through Max's scheduler on its way
|
||
to your instrument — a small cost, and a pointless one here.
|
||
|
||
The audio variant declares no I/O at all: no `midiin`/`midiout`, no
|
||
`plugin~`/`plugout~`. Nothing you play goes through Max. Park it on a **dedicated
|
||
empty Audio track** and it cannot be in the signal path of anything.
|
||
|
||
If you would rather not add a track, the MIDI variant on a dedicated empty MIDI
|
||
track is just as much out of the way — the cost only appears when the device is
|
||
on the track you are actually playing.
|
||
|
||
### Installing it
|
||
|
||
The repo ships `.maxpat` files rather than `.amxd`, because an `.amxd` has to be
|
||
born inside Live:
|
||
|
||
1. In Live, drag a **Max Audio Effect** onto a dedicated empty Audio track (or a
|
||
**Max MIDI Effect** onto a MIDI track, for the MIDI variant) and click its
|
||
edit (pencil) button to open Max.
|
||
2. Open the matching `.maxpat` from `max/` 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.
|
||
3. **File → Save**, and save the device into `max/` — in *this* folder, so `js`
|
||
and `node.script` find their scripts next to it. Suggested names:
|
||
`Ableton Scale Lighting.amxd` and `Ableton Scale Lighting (Audio).amxd`.
|
||
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.
|
||
|
||
> **Load one at a time.** Both variants register the same GameSense game
|
||
> (`ABLETON_SCALE`), so two of them running together fight over the board and
|
||
> either one's `shutdown` blanks it for both.
|
||
|
||
### Comparing the two
|
||
|
||
Both patchers keep `@watch 1` on `node.script` and `autowatch` in
|
||
`scale-observer.js`, so the only difference between them is the passthrough and
|
||
the A/B measures exactly that.
|
||
|
||
The honest test is not "does it feel laggy" — it is a recording. On one MIDI
|
||
track with an instrument that has a sharp attack:
|
||
|
||
1. Record a bar of audio with no device on the track.
|
||
2. Drop the MIDI variant *before* the instrument, play the same part, record again.
|
||
3. Do the same with the audio variant on its own separate track.
|
||
|
||
Line the three takes up and compare note onsets. Live's CPU meter and Max's
|
||
**Window → Max Console** are worth a glance too, but neither shows scheduler
|
||
latency — only the onsets do.
|
||
|
||
Note that both variants start a `node.script` process and heartbeat GameSense
|
||
every 5s regardless of where they sit; that cost is identical and is not what
|
||
this comparison is about.
|
||
|
||
### 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 M4L patcher, MIDI Effect (midiin/midiout passthrough)
|
||
max/scale-lighting-audio.maxpat M4L patcher, Audio Effect (no passthrough at all)
|
||
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.
|