# 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`](./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) ```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. 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. ```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. 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: ```bash 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: ```ts import { ScaleLighting } from 'steelseries-live-scale'; // or, from a Node for Max script: require('/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 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`.