khannurien 2f0d5841c6 Add the Max for Live device that reads Live's scale
Build step 2 of the brief: an M4L device that observes root_note /
scale_name / scale_intervals in the Live Object Model and prints the
QWERTY keys the scale maps to. No lighting yet — that is step 3.

LiveAPI only exists inside Max's js objects, so max/scale-observer.js
(plain ES5, the one uncompiled file here) observes the LOM and forwards
raw values as flat messages; src/max/device.ts resolves them, keeping
every decision in TypeScript and testable off the hardware.

Follows the Song's scale by default and the selected clip's on request,
since the brief left that decision open. scale_intervals only exists
from Live 12.1, so scale_name resolves against a table of Live's
built-ins as a fallback; reported intervals always win.

Ships a .maxpat rather than an .amxd because an .amxd has to be created
from inside Live — the README has the paste-into-a-new-device steps.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 08:48:05 +00:00
2026-08-12 13:30:53 +00:00
2026-08-12 13:30:53 +00:00
2026-08-12 13:30:53 +00:00
2026-08-12 13:30:53 +00:00
2026-08-12 13:30:53 +00:00

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.

Two of the four build steps in 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.

TypeScript, no runtime dependencies. Node 16+.

Quick start (Windows, with SteelSeries GG running)

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

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

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.

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-anote-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.

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.

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.

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:

npm test

The Max for Live device (step 2)

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.

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 prints

Open the Max console (Cmd/Ctrl-Shift-M). Change the scale in Live's control bar and each change prints:

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.

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. That keeps every decision in TypeScript, and means step 3 only has to call ScaleLighting from a place that already has the scale.

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 step 3 will call from Node for Max
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
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.

What step 3 has to do

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:

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.

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 yetlive.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.
Description
🎹 Light the notes of a musical scale on a SteelSeries keyboard using the GameSense SDK, mapped to Ableton Live's computer MIDI keyboard layout.
Readme 137 KiB
Languages
TypeScript 70.6%
Max 15%
JavaScript 14.4%