The Max Console

This is a follow-up to the JavaScript (V8) in Live tutorial "Real Time MIDI Processing". You should know how to setup a new Max for Live device with a v8 or v8.codebox object as explained in "Getting Started".

To work with JavaScript in Live, we need to be able to understand what our code is doing. One of the best ways to do that is to send messages to the Max Console to check the state of variables and trace the execution of the script. As we saw in previous tutorials, we can use the built in post() function to display messages in the Max Console. This tutorial shows how to use post() to display whatever data you want in an understandable way, which is not always straightforward.

To achieve this, we'll wrap post() with a general purpose log() function for printing information in the Max Console. This is inspired by the console.log() function that's available to JavaScript in web browsers and Node.js. We'll use log() in future tutorials including the next tutorial, "The Live API", where we'll explore how the Live API works.

Finding the Max Console

Before we start coding, let's take a closer look at the Max Console. There are a few ways to open it:

  1. Clicking the icon in the Max patch editor's right sidebar (highlighted blue here):
  2. Opening a dedicated window via the Max patch editor's "Window" menu (note the keyboard shortcut: Command+Shift+M on macOS, Ctrl+Shift+M on Windows):
  3. Under the device's "Show Options" menu in Live (also accessible by right-clicking the device's title bar). Here, the Max Console is called the "Max Window":
    Messages in Live's Max Window are prefixed with the Max for Live device name (for example, V8 in Live Example.amxd • in this screenshot), which helps distinguish logs from multiple devices.

It's important to understand options 1 and 2 open the Max Console in the Max application, and option 3 opens the Max Window in the Live application. They are different things to be used at different times.

The "Max Window" opened from the device in Live is independent of the console opened from the Max patch editor. There's two different copies of your device: The one that runs in Live and the one you edit in Max. Each one has its own console.

When you're actually using your device in Live, opening the "Max Window" from the device's options menu / title bar is a good place to check for errors when something isn't working. Otherwise, when actively editing the device, use the Max Console opened from the patch editor.

Console Features

The Max Console has useful features in its top and bottom toolbars. My most used are:

Try right clicking a message for even more features.

The Built-in post() Function

As explained in "Getting Started", run this JavaScript code using a v8 or v8.codebox object in a new Max MIDI Effect device:

post("Hello");

You should see v8 • Hello in the Max Console.

Let's try printing multiple messages. Run this code:

post("Hello");
post("Goodbye");

Now the Max Console shows v8 • Hello Hello Goodbye on a single line. That's not ideal. We can add explicit newlines:

post("Hello\n");
post("Goodbye\n");

And now the Max Console should look like:

v8 • Hello
v8 • Goodbye

To be exact: The first time you ran it, "Hello" was still be on the same line as the other messages because none of them ended with an explicit newline. Run it twice and, if you've been following along, you now see something like:

Before we move on, note the v8 • prefix indicates the message came from a v8 object. This will always be the case. I'm going to omit this prefix from now on.

A Custom log() Function

I don't know about you, but I don't want to type "\n" at the end of every message I send to the Max Console. Let's introduce our own function for logging information in the console. We'll take advantage of the fact that post() accepts multiple parameters:


function log(message) {
  post(message, "\n");
}

log("Hello");
log("Goodbye");

Now the Max Console prints our log messages on their own lines by default. Nice:

Hello
Goodbye

We should be aware whenever we define functions at the top level like this, they will be callable from the Max patch. With the code right now, we can do things like add a message box such as log "Hi from Max" to the patch, connect it to v8 or v8.codebox, and click it (after locking the patch) to trigger a log() call:

Sometimes this behavior is very useful. It's a big part of building feature-rich interfaces between Max patches and JavaScript. I feel like a logging function is really an "internal utility" though, and I don't want something like this callable from the Max patch. With v8, we can make it not callable from the patch by using arrow function expressions:


const log = (message) => {
  post(message, "\n");
}

log("Hello");
log("Goodbye");

Now attempting to call it from the Max patch results in a "no function" error:

Cool. We can make as many utility/helper functions as we want without unnecessary exposing anything to the Max patch.

Let's try logging different things and see what happens:


const log = (message) => {
  post(message, "\n");
}

log("------------------------------");
log(new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);

We're going to be changing and re-running the code a lot, so I added a divider line "------------------------------" to make it easier to distinguish different runs of the code in the Max Console. I also like to display the current date and time with Date() after that divider line to help me keep track of what's happening. I find it generally useful for JavaScript development in Max.

Here's the output I get for that code:

