Add the Max for Live device that reads Live's scale

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>
This commit is contained in:
khannurien
2026-08-15 08:48:05 +00:00
parent 3b02612461
commit 2f0d5841c6
8 changed files with 1454 additions and 9 deletions

317
max/scale-lighting.maxpat Normal file
View File

@@ -0,0 +1,317 @@
{
"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, 420.0, 20.0 ],
"fontsize" : 13.0,
"text" : "Ableton Scale Lighting — step 2: read the scale, print the keys"
}
}
, {
"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 and prints the QWERTY keys to the Max console."
}
}
, {
"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-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."
}
}
, {
"box" : {
"id" : "obj-15",
"maxclass" : "comment",
"numinlets" : 1,
"numoutlets" : 0,
"patching_rect" : [ 560.0, 103.0, 175.0, 20.0 ],
"text" : "MIDI passthrough"
}
}
, {
"box" : {
"id" : "obj-16",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 1,
"outlettype" : [ "int" ],
"patching_rect" : [ 560.0, 140.0, 48.0, 22.0 ],
"text" : "midiin"
}
}
, {
"box" : {
"id" : "obj-17",
"maxclass" : "newobj",
"numinlets" : 1,
"numoutlets" : 0,
"patching_rect" : [ 560.0, 185.0, 55.0, 22.0 ],
"text" : "midiout"
}
}
],
"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-13", 0 ],
"source" : [ "obj-12", 0 ]
}
}
, {
"patchline" : {
"destination" : [ "obj-17", 0 ],
"source" : [ "obj-16", 0 ]
}
}
],
"dependency_cache" : [ ],
"autosave" : 0
}
}