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:
khannurien
2026-08-15 09:00:42 +00:00
parent 2f0d5841c6
commit b8d01e3f59
5 changed files with 632 additions and 67 deletions

100
README.md
View File

@@ -3,15 +3,16 @@
Light the notes of a musical scale on a **SteelSeries Apex 7** using the 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. 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: [`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 1. **Done** — a standalone CLI that lights a scale via GameSense. You pass the
scale on the command line; Live is not involved. scale on the command line; Live is not involved.
2. **Done** — a Max for Live device that reads the scale from Live's Object 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. Model and prints the computed key set to the Max console.
3. Next — wire the two together so the board follows Live. 3. **Done** — the two are wired together: change the scale in Live and the
4. Then — polish. keyboard follows.
4. Next — polish (colors on the device face, handling scale mode being off).
TypeScript, no runtime dependencies. Node 16+. 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` | | 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 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 bound **once** at startup and a scale change is a single POST — which is what
step 3 needs when the scale changes mid-set. makes following Live mid-set cheap.
Two design notes: 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, `--address` (or `GAMESENSE_ADDRESS`) skips `coreProps.json` discovery entirely,
so this works on Linux too. `--dry-run` prints the payloads without sending. 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 — 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 plus the LOM parsing, the device's message handling and its lighting behaviour,
Max: driven through a fake Max and a fake controller:
```bash ```bash
npm test 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. `max/` holds a device that watches Live's scale, prints the keys it computes,
It does not light anything yet — that is step 3. Its job is to prove the LOM and lights them on the Apex 7. With it loaded and SteelSeries GG running, the
half of the chain against the mapping table in the brief. whole chain from the brief is live:
```
Live 12 → js (LiveAPI) → Node for Max → GameSense → Apex 7
```
### Installing it ### Installing it
@@ -132,10 +140,14 @@ born inside Live:
Add this repo's folder in Live's browser (**Add Folder…**) to load the device Add this repo's folder in Live's browser (**Add Folder…**) to load the device
from there in future sets. 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 Change the scale in Live's control bar and the board follows: scale notes in
and each change prints: 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 C Major — from the song
@@ -163,6 +175,25 @@ both. Click the message boxes in the patcher:
- `refresh` — re-read and re-send. - `refresh` — re-read and re-send.
- `debug 1` / `verbose 1` — log every LOM read / every commit, not just changes. - `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 `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 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 (`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 `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) 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 observes the LOM and forwards raw values as flat messages, and
`src/max/device.ts` resolves them. That keeps every decision in TypeScript, and `src/max/device.ts` resolves them and calls `ScaleLighting`. That keeps every
means step 3 only has to call `ScaleLighting` from a place that already has the decision in TypeScript: the `js` object only reads the LOM, and the HTTP half
scale. never has to know about Live.
## Layout ## 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/gamesense.ts GameSense REST client (coreProps discovery, heartbeat, cleanup)
src/lighting.ts handler + frame construction src/lighting.ts handler + frame construction
src/errors.ts `catch (err: unknown)` helpers 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/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-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-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 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 `tsconfig.json` emits CommonJS at ES2020, because Node for Max loads CJS on a
Node version we do not control. 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 `src/index.ts` is what joins the two halves, and it is usable on its own — no
inside `src/max/device.ts`: it already resolves a `LiveScale` on every `commit`, CLI concerns in it, and it ships type declarations alongside the compiled JS:
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 ```ts
import { ScaleLighting } from 'steelseries-live-scale'; 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 `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 ## Troubleshooting
@@ -245,3 +280,12 @@ as `root_note` and `scale_intervals`.
scale set. Click `refresh` in the patcher. scale set. Click `refresh` in the patcher.
- **Nothing changes when you pick a clip** — the device follows the Song by - **Nothing changes when you pick a clip** — the device follows the Song by
default; click `source clip`. 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.

View File

@@ -26,5 +26,23 @@ if (!fs.existsSync(compiled)) {
Max.POST_LEVELS.ERROR Max.POST_LEVELS.ERROR
); );
} else { } else {
require(compiled).start(Max); const device = require(compiled).start(Max);
// Node for Max kills this process when the device is deleted or the Live set
// is closed. Blank the board on the way out so the Apex 7 goes back to its
// SteelSeries GG profile rather than sitting on the last scale. Best effort:
// if the signal never arrives, GameSense drops the effect ~15s after the
// heartbeat stops anyway. The patcher's `closebang` covers device deletion,
// which is the case Live does not signal.
let leaving = false;
const leave = () => {
if (leaving) return;
leaving = true;
device
.shutdown()
.catch(() => {})
.then(() => process.exit(0));
};
process.on('SIGTERM', leave);
process.on('SIGINT', leave);
} }

