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

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`);
});