Real Time MIDI Processing
This tutorial builds on the setup from "Getting Started". You should be comfortable creating Max
for Live devices with js
objects in them. Before we get into the details of
debugging with the Max Console and using the Live API, let's jump into building
something.
We will alter MIDI in real time to change the pitch being played based on a simple set of rules. This means we can play a MIDI clip in Live or physically play some notes on MIDI controller hardware and JavaScript will change the pitches being played as they are occurring.
Here's the idea, unimaginatively called octave-switcher.js
:
The first time any pitch repeats between consecutive notes, play it one octave up. The second time a pitch repeats (in a row), play it one octave down. The third time the pitch repeats, repeat the pattern by playing the original octave again, and so on: normal, up, down, normal, up, down, etc. And whenever the pitch changes, the octave resets to normal. For example, if we started with this MIDI clip:
It will look like this after our JavaScript processes it:
Look closely and notice the notes are slightly delayed (check the left edge of the first note in the two screenshots). That's because real time processing adds delay, referred to as latency in this context. As discussed in limitations, JavaScript is relatively slow and will inherently add some latency. I also had the device open in the Max editor when I recorded the processed MIDI in the screenshot above. Actively editing a Live device with the Max editor adds a lot more latency than using the Max device directly inside Live. So keep that in mind: if you have too much latency and you are editing your Max patch, try saving and closing the Max editor, and test again directly in Live.
Real Time Note Events
MIDI note events have a pitch and velocity values (velocity represents how intensely the note is played), and they come
in two general flavors: "note on" events representing the start of a note, and "note off" events representing the end of
a note. If you've ever handled keyboard events in a web app, this is analogous to
keyDown
and keyUp
events. In a Live
MIDI clip, the left edge of a note in the piano roll is the "note on" event and the right edge is the "note off" event.
Our JavaScript code will see a separate "note on" and "note off" event for every note in the piano roll.
It's important to understand this because later we'll be working with notes in non-realtime and we can work with note objects that have a start time and duration. The real time situation is completely different: there is no start time because everything is happening now (from the code's perspective), and we can't know a duration because the "note off" event that determines duration hasn't happened yet (when we're processing "note on" events).
How do we know the difference between a "note on" and a "note off" event? By convention, when
velocity == 0
it's a "note off", and when
velocity > 0
, it's a "note on" event. Negative pitches and velocities are
invalid and unlikely to be encountered, so you can ignore them.
Intercepting MIDI Notes Events
Ok, enough theory. Let's figure out how to pass all MIDI data through our Live device while intercepting note events. A
fresh Max MIDI Effect device starts with a simple MIDI passthrough that connects a
midiin
object directly to a midiout
object:
To intercept the note events while continuing to pass through everything else, we need to split apart the raw MIDI data
stream into the different types of MIDI events. We do that by inserting a midiparse
object after the midiin
. We then immediate recombine all the separated MIDI events into
a single MIDI data stream with a midiformat
object that connects all the outlets from
midiparse
(with the exception of the rightmost) to its corresponding inlets, and
continue to use midiout
for the final output, like this:
Now is a good time to check that MIDI is in fact passing through the device. Do this:
- Save the device if you haven't already, for example:
octave-switcher.amxd
- Add a MIDI clip to the track containing your device
- Add some notes to the clip
- Add an instrument to the track if you haven't already
- Instrument selection tips: We will encounter bugs with stuck notes, so it's useful to choose an instrument that plays long sustained notes so the problem can be easily heard. To test things, we'll play clips with lots of short notes, so a fast attack works better than a slow drone/pad sound. A preset for Ableton's built-in Drift instrument called "Synthetic Xylophone" works well.
- Play the clip
You should hear the instrument playing the notes:
All good? If not, debug it before we go any further.
Back to the patch: The leftmost outlet of midiparse
outputs the MIDI note events we are
interested in. If you hover over it, you should see a tooltip:
This is telling us that outlet sends two-item lists containing pitch and velocity that represents "note on" and "note
off" events. Let's take a look: Add a message object (by dragging from the top toolbar or pressing 'm') and connect the
midiparse
leftmost outlet to the right inlet of the message object. Play the MIDI clip
again and you can see the note data, which is two numbers:
That's the pitch and velocity. This screenshot shows "60 100", which is a "note on" event with a pitch of 60 and a velocity of 100.
We intercept this data by routing it through the js
object, like this:
That's the complete Max patch structure, but the data in not passing through the js
object because we haven't written any code yet. So how do we pass that data from the js
inlet to its outlet? Remember, the input is a two-number list containing the pitch and velocity. In
js
, we define a handler for list input by defining a
list()
function, and access the arguments with the
arguments
array. If you open up the js
help
file, the example script in there shows examples of handling various types of input. For our case, we want to do this:
function list() {
var pitch = arguments[0];
var velocity = arguments[1];
post("pitch=" + pitch + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
}
After we've read the pitch
and
velocity
, we can output them as a two item list with
outlet(0, pitch, velocity);
, where 0
is the outlet index (we can configure js
with multiple outlets, but that's a topic for
later). Note: this is equivalent to outlet(0, [pitch, velocity]);
but it's
unnecessary and inefficient to create an array like that.
Now with the Max patch editor still open, you can open the Max Console and watch the JavaScript code log the pitch and velocity data when you play the MIDI clip again:
pitch=60, velocity=100 pitch=60, velocity=0 pitch=60, velocity=100 pitch=60, velocity=0 pitch=65, velocity=100 pitch=65, velocity=0 pitch=65, velocity=100
Detecting Note On Events and Repeated Pitches
As discussed above, "note on" events have a velocity higher than zero, which is easily checked in code:
function list() {
var pitch = arguments[0];
var velocity = arguments[1];
if (velocity > 0) {
post("Note on\n");
} else {
post("Note off\n");
}
post("pitch=" + pitch + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
}
To detect repeat pitches, we can define a top-level variable outside of the function to remember state between different
function calls. At the end of each call to our list()
handler, we'll store the
current pitch
in the previousPitch
top-level variable. Then, our logic can check the incoming current pitch
is the
same as previousPitch
to detect repeated pitches.
For the purpose of checking the pitch of consecutive notes, it doesn't make sense to check "note off" events, because they represent the end of an existing note instead of a new note. Therefore, we put the logic to check for repeating pitches inside the conditional block for "note on" events:
var previousPitch = null;
function list() {
var pitch = arguments[0];
var velocity = arguments[1];
if (velocity > 0) {
post("Note on\n");
if (pitch == previousPitch) {
post("Repeat pitch: " + pitch + "\n");
}
} else {
post("Note off\n");
}
post("pitch=" + pitch + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
previousPitch = pitch;
}
We're adding all the post()
calls so we can confirm things are working as
intended in the Max console logs. Does it look like what you expect?
Note on pitch=60, velocity=100 Note off pitch=60, velocity=0 Note on Repeat pitch: 60 pitch=60, velocity=100 Note off pitch=60, velocity=0 Note on Repeat pitch: 60 pitch=60, velocity=100
Cycling Octave States
Let's define some top-level constants (still defined using var
because this is
an old version of JavaScript) to represent the normal pass-through, octave up, and octave down states that we'll cycle
through on repeat pitches:
var PASS_THROUGH = 0;
var OCTAVE_UP = 1;
var OCTAVE_DOWN = 2;
And add a top-level state
variable to store the current state, which defaults
to the normal pass-through:
var state = PASS_THROUGH;
When a repeat pitch is detected, we want to cycle through the states like this:
if (state == PASS_THROUGH) state = OCTAVE_UP;
else if (state == OCTAVE_UP) state = OCTAVE_DOWN;
else state = PASS_THROUGH;
This can be expressed much more succinctly as:
state = (state + 1) % 3;
but I think the more verbose version is much clearer about the intent, so for the purpose of this tutorial, I'll keep using it for clarity.
All those changes together look like:
var PASS_THROUGH = 0;
var OCTAVE_UP = 1;
var OCTAVE_DOWN = 2;
var state = PASS_THROUGH;
var previousPitch = null;
function list() {
var pitch = arguments[0];
var velocity = arguments[1];
if (velocity > 0) {
post("Note on\n");
if (pitch == previousPitch) {
if (state == PASS_THROUGH) state = OCTAVE_UP;
else if (state == OCTAVE_UP) state = OCTAVE_DOWN;
else state = PASS_THROUGH;
post("Repeat pitch " + pitch + ", state → " + state + "\n");
}
} else {
post("Note off\n");
}
post("pitch=" + pitch + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
previousPitch = pitch;
}
Continuing to test with the logs:
Note on pitch=60, velocity=100 Note off pitch=60, velocity=0 Note on Repeat pitch 60, state → 1 pitch=60, velocity=100 Note off pitch=60, velocity=0 Note on Repeat pitch 60, state → 2 pitch=60, velocity=100 Note off pitch=60, velocity=0 Note on Repeat pitch 60, state → 0 pitch=60, velocity=100
Ok! Let's actually change the octave based on the state
:
var PASS_THROUGH = 0;
var OCTAVE_UP = 1;
var OCTAVE_DOWN = 2;
var state = PASS_THROUGH;
var previousPitch = null;
function list() {
var pitch = arguments[0];
var velocity = arguments[1];
if (velocity > 0) {
post("Note on\n");
if (pitch == previousPitch) {
if (state == PASS_THROUGH) state = OCTAVE_UP;
else if (state == OCTAVE_UP) state = OCTAVE_DOWN;
else state = PASS_THROUGH;
post("Repeat pitch " + pitch + ", state → " + state + "\n");
}
if (state == OCTAVE_UP) pitch += 12;
else if (state == OCTAVE_DOWN) pitch -= 12;
} else {
post("Note off\n");
}
post("pitch=" + pitch + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
previousPitch = pitch;
}
Handling Note Off Events
Most of the repeat pitch detection and octave-switching logic is in place, but there is a major flaw. Earlier we were discussing how every note comes in a "note on" + "note off" event pair. A key aspect of "note off" events is they always have the same pitch as their corresponding "note on" event. Whenever a "note off" occurs, it ends whatever "note on" was playing at that pitch.
See the problem? We are only changing the pitch for "note on" events, so the notes can no longer end properly.
We can investigate this by creating another MIDI track, setting its input to the track with our Max for Live device, and recording the MIDI output from our device. When you setup your track for recording, be sure to select "Post FX" in the input / "MIDI From" settings. If you select "Pre FX", it will bypass the Max for Live device. Don't forget to arm the track for recording:
The recorded notes should be the same duration as the original MIDI clip. In my case all original notes were short 16th notes. Some of the notes are playing for way too long. The long notes are the ones where we changed the pitch. You may even have stuck notes. If that happens, hit the stop button in Live's transport to send an "all notes off" message to everything.
To fix this, we need to keep track of how we are mapping "note on" pitches from their original pitch to the modified pitch, and apply the same mapping for "note off" events. A basic JavaScript object works as a map data structure:
var noteOffMap = {};
To keep track of whether the pitch is changing, it's convenient to introduce another variable for the incoming pitch that will not change:
function list() {
var pitch = arguments[0];
var originalPitch = pitch;
Then, after we might have changed the pitch, we check if we actually changed the pitch for this note. If so, we then
keep track of how we mapped the original pitch to the modified pitch in our
noteOffMap
data structure:
if (state == OCTAVE_UP) pitch += 12;
else if (state == OCTAVE_DOWN) pitch -= 12;
if (pitch != originalPitch) {
noteOffMap[originalPitch] = pitch;
}
Now we are ready to implement the "note off" behavior. If the pitch was modified for a "note on", make the same modification to the corresponding "note off". We don't want to keep applying the modification, so we also remove this entry from the map:
} else {
post("Note off\n");
var modifiedPitch = noteOffMap[originalPitch];
if (modifiedPitch != null) {
pitch = modifiedPitch;
noteOffMap[originalPitch] = null; // remove the mapping
}
}
To help test all this new logic, I updated the main log line to show when a pitch is being changed:
post("pitch=" +
(pitch == originalPitch ? pitch : originalPitch + " → " + pitch) +
", velocity=" + velocity + "\n");
At this point there's still a bug and this tutorial is getting long, so I'll jump to the solution. I encourage you to
experiment and record the output of the device again and make sure you understand why everything is happening the way it
is. Add more post()
logging if you need to.
Anyway, the remaining bug is we want to detect repeated pitches with respect to the input, but we sometimes change the
value of pitch
, and then we end our function with
previousPitch = pitch;
. The problem is
pitch
is no longer the input pitch if we changed it. The solution is to ensure
we use the original input pitch with: previousPitch = originalPitch;
Wrapping Up
Here's all the changes for handling "note off" events properly and the
previousPitch
bug fix. I cleaned up, removed most of the logging and added a
few comments. This is the final version of octave-switcher.js
:
var PASS_THROUGH = 0;
var OCTAVE_UP = 1;
var OCTAVE_DOWN = 2;
var state = PASS_THROUGH;
var previousPitch = null;
var noteOffMap = {};
function list() {
var pitch = arguments[0];
var originalPitch = pitch;
var velocity = arguments[1];
if (velocity > 0) { // handle note on:
if (pitch == previousPitch) { // cycle state for repeated pitch:
if (state == PASS_THROUGH) state = OCTAVE_UP;
else if (state == OCTAVE_UP) state = OCTAVE_DOWN;
else state = PASS_THROUGH;
}
else { // reset state for changed pitch:
state = PASS_THROUGH;
}
if (state == OCTAVE_UP) pitch += 12;
else if (state == OCTAVE_DOWN) pitch -= 12;
if (pitch != originalPitch) {
noteOffMap[originalPitch] = pitch;
}
}
else { // handle note off:
var modifiedPitch = noteOffMap[originalPitch];
if (modifiedPitch != null) {
pitch = modifiedPitch;
noteOffMap[originalPitch] = null;
}
}
// post("pitch=" + (pitch == originalPitch ? pitch : originalPitch + " → " + pitch) + ", velocity=" + velocity + "\n");
outlet(0, pitch, velocity);
previousPitch = originalPitch;
}
Next steps
The next tutorial, "The Max Console", shows how to better use
post()
and the Max console to inspect and debug JavaScript programs running in
Max for Live. Tired of ending every post()
call with
"\n"
? Read on!