View File

@@ -46,7 +46,7 @@
"numoutlets" : 0, "numoutlets" : 0,
"patching_rect" : [ 20.0, 15.0, 420.0, 20.0 ], "patching_rect" : [ 20.0, 15.0, 420.0, 20.0 ],
"fontsize" : 13.0, "fontsize" : 13.0,
"text" : "Ableton Scale Lighting — step 2: read the scale, print the keys" "text" : "Ableton Scale Lighting — the Apex 7 follows Live's scale"
} }
} }
@@ -58,7 +58,7 @@
"numoutlets" : 0, "numoutlets" : 0,
"linecount" : 3, "linecount" : 3,
"patching_rect" : [ 20.0, 40.0, 480.0, 47.0 ], "patching_rect" : [ 20.0, 40.0, 480.0, 47.0 ],
"text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them and prints the QWERTY keys to the Max console." "text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them, prints the QWERTY keys to the Max console and lights them on the keyboard through GameSense."
} }
} }
@@ -168,6 +168,90 @@
"text" : "verbose 1" "text" : "verbose 1"
} }
}
, {
"box" : {
"id" : "obj-18",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 326.0, 185.0, 56.0, 22.0 ],
"text" : "lights 1"
}
}
, {
"box" : {
"id" : "obj-19",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 390.0, 185.0, 56.0, 22.0 ],
"text" : "lights 0"
}
}
, {
"box" : {
"id" : "obj-20",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 454.0, 185.0, 44.0, 22.0 ],
"text" : "retry"
}
}
, {
"box" : {
"id" : "obj-21",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 1,
"outlettype" : [ "bang" ],
"patching_rect" : [ 430.0, 100.0, 66.0, 22.0 ],
"text" : "closebang"
}
}
, {
"box" : {
"id" : "obj-22",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 430.0, 140.0, 66.0, 22.0 ],
"text" : "shutdown"
}
}
, {
"box" : {
"id" : "obj-23",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 20.0, 320.0, 175.0, 22.0 ],
"text" : "address 127.0.0.1:51000"
}
}
, {
"box" : {
"id" : "obj-24",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 2,
"patching_rect" : [ 204.0, 317.0, 400.0, 33.0 ],
"text" : "Only for testing against tools/fake-gamesense.ts — leave it alone and the device finds SteelSeries GG through coreProps.json. Send \"address\" with no value to go back to discovery."
}
} }
, { , {
"box" : { "box" : {
@@ -200,7 +284,7 @@
"numoutlets" : 0, "numoutlets" : 0,
"linecount" : 2, "linecount" : 2,
"patching_rect" : [ 104.0, 272.0, 400.0, 33.0 ], "patching_rect" : [ 104.0, 272.0, 400.0, 33.0 ],
"text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root." "text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root. SteelSeries GG must be running for the keys to light."
} }
} }
@@ -294,6 +378,48 @@
"source" : [ "obj-11", 0 ] "source" : [ "obj-11", 0 ]
} }
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-18", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-19", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-20", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-22", 0 ],
"source" : [ "obj-21", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-22", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-23", 0 ]
}
} }
, { , {
"patchline" : { "patchline" : {

View File

@@ -1,9 +1,11 @@
/** /**
* Build step 2: the Node for Max side of the M4L device. * Build step 3: the Node for Max side of the M4L device — Live's scale on the
* keyboard.
* *
* `max/scale-observer.js` (a `js` object, the only place that can touch the * `max/scale-observer.js` (a `js` object, the only place that can touch the
* Live API) watches the LOM and forwards raw values here; this module turns * Live API) watches the LOM and forwards raw values here; this module turns
* them into a scale and prints the computed key set to the Max console. * them into a scale, prints the computed key set, and pushes it to the Apex 7
* through `ScaleLighting`.
* *
* Wire protocol from the observer, one field per message: * Wire protocol from the observer, one field per message:
* *
@@ -13,21 +15,29 @@
* mode 0|1 Song `scale_mode` * mode 0|1 Song `scale_mode`
* name <symbol> `scale_name`, e.g. "Whole Tone" * name <symbol> `scale_name`, e.g. "Whole Tone"
* intervals 0 2 4 ... `scale_intervals` (absent before Live 12.1) * intervals 0 2 4 ... `scale_intervals` (absent before Live 12.1)
* commit resolve everything above and report * commit resolve everything above, report, and light it
* *
* Plus two for hand-driving it from a message box: * Plus, for hand-driving it from a message box:
* *
* status re-print the current scale * status re-print the current scale and the lighting state
* verbose 0|1 log every commit, or only changes (default) * verbose 0|1 log every commit, or only changes (default)
* lights 0|1 stop / start driving the keyboard
* retry re-connect to GameSense and re-send the scale
* address <host:port> skip coreProps.json discovery (empty = discover)
* shutdown blank the board and hand it back to SteelSeries GG
* *
* Max is injected rather than `require`d so the module stays testable outside * Both Max and the lighting controller are injected rather than `require`d, so
* of Max; `max/scale-device.js` is the launcher that supplies the real one. * the whole device is testable outside of Max and off the hardware;
* `max/scale-device.js` is the launcher that supplies the real Max.
*/ */
import { errorMessage } from '../errors'; import { errorMessage } from '../errors';
import { ScaleLighting } from '../index';
import type { ScaleLightingOptions } from '../index';
import * as live from '../live'; import * as live from '../live';
import type { LiveScale, PartialLiveScale } from '../live'; import type { LiveScale, PartialLiveScale } from '../live';
import { noteName } from '../scale'; import { noteName } from '../scale';
import type { PitchClass } from '../scale';
/** The slice of the `max-api` module this device uses. */ /** The slice of the `max-api` module this device uses. */
export interface MaxApi { export interface MaxApi {
@@ -36,11 +46,36 @@ export interface MaxApi {
addHandler(name: string, fn: (...args: any[]) => void): void; addHandler(name: string, fn: (...args: any[]) => void): void;
} }
/** The slice of `ScaleLighting` this device drives. */
export interface Lights {
readonly isStarted: boolean;
start(): Promise<void>;
showScale(root: PitchClass, intervals: readonly number[]): Promise<unknown>;
stop(removeGame?: boolean): Promise<void>;
}
export interface DeviceOptions {
/** Build the lighting controller. Injected by tests. */
createLights?: (options: ScaleLightingOptions) => Lights;
/** Drive the keyboard from the start (default true). */
lights?: boolean;
/** Skip coreProps.json discovery, e.g. "127.0.0.1:51000". */
address?: string;
}
export interface Device { export interface Device {
/** The last successfully resolved scale, or null. */ /** The last successfully resolved scale, or null. */
readonly scale: LiveScale | null; readonly scale: LiveScale | null;
/** Fields received since the last `commit`/`reset`. */ /** Fields received since the last `commit`/`reset`. */
readonly pending: PartialLiveScale; readonly pending: PartialLiveScale;
/** The lighting controller, once anything has needed one. */
readonly lights: Lights | null;
/** Whether scales are being pushed to the keyboard at all. */
readonly lightsEnabled: boolean;
/** Resolves once the queued lighting work has settled. */
idle(): Promise<void>;
/** Blank the board and hand lighting back to SteelSeries GG. */
shutdown(): Promise<void>;
} }
const PREFIX = 'scale:'; const PREFIX = 'scale:';
@@ -48,14 +83,29 @@ const PREFIX = 'scale:';
/** /**
* Register the message handlers on a Max API object. * Register the message handlers on a Max API object.
* *
* Returns a handle mostly so tests (and step 3) can look at the resolved * Returns a handle so the launcher can shut the lighting down, and so tests can
* scale without going through Max. * look at the resolved scale without going through Max.
*/ */
export function start(max: MaxApi): Device { export function start(max: MaxApi, options: DeviceOptions = {}): Device {
const createLights =
options.createLights ?? ((opts: ScaleLightingOptions) => new ScaleLighting(opts));
let pending: PartialLiveScale = {}; let pending: PartialLiveScale = {};
let scale: LiveScale | null = null; let scale: LiveScale | null = null;
let verbose = false; let verbose = false;
let lightsEnabled = options.lights ?? true;
let address = options.address;
let lights: Lights | null = null;
/** The scale currently on the keyboard, so an unchanged commit costs nothing. */
let shown: LiveScale | null = null;
/** The scale the keyboard should be showing — read when a queued push runs. */
let wanted: LiveScale | null = null;
/** Last failure posted, so a keyboard that stays unplugged says so only once. */
let lastFailure = '';
/** All lighting work is serialized: a burst of commits must not interleave. */
let queue: Promise<void> = Promise.resolve();
const post = (msg: string) => max.post(`${PREFIX} ${msg}`); const post = (msg: string) => max.post(`${PREFIX} ${msg}`);
/** Any handler may be fed junk by a stray message box; never throw at Max. */ /** Any handler may be fed junk by a stray message box; never throw at Max. */
@@ -74,6 +124,68 @@ export function start(max: MaxApi): Device {
max.outlet('scale', live.summarizeScale(resolved)); max.outlet('scale', live.summarizeScale(resolved));
}; };
/* --- lighting ---------------------------------------------------------- */
const failed = (what: string, err: unknown) => {
// Whatever went wrong, the board no longer shows what we think it does.
shown = null;
const msg = `${what}: ${errorMessage(err)}`;
if (msg === lastFailure) return;
const first = !lastFailure;
lastFailure = msg;
post(msg);
if (first) {
post('the scale is still being tracked — fix that and click retry');
}
};
const enqueue = (what: string, fn: () => Promise<void>): Promise<void> => {
queue = queue.then(fn).catch((err: unknown) => failed(what, err));
return queue;
};
const pushLights = (): Promise<void> =>
enqueue('lighting', async () => {
const target = wanted;
if (!target || !lightsEnabled) return;
const controller = lights ?? (lights = createLights({ address, log: post }));
if (controller.isStarted && live.sameScale(shown, target)) return;
if (!controller.isStarted) {
await controller.start();
post('lighting on — the board is following Live');
max.outlet('lighting', 1);
}
await controller.showScale(target.root, target.intervals);
shown = target;
if (lastFailure) {
lastFailure = '';
post('lighting recovered');
}
if (verbose) post(`lit ${live.summarizeScale(target)}`);
});
/** Blank the board and drop the controller, so the next push rebuilds it. */
const dropLights = (): Promise<void> =>
enqueue('lighting stop', async () => {
const controller = lights;
lights = null;
shown = null;
if (!controller) return;
await controller.stop(true);
max.outlet('lighting', 0);
});
/** Re-create the controller — for anything that only applies at startup. */
const restartLights = (): Promise<void> => {
void dropLights();
return lightsEnabled ? pushLights() : queue;
};
/* --- messages from the observer ---------------------------------------- */
max.addHandler('reset', () => { max.addHandler('reset', () => {
pending = {}; pending = {};
}); });
@@ -113,12 +225,25 @@ export function start(max: MaxApi): Device {
const changed = !live.sameScale(scale, resolved); const changed = !live.sameScale(scale, resolved);
scale = resolved; scale = resolved;
if (changed || verbose) report(resolved); if (changed || verbose) report(resolved);
wanted = resolved;
void pushLights();
}); });
}); });
/* --- messages from the patcher ----------------------------------------- */
max.addHandler('status', () => { max.addHandler('status', () => {
if (scale) report(scale); if (scale) report(scale);
else post('no scale yet — is the device loaded in a Live set?'); else post('no scale yet — is the device loaded in a Live set?');
post(
lightsEnabled
? shown
? 'lighting on'
: 'lighting on, nothing pushed to the board yet'
: 'lighting off'
);
if (lastFailure) post(lastFailure);
}); });
max.addHandler('verbose', (value: unknown) => { max.addHandler('verbose', (value: unknown) => {
@@ -126,6 +251,40 @@ export function start(max: MaxApi): Device {
post(`verbose ${verbose ? 'on' : 'off'}`); post(`verbose ${verbose ? 'on' : 'off'}`);
}); });
max.addHandler('lights', (value: unknown) => {
const on = live.toBoolean(value);
if (on === lightsEnabled) return;
lightsEnabled = on;
if (on) {
lastFailure = '';
post('lighting enabled');
void pushLights();
} else {
post('lighting disabled — handing the board back to SteelSeries GG');
void dropLights();
}
});
// Deliberately keeps `lastFailure`: a retry against a still-broken GameSense
// stays quiet, and a successful one gets to say "recovered".
max.addHandler('retry', () => {
post('retrying');
void restartLights();
});
max.addHandler('address', (...args: unknown[]) => {
const value = args.map((a) => String(a)).join('').trim();
address = value || undefined;
post(`GameSense address: ${address ?? 'from coreProps.json'}`);
lastFailure = '';
void restartLights();
});
max.addHandler('shutdown', () => {
void dropLights();
});
post('ready — waiting for the scale from Live'); post('ready — waiting for the scale from Live');
max.outlet('ready', 1); max.outlet('ready', 1);
@@ -136,5 +295,18 @@ export function start(max: MaxApi): Device {
get pending() { get pending() {
return pending; return pending;
}, },
get lights() {
return lights;
},
get lightsEnabled() {
return lightsEnabled;
},
idle() {
return queue;
},
shutdown() {
lightsEnabled = false;
return dropLights();
},
}; };
} }

