Move the standalone GameSense scale lighting to TypeScript ahead of the
Max for Live work, so step 3 gets type declarations at the seam.
Build emits CommonJS at ES2020 into dist/, because Node for Max loads CJS
on a Node version we do not control. bin/apex7-scale.js becomes a plain-JS
launcher so `node bin/apex7-scale.js` keeps working; the CLI itself moves
to src/cli.ts.
Two modules make previously implicit structure explicit:
- src/protocol.ts, the GameSense wire types, so the transport and the
payload construction agree on shapes neither of them owns
- src/errors.ts, since `catch (err)` binds `unknown` under strict
Typing surfaced a few real fixes:
- ScaleLighting.started was a public field the CLI set by hand to make
--off work without start(); that is now release(), with started
private behind isStarted
- readAddress() trusted JSON.parse output; the address is now checked
for being a non-empty string
- res.statusCode is number|undefined, so the old `>= 200` comparison
coerced silently; a missing status now rejects
- parseIntervals claimed to dedupe and sort, and never did (comment
corrected; pitchClassesFor is what dedupes)
Behavior is otherwise unchanged: all 18 tests pass and --once, --off,
--demo, --key-test, the resident stdin loop and the error paths were
verified against tools/fake-gamesense.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
7.1 KiB
TypeScript
219 lines
7.1 KiB
TypeScript
import assert from 'assert';
|
|
|
|
import { splitAddress } from '../src/gamesense';
|
|
import { ALL_KEYS, HID } from '../src/hid';
|
|
import {
|
|
BACKGROUND_FRAME_KEY,
|
|
BACKGROUND_KEYS,
|
|
buildBlackFrame,
|
|
buildFrame,
|
|
buildHandlers,
|
|
parseColor,
|
|
} from '../src/lighting';
|
|
import {
|
|
NOTE_KEYS,
|
|
NOTE_KEY_HIDS,
|
|
SCALES,
|
|
keysFor,
|
|
noteName,
|
|
parseIntervals,
|
|
parseRoot,
|
|
parseScale,
|
|
pitchClassesFor,
|
|
} from '../src/scale';
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
console.log('scale math');
|
|
|
|
test('root parsing accepts names, accidentals and numbers', () => {
|
|
assert.strictEqual(parseRoot('C'), 0);
|
|
assert.strictEqual(parseRoot('c'), 0);
|
|
assert.strictEqual(parseRoot('F#'), 6);
|
|
assert.strictEqual(parseRoot('Gb'), 6);
|
|
assert.strictEqual(parseRoot('Bb'), 10);
|
|
assert.strictEqual(parseRoot('B'), 11);
|
|
assert.strictEqual(parseRoot(7), 7);
|
|
assert.strictEqual(parseRoot('11'), 11);
|
|
assert.throws(() => parseRoot('H'));
|
|
assert.throws(() => parseRoot(12));
|
|
});
|
|
|
|
test('scale names normalize', () => {
|
|
assert.deepStrictEqual(parseScale('major'), [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(parseScale('Harmonic Minor'), [0, 2, 3, 5, 7, 8, 11]);
|
|
assert.deepStrictEqual(parseScale('minor_pentatonic'), [0, 3, 5, 7, 10]);
|
|
assert.throws(() => parseScale('bebop'));
|
|
});
|
|
|
|
test('raw interval lists parse', () => {
|
|
assert.deepStrictEqual(parseIntervals('0,2,4,7,9'), [0, 2, 4, 7, 9]);
|
|
assert.deepStrictEqual(parseIntervals([0, 3, 7]), [0, 3, 7]);
|
|
assert.throws(() => parseIntervals(''));
|
|
assert.throws(() => parseIntervals('0,x'));
|
|
});
|
|
|
|
test('C major lights the seven white keys', () => {
|
|
const pcs = pitchClassesFor(0, SCALES.major);
|
|
assert.deepStrictEqual(pcs, [0, 2, 4, 5, 7, 9, 11]);
|
|
assert.deepStrictEqual(pcs.map((pc) => noteName(pc)), ['C', 'D', 'E', 'F', 'G', 'A', 'B']);
|
|
// Home row plus the octave key.
|
|
assert.deepStrictEqual(keysFor(pcs), ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']);
|
|
});
|
|
|
|
test('F# major wraps around the octave correctly', () => {
|
|
const pcs = pitchClassesFor(6, SCALES.major);
|
|
assert.deepStrictEqual(pcs, [1, 3, 6, 8, 10, 11, 5].sort((a, b) => a - b));
|
|
assert.deepStrictEqual(
|
|
pcs.map((pc) => noteName(pc)).sort(),
|
|
['A#', 'B', 'C#', 'D#', 'F', 'F#', 'G#']
|
|
);
|
|
});
|
|
|
|
test('A minor is the same pitch classes as C major', () => {
|
|
assert.deepStrictEqual(
|
|
pitchClassesFor(9, SCALES.minor),
|
|
pitchClassesFor(0, SCALES.major)
|
|
);
|
|
});
|
|
|
|
test('chromatic lights every note key', () => {
|
|
const pcs = pitchClassesFor(0, SCALES.chromatic);
|
|
assert.strictEqual(pcs.length, 12);
|
|
assert.strictEqual(keysFor(pcs).length, NOTE_KEYS.length);
|
|
});
|
|
|
|
console.log('key mapping');
|
|
|
|
test('note keys match the Ableton layout table in the brief', () => {
|
|
const expected = [
|
|
['a', 0], ['w', 1], ['s', 2], ['e', 3], ['d', 4], ['f', 5],
|
|
['t', 6], ['g', 7], ['y', 8], ['h', 9], ['u', 10], ['j', 11],
|
|
['k', 0],
|
|
];
|
|
assert.deepStrictEqual(
|
|
NOTE_KEYS.map((n) => [n.key, n.pitchClass]),
|
|
expected
|
|
);
|
|
});
|
|
|
|
test('HID codes match the USB HID keyboard page', () => {
|
|
const expected: Record<string, number> = {
|
|
a: 0x04, w: 0x1a, s: 0x16, e: 0x08, d: 0x07, f: 0x09, t: 0x17,
|
|
g: 0x0a, y: 0x1c, h: 0x0b, u: 0x18, j: 0x0d, k: 0x0e,
|
|
};
|
|
for (const note of NOTE_KEYS) {
|
|
assert.strictEqual(note.hid, expected[note.key], `HID for ${note.key}`);
|
|
}
|
|
});
|
|
|
|
test('HID table has no duplicate codes', () => {
|
|
assert.strictEqual(new Set(ALL_KEYS).size, ALL_KEYS.length);
|
|
});
|
|
|
|
test('frame keys are unique', () => {
|
|
const keys = NOTE_KEYS.map((n) => n.frameKey);
|
|
assert.strictEqual(new Set(keys).size, keys.length);
|
|
assert.ok(!keys.includes(BACKGROUND_FRAME_KEY));
|
|
});
|
|
|
|
console.log('handlers and frames');
|
|
|
|
test('background zone covers every key except the note keys', () => {
|
|
assert.strictEqual(BACKGROUND_KEYS.length, ALL_KEYS.length - NOTE_KEY_HIDS.length);
|
|
for (const hid of NOTE_KEY_HIDS) {
|
|
assert.ok(!BACKGROUND_KEYS.includes(hid), `note key ${hid} leaked into background`);
|
|
}
|
|
assert.ok(BACKGROUND_KEYS.includes(HID.space));
|
|
assert.ok(BACKGROUND_KEYS.includes(HID.z), 'octave-down key is background');
|
|
});
|
|
|
|
test('handlers: one background + one per note key, all context-color', () => {
|
|
const handlers = buildHandlers();
|
|
assert.strictEqual(handlers.length, 1 + NOTE_KEYS.length);
|
|
for (const h of handlers) {
|
|
assert.strictEqual(h['device-type'], 'rgb-per-key-zones');
|
|
assert.strictEqual(h.mode, 'context-color');
|
|
assert.ok(Array.isArray(h['custom-zone-keys']));
|
|
assert.ok(h['custom-zone-keys'].length > 0);
|
|
assert.strictEqual(typeof h['context-frame-key'], 'string');
|
|
}
|
|
// Every context-frame-key a handler asks for must exist in a frame.
|
|
const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0 });
|
|
for (const h of handlers) {
|
|
assert.ok(
|
|
h['context-frame-key'] in frame,
|
|
`frame is missing ${h['context-frame-key']}`
|
|
);
|
|
}
|
|
});
|
|
|
|
test('frame colors the root, the scale and the rest distinctly', () => {
|
|
const colors = {
|
|
background: '#0a0a0f',
|
|
scale: '#00b4ff',
|
|
root: '#ff5a00',
|
|
off: '#000000',
|
|
};
|
|
const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0, colors });
|
|
|
|
assert.deepStrictEqual(frame[BACKGROUND_FRAME_KEY], { red: 10, green: 10, blue: 15 });
|
|
assert.deepStrictEqual(frame['note-a'], { red: 255, green: 90, blue: 0 }); // C = root
|
|
assert.deepStrictEqual(frame['note-d'], { red: 0, green: 180, blue: 255 }); // E
|
|
assert.deepStrictEqual(frame['note-g'], { red: 0, green: 180, blue: 255 }); // G
|
|
assert.deepStrictEqual(frame['note-w'], { red: 0, green: 0, blue: 0 }); // C# unlit
|
|
// The octave key follows C, but is not treated as the root.
|
|
assert.deepStrictEqual(frame['note-k'], { red: 255, green: 90, blue: 0 });
|
|
});
|
|
|
|
test('frame accepts a Set and a null root', () => {
|
|
const frame = buildFrame({ pitchClasses: new Set([2, 5]), root: null });
|
|
assert.deepStrictEqual(frame['note-s'], frame['note-f']);
|
|
});
|
|
|
|
test('black frame blanks everything', () => {
|
|
const frame = buildBlackFrame();
|
|
for (const value of Object.values(frame)) {
|
|
assert.deepStrictEqual(value, { red: 0, green: 0, blue: 0 });
|
|
}
|
|
});
|
|
|
|
console.log('parsing helpers');
|
|
|
|
test('color parsing handles #rgb, rrggbb and objects', () => {
|
|
assert.deepStrictEqual(parseColor('#00b4ff'), { red: 0, green: 180, blue: 255 });
|
|
assert.deepStrictEqual(parseColor('00B4FF'), { red: 0, green: 180, blue: 255 });
|
|
assert.deepStrictEqual(parseColor('#f0a'), { red: 255, green: 0, blue: 170 });
|
|
assert.deepStrictEqual(parseColor({ red: 300, green: -5, blue: 1.4 }), {
|
|
red: 255,
|
|
green: 0,
|
|
blue: 1,
|
|
});
|
|
assert.throws(() => parseColor('#gggggg'));
|
|
assert.throws(() => parseColor('blue'));
|
|
});
|
|
|
|
test('address parsing', () => {
|
|
assert.deepStrictEqual(splitAddress('127.0.0.1:52384'), {
|
|
host: '127.0.0.1',
|
|
port: 52384,
|
|
});
|
|
assert.deepStrictEqual(splitAddress('http://127.0.0.1:1'), {
|
|
host: '127.0.0.1',
|
|
port: 1,
|
|
});
|
|
assert.throws(() => splitAddress('127.0.0.1'));
|
|
});
|
|
|
|
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);
|