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:
- Clicking the icon in the Max patch editor's right sidebar (highlighted blue here):
- 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):
- 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:
- Clear all messages. This gets used frequently before saving changes to a script. It gives a "clean run" in the Max Console logs.
- Search the messages. Logged too much and want to confirm a log line was executed? Search for it.
- Highlight the object in the Max patch that printed the message via the "Show Object" button in the bottom toolbar or
by double clicking any message. This is helpful with multiple
v8
objects or a deep patcher hierarchy.
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 2Source 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 9Source 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".