npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

maxpat-probe

v0.1.0

Published

Read Max/MSP patchers and Max for Live devices from the command line — inspect, search, extract and diff .maxpat and .amxd files without opening Max.

Readme

maxpat-probe

Read Max/MSP patchers and Max for Live devices from the command line. Inspect, search, extract and diff .maxpat and .amxd files without opening Max.

No dependencies. Node 18+.

npm install -g maxpat-probe
maxpat-probe params MyDevice.amxd

Why

Two problems, both annoying in the same way.

.amxd files look like binaries. Every tool refuses to read them, git diff reports Bin 3103816 -> 3653532 bytes, and code review is impossible. They aren't really opaque — the patcher JSON is sitting in plain UTF-8 in the middle of the file — but nothing will show it to you.

.maxpat JSON is enormous. A moderately complex device serialises to megabytes. Opening one to answer "what parameters does this expose?" or "where is the playhead control?" means paging through hundreds of thousands of lines of patching_rect noise. If you are working with an LLM, that is the whole context window gone — and a real chunk of your usage allowance — to answer a question whose useful answer is forty lines long.

maxpat-probe answers those questions directly:

$ maxpat-probe params SIGNe-Screen.amxd
PATH        CLASS        PARAMETER              VISIBILITY        RANGE/TYPE
Settings    live.dial    Scroll Progress        automated+stored  type=0
Settings    live.text    Show Background Grid   hidden            type=2
Settings    live.text    Invert Mouse Scroll    stored            type=2
…
77 parameters — 53 hidden, 6 automated+stored, 18 stored

It never writes to the file it reads

Every command opens the input read-only. No command modifies, moves or re-saves a .maxpat or .amxd, and writing a .amxd back out is not supported at all — see Reading only, on purpose. The single command that writes anything is extract, which only ever creates new files under the directory you name.

extract will overwrite existing files in that directory without asking, so point it somewhere fresh if that matters.

Dependency names come out of the file being read, which makes them untrusted input. A crafted container can name a payload something like ../../.bashrc; extract resolves every target and refuses anything landing outside the output directory, reporting it rather than writing it:

  REFUSED (path escapes ./out): ../../ESCAPED.js
  1 refused — the file names a path outside the output directory.

Devices Max produces never do this. If you see that message, treat the file as suspect.

The workflow this is built for

maxpat-probe reads. It does not write .amxd files, and that is deliberate (see Reading only, on purpose).

The loop it is designed for, when you are working on a patch with an LLM:

  1. The assistant reads the patch through maxpat-probe — find to locate a control, params to see what is exposed to Live, tree to understand the structure. Forty lines of answer instead of two megabytes of JSON.
  2. The assistant proposes a change in terms you can act on: which device, which subpatcher, which object, which inspector setting.
  3. You make the change in Max, by hand, in the editor, where you can see what you are doing.
  4. maxpat-probe diff verifies it — comparing the device before and after confirms that exactly what was intended changed, and nothing else did.

Step 4 matters more than it sounds. Max rewrites parts of a patcher file on every save, so "did my edit do what I think it did?" is a real question with a non-obvious answer. diff answers it.

This division is not a limitation to work around. Max is a visual editor with its own consistency rules; hand-writing patcher JSON produces files that load but behave strangely. Let Max write, and use maxpat-probe to decide what to change and to check what changed.


Commands

maxpat-probe <command> <file> [options]

Inspect

stats <file>

Count boxes by maxclass — Max's name for an object's type (newobj for a typed-in object, live.dial for a dial, comment for a comment, and so on). A quick sense of size and composition.

$ maxpat-probe stats SIGNe-Screen.amxd
newobj                      2054
message                      179
comment                       88
live.dial                     27
…
TOTAL                       2562

tree <file> [--depth N]

The subpatcher hierarchy. A subpatcher is a patch inside a patch — an object box typed p SomeName that opens into its own window. --depth 1 shows only the top level.

$ maxpat-probe tree SIGNe-Screen.amxd --depth 1
p MarqueeSelection      [-]  boxes=143
p Settings              [-]  boxes=264
p Properties            [-]  boxes=1634

Note that abstractions do not appear here. An abstraction is a separate .maxpat file used as an object; it is referenced by name, not nested inline, so its contents live elsewhere. In a frozen device they are inside the container — see deps and extract.

params <file>

Every live.* object that exposes a parameter to Live, with its Parameter Visibility — the setting that decides whether Live saves and automates it. This is the first thing to check when a control "doesn't remember its setting"; that is usually a hidden that should be stored.

