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:
@@ -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' : ''}`);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user