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 Root note: C, F#, Bb, or 0-11 (default: C) --scale Scale name (see --list) (default: major) --intervals Raw intervals instead of --scale, e.g. 0,2,4,7,9 Colors (hex, #rrggbb): --bg Background for all non-note keys (default: ${DEFAULT_COLORS.background}) --color Notes in the scale (default: ${DEFAULT_COLORS.scale}) --root-color The root note (default: ${DEFAULT_COLORS.root}) --off-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 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 " " 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 = { background: '--bg', scale: '--color', root: '--root-color', off: '--off-color', }; interface CliOptions { root: string; scale: string; intervals: string | null; colors: Partial>; 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 { 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 { 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 => { 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 => { 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 " " lines on stdin, from a terminal or a pipe. const interactive = Boolean(process.stdin.isTTY); if (interactive) { console.log('\nType " " 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(() => {}); } 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); }); }