Max offers five modes. The stored encoding is parameter_invisible, confirmed by setting one control to each mode and reading the saved file back:

| Value | Inspector label | Automatable | Saved with set | | --- | --- | --- | --- | | absent / 0 | Automated | yes | yes | | 1 | Stored Only | no | yes | | 2 | Hidden | no | no | | 3 | Visible | no | yes | | 4 | Visible (Not Stored) | no | no |

The two "Visible" modes are newer. They expose a parameter to Push and other control surfaces without giving it an automation lane, are offered only for Float, Int and Enum parameter types, and are the only ones that surface the extra Undo When Visible attribute in the inspector.

Any value outside this table is printed as unknown(N) rather than guessed at, since a wrong label here would be worse than no label.

Add --json for machine-readable output including visibility (the raw number), initialEnable, initial, modmode, unitstyle, shortname and the object's rectangle.

Parameter Visibility is a different setting from Initial Enable, which only controls whether the stored Initial value is pushed to the object when it is first created. Initial Enable has no bearing on whether a value is saved with or recalled from a Live set.

ui <file>

UI objects with their on-screen rectangles — dials, panels, comments, pictures, buttons. Useful for "what is at these coordinates" and for spotting overlaps.

find <file> <regex> [--all]

Search every box's class, variable name and text. Case-insensitive.

$ maxpat-probe find SIGNe-Symbol.amxd 'rotation'
(root)   live.dial   Duplicate Rotation   3614 1754 27 37
(root)   newobj      -                    3613 1814 176 20   AutoModGate DuplicateRotation 15

--all also searches the abstractions embedded in the device, which is where a lot of the real implementation lives. Matches are then prefixed with the file they were found in.

The pattern is matched against "<maxclass> <varname> <text>" joined together, so anchoring with ^ matches the start of the class, not the text.

boxes <file> <path>

Everything at one subpatcher path, as printed by tree. Use "" for the top level.

$ maxpat-probe boxes SIGNe-Screen.amxd Settings

Container

These apply to .amxd only; a .maxpat has no container around it.

deps <file>

The embedded dependencies, from the tail index. Also reports whether the device is frozen at all:

$ maxpat-probe deps SIGNe-Screen.amxd
TYPE  NAME                                    SIZE
JSON  SIGNe-Screen.amxd                    2906644
JSON  PathManagement.maxpat                   9071
TEXT  marquee_selection_instancing.js       110313
TEXT  signe_atlas_instancing.jxs              4007
PNG   SIGNe_DefaultIcon.png                  10025
…
17 embedded files

A device that carries an anno chunk has its own description printed first — plain text written by whoever built it, and the only account of what a device does that travels inside the file:

$ maxpat-probe deps LiveAPI-Interactive.amxd
Display an interactive 'Terminal' style prompt for querying the LiveAPI.

TYPE       NAME                                          SIZE
JSON       LiveAPI-Interactive.maxpat                   21835
…

Only six of 5,183 community devices carry one, so most of the time you will not see it. --json includes it as annotation.

An unfrozen device says so loudly, because shipping one is a common and badly-diagnosed mistake — it resolves its dependencies through the Max search path of whoever opens it, so it works on your machine and breaks on theirs.

That is not a hypothetical, and the rate is remarkably stable. Across all 5,085 readable devices available for direct download from maxforlive.com — every category, every page — 2,859 are frozen and 2,226 are not. Most of the unfrozen ones are harmless: 1,682 reference nothing outside Max, so there is nothing for them to fail to find.

But 544 of them, 11% of everything on the site, are unfrozen and name a file they do not carry. The proportion barely moves between categories:

| Category | Devices | Will not work elsewhere | | --- | --- | --- | | Audio effects | 1,834 | 189 (10%) | | Instruments | 541 | 57 (11%) | | MIDI effects | 2,622 | 293 (11%) | | MIDI generators | 43 | 3 (7%) | | MIDI transformations | 45 | 2 (4%) |

133 of them name an absolute path on their author's own disk, which cannot resolve anywhere else under any circumstances:

3D Panner.amxd              Macintosh HD:/Users/<author>/Desktop/3D Pan.png
AirWindows_CreamCoat64…     C:/Users/<author>/Website Assets/Pictures/…
CutOff 1.03.amxd            D:/Imagenes/PERFILES/AMANTE_00.jpg
Elektron_Clock_Sync…        /Users/<author>/Desktop/2touch/Want2_Assets/…
ESX8GT Grids_M1.amxd        Macintosh HD:/Users/<author>/Desktop/MI_Icon.png

