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
|
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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" : {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
function test(name: string, fn: () => void | Promise<void>): void {
|
||||||
|
chain = chain.then(async () => {
|
||||||
try {
|
try {
|
||||||
fn();
|
await fn();
|
||||||
passed++;
|
passed++;
|
||||||
console.log(` ok ${name}`);
|
console.log(` ok ${name}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`);
|
console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`);
|
||||||
process.exitCode = 1;
|
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', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void chain.then(() => {
|
||||||
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
|
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user