js2max

July 27, 2026 ยท View on GitHub

The JavaScript counterpart to py2max: the .maxpat format as TypeScript types, a small object model, and a bridge that builds patches live inside Max through the v8 object.

Python writes patches. This runs inside one.

Both directions are confirmed against Max: a patch description becomes live objects in an open patcher, and a live patcher serializes back to a .maxpat that Max reopens -- the same description either way, because the format lives in one set of types rather than once per direction.

This began as a spike whose verdict was "kill it": every type-system benefit it demonstrated turned out to be reachable in Python with TypedDict/Unpack, which is now done (see py2max/core/props.py). What revived it is the one thing Python cannot reach -- Max embeds a JavaScript engine, and v8 ships in current Max, so one description can be written to a file offline and instantiated into a running patcher.

Changes are recorded in CHANGELOG.md; the Python package keeps its own, one level up.

What you get

Two artifacts in max/, built from one core:

FileFormatUse
max/js2max.v8.jsIIFEDrop-in. [v8 js2max.v8.js], then send it messages.
max/js2max.jsCommonJSvar js2max = require("js2max.js") from your own script.

Both are committed, so a Max user needs no toolchain -- and both ship inside the py2max wheel, so a pip install py2max user does not need this repository either: p.add_v8_bridge() adds the [v8] box and p.save() writes the runtime beside the patch. See py2max/js2max_runtime.py.

Three test patches sit beside them, generated by py2max itself:

PatchFor
max/v8-harness.maxpatEvery message, run against the patch itself
max/save-demo.maxpatsave and writePatch -- the exact route out
max/serialize-demo.maxpatwrite and serialize -- a patcher as data

Each demo shows its pair twice: once as a message to the shipped js2max.v8.js, and once from a small script of your own -- max/save-example.js and max/serialize-example.js, which are worth reading as the shortest statement of each API.

The serialize demo makes the honest point about its own method: serializing a whole patcher and saving it is a worse cp -- a file copy is exact and this is not. So it filters instead. extract pulls the signal chain out of a mixed patch of eight objects and writes those three as their own file, which no copy could produce.

Why bundled