------------------------------   
jsobject
Hello
2
3.14
inf
nan
1
0
0
0
0
1  2  3
jsobject
1  jsobject

This situation could be better. Date doesn't print properly. 3.14159265359 displays as 3.14. true displays as 1. false, null, and undefined display as 0. jsobject isn't useful.

These issues will impede efforts to debug. Let's do something about it.

Coercing to Strings

Some of the output above appears as jsobject, a representation of some kind of Object. JavaScript has a few ways of coercing objects and other non-string types to strings, and strings are likely to be more readable.

A clean way to coerce anything to a string in modern JavaScript is with string templates, like `${any}`, where any can be almost anything: any variable, constant, literal, or even a complex expression. We can also include the newline in the string template. Let's give it a try. I decided to name the log function parameter from message to any to reflect the fact that we want it to work with anything.


const log = (any) => {
  post(`${any}\n`);
}

log("------------------------------");
log(new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);
------------------------------ 
Wed Dec 04 2024 09:19:07 GMT-0800 (Pacific Standard Time)
Hello
2
3.14159265359
Infinity
NaN
true
false
null
undefined
undefined
1,2,3
[object Object]
1,[object Object]

Some things have improved. Date prints reasonably. Floating point numbers have more precision (3.14159265359). We see true instead of 1. false and null and undefined are displayed instead of 0, and commas are printing in between array items.

There is a problem though. The docs on JavaScript string coercion say "Symbols throw a TypeError.". Throwing an error doesn't sound good, so let's check the behavior in Max


const log = (any) => {
  post(`${any}\n`);
}

log("------------------------------");
log(new Date());
// ... other log() calls ...
log([1, { a: { b: true } }]);
log(Symbol("$"));
------------------------------ 
Wed Dec 04 2024 09:25:36 GMT-0800 (Pacific Standard Time)
...
1,[object Object]
none: TypeError: Cannot convert a Symbol value to a string, line 2
Source line: post(`${any}\n`);
Stack Trace: TypeError: Cannot convert a Symbol value to a string at log (none:2:11) at none:20:1

Confirmed. It's throwing the TypeError. From some experimentation, I think the best solution is to use String(any) to coerce to a string. This behaves very similarly to `${any}` but with the benefit of handling Symbols without errors:


const log = (any) => {
  post(String(any), "\n");
}

log("------------------------------");
log(new Date());
// ... other log() calls ...
log([1, { a: { b: true } }]);
log(Symbol("$"));
------------------------------ 
Wed Dec 04 2024 09:29:46 GMT-0800 (Pacific Standard Time)
...
[object Object]
1,[object Object]
Symbol($)

Excellent.

Let's see what we can do about [object Object].

Using JSON.stringify()

Since String(any) is still giving us problems logging objects, let's try converting them to a JSON string with JSON.stringify(). As an experiment, try it on everything to see what happens:


const log = (any) => {
  post(JSON.stringify(any), "\n");
}

log("------------------------------");
log(new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);
log(Symbol("$")); 		
"------------------------------"
"2024-12-06T05:44:25.191Z"
"Hello"
2
3.14159265359
null
null
true
false
null
0
0
[1,2,3]
{"a":1,"b":2}
[1,{"a":{"b":true}}]
0

Overall, it's not bad. Things like [1,{"a":{"b":true}}] look good. Strings are wrapped in explicit quotes now (like "Hello"), and the Date looks different. Those aren't necessarily problems. Displaying null for Infinity and NaN, and displaying 0 for undefined and Symbols is unfortunate.

There's a bigger problem though: If we blindly use JSON.stringify() like this, it's going to make some logs unusable. Let's try logging a LiveAPI object:


const log = (any) => {
  post(JSON.stringify(any), "\n");
}

