Files
steelseries-live-scale/src/scale.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

135 lines
4.2 KiB
JavaScript

'use strict';
const { HID } = require('./hid');
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const NOTE_ALIASES = {
C: 0, 'B#': 0,
'C#': 1, DB: 1,
D: 2,
'D#': 3, EB: 3,
E: 4, FB: 4,
F: 5, 'E#': 5,
'F#': 6, GB: 6,
G: 7,
'G#': 8, AB: 8,
A: 9,
'A#': 10, BB: 10,
B: 11, CB: 11,
};
/** Interval sets, matching Live 12's `scale_intervals`. */
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
'harmonic-minor': [0, 2, 3, 5, 7, 8, 11],
'melodic-minor': [0, 2, 3, 5, 7, 9, 11],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
locrian: [0, 1, 3, 5, 6, 8, 10],
'major-pentatonic': [0, 2, 4, 7, 9],
'minor-pentatonic': [0, 3, 5, 7, 10],
blues: [0, 3, 5, 6, 7, 10],
'whole-tone': [0, 2, 4, 6, 8, 10],
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
};
/**
* Ableton's computer MIDI keyboard, one octave laid out like a piano:
* white keys on the home row, black keys on the row above, plus the octave C.
*/
const NOTE_KEYS = [
{ key: 'a', hid: HID.a, pitchClass: 0, frameKey: 'note-a' },
{ key: 'w', hid: HID.w, pitchClass: 1, frameKey: 'note-w' },
{ key: 's', hid: HID.s, pitchClass: 2, frameKey: 'note-s' },
{ key: 'e', hid: HID.e, pitchClass: 3, frameKey: 'note-e' },
{ key: 'd', hid: HID.d, pitchClass: 4, frameKey: 'note-d' },
{ key: 'f', hid: HID.f, pitchClass: 5, frameKey: 'note-f' },
{ key: 't', hid: HID.t, pitchClass: 6, frameKey: 'note-t' },
{ key: 'g', hid: HID.g, pitchClass: 7, frameKey: 'note-g' },
{ key: 'y', hid: HID.y, pitchClass: 8, frameKey: 'note-y' },
{ key: 'h', hid: HID.h, pitchClass: 9, frameKey: 'note-h' },
{ key: 'u', hid: HID.u, pitchClass: 10, frameKey: 'note-u' },
{ key: 'j', hid: HID.j, pitchClass: 11, frameKey: 'note-j' },
// The octave key: same pitch class as A, one octave up.
{ key: 'k', hid: HID.k, pitchClass: 0, frameKey: 'note-k', octaveUp: true },
];
const NOTE_KEY_HIDS = NOTE_KEYS.map((k) => k.hid);
/** "C", "f#", "Bb", "3" -> 0..11. Throws on garbage. */
function parseRoot(input) {
if (typeof input === 'number') {
if (!Number.isInteger(input) || input < 0 || input > 11) {
throw new Error(`root must be an integer 0-11, got ${input}`);
}
return input;
}
const raw = String(input).trim();
if (/^\d+$/.test(raw)) return parseRoot(Number(raw));
const normalized = raw.toUpperCase().replace(/♯/g, '#').replace(/♭/g, 'B');
if (normalized in NOTE_ALIASES) return NOTE_ALIASES[normalized];
throw new Error(`unknown root note: ${input}`);
}
/** "major", "Harmonic Minor", "minor_pentatonic" -> interval array. */
function parseScale(input) {
const normalized = String(input).trim().toLowerCase().replace(/[\s_]+/g, '-');
if (normalized in SCALES) return SCALES[normalized];
throw new Error(
`unknown scale: ${input} (known: ${Object.keys(SCALES).join(', ')})`
);
}
/** "0,2,4,5,7,9,11" or [0,2,...] -> normalized, deduped, sorted interval array. */
function parseIntervals(input) {
const list = Array.isArray(input) ? input : String(input).split(/[,\s]+/);
const parsed = list
.filter((v) => String(v).length > 0)
.map((v) => {
const n = Number(v);
if (!Number.isInteger(n)) throw new Error(`interval is not an integer: ${v}`);
return n;
});
if (parsed.length === 0) throw new Error('interval list is empty');
return parsed;
}
/**
* The core of the whole project:
* lit = { (root + interval) % 12 }
* Returns a sorted array of pitch classes.
*/
function pitchClassesFor(root, intervals) {
const set = new Set(intervals.map((i) => (((root + i) % 12) + 12) % 12));
return [...set].sort((a, b) => a - b);
}
/** Pitch classes -> the QWERTY keys Ableton maps them to (includes 'k'). */
function keysFor(pitchClasses) {
const set = new Set(pitchClasses);
return NOTE_KEYS.filter((k) => set.has(k.pitchClass)).map((k) => k.key);
}
function noteName(pitchClass) {
return NOTE_NAMES[(((pitchClass % 12) + 12) % 12)];
}
module.exports = {
NOTE_NAMES,
NOTE_ALIASES,
SCALES,
NOTE_KEYS,
NOTE_KEY_HIDS,
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
};