Each of those loads, shows its interface, and silently does not work. The author never saw it, because on their machine the file is right there.

This is the single most useful thing deps does, and it takes one command to check before you publish.

A file with no index gets one of three answers, because they call for very different responses: UNFROZEN (re-freeze it), DAMAGED (the patcher will not parse either — most likely truncated in transit, so check the file size against the source), or ENCRYPTED. The last is not a fault: Ableton ships its own built-in devices with the patcher in a ciph chunk instead of ptch, and nothing can be read out of one — 41 of the 404 devices installed on the machine this was tested against. Your own devices are never encrypted.

extract <file> [--out DIR] [--only NAME]

Write the embedded files out to disk, byte-exact.

Why you want this: the script inside a frozen device is a copy. Editing myscript.js in your source tree changes nothing until the device is re-frozen. extract lets you diff what actually shipped against what is in your repo — the two drift silently otherwise.

json <file> [--only NAME]

The patcher JSON to stdout. This is the whole document, so it is large; it exists mainly to feed git (below) and to pipe into other tools. --only emits one embedded dependency instead.

Compare

diff <old> <new>

A structural change report. git diff cannot help with a device file, and git cannot three-way merge one either.

$ maxpat-probe diff before.amxd after.amxd
PARAMETERS
  ~ Show Background Grid
      live.text in Settings  type=2  hidden
      live.text in Settings  type=2  stored

BOXES  (+1 added, -0 removed, 53 changed)
  attributes changed: hidden×51, patching_rect×1, items×1
  + Settings   newobj    print OverlayMenuUIVisibility
  ~ Settings   comment   GRID
      hidden: 1  ->  (absent)
  ~ Properties umenu     #4
      items: ["SIGNe_DefaultIcon",",","User"]  ->  "SIGNe_DefaultIcon"

CONNECTIONS  (+1 added, -0 removed)
  + Settings/[Settings] [0] -> Settings/print OverlayMenuUIVisibilit [0]

Five things are compared, and the ones after the first are what catch the changes you weren't looking for:

Embedded dependencies — added, removed, or content changed (compared by hash, so a same-size edit is still caught). Also flags a change in frozen state. A device whose script changed but whose patcher didn't is otherwise indistinguishable from an unchanged one:

EMBEDDED DEPENDENCIES
  - jit.gl.spoutsender.mxe64                311808B
  - marquee_selection_instancing_v8.js      126136B
  ~ marquee_selection_instancing.js         125196B -> 110313B  (content differs)

Parameters — added, removed or changed. A change of visibility or initial value shows up rather than passing silently.

Boxes — paired on subpatcher path, class, variable name and text, then compared in full. Not just position: a changed colour, hint, font, menu contents, presentation_rect, or hidden flag is reported, with the attribute named and its before/after values. The attributes changed: tally at the top summarises a run of identical edits so that fifty boxes toggling one flag reads as one line rather than fifty.

This matters more than it sounds. Max writes a lot of runtime state into the patcher file when you save — which panel of a tabbed interface was showing, what a umenu had been populated with — so opening a device to look at something and saving it can persist changes you never intended to make. A diff that only compared positions would show you none of that.

Connections — patchlines reference boxes by id, which is unstable, so a raw comparison of the connection lists is meaningless. Each endpoint is translated through the same stable box key, which makes rewiring visible.

Patcher attributes — settings on a patcher rather than on a box: openinpresentation, gridsize, the window rect, the project manifest. A change stamped across every patcher at once (appversion after a Max update, typically) is reported once with a count rather than repeated for each:

PATCHER ATTRIBUTES  (22 patchers changed)
  appversion (×22 patchers): {"major":9,"minor":1,"revision":4,…}  ->  {"major":9,"minor":1,"revision":5,…}
  ~ Settings
      visible: (absent)  ->  1

Long values are truncated for readability; maxpat-probe json has the full text.

A known limitation

Boxes identical in subpatcher, class, varname and text — several copies of the same abstraction side by side, say — can only be paired by document order. Reordering them can therefore read as a change. When such a group is involved in a reported change, the diff says so explicitly rather than leaving you to wonder:

NOTE  2 of the reported changes involve groups of identical sibling boxes
      (same subpatcher, class, varname and text). These are paired by document
      order, so a reordering within a group can read as a change.

Changing an object's text also reads as one box removed and another added, since text is part of a box's identity.