log("------------------------------");
log(new Date());
// ... other log() calls ...
log(new LiveAPI('live_set').info);
"------------------------------"
"Wed Dec 04 2024 10:05:52 GMT-0800 (Pacific Standard Time)"
...
"id 1\ntype Song\ndescription This class represents a Live set.\nchildren cue_points CuePoint\nchildren return_tracks Track\nchildren scenes Scene\nchildren tracks Track\nchildren visible_tracks Track\nchild groove_pool GroovePool\nchild master_track Track\nchild view View\nproperty appointed_device NoneType\nproperty arrangement_overdub bool\nproperty back_to_arranger bool\nproperty can_capture_midi bool\nproperty can_jump_to_next_cue bool\nproperty can_jump_to_prev_cue bool\nproperty can_redo bool\nproperty can_undo bool\nproperty clip_trigger_quantization int\nproperty count_in_duration int\nproperty current_song_time float\nproperty exclusive_arm bool\nproperty exclusive_solo bool\nproperty file_path str\nproperty groove_amount float\nproperty is_ableton_link_enabled bool\nproperty is_ableton_link_start_stop_sync_enabled bool\nproperty is_counting_in bool\nproperty is_playing bool\nproperty last_event_time float\nproperty loop bool\nproperty loop_length float\nproperty loop_start float\nproperty metronome bool\nproperty midi_recording_quantization int\nproperty name str\nproperty nudge_down bool\nproperty nudge_up bool\nproperty overdub bool\nproperty punch_in bool\nproperty punch_out bool\nproperty re_enable_automation_enabled bool\nproperty record_mode bool\nproperty root_note int\nproperty scale_intervals IntVector\nproperty scale_mode bool\nproperty scale_name str\nproperty select_on_launch bool\nproperty session_automation_record bool\nproperty session_record bool\nproperty session_record_status int\nproperty signature_denominator int\nproperty signature_numerator int\nproperty song_length float\nproperty start_time float\nproperty swing_amount float\nproperty tempo float\nproperty tempo_follower_enabled bool\nproperty tuning_system NoneType\nfunction capture_and_insert_scene\nfunction capture_midi\nfunction continue_playing\nfunction create_audio_track\nfunction create_midi_track\nfunction create_return_track\nfunction create_scene\nfunction delete_return_track\nfunction delete_scene\nfunction delete_track\nfunction duplicate_scene\nfunction duplicate_track\nfunction find_device_position\nfunction force_link_beat_time\nfunction get_beats_loop_length\nfunction get_beats_loop_start\nfunction get_current_beats_song_time\nfunction get_current_smpte_song_time\nfunction is_cue_point_selected\nfunction jump_by\nfunction jump_to_next_cue\nfunction jump_to_prev_cue\nfunction move_device\nfunction play_selection\nfunction re_enable_automation\nfunction redo\nfunction scrub_by\nfunction set_or_delete_cue\nfunction start_playing\nfunction stop_all_clips\nfunction stop_playing\nfunction tap_tempo\nfunction trigger_session_record\nfunction undo\ndone"

Yikes! That's not going to work. We want to read this information to help understand the Live API.

The Best of Both Worlds

We can combine both approaches. We'll try to use String(message) first, and if that results in any "[object Object]" text, we'll use JSON.stringify() instead.


const log = (any) => {
  let s = String(any);
  if(s.includes("[object Object]")) {
    s = JSON.stringify(any);
  }
  post(s, "\n");
}

log("------------------------------");
log(new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);
log(Symbol("$"));
log(new LiveAPI('live_set').info);
------------------------------   
Wed Dec 04 2024 10:08:56 GMT-0800 (Pacific Standard Time)
Hello
2
3.14159265359
Infinity
NaN
true
false
null
undefined
undefined
1,2,3
{"a":1,"b":2}
[1,{"a":{"b":true}}]
Symbol($)   
id 1 
type Song 
description This class represents a Live set. 
children cue_points CuePoint 
children return_tracks Track 
children scenes Scene 
...

The line that says id 1 is where the new LiveAPI('live_set').info starts printing a lot of information across many lines, most of which I've omitted. Don't worry about what all of that means yet. You'll find out soon. The point is: it is now readable. Mission accomplished.

Multiple arguments

For more flexibility, let's support passing multiple values to log() so we can log things like log(x, y); instead of log(x + " " + y);. Right now, if we do log(1, 2, 3) it only prints 1. We can handle multiple parameters by using spread syntax:


const log = (...messages) => {
  for (const message of messages) {
    let s = String(message);
    if(s.includes("[object Object]")) {
      s = JSON.stringify(message);
    }
    post(s);
  }
  post("\n");
}

log("------------------------------");
log(new Date());
// ...
log(1, 2, 3);
log();
log([1, 2, 3]); // should log the same as before
// ...

Which results in:

1  2  3

1,2,3

Note that calling log(); with no arguments now prints an empty line instead of undefined. I prefer this behavior. It's similar to how console.log() works in web browsers.

At this point we have a pretty good first version of a logging function. Arguably good enough.

Reporting Errors

Max's v8 object has another function similar to post() called error(). It prints to the Max Console with a reddish background to indicate something went wrong. So, for example, maybe you have a try{...} catch(exception) block in your code where you want to log the exception and make it clear it's an error.

