Port phase 1 to TypeScript

Move the standalone GameSense scale lighting to TypeScript ahead of the
Max for Live work, so step 3 gets type declarations at the seam.

Build emits CommonJS at ES2020 into dist/, because Node for Max loads CJS
on a Node version we do not control. bin/apex7-scale.js becomes a plain-JS
launcher so `node bin/apex7-scale.js` keeps working; the CLI itself moves
to src/cli.ts.

Two modules make previously implicit structure explicit:

  - src/protocol.ts, the GameSense wire types, so the transport and the
    payload construction agree on shapes neither of them owns
  - src/errors.ts, since `catch (err)` binds `unknown` under strict

Typing surfaced a few real fixes:

  - ScaleLighting.started was a public field the CLI set by hand to make
    --off work without start(); that is now release(), with started
    private behind isStarted
  - readAddress() trusted JSON.parse output; the address is now checked
    for being a non-empty string
  - res.statusCode is number|undefined, so the old `>= 200` comparison
    coerced silently; a missing status now rejects
  - parseIntervals claimed to dedupe and sort, and never did (comment
    corrected; pitchClassesFor is what dedupes)

Behavior is otherwise unchanged: all 18 tests pass and --once, --off,
--demo, --key-test, the resident stdin loop and the error paths were
verified against tools/fake-gamesense.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-12 13:30:53 +00:00
parent 425eb86327
commit 3b02612461
18 changed files with 1550 additions and 837 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,10 @@
node_modules/ node_modules/
npm-debug.log* npm-debug.log*
# TypeScript build output
dist/
*.tsbuildinfo
# Claude Code local (per-machine) settings # Claude Code local (per-machine) settings
.claude/settings.local.json .claude/settings.local.json
.claude/settings.local.json.tmp.* .claude/settings.local.json.tmp.*

View File

@@ -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 a standalone script that talks to GameSense directly. Live/Max for Live is not
involved yet — you pass the scale on the command line. 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) ## Quick start (Windows, with SteelSeries GG running)
```powershell ```powershell
npm install # also compiles src\*.ts to dist\
node bin\apex7-scale.js --root C --scale major 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 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 orange. The process stays resident and heartbeats so the lighting sticks; you
can type new scales at the prompt: can type new scales at the prompt:
@@ -78,12 +82,12 @@ Two design notes:
## Testing without hardware ## 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 real endpoints, resolves the bound handlers against each frame, and renders the
resulting keyboard as ANSI color in the terminal. resulting keyboard as ANSI color in the terminal.
```bash ```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 node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # terminal 2
``` ```
@@ -99,22 +103,31 @@ npm test
## Layout ## Layout
``` ```
bin/apex7-scale.js CLI bin/apex7-scale.js launcher (plain JS) — runs dist/src/cli.js
src/hid.js USB HID usage codes; the full-board key list src/cli.ts argument parsing, output, the resident stdin loop
src/scale.js scale presets, root/interval parsing, pitch class -> QWERTY key src/protocol.ts GameSense wire types (Rgb, Frame, Handler, …)
src/gamesense.js GameSense REST client (coreProps discovery, heartbeat, cleanup) src/hid.ts USB HID usage codes; the full-board key list
src/lighting.js handler + frame construction src/scale.ts scale presets, root/interval parsing, pitch class -> QWERTY key
src/index.js ScaleLighting — the API step 3 will call from Node for Max src/gamesense.ts GameSense REST client (coreProps discovery, heartbeat, cleanup)
tools/fake-gamesense.js terminal simulator of the GameSense server src/lighting.ts handler + frame construction
test/logic.test.js unit tests 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) ## 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 ```ts
const { ScaleLighting } = require('./src'); import { ScaleLighting } from 'steelseries-live-scale';
// or, from a Node for Max script: require('<repo>/dist/src')
const lights = new ScaleLighting(); const lights = new ScaleLighting();
await lights.start(); // register + bind + heartbeat await lights.start(); // register + bind + heartbeat
@@ -135,5 +148,6 @@ as `root_note` and `scale_intervals`.
holds an exclusive lighting profile. holds an exclusive lighting profile.
- **Lighting reverts after ~15s** — that's GameSense's deactivation timeout; - **Lighting reverts after ~15s** — that's GameSense's deactivation timeout;
it only happens with `--once`, since resident mode heartbeats every 5s. 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. keys not addressed by any handler go black in GameSense mode.
- **`apex7-scale is not built yet`** — run `npm run build`.

View File

