Add an Audio Effect variant of the device, alongside the MIDI one

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>
This commit is contained in:
khannurien
2026-08-15 12:17:57 +00:00
parent b8d01e3f59
commit fd5bb786e5
3 changed files with 559 additions and 41 deletions

View File

@@ -122,24 +122,75 @@ whole chain from the brief is live:
Live 12 → js (LiveAPI) → Node for Max → GameSense → Apex 7 Live 12 → js (LiveAPI) → Node for Max → GameSense → Apex 7
``` ```
### Two variants
The device is pure observer — it reads the LOM and talks to GameSense, and it
never looks at a note. So it does not have to sit in the path of anything you
play. Two patchers ship, identical apart from that:
| Patcher | Live device type | Passthrough |
| --- | --- | --- |
| `max/scale-lighting.maxpat` | Max **MIDI** Effect | `midiin``midiout` |
| `max/scale-lighting-audio.maxpat` | Max **Audio** Effect | none |
The MIDI variant needs the passthrough: a Max MIDI Effect without `midiin`
`midiout` swallows MIDI instead of passing it on. That means when it sits on the
track you play, every note you play is routed through Max's scheduler on its way
to your instrument — a small cost, and a pointless one here.
The audio variant declares no I/O at all: no `midiin`/`midiout`, no
`plugin~`/`plugout~`. Nothing you play goes through Max. Park it on a **dedicated
empty Audio track** and it cannot be in the signal path of anything.
If you would rather not add a track, the MIDI variant on a dedicated empty MIDI
track is just as much out of the way — the cost only appears when the device is
on the track you are actually playing.
### Installing it ### Installing it
The repo ships a `.maxpat` rather than a `.amxd`, because an `.amxd` has to be The repo ships `.maxpat` files rather than `.amxd`, because an `.amxd` has to be
born inside Live: born inside Live:
1. In Live, drag a **Max MIDI Effect** onto a MIDI track and click its edit 1. In Live, drag a **Max Audio Effect** onto a dedicated empty Audio track (or a
(pencil) button to open Max. **Max MIDI Effect** onto a MIDI track, for the MIDI variant) and click its
2. Open `max/scale-lighting.maxpat` in a text editor, copy all of it, then in edit (pencil) button to open Max.
the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**. 2. Open the matching `.maxpat` from `max/` in a text editor, copy all of it, then
Max pastes the whole patcher, `midiin`/`midiout` passthrough included. in the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**.
3. **File → Save**, and save the device as `max/Ableton Scale Lighting.amxd` Max pastes the whole patcher.
in *this* folder, so `js` and `node.script` find their scripts next to it. 3. **File → Save**, and save the device into `max/` — in *this* folder, so `js`
and `node.script` find their scripts next to it. Suggested names:
`Ableton Scale Lighting.amxd` and `Ableton Scale Lighting (Audio).amxd`.
4. Make sure `dist/` is built (`npm install` in the repo root). `node.script` 4. Make sure `dist/` is built (`npm install` in the repo root). `node.script`
loads `dist/src/max/device.js`. loads `dist/src/max/device.js`.
Add this repo's folder in Live's browser (**Add Folder…**) to load the device Add this repo's folder in Live's browser (**Add Folder…**) to load the device
from there in future sets. from there in future sets.
> **Load one at a time.** Both variants register the same GameSense game
> (`ABLETON_SCALE`), so two of them running together fight over the board and
> either one's `shutdown` blanks it for both.
### Comparing the two
Both patchers keep `@watch 1` on `node.script` and `autowatch` in
`scale-observer.js`, so the only difference between them is the passthrough and
the A/B measures exactly that.
The honest test is not "does it feel laggy" — it is a recording. On one MIDI
track with an instrument that has a sharp attack:
1. Record a bar of audio with no device on the track.
2. Drop the MIDI variant *before* the instrument, play the same part, record again.
3. Do the same with the audio variant on its own separate track.
Line the three takes up and compare note onsets. Live's CPU meter and Max's
**Window → Max Console** are worth a glance too, but neither shows scheduler
latency — only the onsets do.
Note that both variants start a `node.script` process and heartbeat GameSense
every 5s regardless of where they sit; that cost is identical and is not what
this comparison is about.
### What it does ### What it does
Change the scale in Live's control bar and the board follows: scale notes in Change the scale in Live's control bar and the board follows: scale notes in
@@ -222,7 +273,8 @@ src/errors.ts `catch (err: unknown)` helpers
src/index.ts ScaleLighting — the API the M4L device drives src/index.ts ScaleLighting — the API the M4L device drives
src/live.ts LOM values -> a resolved scale, and how to print it src/live.ts LOM values -> a resolved scale, and how to print it
src/max/device.ts the Node for Max device: Live's scale in, lighting out src/max/device.ts the Node for Max device: Live's scale in, lighting out
max/scale-lighting.maxpat the M4L patcher (paste into a device created in Live) max/scale-lighting.maxpat M4L patcher, MIDI Effect (midiin/midiout passthrough)
max/scale-lighting-audio.maxpat M4L patcher, Audio Effect (no passthrough at all)
max/scale-observer.js the `js` object that observes the Live Object Model (ES5) max/scale-observer.js the `js` object that observes the Live Object Model (ES5)
max/scale-device.js `node.script` launcher — runs dist/src/max/device.js max/scale-device.js `node.script` launcher — runs dist/src/max/device.js
tools/fake-gamesense.ts terminal simulator of the GameSense server tools/fake-gamesense.ts terminal simulator of the GameSense server

