Max for Live,
in JavaScript
From a CS background to building, validating, and extending Max for Live devices in JavaScript. The language is the easy part. What's new is the runtime: a message-passing host, a live object tree you steer through a single API, and an engine from a different decade.
Why M4L coding feels off, even with a CS degree
You receive messages, you don't get called
The patch sends bare values into numbered inlets. Max routes each to a magic-named handler (msg_int, bang, anything…). The value's type and this.inlet are your whole interface, with no signature you design.
There is no return value
Handlers return nothing to the patch. Push results out through numbered outlets with outlet(n, …). Think wires, not call stacks.
One global interpreter, on the main thread
State lives in module globals. The script runs on Live's main thread, so a long loop freezes the UI, not audio. Heavy work gets deferred. There's no async runtime.
Live is only visible through one keyhole
Everything goes through LiveAPI and the Live Object Model. You navigate a tree, read/write properties, call functions. Object identities are temporary, and figuring out what's stable is half the work.
The Live Object Model
Everything a device does goes through LiveAPI calls against the Live Object Model. Reference: LOM class list and Live API Overview.
The four root paths
live_set | the open Set (the Song): tracks, scenes, master, transport. |
live_app | the application itself, including its browser. |
this_device | the M4L device the script is running inside. |
control_surfaces | connected controllers. |
Explore it
Click any row to expand. The bar at the bottom shows the exact path you'd pass to LiveAPI.
Three ways to point at an object
// 1. By PATH — positional, readable, but shifts if tracks move new LiveAPI(null, "live_set tracks 0 devices 0"); // 2. By ID — a runtime handle Live hands you; stable *within* a session new LiveAPI(null, "id", 42); // 3. By a VIEW path — "what the user has selected right now" new LiveAPI(null, "live_set view selected_track");
null to read/write once; pass a function and set .property to be notified on every change. LiveAPI reference.LiveAPI in global code: only inside a function, after Live has loaded.live_set view selected_track is the workhorse pattern: acts on whatever the user clicked without hard-coding any index.The four verbs
.get("prop") | Returns an array, even for a single value: ["Drums"], not "Drums". This trips everyone up once. |
.set("prop", v) | write a property. |
.call("fn", …) | invoke a function on the object. |
.info / .id / .path | metadata. .info dumps every property and function the object exposes in your Live version. |
obj.info and read the real list.What's stable, what's not
| id | Runtime-only. Yesterday's id 42 is someone else today. Never save across sessions. |
| path | Positional — shifts when you drag tracks. |
| stable handle | Save a name, tag, or browser uri. Re-resolve to an id at load time. |
Inlets, outlets, and the magic handlers
inlets = 1; // how many inlets the object box shows
outlets = 2; // and how many outlets
// Handlers are matched by the *type* of the incoming message:
function msg_int(n) { /* an integer arrived */ }
function msg_float(f) { /* a float arrived */ }
function bang() { /* a bang arrived */ }
function msg_symbol(s) { /* a single word */ }
function list(a, b) { /* a space-separated list */ }
function anything(sel) { /* anything else; sel = first word */ }
// Which inlet did it come in on? -> this.inlet (0-based)anything is the catch-all; its first argument is the leading symbol.this.inlet lets one handler serve multiple inlets. Branch on it to give each inlet a distinct meaning.The reload / load burst
Saving a JS file and opening a Set both fire a rapid burst of messages into your script. Guard destructive actions with a time gap so a replay can't double-fire.
let lastFire = 0;
function msg_bang_action() {
const now = Date.now();
if (now - lastFire < 50) return; // swallow burst replays
lastFire = now;
doTheDestructiveThing();
}Two engines, two decades of JavaScript
the js object | the v8 object | |
|---|---|---|
| engine | Mozilla SpiderMonkey, ~2011 vintage | Google V8 (Chrome/Node) |
| language | roughly ES5: var only, no arrows, no class, no Map, no template literals | up to ES2022: modern everything, plus real JSON |
| ships in | Max 8 / Live 11 and earlier | Max 9 / Live 12.2+ (mid-2025) |
| reach | runs everywhere | Live 12.2+ users only |
js file.js for v8 file.js, modernize syntax at leisure.v8 device and every Live 11 user can't run it. Target jsfor reach, or label the device "requires Live 12.2+".JSON + Map make LOM-wrangling far less painful. The example below is v8 but works under js too.A whole device, side by side
Reads the selected track's volume, lets you set it from a float, and updates live when you move the fader or switch tracks.
4.1 — Setup & state
// trackvol.js — a minimal Max for Live device. // Shows and controls the volume of the *currently selected* // track, and updates live when you pick a different track or // move the fader. // Runs in a v8 object (Max 9 / Live 12.2+); the legacy js // object works too. autowatch = 1; // reload on save while developing inlets = 1; // inlet 0: a float 0.0–1.0 to set the volume outlets = 2; // outlet 0: track name | outlet 1: volume // You CANNOT create a LiveAPI in global code — only inside a // function, after Live has opened the Set. let volumeObserver = null; let volumePath = null;
LiveAPI in global code: only inside a function, after Live has loaded. Observers are declared empty here and built later inside init.4.2 — Init
// A `live.thisdevice` object bangs once, when the device is
// fully loaded. Wire its bang to this script.
function init() {
const view = new LiveAPI(onTrackChange, "live_set view");
view.property = "selected_track";
onTrackChange(); // prime it for whatever's selected now
}.property registers a live observer. Calling onTrackChange() immediately primes the display for the current selection.4.3 — React to the selected track changing
function onTrackChange() {
const t = new LiveAPI(null,
"live_set view selected_track");
if (t.id === 0) return; // id 0 = "no object"
// .get() ALWAYS returns an array: ["Drums"]
outlet(0, "set", t.get("name")[0]);
volumePath =
"live_set view selected_track mixer_device volume";
if (!volumeObserver)
volumeObserver = new LiveAPI(onVolumeChange);
volumeObserver.path = volumePath;
volumeObserver.property = "value";
}.get returns an array. [0] is the single most common fix.4.4 — React to the volume value changing
// Fires whenever the volume changes — whether we set it,
// the mouse moved the fader, or automation did.
// args looks like ["value", 0.85].
function onVolumeChange(args) {
if (args[0] === "value") outlet(1, args[1]);
}[propertyName, value]. Checking args[0] future-proofs it if you later observe multiple properties with one callback.set, mouse moves, and automation. You don't poll.4.5 — Write back, and a manual refresh
// A float arriving on inlet 0 sets the current track's volume.
function msg_float(v) {
if (this.inlet !== 0 || !volumePath) return;
const vol = new LiveAPI(null, volumePath);
vol.set("value", Math.max(0, Math.min(1, v)));
}
// A bang re-reads everything — handy behind a "refresh" button.
function bang() { onTrackChange(); }value fires onVolumeChange. Fine here, but an observer reacting to its own write can spiral in richer devices. Gate writes when needed.Validating a device
- 1Open the Max Console. Every post() lands here. If it's empty when you act, the messages aren't reaching the script. Check patch wiring before the JS.
- 2Confirm init ran. post a line at the end of init. If you never see it, the live.thisdevice bang isn't wired and every API call afterward is operating on nothing.
- 3Verify property names with .info. Log the owning object's .info and confirm the name exists in your Live version. Silent no-ops from a misremembered property are the most common failure.
- 4Exercise the read path. Select different tracks; name and volume outputs should update. If not, log t.id: 0 means the path resolved to nothing.
- 5Exercise the write path. Send floats in; the fader should move. Send 2.0 and −1.0 to confirm your clamp holds.
- 6Confirm the observer fires from outside. Move the fader with the mouse and with automation. Both should update the output. You're observing, not polling.
- 7Save, close, reopen the Set. The device should re-init cleanly. This catches anything that depended on stale ids.
- 8Watch the thread. Big traversals go behind deferlow. Long work on the main thread freezes Live's UI.
- 9Mind async writes. Loading a device isn't synchronous, so the object appears a tick later. Defer the follow-up read.
Patterns worth keeping
Reusable building blocks
Observe the selection
live_set view selected_track generalizes to any selection-driven tool: device, clip, scene.
Read & set parameters
Walk to a DeviceParameter and use value, min, max, display_value. Covers macros, randomizers, most parameter tools.
Persist a stable handle
Never save an id. Save a name, tag, or browser uri and re-resolve on load. Prefer getvalueof/setvalueof over the outlet/inlet dance.
Load from the browser
Reach live_app browser, walk a category, and call("load_item", …) to drop a device on the selected track.
Reuse observers
There's no clean unregister. Spawning one per event accumulates them and slows Live. One observer, repointed.
Defer heavy work
Big tree walks behind deferlow. Reads after an async call get deferred a tick.
Official resources
Pair these with your own .info dumps. Never trust one source alone.
- Live Object Model reference
Every property, function, and the parent–child hierarchy. Tracks Live 12.3.5.
- Live API Overview
Paths, ids, children: core concepts with the track-volume example.
- Max JavaScript API
js, v8, v8.codebox, v8ui: handlers, threading, File and Task APIs.
- LiveAPI object reference
Constructor, get/set/call, observation, and the global-code restriction.
- Cycling '74 forums & Max Discord
Where undocumented LOM corners get mapped. Search before asking.
One-screen cheat sheet
Address an object
new LiveAPI(null, "live_set view selected_track")
Read (returns an array!)
let name = api.get("name")[0];Write
api.set("value", 0.8);Call a function
browser.call("load_item", "id", itemId);Observe a property
var a = new LiveAPI(cb, path); a.property = "value";
Self-verify the API
post(api.info); // the real property list
Decode a message by type
msg_int / msg_float / bang / msg_symbol / anything this.inlet // which inlet fired
Two hard rules
No LiveAPI in global code. Never persist an id: store a stable handle and re-resolve.
Don't block
Main thread. Heavy walks → deferlow. Reads after async calls → defer a tick.