Files
steelseries-live-scale/src/cli.ts
khannurien 3b02612461 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>
2026-08-12 13:30:53 +00:00

359 lines
10 KiB
TypeScript

import readline from 'readline';
import { errorMessage, errorStack } from './errors';
import { ScaleLighting } from './index';
import { DEFAULT_COLORS, buildFrame, parseColor } from './lighting';
import type { PaletteKey } from './lighting';
import type { Frame } from './protocol';
import {
NOTE_KEYS,
SCALES,
keysFor,
noteName,
parseIntervals,
parseRoot,
parseScale,
pitchClassesFor,
} from './scale';
import type { NoteKey, PitchClass, ScaleName } from './scale';
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();
/** Which CLI flag sets which palette entry — also used to report bad values. */
const COLOR_FLAGS: Record<PaletteKey, string> = {
background: '--bg',
scale: '--color',
root: '--root-color',
off: '--off-color',
};
interface CliOptions {
root: string;
scale: string;
intervals: string | null;
colors: Partial<Record<PaletteKey, string>>;
once: boolean;
demo: number | null;
keyTest: number | null;
off: boolean;
address?: string;
dryRun: boolean;
list: boolean;
help: boolean;
quiet: boolean;
}
export function parseArgs(argv: readonly string[]): CliOptions {
const opts: CliOptions = {
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: number, fallback: number) => {
const next: string | undefined = 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 = (): string => {
const value: string | undefined = 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;
}
interface ScaleSummary {
pitchClasses: PitchClass[];
notes: string[];
keys: string[];
}
function describe(root: PitchClass, intervals: readonly number[]): ScaleSummary {
const pcs = pitchClassesFor(root, intervals);
return {
pitchClasses: pcs,
notes: pcs.map((pc) => noteName(pc)),
keys: keysFor(pcs),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** One note key lit, everything else at background — used by --key-test. */
function singleKeyFrame(controller: ScaleLighting, note: NoteKey): Frame {
const frame = buildFrame({ pitchClasses: [], colors: controller.colors });
frame[note.frameKey] = controller.color('scale');
return frame;
}
export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
let opts: CliOptions;
try {
opts = parseArgs(argv);
} catch (err) {
console.error(`error: ${errorMessage(err)}\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.
for (const role of Object.keys(COLOR_FLAGS) as PaletteKey[]) {
const value = opts.colors[role];
if (value === undefined) continue;
try {
parseColor(value);
} catch (err) {
console.error(`error: ${COLOR_FLAGS[role]}: ${errorMessage(err)}`);
process.exitCode = 2;
return;
}
}
const verbose = !opts.quiet;
const log = verbose
? (msg: string) => console.error(`[gamesense] ${msg}`)
: () => {};
const controller = new ScaleLighting({
colors: opts.colors,
address: opts.address,
dryRun: opts.dryRun,
log,
});
if (opts.off) {
try {
await controller.release();
console.log('lighting released back to SteelSeries GG');
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
process.exitCode = 1;
}
return;
}
let root: PitchClass;
let intervals: readonly number[];
try {
root = parseRoot(opts.root);
intervals = opts.intervals ? parseIntervals(opts.intervals) : parseScale(opts.scale);
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
process.exitCode = 2;
return;
}
const show = async (
r: PitchClass,
iv: readonly number[],
label: string
): Promise<void> => {
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): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
await controller.stop(true);
process.exit(code);
};
process.on('SIGINT', () => void shutdown(0));
process.on('SIGTERM', () => void 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.showFrame(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) as ScaleName[];
for (let i = 0; ; i++) {
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);
}
}
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', (line: string) => {
const text = line.trim();
if (!text || text.startsWith('#')) return;
if (text === 'quit' || text === 'exit') {
void shutdown(0);
return;
}
const [rootText, ...rest] = text.split(/\s+/);
const scaleText = rest.join('-') || 'major';
void (async () => {
try {
await show(parseRoot(rootText), parseScale(scaleText), scaleText);
} catch (err) {
console.error(` ! ${errorMessage(err)}`);
}
})();
});
// 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) void shutdown(0);
});
// The heartbeat interval keeps the process alive from here on.
await new Promise<never>(() => {});
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
await controller.stop(true).catch(() => {});
process.exitCode = 1;
}
}
/** Entry point for `bin/apex7-scale.js`: run `main` and report crashes. */
export function run(argv?: readonly string[]): void {
main(argv).catch((err: unknown) => {
console.error(errorStack(err));
process.exit(1);
});
}