Why not pair on id? Every box in a patcher carries an id like "obj-177". It is not a stable identifier — it is a per-document serial number that Max hands out as it writes the file, and it reshuffles on almost every save. Adding one object near the top can renumber hundreds below it. Diffing on id therefore reports an entire patch as changed when nothing meaningful did. maxpat-probe ignores id entirely and identifies a box by where it sits and what it says, which is stable across saves. A test asserts this: renumbering every id produces an empty diff.

Options

| Option | Effect | | --- | --- | | --depth N | limit tree depth | | --out DIR | output directory for extract | | --only NAME | act on one embedded dependency instead of the device patcher | | --all | find also searches embedded abstractions | | --json | JSON output for params, stats, deps |


Reading patches from the forum

Patches on the Cycling '74 forum are almost never attached as files. They are pasted as a Copy Compressed block:

----------begin_max5_patcher----------
381.3ocyT91aBBCDF+89onoulsPgglsuJFioVu3pCZIsEGFie2Wuh+AyXLXK
yLR.JWeN5C+n2cXBwePWoqAK8Ex7vi3wgKiNKvO8sACSTvqE4bKlLUAuqWsk
…
-----------end_max5_patcher-----------

maxpat-probe reads those. Save the post to a file, or pipe it in — every command works on a blob, because it is decoded before anything else happens:

$ maxpat-probe stats forumpost.txt
flonum                         1
newobj                         1
number~                        1
--------------------------------
TOTAL                          3

$ pbpaste | maxpat-probe find - 'gate'
$ pbpaste | maxpat-probe json - > patch.maxpat

That last one is the useful one: it turns a forum post into a file you can open, diff, or keep.

Going the other way

compress emits a block ready to paste into a post:

$ maxpat-probe compress MyAbstraction.maxpat
----------begin_max5_patcher----------
395.3ocoRsjabCCC8pXv0LAxNSBRzEHHKxIXfw.ZalYz.8wPR1wEF9tWHU25
…
-----------end_max5_patcher-----------

It reduces the patcher to the shape Copy Compressed produces — boxes, lines, and the version keys, without the window attributes that belong to a document rather than to a selection — then compresses at level 9, which is what Max uses.

That last detail is checkable, and it is the one externally verifiable sign that the zlib header is right: every block Max writes begins 3o, and the default compression level would produce 3w.

Every block is decoded again before it is printed, so one that could not be read back is an error rather than something you find out about after posting it.

One caveat. This produces a block that is well-formed by every rule derived from real samples and that round-trips through the decoder. Whether Max accepts it on paste has not been verified, because that needs Max. If you try it, the result is worth an issue either way.

What you get is a fragment, not a document. Copy Compressed copies a selection, so the JSON is {boxes, lines, appversion, classnamespace} with no enclosing "patcher" key. maxpat-probe treats a bare object carrying boxes as a patcher, so this is invisible in normal use — but it is why tree usually shows nothing for a blob. There are rarely subpatchers in a pasted selection.

The encoding

Cycling '74 does not document it. What follows was derived by reading real blobs, and is checked against 71 of them found in the Max 8 and Max 9 documentation and in third-party repositories — all 71 decode to valid JSON and all 71 match their own declared length.

The 84 span roughly fifteen years: Max 9.2, 9.0, 8.6 and 8.1, plus six that carry no appversion at all. Those six predate the field and default to Arial, which places them in Max 5 — so the "max5" in the marker is accurate, and the encoding has not changed since.

Note that the Cycling '74 forum itself will not give you a blob by reading the page. A posted patch renders as a card with a Copy Patch button and the text lives nowhere in the HTML, so the way to get one is the way a person does it: click Copy, then pipe it in.

381.3ocyT91aBBCD…
│   └── payload
└────── number of compressed bytes the payload encodes

The payload is a continuous least-significant-bit-first stream, six bits per character, over this 64-character alphabet:

.ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+

Those bytes are a zlib stream; inflating gives the JSON.

Three things will catch you out if you write your own decoder:

  • It is not base64. The alphabet is rotated so . leads and + trails, and the bit order is reversed. Feeding it to a base64 decoder yields plausible bytes that inflate to nothing.
  • The trailing group can be partial. Four characters carry three bytes, but a final group of two or three characters is legal and carries one or two. Handle only whole groups and you truncate the stream — zlib then reports an unexpected end of file. Two thirds of real blobs end this way, which is exactly enough to make a naive decoder look like it works.
  • The declared count is compressed bytes, not decompressed. It is worth checking: a forum post that lost a line while being copied fails this test before zlib gets a confusing chance to.

Rendering .amxd diffs in git

