From 3b02612461ac271240418322875b6c215ca8bea7 Mon Sep 17 00:00:00 2001 From: khannurien Date: Wed, 12 Aug 2026 13:30:53 +0000 Subject: [PATCH] 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 --- .gitignore | 4 + README.md | 44 ++- bin/apex7-scale.js | 328 +------------------- package-lock.json | 415 ++++++++++++++++++++++++++ package.json | 24 +- src/cli.ts | 358 ++++++++++++++++++++++ src/errors.ts | 17 ++ src/{gamesense.js => gamesense.ts} | 154 +++++----- src/{hid.js => hid.ts} | 16 +- src/index.js | 117 -------- src/index.ts | 166 +++++++++++ src/{lighting.js => lighting.ts} | 94 +++--- src/protocol.ts | 55 ++++ src/{scale.js => scale.ts} | 81 ++--- test/{logic.test.js => logic.test.ts} | 53 ++-- tools/fake-gamesense.js | 205 ------------- tools/fake-gamesense.ts | 226 ++++++++++++++ tsconfig.json | 30 ++ 18 files changed, 1550 insertions(+), 837 deletions(-) create mode 100644 package-lock.json create mode 100644 src/cli.ts create mode 100644 src/errors.ts rename src/{gamesense.js => gamesense.ts} (59%) rename src/{hid.js => hid.ts} (86%) delete mode 100644 src/index.js create mode 100644 src/index.ts rename src/{lighting.js => lighting.ts} (63%) create mode 100644 src/protocol.ts rename src/{scale.js => scale.ts} (61%) rename test/{logic.test.js => logic.test.ts} (91%) delete mode 100644 tools/fake-gamesense.js create mode 100644 tools/fake-gamesense.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index eaa4567..23bd494 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ node_modules/ npm-debug.log* +# TypeScript build output +dist/ +*.tsbuildinfo + # Claude Code local (per-machine) settings .claude/settings.local.json .claude/settings.local.json.tmp.* diff --git a/README.md b/README.md index 82419e0..01a27ac 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,18 @@ This is **build step 1** of [`apex7-ableton-scale-lighting.md`](./apex7-ableton- a standalone script that talks to GameSense directly. Live/Max for Live is not involved yet — you pass the scale on the command line. -No dependencies. Node 14+. +TypeScript, no runtime dependencies. Node 16+. ## Quick start (Windows, with SteelSeries GG running) ```powershell +npm install # also compiles src\*.ts to dist\ node bin\apex7-scale.js --root C --scale major ``` +`npm install` builds via the `prepare` script; after editing any `.ts` file run +`npm run build` (or `npm run typecheck` for types only). + The board dims, the C-major keys light up (`A S D F G H J K`), the root is orange. The process stays resident and heartbeats so the lighting sticks; you can type new scales at the prompt: @@ -78,12 +82,12 @@ Two design notes: ## Testing without hardware -`tools/fake-gamesense.js` stands in for the GameSense server: it accepts the +`tools/fake-gamesense.ts` stands in for the GameSense server: it accepts the real endpoints, resolves the bound handlers against each frame, and renders the resulting keyboard as ANSI color in the terminal. ```bash -node tools/fake-gamesense.js --port 51000 # terminal 1 +npm run stub -- --port 51000 # terminal 1 node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # terminal 2 ``` @@ -99,22 +103,31 @@ npm test ## Layout ``` -bin/apex7-scale.js CLI -src/hid.js USB HID usage codes; the full-board key list -src/scale.js scale presets, root/interval parsing, pitch class -> QWERTY key -src/gamesense.js GameSense REST client (coreProps discovery, heartbeat, cleanup) -src/lighting.js handler + frame construction -src/index.js ScaleLighting — the API step 3 will call from Node for Max -tools/fake-gamesense.js terminal simulator of the GameSense server -test/logic.test.js unit tests +bin/apex7-scale.js launcher (plain JS) — runs dist/src/cli.js +src/cli.ts argument parsing, output, the resident stdin loop +src/protocol.ts GameSense wire types (Rgb, Frame, Handler, …) +src/hid.ts USB HID usage codes; the full-board key list +src/scale.ts scale presets, root/interval parsing, pitch class -> QWERTY key +src/gamesense.ts GameSense REST client (coreProps discovery, heartbeat, cleanup) +src/lighting.ts handler + frame construction +src/errors.ts `catch (err: unknown)` helpers +src/index.ts ScaleLighting — the API step 3 will call from Node for Max +tools/fake-gamesense.ts terminal simulator of the GameSense server +test/logic.test.ts unit tests +dist/ compiled CommonJS + .d.ts (gitignored) ``` +`tsconfig.json` emits CommonJS at ES2020, because Node for Max loads CJS on a +Node version we do not control. + ## Reusing this from Max for Live (step 3) -`src/index.js` is the seam — no CLI concerns in it: +`src/index.ts` is the seam — no CLI concerns in it, and it ships type +declarations alongside the compiled JS: -```js -const { ScaleLighting } = require('./src'); +```ts +import { ScaleLighting } from 'steelseries-live-scale'; +// or, from a Node for Max script: require('/dist/src') const lights = new ScaleLighting(); await lights.start(); // register + bind + heartbeat @@ -135,5 +148,6 @@ as `root_note` and `scale_intervals`. holds an exclusive lighting profile. - **Lighting reverts after ~15s** — that's GameSense's deactivation timeout; it only happens with `--once`, since resident mode heartbeats every 5s. -- **A key stays dark** — it may not be in `src/hid.js`. Add its HID code there; +- **A key stays dark** — it may not be in `src/hid.ts`. Add its HID code there; keys not addressed by any handler go black in GameSense mode. +- **`apex7-scale is not built yet`** — run `npm run build`. diff --git a/bin/apex7-scale.js b/bin/apex7-scale.js index c23e0a1..5050a24 100644 --- a/bin/apex7-scale.js +++ b/bin/apex7-scale.js @@ -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 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); +if (!fs.existsSync(compiled)) { + console.error('apex7-scale is not built yet — run: npm run build'); process.exit(1); -}); +} + +require(compiled).run(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1983f1a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,415 @@ +{ + "name": "steelseries-live-scale", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "steelseries-live-scale", + "version": "0.1.0", + "license": "MIT", + "bin": { + "apex7-scale": "bin/apex7-scale.js" + }, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "^7.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json index 8f06537..bda8dac 100644 --- a/package.json +++ b/package.json @@ -2,18 +2,30 @@ "name": "steelseries-live-scale", "version": "0.1.0", "description": "Highlight the notes of the current Ableton Live scale on a SteelSeries Apex 7 via GameSense", - "main": "src/index.js", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", "bin": { "apex7-scale": "bin/apex7-scale.js" }, + "files": [ + "bin", + "dist/src" + ], "scripts": { - "start": "node bin/apex7-scale.js", - "stub": "node tools/fake-gamesense.js", - "test": "node test/logic.test.js" + "build": "tsc", + "typecheck": "tsc --noEmit", + "prepare": "npm run build", + "start": "npm run build && node bin/apex7-scale.js", + "stub": "npm run build && node dist/tools/fake-gamesense.js", + "test": "npm run build && node dist/test/logic.test.js" }, "engines": { - "node": ">=14" + "node": ">=16" }, "license": "MIT", - "private": true + "private": true, + "devDependencies": { + "@types/node": "^26.2.0", + "typescript": "^7.0.2" + } } diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..51ca988 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,358 @@ +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); + }); +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..0879595 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,17 @@ +/** + * `catch (err)` binds `unknown` under `strict`. These helpers are the one place + * that copes with that, so the rest of the code can stay readable. + */ + +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function errorStack(err: unknown): string { + return err instanceof Error && err.stack ? err.stack : String(err); +} + +/** Node's fs/net errors carry a `code`, which the `Error` type does not model. */ +export function errorCode(err: unknown): string | undefined { + return err instanceof Error ? (err as NodeJS.ErrnoException).code : undefined; +} diff --git a/src/gamesense.js b/src/gamesense.ts similarity index 59% rename from src/gamesense.js rename to src/gamesense.ts index ae63084..3db70ec 100644 --- a/src/gamesense.js +++ b/src/gamesense.ts @@ -1,8 +1,14 @@ -'use strict'; +import fs from 'fs'; +import http from 'http'; +import path from 'path'; -const fs = require('fs'); -const http = require('http'); -const path = require('path'); +import { errorCode, errorMessage } from './errors'; +import type { + BindEventOptions, + EventData, + GameSenseResponse, + Handler, +} from './protocol'; /** * Minimal, dependency-free GameSense (SteelSeries Engine 3) client. @@ -12,7 +18,7 @@ const path = require('path'); * something we control. */ -const CORE_PROPS_PATHS = { +const CORE_PROPS_PATHS: Record string> = { win32: () => path.join( process.env.PROGRAMDATA || 'C:\\ProgramData', @@ -24,7 +30,7 @@ const CORE_PROPS_PATHS = { '/Library/Application Support/SteelSeries Engine 3/coreProps.json', }; -function corePropsPath() { +export function corePropsPath(): string { const resolve = CORE_PROPS_PATHS[process.platform]; if (!resolve) { throw new Error( @@ -36,15 +42,15 @@ function corePropsPath() { } /** Resolve the local REST server address, e.g. "127.0.0.1:52384". */ -function readAddress() { +export function readAddress(): string { if (process.env.GAMESENSE_ADDRESS) return process.env.GAMESENSE_ADDRESS.trim(); const file = corePropsPath(); - let raw; + let raw: string; try { raw = fs.readFileSync(file, 'utf8'); } catch (err) { - if (err.code === 'ENOENT') { + if (errorCode(err) === 'ENOENT') { throw new Error( `coreProps.json not found at ${file}. Is SteelSeries GG running?` ); @@ -52,36 +58,58 @@ function readAddress() { throw err; } - let parsed; + let parsed: unknown; try { parsed = JSON.parse(raw); } catch (err) { - throw new Error(`coreProps.json at ${file} is not valid JSON: ${err.message}`); + throw new Error(`coreProps.json at ${file} is not valid JSON: ${errorMessage(err)}`); } - if (!parsed.address) { + const address = + typeof parsed === 'object' && parsed !== null + ? (parsed as { address?: unknown }).address + : undefined; + if (typeof address !== 'string' || address.length === 0) { throw new Error(`coreProps.json at ${file} has no "address" key`); } - return parsed.address; + return address; } -function splitAddress(address) { +export interface HostPort { + host: string; + port: number; +} + +export function splitAddress(address: string): HostPort { const match = /^(?:https?:\/\/)?([^:/]+):(\d+)$/.exec(String(address).trim()); if (!match) throw new Error(`malformed GameSense address: ${address}`); return { host: match[1], port: Number(match[2]) }; } -class GameSenseClient { - /** - * @param {object} options - * @param {string} options.game uppercase A-Z 0-9 - _ only - * @param {string} [options.gameDisplayName] - * @param {string} [options.developer] - * @param {string} [options.address] skip coreProps.json discovery - * @param {boolean} [options.dryRun] log payloads instead of sending - * @param {(msg: string) => void} [options.log] - */ - constructor(options) { +export interface GameSenseClientOptions { + /** Uppercase A-Z 0-9 - _ only. */ + game: string; + gameDisplayName?: string; + developer?: string; + /** Skip coreProps.json discovery. */ + address?: string; + /** Log payloads instead of sending them. */ + dryRun?: boolean; + log?: (msg: string) => void; +} + +export class GameSenseClient { + readonly game: string; + readonly gameDisplayName: string; + readonly developer: string; + readonly dryRun: boolean; + readonly log: (msg: string) => void; + + private address: string | null; + private announced = false; + private heartbeatTimer: ReturnType | null = null; + + constructor(options: GameSenseClientOptions) { if (!options || !options.game) throw new Error('options.game is required'); if (!/^[A-Z0-9_-]+$/.test(options.game)) { throw new Error( @@ -94,11 +122,10 @@ class GameSenseClient { this.address = options.address || null; this.dryRun = Boolean(options.dryRun); this.log = options.log || (() => {}); - this.heartbeatTimer = null; } /** Resolve the server address (idempotent). */ - connect() { + connect(): string { if (!this.address) this.address = this.dryRun ? '127.0.0.1:0' : readAddress(); if (!this.announced) { this.announced = true; @@ -107,7 +134,7 @@ class GameSenseClient { return this.address; } - post(endpoint, body) { + post(endpoint: string, body: unknown): Promise { if (this.dryRun) { this.log(`POST ${endpoint}\n${JSON.stringify(body, null, 2)}`); return Promise.resolve({ dryRun: true }); @@ -116,7 +143,7 @@ class GameSenseClient { const { host, port } = splitAddress(this.connect()); const payload = Buffer.from(JSON.stringify(body), 'utf8'); - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const req = http.request( { host, @@ -129,28 +156,25 @@ class GameSenseClient { }, }, (res) => { - const chunks = []; - res.on('data', (c) => chunks.push(c)); + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); - if (res.statusCode >= 200 && res.statusCode < 300) { + const status = res.statusCode ?? 0; + if (status >= 200 && status < 300) { resolve(text ? safeParse(text) : {}); } else { - reject( - new Error(`${endpoint} -> HTTP ${res.statusCode}: ${text.trim()}`) - ); + reject(new Error(`${endpoint} -> HTTP ${status}: ${text.trim()}`)); } }); } ); - req.on('error', (err) => - reject(new Error(`${endpoint} -> ${err.message}`)) - ); + req.on('error', (err) => reject(new Error(`${endpoint} -> ${err.message}`))); req.end(payload); }); } - registerGame() { + registerGame(): Promise { return this.post('/game_metadata', { game: this.game, game_display_name: this.gameDisplayName, @@ -158,29 +182,27 @@ class GameSenseClient { }); } - /** - * @param {string} event - * @param {object[]} handlers - * @param {object} [opts] min_value / max_value / icon_id / value_optional - */ - bindEvent(event, handlers, opts = {}) { + bindEvent( + event: string, + handlers: readonly Handler[], + opts: BindEventOptions = {} + ): Promise { return this.post('/bind_game_event', { game: this.game, event, - min_value: opts.min_value === undefined ? 0 : opts.min_value, - max_value: opts.max_value === undefined ? 100 : opts.max_value, - icon_id: opts.icon_id === undefined ? 0 : opts.icon_id, + min_value: opts.min_value ?? 0, + max_value: opts.max_value ?? 100, + icon_id: opts.icon_id ?? 0, value_optional: Boolean(opts.value_optional), handlers, }); } - /** `data` is the full event data object, e.g. { value, frame }. */ - sendEvent(event, data) { + sendEvent(event: string, data: EventData): Promise { return this.post('/game_event', { game: this.game, event, data }); } - heartbeat() { + heartbeat(): Promise { return this.post('/game_heartbeat', { game: this.game }); } @@ -189,21 +211,18 @@ class GameSenseClient { * * The interval is deliberately *not* unref'd: while lighting is active this * timer is what holds the process open, which is what a resident CLI wants. - * - * @param {number} [intervalMs] - * @param {(err: Error) => void} [onError] */ - startHeartbeat(intervalMs = 5000, onError) { + startHeartbeat(intervalMs = 5000, onError?: (err: unknown) => void): void { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { - this.heartbeat().catch((err) => { + this.heartbeat().catch((err: unknown) => { if (onError) onError(err); - else this.log(`heartbeat failed: ${err.message}`); + else this.log(`heartbeat failed: ${errorMessage(err)}`); }); }, intervalMs); } - stopHeartbeat() { + stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; @@ -211,22 +230,19 @@ class GameSenseClient { } /** Hand the keyboard back to SteelSeries GG. */ - removeGame() { + removeGame(): Promise { return this.post('/remove_game', { game: this.game }); } } -function safeParse(text) { +function safeParse(text: string): GameSenseResponse { try { - return JSON.parse(text); - } catch (_) { + const parsed: unknown = JSON.parse(text); + if (typeof parsed === 'object' && parsed !== null) { + return parsed as GameSenseResponse; + } + return { value: parsed }; + } catch { return { raw: text }; } } - -module.exports = { - GameSenseClient, - readAddress, - corePropsPath, - splitAddress, -}; diff --git a/src/hid.js b/src/hid.ts similarity index 86% rename from src/hid.js rename to src/hid.ts index 4923c57..607303e 100644 --- a/src/hid.js +++ b/src/hid.ts @@ -1,5 +1,3 @@ -'use strict'; - /** * USB HID Keyboard/Keypad usage codes (usage page 0x07). * @@ -7,7 +5,7 @@ * is the single source of truth for "which physical key do I light". */ -const HID = { +export const HID = { // Letters a: 0x04, b: 0x05, c: 0x06, d: 0x07, e: 0x08, f: 0x09, g: 0x0a, h: 0x0b, i: 0x0c, j: 0x0d, k: 0x0e, l: 0x0f, m: 0x10, n: 0x11, o: 0x12, p: 0x13, @@ -78,7 +76,13 @@ const HID = { shiftRight: 0xe5, altRight: 0xe6, metaRight: 0xe7, -}; +} as const satisfies Record; + +/** Every name in the table above, e.g. `'a'` or `'numpadEnter'`. */ +export type HidName = keyof typeof HID; + +/** A USB HID usage code from the table above. */ +export type HidCode = (typeof HID)[HidName]; /** * Every key we are willing to paint on a full-size ANSI board (Apex 7 TKL and @@ -88,6 +92,4 @@ const HID = { * keyboard switches into GameSense mode and every key we do NOT address goes * dark. Painting the full set keeps the board usable. */ -const ALL_KEYS = Object.values(HID); - -module.exports = { HID, ALL_KEYS }; +export const ALL_KEYS: readonly number[] = Object.values(HID); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index 096da06..0000000 --- a/src/index.js +++ /dev/null @@ -1,117 +0,0 @@ -'use strict'; - -const { GameSenseClient } = require('./gamesense'); -const lighting = require('./lighting'); -const scale = require('./scale'); - -const DEFAULT_GAME = 'ABLETON_SCALE'; -const DEFAULT_EVENT = 'SCALE'; - -/** - * High-level controller: register once, then push scales. - * - * Kept free of CLI concerns so step 3 can `require()` it straight from a - * Node for Max script. - */ -class ScaleLighting { - constructor(options = {}) { - this.event = options.event || DEFAULT_EVENT; - this.colors = Object.assign({}, lighting.DEFAULT_COLORS, options.colors); - this.log = options.log || (() => {}); - this.started = false; - this.lastFrame = null; - - this.client = new GameSenseClient({ - game: options.game || DEFAULT_GAME, - gameDisplayName: options.gameDisplayName || 'Ableton Scale Lighting', - developer: options.developer || 'steelseries-live-scale', - address: options.address, - dryRun: options.dryRun, - log: this.log, - }); - } - - /** Register the game + bind the event handlers. Safe to call once. */ - async start() { - this.client.connect(); - await this.client.registerGame(); - this.log(`registered game ${this.client.game}`); - - await this.client.bindEvent(this.event, lighting.buildHandlers(), { - min_value: 0, - max_value: 100, - value_optional: true, - }); - this.log(`bound event ${this.event}`); - - this.client.startHeartbeat(5000, (err) => - this.log(`heartbeat failed: ${err.message}`) - ); - this.started = true; - } - - /** Push an explicit set of pitch classes. */ - async showPitchClasses(pitchClasses, root = null) { - const frame = lighting.buildFrame({ - pitchClasses, - root, - colors: this.colors, - }); - this.lastFrame = frame; - await this.client.sendEvent(this.event, { value: 100, frame }); - return frame; - } - - /** - * Push a scale. - * @param {number} root 0-11 - * @param {number[]} intervals e.g. [0,2,4,5,7,9,11] - */ - async showScale(root, intervals) { - return this.showPitchClasses(scale.pitchClassesFor(root, intervals), root); - } - - /** Re-send the last frame (useful after GG restarts). */ - async refresh() { - if (!this.lastFrame) return null; - await this.client.sendEvent(this.event, { value: 100, frame: this.lastFrame }); - return this.lastFrame; - } - - /** - * Blank the board and hand it back to SteelSeries GG. - * @param {boolean} [removeGame] also delete the game registration - */ - async stop(removeGame = true) { - this.client.stopHeartbeat(); - if (!this.started) return; - - try { - await this.client.sendEvent(this.event, { - value: 0, - frame: lighting.buildBlackFrame(), - }); - } catch (err) { - this.log(`blackout failed: ${err.message}`); - } - - if (removeGame) { - try { - await this.client.removeGame(); - this.log(`removed game ${this.client.game}`); - } catch (err) { - this.log(`remove_game failed: ${err.message}`); - } - } - this.started = false; - } -} - -module.exports = { - ScaleLighting, - DEFAULT_GAME, - DEFAULT_EVENT, - GameSenseClient, - lighting, - scale, -}; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..93484d1 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,166 @@ +import { errorMessage } from './errors'; +import { GameSenseClient } from './gamesense'; +import * as lighting from './lighting'; +import type { ColorInput, Palette, PaletteKey } from './lighting'; +import type { EventData, Frame, Rgb } from './protocol'; +import * as scale from './scale'; +import type { PitchClass } from './scale'; + +export const DEFAULT_GAME = 'ABLETON_SCALE'; +export const DEFAULT_EVENT = 'SCALE'; + +export interface ScaleLightingOptions { + /** GameSense game id: uppercase A-Z 0-9 - _ only. */ + game?: string; + gameDisplayName?: string; + developer?: string; + /** GameSense event name. */ + event?: string; + /** Overrides for `lighting.DEFAULT_COLORS`. */ + colors?: Partial; + /** Skip coreProps.json discovery, e.g. "127.0.0.1:51000". */ + address?: string; + /** Log payloads instead of sending them. */ + dryRun?: boolean; + log?: (msg: string) => void; +} + +/** + * High-level controller: register once, then push scales. + * + * Kept free of CLI concerns so step 3 can import it straight from a Node for + * Max script. + */ +export class ScaleLighting { + readonly client: GameSenseClient; + readonly event: string; + readonly colors: Palette; + + private readonly log: (msg: string) => void; + private started = false; + private lastFrame: Frame | null = null; + + constructor(options: ScaleLightingOptions = {}) { + this.event = options.event || DEFAULT_EVENT; + this.colors = { ...lighting.DEFAULT_COLORS, ...options.colors }; + this.log = options.log || (() => {}); + + this.client = new GameSenseClient({ + game: options.game || DEFAULT_GAME, + gameDisplayName: options.gameDisplayName || 'Ableton Scale Lighting', + developer: options.developer || 'steelseries-live-scale', + address: options.address, + dryRun: options.dryRun, + log: this.log, + }); + } + + /** True once `start()` has registered the game. */ + get isStarted(): boolean { + return this.started; + } + + /** One palette entry, resolved to RGB. */ + color(role: PaletteKey): Rgb { + return lighting.parseColor(this.colors[role]); + } + + /** Register the game + bind the event handlers. Safe to call once. */ + async start(): Promise { + this.client.connect(); + await this.client.registerGame(); + this.log(`registered game ${this.client.game}`); + + await this.client.bindEvent(this.event, lighting.buildHandlers(), { + min_value: 0, + max_value: 100, + value_optional: true, + }); + this.log(`bound event ${this.event}`); + + this.client.startHeartbeat(5000, (err) => + this.log(`heartbeat failed: ${errorMessage(err)}`) + ); + this.started = true; + } + + /** Push a frame as-is, and remember it for `refresh()`. */ + async showFrame(frame: Frame, value = 100): Promise { + this.lastFrame = frame; + const data: EventData = { value, frame }; + await this.client.sendEvent(this.event, data); + return frame; + } + + /** Push an explicit set of pitch classes. */ + async showPitchClasses( + pitchClasses: Iterable, + root: PitchClass | null = null + ): Promise { + return this.showFrame( + lighting.buildFrame({ pitchClasses, root, colors: this.colors }) + ); + } + + /** + * Push a scale. + * + * @param root 0-11 + * @param intervals e.g. [0,2,4,5,7,9,11] + */ + async showScale(root: PitchClass, intervals: readonly number[]): Promise { + return this.showPitchClasses(scale.pitchClassesFor(root, intervals), root); + } + + /** Re-send the last frame (useful after GG restarts). */ + async refresh(): Promise { + if (!this.lastFrame) return null; + return this.showFrame(this.lastFrame); + } + + /** + * Blank the board and hand it back to SteelSeries GG without a prior + * `start()` — for a "turn it off" command against a running GameSense. + */ + async release(): Promise { + this.client.connect(); + this.started = true; + await this.stop(true); + } + + /** + * Blank the board and hand it back to SteelSeries GG. + * + * @param removeGame also delete the game registration + */ + async stop(removeGame = true): Promise { + this.client.stopHeartbeat(); + if (!this.started) return; + + try { + // Deliberately not via showFrame(): a blackout should not become the + // frame that refresh() restores. + await this.client.sendEvent(this.event, { + value: 0, + frame: lighting.buildBlackFrame(), + }); + } catch (err) { + this.log(`blackout failed: ${errorMessage(err)}`); + } + + if (removeGame) { + try { + await this.client.removeGame(); + this.log(`removed game ${this.client.game}`); + } catch (err) { + this.log(`remove_game failed: ${errorMessage(err)}`); + } + } + this.started = false; + } +} + +export { GameSenseClient, lighting, scale }; +export type { ColorInput, Frame, Palette, PaletteKey, PitchClass, Rgb }; +export type { Handler } from './protocol'; +export type { NoteKey, ScaleName } from './scale'; diff --git a/src/lighting.js b/src/lighting.ts similarity index 63% rename from src/lighting.js rename to src/lighting.ts index d232dff..3b67ccf 100644 --- a/src/lighting.js +++ b/src/lighting.ts @@ -1,7 +1,7 @@ -'use strict'; - -const { ALL_KEYS } = require('./hid'); -const { NOTE_KEYS, NOTE_KEY_HIDS, pitchClassesFor } = require('./scale'); +import { ALL_KEYS } from './hid'; +import type { Frame, Handler, Rgb } from './protocol'; +import { NOTE_KEYS, NOTE_KEY_HIDS, pitchClassesFor } from './scale'; +import type { PitchClass } from './scale'; const DEVICE_TYPE = 'rgb-per-key-zones'; const BACKGROUND_FRAME_KEY = 'background'; @@ -24,9 +24,21 @@ const BACKGROUND_FRAME_KEY = 'background'; * handles the "GameSense mode blacks out unaddressed keys" caveat. */ -const BACKGROUND_KEYS = ALL_KEYS.filter((hid) => !NOTE_KEY_HIDS.includes(hid)); +export { DEVICE_TYPE, BACKGROUND_FRAME_KEY }; -const DEFAULT_COLORS = { +export const BACKGROUND_KEYS: readonly number[] = ALL_KEYS.filter( + (hid) => !NOTE_KEY_HIDS.includes(hid) +); + +/** The four roles a key can play, and the color each one gets. */ +export type PaletteKey = 'background' | 'scale' | 'root' | 'off'; + +/** Anything `parseColor` accepts. */ +export type ColorInput = string | Rgb; + +export type Palette = Record; + +export const DEFAULT_COLORS: Record = { background: '#0a0a0f', // near-off, keeps the rest of the board readable scale: '#00b4ff', // notes in the scale root: '#ff5a00', // the root, so you can find "home" at a glance @@ -34,7 +46,7 @@ const DEFAULT_COLORS = { }; /** "#rrggbb" / "rrggbb" / "#rgb" / {red,green,blue} -> {red,green,blue}. */ -function parseColor(input) { +export function parseColor(input: ColorInput): Rgb { if (input && typeof input === 'object') { const { red, green, blue } = input; return { red: clampByte(red), green: clampByte(green), blue: clampByte(blue) }; @@ -59,14 +71,14 @@ function parseColor(input) { }; } -function clampByte(n) { +function clampByte(n: number): number { const v = Math.round(Number(n)); if (!Number.isFinite(v)) throw new Error(`invalid color component: ${n}`); return Math.min(255, Math.max(0, v)); } /** Scale an RGB triple by a 0..1 factor — used for the dim background. */ -function dim(color, factor) { +export function dim(color: Rgb, factor: number): Rgb { return { red: clampByte(color.red * factor), green: clampByte(color.green * factor), @@ -75,8 +87,8 @@ function dim(color, factor) { } /** The handler list to POST to /bind_game_event. Bound once, at startup. */ -function buildHandlers() { - const handlers = [ +export function buildHandlers(): Handler[] { + const handlers: Handler[] = [ { 'device-type': DEVICE_TYPE, mode: 'context-color', @@ -97,25 +109,30 @@ function buildHandlers() { return handlers; } -/** - * Build the `frame` for a set of lit pitch classes. - * - * @param {object} options - * @param {number[]|Set} options.pitchClasses lit pitch classes (0-11) - * @param {number|null} [options.root] highlighted differently - * @param {object} [options.colors] overrides DEFAULT_COLORS - * @returns {object} frame keyed by context-frame-key - */ -function buildFrame({ pitchClasses, root = null, colors = {} }) { +export interface BuildFrameOptions { + /** The lit pitch classes (0-11). */ + pitchClasses: Iterable; + /** Highlighted differently from the rest of the scale. */ + root?: PitchClass | null; + /** Overrides for `DEFAULT_COLORS`. */ + colors?: Partial; +} + +/** Build the `frame` for a set of lit pitch classes, keyed by frame key. */ +export function buildFrame({ + pitchClasses, + root = null, + colors = {}, +}: BuildFrameOptions): Frame { const palette = { - background: parseColor(colors.background || DEFAULT_COLORS.background), - scale: parseColor(colors.scale || DEFAULT_COLORS.scale), - root: parseColor(colors.root || DEFAULT_COLORS.root), - off: parseColor(colors.off || DEFAULT_COLORS.off), + background: parseColor(colors.background ?? DEFAULT_COLORS.background), + scale: parseColor(colors.scale ?? DEFAULT_COLORS.scale), + root: parseColor(colors.root ?? DEFAULT_COLORS.root), + off: parseColor(colors.off ?? DEFAULT_COLORS.off), }; const lit = pitchClasses instanceof Set ? pitchClasses : new Set(pitchClasses); - const frame = { [BACKGROUND_FRAME_KEY]: palette.background }; + const frame: Frame = { [BACKGROUND_FRAME_KEY]: palette.background }; for (const note of NOTE_KEYS) { if (!lit.has(note.pitchClass)) { @@ -131,31 +148,22 @@ function buildFrame({ pitchClasses, root = null, colors = {} }) { } /** Everything black — used to hand the board back cleanly. */ -function buildBlackFrame() { - const black = { red: 0, green: 0, blue: 0 }; - const frame = { [BACKGROUND_FRAME_KEY]: black }; +export function buildBlackFrame(): Frame { + const black: Rgb = { red: 0, green: 0, blue: 0 }; + const frame: Frame = { [BACKGROUND_FRAME_KEY]: black }; for (const note of NOTE_KEYS) frame[note.frameKey] = black; return frame; } /** Convenience: root + intervals -> frame, in one call. */ -function frameForScale(root, intervals, colors) { +export function frameForScale( + root: PitchClass, + intervals: readonly number[], + colors?: Partial +): Frame { return buildFrame({ pitchClasses: pitchClassesFor(root, intervals), root, colors, }); } - -module.exports = { - DEVICE_TYPE, - BACKGROUND_FRAME_KEY, - BACKGROUND_KEYS, - DEFAULT_COLORS, - parseColor, - dim, - buildHandlers, - buildFrame, - buildBlackFrame, - frameForScale, -}; diff --git a/src/protocol.ts b/src/protocol.ts new file mode 100644 index 0000000..30ae3e4 --- /dev/null +++ b/src/protocol.ts @@ -0,0 +1,55 @@ +/** + * The slice of the GameSense JSON API this project speaks. + * + * These are wire types: field names are GameSense's (kebab-case for handler + * properties, snake_case for request bodies), so they are quoted as-is rather + * than renamed. Keeping them in one file means `gamesense.ts` (transport) and + * `lighting.ts` (payload construction) agree on the shapes without either + * depending on the other. + */ + +/** GameSense colors are plain 0-255 triples. */ +export interface Rgb { + readonly red: number; + readonly green: number; + readonly blue: number; +} + +/** The only device class we address: per-key RGB keyboards like the Apex 7. */ +export type DeviceType = 'rgb-per-key-zones'; + +/** Name a handler and a frame agree on, e.g. `background` or `note-a`. */ +export type FrameKey = string; + +/** + * A handler in `context-color` mode: the zone is fixed at bind time, the color + * is read from the event frame at `context-frame-key` on every event. + */ +export interface ContextColorHandler { + 'device-type': DeviceType; + mode: 'context-color'; + 'custom-zone-keys': readonly number[]; + 'context-frame-key': FrameKey; +} + +export type Handler = ContextColorHandler; + +/** The `frame` carried by every event: which color each handler picks up. */ +export type Frame = Record; + +/** The `data` object of a `/game_event` POST. */ +export interface EventData { + value: number; + frame: Frame; +} + +/** Optional fields of a `/bind_game_event` POST. */ +export interface BindEventOptions { + min_value?: number; + max_value?: number; + icon_id?: number; + value_optional?: boolean; +} + +/** GameSense replies with small JSON objects we only ever log. */ +export type GameSenseResponse = Record; diff --git a/src/scale.js b/src/scale.ts similarity index 61% rename from src/scale.js rename to src/scale.ts index 9f04596..e27ca76 100644 --- a/src/scale.js +++ b/src/scale.ts @@ -1,10 +1,12 @@ -'use strict'; +import { HID } from './hid'; -const { HID } = require('./hid'); +/** A pitch class: 0 = C … 11 = B, exactly like Live's `root_note`. */ +export type PitchClass = number; -const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; +export const NOTE_NAMES: readonly string[] = + ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; -const NOTE_ALIASES = { +export const NOTE_ALIASES: Record = { C: 0, 'B#': 0, 'C#': 1, DB: 1, D: 2, @@ -20,7 +22,7 @@ const NOTE_ALIASES = { }; /** Interval sets, matching Live 12's `scale_intervals`. */ -const SCALES = { +export const SCALES = { major: [0, 2, 4, 5, 7, 9, 11], minor: [0, 2, 3, 5, 7, 8, 10], 'harmonic-minor': [0, 2, 3, 5, 7, 8, 11], @@ -35,13 +37,28 @@ const SCALES = { blues: [0, 3, 5, 6, 7, 10], 'whole-tone': [0, 2, 4, 6, 8, 10], chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], -}; +} as const satisfies Record; + +export type ScaleName = keyof typeof SCALES; + +/** One key of Ableton's computer MIDI keyboard. */ +export interface NoteKey { + /** The QWERTY key, lowercase. */ + readonly key: string; + /** Its USB HID usage code — how GameSense addresses it. */ + readonly hid: number; + readonly pitchClass: PitchClass; + /** Where this key looks up its color in an event frame. */ + readonly frameKey: string; + /** True for `K`, which is the same pitch class one octave up. */ + readonly octaveUp?: boolean; +} /** * Ableton's computer MIDI keyboard, one octave laid out like a piano: * white keys on the home row, black keys on the row above, plus the octave C. */ -const NOTE_KEYS = [ +export const NOTE_KEYS: readonly NoteKey[] = [ { key: 'a', hid: HID.a, pitchClass: 0, frameKey: 'note-a' }, { key: 'w', hid: HID.w, pitchClass: 1, frameKey: 'note-w' }, { key: 's', hid: HID.s, pitchClass: 2, frameKey: 'note-s' }, @@ -58,36 +75,43 @@ const NOTE_KEYS = [ { key: 'k', hid: HID.k, pitchClass: 0, frameKey: 'note-k', octaveUp: true }, ]; -const NOTE_KEY_HIDS = NOTE_KEYS.map((k) => k.hid); +export const NOTE_KEY_HIDS: readonly number[] = NOTE_KEYS.map((k) => k.hid); /** "C", "f#", "Bb", "3" -> 0..11. Throws on garbage. */ -function parseRoot(input) { +export function parseRoot(input: string | number): PitchClass { if (typeof input === 'number') { if (!Number.isInteger(input) || input < 0 || input > 11) { throw new Error(`root must be an integer 0-11, got ${input}`); } return input; } - const raw = String(input).trim(); + + const raw = input.trim(); if (/^\d+$/.test(raw)) return parseRoot(Number(raw)); const normalized = raw.toUpperCase().replace(/♯/g, '#').replace(/♭/g, 'B'); - if (normalized in NOTE_ALIASES) return NOTE_ALIASES[normalized]; + const pitchClass = NOTE_ALIASES[normalized]; + if (pitchClass !== undefined) return pitchClass; throw new Error(`unknown root note: ${input}`); } /** "major", "Harmonic Minor", "minor_pentatonic" -> interval array. */ -function parseScale(input) { - const normalized = String(input).trim().toLowerCase().replace(/[\s_]+/g, '-'); - if (normalized in SCALES) return SCALES[normalized]; +export function parseScale(input: string): readonly number[] { + const normalized = input.trim().toLowerCase().replace(/[\s_]+/g, '-'); + if (isScaleName(normalized)) return SCALES[normalized]; throw new Error( `unknown scale: ${input} (known: ${Object.keys(SCALES).join(', ')})` ); } -/** "0,2,4,5,7,9,11" or [0,2,...] -> normalized, deduped, sorted interval array. */ -function parseIntervals(input) { - const list = Array.isArray(input) ? input : String(input).split(/[,\s]+/); +export function isScaleName(name: string): name is ScaleName { + return Object.prototype.hasOwnProperty.call(SCALES, name); +} + +/** "0,2,4,5,7,9,11" or [0,2,...] -> normalized interval array. */ +export function parseIntervals(input: string | readonly number[]): number[] { + const list: readonly (string | number)[] = + typeof input === 'string' ? input.split(/[,\s]+/) : input; const parsed = list .filter((v) => String(v).length > 0) .map((v) => { @@ -104,31 +128,20 @@ function parseIntervals(input) { * lit = { (root + interval) % 12 } * Returns a sorted array of pitch classes. */ -function pitchClassesFor(root, intervals) { +export function pitchClassesFor( + root: PitchClass, + intervals: readonly number[] +): PitchClass[] { const set = new Set(intervals.map((i) => (((root + i) % 12) + 12) % 12)); return [...set].sort((a, b) => a - b); } /** Pitch classes -> the QWERTY keys Ableton maps them to (includes 'k'). */ -function keysFor(pitchClasses) { +export function keysFor(pitchClasses: Iterable): string[] { const set = new Set(pitchClasses); return NOTE_KEYS.filter((k) => set.has(k.pitchClass)).map((k) => k.key); } -function noteName(pitchClass) { +export function noteName(pitchClass: PitchClass): string { return NOTE_NAMES[(((pitchClass % 12) + 12) % 12)]; } - -module.exports = { - NOTE_NAMES, - NOTE_ALIASES, - SCALES, - NOTE_KEYS, - NOTE_KEY_HIDS, - parseRoot, - parseScale, - parseIntervals, - pitchClassesFor, - keysFor, - noteName, -}; diff --git a/test/logic.test.js b/test/logic.test.ts similarity index 91% rename from test/logic.test.js rename to test/logic.test.ts index 1df85f4..fed0c5b 100644 --- a/test/logic.test.js +++ b/test/logic.test.ts @@ -1,37 +1,35 @@ -'use strict'; +import assert from 'assert'; -const assert = require('assert'); - -const { - parseRoot, - parseScale, - parseIntervals, - pitchClassesFor, - keysFor, - noteName, +import { splitAddress } from '../src/gamesense'; +import { ALL_KEYS, HID } from '../src/hid'; +import { + BACKGROUND_FRAME_KEY, + BACKGROUND_KEYS, + buildBlackFrame, + buildFrame, + buildHandlers, + parseColor, +} from '../src/lighting'; +import { NOTE_KEYS, NOTE_KEY_HIDS, SCALES, -} = require('../src/scale'); -const { - parseColor, - buildHandlers, - buildFrame, - buildBlackFrame, - BACKGROUND_KEYS, - BACKGROUND_FRAME_KEY, -} = require('../src/lighting'); -const { splitAddress } = require('../src/gamesense'); -const { HID, ALL_KEYS } = require('../src/hid'); + keysFor, + noteName, + parseIntervals, + parseRoot, + parseScale, + pitchClassesFor, +} from '../src/scale'; let passed = 0; -function test(name, fn) { +function test(name: string, fn: () => void): void { try { fn(); passed++; console.log(` ok ${name}`); } catch (err) { - console.error(` FAIL ${name}\n ${err.message}`); + console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`); process.exitCode = 1; } } @@ -68,7 +66,7 @@ test('raw interval lists parse', () => { test('C major lights the seven white keys', () => { const pcs = pitchClassesFor(0, SCALES.major); assert.deepStrictEqual(pcs, [0, 2, 4, 5, 7, 9, 11]); - assert.deepStrictEqual(pcs.map(noteName), ['C', 'D', 'E', 'F', 'G', 'A', 'B']); + assert.deepStrictEqual(pcs.map((pc) => noteName(pc)), ['C', 'D', 'E', 'F', 'G', 'A', 'B']); // Home row plus the octave key. assert.deepStrictEqual(keysFor(pcs), ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']); }); @@ -76,7 +74,10 @@ test('C major lights the seven white keys', () => { test('F# major wraps around the octave correctly', () => { const pcs = pitchClassesFor(6, SCALES.major); assert.deepStrictEqual(pcs, [1, 3, 6, 8, 10, 11, 5].sort((a, b) => a - b)); - assert.deepStrictEqual(pcs.map(noteName).sort(), ['A#', 'B', 'C#', 'D#', 'F', 'F#', 'G#']); + assert.deepStrictEqual( + pcs.map((pc) => noteName(pc)).sort(), + ['A#', 'B', 'C#', 'D#', 'F', 'F#', 'G#'] + ); }); test('A minor is the same pitch classes as C major', () => { @@ -107,7 +108,7 @@ test('note keys match the Ableton layout table in the brief', () => { }); test('HID codes match the USB HID keyboard page', () => { - const expected = { + const expected: Record = { a: 0x04, w: 0x1a, s: 0x16, e: 0x08, d: 0x07, f: 0x09, t: 0x17, g: 0x0a, y: 0x1c, h: 0x0b, u: 0x18, j: 0x0d, k: 0x0e, }; diff --git a/tools/fake-gamesense.js b/tools/fake-gamesense.js deleted file mode 100644 index e7e9ba8..0000000 --- a/tools/fake-gamesense.js +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env node -'use strict'; - -/** - * A stand-in for the SteelSeries GameSense server. - * - * It speaks enough of the real API to exercise the client, and it resolves the - * bound handlers against each incoming frame to render the keyboard as ANSI - * colour in the terminal. That makes the lighting logic verifiable without an - * Apex 7 (or Windows) in front of you. - * - * node tools/fake-gamesense.js --port 51000 - * node bin/apex7-scale.js --address 127.0.0.1:51000 --root D --scale dorian - */ - -const http = require('http'); -const { HID } = require('../src/hid'); - -const args = process.argv.slice(2); -const portArg = args.indexOf('--port'); -const PORT = portArg >= 0 ? Number(args[portArg + 1]) : 51000; -const QUIET = args.includes('--quiet'); -const ONCE = args.includes('--once'); // exit after the first rendered frame - -// hid -> [label, width] -const LAYOUT = [ - [['esc', HID.escape], null, ['f1', HID.f1], ['f2', HID.f2], ['f3', HID.f3], ['f4', HID.f4], null, - ['f5', HID.f5], ['f6', HID.f6], ['f7', HID.f7], ['f8', HID.f8], null, - ['f9', HID.f9], ['f10', HID.f10], ['f11', HID.f11], ['f12', HID.f12], null, - ['prt', HID.printScreen], ['scr', HID.scrollLock], ['brk', HID.pause]], - - [['`', HID.backquote], ['1', HID['1']], ['2', HID['2']], ['3', HID['3']], ['4', HID['4']], - ['5', HID['5']], ['6', HID['6']], ['7', HID['7']], ['8', HID['8']], ['9', HID['9']], - ['0', HID['0']], ['-', HID.minus], ['=', HID.equal], ['bsp', HID.backspace], null, - ['ins', HID.insert], ['hom', HID.home], ['pup', HID.pageUp], null, - ['num', HID.numLock], ['/', HID.numpadDivide], ['*', HID.numpadMultiply], ['-', HID.numpadSubtract]], - - [['tab', HID.tab], ['q', HID.q], ['w', HID.w], ['e', HID.e], ['r', HID.r], ['t', HID.t], - ['y', HID.y], ['u', HID.u], ['i', HID.i], ['o', HID.o], ['p', HID.p], - ['[', HID.bracketLeft], [']', HID.bracketRight], ['\\', HID.backslash], null, - ['del', HID.delete], ['end', HID.end], ['pdn', HID.pageDown], null, - ['7', HID.numpad7], ['8', HID.numpad8], ['9', HID.numpad9], ['+', HID.numpadAdd]], - - [['cap', HID.capsLock], ['a', HID.a], ['s', HID.s], ['d', HID.d], ['f', HID.f], ['g', HID.g], - ['h', HID.h], ['j', HID.j], ['k', HID.k], ['l', HID.l], [';', HID.semicolon], - ["'", HID.quote], ['ent', HID.enter], null, null, null, null, null, null, - ['4', HID.numpad4], ['5', HID.numpad5], ['6', HID.numpad6]], - - [['sft', HID.shiftLeft], ['z', HID.z], ['x', HID.x], ['c', HID.c], ['v', HID.v], ['b', HID.b], - ['n', HID.n], ['m', HID.m], [',', HID.comma], ['.', HID.period], ['/', HID.slash], - ['sft', HID.shiftRight], null, null, null, null, ['up', HID.arrowUp], null, null, - ['1', HID.numpad1], ['2', HID.numpad2], ['3', HID.numpad3], ['ent', HID.numpadEnter]], - - [['ctl', HID.controlLeft], ['win', HID.metaLeft], ['alt', HID.altLeft], - ['spc', HID.space], ['alt', HID.altRight], ['win', HID.metaRight], - ['men', HID.application], ['ctl', HID.controlRight], null, null, null, null, null, - ['lft', HID.arrowLeft], ['dn', HID.arrowDown], ['rgt', HID.arrowRight], null, - ['0', HID.numpad0], ['.', HID.numpadDecimal]], -]; - -const state = { - games: new Map(), // game -> { metadata, events: Map } - lastColors: new Map(), // hid -> {red,green,blue} -}; - -function cell(label, color) { - const text = ` ${String(label).slice(0, 3).padEnd(3)} `; - if (!color) return `\x1b[2m${text}\x1b[0m`; - const { red, green, blue } = color; - // Relative luminance, to keep the label readable on any background. - const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue; - const fg = luma > 140 ? '30' : '97'; - return `\x1b[48;2;${red};${green};${blue}m\x1b[${fg}m${text}\x1b[0m`; -} - -function render() { - const lines = LAYOUT.map((row) => - row.map((key) => (key ? cell(key[0], state.lastColors.get(key[1])) : ' ')).join('') - ); - console.log(`\n${lines.join('\n')}\n`); -} - -/** Resolve bound handlers against an incoming frame -> hid => color. */ -function applyFrame(handlers, frame) { - const colors = new Map(); - for (const handler of handlers) { - if (handler['device-type'] !== 'rgb-per-key-zones') continue; - - let color = null; - if (handler.mode === 'context-color') { - color = frame[handler['context-frame-key']] || null; - } else if (handler.color) { - color = handler.color; - } - if (!color) continue; - - for (const hid of handler['custom-zone-keys'] || []) colors.set(hid, color); - } - return colors; -} - -function readBody(req) { - return new Promise((resolve, reject) => { - const chunks = []; - req.on('data', (c) => chunks.push(c)); - req.on('end', () => { - const text = Buffer.concat(chunks).toString('utf8'); - try { - resolve(text ? JSON.parse(text) : {}); - } catch (err) { - reject(err); - } - }); - req.on('error', reject); - }); -} - -function log(...parts) { - if (!QUIET) console.log(...parts); -} - -const server = http.createServer(async (req, res) => { - let body; - try { - body = await readBody(req); - } catch (err) { - res.writeHead(400, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ error: `bad JSON: ${err.message}` })); - return; - } - - const reply = (code, payload) => { - res.writeHead(code, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(payload)); - }; - - const game = body.game; - const entry = () => { - if (!state.games.has(game)) { - state.games.set(game, { metadata: null, events: new Map() }); - } - return state.games.get(game); - }; - - switch (req.url) { - case '/game_metadata': - if (!game) return reply(400, { error: 'missing game' }); - entry().metadata = body; - log(`[metadata] ${game} "${body.game_display_name}" by ${body.developer}`); - return reply(200, { game }); - - case '/bind_game_event': { - if (!game || !body.event) return reply(400, { error: 'missing game or event' }); - const handlers = body.handlers || []; - entry().events.set(body.event, handlers); - const zoneKeys = handlers.reduce( - (n, h) => n + (h['custom-zone-keys'] || []).length, - 0 - ); - log( - `[bind] ${game}/${body.event}: ${handlers.length} handlers, ${zoneKeys} keys addressed` - ); - return reply(200, { game, event: body.event }); - } - - case '/game_event': { - const known = state.games.get(game); - const handlers = known && known.events.get(body.event); - if (!handlers) return reply(404, { error: `no handler for ${game}/${body.event}` }); - - const frame = (body.data && body.data.frame) || {}; - const missing = handlers - .filter((h) => h.mode === 'context-color' && !(h['context-frame-key'] in frame)) - .map((h) => h['context-frame-key']); - if (missing.length) { - log(`[warn] frame is missing keys: ${missing.join(', ')}`); - } - - const colors = applyFrame(handlers, frame); - state.lastColors = colors; - log(`[event] ${game}/${body.event} value=${body.data && body.data.value}`); - render(); - if (ONCE) setTimeout(() => process.exit(0), 50); - return reply(200, { game, event: body.event }); - } - - case '/game_heartbeat': - log(`[heartbeat] ${game}`); - return reply(200, { game }); - - case '/remove_game': - state.games.delete(game); - state.lastColors = new Map(); - log(`[remove] ${game}`); - return reply(200, { game }); - - default: - return reply(404, { error: `unhandled endpoint ${req.url}` }); - } -}); - -server.listen(PORT, '127.0.0.1', () => { - console.log(`fake GameSense server on 127.0.0.1:${PORT}`); - console.log(` GAMESENSE_ADDRESS=127.0.0.1:${PORT} node bin/apex7-scale.js --root C --scale major\n`); -}); diff --git a/tools/fake-gamesense.ts b/tools/fake-gamesense.ts new file mode 100644 index 0000000..111d202 --- /dev/null +++ b/tools/fake-gamesense.ts @@ -0,0 +1,226 @@ +#!/usr/bin/env node + +/** + * A stand-in for the SteelSeries GameSense server. + * + * It speaks enough of the real API to exercise the client, and it resolves the + * bound handlers against each incoming frame to render the keyboard as ANSI + * colour in the terminal. That makes the lighting logic verifiable without an + * Apex 7 (or Windows) in front of you. + * + * npm run stub -- --port 51000 + * node bin/apex7-scale.js --address 127.0.0.1:51000 --root D --scale dorian + */ + +import http from 'http'; + +import { errorMessage } from '../src/errors'; +import { HID } from '../src/hid'; +import type { Frame, Handler, Rgb } from '../src/protocol'; + +const args = process.argv.slice(2); +const portArg = args.indexOf('--port'); +const PORT = portArg >= 0 ? Number(args[portArg + 1]) : 51000; +const QUIET = args.includes('--quiet'); +const ONCE = args.includes('--once'); // exit after the first rendered frame + +/** A printable key, or a gap in the row. */ +type Cell = readonly [label: string, hid: number] | null; + +const LAYOUT: readonly (readonly Cell[])[] = [ + [['esc', HID.escape], null, ['f1', HID.f1], ['f2', HID.f2], ['f3', HID.f3], ['f4', HID.f4], null, + ['f5', HID.f5], ['f6', HID.f6], ['f7', HID.f7], ['f8', HID.f8], null, + ['f9', HID.f9], ['f10', HID.f10], ['f11', HID.f11], ['f12', HID.f12], null, + ['prt', HID.printScreen], ['scr', HID.scrollLock], ['brk', HID.pause]], + + [['`', HID.backquote], ['1', HID[1]], ['2', HID[2]], ['3', HID[3]], ['4', HID[4]], + ['5', HID[5]], ['6', HID[6]], ['7', HID[7]], ['8', HID[8]], ['9', HID[9]], + ['0', HID[0]], ['-', HID.minus], ['=', HID.equal], ['bsp', HID.backspace], null, + ['ins', HID.insert], ['hom', HID.home], ['pup', HID.pageUp], null, + ['num', HID.numLock], ['/', HID.numpadDivide], ['*', HID.numpadMultiply], ['-', HID.numpadSubtract]], + + [['tab', HID.tab], ['q', HID.q], ['w', HID.w], ['e', HID.e], ['r', HID.r], ['t', HID.t], + ['y', HID.y], ['u', HID.u], ['i', HID.i], ['o', HID.o], ['p', HID.p], + ['[', HID.bracketLeft], [']', HID.bracketRight], ['\\', HID.backslash], null, + ['del', HID.delete], ['end', HID.end], ['pdn', HID.pageDown], null, + ['7', HID.numpad7], ['8', HID.numpad8], ['9', HID.numpad9], ['+', HID.numpadAdd]], + + [['cap', HID.capsLock], ['a', HID.a], ['s', HID.s], ['d', HID.d], ['f', HID.f], ['g', HID.g], + ['h', HID.h], ['j', HID.j], ['k', HID.k], ['l', HID.l], [';', HID.semicolon], + ["'", HID.quote], ['ent', HID.enter], null, null, null, null, null, null, + ['4', HID.numpad4], ['5', HID.numpad5], ['6', HID.numpad6]], + + [['sft', HID.shiftLeft], ['z', HID.z], ['x', HID.x], ['c', HID.c], ['v', HID.v], ['b', HID.b], + ['n', HID.n], ['m', HID.m], [',', HID.comma], ['.', HID.period], ['/', HID.slash], + ['sft', HID.shiftRight], null, null, null, null, ['up', HID.arrowUp], null, null, + ['1', HID.numpad1], ['2', HID.numpad2], ['3', HID.numpad3], ['ent', HID.numpadEnter]], + + [['ctl', HID.controlLeft], ['win', HID.metaLeft], ['alt', HID.altLeft], + ['spc', HID.space], ['alt', HID.altRight], ['win', HID.metaRight], + ['men', HID.application], ['ctl', HID.controlRight], null, null, null, null, null, + ['lft', HID.arrowLeft], ['dn', HID.arrowDown], ['rgt', HID.arrowRight], null, + ['0', HID.numpad0], ['.', HID.numpadDecimal]], +]; + +interface GameEntry { + metadata: unknown; + events: Map; +} + +const state = { + games: new Map(), + lastColors: new Map(), +}; + +/** + * Request bodies are whatever the client POSTs. This stub only ever talks to + * our own client on localhost, so it reads the fields it cares about rather + * than validating the whole shape. + */ +interface GameSenseRequest { + game?: string; + event?: string; + handlers?: readonly Handler[]; + data?: { value?: number; frame?: Frame }; + game_display_name?: string; + developer?: string; +} + +function cell(label: string, color: Rgb | undefined): string { + const text = ` ${String(label).slice(0, 3).padEnd(3)} `; + if (!color) return `\x1b[2m${text}\x1b[0m`; + const { red, green, blue } = color; + // Relative luminance, to keep the label readable on any background. + const luma = 0.2126 * red + 0.7152 * green + 0.0722 * blue; + const fg = luma > 140 ? '30' : '97'; + return `\x1b[48;2;${red};${green};${blue}m\x1b[${fg}m${text}\x1b[0m`; +} + +function render(): void { + const lines = LAYOUT.map((row) => + row.map((key) => (key ? cell(key[0], state.lastColors.get(key[1])) : ' ')).join('') + ); + console.log(`\n${lines.join('\n')}\n`); +} + +/** Resolve bound handlers against an incoming frame -> hid => color. */ +function applyFrame(handlers: readonly Handler[], frame: Frame): Map { + const colors = new Map(); + for (const handler of handlers) { + if (handler['device-type'] !== 'rgb-per-key-zones') continue; + if (handler.mode !== 'context-color') continue; + + const color = frame[handler['context-frame-key']]; + if (!color) continue; + + for (const hid of handler['custom-zone-keys'] || []) colors.set(hid, color); + } + return colors; +} + +function readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + try { + resolve(text ? (JSON.parse(text) as GameSenseRequest) : {}); + } catch (err) { + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + req.on('error', reject); + }); +} + +function log(...parts: unknown[]): void { + if (!QUIET) console.log(...parts); +} + +const server = http.createServer((req, res) => { + const reply = (code: number, payload: unknown): void => { + res.writeHead(code, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(payload)); + }; + + void (async () => { + let body: GameSenseRequest; + try { + body = await readBody(req); + } catch (err) { + reply(400, { error: `bad JSON: ${errorMessage(err)}` }); + return; + } + + const game = body.game; + const entry = (name: string): GameEntry => { + let found = state.games.get(name); + if (!found) { + found = { metadata: null, events: new Map() }; + state.games.set(name, found); + } + return found; + }; + + switch (req.url) { + case '/game_metadata': + if (!game) return reply(400, { error: 'missing game' }); + entry(game).metadata = body; + log(`[metadata] ${game} "${body.game_display_name}" by ${body.developer}`); + return reply(200, { game }); + + case '/bind_game_event': { + if (!game || !body.event) return reply(400, { error: 'missing game or event' }); + const handlers = body.handlers || []; + entry(game).events.set(body.event, handlers); + const zoneKeys = handlers.reduce( + (n, h) => n + (h['custom-zone-keys'] || []).length, + 0 + ); + log( + `[bind] ${game}/${body.event}: ${handlers.length} handlers, ${zoneKeys} keys addressed` + ); + return reply(200, { game, event: body.event }); + } + + case '/game_event': { + const known = game !== undefined ? state.games.get(game) : undefined; + const handlers = known && body.event ? known.events.get(body.event) : undefined; + if (!handlers) return reply(404, { error: `no handler for ${game}/${body.event}` }); + + const frame = body.data?.frame ?? {}; + const missing = handlers + .filter((h) => h.mode === 'context-color' && !(h['context-frame-key'] in frame)) + .map((h) => h['context-frame-key']); + if (missing.length) { + log(`[warn] frame is missing keys: ${missing.join(', ')}`); + } + + state.lastColors = applyFrame(handlers, frame); + log(`[event] ${game}/${body.event} value=${body.data?.value}`); + render(); + if (ONCE) setTimeout(() => process.exit(0), 50); + return reply(200, { game, event: body.event }); + } + + case '/game_heartbeat': + log(`[heartbeat] ${game}`); + return reply(200, { game }); + + case '/remove_game': + if (game !== undefined) state.games.delete(game); + state.lastColors = new Map(); + log(`[remove] ${game}`); + return reply(200, { game }); + + default: + return reply(404, { error: `unhandled endpoint ${req.url}` }); + } + })(); +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`fake GameSense server on 127.0.0.1:${PORT}`); + console.log(` GAMESENSE_ADDRESS=127.0.0.1:${PORT} node bin/apex7-scale.js --root C --scale major\n`); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bc5e49c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + /* Node for Max (build step 3) loads CommonJS from a Node we do not + control, so emit plain CJS at a conservative language level. */ + "target": "ES2020", + "lib": ["ES2020"], + "types": ["node"], + /* package.json has no "type", so node16 emits CommonJS. */ + "module": "node16", + "moduleResolution": "node16", + + "rootDir": ".", + "outDir": "dist", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "forceConsistentCasingInFileNames": true, + + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "tools/**/*.ts"] +}