Files
steelseries-live-scale/test/live.test.ts
khannurien fd5bb786e5 Add an Audio Effect variant of the device, alongside the MIDI one
The device is a pure observer — it reads the LOM and talks to GameSense and
never looks at a note — so it does not have to sit in the path of anything you
play. But a Max MIDI Effect without midiin -> midiout swallows MIDI instead of
passing it on, so the shipped device routes every note you play through Max's
scheduler on its way to your instrument. Pointless cost for this device.

max/scale-lighting-audio.maxpat is the same patcher as an Audio Effect,
declaring no I/O at all: no midiin/midiout, no plugin~/plugout~. Park it on a
dedicated empty Audio track and it is provably out of every signal path.

The two patchers differ by exactly midiin, midiout and the patchline between
them — @watch 1 and everything else are deliberately left identical on both
sides, so an A/B measures the passthrough and nothing else. Both register the
same GameSense game, so they have to be loaded one at a time; that and a way to
actually measure the difference (compare note onsets across recorded takes, not
the CPU meter) are in the README.

The patcher test now runs over both files, and two new tests pin the
distinction: the MIDI variant must wire midiin to midiout, and the audio variant
must declare none of those four objects — the kind of thing a later edit would
otherwise silently undo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 12:17:57 +00:00

598 lines
20 KiB
TypeScript