Git has a feature called textconv: a per-filetype command that converts a file to text for display purposes only, so that a file git would otherwise treat as binary can be diffed readably. ("Driver" here is git's word for a pluggable handler — nothing to do with device drivers. It is also unrelated to Microsoft's texconv texture tool, which is a similar-looking name for an entirely different thing.)

Point it at maxpat-probe json and git will show device diffs as patcher JSON.

Two pieces are needed. First, in .gitattributes — commit this, it is shared:

*.amxd diff=amxd

That says "use the diff driver named amxd for these files" but does not say what that driver is. Second, name the command — this is per clone, and cannot be committed:

git config diff.amxd.textconv "maxpat-probe json"
git config diff.amxd.cachetextconv true

Git deliberately refuses to let a repository configure its own diff drivers: a driver is an arbitrary shell command, so cloning a repo would otherwise mean executing whatever its author chose. Every clone opts in explicitly.

After that, git diff, git log -p and git show render devices as JSON.

What this does and doesn't change

It is display-only. Git still stores .amxd as binary, still cannot merge two changed versions, and the diff is not applyable as a patch. git diff --stat still prints Bin 3103816 -> 2906500 bytes, because the stat line reports storage, not display.

Only tools that run git diff benefit. The git CLI does. Anything that implements its own diff rendering — including some GUI clients — may not, since they often detect binary content themselves and skip the driver, or use a git library rather than the git command. Worth testing in your client rather than assuming: make a trivial change to a device and see whether it renders.


The .amxd container

Useful to know if you are debugging something odd.

