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>
218 lines
7.0 KiB
JavaScript
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' : ''}`);
|