import assert from 'assert';
import fs from 'fs';
import path from 'path';
import {
LIVE_SCALE_INTERVALS,
describeScale,
intervalsForScaleName,
keyDiagram,
keysOf,
normalizeScaleName,
pitchClassesOf,
resolveScale,
sameScale,
summarizeScale,
toBoolean,
toIntervals,
toPitchClass,
toScaleSource,
} from '../src/live';
import type { LiveScale } from '../src/live';
import { start } 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;
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, '..', '..');
section('Live scale names');
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]);
assert.deepStrictEqual(intervalsForScaleName('Whole Tone'), [0, 2, 4, 6, 8, 10]);
assert.deepStrictEqual(intervalsForScaleName('whole_tone'), [0, 2, 4, 6, 8, 10]);
assert.deepStrictEqual(intervalsForScaleName('Half-whole Dim.'), [0, 1, 3, 4, 6, 7, 9, 10]);
assert.deepStrictEqual(intervalsForScaleName('Minor Pentatonic'), [0, 3, 5, 7, 10]);
assert.strictEqual(intervalsForScaleName('Bebop Dominant'), null);
assert.strictEqual(intervalsForScaleName(''), null);
});
test('aliases point at the same intervals', () => {
assert.deepStrictEqual(intervalsForScaleName('Aeolian'), LIVE_SCALE_INTERVALS.Minor);
assert.deepStrictEqual(intervalsForScaleName('Ionian'), LIVE_SCALE_INTERVALS.Major);
assert.deepStrictEqual(intervalsForScaleName('Blues'), LIVE_SCALE_INTERVALS['Minor Blues']);
});
test('the fallback table agrees with the CLI presets', () => {
assert.deepStrictEqual(intervalsForScaleName('Major'), SCALES.major);
assert.deepStrictEqual(intervalsForScaleName('Minor'), SCALES.minor);
assert.deepStrictEqual(intervalsForScaleName('Dorian'), SCALES.dorian);
assert.deepStrictEqual(intervalsForScaleName('Harmonic Minor'), SCALES['harmonic-minor']);
assert.deepStrictEqual(intervalsForScaleName('Minor Blues'), SCALES.blues);
assert.deepStrictEqual(intervalsForScaleName('Chromatic'), SCALES.chromatic);
});
test('every scale in the table is a sane interval set', () => {
for (const [name, intervals] of Object.entries(LIVE_SCALE_INTERVALS)) {
assert.ok(intervals.length >= 5, `${name} is suspiciously short`);
assert.strictEqual(intervals[0], 0, `${name} does not start on the root`);
assert.strictEqual(new Set(intervals).size, intervals.length, `${name} has duplicates`);
for (const i of intervals) {
assert.ok(Number.isInteger(i) && i >= 0 && i < 12, `${name} has a bad interval ${i}`);
}
const ascending = [...intervals].sort((a, b) => a - b);
assert.deepStrictEqual(intervals, ascending, `${name} is not ascending`);
}
});
test('name normalization collapses Live punctuation', () => {
assert.strictEqual(normalizeScaleName(' Half-whole Dim. '), 'half whole dim');
assert.strictEqual(normalizeScaleName('Major_Pentatonic'), 'major pentatonic');
assert.strictEqual(normalizeScaleName('Dorian #4'), 'dorian #4');
});
section('coercion of Max atoms');
test('atoms coerce the way Max sends them', () => {
assert.strictEqual(toPitchClass(0), 0);
assert.strictEqual(toPitchClass(7.0), 7);
assert.strictEqual(toPitchClass('11'), 11);
assert.strictEqual(toPitchClass('F#'), 6);
assert.throws(() => toPitchClass(12));
assert.deepStrictEqual(toIntervals([0, 2, 4, 5, 7, 9, 11]), [0, 2, 4, 5, 7, 9, 11]);
assert.deepStrictEqual(toIntervals(['0', '3', '7']), [0, 3, 7]);
assert.throws(() => toIntervals([]));
assert.strictEqual(toBoolean(1), true);
assert.strictEqual(toBoolean(0), false);
assert.strictEqual(toBoolean('0'), false);
assert.strictEqual(toBoolean('off'), false);
assert.strictEqual(toBoolean(undefined), true);
assert.strictEqual(toBoolean(undefined, false), false);
assert.strictEqual(toScaleSource('song'), 'song');
assert.strictEqual(toScaleSource('Clip'), 'clip');
assert.throws(() => toScaleSource('track'));
});
section('resolving what Live reports');
test('intervals from Live win over the name', () => {
const scale = resolveScale({
root: 2,
name: 'Major',
// A user-edited scale in Live 12.1 keeps the name but changes the notes.
intervals: [0, 2, 4, 5, 7, 9, 10],
scaleMode: true,
source: 'song',
});
assert.deepStrictEqual(scale.intervals, [0, 2, 4, 5, 7, 9, 10]);
});
test('a missing scale_intervals falls back to the name (pre-12.1 Live)', () => {
const scale = resolveScale({ root: 9, name: 'Dorian' });
assert.deepStrictEqual(scale.intervals, [0, 2, 3, 5, 7, 9, 10]);
assert.strictEqual(scale.source, 'song');
assert.strictEqual(scale.scaleMode, true);
});
test('unresolvable input explains itself', () => {
assert.throws(() => resolveScale({}), /root note/);
assert.throws(() => resolveScale({ root: 0 }), /neither/);
assert.throws(() => resolveScale({ root: 0, name: 'Bebop' }), /not a scale we know/);
});
test('scale equality ignores nothing that matters', () => {
const base: LiveScale = {
root: 0,
intervals: [0, 2, 4, 5, 7, 9, 11],
name: 'Major',
scaleMode: true,
source: 'song',
};
assert.ok(sameScale(base, { ...base }));
assert.ok(!sameScale(base, { ...base, root: 1 }));
assert.ok(!sameScale(base, { ...base, source: 'clip' }));
assert.ok(!sameScale(base, { ...base, scaleMode: false }));
assert.ok(!sameScale(base, { ...base, intervals: [0, 2, 4, 5, 7, 9, 10] }));
assert.ok(!sameScale(base, null));
assert.ok(sameScale(null, null));
});
section('reporting');
test('C major from the LOM lights the home row', () => {
const scale = resolveScale({ root: 0, name: 'Major', intervals: SCALES.major });
assert.deepStrictEqual(pitchClassesOf(scale), [0, 2, 4, 5, 7, 9, 11]);
assert.deepStrictEqual(keysOf(scale), ['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']);
assert.match(summarizeScale(scale), /^C Major \(song\) — A S D F G H J K$/);
});
test('the key diagram lines up with the mapping table in the brief', () => {
const [header, marks] = keyDiagram(pitchClassesOf(
resolveScale({ root: 0, name: 'Major', intervals: SCALES.major })
));
assert.strictEqual(header, 'A W S E D F T G Y H U J K');
assert.strictEqual(marks, '* . * . * * . * . * . * *');
assert.strictEqual(header.length, marks.length);
});
test('F# major marks the black keys', () => {
const scale = resolveScale({ root: 6, name: 'Major', intervals: SCALES.major });
assert.deepStrictEqual(keysOf(scale), ['W', 'E', 'F', 'T', 'Y', 'U', 'J']);
});
test('describeScale flags scale mode being off', () => {
const on = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: true }));
const off = describeScale(resolveScale({ root: 0, name: 'Major', scaleMode: false }));
assert.ok(!on[0].includes('OFF'));
assert.ok(off[0].includes('OFF'));
assert.strictEqual(on.length, 6);
});
section('the Node for Max device');
interface FakeMax extends MaxApi {
handlers: Map<string, (...args: any[]) => void>;
posts: string[];
outlets: unknown[][];
send(name: string, ...args: unknown[]): void;
}
function fakeMax(): FakeMax {
const handlers = new Map<string, (...args: any[]) => void>();
const posts: string[] = [];
const outlets: unknown[][] = [];
return {
handlers,
posts,
outlets,
post: (...args: unknown[]) => posts.push(args.map((a) => String(a)).join(' ')),
outlet: (...args: unknown[]) => outlets.push(args),
addHandler: (name, fn) => handlers.set(name, fn),
send(name, ...args) {
const handler = handlers.get(name);
if (!handler) throw new Error(`no handler for ${name}`);
handler(...args);
},
};
}
/** 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');
for (const [name, args] of Object.entries(fields)) max.send(name, ...args);
max.send('commit');
}
test('the device registers every message the observer sends', () => {
const max = fakeMax();
startDevice(max);
for (const name of ['reset', 'source', 'root', 'mode', 'name', 'intervals', 'commit']) {
assert.ok(max.handlers.has(name), `missing handler: ${name}`);
}
assert.ok(max.posts.some((p) => p.includes('ready')));
});
test('a burst from the observer resolves to a scale', () => {
const max = fakeMax();
const device = startDevice(max);
observe(max, {
source: ['song'],
root: [2],
mode: [1],
name: ['Dorian'],
intervals: [0, 2, 3, 5, 7, 9, 10],
});
assert.ok(device.scale);
assert.strictEqual(device.scale.root, 2);
assert.strictEqual(device.scale.name, 'Dorian');
assert.deepStrictEqual(device.pending, {}, 'commit should clear the pending fields');
// D dorian is the white keys, so the notes outlet is what pins the root down.
const notes = max.outlets.find((o) => o[0] === 'notes');
assert.deepStrictEqual(notes, ['notes', 'C', 'D', 'E', 'F', 'G', 'A', 'B']);
const keys = max.outlets.find((o) => o[0] === 'keys');
assert.deepStrictEqual(keys, ['keys', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K']);
assert.ok(max.posts.some((p) => p.includes('D Dorian')));
});
test('a multi-word scale name survives being split into atoms', () => {
const max = fakeMax();
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]);
});
test('an unchanged scale is not re-reported, a changed one is', () => {
const max = fakeMax();
startDevice(max);
const fields = { root: [0], name: ['Major'], intervals: [0, 2, 4, 5, 7, 9, 11] };
observe(max, fields);
const afterFirst = max.posts.length;
observe(max, fields);
assert.strictEqual(max.posts.length, afterFirst, 'identical scale re-printed');
observe(max, { ...fields, root: [5] });
assert.ok(max.posts.length > afterFirst, 'changed scale not printed');
});
test('verbose reports every commit', () => {
const max = fakeMax();
startDevice(max);
const fields = { root: [0], name: ['Major'] };
observe(max, fields);
max.send('verbose', 1);
const before = max.posts.length;
observe(max, fields);
assert.ok(max.posts.length > before);
});
test('garbage from a message box is reported, not thrown', () => {
const max = fakeMax();
const device = startDevice(max);
max.send('root', 99);
max.send('source', 'track');
max.send('intervals', 'x');
max.send('commit');
assert.strictEqual(device.scale, null);
assert.ok(max.posts.some((p) => p.includes('root:')));
assert.ok(max.posts.some((p) => p.includes('source:')));
assert.ok(max.posts.some((p) => p.includes('intervals:')));
assert.ok(max.posts.some((p) => p.includes('commit:')));
});
test('status prints the current scale, or says there is none', () => {
const max = fakeMax();
startDevice(max);
max.send('status');
assert.ok(max.posts.some((p) => p.includes('no scale yet')));
observe(max, { root: [0], name: ['Major'] });
const before = max.posts.length;
max.send('status');
assert.ok(max.posts.length > before);
});
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');
interface Patcher {
patcher: {
boxes: { box: { id: string; text?: string } }[];
lines: { patchline: { source: [string, number]; destination: [string, number] } }[];
};
}
function readPatcher(file: string): Patcher {
return JSON.parse(fs.readFileSync(path.join(REPO, 'max', file), 'utf8')) as Patcher;
}
// Both variants drive the same device; they differ only in what they let
// through, which is the whole reason the audio one exists.
for (const file of ['scale-lighting.maxpat', 'scale-lighting-audio.maxpat']) {
test(`${file} is valid JSON with the objects we wired`, () => {
const patch = readPatcher(file);
const boxes = patch.patcher.boxes.map((b) => b.box);
const texts = boxes.map((b) => b.text ?? '');
const expectedTexts = [
'live.thisdevice',
'js scale-observer.js',
// 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), `${file} is missing ${expected}`);
}
assert.ok(
texts.some((t) => t.startsWith('node.script scale-device.js')),
`${file} is missing node.script`
);
// Every patch cord must point at a box that exists.
const ids = new Set(boxes.map((b) => b.id));
for (const { patchline } of patch.patcher.lines) {
assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`);
assert.ok(
ids.has(patchline.destination[0]),
`dangling destination ${patchline.destination[0]}`
);
}
});
}
// A Max MIDI Effect without this swallows MIDI instead of passing it on.
test('the MIDI variant passes MIDI through', () => {
const patch = readPatcher('scale-lighting.maxpat');
const boxes = patch.patcher.boxes.map((b) => b.box);
const idOf = (text: string) => boxes.find((b) => b.text === text)?.id;
const from = idOf('midiin');
const to = idOf('midiout');
assert.ok(from && to, 'patcher is missing midiin/midiout');
assert.ok(
patch.patcher.lines.some(
({ patchline }) => patchline.source[0] === from && patchline.destination[0] === to
),
'midiin is not connected to midiout'
);
});
// The point of the audio variant: it declares no I/O, so nothing the user plays
// is ever routed through Max. Adding a passthrough here would silently undo it.
test('the audio variant is not in the path of anything', () => {
const texts = readPatcher('scale-lighting-audio.maxpat').patcher.boxes.map(
(b) => b.box.text ?? ''
);
for (const forbidden of ['midiin', 'midiout', 'plugin~', 'plugout~']) {
assert.ok(
!texts.includes(forbidden),
`the audio variant must not declare I/O, but has ${forbidden}`
);
}
});
test('the scripts the patcher names exist next to it', () => {
for (const file of ['scale-observer.js', 'scale-device.js']) {
assert.ok(fs.existsSync(path.join(REPO, 'max', file)), `max/${file} is missing`);
}
});
void chain.then(() => {
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
});