#!/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 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(); 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 " " 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', 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); });