Files
steelseries-live-scale/test/logic.test.js
khannurien 425eb86327 Add standalone GameSense scale lighting for the Apex 7
Build step 1 of the Ableton scale-lighting brief: a dependency-free Node
script that drives the keyboard directly, with the scale passed on the
command line. Live and Max for Live come later.

Lighting uses one event with 14 context-color handlers — a background zone
covering every key except the 13 note keys, plus one handler per note key
addressed by USB HID code. Colors travel in the event frame, so handlers are
bound once and a scale change is a single POST, which is what the M4L step
will need.

Chose custom zones over bitmap mode: bitmap's 22x6 grid has no documented
index-to-key table per model, while HID codes are exact. Painting the whole
board with the background zone covers the same blackout caveat bitmap was
suggested for.

tools/fake-gamesense.js stands in for the GameSense server and renders the
resolved frame as ANSI colour, so the mapping is verifiable without hardware
or Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 13:10:35 +00:00

218 lines
7.0 KiB
JavaScript

'use strict';
const assert = require('assert');
const {
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
NOTE_KEYS,
NOTE_KEY_HIDS,
SCALES,
} = require('../src/scale');
const {
parseColor,
buildHandlers,
buildFrame,
buildBlackFrame,
BACKGROUND_KEYS,
BACKGROUND_FRAME_KEY,
} = require('../src/lighting');
const { splitAddress } = require('../src/gamesense');
const { HID, ALL_KEYS } = require('../src/hid');
let passed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(` ok ${name}`);
} catch (err) {
console.error(` FAIL ${name}\n ${err.message}`);
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(noteName), ['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(noteName).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 = {
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' : ''}`);