Port phase 1 to TypeScript
Move the standalone GameSense scale lighting to TypeScript ahead of the
Max for Live work, so step 3 gets type declarations at the seam.
Build emits CommonJS at ES2020 into dist/, because Node for Max loads CJS
on a Node version we do not control. bin/apex7-scale.js becomes a plain-JS
launcher so `node bin/apex7-scale.js` keeps working; the CLI itself moves
to src/cli.ts.
Two modules make previously implicit structure explicit:
- src/protocol.ts, the GameSense wire types, so the transport and the
payload construction agree on shapes neither of them owns
- src/errors.ts, since `catch (err)` binds `unknown` under strict
Typing surfaced a few real fixes:
- ScaleLighting.started was a public field the CLI set by hand to make
--off work without start(); that is now release(), with started
private behind isStarted
- readAddress() trusted JSON.parse output; the address is now checked
for being a non-empty string
- res.statusCode is number|undefined, so the old `>= 200` comparison
coerced silently; a missing status now rejects
- parseIntervals claimed to dedupe and sort, and never did (comment
corrected; pitchClassesFor is what dedupes)
Behavior is otherwise unchanged: all 18 tests pass and --once, --off,
--demo, --key-test, the resident stdin loop and the error paths were
verified against tools/fake-gamesense.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,322 +1,20 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
const readline = require('readline');
|
||||
/**
|
||||
* Launcher only. The CLI itself is TypeScript (`src/cli.ts`); this file stays
|
||||
* plain JS so `node bin/apex7-scale.js` and the `apex7-scale` bin link keep
|
||||
* working without a loader.
|
||||
*/
|
||||
|
||||
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 fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const USAGE = `
|
||||
apex7-scale — light the notes of a scale on a SteelSeries Apex 7 (GameSense)
|
||||
const compiled = path.join(__dirname, '..', 'dist', 'src', 'cli.js');
|
||||
|
||||
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);
|
||||
if (!fs.existsSync(compiled)) {
|
||||
console.error('apex7-scale is not built yet — run: npm run build');
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
require(compiled).run();
|
||||
|
||||
Reference in New Issue
Block a user