Programming in Max (a Haskeller's perspective)
Posted on November 22, 2020Introduction
The programming language Max has been around since the mid 80s, as long as Haskell has. As a programming language it is quite interesting. Where Haskell takes purity to its logical extreme, Max does the opposite and takes stateful programming to its logical extreme. Where Haskell is entirely pull based (laziness drives everything, nothing is evaluated unless and until the result demands it), Max is entirely push based: computation is exclusively driven by incoming data.
Today probably one of Max’s most important use cases is as an embedded programming language inside the Digital Audio Workstation (DAW) software Ableton Live, where it is known as Max for Live. Within the context of Max for Live, Max programs can transform MIDI data (“notes”) to new MIDI data (known as a MIDI effect), generate audio data from MIDI data (known as an instrument), or process audio data (an audio effect).
In this blog post we will however ignore such specific use cases, and focus on the language itself. Although this is written from a Haskeller’s perspective, it does not really depend on much Haskell knowledge at all, so perhaps it could also serve as a Max tutorial for programmers.
Fundamentals
Variables
Since Max is a stateful programming language, we expect there to be variables, and indeed there are. Two basic objects are the (i)
and (f)
objects, which store an integer and floating point value, respectively. However, variables aren’t nearly as widely used as one might expect in a stateful language. The reason, perhaps somewhat counterintuitively, is that Max sticks to the stateful paradigm very rigorously. Let me illustrate with an example. Suppose we introduce two variables, add them together, and print the result
(Did I mention Max is a graphical programming language? Max is a graphical programming language.) This example consists of 4 objects: two (i)
objects (variables), with an initial value of 1
and 2
respectively, and then a (+)
and (print)
object, the latter of which prints incoming messages to the Max console.
Enforcing output
So when does anything actually get printed? As mentioned, everything in Max is push-driven: nothing happens unless there is incoming data. One way in which we can force the (+)
object (or indeed most other objects) to output a result is to send it a bang
message. We can use a button
object to help us send such messages: every time the button is pressed, it outputs a bang
:
Push, don’t pull
When we do send the (+)
a bang message, however, we see this printed on the console:
print • 0
The first part is just a label, used to differentiate multiple print
objects; the part after the bullet is the value we’re actually printing: zero. Why zero? Recall that everything in Max is push driven, and everything is stateful. This includes the inputs to (+)
. The (i)
objects did not output their value, and so (+)
did not receive them; (+)
is not “pulling” the value, it must be “pushed”. For example, we can instead connect the button
to both variables instead, to force them to output their value:
When we press the button now, we see
print • 3
Hot and cold inlets
The value 3
seems reasonable enough, but there are a number of subtleties even in this tiny example. For starters, why did we only see one message printed to the console? After all, we sent two bang messages, to both variables, and both of them must have worked, because the (+)
would need to receive both 1
and 2
to come up with 3
.
The answer lies in the concept of “hot” and “cold” inlets. If you look closely at the (+)
object, you see that its first inlet is red (“hot”), and its second inlet is blue (“cold”). Objects that receive message on a hot inlet process that message and then output a message of their own; messages received on cold inlets are processed but do not result in new message being generated.
Message ordering
A moment’s thought reveals that this means that message ordering is rather important in our example: if the left (i)
object would output its value before the right, we’d expect the output to be 1
; after all, the right inlet would not yet have received a value. Did we just get lucky? No: messages are delivered from right-to-left (and bottom-to-top) order (of the receivers); most objects match this convention by having a (single) hot inlet as the left most inlet, and cold inlets to the right.
As an alternative to relying on the layout of our program for message ordering, we can use the trigger
object to be more explicit:
The trigger object takes an incoming message and turns it into a sequence of messages; the number of such messages and their types are indicated by the argument to trigger; here we want two bang
messages. Now we can move the receivers around without affecting message ordering, and we can also change message ordering more easily. For example, if we did want the left (i)
object to receive the bang
first, we can easily swap the order:
And indeed, if we now press the button, we see
print • 1
The stateful nature of (+)
To conclude this section, notice that due to its stateful nature, (+)
itself acts as storage, as indeed do virtually all other objects. This means that the need for variables (objects such as (i)
) is much reduced. For example, consider this simple program where instead of variables, we connect some messages to the inlets of (+)
:
If we now press the 3
message first, then every time we press 2
, we see 5
printed to the console; after pressing 4
, every time we press 2
we see 6
. Typically of course such messages would come from elsewhere in the program.
Compositionality
Encapculation
Compositionality is as important in Max as it is in any other programming paradigm. One way in which we can increase compositionality in Max is by encapsulating bits of code as so-called “patchers” that we can then use in other programs. For example, let’s write a counter object that we can then use in later examples:
The state of the counter is held in the central (i)
object, and is initialized to zero. When the (- 1)
and (+ 1)
objects above it receive a bang
message, they output the old value of the counter, decremented or incremented by 1 respectively, to the (i)
object, thereby updating it.
Recall that everything is stateful. We therefore have to make sure that the (+)
and (-)
objects are updated with the new value of the counter when it changes. To do this, we connect the output of the (i)
object back to the (+)
and (-)
objects, but not before prepending the symbol set
. This is crucial: if we just wired the output of the (i)
object back to the input to the (+)
object, then that would in turn result in the (i)
object being updated and we’d have an infinite loop (Max programmers might call this a “feedback loop”). By sending set n
for some n
, we can update the state of these objects without causing it to output anything; it is a way to turn a hot inlet into a cold inlet.
When we encapsulate code as a reusable patcher, we have to declare what its inlets and outlets are; this is done with the (inlet)
and (outlet)
objects, shown as the (i▸)
and (▸o)
objects within the editor. For our counter, we have connected the two inlets to the (-)
and (+)
objects, and we have connected the output of the counter to the (only) outlet.
Local encapsulation
We can also choose to take part of our code and turn it into a “local patcher”, much like we’d use a where
clause in Haskell. Use of such local patchers can clean up the code a lot. For example, in our counter code, we used (prepend set)
to construct versions of (- 1)
and (+ 1)
with an additional cold inlet. We could hide this logic in two local patchers instead; here is what that would look like for (- 1)
:
The local patcher for (+ 1)
is very similar. We can now write our code as follows:
Not only is the flow much cleaner, we can also indicate as part of our local patch which inlets are hot and cold, making it immediately clear that the first two outputs from the (t)
(trigger) go into a cold inlet and will therefore not cause further output. Note that although local patchers can be given names (here we have used Dec
and Inc
), these names are essentially just comments; if you tried to use another (p Dec)
local patcher elsewhere, that would not be the same patcher.
Conditionals
Of course we can nest patchers arbitrarily. To illustrate this, let’s create an abstraction which we will call a “moat” (using odd terminology just to avoid name clashes with existing concepts in computer science). Messages can only make it across the moat if it is dry; when the moat is filled, it must be drained before messages can be sent across the moat, and it filled multiple times, it must be drained multiple times until it is completely dry.
We can implement this using our counter and a new Max object we have seen yet: (gate)
. Gates are how Max implement conditionals, although Max programmers might prefer to talk about “routing” instead. In general, a gate can have any number of outlets; depending on the state of the gate, incoming messages are routed to one of these outlets, or none at all if the gate is “disabled”. For us, we will only need one outlet: we will disable the gate if the counter is more than zero (“the moat is not dry”):
Every time the value of the counter is changed, we compare it to zero; the result of such a comparison is 0 or 1 (Max does not have a dedicated boolean type); we feed this to the first inlet of the gate, enabling or disabling it. Note that (gate)
is unusual in that its first inlet is cold, not hot.
This example also shows another important object: loadbang
. This object sends out a bang
when the patcher is first loaded; here we are using it to make sure the gate is enabled initially.
Recursion
Fibonacci
Let’s look at some examples of slightly more interesting Max programs. As functional programmers, our instinct will be to wonder about recursion, so let’s consider how we might compute the Fibonacci sequence in Max:
We are two number
objects (shown as (▸n)
in the interface), which are like variables but with an UI element that allows the user to modify the value. Here we are using them to hold the two initial values of our Fibonacci sequence, and we have initialized them to 0
and 1
. The two (i)
objects above them function as delays: they hold the next values in the sequence: the right number
will become the the left number, and the left number
will become the sum of the two old values. We can trigger an update by sending bang
to both (i)
objects using the object, and as we do so repeatedly we will see (both) number
s spell out the Fibonacci sequence; we have essentially implemented the Max equivalent of
= 0 : 1 : zipWith (+) fibs (tail fibs) fibs
Iteration
Every time we press the button, we get the next Fibonacci number, but what if we wanted to know the n
th Fibonacci number instead? We can implement this by adding some control code:
The part in blue is our original unmodified Fibonacci code. On the left, we have added some simple control code: whenever the user modifies the number in the (number)
object, we send a sequence of three messages ((t)
is shorthand for (trigger)
): first, we re-initialize the Fibonacci sequence to start at 0
and 1
again, and then we send the value entered by the user to the (uzi)
object: when this object receives a number n
, it will “fire” n
bang messages; in our case, this means “pressing” the button n
times to compute the n
th Fibonacci number.
If we wanted to output the final number, we can do that by introducing one more (i)
(again, acting as a delay), and trigger a final bang
after the (uzi)
has finished firing:
This illustrates another important concept in Max: messages are processed in depth-first order: the final bang
message to output the result is not generated until the (uzi)
has delivered all of its messages.
Factorial
Our implementation of fibonacci was very imperative: we iterated a computation as often as required, updating local accumulators as went. We can write recursive functions in more direct way by carefully routing messages. As an example, let’s write a function to compute factorial.
Skeleton
To start, I personally often use a structure such as this:
We have an inlet and an outlet as expected, but the inlet feeds into a trigger with a single output; this is just to a convenient point to combine the “normal” inputs with debugging inputs. Similarly, a trigger with a single output is feeding the outlet; again, this is useful to then also route this output to, say, a (print)
object for debugging.
Base case
In Haskell we would define factorial as
fac :: Int -> Int
0 = 1
fac = n * fac (n - 1) fac n
If we want to use a similar strategy in Max, we have to think about how we route the input to the output. For the base case (if the input is zero), we can route the answer 1
straight to the output. Let’s as a first step create a local patcher that implements this logic: given an incoming value, check if it zero; if it, output 1
on the first outlet, otherwise output the original number on a second outlet for further processing:
Plugging it into our skeleton:
Recursive case
For the recursive case, if n
is not zero, we first have to compute factorial of n-1
, and then multiply that by n
. We can implement this quite directly in Max, using a (trigger)
object to be explicit about ordering.
We subtract one from n
, route that back to the input to our program, and then route the output of the program into the cold inlet of our multiplier; after that is done (note that we rely crucially here on the depth-first message processing of Max), we send n
to the hot inlet of the multiplier, sending n * fac (n - 1)
to the output.
Avoiding multiple outputs
With our code as it stands, if we compute the factorial of 3 we will see on the console
print • 1
print • 1
print • 2
print • 6
Ideally we would only produce a single output, but our logic does not have sufficient context to be able to distinguish between “we are computing fac n
for fac (n + 1)
” and “we are computing fac n
because that’s what the user requested”. What we do know locally, however, is that when we do a recursive call, the result of that computation should not make it to the final output. We can use the “moat” abstraction we created earlier to make this happen: every time we do a recursive call, we first fill the moat, and when we are done, we drain it; if the moat is entirely drained, we have reached the top-level and we do indeed want the output:
Alternatively, we can also solve this in the same way that we did for fibonacci, above. I’ll leave that as an exercise for the reader.
Further reading
In this blog post we have only covered the most important principles of the language itself. In order to be able to work with effectively it is of course also necessary to be familiar with the standard library, common patterns, etc. The Max tutorials that come with Max itself are a great introduction to both the language and many of the standard objects, and are required reading for anyone wanting to begin Max programming. I recommend reading them within Max itself (open “Reference” from the “Help” menu, then click on “Docs” and scroll down to the tutorials), so that you can actually experiment with the examples in the tutorials.
Values that we can send across wires in Max include messages (we have seen bang
and set
), integers, floats, and—of particular interest to functional programmers—lists (though not nested lists). The Max tutorials have a chapter on list processing. The Max tutorials by Peter Elsea also include a tutorial on using lists, which I can highly recommend.
Finally, more advanced data structures are implemented through various kinds of objects. Of particular note are the
(bag)
: sets and multisets of numbers(coll)
: key-value store(funbuff)
: pairs of numbers(offer)
: store “one time use” pairs of numbers (the pair is deleted when retrieved)(table)
: 1-dimension array, including a convenient UI for editing them(jit.matrix)
: N-dimensional arrays; this is part of Max standard Jitter library but is useful even if you don’t need anything else from Jitter.
Finally, the Max Cookbook by Christopher Dobrian also contains some useful snippets; if nothing else, it can help to find which objects are available in the Max libraries that might help with the problem you are trying to solve.