View File

@@ -20,24 +20,37 @@ import {
} from '../src/live'; } from '../src/live';
import type { LiveScale } from '../src/live'; import type { LiveScale } from '../src/live';
import { start } from '../src/max/device'; import { start } from '../src/max/device';
import type { MaxApi } from '../src/max/device'; import type { Device, Lights, MaxApi } from '../src/max/device';
import { SCALES } from '../src/scale'; import { SCALES } from '../src/scale';
import type { PitchClass } from '../src/scale';
/**
* Tests run in order on one promise chain: the device's lighting work is async,
* so a test may need to `await device.idle()` before asserting.
*/
let passed = 0; let passed = 0;
function test(name: string, fn: () => void): void { let chain: Promise<void> = Promise.resolve();
try {
fn(); function test(name: string, fn: () => void | Promise<void>): void {
passed++; chain = chain.then(async () => {
console.log(` ok ${name}`); try {
} catch (err) { await fn();
console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); passed++;
process.exitCode = 1; console.log(` ok ${name}`);
} } catch (err) {
console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`);
process.exitCode = 1;
}
});
}
function section(name: string): void {
chain = chain.then(() => console.log(name));
} }
const REPO = path.join(__dirname, '..', '..'); const REPO = path.join(__dirname, '..', '..');
console.log('Live scale names'); section('Live scale names');
test('Live scale names resolve, however they are spelled', () => { test('Live scale names resolve, however they are spelled', () => {
assert.deepStrictEqual(intervalsForScaleName('Major'), [0, 2, 4, 5, 7, 9, 11]); assert.deepStrictEqual(intervalsForScaleName('Major'), [0, 2, 4, 5, 7, 9, 11]);
@@ -84,7 +97,7 @@ test('name normalization collapses Live punctuation', () => {
assert.strictEqual(normalizeScaleName('Dorian #4'), 'dorian #4'); assert.strictEqual(normalizeScaleName('Dorian #4'), 'dorian #4');
}); });
console.log('coercion of Max atoms'); section('coercion of Max atoms');
test('atoms coerce the way Max sends them', () => { test('atoms coerce the way Max sends them', () => {
assert.strictEqual(toPitchClass(0), 0); assert.strictEqual(toPitchClass(0), 0);
@@ -109,7 +122,7 @@ test('atoms coerce the way Max sends them', () => {
assert.throws(() => toScaleSource('track')); assert.throws(() => toScaleSource('track'));
}); });
console.log('resolving what Live reports'); section('resolving what Live reports');
test('intervals from Live win over the name', () => { test('intervals from Live win over the name', () => {
const scale = resolveScale({ const scale = resolveScale({
@@ -153,7 +166,7 @@ test('scale equality ignores nothing that matters', () => {
assert.ok(sameScale(null, null)); assert.ok(sameScale(null, null));
}); });
console.log('reporting'); section('reporting');
test('C major from the LOM lights the home row', () => { test('C major from the LOM lights the home row', () => {
const scale = resolveScale({ root: 0, name: 'Major', intervals: SCALES.major }); const scale = resolveScale({ root: 0, name: 'Major', intervals: SCALES.major });
@@ -184,7 +197,7 @@ test('describeScale flags scale mode being off', () => {
assert.strictEqual(on.length, 6); assert.strictEqual(on.length, 6);
}); });
console.log('the Node for Max device'); section('the Node for Max device');
interface FakeMax extends MaxApi { interface FakeMax extends MaxApi {
handlers: Map<string, (...args: any[]) => void>; handlers: Map<string, (...args: any[]) => void>;
@@ -212,6 +225,46 @@ function fakeMax(): FakeMax {
}; };
} }
/** A stand-in for ScaleLighting: records what the keyboard was asked to do. */
interface FakeLights extends Omit<Lights, 'isStarted'> {
isStarted: boolean;
starts: number;
stops: number;
frames: { root: PitchClass; intervals: readonly number[] }[];
/** When set, every call rejects with it — a GameSense that is not there. */
fail: string | null;
}
function fakeLights(): FakeLights {
const lights: FakeLights = {
isStarted: false,
starts: 0,
stops: 0,
frames: [],
fail: null,
async start() {
if (lights.fail) throw new Error(lights.fail);
lights.starts++;
lights.isStarted = true;
},
async showScale(root, intervals) {
if (lights.fail) throw new Error(lights.fail);
lights.frames.push({ root, intervals });
return {};
},
async stop() {
lights.stops++;
lights.isStarted = false;
},
};
return lights;
}
/** The device, never touching real hardware. */
function startDevice(max: FakeMax, lights: FakeLights = fakeLights()): Device {
return start(max, { createLights: () => lights });
}
/** What max/scale-observer.js emits for one scale. */ /** What max/scale-observer.js emits for one scale. */
function observe(max: FakeMax, fields: Record<string, unknown[]>): void { function observe(max: FakeMax, fields: Record<string, unknown[]>): void {
max.send('reset'); max.send('reset');
@@ -221,7 +274,7 @@ function observe(max: FakeMax, fields: Record<string, unknown[]>): void {
test('the device registers every message the observer sends', () => { test('the device registers every message the observer sends', () => {
const max = fakeMax(); const max = fakeMax();
start(max); startDevice(max);
for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) { for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) {
assert.ok(max.handlers.has(name), `missing handler: ${name}`); assert.ok(max.handlers.has(name), `missing handler: ${name}`);
} }
@@ -230,7 +283,7 @@ test('the device registers every message the observer sends', () => {
test('a burst from the observer resolves to a scale', () => { test('a burst from the observer resolves to a scale', () => {
const max = fakeMax(); const max = fakeMax();
const device = start(max); const device = startDevice(max);
observe(max, { observe(max, {
source: ['song'], source: ['song'],
root: [2], root: [2],
@@ -254,7 +307,7 @@ test('a burst from the observer resolves to a scale', () => {
test('a multi-word scale name survives being split into atoms', () => { test('a multi-word scale name survives being split into atoms', () => {
const max = fakeMax(); const max = fakeMax();
const device = start(max); const device = startDevice(max);
observe(max, { root: [0], name: ['Whole', 'Tone'] }); observe(max, { root: [0], name: ['Whole', 'Tone'] });
assert.strictEqual(device.scale?.name, 'Whole Tone'); assert.strictEqual(device.scale?.name, 'Whole Tone');
assert.deepStrictEqual(device.scale?.intervals, [0, 2, 4, 6, 8, 10]); assert.deepStrictEqual(device.scale?.intervals, [0, 2, 4, 6, 8, 10]);
@@ -262,7 +315,7 @@ test('a multi-word scale name survives being split into atoms', () => {
test('an unchanged scale is not re-reported, a changed one is', () => { test('an unchanged scale is not re-reported, a changed one is', () => {
const max = fakeMax(); const max = fakeMax();
start(max); startDevice(max);
const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] }; const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
observe(max, fields); observe(max, fields);
@@ -276,7 +329,7 @@ test('an unchanged scale is not re-reported, a changed one is', () => {
test('verbose reports every commit', () => { test('verbose reports every commit', () => {
const max = fakeMax(); const max = fakeMax();
start(max); startDevice(max);
const fields = { root: [0], name: ['Major'] }; const fields = { root: [0], name: ['Major'] };
observe(max, fields); observe(max, fields);
@@ -288,7 +341,7 @@ test('verbose reports every commit', () => {
test('garbage from a message box is reported, not thrown', () => { test('garbage from a message box is reported, not thrown', () => {
const max = fakeMax(); const max = fakeMax();
const device = start(max); const device = startDevice(max);
max.send('root', 99); max.send('root', 99);
max.send('source', 'track'); max.send('source', 'track');
@@ -304,7 +357,7 @@ test('garbage from a message box is reported, not thrown', () => {
test('status prints the current scale, or says there is none', () => { test('status prints the current scale, or says there is none', () => {
const max = fakeMax(); const max = fakeMax();
start(max); startDevice(max);
max.send('status'); max.send('status');
assert.ok(max.posts.some((p) => p.includes('no scale yet'))); assert.ok(max.posts.some((p) => p.includes('no scale yet')));
@@ -314,7 +367,145 @@ test('status prints the current scale, or says there is none', () => {
assert.ok(max.posts.length > before); assert.ok(max.posts.length > before);
}); });
console.log('Max patcher'); section('driving the keyboard');
const C_MAJOR = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
test('a commit from Live reaches the keyboard', async () => {
const max = fakeMax();
const lights = fakeLights();
const device = startDevice(max, lights);
observe(max, { source: ['song'], root: [2], name: ['Dorian'], intervals: [0, 2, 3, 5, 7, 9, 10] });
await device.idle();
assert.strictEqual(lights.starts, 1, 'GameSense should be started once, lazily');
assert.deepStrictEqual(lights.frames, [{ root: 2, intervals: [0, 2, 3, 5, 7, 9, 10] }]);
assert.ok(max.posts.some((p) => p.includes('lighting on')));
assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 1));
});
test('an unchanged scale is not re-sent, a changed one is', async () => {
const max = fakeMax();
const lights = fakeLights();
const device = startDevice(max, lights);
observe(max, C_MAJOR);
observe(max, C_MAJOR);
await device.idle();
assert.strictEqual(lights.frames.length, 1, 'the same scale was pushed twice');
assert.strictEqual(lights.starts, 1, 'GameSense was re-registered');
observe(max, { ...C_MAJOR, root: [5] });
await device.idle();
assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]);
});
test('lights 0 hands the board back, lights 1 takes it again', async () => {
const max = fakeMax();
const lights = fakeLights();
const device = startDevice(max, lights);
observe(max, C_MAJOR);
await device.idle();
max.send('lights', 0);
await device.idle();
assert.strictEqual(lights.stops, 1);
assert.strictEqual(lights.isStarted, false);
assert.strictEqual(device.lightsEnabled, false);
assert.ok(max.outlets.some((o) => o[0] === 'lighting' && o[1] === 0));
// Live keeps changing scale while the lighting is off; nothing is sent...
observe(max, { ...C_MAJOR, root: [5] });
await device.idle();
assert.strictEqual(lights.frames.length, 1);
assert.ok(device.scale, 'the scale should still be tracked');
// ...but turning it back on catches the board up with the current scale.
max.send('lights', 1);
await device.idle();
assert.strictEqual(lights.starts, 2);
assert.deepStrictEqual(lights.frames.map((f) => f.root), [0, 5]);
});
test('a GameSense failure is reported once, and retry recovers', async () => {
const max = fakeMax();
const lights = fakeLights();
lights.fail = 'coreProps.json not found. Is SteelSeries GG running?';
const device = startDevice(max, lights);
observe(max, C_MAJOR);
await device.idle();
const complaints = () => max.posts.filter((p) => p.includes('coreProps.json')).length;
assert.strictEqual(complaints(), 1);
assert.ok(max.posts.some((p) => p.includes('retry')), 'no hint about how to recover');
// The same failure on every commit must not fill the Max console.
observe(max, { ...C_MAJOR, root: [5] });
observe(max, C_MAJOR);
await device.idle();
assert.strictEqual(complaints(), 1);
assert.ok(device.scale, 'a dead GameSense must not stop the device tracking Live');
lights.fail = null;
max.send('retry');
await device.idle();
assert.deepStrictEqual(lights.frames, [{ root: 0, intervals: C_MAJOR.intervals }]);
assert.ok(max.posts.some((p) => p.includes('recovered')));
});
test('address re-points the controller and re-sends', async () => {
const max = fakeMax();
const lights = fakeLights();
const addresses: (string | undefined)[] = [];
const device = start(max, {
createLights: (opts) => {
addresses.push(opts.address);
return lights;
},
});
observe(max, C_MAJOR);
await device.idle();
max.send('address', '127.0.0.1:51000');
await device.idle();
assert.deepStrictEqual(addresses, [undefined, '127.0.0.1:51000']);
assert.strictEqual(lights.stops, 1, 'the old registration should be removed');
assert.strictEqual(lights.frames.length, 2, 'the scale should be re-sent');
});
test('shutdown blanks the board', async () => {
const max = fakeMax();
const lights = fakeLights();
const device = startDevice(max, lights);
observe(max, C_MAJOR);
await device.idle();
await device.shutdown();
assert.strictEqual(lights.stops, 1);
assert.strictEqual(lights.isStarted, false);
assert.strictEqual(device.lights, null);
});
test('status says whether the board is being driven', async () => {
const max = fakeMax();
const device = startDevice(max);
observe(max, C_MAJOR);
await device.idle();
max.send('status');
assert.ok(max.posts.some((p) => p.endsWith('lighting on')));
max.send('lights', 0);
await device.idle();
max.send('status');
assert.ok(max.posts.some((p) => p.endsWith('lighting off')));
});
section('Max patcher');
test('scale-lighting.maxpat is valid JSON with the objects we wired', () => { test('scale-lighting.maxpat is valid JSON with the objects we wired', () => {
const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8'); const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8');
@@ -327,7 +518,19 @@ test('scale-lighting.maxpat is valid JSON with the objects we wired', () => {
const boxes = patch.patcher.boxes.map((b) => b.box); const boxes = patch.patcher.boxes.map((b) => b.box);
const texts = boxes.map((b) => b.text ?? ''); const texts = boxes.map((b) => b.text ?? '');
for (const expected of ['live.thisdevice', 'js scale-observer.js', 'midiin', 'midiout']) { const expectedTexts = [
'live.thisdevice',
'js scale-observer.js',
'midiin',
'midiout',
// Step 3: the lighting controls, and blanking the board on device deletion.
'lights 1',
'lights 0',
'retry',
'closebang',
'shutdown',
];
for (const expected of expectedTexts) {
assert.ok(texts.includes(expected), `patcher is missing ${expected}`); assert.ok(texts.includes(expected), `patcher is missing ${expected}`);
} }
assert.ok( assert.ok(
@@ -349,4 +552,6 @@ test('the scripts the patcher names exist next to it', () => {
} }
}); });
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`); void chain.then(() => {
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
});