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