Add standalone GameSense scale lighting for the Apex 7

Build step 1 of the Ableton scale-lighting brief: a dependency-free Node
script that drives the keyboard directly, with the scale passed on the
command line. Live and Max for Live come later.

Lighting uses one event with 14 context-color handlers — a background zone
covering every key except the 13 note keys, plus one handler per note key
addressed by USB HID code. Colors travel in the event frame, so handlers are
bound once and a scale change is a single POST, which is what the M4L step
will need.

Chose custom zones over bitmap mode: bitmap's 22x6 grid has no documented
index-to-key table per model, while HID codes are exact. Painting the whole
board with the background zone covers the same blackout caveat bitmap was
suggested for.

tools/fake-gamesense.js stands in for the GameSense server and renders the
resolved frame as ANSI colour, so the mapping is verifiable without hardware
or Windows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-08-12 13:10:35 +00:00
commit 425eb86327
13 changed files with 1823 additions and 0 deletions

View File

@@ -0,0 +1,22 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/alpine
{
"name": "Alpine",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/base:alpine3.23"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "uname -a",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
npm-debug.log*
# Claude Code local (per-machine) settings
.claude/settings.local.json
.claude/settings.local.json.tmp.*
.DS_Store

139
README.md Normal file
View File

@@ -0,0 +1,139 @@
# steelseries-live-scale
Light the notes of a musical scale on a **SteelSeries Apex 7** using the
GameSense SDK, mapped to Ableton Live's computer MIDI keyboard layout.
This is **build step 1** of [`apex7-ableton-scale-lighting.md`](./apex7-ableton-scale-lighting.md):
a standalone script that talks to GameSense directly. Live/Max for Live is not
involved yet — you pass the scale on the command line.
No dependencies. Node 14+.
## Quick start (Windows, with SteelSeries GG running)
```powershell
node bin\apex7-scale.js --root C --scale major
```
The board dims, the C-major keys light up (`A S D F G H J K`), the root is
orange. The process stays resident and heartbeats so the lighting sticks; you
can type new scales at the prompt:
```
F# dorian
Bb minor-pentatonic
quit
```
Ctrl+C blanks the board and hands lighting back to SteelSeries GG.
### Verify the key mapping on hardware
```powershell
node bin\apex7-scale.js --key-test
```
Lights one note key at a time, in order, printing the note it should be. Walk it
against the table in the brief — `A`=C, `W`=C#, … `J`=B, `K`=C (octave up).
### Other modes
```powershell
node bin\apex7-scale.js --demo 2 # cycle scales, 2s each
node bin\apex7-scale.js --intervals 0,3,5,6,7,10 --root A # raw intervals (blues)
node bin\apex7-scale.js --once # one frame, then exit
node bin\apex7-scale.js --off # blank + deregister
node bin\apex7-scale.js --list # known scale names
node bin\apex7-scale.js --help
```
Colors: `--bg`, `--color`, `--root-color`, `--off-color`, all `#rrggbb`.
```powershell
node bin\apex7-scale.js --root D --scale minor --bg "#020208" --color "#ff00d0" --root-color "#ffffff"
```
## How the lighting works
One GameSense event (`SCALE`) with **14 handlers**, all in `context-color` mode:
| Handler | Zone | Frame key |
|---|---|---|
| background | every key *except* the 13 note keys (93 keys) | `background` |
| 13 × note key | one key each, by HID code | `note-a``note-k` |
Because every handler pulls its color from the event's `frame`, the handlers are
bound **once** at startup and a scale change is a single POST. That is what
step 3 needs when the scale changes mid-set.
Two design notes:
- **Zones, not `bitmap`.** The brief suggested bitmap mode. Bitmap's 22×6 grid
has no documented index→key table per keyboard model, whereas HID usage codes
are exact. Painting *all* keys with the background zone solves the same
problem bitmap was suggested for: the moment a GameSense event arrives the
board enters GameSense mode and any key you don't address goes black.
- **The octave key `K`** follows pitch class 0 (same as `A`) but is only tinted
as the root when C actually is the root.
## Testing without hardware
`tools/fake-gamesense.js` stands in for the GameSense server: it accepts the
real endpoints, resolves the bound handlers against each frame, and renders the
resulting keyboard as ANSI color in the terminal.
```bash
node tools/fake-gamesense.js --port 51000 # terminal 1
node bin/apex7-scale.js --address 127.0.0.1:51000 --root F# --scale dorian # terminal 2
```
`--address` (or `GAMESENSE_ADDRESS`) skips `coreProps.json` discovery entirely,
so this works on Linux too. `--dry-run` prints the payloads without sending.
Unit tests for the scale math, key mapping, handler shape and frame colors:
```bash
npm test
```
## Layout
```
bin/apex7-scale.js CLI
src/hid.js USB HID usage codes; the full-board key list
src/scale.js scale presets, root/interval parsing, pitch class -> QWERTY key
src/gamesense.js GameSense REST client (coreProps discovery, heartbeat, cleanup)
src/lighting.js handler + frame construction
src/index.js ScaleLighting — the API step 3 will call from Node for Max
tools/fake-gamesense.js terminal simulator of the GameSense server
test/logic.test.js unit tests
```
## Reusing this from Max for Live (step 3)
`src/index.js` is the seam — no CLI concerns in it:
```js
const { ScaleLighting } = require('./src');
const lights = new ScaleLighting();
await lights.start(); // register + bind + heartbeat
await lights.showScale(0, [0,2,4,5,7,9,11]); // root_note + scale_intervals from the LOM
// ...
await lights.stop();
```
`showScale(root, intervals)` takes exactly what Live 12's `Song` object exposes
as `root_note` and `scale_intervals`.
## Troubleshooting
- **`coreProps.json not found`** — SteelSeries GG isn't running, or is installed
somewhere unusual. Expected at
`%PROGRAMDATA%\SteelSeries\SteelSeries Engine 3\coreProps.json`.
- **Nothing lights up** — check GG's Engine is enabled and that no other app
holds an exclusive lighting profile.
- **Lighting reverts after ~15s** — that's GameSense's deactivation timeout;
it only happens with `--once`, since resident mode heartbeats every 5s.
- **A key stays dark** — it may not be in `src/hid.js`. Add its HID code there;
keys not addressed by any handler go black in GameSense mode.

View File

@@ -0,0 +1,154 @@
# SteelSeries Apex 7 × Ableton Live — Scale-Aware Key Lighting
> Handoff brief for a Claude Code agent. Translated and structured from a
> planning conversation. Goal: build the tooling to highlight the notes of the
> currently selected Ableton scale on the physical keyboard's RGB backlight.
## Project goal
Use the per-key RGB backlight of a **SteelSeries Apex 7** to highlight, in real
time, the keys that correspond to the notes of the scale currently selected in
**Ableton Live 12**. The physical keyboard is fitted with transparent keycaps so
the light reads clearly as note highlighting.
The full desired flow:
```
Live 12 (Live API: root_note + scale_intervals)
→ Max for Live device (computes the pitch classes in the scale)
→ Node for Max (POST JSON over HTTP)
→ GameSense server (127.0.0.1)
→ Apex 7 per-key RGB
```
## Hardware notes
- **Keyboard:** SteelSeries Apex 7 — per-key RGB, MX-compatible (cross-stem)
switches, so standard MX keycaps fit.
- **Keycaps:** transparent / "pudding" MX keycaps for light bleed. (Already
swapped; not part of the software build.)
- Requires **SteelSeries GG** running in the background for GameSense to work.
## Key ↔ note mapping (Ableton computer MIDI keyboard)
When Ableton's computer MIDI keyboard is enabled, one octave maps like a piano.
White keys on the bottom row, black keys on the row above:
| Note | Key | Note | Key |
|------|-----|------|-----|
| C | A | F# | T |
| C# | W | G | G |
| D | S | G# | Y |
| D# | E | A | H |
| E | D | A# | U |
| F | F | B | J |
| | | C | K |
`Z` / `X` = octave down/up, `C` / `V` = velocity down/up.
Pitch-class → key lookup (index 011):
```
0 C -> A
1 C# -> W
2 D -> S
3 D# -> E
4 E -> D
5 F -> F
6 F# -> T
7 G -> G
8 G# -> Y
9 A -> H
10 A# -> U
11 B -> J
(12 C -> K, the octave key)
```
## Reading the scale from Live 12
Live 12 exposes the global/selected scale on the `Song` object in the Live
Object Model (LOM), accessible from Max for Live:
- `root_note` — root, integer `011` (0 = C … 11 = B)
- `scale_intervals` — list of ints, e.g. major = `[0, 2, 4, 5, 7, 9, 11]`
- `scale_name` — display name string
- `scale_mode` — whether scale mode is on
Compute the lit pitch classes:
```
lit = { (root_note + interval) % 12 for interval in scale_intervals }
```
Then map each pitch class to its QWERTY key via the table above.
**Caveat:** in Live 12 the scale is set **per clip**. Decide whether to observe
the selected clip's scale or fix a global scale (existing "Scale Awareness" M4L
packs can help enforce a global scale). Observe the relevant LOM property so the
lighting updates when the scale changes.
## Controlling the Apex 7 (SteelSeries GameSense SDK)
GameSense runs a **local REST server**. Flow:
1. Read the server port from `coreProps.json`:
- Windows: `%PROGRAMDATA%\SteelSeries\SteelSeries Engine 3\coreProps.json`
- macOS: `/Library/Application Support/SteelSeries Engine 3/coreProps.json`
- The file gives an address like `127.0.0.1:<port>`.
2. Register a game + event + handler, then POST event data to
`http://127.0.0.1:<port>/game_event` (register via `/game_metadata` and
`/bind_game_event`).
3. Per-key control options:
- **`custom-zone-keys`** — address specific keys by **USB HID usage code**,
with `context-color` to set their color at runtime.
- **`bitmap` mode** — paint the whole keyboard at once (a 132-length array of
`[R,G,B]`, interpreted as a 22×6 grid mapped to nearest keys). Best for
"dim background + bright scale keys" because it sets every key each update.
**HID usage codes** for the relevant letter keys (USB HID keyboard page):
```
A 0x04 S 0x16 D 0x07 F 0x09 G 0x0A H 0x0B J 0x0D K 0x0E
W 0x1A E 0x08 T 0x17 Y 0x1C U 0x18
```
**Caveat:** as soon as a GameSense event arrives, the keyboard enters
"GameSense mode" and other keys go dark unless you set them. Set a base color
for all keys (bitmap mode handles this cleanly), then override the scale keys.
## The bridge: Max for Live → GameSense
Cleanest option is **Node for Max** running inside the M4L device: it can read
`coreProps.json` and issue the HTTP POSTs directly — no separate app needed.
Alternative: Max sends OSC/UDP to a small external Python/Node script that talks
to GameSense.
## Suggested build order
1. Standalone script (Node or Python) that talks to GameSense: register a game,
light a hardcoded set of keys by HID code, set a dim background. Verify on
hardware.
2. M4L device that reads `root_note` / `scale_intervals` from the LOM and prints
the computed key set. Verify against the mapping table.
3. Wire the two together (Node for Max inside the device), observe the scale
property so lighting updates live.
4. Polish: base color, highlight color, octave key, handle scale-mode off.
## Open decisions for the user
- Platform: **Windows or macOS** (changes the `coreProps.json` path).
- Lighting style: **bitmap** (background + highlight) vs. just lighting the
scale keys.
- Scale source: selected clip vs. enforced global scale.
## References
- SteelSeries GameSense SDK (JSON handlers, per-key / bitmap lighting):
https://github.com/SteelSeries/gamesense-sdk
- Live Object Model — `Song` (scale properties):
https://docs.cycling74.com/apiref/lom/song/
- Controlling Live using Max for Live:
https://help.ableton.com/hc/en-us/articles/5402681764242-Controlling-Live-using-Max-for-Live
- Keys and Scales in Live 12 FAQ:
https://help.ableton.com/hc/en-us/articles/11425083250972-Keys-and-Scales-in-Live-12-FAQ