v8 supports CommonJS require() and include(), but not ESM import (Cycling '74 forum). This core is written as ES modules, so it is flattened before Max sees it. The IIFE build needs no resolution at all; the CommonJS build is what require() expects.

Drop-in use

Put max/js2max.v8.js where Max can find it (beside your patch is enough):

[demo(   [count(   [clear(
    \       |       /
     [v8 js2max.v8.js]
             |
     [print js2max]
MessageEffect
demoBuilds cycle~ 440 -> gain~ -> ezdac~ into the patcher
synthBuilds a small instrument -- toggle, metro, random, mtof, cycle~, *, ezdac
build <json>Builds a patch description passed as a message. Cannot carry a real patch; use builddict
builddict <name>Builds the description held in a Max dict -- the usable form of build
clearRemoves what the last build created, leaving the rest alone
clearallRemoves everything except this [v8] object
read <path>Opens a .maxpat from disk and builds it here
save <path>Writes a description straight to a .maxpat -- no objects created
write <path>Serializes the whole live patcher to a .maxpat
write <path> builtSerializes only what the last build created
extract <path> [match]Writes only the objects matching match (default ~)
write <path> fullAs above, plus each object box's own attributes
probeLogs what each box reports about itself (diagnostic)
verifyRuns the bridge's own assumptions against Max and reports (diagnostic)
diagnoseBuilds the variants side by side and prints every field (diagnostic)
countReports the object count

Modes combine: write out.maxpat built full. A word that is not a mode is refused rather than ignored -- write out.maxpat buit used to serialize the whole patcher, [v8] box included, and report success.

built tracks the objects from the most recent demo / synth / build, in memory. clear resets it, and so does reloading the script -- which is why the bundle sets autowatch = 0: with it on, rebuilding js2max.v8.js in another window would silently discard what you had built.

verify -- the assumptions, settled by Max

Everything in scripting.ts is tested against a mock. That proves the mapping is right and proves nothing about whether Max accepts the calls, so four assumptions underneath the rest of the bridge had never been exercised at all:

CheckIf it does not hold
message("set", ...) fills a message boxevery message box js2max builds is empty, and the patch looks right while doing nothing
newdefault returns null for an unknown class rather than throwingboth are handled, but every "skipped" reason ever logged has named the wrong cause
a box with a nested patcher exposes it through subpatcher()subpatcher recursion silently builds the box and none of its contents
snapshot reads a live patcher's boxes and cordsextract and write have nothing to read

verify does each in turn, reads back what Max did, and removes what it built, so it is safe to click on any patch and leaves the patcher as it found it. Each line is logged with the observation, not just a verdict -- [NO ] marks a real finding worth copying out of the console.

The checks are themselves tested (test/verify.test.ts) against a mock host configured to fail in each of those ways, because a check that cannot fail reports "yes" whether or not the assumption holds -- and a verification harness nobody verified is worth about as much as the assumption it was meant to settle.

Neither clear nor clearall will delete the [v8] box running the script. Doing so does not fail cleanly: Max frees the object, execution continues against the freed pointer, and the next post() reports bad object / typedmess: post: corrupt object. clearall refuses to run at all if it cannot identify its own box.

Two ways to hand js2max a description, and one of them is not usable.

read <path> points at a .maxpat on disk and the patch assembles itself in place. builddict <name> does the same from a Max dict, which is the answer to a limitation rather than a second convenience: a message box ends its message at the first , and splits it at every space, so build <json> is truncated a few characters into any real patch. A dict is passed by name, so the message carries one symbol and the document never meets Max's message parser at all -- and a dict can load the file itself:

[import my-patch.json(        [builddict my_patch(
        |                              |
[dict my_patch]                [v8 js2max.v8.js]

The harness runs that as a three-click chain needing nothing from outside the patch: save js2max-dict-test.json writes the built-in synth description to a file, import js2max-dict-test.json loads it into the [dict], and builddict js2max_patch builds it -- nine boxes, offset clear of the harness's own controls, which clear then removes. The file is save's output rather than write's on purpose: write serializes this whole patcher, [v8] box included, so building it would drop a second copy of the script into the patch.

read is for a file this script chooses; builddict is for whatever the patch has already assembled -- a file, a dictionary other objects built, or JSON that arrived over the network. build <json> remains for a caller that can deliver the atoms itself, and reports what actually arrived when parsing fails.

Library use

var js2max = require("js2max.js");

function bang() {
    var p = new js2max.Patcher();
    var osc = p.add("cycle~ 440");
    var dac = p.add("ezdac~");   // maxclass "ezdac~", 2 inlets, no outlets
    p.connect(osc, dac, 0, 0);
    p.connect(osc, dac, 0, 1);

    var result = js2max.instantiate(this.patcher, p.toPatcherDict());
    post("built " + result.created + " objects\n");
}

A box knows what it is before Max sees it: the box class, port counts and outlet types are looked up from the object class in src/objects.ts, so p.add("ezdac~") is a maxclass: "ezdac~" box with two inlets and no outlets, and p.add("gain~") has one inlet and two. Anything passed explicitly wins. This matters beyond typing less -- a box that declares a port it does not have loses the cord attached to it, silently, when Max opens the file.

The same Patcher also serializes: p.toJSON() produces a .maxpat document identical in shape to the Python package's output.

To get it onto disk, fileio.ts wraps Max's File class:

var loaded = js2max.readPatch("my-patch.maxpat");   // parse a .maxpat
js2max.instantiate(this.patcher, loaded.patcher);   // build it here
js2max.writePatch("out.maxpat", p);                 // serialize one out

Paths resolve the way Max resolves them, so a bare filename finds a patch on the search path. Outside Max there is no File, and the default factory says so rather than throwing a ReferenceError; pass { factory } to substitute one.

Two ways out, and they are not equivalent

save / writePatch -- a description straight to a file. Exact by construction: a description already is the shape of a .maxpat, so nothing has to be read back out of Max, no accessor is involved and no default has to be guessed at. Creates no objects. This is the normal way to write a patch, and the one to reach for.

write / serialize -- a live patcher back into a description. Necessarily lossy, because every field has to be rebuilt from whatever the JS API exposes: a font matching the patcher default is indistinguishable from an unset one, port counts maxref does not state are omitted for Max to derive, and linecount / filename / textfile have no accessor at all.

The font case is the one that had to be handled rather than merely stated. A box attribute equal to the patcher's default is omitted -- that is how Max writes a file, and the only way to tell a styled box from a normal one -- so the emitted patcher records default_fontname / default_fontsize / default_fontface alongside the boxes. Filtering without recording was silent damage: a patcher defaulting to 14pt lost fontsize: 14 from every box as "unstyled" and kept no default to restore it, so the file reopened at Max's 12pt with nothing said.

The patcher's window rect is not read back; it is the default unless you pass serialize(p, { rect }). See "Still unverified" for why.

So do not use it to copy a patcher -- cp does that better. Use it to get the patch as data, then do something a copy cannot:

filterextract <path> [match], or serialize(p, { only }) -- write a subset as its own patch
measurewalk the boxes and cords: what is unconnected, what classes are in here
rewriteread the positions, compute new ones, push them back

serialize-example.js does all three. Building objects only to read them back is the wrong shape for an export, and it was how the demo worked at first; save replaced it.

write goes via serialize in src/serialize.ts.

Two wrong theories preceded this, both settled by running probe in Max:

  1. Write what the object reports. Maxobj.maxclass for a cycle~ 440 box is "cycle~"; the file needs maxclass: "newobj" with the text alongside.
  2. Read the box attributes. getboxattr("maxclass"), ("numinlets"), ("numoutlets") all return null -- they are not box attributes, and neither is text. getboxattrnames() lists colours, fonts, rects and varname, never the class or the ports.

What Max does give:

Needed in the fileWhere it comes from
the object classMaxobj.maxclass -- print, v8, comment
text: "cycle~ 440"Maxobj.boxtext (v8 only; there is no fallback)
maxclass: "newobj" vs "comment"OWN_MAXCLASS in src/objects.ts
numinlets / numoutlets / outlettypePORTS in src/objects.ts
everything elsegetboxattrnames(), with { allAttributes: true }

src/objects.ts is generated from py2max (make js2max): the port counts and box classes are static per object class, and py2max already knows them for all 1175 objects in the maxref bundle. The same table backs Patcher.add, so the two directions agree on what a gain~ is. An object class with no entry -- a third-party external -- omits its port counts rather than guessing, since Max derives them from the instantiated object anyway.

result.unresolved counts the cords that did not make it into the file because an endpoint was not written. Under extract that is the boundary being cut, which is the point; serializing a whole patcher it should be zero, since every endpoint is in the set by construction. It is counted rather than assumed because endpoints are matched by object identity, and the JS API does not promise two reads of an object give the same wrapper -- if that is ever wrong, this reports it instead of a patch arriving with no connections in it.

write refuses rather than writing a file Max cannot load. Any box that cannot be fully described is named in the console and the write is abandoned; write <path> partial overrides. An object box missing its text counts as incomplete, because it would reopen empty -- silent data loss is worse than a refusal.

snapshot remains, for inspecting a patcher's structure cheaply; serialize is what you want for a file. (toFile() is separately misnamed: it returns the document object, not a file.)

instantiate never throws for a bad box -- an unknown object class, or a patchline to a box that failed, is collected in result.skipped with a reason and the rest of the patch still builds. A half-built patcher with a clear log beats an exception halfway through.

A built box carries the description's appearance, not just its position: the box attributes a .maxpat records -- colours, fonts, presentation, hidden -- are applied to the object, so what serialize preserves is what instantiate restores. Only attributes the box itself reports through getboxattrnames() are attempted; asking a box for one it does not have is how the Max console fills up over a file that is otherwise fine. Pass { applyAttributes: false } to build bare boxes.

What a built box does not get is a name it never asked for. result.objects maps every model id to its live object, which is how a script addresses what it just built; naming the boxes as well was redundant and not free, since a varname invented from the model id is a key serialize then writes into the file and py2max never would. Pass { nameById: true } when the name has to outlive the call -- a later message, another object reaching in with getnamed -- and the names are made unique, so a second build does not shadow the first. A varname the description carries is applied either way.

result.warnings is the other list, for boxes that were built but not as described: an attribute the object refused, or a message box whose text contains , or ;. Those are Max's message separators -- 1, 2 is two messages -- and they are A_COMMA / A_SEMI atoms that nothing in the JS API can produce, so a set message delivers an ordinary symbol instead. The box exists and the rest of the patch is unaffected; save writes such a patch correctly, because a description goes to the file without passing through Max. Empty skipped means it was built; empty warnings means it was built as written.

Box text is tokenized the same way in both directions, so a message box 1 2 3 holds three ints rather than three symbols. Only Max's own number syntax converts: 0x10, 1e3 and Infinity stay symbols, because Max does not read them as numbers and rewriting gate 0x10 into gate 16 would change the patch.

Development

cd js2max
bun install
bun run check     # tsc --noEmit, then bun test, then verify max/ is not stale
bun run build     # rebuild max/

Requires Bun (developed against 1.3) and TypeScript 5.9. From the repository root, make js2max builds and make js2max-check verifies.

Layout

PathContents
src/format.tsThe .maxpat wire format as types
src/model.tsPatcher / LoadedPatcher: build, parse, serialize
src/maxclass.tsDiscriminated union over maxclass, for exhaustiveness
src/scripting.tsThe bridge: a description becomes live Max objects
src/fileio.ts.maxpat read/write over Max's File class
src/dict.tsA patch description read out of a Max dict
src/serialize.tsLive patcher -> .maxpat
src/objects.tsGenerated from py2max: box classes, port counts, Max version
src/max.d.tsThe v8 host API, declared with per-item provenance (reference, measured, or narrowed)
src/commands.tsThe decisions the messages make, where tests can reach them
src/verify.tsThe bridge's assumptions, as checks that run inside Max
src/entry.v8.tsMessage handlers for the drop-in build -- glue only
src/lib.v8.tsPublic surface for the require() build
test/mockhost.tsRecording stand-in for the Max host
test/mockfile.tsIn-memory stand-in for Max's File
test/mockdict.tsIn-memory stand-in for Max's Dict

What is tested, and what is not

bun test runs 236 tests. The round-trip tests parse every .maxpat fixture under tests/ and re-emit it. The bridge tests drive instantiate against MockPatcher, a double that records the Patcher-API calls, covering class-name derivation, typed-in argument coercion, the [x,y,w,h] to [l,t,r,b] rect conversion, connection indices, subpatcher recursion, and failure collection.

None of that proves Max accepts the calls. It proves the mapping is right. max/v8-harness.maxpat is how you check the other half.

Confirmed in Max

Run against Max with max/v8-harness.maxpat:

  • getboxattr does not carry the class or the port counts. maxclass, numinlets, numoutlets and text all come back null; getboxattrnames() never lists them. This is why src/objects.ts exists.

  • Maxobj.boxtext works, returning the full text of every box -- including a comment's prose and a message box's contents.

  • Maxobj.maxclass is the object class, not the box class: print, v8, comment, message.

  • getattrnames() exists, but for a UI box it returns the same list as getboxattrnames() -- there the box and the object are one thing, so a comment reports the same 33 names either way. Only object boxes have anything of their own: print reports bettersymquotes deltatime floatprecision level popup time, a [v8] box reports annotation_name embed parameter_enable parameter_mappable.

  • filename, textfile and linecount are not recoverable. None appear in getattrnames(), so they are bookkeeping Max writes on save rather than state anything can read back. Asking for one anyway is worse than useless: getattr("textfile") returns a Max object the bridge cannot wrap and logs v8_wrapobject: couldn't wrap instance of class textfile.

  • newdefault does NOT return null for an unknown object class. Max logs <name>: No such object to the console and returns a box anyway, whose maxclass is jbogus -- the dashed-border placeholder you get by typing a bad name into a patcher. valid is 1 and boxtext is the text asked for, so nothing short of the class name gives it away, and instantiate had never detected an unknown class: skipped stayed empty, created counted the dead box, and the patch quietly contained one. It now tests for jbogus, reports the box as an unknown class and removes the placeholder, since skipped means "not built".

    The obvious alternative -- does maxclass equal the class asked for -- is wrong, and the same run proved it: Max resolves aliases, so t b i comes back as trigger and would have read as broken.

  • getattrnames() returns null for some objects, rather than an empty array or an error. Both trigger and jbogus do. A try around the call does not help, because there is nothing to catch: the null arrives cleanly and the TypeError lands wherever the result is first treated as an array, several frames away. This crashed write <path> full on any patcher containing a trigger, and aborted probe halfway. Every such read now goes through one guard.

  • understands() does not return a boolean. cycle~ answered 0 for bang, while trigger answered 4342878924 -- a pointer, truthy. Fine to test for truth, wrong to compare against true.

  • The dict route works end to end. save wrote the synth description to a .json, [dict] imported it, and builddict built the nine boxes and nine cords into the patcher. So Dict.stringify() returns JSON that JSON.parse accepts, for a whole patch and not merely for the {} an empty dictionary gives -- which was the last assumption the route rested on.

  • dict has no import_json message; the message is import. It reads a dictionary's contents from a .json or .yaml file and chooses its reader by extension, so the harness writes .json rather than .maxpat -- a .maxpat is JSON by content but not by name. An empty dictionary stringifies to {}, not to an empty string, so it parses cleanly and used to fail much later as "no patcher.boxes", blaming the description for a load that never happened.

  • A patch js2max wrote opens in Max. The whole harness -- 15 boxes, 10 cords, comments, message boxes, a [v8] object and a print -- was serialized from the live patcher, written to a file, and opened. It loads cleanly, and ten of the fifteen boxes are byte-identical to the source it was serialized from. The differences are three, and all three are understood:

    DifferenceWhy
    fontsize: 14 on one commentCorrect. The harness sets that comment to 14pt; the other fourteen boxes sit at Max's default and are omitted, which is what the font filter is for
    four comment heights, e.g. 460, 24 to 460, 22Max's own auto-sizing of wrapped text, applied when the patch was open. Nothing js2max wrote
    print js2max without numinlets / numoutlets / outlettypemaxref states no ports for print, so the table omits them rather than guessing. The file opening proves omitting is safe: Max derives them from the instantiated object, as the design assumed
  • A patcher's rect answers in the file's convention, [x, y, w, h] -- not the [left, top, right, bottom] every box rect uses. A patcher 640x560 at (85, 104) read back as [85, 104, 640, 560], where the other convention would have given [85, 104, 725, 664]. serialize reads it now; the ambiguity was the only thing stopping it.

  • A patcher does not report its own font defaults. getattr returns null for default_fontname, default_fontsize and default_fontface alike, so the filter that judges a box's fonts against them had nothing to judge against -- and a written patch came back with fontname: "Arial" and fontsize: 12 on every box, neither of which the source file has, because those are Max's own defaults and Max omits them. The filter now falls back to Max's defaults when the patcher will not say.

  • A box reports presentation_rect whether or not it is in presentation mode, as a copy of its patching rect. Written on sight, that put a presentation_rect on all fifteen boxes of a round-tripped patch. It is now written only when presentation is actually set.

  • A message box needs newobject, not newdefault. Four routes through newdefault were tried and every one left the box empty: set with separate atoms, set with a single symbol, the content as creation arguments, and setboxattr("text", ...). The box was real -- its attribute list matches a file-loaded message box exactly, convertobj / parseobj / valuepopup and all -- and it did not even resize, which a message box does to fit its contents. Nor was the content hidden in an attribute: a built box and a file-loaded one report the same 27 names and not one holds the text, so the file box's content is reachable only through boxtext.

    newobject takes the box's parameters explicitly, and two calls decoded its signature between them:

    newobject("message", 24, 720, 1, 2, 3)          boxtext "3"
    newobject("message", 24, 752, 100, 0, "1 2 3")  boxtext "\"1 2 3\""
    

    The first consumed 1 and 2 as width and font size, leaving 3 as the text; the second passed the content as one symbol, which Max quoted. So it is (class, left, top, width, fontsize, ...text atoms), and the atoms must go separately -- joining them produces a box holding the literal string. A font size of 0 means the patcher default, so an explicit fontsize is applied afterwards with the other box attributes.

    Confirmed: verify check 1 reads the box back as "1 2 3". Comments are built the same way, by inference from the same signature -- verify check 4 exists to hold that to the same standard rather than leaving it assumed.

  • Subpatcher recursion works. A box carrying a nested patcher exposes it through subpatcher(), and the contents build: 3 objects from 1 box plus 2 inside.

  • snapshot reads a live patcher: 18 boxes and 10 cords, 0 unresolved. Worth more than the check it was written for -- resolving every cord means object identity held across separate reads, which is the assumption serialize matches endpoints by and the one snapshot carries a varname fallback against.

  • this is bound as expected inside the bundled IIFE scope. demo and count both reach this.patcher; the global-handle fallback in contextOf was not needed, and is kept only as a cheap safety net.

  • newdefault, connect and the rect conversion all work. demo produced cycle~ 440 -> gain~ -> ezdac~, correctly wired and positioned.

  • outlet() works -- count returned 11 in an 8-box patch after demo added 3.

  • Deleting the script's own box corrupts the running script, rather than failing cleanly. This is why clear was redesigned; see above.

Still unverified

  • whether a comment gets its content. Comments are built by the same newobject signature that fixed message boxes, which is an inference from one confirmed case rather than an observation. verify check 4 reports it.
  • whether a , in a message box's text can be carried at all. It cannot be passed as an atom, which is what result.warnings flags; whether Max would even accept one is untested.
  • whether assigning eof = 0 truncates. The reference documents the eof setter as extending a file with NULL bytes; shrinking is assumed. If it does not truncate, writing a shorter patch over a longer one leaves trailing bytes after the closing brace and the result is invalid JSON. writeText tolerates the assignment failing, so this shows up as a corrupt file rather than an error -- worth checking first if write misbehaves.
  • whether a full write reopens correctly in Max. A plain write does -- see "Confirmed in Max" -- but full adds every object attribute under saved_object_attributes, which is a great many more keys and has not been put in front of Max.
  • read has not been run in Max at all -- though builddict, which shares every step but the file access, has. Everything else in the round trip is confirmed: write and its output opening, the dict route, subpatcher recursion, snapshot, the message-box route, unknown-class detection and the patcher rect.
  • build <json> remains unusable from a message box, by construction rather than by omission -- Max ends the message at the first ,. It is kept for a caller that can deliver the atoms; builddict is the route that works

Is this reinventing the wheel?

Partly, and it is worth being precise about which part.

The Max JS API does not do what the model does. Patcher has no JSON export or import, and no way to create objects from a patch description -- confirmed against the API reference. It manipulates a live patcher; .maxpat is a document. format.ts, model.ts and instantiate bridge the two, and nothing in the JS API does.

But if you are only scripting a live patcher, use the JS API directly. this.patcher.newdefault(...) is simpler than building a description and instantiating it. js2max earns its place when the description comes from somewhere else -- a file py2max wrote, a dict, a network message -- or when you want the same code to produce a file and a live patch.

One thing here was reinventing a worse wheel. An earlier version claimed the JS API could not enumerate patchlines, and cut the live -> model direction down to boxes only. That was wrong: Maxobj.patchcords returns inputs and outputs arrays of MaxobjConnection. snapshot now reads connections properly.

Worth knowing before writing anything here: the JS API is much larger than the part this uses -- applydeep / applyif / getlogical for iteration, Maxobj.valid, hiddenconnect, MaxobjListener, Dict, Task, File, SQLite, LiveAPI. Reach for those before adding to scripting.ts.

Deliberate limits

  • snapshot describes the patcher, not the file. A live object exposes its class, name, position and cords, but not the typed-in text or the per-class attributes a .maxpat records, so a snapshot is not a .maxpat.
  • No layout, maxref, database, CLI or SVG. Those stay in Python, where the users and the graph-layout libraries are. This is a second front end to the format, not a port.