Port phase 1 to TypeScript

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>
This commit is contained in:
khannurien
2026-08-12 13:30:53 +00:00
parent 425eb86327
commit 3b02612461
18 changed files with 1550 additions and 837 deletions

218
test/logic.test.ts Normal file
View File

@@ -0,0 +1,218 @@
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' : ''}`);