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 ${key} → ${value} and join all those strings together with a comma:


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?

👉 Leave feedback on this article 👈