offset 0    "ampf" u32le(4) "mmmm"      magic, then the device type
offset 12   "meta" u32le(4) u32le(7)    format metadata
offset 24   "ptch" u32le(size)          everything below
offset 32   "mx@c" u32be(16) …          16-byte header ahead of the JSON
offset 48   {                           the device's own patcher JSON, plain UTF-8
   …        …                           embedded dependencies, concatenated
tail        "dire" records              index describing those dependencies

Those offsets are typical, not guaranteed. A device with no meta chunk puts ptch at offset 12, so its content starts at 20 rather than 32. Walk the chunk chain; do not hardcode the constant.

That is not a rare quirk. It is uncommon in Ableton's own output — 3 devices in 486, all copies of Max Compressor — but 10 of 55 third-party devices tested here are built that way, including every device in one published suite. A reader that assumes 32 works fine against factory content and then fails against real community devices, which is the worst way to discover it.

The file is a series of chunks. A chunk is a four-character tag naming what follows, then a length, then that many bytes of content — a common way to build extensible binary formats, in the same tradition as RIFF for WAV or FORM for AIFF.

The tail index — the term used throughout this document — is the run of dire records sitting at the end of the file, after all the embedded data. "Tail" because it is at the end; "index" in the book sense, a table of contents. Each record names one embedded file and says where to find it. It is a descriptive name rather than an official one; Cycling '74 does not document this format publicly, and everything here was derived by reading real files.

The four-character tags are Max's own, and short because the convention dates from when tags were literally four bytes:

| Tag | Meaning | | --- | --- | | ampf | the file's magic — the signature bytes at offset 0 that identify the format. Every .amxd starts with these four characters, which is how a tool knows what it is holding without trusting the file extension | | ptch | patch — the chunk containing the patcher and everything embedded with it | | ciph | cipher — stands in place of ptch when the patcher is encrypted. Ableton's built-in devices are shipped this way | | anno | annotation — the device's own description, as plain text. Optional, and sits between ampf and ptch when present, which is another reason to walk the chunk chain rather than assume offsets | | mx@c | a fixed 16-byte header sitting between the start of the ptch content and the JSON. Its length is declared in the big-endian u32 at +4 | | dire | directory — one record in the tail index, describing one embedded file | | type | the embedded file's kind — JSON, TEXT, svg, PNG, PICT, mx64, iLaX, or empty. See How externals are embedded | | fnam | filename — e.g. MIDITrigger.maxpat | | sz32 | size, 32-bit — how many bytes the embedded file occupies | | of32 | offset, 32-bit — where it starts, measured from the beginning of the ptch content | | vers, flag, mdat | version, flags, and a checksum-ish trailer; not needed for reading |

The four bytes immediately after the ampf tag and its length say what kind of device this is. Counts are from the 219 devices in Live 11 Suite, Live 12 Suite and SIGN/e:

| Code | Device type | Seen | | --- | --- | --- | | aaaa | audio effect | 152 | | iiii | instrument | 40 | | mmmm | MIDI effect | 23 | | nagg | MIDI generator (a Live 12 MIDI Tool) | 2 | | natt | MIDI transformation (a Live 12 MIDI Tool) | 2 |

A Max for Live device is frozen: every abstraction, script, shader and image it depends on is copied inside it. One .amxd therefore holds many documents. SIGNe-Text.amxd, for instance, carries 12 patcher documents plus JS and SVG payloads. The dire records are what make them recoverable — of32 and sz32 give each one's exact byte range.

How externals are embedded

What an external looks like inside a device depends on which binaries its package shipped — not on the platform you read the file with, and not, as you might expect, on the platform it was frozen on either. Max embeds every build it finds, so a device frozen on a Mac can carry a Windows DLL, and does.

The record shape follows the kind of file. A DLL is one file and becomes one record. A .mxo is a bundle — a directory — which cannot be stored as one payload, so Max decomposes it into four.

A DLL becomes a single record tagged mx64:

mx64  jit.gl.spoutsender.mxe64                    311808

A .mxo bundle becomes a marker plus three parts, all three of which have no type tag at all:

iLaX       live.stripstyles.mxo                             4
(untyped)  live.stripstyles/zipfile                      8406
(untyped)  live.stripstyles/executable                  32892
(untyped)  live.stripstyles/plist                        1530
  • iLaX is a 4-byte marker standing for the bundle. Its contents are four NUL bytes and nothing else.
  • The three untyped records carry the real content: a ZIP of the whole bundle, the Mach-O executable, and the Info.plist. The zipfile already contains the other two, so Max stores them redundantly — byte-identical, confirmed by hash.

A device can carry both. ease_pedale.amxd, frozen on an Apple Silicon Mac with Max 9.1.5, uses the ease package, which ships both builds:

iLaX       ease~.mxo                                        4
(untyped)  ease~/zipfile                               171940
(untyped)  ease~/executable                            583296
(untyped)  ease~/plist                                   1666
mx64       ease~.mxe64                                 422912

file(1) on that last record: PE32+ executable (DLL) (console) x86-64, for MS Windows. A macOS freeze embedded a Windows DLL, so the frozen device runs on either platform. A reader that decides how to handle a payload from the platform it is running on — or from the one the device was built on — gets this wrong.

The same rule explains a device that carries only mx64: SIGN/e embeds Spout, which has no macOS build, so there is no bundle to decompose.

Ableton's Note Echo.amxd makes the point from the other direction. It was frozen on a Mac, and the Windows and macOS installs of Live 10 ship the identical file — so the Windows copy contains a Mach-O executable, complete with __MACOSX AppleDouble sidecars, that could never load on Windows.

Three of those four bundle payloads end in a NUL byte — the marker, the ZIP and the executable; only the Info.plist does not. Add the mx64 record from a package shipping both builds and it is four out of five. That is why the decision about what to NUL-trim has to be an allowlist of text types rather than a list of known binaries: nobody had seen iLaX or an empty tag when this parser was written, and both fell through to "binary" correctly because unknown means binary. Invert that rule and you silently truncate a Mach-O.

maxpat-probe prints an empty tag as (untyped), since rendering it literally produces a blank column that reads as a display fault.

Building a device that exercises all of this

No such device is committed here. One would embed a third-party package's compiled binaries, and redistributing those is that author's call rather than this project's — so the fixture is a recipe instead. It takes a few minutes in Max and produces a file that covers every record shape at once.

  1. Install a package that ships both a .mxo and a .mxe64 build of an external. ease is one. Package Manager, or drop it in Packages/.
  2. Make a Max Audio Effect device. Put that external in it, and one trivial abstraction saved under a filename with a non-ASCII character — Pédale.maxpat is the one used above.
  3. Freeze it.

The result carries JSON, iLaX, untyped and mx64 records in one container, four of its five non-JSON payloads ending in NUL, plus a non-ASCII dependency name. Check it with:

maxpat-probe deps  yourdevice.amxd            # every record shape, labelled
maxpat-probe extract yourdevice.amxd --out ./out
npm run corpus -- .                         # the NUL / trim summary

Every payload should come out at exactly its declared size, except the device's own patcher, which loses one byte of NUL padding because it is a text type. If a binary comes out short, the trim allowlist has been broken.

The shape has held everywhere it has been looked for. Note Echo.amxd (Ableton, frozen 2017, i386 + x86_64), ease_pedale.amxd (Max 9.1.5, frozen 2026 on Apple Silicon, x86_64 + arm64) and a published third-party device carrying six FluCoMa externals all produce byte-for-byte the same four-record structure, redundancy included — across nine years and two architecture transitions. Bundle contents do vary: the newer bundles carry a _CodeSignature directory the 2017 one does not, so nothing should assume a fixed file list inside the ZIP.

The both-builds case is not exotic either. LivePilot_Analyzer.amxd embeds six externals, each as a Windows DLL and a macOS bundle — 31 dependency records in one device, of which 24 are the two external shapes interleaved.


Traps in the format

Every one of these was found by running the parser over real files and having it fail. They are recorded here so the next person writing a reader doesn't have to rediscover them.

of32 is relative, not absolute. Offsets are measured from the start of the ptch chunk's content, not from the start of the file. Treat them as file offsets and every extraction is 32 bytes off.

Sizes are stored inconsistently. The outer chunks use little-endian lengths that exclude the 8-byte header; dire and its sub-chunks use big-endian lengths that include it. Both conventions appear in the same file.

dire also occurs as ordinary text inside the patcher JSON — the word "direction" contains it. So finding the index cannot just be a search for those bytes. maxpat-probe validates a candidate by parsing the whole record chain from it — each record's length says where the next begins, so the records form a chain — and accepting it only if that chain lands exactly at EOF (end of file). The real index is the last thing in the file; a false match inside the JSON runs off into unparseable bytes long before the end.

Do not scan for the first { to find the JSON. The mx@c header ends in a 32-bit size, and that size can contain the byte 0x7b, which is {. Ableton's Poly Vocoder has 0x000b2c7b, so a naive scan starts one byte early and every parse of that device fails with a baffling message. Read the header's declared length from the big-endian u32 at +4 instead.

A dire record's size is not always its document's length. Max Compressor's own patcher record declares 101536 bytes for 98216 bytes of JSON; the surplus is a PNG with no dire record of its own. Write the record out verbatim and you get a .maxpat with an image glued to the end. Since a JSON file holds exactly one top-level value, clamp to the closing brace — but only when what follows is more than whitespace, or you will eat the trailing newline that every .maxpat genuinely does end with.


Max's JSON is not quite JSON

Two things JSON.parse rejects turn up in files Max itself wrote. 333 of the 10,051 readable files installed with Max 8/9 and Live 9-12 have one or the other:

Floats with a trailing point and no fractional digits — "rect" : [ 595., 44., 497., 630. ]. Common in older patchers. The error you get is the unhelpful Unterminated fractional number.

A raw control character inside a string literal, which JSON forbids outright. Six files have one, each inside a base64 blob of saved VST state.

maxpat-probe parses strictly first, so a well-formed document is never rewritten, and only falls back to a repair pass on failure. That pass is string-literal aware: a comment reading see fig. 3. must survive untouched, or extracted text stops matching the source it came from.


Reading only, on purpose

maxpat-probe will not write a .amxd back out.

Changing one byte of the patcher JSON means, at minimum:

  • rewriting the length stored in the ptch chunk size field, since the chunk's content just got longer or shorter;
  • rewriting every offset in the tail index — each of32 is an absolute position within the ptch content, so inserting a single character shifts every embedded file that follows it, and each one's recorded position is now wrong;
  • preserving the padding — the stored sizes for text payloads include trailing NUL and whitespace bytes that align records to predictable boundaries. Recompute a size without accounting for that padding and the index describes ranges that no longer match the data.

Get any of it subtly wrong and you get a device that loads but misbehaves, which is far worse than one that fails loudly — you would be debugging it in Live, at a distance from the actual cause. Max is the writer.

Plain .maxpat files are ordinary JSON, so edit those directly if you want.


Tests

npm test

Two layers.

Unit tests run against a small .maxpat committed in test/fixtures/, so they work in a clean checkout with no setup. They cover parameter extraction, three-state visibility, subpatcher walking, and brace-matching around braces inside string literals. The diff tests are the load-bearing ones:

  • renumbering every id yields an empty diff;
  • a non-positional attribute change is still detected;
  • an added connection is detected, and named by its endpoints rather than by the ids the file stores.

Container tests need real .amxd files, which cannot be committed here — they would be other people's copyrighted devices. Point the suite at a directory of your own:

MAXPAT_PROBE_FIXTURES=/path/to/devices npm test

Without that variable those tests skip rather than fail. When it is set, every device in the directory is checked to confirm that:

  • the container is recognised and its patcher parses;
  • every payload the tail index describes really is a well-formed document of its declared type — each JSON entry parses as JSON, each PNG has the PNG signature, each svg looks like SVG. This is the important one: it proves the offsets and sizes were read correctly, since a half-byte-misaligned slice will not parse;
  • the index agrees with an independent brace-scan of the file on how many patcher documents are present — two different methods reaching the same answer.

An unfrozen device is reported as skipped, not failed; it genuinely has no index to check. So is an encrypted one, which has nothing readable in it at all — so you can point the suite at a folder of Ableton's own devices without it drowning in failures.


Status

Early, but no longer only smoke-tested. The parser is run over every .amxd and .maxpat shipped with Max and Live on this machine:

| Corpus | Files | Result | | --- | --- | --- | | Max for Live devices | 486 | 445 read clean, 41 correctly reported as encrypted, 0 failures | | Plain .maxpat patchers | 9,606 | all parse |

Drawn from Live 9, 10, 11 and 12 Suite plus Max 8 and 9. A second machine ran the same suite over macOS installs of Live 9 through 12 — 6,100 files, also zero failures.

| Third-party corpus | Files | Result | | --- | --- | --- | | 24 openly-licensed GitHub repositories | 274 | 55 devices, 219 patchers, 0 failures | | maxforlive.com, every direct-download device | 5,271 | 5,183 devices, 0 parse failures, 10 inconsistent containers |

Those last two rows matter more than their size suggests. They are the only parts of the corpus not written by Ableton or Cycling '74, and between them they produced every surprise worth documenting here: the common missing-meta layout, the both-builds external device, seventeen dependency type tags that do not occur in a single factory file, and the only two containers whose index disagrees with their own data.

The type tags are the clearest illustration of why the trim rule is an allowlist. Six tags were known from 10,092 factory files. Reading a few hundred community devices took that to twenty-three — gDSP, AIFF, WAVE, Mp3, xdll, GenX, iLaF, maxb, GIFf, BMP and more. Every one was handled correctly without a code change, and nine of them end in NUL bytes that a denylist would have quietly trimmed away.

For the frozen devices that goes further than parsing the patcher: every payload the tail index describes is checked to be a well-formed document of its declared type, which is what proves the offsets and sizes were read correctly.

That corpus is older than it looks. Factory devices are frozen once and carried forward, so Live 12 still ships devices last saved by Max 6, and the spread runs from Max 5.1 to Max 9.1:

| Last saved by | Devices | Patchers | | --- | --- | --- | | Max 5.1 | — | 12 | | Max 6.0 – 6.1 | 253 | 819 | | Max 7.0 – 7.3 | 76 | 1,843 | | Max 8.0 – 8.6 | 16 | 2,328 | | Max 9.0 – 9.1 | 18 | 514 |

Testing on a second operating system is mostly redundant, which is worth knowing before anyone spends a day setting one up. Live 11 and 12 ship byte-identical .amxd files on Windows and macOS — same devices, same encrypted set, same dialect-repair count — so agreement across platforms on those files proves nothing about platform handling. A .amxd is a byte container: what varies is the machine a device was frozen on, and that travels inside the file. The parts genuinely worth running on a second OS are the path-safety tests, which are platform-conditional, and extract, which writes to a real filesystem.

What it does not cover is third-party devices. Every file above was built by Ableton or Cycling '74 — one team, one freezing pipeline, one set of project conventions. The layouts that break a parser come from everyone else. If you have devices from other developers, running npm run corpus against them is the single most useful thing you can do with this repo, and a failure there is worth an issue.

The traps listed above were all found this way, and each one was invisible until a real file exercised it.

Run it against your own install, which takes about a minute and needs no setup:

npm run corpus                       # search the usual install locations
npm run corpus -- /path/to/devices   # or point it somewhere

It walks everything it finds, reports failures, and prints the container layouts and dependency type tags it saw. That last table is the one to look at on a platform other than Windows: it flags any payload type that ends in a NUL byte, which is the signature of the trimming bug that truncated a compiled external by 104 bytes. A tag showing up there as text, trimmed while ending in NUL is a bug — please open an issue.

Reporting a problem. The single most useful thing is the file that reproduces it. Failing that, the output of:

maxpat-probe deps yourdevice.amxd
maxpat-probe --version

plus your Max version and platform. If the file is private, deps output alone is often enough — it shows the tail index, which is where format surprises turn up.

If you have devices to test against, pointing the suite at a folder of them is a real check rather than a smoke test — it verifies that every payload the index describes is a well-formed document of its declared type:

MAXPAT_PROBE_FIXTURES=/path/to/devices npm test

Not affiliated with or endorsed by Cycling '74 or Ableton.

License

MIT