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:
100
README.md
100
README.md
@@ -3,15 +3,16 @@
|
||||
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
|
||||
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. No lighting yet.
|
||||
3. Next — wire the two together so the board follows Live.
|
||||
4. Then — polish.
|
||||
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+.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
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.
|
||||
bound **once** at startup and a scale change is a single POST — which is what
|
||||
makes following Live mid-set cheap.
|
||||
|
||||
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,
|
||||
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 and the device's message handling, driven through a fake
|
||||
Max:
|
||||
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 (step 2)
|
||||
## The Max for Live device
|
||||
|
||||
`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.
|
||||
`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
|
||||
|
||||
@@ -132,10 +140,14 @@ born inside Live:
|
||||
Add this repo's folder in Live's browser (**Add Folder…**) to load the device
|
||||
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
|
||||
and each change prints:
|
||||
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
|
||||
@@ -163,6 +175,25 @@ both. Click the message boxes in the patcher:
|
||||
- `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
|
||||
@@ -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
|
||||
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.
|
||||
`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
|
||||
|
||||
@@ -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/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/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: 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-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
|
||||
@@ -203,14 +234,10 @@ 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
|
||||
## The seam: `ScaleLighting`
|
||||
|
||||
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:
|
||||
`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';
|
||||
@@ -224,7 +251,15 @@ await lights.stop();
|
||||
```
|
||||
|
||||
`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
|
||||
|
||||
@@ -245,3 +280,12 @@ as `root_note` and `scale_intervals`.
|
||||
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.
|
||||
|
||||
@@ -26,5 +26,23 @@ if (!fs.existsSync(compiled)) {
|
||||
Max.POST_LEVELS.ERROR
|
||||
);
|
||||
} 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);
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
"numoutlets" : 0,
|
||||
"patching_rect" : [ 20.0, 15.0, 420.0, 20.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,
|
||||
"linecount" : 3,
|
||||
"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"
|
||||
}
|
||||
|
||||
}
|
||||
, {
|
||||
"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" : {
|
||||
@@ -200,7 +284,7 @@
|
||||
"numoutlets" : 0,
|
||||
"linecount" : 2,
|
||||
"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 ]
|
||||
}
|
||||
|
||||
}
|
||||
, {
|
||||
"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" : {
|
||||
|
||||
@@ -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
|
||||
* 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:
|
||||
*
|
||||
@@ -13,21 +15,29 @@
|
||||
* mode 0|1 Song `scale_mode`
|
||||
* name <symbol> `scale_name`, e.g. "Whole Tone"
|
||||
* 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)
|
||||
* 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
|
||||
* of Max; `max/scale-device.js` is the launcher that supplies the real one.
|
||||
* Both Max and the lighting controller are injected rather than `require`d, so
|
||||
* 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 { ScaleLighting } from '../index';
|
||||
import type { ScaleLightingOptions } from '../index';
|
||||
import * as live from '../live';
|
||||
import type { LiveScale, PartialLiveScale } from '../live';
|
||||
import { noteName } from '../scale';
|
||||
import type { PitchClass } from '../scale';
|
||||
|
||||
/** The slice of the `max-api` module this device uses. */
|
||||
export interface MaxApi {
|
||||
@@ -36,11 +46,36 @@ export interface MaxApi {
|
||||
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 {
|
||||
/** The last successfully resolved scale, or null. */
|
||||
readonly scale: LiveScale | null;
|
||||
/** Fields received since the last `commit`/`reset`. */
|
||||
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:';
|
||||
@@ -48,14 +83,29 @@ const PREFIX = 'scale:';
|
||||
/**
|
||||
* Register the message handlers on a Max API object.
|
||||
*
|
||||
* Returns a handle mostly so tests (and step 3) can look at the resolved
|
||||
* scale without going through Max.
|
||||
* Returns a handle so the launcher can shut the lighting down, and so tests can
|
||||
* 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 scale: LiveScale | null = null;
|
||||
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}`);
|
||||
|
||||
/** 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));
|
||||
};
|
||||
|
||||
/* --- 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', () => {
|
||||
pending = {};
|
||||
});
|
||||
@@ -113,12 +225,25 @@ export function start(max: MaxApi): Device {
|
||||
const changed = !live.sameScale(scale, resolved);
|
||||
scale = resolved;
|
||||
if (changed || verbose) report(resolved);
|
||||
|
||||
wanted = resolved;
|
||||
void pushLights();
|
||||
});
|
||||
});
|
||||
|
||||
/* --- messages from the patcher ----------------------------------------- */
|
||||
|
||||
max.addHandler('status', () => {
|
||||
if (scale) report(scale);
|
||||
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) => {
|
||||
@@ -126,6 +251,40 @@ export function start(max: MaxApi): Device {
|
||||
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');
|
||||
max.outlet('ready', 1);
|
||||
|
||||
@@ -136,5 +295,18 @@ export function start(max: MaxApi): Device {
|
||||
get pending() {
|
||||
return pending;
|
||||
},
|
||||
get lights() {
|
||||
return lights;
|
||||
},
|
||||
get lightsEnabled() {
|
||||
return lightsEnabled;
|
||||
},
|
||||
idle() {
|
||||
return queue;
|
||||
},
|
||||
shutdown() {
|
||||
lightsEnabled = false;
|
||||
return dropLights();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,24 +20,37 @@ import {
|
||||
} from '../src/live';
|
||||
import type { LiveScale } from '../src/live';
|
||||
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 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;
|
||||
function test(name: string, fn: () => void): void {
|
||||
let chain: Promise<void> = Promise.resolve();
|
||||
|
||||
function test(name: string, fn: () => void | Promise<void>): void {
|
||||
chain = chain.then(async () => {
|
||||
try {
|
||||
fn();
|
||||
await fn();
|
||||
passed++;
|
||||
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, '..', '..');
|
||||
|
||||
console.log('Live scale names');
|
||||
section('Live scale names');
|
||||
|
||||
test('Live scale names resolve, however they are spelled', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
console.log('coercion of Max atoms');
|
||||
section('coercion of Max atoms');
|
||||
|
||||
test('atoms coerce the way Max sends them', () => {
|
||||
assert.strictEqual(toPitchClass(0), 0);
|
||||
@@ -109,7 +122,7 @@ test('atoms coerce the way Max sends them', () => {
|
||||
assert.throws(() => toScaleSource('track'));
|
||||
});
|
||||
|
||||
console.log('resolving what Live reports');
|
||||
section('resolving what Live reports');
|
||||
|
||||
test('intervals from Live win over the name', () => {
|
||||
const scale = resolveScale({
|
||||
@@ -153,7 +166,7 @@ test('scale equality ignores nothing that matters', () => {
|
||||
assert.ok(sameScale(null, null));
|
||||
});
|
||||
|
||||
console.log('reporting');
|
||||
section('reporting');
|
||||
|
||||
test('C major from the LOM lights the home row', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
console.log('the Node for Max device');
|
||||
section('the Node for Max device');
|
||||
|
||||
interface FakeMax extends MaxApi {
|
||||
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. */
|
||||
function observe(max: FakeMax, fields: Record<string, unknown[]>): void {
|
||||
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', () => {
|
||||
const max = fakeMax();
|
||||
start(max);
|
||||
startDevice(max);
|
||||
for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) {
|
||||
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', () => {
|
||||
const max = fakeMax();
|
||||
const device = start(max);
|
||||
const device = startDevice(max);
|
||||
observe(max, {
|
||||
source: ['song'],
|
||||
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', () => {
|
||||
const max = fakeMax();
|
||||
const device = start(max);
|
||||
const device = startDevice(max);
|
||||
observe(max, { root: [0], name: ['Whole', 'Tone'] });
|
||||
assert.strictEqual(device.scale?.name, 'Whole Tone');
|
||||
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', () => {
|
||||
const max = fakeMax();
|
||||
start(max);
|
||||
startDevice(max);
|
||||
const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
|
||||
|
||||
observe(max, fields);
|
||||
@@ -276,7 +329,7 @@ test('an unchanged scale is not re-reported, a changed one is', () => {
|
||||
|
||||
test('verbose reports every commit', () => {
|
||||
const max = fakeMax();
|
||||
start(max);
|
||||
startDevice(max);
|
||||
const fields = { root: [0], name: ['Major'] };
|
||||
|
||||
observe(max, fields);
|
||||
@@ -288,7 +341,7 @@ test('verbose reports every commit', () => {
|
||||
|
||||
test('garbage from a message box is reported, not thrown', () => {
|
||||
const max = fakeMax();
|
||||
const device = start(max);
|
||||
const device = startDevice(max);
|
||||
|
||||
max.send('root', 99);
|
||||
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', () => {
|
||||
const max = fakeMax();
|
||||
start(max);
|
||||
startDevice(max);
|
||||
max.send('status');
|
||||
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);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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 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(
|
||||
@@ -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' : ''}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user