322
bin/apex7-scale.js Normal file
View File

@@ -0,0 +1,322 @@
#!/usr/bin/env node
'use strict';
const readline = require('readline');
const { ScaleLighting } = require('../src');
const {
SCALES,
NOTE_KEYS,
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
} = require('../src/scale');
const { parseColor, buildFrame, DEFAULT_COLORS } = require('../src/lighting');
const USAGE = `
apex7-scale — light the notes of a scale on a SteelSeries Apex 7 (GameSense)
Usage:
apex7-scale [options]
Scale selection:
--root <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);
});

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "steelseries-live-scale",
"version": "0.1.0",
"description": "Highlight the notes of the current Ableton Live scale on a SteelSeries Apex 7 via GameSense",
"main": "src/index.js",
"bin": {
"apex7-scale": "bin/apex7-scale.js"
},
"scripts": {
"start": "node bin/apex7-scale.js",
"stub": "node tools/fake-gamesense.js",
"test": "node test/logic.test.js"
},
"engines": {
"node": ">=14"
},
"license": "MIT",
"private": true
}

232
src/gamesense.js Normal file
View File

@@ -0,0 +1,232 @@
'use strict';
const fs = require('fs');
const http = require('http');
const path = require('path');
/**
* Minimal, dependency-free GameSense (SteelSeries Engine 3) client.
*
* Uses `http` rather than `fetch` on purpose: this module is meant to be
* dropped into Node for Max later, where the bundled Node version is not
* something we control.
*/
const CORE_PROPS_PATHS = {
win32: () =>
path.join(
process.env.PROGRAMDATA || 'C:\\ProgramData',
'SteelSeries',
'SteelSeries Engine 3',
'coreProps.json'
),
darwin: () =>
'/Library/Application Support/SteelSeries Engine 3/coreProps.json',
};
function corePropsPath() {
const resolve = CORE_PROPS_PATHS[process.platform];
if (!resolve) {
throw new Error(
`GameSense is only available on Windows and macOS (this is ${process.platform}). ` +
'Pass --address host:port or set GAMESENSE_ADDRESS to test against a stub.'
);
}
return resolve();
}
/** Resolve the local REST server address, e.g. "127.0.0.1:52384". */
function readAddress() {
if (process.env.GAMESENSE_ADDRESS) return process.env.GAMESENSE_ADDRESS.trim();
const file = corePropsPath();
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error(
`coreProps.json not found at ${file}. Is SteelSeries GG running?`
);
}
throw err;
}
let parsed;
try {
parsed = JSON.parse(raw);
} catch (err) {
throw new Error(`coreProps.json at ${file} is not valid JSON: ${err.message}`);
}
if (!parsed.address) {
throw new Error(`coreProps.json at ${file} has no "address" key`);
}
return parsed.address;
}
function splitAddress(address) {
const match = /^(?:https?:\/\/)?([^:/]+):(\d+)$/.exec(String(address).trim());
if (!match) throw new Error(`malformed GameSense address: ${address}`);
return { host: match[1], port: Number(match[2]) };
}
class GameSenseClient {
/**
* @param {object} options
* @param {string} options.game uppercase A-Z 0-9 - _ only
* @param {string} [options.gameDisplayName]
* @param {string} [options.developer]
* @param {string} [options.address] skip coreProps.json discovery
* @param {boolean} [options.dryRun] log payloads instead of sending
* @param {(msg: string) => void} [options.log]
*/
constructor(options) {
if (!options || !options.game) throw new Error('options.game is required');
if (!/^[A-Z0-9_-]+$/.test(options.game)) {
throw new Error(
`invalid game name "${options.game}": use A-Z, 0-9, hyphen and underscore only`
);
}
this.game = options.game;
this.gameDisplayName = options.gameDisplayName || options.game;
this.developer = options.developer || '';
this.address = options.address || null;
this.dryRun = Boolean(options.dryRun);
this.log = options.log || (() => {});
this.heartbeatTimer = null;
}
/** Resolve the server address (idempotent). */
connect() {
if (!this.address) this.address = this.dryRun ? '127.0.0.1:0' : readAddress();
if (!this.announced) {
this.announced = true;
this.log(`GameSense address: ${this.address}`);
}
return this.address;
}
post(endpoint, body) {
if (this.dryRun) {
this.log(`POST ${endpoint}\n${JSON.stringify(body, null, 2)}`);
return Promise.resolve({ dryRun: true });
}
const { host, port } = splitAddress(this.connect());
const payload = Buffer.from(JSON.stringify(body), 'utf8');
return new Promise((resolve, reject) => {
const req = http.request(
{
host,
port,
path: endpoint,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': payload.length,
},
},
(res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(text ? safeParse(text) : {});
} else {
reject(
new Error(`${endpoint} -> HTTP ${res.statusCode}: ${text.trim()}`)
);
}
});
}
);
req.on('error', (err) =>
reject(new Error(`${endpoint} -> ${err.message}`))
);
req.end(payload);
});
}
registerGame() {
return this.post('/game_metadata', {
game: this.game,
game_display_name: this.gameDisplayName,
developer: this.developer,
});
}
/**
* @param {string} event
* @param {object[]} handlers
* @param {object} [opts] min_value / max_value / icon_id / value_optional
*/
bindEvent(event, handlers, opts = {}) {
return this.post('/bind_game_event', {
game: this.game,
event,
min_value: opts.min_value === undefined ? 0 : opts.min_value,
max_value: opts.max_value === undefined ? 100 : opts.max_value,
icon_id: opts.icon_id === undefined ? 0 : opts.icon_id,
value_optional: Boolean(opts.value_optional),
handlers,
});
}
/** `data` is the full event data object, e.g. { value, frame }. */
sendEvent(event, data) {
return this.post('/game_event', { game: this.game, event, data });
}
heartbeat() {
return this.post('/game_heartbeat', { game: this.game });
}
/**
* GameSense deactivates a game after ~15s without events. Keep it alive.
*
* The interval is deliberately *not* unref'd: while lighting is active this
* timer is what holds the process open, which is what a resident CLI wants.
*
* @param {number} [intervalMs]
* @param {(err: Error) => void} [onError]
*/
startHeartbeat(intervalMs = 5000, onError) {
this.stopHeartbeat();
this.heartbeatTimer = setInterval(() => {
this.heartbeat().catch((err) => {
if (onError) onError(err);
else this.log(`heartbeat failed: ${err.message}`);
});
}, intervalMs);
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/** Hand the keyboard back to SteelSeries GG. */
removeGame() {
return this.post('/remove_game', { game: this.game });
}
}
function safeParse(text) {
try {
return JSON.parse(text);
} catch (_) {
return { raw: text };
}
}
module.exports = {
GameSenseClient,
readAddress,
corePropsPath,
splitAddress,
};

93
src/hid.js Normal file
View File

@@ -0,0 +1,93 @@
'use strict';
/**
* USB HID Keyboard/Keypad usage codes (usage page 0x07).
*
* GameSense's `custom-zone-keys` addresses keys by these codes, so this table
* is the single source of truth for "which physical key do I light".
*/
const HID = {
// Letters
a: 0x04, b: 0x05, c: 0x06, d: 0x07, e: 0x08, f: 0x09, g: 0x0a, h: 0x0b,
i: 0x0c, j: 0x0d, k: 0x0e, l: 0x0f, m: 0x10, n: 0x11, o: 0x12, p: 0x13,
q: 0x14, r: 0x15, s: 0x16, t: 0x17, u: 0x18, v: 0x19, w: 0x1a, x: 0x1b,
y: 0x1c, z: 0x1d,
// Number row
1: 0x1e, 2: 0x1f, 3: 0x20, 4: 0x21, 5: 0x22,
6: 0x23, 7: 0x24, 8: 0x25, 9: 0x26, 0: 0x27,
enter: 0x28,
escape: 0x29,
backspace: 0x2a,
tab: 0x2b,
space: 0x2c,
minus: 0x2d,
equal: 0x2e,
bracketLeft: 0x2f,
bracketRight: 0x30,
backslash: 0x31,
nonUsHash: 0x32,
semicolon: 0x33,
quote: 0x34,
backquote: 0x35,
comma: 0x36,
period: 0x37,
slash: 0x38,
capsLock: 0x39,
// Function row
f1: 0x3a, f2: 0x3b, f3: 0x3c, f4: 0x3d, f5: 0x3e, f6: 0x3f,
f7: 0x40, f8: 0x41, f9: 0x42, f10: 0x43, f11: 0x44, f12: 0x45,
printScreen: 0x46,
scrollLock: 0x47,
pause: 0x48,
insert: 0x49,
home: 0x4a,
pageUp: 0x4b,
delete: 0x4c,
end: 0x4d,
pageDown: 0x4e,
arrowRight: 0x4f,
arrowLeft: 0x50,
arrowDown: 0x51,
arrowUp: 0x52,
// Numpad
numLock: 0x53,
numpadDivide: 0x54,
numpadMultiply: 0x55,
numpadSubtract: 0x56,
numpadAdd: 0x57,
numpadEnter: 0x58,
numpad1: 0x59, numpad2: 0x5a, numpad3: 0x5b, numpad4: 0x5c, numpad5: 0x5d,
numpad6: 0x5e, numpad7: 0x5f, numpad8: 0x60, numpad9: 0x61, numpad0: 0x62,
numpadDecimal: 0x63,
nonUsBackslash: 0x64,
application: 0x65,
// Modifiers
controlLeft: 0xe0,
shiftLeft: 0xe1,
altLeft: 0xe2,
metaLeft: 0xe3,
controlRight: 0xe4,
shiftRight: 0xe5,
altRight: 0xe6,
metaRight: 0xe7,
};
/**
* Every key we are willing to paint on a full-size ANSI board (Apex 7 TKL and
* mini boards simply ignore codes they do not have).
*
* This matters because of the GameSense caveat: the moment an event lands the
* keyboard switches into GameSense mode and every key we do NOT address goes
* dark. Painting the full set keeps the board usable.
*/
const ALL_KEYS = Object.values(HID);
module.exports = { HID, ALL_KEYS };

117
src/index.js Normal file
View File

@@ -0,0 +1,117 @@
'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,
};

161
src/lighting.js Normal file
View File

@@ -0,0 +1,161 @@
'use strict';
const { ALL_KEYS } = require('./hid');
const { NOTE_KEYS, NOTE_KEY_HIDS, pitchClassesFor } = require('./scale');
const DEVICE_TYPE = 'rgb-per-key-zones';
const BACKGROUND_FRAME_KEY = 'background';
/**
* Lighting model
* --------------
* One event ("SCALE") carrying one handler per addressable group:
*
* - a background handler covering every key except the 13 note keys
* - one handler per note key
*
* All of them use `context-color`, so the actual colors travel in the event's
* `frame` at runtime. That means we bind once at startup and afterwards a scale
* change is a single POST — no re-registration, which is what step 3 needs when
* the Live scale changes while a set is running.
*
* `bitmap` mode would also work, but its 22x6 grid has no documented per-model
* index->key table, whereas HID codes are exact. Painting *every* key here also
* handles the "GameSense mode blacks out unaddressed keys" caveat.
*/
const BACKGROUND_KEYS = ALL_KEYS.filter((hid) => !NOTE_KEY_HIDS.includes(hid));
const DEFAULT_COLORS = {
background: '#0a0a0f', // near-off, keeps the rest of the board readable
scale: '#00b4ff', // notes in the scale
root: '#ff5a00', // the root, so you can find "home" at a glance
off: '#000000', // note keys outside the scale
};
/** "#rrggbb" / "rrggbb" / "#rgb" / {red,green,blue} -> {red,green,blue}. */
function parseColor(input) {
if (input && typeof input === 'object') {
const { red, green, blue } = input;
return { red: clampByte(red), green: clampByte(green), blue: clampByte(blue) };
}
const raw = String(input).trim().replace(/^#/, '');
const expanded =
raw.length === 3
? raw
.split('')
.map((c) => c + c)
.join('')
: raw;
if (!/^[0-9a-fA-F]{6}$/.test(expanded)) {
throw new Error(`invalid color: ${input} (expected hex like #00b4ff)`);
}
return {
red: parseInt(expanded.slice(0, 2), 16),
green: parseInt(expanded.slice(2, 4), 16),
blue: parseInt(expanded.slice(4, 6), 16),
};
}
function clampByte(n) {
const v = Math.round(Number(n));
if (!Number.isFinite(v)) throw new Error(`invalid color component: ${n}`);
return Math.min(255, Math.max(0, v));
}
/** Scale an RGB triple by a 0..1 factor — used for the dim background. */
function dim(color, factor) {
return {
red: clampByte(color.red * factor),
green: clampByte(color.green * factor),
blue: clampByte(color.blue * factor),
};
}
/** The handler list to POST to /bind_game_event. Bound once, at startup. */
function buildHandlers() {
const handlers = [
{
'device-type': DEVICE_TYPE,
mode: 'context-color',
'custom-zone-keys': BACKGROUND_KEYS,
'context-frame-key': BACKGROUND_FRAME_KEY,
},
];
for (const note of NOTE_KEYS) {
handlers.push({
'device-type': DEVICE_TYPE,
mode: 'context-color',
'custom-zone-keys': [note.hid],
'context-frame-key': note.frameKey,
});
}
return handlers;
}
/**
* Build the `frame` for a set of lit pitch classes.
*
* @param {object} options
* @param {number[]|Set<number>} options.pitchClasses lit pitch classes (0-11)
* @param {number|null} [options.root] highlighted differently
* @param {object} [options.colors] overrides DEFAULT_COLORS
* @returns {object} frame keyed by context-frame-key
*/
function buildFrame({ pitchClasses, root = null, colors = {} }) {
const palette = {
background: parseColor(colors.background || DEFAULT_COLORS.background),
scale: parseColor(colors.scale || DEFAULT_COLORS.scale),
root: parseColor(colors.root || DEFAULT_COLORS.root),
off: parseColor(colors.off || DEFAULT_COLORS.off),
};
const lit = pitchClasses instanceof Set ? pitchClasses : new Set(pitchClasses);
const frame = { [BACKGROUND_FRAME_KEY]: palette.background };
for (const note of NOTE_KEYS) {
if (!lit.has(note.pitchClass)) {
frame[note.frameKey] = palette.off;
} else if (root !== null && note.pitchClass === root) {
frame[note.frameKey] = palette.root;
} else {
frame[note.frameKey] = palette.scale;
}
}
return frame;
}
/** Everything black — used to hand the board back cleanly. */
function buildBlackFrame() {
const black = { red: 0, green: 0, blue: 0 };
const frame = { [BACKGROUND_FRAME_KEY]: black };
for (const note of NOTE_KEYS) frame[note.frameKey] = black;
return frame;
}
/** Convenience: root + intervals -> frame, in one call. */
function frameForScale(root, intervals, colors) {
return buildFrame({
pitchClasses: pitchClassesFor(root, intervals),
root,
colors,
});
}
module.exports = {
DEVICE_TYPE,
BACKGROUND_FRAME_KEY,
BACKGROUND_KEYS,
DEFAULT_COLORS,
parseColor,
dim,
buildHandlers,
buildFrame,
buildBlackFrame,
frameForScale,
};

