Wire the Max for Live device to the keyboard

Step 3 of the brief: the device now holds a ScaleLighting and pushes every
scale Live reports to the Apex 7, instead of only printing it.

The controller is created lazily and injected (start(max, { createLights })),
so the device stays testable with no Max and no hardware. Around the call:
lighting work is serialized on one queue so a burst of LOM changes cannot
interleave two POSTs, an unchanged scale is never re-sent, and a GameSense
that is missing or restarted is printed once rather than on every scale
change — the scale keeps being tracked so a later retry lands on the right one.

New messages: lights 0|1, retry, address <host:port>, shutdown. The patcher
gets boxes for them plus closebang -> shutdown, and the launcher blanks the
board on SIGTERM/SIGINT, so deleting the device hands lighting back to GG.

The test runner now supports async tests, since the lighting half is async.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-15 09:00:42 +00:00
parent 2f0d5841c6
commit b8d01e3f59
5 changed files with 632 additions and 67 deletions

View File

@@ -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<void> = Promise.resolve();
function test(name: string, fn: () => void | Promise<void>): 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<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' : ''}`);
});