Max Console #2: Logging Improvements
As of April 2025, this tutorial requires Live 12.2 Beta,
or the standalone version of Max 9
(not included with Ableton Live Suite) to use the new v8
JavaScript object
(see here for standalone Max 9 setup).
Now that we're done learning about MIDI Tools in "MIDI Tools #1: Transformations" and "MIDI Tools #2: Generators", let's revisit how to use logging to debug our code. This is a follow-up to the JavaScript (V8) in Live tutorial "The Max Console". We'll enhance the log function to display various data structures better.
Problems Printing Data Structures
The v8
object supports
all the standard built-in object in modern JavaScript.
This includes data structures like
Map and
Set, which are very useful for
all sorts of algorithms. For example, we used Map
in
"Real Time MIDI Processing". Unfortunately our logging code isn't handling them very
well. Let's take a look.
Note: I am omitting our error()
function as we work some improvements. I'll show it
again at the end of this article.
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(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
We can see we have a Map
and a Set
, but can't see any
of their contents:
[object Map] [object Set]
My first attempt was to use JSON.stringify()
on these any time we see
"[object "
(instead of "[object Object]"
):
const toString = (any) => {
const s = String(any);
if(s.includes("[object ")) {
return JSON.stringify(any);
}
return s;
}
But this makes things worse:
{} {}
Now we can't even tell they are a Map
and a Set
. Part
of the problem is a Map
can use anything as a key, but JSON requires all keys to be
strings. So there's no fully automatic way to convert a Map
to JSON. I suspect the
reason Set
doesn't automatically convert to JSON is because it would be
indistinguishable from an Array. It is up to you the developer to convert Map
and
Set
to JSON the way you want to by writing custom code (or using a third-party
library/framework).
Constructor Names
One thing that helps is printing the type of object we have. The way to do that in JavaScript is with an object's
constructor.name
:
const toString = (any) => {
const s = String(any);
if(s.includes("[object ")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
Now we can distinguish Map
and Set
again, but we still
can't see their contents:
Map{} Set{}
We have been using JSON as a shortcut for converting to strings. Now it is getting in the way. Let's stop trying to use
JSON here and focus on how we want to convert Map
and
Set
to strings directly.
Pretty Printing Set
and Array
We can add special case logic for any types we want to "pretty print". How do we setup conditional logic in JavaScript based on a variable's type? JavaScript uses an object prototype system to provide object-oriented-style programming. So an object's prototype represents which type of object it is.
We can get the object's prototype with
Object.getPrototypeOf(any),
but we have to be careful because it throws an exception if you call it with null
or
undefined
. We can default null
and
undefined
to something that will return a reasonable value using
nullish coalescing. If
you call Object.getPrototypeOf()
with a prototype instead of an object, it returns
null
, which seems like a good default behavior. In summary, we can get the type of a
JavaScript object with:
Object.getPrototypeOf(any ?? Object.prototype)
We can use this expression to switch on an object's type and special case Set
to return
a custom string representation:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Set.prototype:
const array = [...any];
const commaSeparatedList = array.join(", ");
return `Set(${commaSeparatedList})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
I also changed s.includes("[object ]")
back to s.includes("[object Object]")
because it works better in a lot of cases. We'll leave the any.constructor.name
there because it has some benefits that we'll see in the final round of testing.
The expression [...any]
is an idiom
in JavaScript for converting any iterable object to an array. Once we have an array, it is straightforward to display it
as a string using functions like Array's join()
. I decided to format it like this:
[object Map] Set(1, 2, 3)
The code above was written for clarity but it's a little verbose. We can write it more compactly like:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Set.prototype:
return `Set(${[...any].join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
While we're here, I don't love that things like log([1,2,3])
display as
1,2,3
. We can add a special case arrays to make it clearer they are arrays:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.join(", ")}]`;
case Set.prototype:
return `Set(${[...any].join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, 2, 3, 2]));
log([1,2,3]);
[object Map] Set(1, 2, 3) [1, 2, 3]
Looking good!
Nested Data Structures
What happens with Arrays inside Arrays, Sets inside Arrays, Sets inside Sets, etc?
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,[3]]]);
Set(1, 2,3, [object Set]) [1, 2,3]
It's not quite what I was hoping for. Recursion to the rescue:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
Similar to how we handle the list of parameters to log()
, we use
map()
to call toString()
on every element in an
Array
or Set
. Even if we have an
Array
that contains an Array
that contains another
Array
, it will recursively call toString()
all the way down to
the innermost elements and ensure everything is converted to strings properly:
[object Map] Set(1, [2, 3], Set(2)) [1, [2, [3]]]
Now it's really looking good!
Pretty Printing Map
and Object
We have a good structure in place and now it's mostly a matter of adding more special cases for any object we want to
pretty print. Let's handle Map
and improve general Object
printing along the way too.
A Map
can return a list of entries via it's entries()
method.
These are "key-value pairs" in the format [["key1", "value1"], ["key2", "value2"]]
, which
is the same format the Map
constructors takes (when we create a map with
new
).
The return value of a Map's entries()
is actually an iterator, so similar to what we did
with Set
, we need to convert it to an array with
[...any.entries()]
. Then we can convert every ["key", "value"]
pair to a string
and join all those strings together with a comma:${key} → ${value}
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Map.prototype:
const entries = [...any.entries()];
const m = entries.map(([k,v]) => `${k} → ${v}`).join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["val2", "val3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,[3]]]);
Similar to our first pass for Set
, we have the basic case working, but it's not
handling nested data structures well:
Map(key1 → value1, key2 → value2) Map(key1 → value1, [object Set] → val2,val3) Set(1, [2, 3], Set(2)) [1, [2, [3]]]
And similar to how we solved this problem with Set
, we can recursively call
toString()
on all the keys and the values in the Map
:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Map.prototype:
const m = [...any.entries()]
.map(([k,v]) => `${toString(k)} → ${toString(v)}`)
.join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
Map(key1 → value1, key2 → value2) Map(key1 → value1, Set(key2) → [value2, value3]) Set(1, [2, 3], Set(2)) [1, [2, 3]]
To handle JavaScript objects generally, we do something very similar and use
Object.entries(any)
to get the entries. I separated the key and value with a
:
so it prints like a normal JavaScript object literal:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Object.prototype:
const obj = Object.entries(any)
.map(([k,v]) => `${toString(k)}: ${toString(v)}`)
.join(", ");
return `{${obj}}`;
case Map.prototype:
const m = [...any.entries()]
.map(([k,v]) => `${toString(k)} → ${toString(v)}`)
.join(", ");
return `Map(${m})`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
log({
map: new Map([['key', 'val']]),
set: new Set([1]),
obj: { array: [1,2,3] },
});
If we tried logging that last line before handling case Object.prototype
, it looks like
this:
Object{"map":{},"set":{},"obj":{"array":[1,2,3]}}
and after we add the case Object.prototype
logic, it looks like:
{map: Map(key → val), set: Set(1), obj: {array: [1, 2, 3]}}
Nice.
Max Dictionaries
In the previous tutorials on MIDI Tools we spent a lot of time working with
Max Dictionaries. Thanks to the msg_dictionary()
and
outlet_dictionary()
interfaces, we don't actually have to work with dictionaries directly in MIDI tools. But they
might come up in other contexts so let's take a look:
const dict = new Dict("dictionaryName");
dict.set("foo", "bar");
dict.set(10, 1000);
dict.set("obj", {a: 1, b: 2});
const nested = new Dict("nestedDictionaryName");
nested.set("baz", "quz");
dict.set("nested", nested);
log(dict);
This logs as:
Dict{"quiet":0,"name":"dictionaryName"}
It's not bad. At least we know it's a Dict
and we can see the name. If we went to see what's inside it when
logging, we can do this:
const toString = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(toString).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(toString).join(", ")})`;
case Object.prototype:
const obj = Object.entries(any)
.map(([k,v]) => `${toString(k)}: ${toString(v)}`)
.join(", ");
return `{${obj}}`;
case Map.prototype:
const m = [...any.entries()]
.map(([k,v]) => `${toString(k)} → ${toString(v)}`)
.join(", ");
return `Map(${m})`;
case Dict.prototype:
return `Dict("${any.name}") ${any.stringify().replaceAll("\n", " ")}`;
}
const s = String(any);
if(s.includes("[object Object]")) {
return any.constructor.name + JSON.stringify(any);
}
return s;
}
const log = (...any) => post(...any.map(toString), "\n");
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
log({
map: new Map([['key', 'val']]),
set: new Set([1]),
obj: { array: [1,2,3] },
});
const dict = new Dict("dictionaryName");
dict.set("foo", "bar");
dict.set(10, 1000);
dict.set("obj", {a: 1, b: 2});
const nested = new Dict("nestedDictionaryName");
nested.set("baz", "quz");
dict.set("nested", nested);
log(dict);
Now it logs like this:
Dict("dictionaryName") { "foo" : "bar", "10" : 1000, "obj" : { "a" : 1, "b" : 2 }, "nested" : { "baz" : "quz" }}
Dict.stringify()
converts a Dict
to a JSON string. That string is pretty-printed with newlines in it and can
result in a lot of log lines in the Max console, so I added the .replaceAll("\n", " ")
to replace the newlines with
spaces. If it's too long to fit in one line of the Max console, the console will wrap it and you can still read it.
If you run into a situation where you want to improve the logging of any other objects in the
Max JS API, you can keep adding more case
blocks to the logging logic.
Cleanup and the Final Test
To wrap up, I want to shorten the implementation. I decided to rename toString()
to
str()
. I removed a bunch of newlines and made things more compact. These decisions are
subjective, so if you don't like it, feel free to adjust the code the way you want for your own projects.
Now that we have good handling of built-in objects and nested data structures, and we can add support for more objects
as needed, I changed the condition s.includes("[object Object]")
to the more specific
condition s === "[object Object]"
. This means any object that doesn't have
custom "toString" logic (in the switch
block) will automatically convert to JSON and have
its constructor prepended.
Here is my final version of the logging code. I've included the error()
function again, and
I also wrapped it up in console.log()
and console.error()
for
anyone who are used to that interface (for example, when copy and pasting some open source project or using AI-generated
code):
const str = (any) => {
switch(Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype: return `[${any.map(str).join(", ")}]`;
case Set.prototype: return `Set(${[...any].map(str).join(", ")})`;
case Object.prototype: return `{${Object.entries(any).map(([k,v]) =>`${str(k)}: ${str(v)}`).join(", ")}}`;
case Map.prototype: return `Map(${[...any.entries()].map(([k,v]) =>`${str(k)} → ${str(v)}`).join(", ")})`;
case Dict.prototype: return `Dict("${any.name}") ${any.stringify().replaceAll("\n", " ")}`;
}
const s = String(any);
return s === "[object Object]" ? any.constructor.name + JSON.stringify(any) : s;
}
const log = (...any) => post(...any.map(str), "\n");
const error = (...any) => globalThis.error(...any.map(str), "\n");
const console = {log, error};
Let's test it thoroughly:
class SimpleClass {}
class MyClass {
toString() { return "MyClassInstance"; }
}
log("------------------------------------------------\n", 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();
log(new Map([["key1", "value1"], ["key2", "value2"]]));
log(new Map([["key1", "value1"], [new Set(["key2"]), ["value2", "value3"]]]));
log(new Set([1, 1, [2, 3], new Set([2])]));
log([1,[2,3]]);
log({
map: new Map([['key', 'val']]),
set: new Set([1]),
obj: { array: [1,2,3] },
});
const dict = new Dict("dictionaryName");
dict.set("foo", "bar");
dict.set(10, 1000);
dict.set("obj", {a: 1, b: 2});
const nested = new Dict("nestedDictionaryName");
nested.set("baz", "quz");
dict.set("nested", nested);
log(dict);
log();
// and here's some extra stuff we didn't talk about:
log(`multi
line`);
log(BigInt("1234567890123456789012345678901234567890"));
log(/regexp/);
log(Error("kaboom"));
log(Promise.resolve());
log((x) => Math.sqrt(x));
log(new SimpleClass());
log(SimpleClass);
log(new MyClass());
log({sc: new SimpleClass(), mc: new MyClass()});
log("Live API Test:");
log(new LiveAPI('live_set').info);
------------------------------------------------ Fri Dec 06 2024 18:29:23 GMT-0800 (Pacific Standard Time) Hello 2 3.14159265359 Infinity NaN true false null undefined [1, 2, 3] {a: 1, b: 2} [1, {a: {b: true}}] Symbol($) Map(key1 → value1, key2 → value2) Map(key1 → value1, Set(key2) → [value2, value3]) Set(1, [2, 3], Set(2)) [1, [2, 3]] {map: Map(key → val), set: Set(1), obj: {array: [1, 2, 3]}} Dict("dictionaryName") { "foo" : "bar", "10" : 1000, "obj" : { "a" : 1, "b" : 2 }, "nested" : { "baz" : "quz" }} multi line 1234567890123456789012345678901234567890 /regexp/ Error: kaboom [object Promise] (x) => Math.sqrt(x) SimpleClass{} class SimpleClass {} MyClassInstance {sc: SimpleClass{}, mc: MyClassInstance} Live API Test: id 1 type Song description This class represents a Live set. ...
require()
from the Max Search Path
If you use a utility like this logging function a lot, and you get sick of copy and pasting it into every v8
object, you can save it in a JavaScript file in your Max Search Path and call require()
to import it into any
v8
(or v8.codebox
) code.
At the time of writing, Max 9 does not support ESM import
and export
syntax.
Here's what the code would look like as a require-able module using module.exports
. Now that I'm not trying to
squeeze this into the top of all my v8
scripts, I formatted it nicely:
// console.js (in the Max User Library)
const str = (any) => {
switch (Object.getPrototypeOf(any ?? Object.prototype)) {
case Array.prototype:
return `[${any.map(str).join(", ")}]`;
case Set.prototype:
return `Set(${[...any].map(str).join(", ")})`;
case Object.prototype:
return `{${Object.entries(any)
.map(([k, v]) => `${str(k)}: ${str(v)}`)
.join(", ")}}`;
case Map.prototype:
return `Map(${[...any.entries()]
.map(([k, v]) => `${str(k)} → ${str(v)}`)
.join(", ")})`;
case Dict.prototype:
return `Dict("${any.name}") ${any.stringify().replaceAll("\n", " ")}`;
}
const s = String(any);
return s === "[object Object]" ? any.constructor.name + JSON.stringify(any) : s;
};
const log = (...any) => post(...any.map(str), "\n");
const error = (...any) => globalThis.error(...any.map(str), "\n");
module.exports = { log, error };
To add this file to the search path, find your Max User Library. From the Max application menu, go to "Options" →
"File Preferences..." and this should show you where your User Library is located (for example, on macOS it is probably
/Users/USERNAME/Documents/Max 9/Library/
). Save the above code as a JavaScript file in that folder. I saved it as
console.js
.
Then, in any v8
code, you can do:
const console = require("console");
console.log("Script initialized at", new Date());
console.error("Oh no");
or
const { log, error } = require("console");
log("Script initialized at", new Date());
error("Oh no");
If you saved it with a different filename than console.js
, you will have to adjust the require("console")
calls
accordingly.
Now it feels like robust console logging is built into Max. 😎
Next Steps
Well, shucks. You ran out of JavaScript in Live tutorials. Thanks for reading! I hope you learned a lot and are better equipped to go build cool stuff. Do that. Build cool stuff. Or go outside or something. How long have you been staring at a computer?