134
src/scale.js Normal file
View File

@@ -0,0 +1,134 @@
'use strict';
const { HID } = require('./hid');
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const NOTE_ALIASES = {
C: 0, 'B#': 0,
'C#': 1, DB: 1,
D: 2,
'D#': 3, EB: 3,
E: 4, FB: 4,
F: 5, 'E#': 5,
'F#': 6, GB: 6,
G: 7,
'G#': 8, AB: 8,
A: 9,
'A#': 10, BB: 10,
B: 11, CB: 11,
};
/** Interval sets, matching Live 12's `scale_intervals`. */
const SCALES = {
major: [0, 2, 4, 5, 7, 9, 11],
minor: [0, 2, 3, 5, 7, 8, 10],
'harmonic-minor': [0, 2, 3, 5, 7, 8, 11],
'melodic-minor': [0, 2, 3, 5, 7, 9, 11],
dorian: [0, 2, 3, 5, 7, 9, 10],
phrygian: [0, 1, 3, 5, 7, 8, 10],
lydian: [0, 2, 4, 6, 7, 9, 11],
mixolydian: [0, 2, 4, 5, 7, 9, 10],
locrian: [0, 1, 3, 5, 6, 8, 10],
'major-pentatonic': [0, 2, 4, 7, 9],
'minor-pentatonic': [0, 3, 5, 7, 10],
blues: [0, 3, 5, 6, 7, 10],
'whole-tone': [0, 2, 4, 6, 8, 10],
chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
};
/**
* Ableton's computer MIDI keyboard, one octave laid out like a piano:
* white keys on the home row, black keys on the row above, plus the octave C.
*/
const NOTE_KEYS = [
{ key: 'a', hid: HID.a, pitchClass: 0, frameKey: 'note-a' },
{ key: 'w', hid: HID.w, pitchClass: 1, frameKey: 'note-w' },
{ key: 's', hid: HID.s, pitchClass: 2, frameKey: 'note-s' },
{ key: 'e', hid: HID.e, pitchClass: 3, frameKey: 'note-e' },
{ key: 'd', hid: HID.d, pitchClass: 4, frameKey: 'note-d' },
{ key: 'f', hid: HID.f, pitchClass: 5, frameKey: 'note-f' },
{ key: 't', hid: HID.t, pitchClass: 6, frameKey: 'note-t' },
{ key: 'g', hid: HID.g, pitchClass: 7, frameKey: 'note-g' },
{ key: 'y', hid: HID.y, pitchClass: 8, frameKey: 'note-y' },
{ key: 'h', hid: HID.h, pitchClass: 9, frameKey: 'note-h' },
{ key: 'u', hid: HID.u, pitchClass: 10, frameKey: 'note-u' },
{ key: 'j', hid: HID.j, pitchClass: 11, frameKey: 'note-j' },
// The octave key: same pitch class as A, one octave up.
{ key: 'k', hid: HID.k, pitchClass: 0, frameKey: 'note-k', octaveUp: true },
];
const NOTE_KEY_HIDS = NOTE_KEYS.map((k) => k.hid);
/** "C", "f#", "Bb", "3" -> 0..11. Throws on garbage. */
function parseRoot(input) {
if (typeof input === 'number') {
if (!Number.isInteger(input) || input < 0 || input > 11) {
throw new Error(`root must be an integer 0-11, got ${input}`);
}
return input;
}
const raw = String(input).trim();
if (/^\d+$/.test(raw)) return parseRoot(Number(raw));
const normalized = raw.toUpperCase().replace(/♯/g, '#').replace(/♭/g, 'B');
if (normalized in NOTE_ALIASES) return NOTE_ALIASES[normalized];
throw new Error(`unknown root note: ${input}`);
}
/** "major", "Harmonic Minor", "minor_pentatonic" -> interval array. */
function parseScale(input) {
const normalized = String(input).trim().toLowerCase().replace(/[\s_]+/g, '-');
if (normalized in SCALES) return SCALES[normalized];
throw new Error(
`unknown scale: ${input} (known: ${Object.keys(SCALES).join(', ')})`
);
}
/** "0,2,4,5,7,9,11" or [0,2,...] -> normalized, deduped, sorted interval array. */
function parseIntervals(input) {
const list = Array.isArray(input) ? input : String(input).split(/[,\s]+/);
const parsed = list
.filter((v) => String(v).length > 0)
.map((v) => {
const n = Number(v);
if (!Number.isInteger(n)) throw new Error(`interval is not an integer: ${v}`);
return n;
});
if (parsed.length === 0) throw new Error('interval list is empty');
return parsed;
}
/**
* The core of the whole project:
* lit = { (root + interval) % 12 }
* Returns a sorted array of pitch classes.
*/
function pitchClassesFor(root, intervals) {
const set = new Set(intervals.map((i) => (((root + i) % 12) + 12) % 12));
return [...set].sort((a, b) => a - b);
}
/** Pitch classes -> the QWERTY keys Ableton maps them to (includes 'k'). */
function keysFor(pitchClasses) {
const set = new Set(pitchClasses);
return NOTE_KEYS.filter((k) => set.has(k.pitchClass)).map((k) => k.key);
}
function noteName(pitchClass) {
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,
};

