From b8d01e3f590333774ff957488c8e7f3e3dbe6ac0 Mon Sep 17 00:00:00 2001 From: khannurien Date: Sat, 15 Aug 2026 09:00:42 +0000 Subject: [PATCH] Wire the Max for Live device to the keyboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- README.md | 100 ++++++++++----- max/scale-device.js | 20 ++- max/scale-lighting.maxpat | 132 +++++++++++++++++++- src/max/device.ts | 192 ++++++++++++++++++++++++++-- test/live.test.ts | 255 ++++++++++++++++++++++++++++++++++---- 5 files changed, 632 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index b58a311..24dc8f1 100644 --- a/README.md +++ b/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. diff --git a/max/scale-device.js b/max/scale-device.js index 9786276..f0e27a7 100644 --- a/max/scale-device.js +++ b/max/scale-device.js @@ -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); } diff --git a/max/scale-lighting.maxpat b/max/scale-lighting.maxpat index 4cd82d9..dcb93f7 100644 --- a/max/scale-lighting.maxpat +++ b/max/scale-lighting.maxpat @@ -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" : { diff --git a/src/max/device.ts b/src/max/device.ts index e950b65..6abc002 100644 --- a/src/max/device.ts +++ b/src/max/device.ts @@ -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 `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 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; + showScale(root: PitchClass, intervals: readonly number[]): Promise; + stop(removeGame?: boolean): Promise; +} + +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; + /** Blank the board and hand lighting back to SteelSeries GG. */ + shutdown(): Promise; } 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 = 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): Promise => { + queue = queue.then(fn).catch((err: unknown) => failed(what, err)); + return queue; + }; + + const pushLights = (): Promise => + 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 => + 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 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(); + }, }; } diff --git a/test/live.test.ts b/test/live.test.ts index 5f8ab55..5ecaa0f 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -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 { - try { - fn(); - passed++; - console.log(` ok ${name}`); - } catch (err) { - console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); - process.exitCode = 1; - } +let chain: Promise = Promise.resolve(); + +function test(name: string, fn: () => void | Promise): void { + chain = chain.then(async () => { + try { + 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 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 { + 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): void { max.send('reset'); @@ -221,7 +274,7 @@ function observe(max: FakeMax, fields: Record): 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' : ''}`); +});