Designing a custom Push2 instrument in Max for Live/JavaScript: Part 1
Posted on December 26, 2020As any Ableton Push user knows, the layout of the Push is different when you are using a standard instrument and when you are using a drum rack. In this two-part tutorial we are going to explore how we can design our own instruments that use a completely custom Push layout, but yet integrate well with the normal Ableton work flow.
I have a short video on YouTube demonstrating what the instrument does and how it is used.
We are going to design a Max for Live MIDI effect that works as a new kind of instrument. In order for this device to show a custom layout on the Push, it must take control over the Push’s button matrix. However, we want to take control only if the user is currently using our instrument; if they select a different track, we should relinguish control over the Push so that Ableton can automatically switch back to the default layout for that track. In part 1 of this series we will therefore develop a simple patcher that allows us to keep track of whether the track containing our device is currently selected or not. Part 2 will then develop the actual instrument.
We will assume familiarity with Max for Live, although we will barely make use of any Max for Live features; most of the development will be done in JavaScript. For a brief “for programmers” introduction to Max (along with pointers to further info), you could refer to my blog post Programming in Max; if you need a refresher on JavaScript, my blog post on the core JavaScript semantics may be helpful. There is a list of additional JavaScript in Max for Live resources at the end of this blog post.
Demo
If you download ourtrack.demo.amxd, place it on a MIDI track, and then open the patch, you should see something like this:
If you select the track that the device is on, the LED will be on; if you select another track, the device will be off. If you move the device to another track, you will see the displayed track number changing.
Top-level patcher
When you exit presentation mode, you will see that the top-level patcher is extremely simple:
This is really just a wrapper around the JavaScript code:
- We are routing MIDI in straight to MIDI out; the effect is not doing anything interesting.
- The brain of the patcher is the js object, which is loading the JavaScript code that we will discuss in the rest of this tutorial.
- We use a live.thisdevice to send a bang to our (only) inlet; we will use this to run some initialization code when the Max for Live device has been fully loaded.
- We have two outlets: every time the track of our device is selected, deselected, or changed, the device will output the number of that track on the second outlet, and a boolean indicating whether or not our track is selected on the first outlet.
Top-level JavaScript code
The device consists of two JavaScript files. The first is the code loaded by the [js]
object shown above. It is just as simple as the patcher:
= 1;
inlets = 2;
outlets
var OurTrack = require("ourtrack").OurTrack;
var ourTrack = null;
function bang() {
if(ourTrack == null) {
= new OurTrack(this, trackChanged);
ourTrack
}
}
function trackChanged(trackNo, selected) {
outlet(1, trackNo);
outlet(0, selected);
}
There is not much to say here:
- We declare how many inlets and outlets we use.
- We import the
ourtrack
module. Max for Live supports modules in the standard CommonJS 1.0 style (although it seems that module hierarchies are not supported, so store all modules in the same directory). - We declare a patcher global
ourTrack
but don’t initialize it yet. As we will see, the initialization of theOurTrack
object involves the use of theLiveAPI
object, which cannot be used until the Max for Live device is fully initialized. - In the
bang
function we then actually initialize the patcher globalourTrack
; theOurTrack
constructor takes as argument a callback to invoke whenever our track is selected, deselected, or changed: we pass thetrackChanged
function. - The
trackChanged
function itself just outputs the two values on the two outlets.
The interesting work happens in the ourtrack
module, so let’s focus on that now.
The OurTrack
object
The OurTrack
object stores the track number of the device and whether or not that track is selected. It exports two functions to access this data:
.OurTrack.prototype = {
exportsgetIsSelected: function() {
return this.selected;
}
, getTrackNo: function() {
return this.trackNo;
}
, ...
}
Function update
is more interesting:
: function(object, callback) {
updatevar ourTrack = new LiveAPI(null, "this_device canonical_parent");
var selectedTrack = new LiveAPI(null, "live_set view selected_track");
var selected = ourTrack.id == selectedTrack.id;
var trackNo = parseInt(ourTrack.unquotedpath.split(" ")[2]);
if(this.selected != selected || this.trackNo != trackNo) {
this.selected = selected;
this.trackNo = trackNo;
.call(object, trackNo, selected);
callback
} }
We first use the LiveAPI object to resolve two paths:
live_set view selected_track
is the currently selected track.Although we use the path
live_set view selected_track
to find this object, when we subsequently ask that object for its path (using.unquotedpath
), it will report something different. The reason is thatlive_set view selected_track
is not a “canonical” path. You could compare canonical paths to absolute paths in a file system: just like../b
and/a/b
might be the same file (if we are currently in a directory/a/c
), in Live the pathslive_set view selected_track
andlive_set tracks 3
might refer to the same object (if the fourth track is currently selected); the path reported by the object will always be the canonical path.this_device canonical_parent
is the canonical path to the parent of the current device.Here, “current device” refers to the Max for Live device, and its parent is the track on which it lives.
The remainder of the function is relatively simple: we extract the current track number (recall that the path of the current track will be something like live_set tracks 3
, and so we want to extract the 3
from that string), and we conclude that if the object ID of the currently selected track and our parent track are equal, then our track is currently selected. If either the track number or whether or not we are selected is changed, we invoke the callback (using call to invoke the function on the right object).
Responding to changes
The most interesting part of the code is the OurTrack
constructor, in which we set up some observers that listen to changes: function update
should be called automatically whenever the user selects another track or moves the device from one track to another.
In order to monitor changes, we construct a LiveAPI
object with a callback and a path
or a property
to monitor; the callback is then automatically invoked whenever the path or the property changes. For our patcher, we want to be notified if our device’s parent changes, and when the selected track changes.
Unfortunately, we cannot monitor relative paths; this means that we cannot monitor the path this_device canonical_parent
. What we can do, however, is change our perspective:
- We first resolve the path
this_device
to a canonical path; this will give us something likelive_set tracks 0 devices 2
. - We then monitor that path; if the object at that path changes, it must mean that our device has been moved (in this case, there will either be a different device at this path, or none at all, in which case we will get ID 0).
- Finally, every time we detect a change, we must then again figure out what the new path to our device is, and then update the path that we are monitoring.
The last thing we need to take care of is the mode
of the LiveAPI
object. Suppose we are initializing the object using a path such as live_set tracks 0 devices 2
; now there are two possibilities:
- If the
mode
is 0 (the default), we stay with the object, even if that object gets moved. Thus, the object will stay the same, but its (canonical) path may change. - If the
mode
is 1, we stay with the path: when objects or devices are moved around in the Ableton interface, we are notified what the new object is at this path. In other words, in this mode the path stays the same, but the object may change.
For our use case, we want to know when there is a different device at this path, and so we need to set the mode to 1.
The relevant part of the constructor is:
var outerThis = this;
var initialPath = new LiveAPI(null, "this_device").unquotedpath;
this.monitorTrack = new LiveAPI(function(args) {
var currentPath = new LiveAPI(null, "this_device").unquotedpath;
if(currentPath != outerThis.monitorTrack.unquotedpath) {
.monitorTrack.path = currentPath;
outerThis.update(object, callback);
outerThis
};
})this.monitorTrack.path = initialPath;
this.monitorTrack.mode = 1;
Fortunately, in order to monitor the currently selected track we have to do much less work because in this case there is a property we can observe:
this.monitorSelected = new LiveAPI(function(args) {
if(args[0] == "selected_track") {
.update(object, callback);
outerThis
};
})this.monitorSelected.path = "live_set view";
this.monitorSelected.property = "selected_track";
The only other thing to do in the constructor is the initial call to update
, and we’re done.
Deleting observers
There is one final complication to take care of, which isn’t so important in this demo but will be important later. We had written our initialization code as
function bang() {
if(ourTrack == null) {
= new OurTrack(this, trackChanged);
ourTrack
} }
If for some reason we wanted to initialize a new OurTrack
object every time we receive a bang message, letting the old one be garbage collected, we must write this as
function bang() {
if(ourTrack != null) {
.deleteObservers();
ourTrack
}
= new OurTrack(this, trackChanged);
ourTrack }
If we don’t do this, then the observers and their callbacks of the old object will stay active, and so each time we run this initialization code we will accumulate more and more observers.
Fortunately, actually removing the observer is not difficult: if we are observing a property
, we must clear it; if we are following a path, we must clear the mode:
: function() {
deleteObserversthis.monitorTrack.mode = 0;
this.monitorSelected.path = ""; // Setting to 'null' does not work!
}
This can be especially important during development, if we are reloading our JavaScript code frequently. In part 2 of this series we will set up some convenient infrastructure to make this a little easier.
Conclusions
In part 1 of this tutorial series we laid down some groundwork: we now have a way to reliably detect when the track that our Max for Live lives on is selected and deselected. We will use this to grab control over the Push’s button matrix whenever we user is using our instrument, and relinguish control whenever they switch to another track.
Although it is useful, the patcher we developed in this part wasn’t particularly exciting. Time to actually start developing our custom instrument in part 2!.
Additional resources
If you are completely new to JavaScript in Max for Live, it is probably a good idea to start with the chapters on JavaScript in the Max Tutorials. These tutorials are pretty good, and a must-read when getting started.
Javascript in Max is a collection of reference material for many of the available JavaScript objects.
Of particular relevance for this this tutorial is the description of the LiveAPI Object. You should also read the Live API Overview, and the Live Object Model is a useful reference; neither of these two is JavaScript specific however.
Also not JavaScript specific but sometimes a useful source of inspiration (if only to point you in the right direction) is the set of Live API Abstractions, which are example Max for Live patchers showing how to interact with the Live API. They can be accessed directly in Max itself under Extras, Max for Live API Abstractions.
Although I haven’t tried it yet, the Live API explorer, a patcher for exploring the Live API in a GUI, seems like it could be rather useful.
When the documentation doesn’t answer your questions, the Cycling ’74 Max for Live forums are your friend; they contain a wealth of information, and I refer to them all the time.
Adam Murray has written a number of JavaScript Live API Tutorials which have some useful information.
If you want to distribute your Max patcher, you should freeze it to bundle it with its dependencies; see Freezing Max for Live Devices. What that page does not mention is that if you have additional files to be included in the frozen device (such as external JavaScript modules!), you should add them to the project by clicking on the “Show Containing Project” button of the Max window and then adding any additional files that should be included. (There also seem to be some implicit conventions for included directories but so far I’ve managed to avoid these.)
In addition, maxforlive.com provides some guidelines for sharing Max for Live devices that are worth a read.
Unrelated to Max specifically, jsdoc.app is a useful reference for formatting JavaScript comments to document your classes, along with the description of JavaScript types of Google’s closure compiler for JavaScript.