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:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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.*
|
||||||
|
|||||||
44
README.md
44
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
|
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`.
|
||||||
|
|||||||
@@ -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
415
package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
package.json
24
package.json
@@ -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
358
src/cli.ts
Normal 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
17
src/errors.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
117
src/index.js
117
src/index.js
@@ -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
166
src/index.ts
Normal 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';
|
||||||
@@ -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
55
src/protocol.ts
Normal 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>;
|
||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
@@ -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
226
tools/fake-gamesense.ts
Normal 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
30
tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user