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>
323 lines
9.3 KiB
JavaScript
323 lines
9.3 KiB
JavaScript
#!/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);
|
|
});
|