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:
| File | Format | Use |
|---|---|---|
max/js2max.v8.js | IIFE | Drop-in. [v8 js2max.v8.js], then send it messages. |
max/js2max.js | CommonJS | var 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:
| Patch | For |
|---|---|
max/v8-harness.maxpat | Every message, run against the patch itself |
max/save-demo.maxpat | save and writePatch -- the exact route out |
max/serialize-demo.maxpat | write 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]
| Message | Effect |
|---|---|
demo | Builds cycle~ 440 -> gain~ -> ezdac~ into the patcher |
synth | Builds a small instrument -- toggle, metro, random, mtof, cycle~, * |
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 |
clear | Removes what the last build created, leaving the rest alone |
clearall | Removes 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> built | Serializes only what the last build created |
extract <path> [match] | Writes only the objects matching match (default ~) |
write <path> full | As above, plus each object box's own attributes |
probe | Logs what each box reports about itself (diagnostic) |
verify | Runs the bridge's own assumptions against Max and reports (diagnostic) |
diagnose | Builds the variants side by side and prints every field (diagnostic) |
count | Reports 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:
| Check | If it does not hold |
|---|---|
message("set", ...) fills a message box | every message box js2max builds is empty, and the patch looks right while doing nothing |
newdefault returns null for an unknown class rather than throwing | both 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 cords | extract 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:
| filter | extract <path> [match], or serialize(p, { only }) -- write a subset as its own patch |
| measure | walk the boxes and cords: what is unconnected, what classes are in here |
| rewrite | read 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:
- Write what the object reports.
Maxobj.maxclassfor acycle~ 440box is"cycle~"; the file needsmaxclass: "newobj"with the text alongside. - Read the box attributes.
getboxattr("maxclass"),("numinlets"),("numoutlets")all return null -- they are not box attributes, and neither istext.getboxattrnames()lists colours, fonts, rects andvarname, never the class or the ports.
What Max does give:
| Needed in the file | Where it comes from |
|---|---|
| the object class | Maxobj.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 / outlettype | PORTS in src/objects.ts |
| everything else | getboxattrnames(), 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
| Path | Contents |
|---|---|
src/format.ts | The .maxpat wire format as types |
src/model.ts | Patcher / LoadedPatcher: build, parse, serialize |
src/maxclass.ts | Discriminated union over maxclass, for exhaustiveness |
src/scripting.ts | The bridge: a description becomes live Max objects |
src/fileio.ts | .maxpat read/write over Max's File class |
src/dict.ts | A patch description read out of a Max dict |
src/serialize.ts | Live patcher -> .maxpat |
src/objects.ts | Generated from py2max: box classes, port counts, Max version |
src/max.d.ts | The v8 host API, declared with per-item provenance (reference, measured, or narrowed) |
src/commands.ts | The decisions the messages make, where tests can reach them |
src/verify.ts | The bridge's assumptions, as checks that run inside Max |
src/entry.v8.ts | Message handlers for the drop-in build -- glue only |
src/lib.v8.ts | Public surface for the require() build |
test/mockhost.ts | Recording stand-in for the Max host |
test/mockfile.ts | In-memory stand-in for Max's File |
test/mockdict.ts | In-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:
-
getboxattrdoes not carry the class or the port counts.maxclass,numinlets,numoutletsandtextall come back null;getboxattrnames()never lists them. This is whysrc/objects.tsexists. -
Maxobj.boxtextworks, returning the full text of every box -- including a comment's prose and a message box's contents. -
Maxobj.maxclassis the object class, not the box class:print,v8,comment,message. -
getattrnames()exists, but for a UI box it returns the same list asgetboxattrnames()-- 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:printreportsbettersymquotes deltatime floatprecision level popup time, a[v8]box reportsannotation_name embed parameter_enable parameter_mappable. -
filename,textfileandlinecountare not recoverable. None appear ingetattrnames(), 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 logsv8_wrapobject: couldn't wrap instance of class textfile. -
newdefaultdoes NOT return null for an unknown object class. Max logs<name>: No such objectto the console and returns a box anyway, whosemaxclassisjbogus-- the dashed-border placeholder you get by typing a bad name into a patcher.validis 1 andboxtextis the text asked for, so nothing short of the class name gives it away, andinstantiatehad never detected an unknown class:skippedstayed empty,createdcounted the dead box, and the patch quietly contained one. It now tests forjbogus, reports the box as an unknown class and removes the placeholder, sinceskippedmeans "not built".The obvious alternative -- does
maxclassequal the class asked for -- is wrong, and the same run proved it: Max resolves aliases, sot b icomes back astriggerand would have read as broken. -
getattrnames()returns null for some objects, rather than an empty array or an error. Bothtriggerandjbogusdo. Atryaround the call does not help, because there is nothing to catch: the null arrives cleanly and theTypeErrorlands wherever the result is first treated as an array, several frames away. This crashedwrite <path> fullon any patcher containing atrigger, and abortedprobehalfway. Every such read now goes through one guard. -
understands()does not return a boolean.cycle~answered0forbang, whiletriggeranswered4342878924-- a pointer, truthy. Fine to test for truth, wrong to compare againsttrue. -
The
dictroute works end to end.savewrote the synth description to a.json,[dict]imported it, andbuilddictbuilt the nine boxes and nine cords into the patcher. SoDict.stringify()returns JSON thatJSON.parseaccepts, for a whole patch and not merely for the{}an empty dictionary gives -- which was the last assumption the route rested on. -
dicthas noimport_jsonmessage; the message isimport. It reads a dictionary's contents from a.jsonor.yamlfile and chooses its reader by extension, so the harness writes.jsonrather than.maxpat-- a.maxpatis 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 aprint-- 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:Difference Why fontsize: 14on 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, 24to460, 22Max's own auto-sizing of wrapped text, applied when the patch was open. Nothing js2max wrote print js2maxwithoutnuminlets/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
rectanswers 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].serializereads it now; the ambiguity was the only thing stopping it. -
A patcher does not report its own font defaults.
getattrreturns null fordefault_fontname,default_fontsizeanddefault_fontfacealike, so the filter that judges a box's fonts against them had nothing to judge against -- and a written patch came back withfontname: "Arial"andfontsize: 12on 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_rectwhether or not it is in presentation mode, as a copy of its patching rect. Written on sight, that put apresentation_recton all fifteen boxes of a round-tripped patch. It is now written only whenpresentationis actually set. -
A message box needs
newobject, notnewdefault. Four routes throughnewdefaultwere tried and every one left the box empty:setwith separate atoms,setwith a single symbol, the content as creation arguments, andsetboxattr("text", ...). The box was real -- its attribute list matches a file-loaded message box exactly,convertobj/parseobj/valuepopupand 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 throughboxtext.newobjecttakes 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
1and2as width and font size, leaving3as 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 of0means the patcher default, so an explicitfontsizeis applied afterwards with the other box attributes.Confirmed:
verifycheck 1 reads the box back as"1 2 3". Comments are built the same way, by inference from the same signature --verifycheck 4 exists to hold that to the same standard rather than leaving it assumed. -
Subpatcher recursion works. A box carrying a nested
patcherexposes it throughsubpatcher(), and the contents build: 3 objects from 1 box plus 2 inside. -
snapshotreads 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 assumptionserializematches endpoints by and the onesnapshotcarries avarnamefallback against. -
thisis bound as expected inside the bundled IIFE scope.demoandcountboth reachthis.patcher; the global-handle fallback incontextOfwas not needed, and is kept only as a cheap safety net. -
newdefault,connectand the rect conversion all work.demoproducedcycle~ 440 -> gain~ -> ezdac~, correctly wired and positioned. -
outlet()works --countreturned 11 in an 8-box patch afterdemoadded 3. -
Deleting the script's own box corrupts the running script, rather than failing cleanly. This is why
clearwas redesigned; see above.
Still unverified
- whether a comment gets its content. Comments are built by the same
newobjectsignature that fixed message boxes, which is an inference from one confirmed case rather than an observation.verifycheck 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 whatresult.warningsflags; whether Max would even accept one is untested. - whether assigning
eof = 0truncates. The reference documents theeofsetter 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.writeTexttolerates the assignment failing, so this shows up as a corrupt file rather than an error -- worth checking first ifwritemisbehaves. - whether a
fullwrite reopens correctly in Max. A plainwritedoes -- see "Confirmed in Max" -- butfulladds every object attribute undersaved_object_attributes, which is a great many more keys and has not been put in front of Max. readhas not been run in Max at all -- thoughbuilddict, which shares every step but the file access, has. Everything else in the round trip is confirmed:writeand its output opening, thedictroute, subpatcher recursion,snapshot, the message-box route, unknown-class detection and the patcherrect.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;builddictis 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
snapshotdescribes 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.maxpatrecords, 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.