We can create another logging function for this. But first, let's take a moment to organize the code to make the next step easier. log() currently has two main responsibilities: (1) converting potentially anything into a human-readable string and (2) printing those strings to the Max Console followed by a newline. We can introduce a second function focused on the string conversion and simplify log():


const toString = (any) => {
  const s = String(any);
  if(s.includes("[object Object]")) {
    return JSON.stringify(any);
  }
  return s;
}
const log = (...any) => post(...any.map(toString), "\n");

log("------------------------------");
log(new Date());
log("Hello");
log(2);
log(3.14159265359);
log(Infinity);
log(NaN);
log(true);
log(false);
log(null);
log(undefined);
log(1, 2, 3);
log();
log([1, 2, 3]);
log({ a: 1, b: 2 });
log([1, { a: { b: true } }]);
log(Symbol("$"));

The results in the Max Console should still look the same.

If you aren't familiar with the Array map() function, it creates an array by calling the given function on all the elements of the target array. We don't need the for (const message of messages) loop anymore.

Adding a warn() function

With our new toString() utility, we're ready to add more logging functions. Since Max's built-in function is called error(), I'll call ours warn():


const toString = (any) => {
  const s = String(any);
  if(s.includes("[object Object]")) {
    return JSON.stringify(any);
  }
  return s;
}
const log = (...any) => post(...any.map(toString), "\n");
const warn = (...any) => error(...any.map(toString), "\n");

log("Everything is ok.");
warn("Something bad happened!");

It's exactly the same code as log() but replaces post() with error().

It looks like this in the Max Console:

Renaming to error()

It's confusing we didn't name the function error() because they are called errors in the Max Console (see the "Show Only Errors" button in the Max Console's bottom toolbar). If we want to name our function error(), it appears we'd overwrite the built-in error() function:


const error = (...any) => error(...any.map(toString), "\n");

log("Everything is ok.");
error("Something bad happened!");

When we try to call the built-in function, we are recursively calling the function we defined, which creates an infinite loop:

Everything is ok.
none: RangeError: Maximum call stack size exceeded, line 9
Source line: const error = (...any) => error(...any.map(toString), "\n");
Stack Trace: RangeError: Maximum call stack size exceeded at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27) at error (none:9:27)

Thankfully, there's a simple solution. We didn't actually overwrite the built-in error() function. When we define a function, it's defined in the top-level scope for the v8 object, but that isn't the global scope. We can still access the built-in functions in the global scope by explicitly referencing them via globalThis:


const error = (...any) => globalThis.error(...any.map(toString), "\n");

log("Everything is ok.");
error("Something bad happened!");

And now it works again:

Everything is ok.
Something bad happened!

I typically don't use error() in these tutorials, but you may find it useful, especially in large complex projects. The "Show Only Errors" button in the Max Console's bottom toolbar toggles between showing only errors or showing all messages, which helps focus on problems when you're logging a lot of stuff.

One benefit of error logging is it can aid with debugging remotely: If you share your patch with someone and it's not working, have them open the Max Window (from the Device's title bar menu), click "Show Only Errors" , and tell you what they see. Even if you aren't explicitly logging errors, any unhandled exceptions should be displayed in the Max Console. This can help pinpoint the problem.

Final "1.0" Version and Future Improvements

For reference, here's the final version of the logger code from this article, with support for multiple arguments and logging errors. I frequently drop add this to the top of the code in a new JavaScript in Max/Live project.

I rewrote the if(s.includes("[object Object]")) {...} part to use the conditional operator to make it more compact. And as I mentioned earlier, I often print the divider line and current date to help me keep track of different runs of the code. I've combined that into one line to save a little space.


const toString = (any) => {
  const s = String(any);
  return s.includes("[object Object]") ? JSON.stringify(any) : s;
}
const log = (...any) => post(...any.map(toString), "\n");
const error = (...any) => globalThis.error(...any.map(toString), "\n");
log("------------------------------------------------\n", new Date());

There is a lot more we could do with this logging logic. In the previous tutorial, "Real Time MIDI Processing", we used a Map object. These won't print very well with our current logger. Handling this properly is complicated, so a separate article is dedicated to it: "Max Console Part 2: Better Logging for Objects". We'll come back to that later.

Next Steps

Now that we're able to print out information from our JavaScript code, we can use this ability to learn the Live API in the next tutorial, "The Live API".

Table of Contents:
  1. JavaScript (V8) in Ableton Live Overview
  2. Getting Started
  3. Real Time MIDI Processing
  4. The Max Console
  5. The Live API
  6. Generating MIDI Clips
  7. Max Console Part 2: Better Logging for Objects