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:
khannurien
2026-08-12 13:10:35 +00:00
commit 425eb86327
13 changed files with 1823 additions and 0 deletions

322
bin/apex7-scale.js Normal file
View File

@@ -0,0 +1,322 @@
#!/usr/bin/env node
'use strict';
const readline = require('readline');
const { ScaleLighting } = require('../src');
const {
SCALES,
NOTE_KEYS,
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
} = require('../src/scale');
const { parseColor, buildFrame, DEFAULT_COLORS } = require('../src/lighting');
const USAGE = `
apex7-scale — light the notes of a scale on a SteelSeries Apex 7 (GameSense)
Usage:
apex7-scale [options]
Scale selection:
--root <note> Root note: C, F#, Bb, or 0-11 (default: C)
--scale <name> Scale name (see --list) (default: major)
--intervals <list> Raw intervals instead of --scale, e.g. 0,2,4,7,9
Colors (hex, #rrggbb):
--bg <color> Background for all non-note keys (default: ${DEFAULT_COLORS.background})
--color <color> Notes in the scale (default: ${DEFAULT_COLORS.scale})
--root-color <color> The root note (default: ${DEFAULT_COLORS.root})
--off-color <color> Note keys outside the scale (default: ${DEFAULT_COLORS.off})
Modes:
--once Send one frame and exit (lighting reverts after ~15s)
--demo [seconds] Cycle through roots and scales (default: 3s)
--key-test [seconds] Light each note key one at a time, to verify the mapping
--off Blank the board, deregister, exit
Other:
--address <host:port> Skip coreProps.json discovery
--dry-run Print the JSON payloads instead of sending them
--list List known scale names
--quiet Only print the scale summary
-h, --help This message
Without --once/--demo/--key-test the process stays resident (heartbeating so the
lighting sticks) and reads "<root> <scale>" lines from stdin, e.g. "F# dorian".
Ctrl+C restores normal lighting.
`.trim();
function parseArgs(argv) {
const opts = {
root: 'C',
scale: 'major',
intervals: null,
colors: {},
once: false,
demo: null,
keyTest: null,
off: false,
address: undefined,
dryRun: false,
list: false,
help: false,
quiet: false,
};
/** Read an optional numeric value that may follow a flag. */
const optionalNumber = (i, fallback) => {
const next = argv[i + 1];
if (next !== undefined && /^\d+(\.\d+)?$/.test(next)) {
return { value: Number(next), consumed: 1 };
}
return { value: fallback, consumed: 0 };
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const need = () => {
const value = argv[++i];
if (value === undefined) throw new Error(`${arg} requires a value`);
return value;
};
switch (arg) {
case '--root': opts.root = need(); break;
case '--scale': opts.scale = need(); break;
case '--intervals': opts.intervals = need(); break;
case '--bg': opts.colors.background = need(); break;
case '--color': opts.colors.scale = need(); break;
case '--root-color': opts.colors.root = need(); break;
case '--off-color': opts.colors.off = need(); break;
case '--address': opts.address = need(); break;
case '--once': opts.once = true; break;
case '--off': opts.off = true; break;
case '--dry-run': opts.dryRun = true; break;
case '--list': opts.list = true; break;
case '--quiet': opts.quiet = true; break;
case '-h':
case '--help': opts.help = true; break;
case '--demo': {
const { value, consumed } = optionalNumber(i, 3);
opts.demo = value;
i += consumed;
break;
}
case '--key-test': {
const { value, consumed } = optionalNumber(i, 1.5);
opts.keyTest = value;
i += consumed;
break;
}
default:
throw new Error(`unknown option: ${arg}`);
}
}
return opts;
}
function describe(root, intervals) {
const pcs = pitchClassesFor(root, intervals);
return {
pitchClasses: pcs,
notes: pcs.map(noteName),
keys: keysFor(pcs),
};
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(`error: ${err.message}\n`);
console.error(USAGE);
process.exitCode = 2;
return;
}
if (opts.help) {
console.log(USAGE);
return;
}
if (opts.list) {
for (const [name, intervals] of Object.entries(SCALES)) {
console.log(`${name.padEnd(18)} ${intervals.join(', ')}`);
}
return;
}
// Validate colors up front so a typo fails before we touch the keyboard.
const COLOR_FLAGS = { background: '--bg', scale: '--color', root: '--root-color', off: '--off-color' };
for (const [name, value] of Object.entries(opts.colors)) {
try {
parseColor(value);
} catch (err) {
console.error(`error: ${COLOR_FLAGS[name]}: ${err.message}`);
process.exitCode = 2;
return;
}
}
const verbose = !opts.quiet;
const log = verbose ? (msg) => console.error(`[gamesense] ${msg}`) : () => {};
const controller = new ScaleLighting({
colors: opts.colors,
address: opts.address,
dryRun: opts.dryRun,
log,
});
if (opts.off) {
try {
controller.started = true; // allow stop() to blank without a full start
controller.client.connect();
await controller.stop(true);
console.log('lighting released back to SteelSeries GG');
} catch (err) {
console.error(`error: ${err.message}`);
process.exitCode = 1;
}
return;
}
let root;
let intervals;
try {
root = parseRoot(opts.root);
intervals = opts.intervals ? parseIntervals(opts.intervals) : parseScale(opts.scale);
} catch (err) {
console.error(`error: ${err.message}`);
process.exitCode = 2;
return;
}
const show = async (r, iv, label) => {
const info = describe(r, iv);
console.log(
`${noteName(r)} ${label} -> ${info.notes.join(' ')} | keys: ${info.keys
.join(' ')
.toUpperCase()}`
);
await controller.showScale(r, iv);
};
let shuttingDown = false;
const shutdown = async (code = 0) => {
if (shuttingDown) return;
shuttingDown = true;
await controller.stop(true);
process.exit(code);
};
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
try {
await controller.start();
if (opts.keyTest !== null) {
console.log('key test: each note key lights on its own, in order');
for (const note of NOTE_KEYS) {
console.log(
` ${note.key.toUpperCase()} -> ${noteName(note.pitchClass)}${
note.octaveUp ? ' (octave up)' : ''
}`
);
await controller.client.sendEvent(controller.event, {
value: 100,
frame: singleKeyFrame(controller, note),
});
await sleep(opts.keyTest * 1000);
}
await shutdown(0);
return;
}
if (opts.demo !== null) {
console.log('demo: Ctrl+C to stop');
const names = Object.keys(SCALES);
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const scaleName = names[i % names.length];
const demoRoot = (i * 5) % 12; // walk the circle of fourths
await show(demoRoot, SCALES[scaleName], scaleName);
await sleep(opts.demo * 1000);
i++;
}
}
await show(root, intervals, opts.intervals ? `[${intervals.join(',')}]` : opts.scale);
if (opts.once) {
controller.client.stopHeartbeat();
if (verbose) {
console.error(
'[gamesense] --once: GameSense drops the effect after ~15s without events'
);
}
return;
}
// Accept "<root> <scale>" lines on stdin, from a terminal or a pipe.
const interactive = Boolean(process.stdin.isTTY);
if (interactive) {
console.log('\nType "<root> <scale>" to change (e.g. "F# dorian"), Ctrl+C to quit.');
} else if (verbose) {
console.error('[gamesense] holding lighting, Ctrl+C to quit');
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', async (line) => {
const text = line.trim();
if (!text || text.startsWith('#')) return;
if (text === 'quit' || text === 'exit') return shutdown(0);
const [rootText, ...rest] = text.split(/\s+/);
const scaleText = rest.join('-') || 'major';
try {
const r = parseRoot(rootText);
const iv = parseScale(scaleText);
await show(r, iv, scaleText);
} catch (err) {
console.error(` ! ${err.message}`);
}
});
// A closed terminal means "quit". A closed pipe just means there are no
// more commands coming — keep holding the lighting until signalled.
rl.on('close', () => {
if (interactive) shutdown(0);
});
// The heartbeat interval keeps the process alive from here on.
await new Promise(() => {});
} catch (err) {
console.error(`error: ${err.message}`);
await controller.stop(true).catch(() => {});
process.exitCode = 1;
}
}
/** One note key lit, everything else at background — used by --key-test. */
function singleKeyFrame(controller, note) {
const frame = buildFrame({ pitchClasses: [], colors: controller.colors });
frame[note.frameKey] = parseColor(controller.colors.scale);
return frame;
}
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1);
});