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

30
max/scale-device.js Normal file
View File

@@ -0,0 +1,30 @@
'use strict';
/**
* Launcher for the `node.script` object — the same trick as bin/apex7-scale.js.
*
* The device logic is TypeScript (`src/max/device.ts`); this file stays plain
* CommonJS because Node for Max runs it on a Node version we do not control,
* with no loader and no build step of its own.
*
* `max-api` is required *here*, in the folder Node for Max resolves modules
* from, and handed to the compiled module — which keeps that module importable
* (and testable) outside of Max.
*/
const fs = require('fs');
const path = require('path');
const Max = require('max-api');
const compiled = path.join(__dirname, '..', 'dist', 'src', 'max', 'device.js');
if (!fs.existsSync(compiled)) {
Max.post(
'scale: not built yet — run `npm install` (or `npm run build`) in ' +
path.join(__dirname, '..'),
Max.POST_LEVELS.ERROR
);
} else {
require(compiled).start(Max);
}

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
}
}

248
max/scale-observer.js Normal file
View File

@@ -0,0 +1,248 @@
autowatch = 1;
inlets = 1;
outlets = 1;
/**
* Build step 2: the only part of this project that touches the Live API.
*
* `LiveAPI` exists exclusively inside Max's `js`/`v8` objects — Node for Max
* cannot see it — so this script watches the LOM and forwards raw values out
* its outlet to `node.script scale-device.js`, which does the thinking.
*
* Plain ES5 on purpose: the `js` object runs an ES5 engine, so this is the one
* file in the repo that is neither TypeScript nor compiled. Keep it dumb.
*
* Messages in:
* bang start observing (wire this to live.thisdevice)
* source song read the Song's scale — the control bar's global scale
* source clip read the selected clip's scale, falling back to the Song
* refresh re-read and re-send everything
* debug 1 log each push to the Max console
*
* Messages out: reset, source, root, mode, name, intervals, commit.
*/
var SONG_PROPS = ['root_note', 'scale_name', 'scale_mode'];
var CLIP_PROPS = ['root_note', 'scale_name'];
var CLIP_PATH = 'live_set view detail_clip';
var COALESCE_MS = 30;
var started = false;
var scaleSource = 'song';
var logging = 0;
var songObservers = [];
var clipObservers = [];
var selectionObserver = null;
var pushTask = null;
setinletassist(0, 'bang to start; source song|clip, refresh, debug 0|1');
setoutletassist(0, 'to node.script: reset/source/root/mode/name/intervals/commit');
/* --- messages ---------------------------------------------------------- */
// live.thisdevice bangs once the device is fully loaded, which is the earliest
// moment a LiveAPI object may be created.
function bang() {
start();
}
function source(which) {
which = String(which);
if (which !== 'song' && which !== 'clip') {
error('scale-observer: source must be song or clip\n');
return;
}
scaleSource = which;
schedulePush();
}
function refresh() {
schedulePush();
}
function debug(value) {
logging = value ? 1 : 0;
}
/* --- observing --------------------------------------------------------- */
function start() {
if (started) return;
started = true;
pushTask = new Task(push, this);
for (var i = 0; i < SONG_PROPS.length; i++) {
songObservers.push(observe('live_set', SONG_PROPS[i]));
}
// Which clip is selected changes independently of that clip's scale, so it
// needs its own observer that re-points the clip observers when it fires.
selectionObserver = new LiveAPI(onSelectionChanged, 'live_set view');
selectionObserver.property = 'detail_clip';
observeClip();
schedulePush();
}
function observe(path, property) {
var api = new LiveAPI(onChanged, path);
api.property = property;
return api;
}
function onChanged() {
schedulePush();
}
function onSelectionChanged() {
observeClip();
schedulePush();
}
// A LiveAPI binds to the object its path resolved to at creation time, so
// selecting a different clip means throwing these away and making new ones.
function observeClip() {
release(clipObservers);
clipObservers = [];
var clip = new LiveAPI(noop, CLIP_PATH);
if (!clip.id || clip.id == 0) return;
for (var i = 0; i < CLIP_PROPS.length; i++) {
if (has(clip, CLIP_PROPS[i])) {
clipObservers.push(observe(CLIP_PATH, CLIP_PROPS[i]));
}
}
}
function release(observers) {
for (var i = 0; i < observers.length; i++) {
try {
observers[i].property = '';
} catch (e) {
// Live is tearing the object down anyway.
}
}
}
function notifydeleted() {
release(songObservers);
release(clipObservers);
if (selectionObserver) release([selectionObserver]);
if (pushTask) pushTask.freepeer();
}
/* --- reading and sending ------------------------------------------------ */
// Several properties change together when the scale changes; coalesce so the
// device gets one burst of messages instead of three.
function schedulePush() {
if (!started) return;
pushTask.cancel();
pushTask.schedule(COALESCE_MS);
}
function push() {
var scale = null;
if (scaleSource === 'clip') scale = readClip();
if (!scale) scale = readSong();
if (!scale) return;
if (logging) {
post(
'scale-observer: ' + scale.source + ' root=' + scale.root +
' name=' + scale.name + ' intervals=' + scale.intervals + '\n'
);
}
emit(scale);
}
function readSong() {
var api = new LiveAPI(noop, 'live_set');
if (!api.id) return null;
var root = first(get(api, 'root_note'));
if (root === null) return null;
return {
source: 'song',
root: root,
name: joined(get(api, 'scale_name')),
mode: first(get(api, 'scale_mode')),
// scale_intervals only exists from Live 12.1; the device falls back to
// resolving the name when this comes back empty.
intervals: get(api, 'scale_intervals')
};
}
function readClip() {
var api = new LiveAPI(noop, CLIP_PATH);
if (!api.id || api.id == 0) return null;
var root = first(get(api, 'root_note'));
var name = joined(get(api, 'scale_name'));
if (root === null || !name) return null;
var song = new LiveAPI(noop, 'live_set');
return {
source: 'clip',
root: root,
name: name,
// Clips carry no scale_mode of their own; the Song's flag still says
// whether Live is enforcing the scale at all.
mode: first(get(song, 'scale_mode')),
intervals: get(api, 'scale_intervals')
};
}
function emit(scale) {
outlet(0, 'reset');
outlet(0, 'source', scale.source);
outlet(0, 'root', scale.root);
if (scale.mode !== null) outlet(0, 'mode', scale.mode);
if (scale.name) outlet(0, 'name', scale.name);
if (scale.intervals && scale.intervals.length) {
outlet(0, ['intervals'].concat(scale.intervals));
}
outlet(0, 'commit');
}
/* --- LOM helpers -------------------------------------------------------- */
function noop() {}
// Asking for a property the running Live does not have prints an error in the
// Max console, so check `info` before reaching for anything version-dependent.
function has(api, property) {
try {
var info = api.info;
if (typeof info !== 'string') return true;
return info.indexOf('property ' + property) !== -1;
} catch (e) {
return true;
}
}
function get(api, property) {
if (!has(api, property)) return null;
try {
return api.get(property);
} catch (e) {
return null;
}
}
function first(value) {
if (value === null || value === undefined) return null;
if (value instanceof Array) return value.length ? value[0] : null;
return value;
}
// A symbol property comes back as an array, sometimes split on its spaces.
function joined(value) {
if (value === null || value === undefined) return '';
if (value instanceof Array) return value.join(' ');
return String(value);
}