View File

@@ -0,0 +1,426 @@
{
"patcher" : {
"fileversion" : 1,
"appversion" : {
"major" : 8,
"minor" : 5,
"revision" : 6,
"architecture" : "x64",
"modernui" : 1
}
,
"classnamespace" : "box",
"rect" : [ 100.0, 100.0, 760.0, 460.0 ],
"bglocked" : 0,
"openinpresentation" : 0,
"default_fontsize" : 12.0,
"default_fontface" : 0,
"default_fontname" : "Arial",
"gridonopen" : 1,
"gridsize" : [ 15.0, 15.0 ],
"gridsnaponopen" : 1,
"objectsnaponopen" : 1,
"statusbarvisible" : 2,
"toolbarvisible" : 1,
"lefttoolbarpinned" : 0,
"toptoolbarpinned" : 0,
"righttoolbarpinned" : 0,
"bottomtoolbarpinned" : 0,
"toolbars_unpinned_last_save" : 0,
"tallnewobj" : 0,
"boxanimatetime" : 200,
"enablehscroll" : 1,
"enablevscroll" : 1,
"devicewidth" : 0.0,
"description" : "",
"digest" : "",
"tags" : "",
"style" : "",
"subpatcher_template" : "",
"assistshowspatchername" : 0,
"boxes" : [ {
"box" : {
"id" : "obj-1",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"patching_rect" : [ 20.0, 15.0, 480.0, 20.0 ],
"fontsize" : 13.0,
"text" : "Ableton Scale Lighting (Audio Effect) — the Apex 7 follows Live's scale"
}
}
, {
"box" : {
"id" : "obj-2",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 3,
"patching_rect" : [ 20.0, 40.0, 480.0, 47.0 ],
"text" : "The js object is the only part that may touch the Live API; it forwards raw root_note / scale_name / scale_intervals to node.script, which resolves them, prints the QWERTY keys to the Max console and lights them on the keyboard through GameSense."
}
}
, {
"box" : {
"id" : "obj-3",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 3,
"outlettype" : [ "bang", "", "" ],
"patching_rect" : [ 20.0, 100.0, 105.0, 22.0 ],
"text" : "live.thisdevice"
}
}
, {
"box" : {
"id" : "obj-4",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"patching_rect" : [ 131.0, 103.0, 190.0, 20.0 ],
"text" : "bang once the set is loaded"
}
}
, {
"box" : {
"id" : "obj-5",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 20.0, 140.0, 80.0, 22.0 ],
"text" : "source song"
}
}
, {
"box" : {
"id" : "obj-6",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 110.0, 140.0, 76.0, 22.0 ],
"text" : "source clip"
}
}
, {
"box" : {
"id" : "obj-7",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 196.0, 140.0, 55.0, 22.0 ],
"text" : "refresh"
}
}
, {
"box" : {
"id" : "obj-8",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 261.0, 140.0, 48.0, 22.0 ],
"text" : "debug 1"
}
}
, {
"box" : {
"id" : "obj-9",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 20.0, 185.0, 155.0, 22.0 ],
"text" : "js scale-observer.js"
}
}
, {
"box" : {
"id" : "obj-10",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 196.0, 185.0, 48.0, 22.0 ],
"text" : "status"
}
}
, {
"box" : {
"id" : "obj-11",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 254.0, 185.0, 62.0, 22.0 ],
"text" : "verbose 1"
}
}
, {
"box" : {
"id" : "obj-18",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 326.0, 185.0, 56.0, 22.0 ],
"text" : "lights 1"
}
}
, {
"box" : {
"id" : "obj-19",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 390.0, 185.0, 56.0, 22.0 ],
"text" : "lights 0"
}
}
, {
"box" : {
"id" : "obj-20",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 454.0, 185.0, 44.0, 22.0 ],
"text" : "retry"
}
}
, {
"box" : {
"id" : "obj-21",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 1,
"outlettype" : [ "bang" ],
"patching_rect" : [ 430.0, 100.0, 66.0, 22.0 ],
"text" : "closebang"
}
}
, {
"box" : {
"id" : "obj-22",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 430.0, 140.0, 66.0, 22.0 ],
"text" : "shutdown"
}
}
, {
"box" : {
"id" : "obj-23",
"maxclass" : "message",
"numinlets" : 2,
"numoutlets" : 1,
"outlettype" : [ "" ],
"patching_rect" : [ 20.0, 320.0, 175.0, 22.0 ],
"text" : "address 127.0.0.1:51000"
}
}
, {
"box" : {
"id" : "obj-24",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 2,
"patching_rect" : [ 204.0, 317.0, 400.0, 33.0 ],
"text" : "Only for testing against tools/fake-gamesense.ts — leave it alone and the device finds SteelSeries GG through coreProps.json. Send \"address\" with no value to go back to discovery."
}
}
, {
"box" : {
"id" : "obj-12",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 2,
"outlettype" : [ "", "" ],
"patching_rect" : [ 20.0, 230.0, 300.0, 22.0 ],
"text" : "node.script scale-device.js @autostart 1 @watch 1"
}
}
, {
"box" : {
"id" : "obj-13",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 0,
"patching_rect" : [ 20.0, 275.0, 74.0, 22.0 ],
"text" : "print scale"
}
}
, {
"box" : {
"id" : "obj-14",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 2,
"patching_rect" : [ 104.0, 272.0, 400.0, 33.0 ],
"text" : "Open the Max console (Cmd/Ctrl-Shift-M) to watch the scale change. node.script needs dist/ built: npm install in the repo root. SteelSeries GG must be running for the keys to light."
}
}
, {
"box" : {
"id" : "obj-15",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 4,
"patching_rect" : [ 530.0, 100.0, 205.0, 60.0 ],
"text" : "No passthrough on purpose. This device never touches your MIDI, and with no plugin~ / plugout~ it declares no audio I/O either — nothing you play is routed through Max. That is the whole point of this variant."
}
}
, {
"box" : {
"id" : "obj-16",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"linecount" : 3,
"patching_rect" : [ 530.0, 170.0, 205.0, 47.0 ],
"text" : "Park it on a dedicated empty Audio track. Then it cannot be in the signal path of anything, whatever Live decides to do with a device that has no audio objects."
}
}
],
"lines" : [ {
"patchline" : {
"destination" : [ "obj-9", 0 ],
"source" : [ "obj-3", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-9", 0 ],
"source" : [ "obj-5", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-9", 0 ],
"source" : [ "obj-6", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-9", 0 ],
"source" : [ "obj-7", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-9", 0 ],
"source" : [ "obj-8", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-9", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-10", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-11", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-18", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-19", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-20", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-22", 0 ],
"source" : [ "obj-21", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-22", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-12", 0 ],
"source" : [ "obj-23", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-13", 0 ],
"source" : [ "obj-12", 0 ]
}
}
],
"dependency_cache" : [ ],
"autosave" : 0
}
}

View File

@@ -507,22 +507,27 @@ test('status says whether the board is being driven', async () => {
section('Max patcher'); section('Max patcher');
test('scale-lighting.maxpat is valid JSON with the objects we wired', () => { interface Patcher {
const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8');
const patch = JSON.parse(raw) as {
patcher: { patcher: {
boxes: { box: { id: string; text?: string } }[]; boxes: { box: { id: string; text?: string } }[];
lines: { patchline: { source: [string, number]; destination: [string, number] } }[]; lines: { patchline: { source: [string, number]; destination: [string, number] } }[];
}; };
}; }
function readPatcher(file: string): Patcher {
return JSON.parse(fs.readFileSync(path.join(REPO, 'max', file), 'utf8')) as Patcher;
}
// Both variants drive the same device; they differ only in what they let
// through, which is the whole reason the audio one exists.
for (const file of ['scale-lighting.maxpat', 'scale-lighting-audio.maxpat']) {
test(`${file} is valid JSON with the objects we wired`, () => {
const patch = readPatcher(file);
const boxes = patch.patcher.boxes.map((b) => b.box); const boxes = patch.patcher.boxes.map((b) => b.box);
const texts = boxes.map((b) => b.text ?? ''); const texts = boxes.map((b) => b.text ?? '');
const expectedTexts = [ const expectedTexts = [
'live.thisdevice', 'live.thisdevice',
'js scale-observer.js', 'js scale-observer.js',
'midiin',
'midiout',
// Step 3: the lighting controls, and blanking the board on device deletion. // Step 3: the lighting controls, and blanking the board on device deletion.
'lights 1', 'lights 1',
'lights 0', 'lights 0',
@@ -531,18 +536,53 @@ test('scale-lighting.maxpat is valid JSON with the objects we wired', () => {
'shutdown', 'shutdown',
]; ];
for (const expected of expectedTexts) { for (const expected of expectedTexts) {
assert.ok(texts.includes(expected), `patcher is missing ${expected}`); assert.ok(texts.includes(expected), `${file} is missing ${expected}`);
} }
assert.ok( assert.ok(
texts.some((t) => t.startsWith('node.script scale-device.js')), texts.some((t) => t.startsWith('node.script scale-device.js')),
'patcher is missing node.script' `${file} is missing node.script`
); );
// Every patch cord must point at a box that exists. // Every patch cord must point at a box that exists.
const ids = new Set(boxes.map((b) => b.id)); const ids = new Set(boxes.map((b) => b.id));
for (const { patchline } of patch.patcher.lines) { for (const { patchline } of patch.patcher.lines) {
assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`); assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`);
assert.ok(ids.has(patchline.destination[0]), `dangling destination ${patchline.destination[0]}`); assert.ok(
ids.has(patchline.destination[0]),
`dangling destination ${patchline.destination[0]}`
);
}
});
}
// A Max MIDI Effect without this swallows MIDI instead of passing it on.
test('the MIDI variant passes MIDI through', () => {
const patch = readPatcher('scale-lighting.maxpat');
const boxes = patch.patcher.boxes.map((b) => b.box);
const idOf = (text: string) => boxes.find((b) => b.text === text)?.id;
const from = idOf('midiin');
const to = idOf('midiout');
assert.ok(from && to, 'patcher is missing midiin/midiout');
assert.ok(
patch.patcher.lines.some(
({ patchline }) => patchline.source[0] === from && patchline.destination[0] === to
),
'midiin is not connected to midiout'
);
});
// The point of the audio variant: it declares no I/O, so nothing the user plays
// is ever routed through Max. Adding a passthrough here would silently undo it.
test('the audio variant is not in the path of anything', () => {
const texts = readPatcher('scale-lighting-audio.maxpat').patcher.boxes.map(
(b) => b.box.text ?? ''
);
for (const forbidden of ['midiin', 'midiout', 'plugin~', 'plugout~']) {
assert.ok(
!texts.includes(forbidden),
`the audio variant must not declare I/O, but has ${forbidden}`
);
} }
}); });