@@ -1,322 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
'use strict'; '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 fs = require('fs');
const { const path = require('path');
SCALES,
NOTE_KEYS,
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
} = require('../src/scale');
const { parseColor, buildFrame, DEFAULT_COLORS } = require('../src/lighting');
const USAGE = ` const compiled = path.join(__dirname, '..', 'dist', 'src', 'cli.js');
apex7-scale — light the notes of a scale on a SteelSeries Apex 7 (GameSense)
Usage: if (!fs.existsSync(compiled)) {
apex7-scale [options] console.error('apex7-scale is not built yet — run: npm run build');
Scale selection:
--root <note> Root note: C, F#, Bb, or 0-11 (default: C)
--scale <name> Scale name (see --list) (default: major)
--intervals <list> Raw intervals instead of --scale, e.g. 0,2,4,7,9
Colors (hex, #rrggbb):
--bg <color> Background for all non-note keys (default: ${DEFAULT_COLORS.background})
--color <color> Notes in the scale (default: ${DEFAULT_COLORS.scale})
--root-color <color> The root note (default: ${DEFAULT_COLORS.root})
--off-color <color> Note keys outside the scale (default: ${DEFAULT_COLORS.off})
Modes:
--once Send one frame and exit (lighting reverts after ~15s)
--demo [seconds] Cycle through roots and scales (default: 3s)
--key-test [seconds] Light each note key one at a time, to verify the mapping
--off Blank the board, deregister, exit
Other:
--address <host:port> Skip coreProps.json discovery
--dry-run Print the JSON payloads instead of sending them
--list List known scale names
--quiet Only print the scale summary
-h, --help This message
Without --once/--demo/--key-test the process stays resident (heartbeating so the
lighting sticks) and reads "<root> <scale>" lines from stdin, e.g. "F# dorian".
Ctrl+C restores normal lighting.
`.trim();
function parseArgs(argv) {
const opts = {
root: 'C',
scale: 'major',
intervals: null,
colors: {},
once: false,
demo: null,
keyTest: null,
off: false,
address: undefined,
dryRun: false,
list: false,
help: false,
quiet: false,
};
/** Read an optional numeric value that may follow a flag. */
const optionalNumber = (i, fallback) => {
const next = argv[i + 1];
if (next !== undefined && /^\d+(\.\d+)?$/.test(next)) {
return { value: Number(next), consumed: 1 };
}
return { value: fallback, consumed: 0 };
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const need = () => {
const value = argv[++i];
if (value === undefined) throw new Error(`${arg} requires a value`);
return value;
};
switch (arg) {
case '--root': opts.root = need(); break;
case '--scale': opts.scale = need(); break;
case '--intervals': opts.intervals = need(); break;
case '--bg': opts.colors.background = need(); break;
case '--color': opts.colors.scale = need(); break;
case '--root-color': opts.colors.root = need(); break;
case '--off-color': opts.colors.off = need(); break;
case '--address': opts.address = need(); break;
case '--once': opts.once = true; break;
case '--off': opts.off = true; break;
case '--dry-run': opts.dryRun = true; break;
case '--list': opts.list = true; break;
case '--quiet': opts.quiet = true; break;
case '-h':
case '--help': opts.help = true; break;
case '--demo': {
const { value, consumed } = optionalNumber(i, 3);
opts.demo = value;
i += consumed;
break;
}
case '--key-test': {
const { value, consumed } = optionalNumber(i, 1.5);
opts.keyTest = value;
i += consumed;
break;
}
default:
throw new Error(`unknown option: ${arg}`);
}
}
return opts;
}
function describe(root, intervals) {
const pcs = pitchClassesFor(root, intervals);
return {
pitchClasses: pcs,
notes: pcs.map(noteName),
keys: keysFor(pcs),
};
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function main() {
let opts;
try {
opts = parseArgs(process.argv.slice(2));
} catch (err) {
console.error(`error: ${err.message}\n`);
console.error(USAGE);
process.exitCode = 2;
return;
}
if (opts.help) {
console.log(USAGE);
return;
}
if (opts.list) {
for (const [name, intervals] of Object.entries(SCALES)) {
console.log(`${name.padEnd(18)} ${intervals.join(', ')}`);
}
return;
}
// Validate colors up front so a typo fails before we touch the keyboard.
const COLOR_FLAGS = { background: '--bg', scale: '--color', root: '--root-color', off: '--off-color' };
for (const [name, value] of Object.entries(opts.colors)) {
try {
parseColor(value);
} catch (err) {
console.error(`error: ${COLOR_FLAGS[name]}: ${err.message}`);
process.exitCode = 2;
return;
}
}
const verbose = !opts.quiet;
const log = verbose ? (msg) => console.error(`[gamesense] ${msg}`) : () => {};
const controller = new ScaleLighting({
colors: opts.colors,
address: opts.address,
dryRun: opts.dryRun,
log,
});
if (opts.off) {
try {
controller.started = true; // allow stop() to blank without a full start
controller.client.connect();
await controller.stop(true);
console.log('lighting released back to SteelSeries GG');
} catch (err) {
console.error(`error: ${err.message}`);
process.exitCode = 1;
}
return;
}
let root;
let intervals;
try {
root = parseRoot(opts.root);
intervals = opts.intervals ? parseIntervals(opts.intervals) : parseScale(opts.scale);
} catch (err) {
console.error(`error: ${err.message}`);
process.exitCode = 2;
return;
}
const show = async (r, iv, label) => {
const info = describe(r, iv);
console.log(
`${noteName(r)} ${label} -> ${info.notes.join(' ')} | keys: ${info.keys
.join(' ')
.toUpperCase()}`
);
await controller.showScale(r, iv);
};
let shuttingDown = false;
const shutdown = async (code = 0) => {
if (shuttingDown) return;
shuttingDown = true;
await controller.stop(true);
process.exit(code);
};
process.on('SIGINT', () => shutdown(0));
process.on('SIGTERM', () => shutdown(0));
try {
await controller.start();
if (opts.keyTest !== null) {
console.log('key test: each note key lights on its own, in order');
for (const note of NOTE_KEYS) {
console.log(
` ${note.key.toUpperCase()} -> ${noteName(note.pitchClass)}${
note.octaveUp ? ' (octave up)' : ''
}`
);
await controller.client.sendEvent(controller.event, {
value: 100,
frame: singleKeyFrame(controller, note),
});
await sleep(opts.keyTest * 1000);
}
await shutdown(0);
return;
}
if (opts.demo !== null) {
console.log('demo: Ctrl+C to stop');
const names = Object.keys(SCALES);
let i = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const scaleName = names[i % names.length];
const demoRoot = (i * 5) % 12; // walk the circle of fourths
await show(demoRoot, SCALES[scaleName], scaleName);
await sleep(opts.demo * 1000);
i++;
}
}
await show(root, intervals, opts.intervals ? `[${intervals.join(',')}]` : opts.scale);
if (opts.once) {
controller.client.stopHeartbeat();
if (verbose) {
console.error(
'[gamesense] --once: GameSense drops the effect after ~15s without events'
);
}
return;
}
// Accept "<root> <scale>" lines on stdin, from a terminal or a pipe.
const interactive = Boolean(process.stdin.isTTY);
if (interactive) {
console.log('\nType "<root> <scale>" to change (e.g. "F# dorian"), Ctrl+C to quit.');
} else if (verbose) {
console.error('[gamesense] holding lighting, Ctrl+C to quit');
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', async (line) => {
const text = line.trim();
if (!text || text.startsWith('#')) return;
if (text === 'quit' || text === 'exit') return shutdown(0);
const [rootText, ...rest] = text.split(/\s+/);
const scaleText = rest.join('-') || 'major';
try {
const r = parseRoot(rootText);
const iv = parseScale(scaleText);
await show(r, iv, scaleText);
} catch (err) {
console.error(` ! ${err.message}`);
}
});
// A closed terminal means "quit". A closed pipe just means there are no
// more commands coming — keep holding the lighting until signalled.
rl.on('close', () => {
if (interactive) shutdown(0);
});
// The heartbeat interval keeps the process alive from here on.
await new Promise(() => {});
} catch (err) {
console.error(`error: ${err.message}`);
await controller.stop(true).catch(() => {});
process.exitCode = 1;
}
}
/** One note key lit, everything else at background — used by --key-test. */
function singleKeyFrame(controller, note) {
const frame = buildFrame({ pitchClasses: [], colors: controller.colors });
frame[note.frameKey] = parseColor(controller.colors.scale);
return frame;
}
main().catch((err) => {
console.error(err && err.stack ? err.stack : err);
process.exit(1); process.exit(1);
}); }
require(compiled).run();

