The device is a pure observer — it reads the LOM and talks to GameSense and
never looks at a note — so it does not have to sit in the path of anything you
play. But a Max MIDI Effect without midiin -> midiout swallows MIDI instead of
passing it on, so the shipped device routes every note you play through Max's
scheduler on its way to your instrument. Pointless cost for this device.
max/scale-lighting-audio.maxpat is the same patcher as an Audio Effect,
declaring no I/O at all: no midiin/midiout, no plugin~/plugout~. Park it on a
dedicated empty Audio track and it is provably out of every signal path.
The two patchers differ by exactly midiin, midiout and the patchline between
them — @watch 1 and everything else are deliberately left identical on both
sides, so an A/B measures the passthrough and nothing else. Both register the
same GameSense game, so they have to be loaded one at a time; that and a way to
actually measure the difference (compare note onsets across recorded takes, not
the CPU meter) are in the README.
The patcher test now runs over both files, and two new tests pin the
distinction: the MIDI variant must wire midiin to midiout, and the audio variant
must declare none of those four objects — the kind of thing a later edit would
otherwise silently undo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Step 3 of the brief: the device now holds a ScaleLighting and pushes every
scale Live reports to the Apex 7, instead of only printing it.
The controller is created lazily and injected (start(max, { createLights })),
so the device stays testable with no Max and no hardware. Around the call:
lighting work is serialized on one queue so a burst of LOM changes cannot
interleave two POSTs, an unchanged scale is never re-sent, and a GameSense
that is missing or restarted is printed once rather than on every scale
change — the scale keeps being tracked so a later retry lands on the right one.
New messages: lights 0|1, retry, address <host:port>, shutdown. The patcher
gets boxes for them plus closebang -> shutdown, and the launcher blanks the
board on SIGTERM/SIGINT, so deleting the device hands lighting back to GG.
The test runner now supports async tests, since the lighting half is async.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build step 2 of the brief: an M4L device that observes root_note /
scale_name / scale_intervals in the Live Object Model and prints the
QWERTY keys the scale maps to. No lighting yet — that is step 3.
LiveAPI only exists inside Max's js objects, so max/scale-observer.js
(plain ES5, the one uncompiled file here) observes the LOM and forwards
raw values as flat messages; src/max/device.ts resolves them, keeping
every decision in TypeScript and testable off the hardware.
Follows the Song's scale by default and the selected clip's on request,
since the brief left that decision open. scale_intervals only exists
from Live 12.1, so scale_name resolves against a table of Live's
built-ins as a fallback; reported intervals always win.
Ships a .maxpat rather than an .amxd because an .amxd has to be created
from inside Live — the README has the paste-into-a-new-device steps.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the standalone GameSense scale lighting to TypeScript ahead of the
Max for Live work, so step 3 gets type declarations at the seam.
Build emits CommonJS at ES2020 into dist/, because Node for Max loads CJS
on a Node version we do not control. bin/apex7-scale.js becomes a plain-JS
launcher so `node bin/apex7-scale.js` keeps working; the CLI itself moves
to src/cli.ts.
Two modules make previously implicit structure explicit:
- src/protocol.ts, the GameSense wire types, so the transport and the
payload construction agree on shapes neither of them owns
- src/errors.ts, since `catch (err)` binds `unknown` under strict
Typing surfaced a few real fixes:
- ScaleLighting.started was a public field the CLI set by hand to make
--off work without start(); that is now release(), with started
private behind isStarted
- readAddress() trusted JSON.parse output; the address is now checked
for being a non-empty string
- res.statusCode is number|undefined, so the old `>= 200` comparison
coerced silently; a missing status now rejects
- parseIntervals claimed to dedupe and sort, and never did (comment
corrected; pitchClassesFor is what dedupes)
Behavior is otherwise unchanged: all 18 tests pass and --once, --off,
--demo, --key-test, the resident stdin loop and the error paths were
verified against tools/fake-gamesense.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>