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>
This commit is contained in:
134
src/scale.js
Normal file
134
src/scale.js
Normal file
@@ -0,0 +1,134 @@
|
||||
'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,
|
||||
};
|
||||
Reference in New Issue
Block a user