415
package-lock.json generated Normal file
View File

@@ -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"
}
}
}

View File

@@ -2,18 +2,30 @@
"name": "steelseries-live-scale", "name": "steelseries-live-scale",
"version": "0.1.0", "version": "0.1.0",
"description": "Highlight the notes of the current Ableton Live scale on a SteelSeries Apex 7 via GameSense", "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": { "bin": {
"apex7-scale": "bin/apex7-scale.js" "apex7-scale": "bin/apex7-scale.js"
}, },
"files": [
"bin",
"dist/src"
],
"scripts": { "scripts": {
"start": "node bin/apex7-scale.js", "build": "tsc",
"stub": "node tools/fake-gamesense.js", "typecheck": "tsc --noEmit",
"test": "node test/logic.test.js" "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": { "engines": {
"node": ">=14" "node": ">=16"
}, },
"license": "MIT", "license": "MIT",
"private": true "private": true,
"devDependencies": {
"@types/node": "^26.2.0",
"typescript": "^7.0.2"
}
} }

358
src/cli.ts Normal file
View File

@@ -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 <note> Root note: C, F#, Bb, or 0-11 (default: C)
--scale <name> Scale name (see --list) (default: major)
--intervals <list> Raw intervals instead of --scale, e.g. 0,2,4,7,9
Colors (hex, #rrggbb):
--bg <color> Background for all non-note keys (default: ${DEFAULT_COLORS.background})
--color <color> Notes in the scale (default: ${DEFAULT_COLORS.scale})
--root-color <color> The root note (default: ${DEFAULT_COLORS.root})
--off-color <color> Note keys outside the scale (default: ${DEFAULT_COLORS.off})
Modes:
--once Send one frame and exit (lighting reverts after ~15s)
--demo [seconds] Cycle through roots and scales (default: 3s)
--key-test [seconds] Light each note key one at a time, to verify the mapping
--off Blank the board, deregister, exit
Other:
--address <host:port> Skip coreProps.json discovery
--dry-run Print the JSON payloads instead of sending them
--list List known scale names
--quiet Only print the scale summary
-h, --help This message
Without --once/--demo/--key-test the process stays resident (heartbeating so the
lighting sticks) and reads "<root> <scale>" lines from stdin, e.g. "F# dorian".
Ctrl+C restores normal lighting.
`.trim();
/** Which CLI flag sets which palette entry — also used to report bad values. */
const COLOR_FLAGS: Record<PaletteKey, string> = {
background: '--bg',
scale: '--color',
root: '--root-color',
off: '--off-color',
};
interface CliOptions {
root: string;
scale: string;
intervals: string | null;
colors: Partial<Record<PaletteKey, string>>;
once: boolean;
demo: number | null;
keyTest: number | null;
off: boolean;
address?: string;
dryRun: boolean;
list: boolean;
help: boolean;
quiet: boolean;
}
export function parseArgs(argv: readonly string[]): CliOptions {
const opts: CliOptions = {
root: 'C',
scale: 'major',
intervals: null,
colors: {},
once: false,
demo: null,
keyTest: null,
off: false,
address: undefined,
dryRun: false,
list: false,
help: false,
quiet: false,
};
/** Read an optional numeric value that may follow a flag. */
const optionalNumber = (i: number, fallback: number) => {
const next: string | undefined = argv[i + 1];
if (next !== undefined && /^\d+(\.\d+)?$/.test(next)) {
return { value: Number(next), consumed: 1 };
}
return { value: fallback, consumed: 0 };
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const need = (): string => {
const value: string | undefined = argv[++i];
if (value === undefined) throw new Error(`${arg} requires a value`);
return value;
};
switch (arg) {
case '--root': opts.root = need(); break;
case '--scale': opts.scale = need(); break;
case '--intervals': opts.intervals = need(); break;
case '--bg': opts.colors.background = need(); break;
case '--color': opts.colors.scale = need(); break;
case '--root-color': opts.colors.root = need(); break;
case '--off-color': opts.colors.off = need(); break;
case '--address': opts.address = need(); break;
case '--once': opts.once = true; break;
case '--off': opts.off = true; break;
case '--dry-run': opts.dryRun = true; break;
case '--list': opts.list = true; break;
case '--quiet': opts.quiet = true; break;
case '-h':
case '--help': opts.help = true; break;
case '--demo': {
const { value, consumed } = optionalNumber(i, 3);
opts.demo = value;
i += consumed;
break;
}
case '--key-test': {
const { value, consumed } = optionalNumber(i, 1.5);
opts.keyTest = value;
i += consumed;
break;
}
default:
throw new Error(`unknown option: ${arg}`);
}
}
return opts;
}
interface ScaleSummary {
pitchClasses: PitchClass[];
notes: string[];
keys: string[];
}
function describe(root: PitchClass, intervals: readonly number[]): ScaleSummary {
const pcs = pitchClassesFor(root, intervals);
return {
pitchClasses: pcs,
notes: pcs.map((pc) => noteName(pc)),
keys: keysFor(pcs),
};
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** One note key lit, everything else at background — used by --key-test. */
function singleKeyFrame(controller: ScaleLighting, note: NoteKey): Frame {
const frame = buildFrame({ pitchClasses: [], colors: controller.colors });
frame[note.frameKey] = controller.color('scale');
return frame;
}
export async function main(argv: readonly string[] = process.argv.slice(2)): Promise<void> {
let opts: CliOptions;
try {
opts = parseArgs(argv);
} catch (err) {
console.error(`error: ${errorMessage(err)}\n`);
console.error(USAGE);
process.exitCode = 2;
return;
}
if (opts.help) {
console.log(USAGE);
return;
}
if (opts.list) {
for (const [name, intervals] of Object.entries(SCALES)) {
console.log(`${name.padEnd(18)} ${intervals.join(', ')}`);
}
return;
}
// Validate colors up front so a typo fails before we touch the keyboard.
for (const role of Object.keys(COLOR_FLAGS) as PaletteKey[]) {
const value = opts.colors[role];
if (value === undefined) continue;
try {
parseColor(value);
} catch (err) {
console.error(`error: ${COLOR_FLAGS[role]}: ${errorMessage(err)}`);
process.exitCode = 2;
return;
}
}
const verbose = !opts.quiet;
const log = verbose
? (msg: string) => console.error(`[gamesense] ${msg}`)
: () => {};
const controller = new ScaleLighting({
colors: opts.colors,
address: opts.address,
dryRun: opts.dryRun,
log,
});
if (opts.off) {
try {
await controller.release();
console.log('lighting released back to SteelSeries GG');
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
process.exitCode = 1;
}
return;
}
let root: PitchClass;
let intervals: readonly number[];
try {
root = parseRoot(opts.root);
intervals = opts.intervals ? parseIntervals(opts.intervals) : parseScale(opts.scale);
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
process.exitCode = 2;
return;
}
const show = async (
r: PitchClass,
iv: readonly number[],
label: string
): Promise<void> => {
const info = describe(r, iv);
console.log(
`${noteName(r)} ${label} -> ${info.notes.join(' ')} | keys: ${info.keys
.join(' ')
.toUpperCase()}`
);
await controller.showScale(r, iv);
};
let shuttingDown = false;
const shutdown = async (code = 0): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
await controller.stop(true);
process.exit(code);
};
process.on('SIGINT', () => void shutdown(0));
process.on('SIGTERM', () => void shutdown(0));
try {
await controller.start();
if (opts.keyTest !== null) {
console.log('key test: each note key lights on its own, in order');
for (const note of NOTE_KEYS) {
console.log(
` ${note.key.toUpperCase()} -> ${noteName(note.pitchClass)}${
note.octaveUp ? ' (octave up)' : ''
}`
);
await controller.showFrame(singleKeyFrame(controller, note));
await sleep(opts.keyTest * 1000);
}
await shutdown(0);
return;
}
if (opts.demo !== null) {
console.log('demo: Ctrl+C to stop');
const names = Object.keys(SCALES) as ScaleName[];
for (let i = 0; ; i++) {
const scaleName = names[i % names.length];
const demoRoot = (i * 5) % 12; // walk the circle of fourths
await show(demoRoot, SCALES[scaleName], scaleName);
await sleep(opts.demo * 1000);
}
}
await show(root, intervals, opts.intervals ? `[${intervals.join(',')}]` : opts.scale);
if (opts.once) {
controller.client.stopHeartbeat();
if (verbose) {
console.error(
'[gamesense] --once: GameSense drops the effect after ~15s without events'
);
}
return;
}
// Accept "<root> <scale>" lines on stdin, from a terminal or a pipe.
const interactive = Boolean(process.stdin.isTTY);
if (interactive) {
console.log('\nType "<root> <scale>" to change (e.g. "F# dorian"), Ctrl+C to quit.');
} else if (verbose) {
console.error('[gamesense] holding lighting, Ctrl+C to quit');
}
const rl = readline.createInterface({ input: process.stdin });
rl.on('line', (line: string) => {
const text = line.trim();
if (!text || text.startsWith('#')) return;
if (text === 'quit' || text === 'exit') {
void shutdown(0);
return;
}
const [rootText, ...rest] = text.split(/\s+/);
const scaleText = rest.join('-') || 'major';
void (async () => {
try {
await show(parseRoot(rootText), parseScale(scaleText), scaleText);
} catch (err) {
console.error(` ! ${errorMessage(err)}`);
}
})();
});
// A closed terminal means "quit". A closed pipe just means there are no
// more commands coming — keep holding the lighting until signalled.
rl.on('close', () => {
if (interactive) void shutdown(0);
});
// The heartbeat interval keeps the process alive from here on.
await new Promise<never>(() => {});
} catch (err) {
console.error(`error: ${errorMessage(err)}`);
await controller.stop(true).catch(() => {});
process.exitCode = 1;
}
}
/** Entry point for `bin/apex7-scale.js`: run `main` and report crashes. */
export function run(argv?: readonly string[]): void {
main(argv).catch((err: unknown) => {
console.error(errorStack(err));
process.exit(1);
});
}

17
src/errors.ts Normal file
View File

@@ -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;
}

View File

@@ -1,8 +1,14 @@
'use strict'; import fs from 'fs';
import http from 'http';
import path from 'path';
const fs = require('fs'); import { errorCode, errorMessage } from './errors';
const http = require('http'); import type {
const path = require('path'); BindEventOptions,
EventData,
GameSenseResponse,
Handler,
} from './protocol';
/** /**
* Minimal, dependency-free GameSense (SteelSeries Engine 3) client. * Minimal, dependency-free GameSense (SteelSeries Engine 3) client.
@@ -12,7 +18,7 @@ const path = require('path');
* something we control. * something we control.
*/ */
const CORE_PROPS_PATHS = { const CORE_PROPS_PATHS: Record<string, () => string> = {
win32: () => win32: () =>
path.join( path.join(
process.env.PROGRAMDATA || 'C:\\ProgramData', process.env.PROGRAMDATA || 'C:\\ProgramData',
@@ -24,7 +30,7 @@ const CORE_PROPS_PATHS = {
'/Library/Application Support/SteelSeries Engine 3/coreProps.json', '/Library/Application Support/SteelSeries Engine 3/coreProps.json',
}; };
function corePropsPath() { export function corePropsPath(): string {
const resolve = CORE_PROPS_PATHS[process.platform]; const resolve = CORE_PROPS_PATHS[process.platform];
if (!resolve) { if (!resolve) {
throw new Error( throw new Error(
@@ -36,15 +42,15 @@ function corePropsPath() {
} }
/** Resolve the local REST server address, e.g. "127.0.0.1:52384". */ /** 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(); if (process.env.GAMESENSE_ADDRESS) return process.env.GAMESENSE_ADDRESS.trim();
const file = corePropsPath(); const file = corePropsPath();
let raw; let raw: string;
try { try {
raw = fs.readFileSync(file, 'utf8'); raw = fs.readFileSync(file, 'utf8');
} catch (err) { } catch (err) {
if (err.code === 'ENOENT') { if (errorCode(err) === 'ENOENT') {
throw new Error( throw new Error(
`coreProps.json not found at ${file}. Is SteelSeries GG running?` `coreProps.json not found at ${file}. Is SteelSeries GG running?`
); );
@@ -52,36 +58,58 @@ function readAddress() {
throw err; throw err;
} }
let parsed; let parsed: unknown;
try { try {
parsed = JSON.parse(raw); parsed = JSON.parse(raw);
} catch (err) { } 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`); 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()); const match = /^(?:https?:\/\/)?([^:/]+):(\d+)$/.exec(String(address).trim());
if (!match) throw new Error(`malformed GameSense address: ${address}`); if (!match) throw new Error(`malformed GameSense address: ${address}`);
return { host: match[1], port: Number(match[2]) }; return { host: match[1], port: Number(match[2]) };
} }
class GameSenseClient { export interface GameSenseClientOptions {
/** /** Uppercase A-Z 0-9 - _ only. */
* @param {object} options game: string;
* @param {string} options.game uppercase A-Z 0-9 - _ only gameDisplayName?: string;
* @param {string} [options.gameDisplayName] developer?: string;
* @param {string} [options.developer] /** Skip coreProps.json discovery. */
* @param {string} [options.address] skip coreProps.json discovery address?: string;
* @param {boolean} [options.dryRun] log payloads instead of sending /** Log payloads instead of sending them. */
* @param {(msg: string) => void} [options.log] dryRun?: boolean;
*/ log?: (msg: string) => void;
constructor(options) { }
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<typeof setInterval> | null = null;
constructor(options: GameSenseClientOptions) {
if (!options || !options.game) throw new Error('options.game is required'); if (!options || !options.game) throw new Error('options.game is required');
if (!/^[A-Z0-9_-]+$/.test(options.game)) { if (!/^[A-Z0-9_-]+$/.test(options.game)) {
throw new Error( throw new Error(
@@ -94,11 +122,10 @@ class GameSenseClient {
this.address = options.address || null; this.address = options.address || null;
this.dryRun = Boolean(options.dryRun); this.dryRun = Boolean(options.dryRun);
this.log = options.log || (() => {}); this.log = options.log || (() => {});
this.heartbeatTimer = null;
} }
/** Resolve the server address (idempotent). */ /** Resolve the server address (idempotent). */
connect() { connect(): string {
if (!this.address) this.address = this.dryRun ? '127.0.0.1:0' : readAddress(); if (!this.address) this.address = this.dryRun ? '127.0.0.1:0' : readAddress();
if (!this.announced) { if (!this.announced) {
this.announced = true; this.announced = true;
@@ -107,7 +134,7 @@ class GameSenseClient {
return this.address; return this.address;
} }
post(endpoint, body) { post(endpoint: string, body: unknown): Promise<GameSenseResponse> {
if (this.dryRun) { if (this.dryRun) {
this.log(`POST ${endpoint}\n${JSON.stringify(body, null, 2)}`); this.log(`POST ${endpoint}\n${JSON.stringify(body, null, 2)}`);
return Promise.resolve({ dryRun: true }); return Promise.resolve({ dryRun: true });
@@ -116,7 +143,7 @@ class GameSenseClient {
const { host, port } = splitAddress(this.connect()); const { host, port } = splitAddress(this.connect());
const payload = Buffer.from(JSON.stringify(body), 'utf8'); const payload = Buffer.from(JSON.stringify(body), 'utf8');
return new Promise((resolve, reject) => { return new Promise<GameSenseResponse>((resolve, reject) => {
const req = http.request( const req = http.request(
{ {
host, host,
@@ -129,28 +156,25 @@ class GameSenseClient {
}, },
}, },
(res) => { (res) => {
const chunks = []; const chunks: Buffer[] = [];
res.on('data', (c) => chunks.push(c)); res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => { res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8'); 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) : {}); resolve(text ? safeParse(text) : {});
} else { } else {
reject( reject(new Error(`${endpoint} -> HTTP ${status}: ${text.trim()}`));
new Error(`${endpoint} -> HTTP ${res.statusCode}: ${text.trim()}`)
);
} }
}); });
} }
); );
req.on('error', (err) => req.on('error', (err) => reject(new Error(`${endpoint} -> ${err.message}`)));
reject(new Error(`${endpoint} -> ${err.message}`))
);
req.end(payload); req.end(payload);
}); });
} }
registerGame() { registerGame(): Promise<GameSenseResponse> {
return this.post('/game_metadata', { return this.post('/game_metadata', {
game: this.game, game: this.game,
game_display_name: this.gameDisplayName, game_display_name: this.gameDisplayName,
@@ -158,29 +182,27 @@ class GameSenseClient {
}); });
} }
/** bindEvent(
* @param {string} event event: string,
* @param {object[]} handlers handlers: readonly Handler[],
* @param {object} [opts] min_value / max_value / icon_id / value_optional opts: BindEventOptions = {}
*/ ): Promise<GameSenseResponse> {
bindEvent(event, handlers, opts = {}) {
return this.post('/bind_game_event', { return this.post('/bind_game_event', {
game: this.game, game: this.game,
event, event,
min_value: opts.min_value === undefined ? 0 : opts.min_value, min_value: opts.min_value ?? 0,
max_value: opts.max_value === undefined ? 100 : opts.max_value, max_value: opts.max_value ?? 100,
icon_id: opts.icon_id === undefined ? 0 : opts.icon_id, icon_id: opts.icon_id ?? 0,
value_optional: Boolean(opts.value_optional), value_optional: Boolean(opts.value_optional),
handlers, handlers,
}); });
} }
/** `data` is the full event data object, e.g. { value, frame }. */ sendEvent(event: string, data: EventData): Promise<GameSenseResponse> {
sendEvent(event, data) {
return this.post('/game_event', { game: this.game, event, data }); return this.post('/game_event', { game: this.game, event, data });
} }
heartbeat() { heartbeat(): Promise<GameSenseResponse> {
return this.post('/game_heartbeat', { game: this.game }); 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 * 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. * 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.stopHeartbeat();
this.heartbeatTimer = setInterval(() => { this.heartbeatTimer = setInterval(() => {
this.heartbeat().catch((err) => { this.heartbeat().catch((err: unknown) => {
if (onError) onError(err); if (onError) onError(err);
else this.log(`heartbeat failed: ${err.message}`); else this.log(`heartbeat failed: ${errorMessage(err)}`);
}); });
}, intervalMs); }, intervalMs);
} }
stopHeartbeat() { stopHeartbeat(): void {
if (this.heartbeatTimer) { if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer); clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null; this.heartbeatTimer = null;
@@ -211,22 +230,19 @@ class GameSenseClient {
} }
/** Hand the keyboard back to SteelSeries GG. */ /** Hand the keyboard back to SteelSeries GG. */
removeGame() { removeGame(): Promise<GameSenseResponse> {
return this.post('/remove_game', { game: this.game }); return this.post('/remove_game', { game: this.game });
} }
} }
function safeParse(text) { function safeParse(text: string): GameSenseResponse {
try { try {
return JSON.parse(text); const parsed: unknown = JSON.parse(text);
} catch (_) { if (typeof parsed === 'object' && parsed !== null) {
return parsed as GameSenseResponse;
}
return { value: parsed };
} catch {
return { raw: text }; return { raw: text };
} }
} }
module.exports = {
GameSenseClient,
readAddress,
corePropsPath,
splitAddress,
};

View File

@@ -1,5 +1,3 @@
'use strict';
/** /**
* USB HID Keyboard/Keypad usage codes (usage page 0x07). * 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". * is the single source of truth for "which physical key do I light".
*/ */
const HID = { export const HID = {
// Letters // Letters
a: 0x04, b: 0x05, c: 0x06, d: 0x07, e: 0x08, f: 0x09, g: 0x0a, h: 0x0b, 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, i: 0x0c, j: 0x0d, k: 0x0e, l: 0x0f, m: 0x10, n: 0x11, o: 0x12, p: 0x13,
@@ -78,7 +76,13 @@ const HID = {
shiftRight: 0xe5, shiftRight: 0xe5,
altRight: 0xe6, altRight: 0xe6,
metaRight: 0xe7, metaRight: 0xe7,
}; } as const satisfies Record<string, number>;
/** 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 * 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 * keyboard switches into GameSense mode and every key we do NOT address goes
* dark. Painting the full set keeps the board usable. * dark. Painting the full set keeps the board usable.
*/ */
const ALL_KEYS = Object.values(HID); export const ALL_KEYS: readonly number[] = Object.values(HID);
module.exports = { HID, ALL_KEYS };

View File

@@ -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,
};

166
src/index.ts Normal file
View File

@@ -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<Palette>;
/** 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<void> {
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<Frame> {
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<PitchClass>,
root: PitchClass | null = null
): Promise<Frame> {
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<Frame> {
return this.showPitchClasses(scale.pitchClassesFor(root, intervals), root);
}
/** Re-send the last frame (useful after GG restarts). */
async refresh(): Promise<Frame | null> {
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<void> {
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<void> {
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';

View File

@@ -1,7 +1,7 @@
'use strict'; import { ALL_KEYS } from './hid';
import type { Frame, Handler, Rgb } from './protocol';
const { ALL_KEYS } = require('./hid'); import { NOTE_KEYS, NOTE_KEY_HIDS, pitchClassesFor } from './scale';
const { NOTE_KEYS, NOTE_KEY_HIDS, pitchClassesFor } = require('./scale'); import type { PitchClass } from './scale';
const DEVICE_TYPE = 'rgb-per-key-zones'; const DEVICE_TYPE = 'rgb-per-key-zones';
const BACKGROUND_FRAME_KEY = 'background'; const BACKGROUND_FRAME_KEY = 'background';
@@ -24,9 +24,21 @@ const BACKGROUND_FRAME_KEY = 'background';
* handles the "GameSense mode blacks out unaddressed keys" caveat. * 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<PaletteKey, ColorInput>;
export const DEFAULT_COLORS: Record<PaletteKey, string> = {
background: '#0a0a0f', // near-off, keeps the rest of the board readable background: '#0a0a0f', // near-off, keeps the rest of the board readable
scale: '#00b4ff', // notes in the scale scale: '#00b4ff', // notes in the scale
root: '#ff5a00', // the root, so you can find "home" at a glance 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}. */ /** "#rrggbb" / "rrggbb" / "#rgb" / {red,green,blue} -> {red,green,blue}. */
function parseColor(input) { export function parseColor(input: ColorInput): Rgb {
if (input && typeof input === 'object') { if (input && typeof input === 'object') {
const { red, green, blue } = input; const { red, green, blue } = input;
return { red: clampByte(red), green: clampByte(green), blue: clampByte(blue) }; 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)); const v = Math.round(Number(n));
if (!Number.isFinite(v)) throw new Error(`invalid color component: ${n}`); if (!Number.isFinite(v)) throw new Error(`invalid color component: ${n}`);
return Math.min(255, Math.max(0, v)); return Math.min(255, Math.max(0, v));
} }
/** Scale an RGB triple by a 0..1 factor — used for the dim background. */ /** 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 { return {
red: clampByte(color.red * factor), red: clampByte(color.red * factor),
green: clampByte(color.green * 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. */ /** The handler list to POST to /bind_game_event. Bound once, at startup. */
function buildHandlers() { export function buildHandlers(): Handler[] {
const handlers = [ const handlers: Handler[] = [
{ {
'device-type': DEVICE_TYPE, 'device-type': DEVICE_TYPE,
mode: 'context-color', mode: 'context-color',
@@ -97,25 +109,30 @@ function buildHandlers() {
return handlers; return handlers;
} }
/** export interface BuildFrameOptions {
* Build the `frame` for a set of lit pitch classes. /** The lit pitch classes (0-11). */
* pitchClasses: Iterable<PitchClass>;
* @param {object} options /** Highlighted differently from the rest of the scale. */
* @param {number[]|Set<number>} options.pitchClasses lit pitch classes (0-11) root?: PitchClass | null;
* @param {number|null} [options.root] highlighted differently /** Overrides for `DEFAULT_COLORS`. */
* @param {object} [options.colors] overrides DEFAULT_COLORS colors?: Partial<Palette>;
* @returns {object} frame keyed by context-frame-key }
*/
function buildFrame({ pitchClasses, root = null, colors = {} }) { /** 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 = { const palette = {
background: parseColor(colors.background || DEFAULT_COLORS.background), background: parseColor(colors.background ?? DEFAULT_COLORS.background),
scale: parseColor(colors.scale || DEFAULT_COLORS.scale), scale: parseColor(colors.scale ?? DEFAULT_COLORS.scale),
root: parseColor(colors.root || DEFAULT_COLORS.root), root: parseColor(colors.root ?? DEFAULT_COLORS.root),
off: parseColor(colors.off || DEFAULT_COLORS.off), off: parseColor(colors.off ?? DEFAULT_COLORS.off),
}; };
const lit = pitchClasses instanceof Set ? pitchClasses : new Set(pitchClasses); 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) { for (const note of NOTE_KEYS) {
if (!lit.has(note.pitchClass)) { if (!lit.has(note.pitchClass)) {
@@ -131,31 +148,22 @@ function buildFrame({ pitchClasses, root = null, colors = {} }) {
} }
/** Everything black — used to hand the board back cleanly. */ /** Everything black — used to hand the board back cleanly. */
function buildBlackFrame() { export function buildBlackFrame(): Frame {
const black = { red: 0, green: 0, blue: 0 }; const black: Rgb = { red: 0, green: 0, blue: 0 };
const frame = { [BACKGROUND_FRAME_KEY]: black }; const frame: Frame = { [BACKGROUND_FRAME_KEY]: black };
for (const note of NOTE_KEYS) frame[note.frameKey] = black; for (const note of NOTE_KEYS) frame[note.frameKey] = black;
return frame; return frame;
} }
/** Convenience: root + intervals -> frame, in one call. */ /** Convenience: root + intervals -> frame, in one call. */
function frameForScale(root, intervals, colors) { export function frameForScale(
root: PitchClass,
intervals: readonly number[],
colors?: Partial<Palette>
): Frame {
return buildFrame({ return buildFrame({
pitchClasses: pitchClassesFor(root, intervals), pitchClasses: pitchClassesFor(root, intervals),
root, root,
colors, colors,
}); });
} }
module.exports = {
DEVICE_TYPE,
BACKGROUND_FRAME_KEY,
BACKGROUND_KEYS,
DEFAULT_COLORS,
parseColor,
dim,
buildHandlers,
buildFrame,
buildBlackFrame,
frameForScale,
};

55
src/protocol.ts Normal file
View File

@@ -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<FrameKey, Rgb>;
/** 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<string, unknown>;

View File

@@ -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<string, PitchClass> = {
C: 0, 'B#': 0, C: 0, 'B#': 0,
'C#': 1, DB: 1, 'C#': 1, DB: 1,
D: 2, D: 2,
@@ -20,7 +22,7 @@ const NOTE_ALIASES = {
}; };
/** Interval sets, matching Live 12's `scale_intervals`. */ /** Interval sets, matching Live 12's `scale_intervals`. */
const SCALES = { export const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11], major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10], minor: [0, 2, 3, 5, 7, 8, 10],
'harmonic-minor': [0, 2, 3, 5, 7, 8, 11], 'harmonic-minor': [0, 2, 3, 5, 7, 8, 11],
@@ -35,13 +37,28 @@ const SCALES = {
blues: [0, 3, 5, 6, 7, 10], blues: [0, 3, 5, 6, 7, 10],
'whole-tone': [0, 2, 4, 6, 8, 10], 'whole-tone': [0, 2, 4, 6, 8, 10],
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
}; } as const satisfies Record<string, readonly number[]>;
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: * 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. * 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: 'a', hid: HID.a, pitchClass: 0, frameKey: 'note-a' },
{ key: 'w', hid: HID.w, pitchClass: 1, frameKey: 'note-w' }, { key: 'w', hid: HID.w, pitchClass: 1, frameKey: 'note-w' },
{ key: 's', hid: HID.s, pitchClass: 2, frameKey: 'note-s' }, { 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 }, { 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. */ /** "C", "f#", "Bb", "3" -> 0..11. Throws on garbage. */
function parseRoot(input) { export function parseRoot(input: string | number): PitchClass {
if (typeof input === 'number') { if (typeof input === 'number') {
if (!Number.isInteger(input) || input < 0 || input > 11) { if (!Number.isInteger(input) || input < 0 || input > 11) {
throw new Error(`root must be an integer 0-11, got ${input}`); throw new Error(`root must be an integer 0-11, got ${input}`);
} }
return input; return input;
} }
const raw = String(input).trim();
const raw = input.trim();
if (/^\d+$/.test(raw)) return parseRoot(Number(raw)); if (/^\d+$/.test(raw)) return parseRoot(Number(raw));
const normalized = raw.toUpperCase().replace(/♯/g, '#').replace(/♭/g, 'B'); 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}`); throw new Error(`unknown root note: ${input}`);
} }
/** "major", "Harmonic Minor", "minor_pentatonic" -> interval array. */ /** "major", "Harmonic Minor", "minor_pentatonic" -> interval array. */
function parseScale(input) { export function parseScale(input: string): readonly number[] {
const normalized = String(input).trim().toLowerCase().replace(/[\s_]+/g, '-'); const normalized = input.trim().toLowerCase().replace(/[\s_]+/g, '-');
if (normalized in SCALES) return SCALES[normalized]; if (isScaleName(normalized)) return SCALES[normalized];
throw new Error( throw new Error(
`unknown scale: ${input} (known: ${Object.keys(SCALES).join(', ')})` `unknown scale: ${input} (known: ${Object.keys(SCALES).join(', ')})`
); );
} }
/** "0,2,4,5,7,9,11" or [0,2,...] -> normalized, deduped, sorted interval array. */ export function isScaleName(name: string): name is ScaleName {
function parseIntervals(input) { return Object.prototype.hasOwnProperty.call(SCALES, name);
const list = Array.isArray(input) ? input : String(input).split(/[,\s]+/); }
/** "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 const parsed = list
.filter((v) => String(v).length > 0) .filter((v) => String(v).length > 0)
.map((v) => { .map((v) => {
@@ -104,31 +128,20 @@ function parseIntervals(input) {
* lit = { (root + interval) % 12 } * lit = { (root + interval) % 12 }
* Returns a sorted array of pitch classes. * 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)); const set = new Set(intervals.map((i) => (((root + i) % 12) + 12) % 12));
return [...set].sort((a, b) => a - b); return [...set].sort((a, b) => a - b);
} }
/** Pitch classes -> the QWERTY keys Ableton maps them to (includes 'k'). */ /** Pitch classes -> the QWERTY keys Ableton maps them to (includes 'k'). */
function keysFor(pitchClasses) { export function keysFor(pitchClasses: Iterable<PitchClass>): string[] {
const set = new Set(pitchClasses); const set = new Set(pitchClasses);
return NOTE_KEYS.filter((k) => set.has(k.pitchClass)).map((k) => k.key); 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)]; 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,
};

View File

@@ -1,37 +1,35 @@
'use strict'; import assert from 'assert';
const assert = require('assert'); import { splitAddress } from '../src/gamesense';
import { ALL_KEYS, HID } from '../src/hid';
const { import {
parseRoot, BACKGROUND_FRAME_KEY,
parseScale, BACKGROUND_KEYS,
parseIntervals, buildBlackFrame,
pitchClassesFor, buildFrame,
keysFor, buildHandlers,
noteName, parseColor,
} from '../src/lighting';
import {
NOTE_KEYS, NOTE_KEYS,
NOTE_KEY_HIDS, NOTE_KEY_HIDS,
SCALES, SCALES,
} = require('../src/scale'); keysFor,
const { noteName,
parseColor, parseIntervals,
buildHandlers, parseRoot,
buildFrame, parseScale,
buildBlackFrame, pitchClassesFor,
BACKGROUND_KEYS, } from '../src/scale';
BACKGROUND_FRAME_KEY,
} = require('../src/lighting');
const { splitAddress } = require('../src/gamesense');
const { HID, ALL_KEYS } = require('../src/hid');
let passed = 0; let passed = 0;
function test(name, fn) { function test(name: string, fn: () => void): void {
try { try {
fn(); fn();
passed++; passed++;
console.log(` ok ${name}`); console.log(` ok ${name}`);
} catch (err) { } catch (err) {
console.error(` FAIL ${name}\n ${err.message}`); console.error(` FAIL ${name}\n ${err instanceof Error ? err.message : err}`);
process.exitCode = 1; process.exitCode = 1;
} }
} }
@@ -68,7 +66,7 @@ test('raw interval lists parse', () => {
test('C major lights the seven white keys', () => { test('C major lights the seven white keys', () => {
const pcs = pitchClassesFor(0, SCALES.major); const pcs = pitchClassesFor(0, SCALES.major);
assert.deepStrictEqual(pcs, [0, 2, 4, 5, 7, 9, 11]); 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. // Home row plus the octave key.
assert.deepStrictEqual(keysFor(pcs), ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']); 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', () => { test('F# major wraps around the octave correctly', () => {
const pcs = pitchClassesFor(6, SCALES.major); const pcs = pitchClassesFor(6, SCALES.major);
assert.deepStrictEqual(pcs, [1, 3, 6, 8, 10, 11, 5].sort((a, b) => a - b)); 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', () => { 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', () => { test('HID codes match the USB HID keyboard page', () => {
const expected = { const expected: Record<string, number> = {
a: 0x04, w: 0x1a, s: 0x16, e: 0x08, d: 0x07, f: 0x09, t: 0x17, 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, g: 0x0a, y: 0x1c, h: 0x0b, u: 0x18, j: 0x0d, k: 0x0e,
}; };

View File

@@ -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<event, handlers> }
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`);
});

226
tools/fake-gamesense.ts Normal file
View File

@@ -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<string, readonly Handler[]>;
}
const state = {
games: new Map<string, GameEntry>(),
lastColors: new Map<number, Rgb>(),
};
/**
* 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<number, Rgb> {
const colors = new Map<number, Rgb>();
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<GameSenseRequest> {
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`);
});

30
tsconfig.json Normal file
View File

@@ -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"]
}