217
test/logic.test.js Normal file
View File

@@ -0,0 +1,217 @@
'use strict';
const assert = require('assert');
const {
parseRoot,
parseScale,
parseIntervals,
pitchClassesFor,
keysFor,
noteName,
NOTE_KEYS,
NOTE_KEY_HIDS,
SCALES,
} = require('../src/scale');
const {
parseColor,
buildHandlers,
buildFrame,
buildBlackFrame,
BACKGROUND_KEYS,
BACKGROUND_FRAME_KEY,
} = require('../src/lighting');
const { splitAddress } = require('../src/gamesense');
const { HID, ALL_KEYS } = require('../src/hid');
let passed = 0;
function test(name, fn) {
try {
fn();
passed++;
console.log(` ok ${name}`);
} catch (err) {
console.error(` FAIL ${name}\n ${err.message}`);
process.exitCode = 1;
}
}
console.log('scale math');
test('root parsing accepts names, accidentals and numbers', () => {
assert.strictEqual(parseRoot('C'), 0);
assert.strictEqual(parseRoot('c'), 0);
assert.strictEqual(parseRoot('F#'), 6);
assert.strictEqual(parseRoot('Gb'), 6);
assert.strictEqual(parseRoot('Bb'), 10);
assert.strictEqual(parseRoot('B'), 11);
assert.strictEqual(parseRoot(7), 7);
assert.strictEqual(parseRoot('11'), 11);
assert.throws(() => parseRoot('H'));
assert.throws(() => parseRoot(12));
});
test('scale names normalize', () => {
assert.deepStrictEqual(parseScale('major'), [0, 2, 4, 5, 7, 9, 11]);
assert.deepStrictEqual(parseScale('Harmonic Minor'), [0, 2, 3, 5, 7, 8, 11]);
assert.deepStrictEqual(parseScale('minor_pentatonic'), [0, 3, 5, 7, 10]);
assert.throws(() => parseScale('bebop'));
});
test('raw interval lists parse', () => {
assert.deepStrictEqual(parseIntervals('0,2,4,7,9'), [0, 2, 4, 7, 9]);
assert.deepStrictEqual(parseIntervals([0, 3, 7]), [0, 3, 7]);
assert.throws(() => parseIntervals(''));
assert.throws(() => parseIntervals('0,x'));
});
test('C major lights the seven white keys', () => {
const pcs = pitchClassesFor(0, SCALES.major);
assert.deepStrictEqual(pcs, [0, 2, 4, 5, 7, 9, 11]);
assert.deepStrictEqual(pcs.map(noteName), ['C', 'D', 'E', 'F', 'G', 'A', 'B']);
// Home row plus the octave key.
assert.deepStrictEqual(keysFor(pcs), ['a', 's', 'd', 'f', 'g', 'h', 'j', 'k']);
});
test('F# major wraps around the octave correctly', () => {
const pcs = pitchClassesFor(6, SCALES.major);
assert.deepStrictEqual(pcs, [1, 3, 6, 8, 10, 11, 5].sort((a, b) => a - b));
assert.deepStrictEqual(pcs.map(noteName).sort(), ['A#', 'B', 'C#', 'D#', 'F', 'F#', 'G#']);
});
test('A minor is the same pitch classes as C major', () => {
assert.deepStrictEqual(
pitchClassesFor(9, SCALES.minor),
pitchClassesFor(0, SCALES.major)
);
});
test('chromatic lights every note key', () => {
const pcs = pitchClassesFor(0, SCALES.chromatic);
assert.strictEqual(pcs.length, 12);
assert.strictEqual(keysFor(pcs).length, NOTE_KEYS.length);
});
console.log('key mapping');
test('note keys match the Ableton layout table in the brief', () => {
const expected = [
['a', 0], ['w', 1], ['s', 2], ['e', 3], ['d', 4], ['f', 5],
['t', 6], ['g', 7], ['y', 8], ['h', 9], ['u', 10], ['j', 11],
['k', 0],
];
assert.deepStrictEqual(
NOTE_KEYS.map((n) => [n.key, n.pitchClass]),
expected
);
});
test('HID codes match the USB HID keyboard page', () => {
const expected = {
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,
};
for (const note of NOTE_KEYS) {
assert.strictEqual(note.hid, expected[note.key], `HID for ${note.key}`);
}
});
test('HID table has no duplicate codes', () => {
assert.strictEqual(new Set(ALL_KEYS).size, ALL_KEYS.length);
});
test('frame keys are unique', () => {
const keys = NOTE_KEYS.map((n) => n.frameKey);
assert.strictEqual(new Set(keys).size, keys.length);
assert.ok(!keys.includes(BACKGROUND_FRAME_KEY));
});
console.log('handlers and frames');
test('background zone covers every key except the note keys', () => {
assert.strictEqual(BACKGROUND_KEYS.length, ALL_KEYS.length - NOTE_KEY_HIDS.length);
for (const hid of NOTE_KEY_HIDS) {
assert.ok(!BACKGROUND_KEYS.includes(hid), `note key ${hid} leaked into background`);
}
assert.ok(BACKGROUND_KEYS.includes(HID.space));
assert.ok(BACKGROUND_KEYS.includes(HID.z), 'octave-down key is background');
});
test('handlers: one background + one per note key, all context-color', () => {
const handlers = buildHandlers();
assert.strictEqual(handlers.length, 1 + NOTE_KEYS.length);
for (const h of handlers) {
assert.strictEqual(h['device-type'], 'rgb-per-key-zones');
assert.strictEqual(h.mode, 'context-color');
assert.ok(Array.isArray(h['custom-zone-keys']));
assert.ok(h['custom-zone-keys'].length > 0);
assert.strictEqual(typeof h['context-frame-key'], 'string');
}
// Every context-frame-key a handler asks for must exist in a frame.
const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0 });
for (const h of handlers) {
assert.ok(
h['context-frame-key'] in frame,
`frame is missing ${h['context-frame-key']}`
);
}
});
test('frame colors the root, the scale and the rest distinctly', () => {
const colors = {
background: '#0a0a0f',
scale: '#00b4ff',
root: '#ff5a00',
off: '#000000',
};
const frame = buildFrame({ pitchClasses: [0, 4, 7], root: 0, colors });
assert.deepStrictEqual(frame[BACKGROUND_FRAME_KEY], { red: 10, green: 10, blue: 15 });
assert.deepStrictEqual(frame['note-a'], { red: 255, green: 90, blue: 0 }); // C = root
assert.deepStrictEqual(frame['note-d'], { red: 0, green: 180, blue: 255 }); // E
assert.deepStrictEqual(frame['note-g'], { red: 0, green: 180, blue: 255 }); // G
assert.deepStrictEqual(frame['note-w'], { red: 0, green: 0, blue: 0 }); // C# unlit
// The octave key follows C, but is not treated as the root.
assert.deepStrictEqual(frame['note-k'], { red: 255, green: 90, blue: 0 });
});
test('frame accepts a Set and a null root', () => {
const frame = buildFrame({ pitchClasses: new Set([2, 5]), root: null });
assert.deepStrictEqual(frame['note-s'], frame['note-f']);
});
test('black frame blanks everything', () => {
const frame = buildBlackFrame();
for (const value of Object.values(frame)) {
assert.deepStrictEqual(value, { red: 0, green: 0, blue: 0 });
}
});
console.log('parsing helpers');
test('color parsing handles #rgb, rrggbb and objects', () => {
assert.deepStrictEqual(parseColor('#00b4ff'), { red: 0, green: 180, blue: 255 });
assert.deepStrictEqual(parseColor('00B4FF'), { red: 0, green: 180, blue: 255 });
assert.deepStrictEqual(parseColor('#f0a'), { red: 255, green: 0, blue: 170 });
assert.deepStrictEqual(parseColor({ red: 300, green: -5, blue: 1.4 }), {
red: 255,
green: 0,
blue: 1,
});
assert.throws(() => parseColor('#gggggg'));
assert.throws(() => parseColor('blue'));
});
test('address parsing', () => {
assert.deepStrictEqual(splitAddress('127.0.0.1:52384'), {
host: '127.0.0.1',
port: 52384,
});
assert.deepStrictEqual(splitAddress('http://127.0.0.1:1'), {
host: '127.0.0.1',
port: 1,
});
assert.throws(() => splitAddress('127.0.0.1'));
});
console.log(`\n${passed} passed${process.exitCode ? ', with failures' : ''}`);

205
tools/fake-gamesense.js Normal file
View File

@@ -0,0 +1,205 @@
#!/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`);
});