Designing a custom Push2 instrument in Max for Live/JavaScript: Part 1

Posted on December 26, 2020

As 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:

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:

inlets  = 1;
outlets = 2;

var OurTrack = require("ourtrack").OurTrack;
var ourTrack = null;

function bang() {
  if(ourTrack == null) {
    ourTrack = new OurTrack(this, trackChanged);
  }
}

function trackChanged(trackNo, selected) {
  outlet(1, trackNo);
  outlet(0, selected);
}

There is not much to say here:

  1. We declare how many inlets and outlets we use.
  2. 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).
  3. We declare a patcher global ourTrack but don’t initialize it yet. As we will see, the initialization of the OurTrack object involves the use of the LiveAPI object, which cannot be used until the Max for Live device is fully initialized.
  4. In the bang function we then actually initialize the patcher global ourTrack; the OurTrack constructor takes as argument a callback to invoke whenever our track is selected, deselected, or changed: we pass the trackChanged function.
  5. 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:

exports.OurTrack.prototype = {
  getIsSelected: function() {
    return this.selected;
  }

, getTrackNo: function() {
    return this.trackNo;
  }

, ...
}  

Function update is more interesting:

  update: function(object, callback) {
    var 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;
      callback.call(object, trackNo, selected);
    }
  }

We first use the LiveAPI object to resolve two paths:

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:

  1. We first resolve the path this_device to a canonical path; this will give us something like live_set tracks 0 devices 2.
  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).
  3. 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:

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) {
    outerThis.monitorTrack.path = currentPath;
    outerThis.update(object, callback);
  }
});
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") {
    outerThis.update(object, callback);
  }
});
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) {
    ourTrack = new OurTrack(this, trackChanged);
  }
}

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) {
    ourTrack.deleteObservers();
  }

  ourTrack = new OurTrack(this, trackChanged);
}

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:

deleteObservers: function() {
  this.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