diff --git a/README.md b/README.md index 24dc8f1..bafe997 100644 --- a/README.md +++ b/README.md @@ -122,24 +122,75 @@ whole chain from the brief is live: 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 -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: -1. In Live, drag a **Max MIDI Effect** onto a MIDI track and click its edit - (pencil) button to open Max. -2. Open `max/scale-lighting.maxpat` in a text editor, copy all of it, then in - the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**. - Max pastes the whole patcher, `midiin`/`midiout` passthrough included. -3. **File → Save**, and save the device as `max/Ableton Scale Lighting.amxd` — - in *this* folder, so `js` and `node.script` find their scripts next to it. +1. In Live, drag a **Max Audio Effect** onto a dedicated empty Audio track (or a + **Max MIDI Effect** onto a MIDI track, for the MIDI variant) and click its + edit (pencil) button to open Max. +2. Open the matching `.maxpat` from `max/` in a text editor, copy all of it, then + in the Max device window: **Edit → Select All**, **Delete**, **Edit → Paste**. + Max pastes the whole patcher. +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` loads `dist/src/max/device.js`. Add this repo's folder in Live's browser (**Add Folder…**) to load the device 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 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/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 -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-device.js `node.script` launcher — runs dist/src/max/device.js tools/fake-gamesense.ts terminal simulator of the GameSense server diff --git a/max/scale-lighting-audio.maxpat b/max/scale-lighting-audio.maxpat new file mode 100644 index 0000000..43cfe18 --- /dev/null +++ b/max/scale-lighting-audio.maxpat @@ -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 + } + +} diff --git a/test/live.test.ts b/test/live.test.ts index 5ecaa0f..e0aefdc 100644 --- a/test/live.test.ts +++ b/test/live.test.ts @@ -507,42 +507,82 @@ test('status says whether the board is being driven', async () => { section('Max patcher'); -test('scale-lighting.maxpat is valid JSON with the objects we wired', () => { - const raw = fs.readFileSync(path.join(REPO, 'max', 'scale-lighting.maxpat'), 'utf8'); - const patch = JSON.parse(raw) as { - patcher: { - boxes: { box: { id: string; text?: string } }[]; - lines: { patchline: { source: [string, number]; destination: [string, number] } }[]; - }; +interface Patcher { + patcher: { + boxes: { box: { id: string; text?: string } }[]; + 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 texts = boxes.map((b) => b.text ?? ''); + const expectedTexts = [ + 'live.thisdevice', + 'js scale-observer.js', + // Step 3: the lighting controls, and blanking the board on device deletion. + 'lights 1', + 'lights 0', + 'retry', + 'closebang', + 'shutdown', + ]; + for (const expected of expectedTexts) { + assert.ok(texts.includes(expected), `${file} is missing ${expected}`); + } + assert.ok( + texts.some((t) => t.startsWith('node.script scale-device.js')), + `${file} is missing node.script` + ); + + // Every patch cord must point at a box that exists. + const ids = new Set(boxes.map((b) => b.id)); + for (const { patchline } of patch.patcher.lines) { + assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[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 texts = boxes.map((b) => b.text ?? ''); - const expectedTexts = [ - 'live.thisdevice', - 'js scale-observer.js', - 'midiin', - 'midiout', - // Step 3: the lighting controls, and blanking the board on device deletion. - 'lights 1', - 'lights 0', - 'retry', - 'closebang', - 'shutdown', - ]; - for (const expected of expectedTexts) { - assert.ok(texts.includes(expected), `patcher is missing ${expected}`); - } - assert.ok( - texts.some((t) => t.startsWith('node.script scale-device.js')), - 'patcher is missing node.script' - ); + const idOf = (text: string) => boxes.find((b) => b.text === text)?.id; - // Every patch cord must point at a box that exists. - const ids = new Set(boxes.map((b) => b.id)); - for (const { patchline } of patch.patcher.lines) { - assert.ok(ids.has(patchline.source[0]), `dangling source ${patchline.source[0]}`); - assert.ok(ids.has(patchline.destination[0]), `dangling destination ${patchline.destination[0]}`); + 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}` + ); } });