PythonTap
Write Max objects in Python. PythonTap embeds a CPython interpreter in a Max package and runs a Python class as a Max object, in one of two ways:
tap.python~runs the class as an audio object: itsprocess()method is called on the signal — once per signal vector with numpy arrays, or once per sample with floats.tap.pythonruns it as an ordinary object, without audio: messages in, messages out — and what a method returns is what the object outputs.
Either way:
- The class's type-annotated attributes become Max attributes (
@gain 0.5in the object box,gain 0.5,getgain, attrui — it all works). - The class's public methods become Max messages, called according to their signatures, with arguments converted according to their type hints.
- The source file is watched and hot-reloaded every time you save it, keeping attribute values, so you can live-code with Max running.
- Python's
print()output and tracebacks land in the Max console, posted from Max's main thread whatever thread printed — and no exception your code raises,sys.exit()included, can take Max down.
from attrs import define, field
@define
class default:
"""The class loaded by tap.python~ and tap.python when no source argument is given.
Demonstrates the mapping from Python to Max:
- the annotated 'gain' field becomes a Max attribute
- any public method (like 'greet') becomes a Max message
- methods named 'int' and 'float' answer those standard Max messages
- process() runs on the audio signal in tap.python~, once per sample (see
numpy_gain.py for the much cheaper once-per-vector form)
- in tap.python, what a method returns is output: bang outputs the gain
"""
gain: float = field(default = 1.0)
def greet(self, name: str) -> None:
print(f"hello {name}, from python!")
def process(self, x: float) -> float:
return x * self.gain
def bang(self) -> float:
return self.gain
# These two come last on purpose: once `def float` has run, the name `float` in
# the class body means this method, so an annotation written after it would no
# longer mean the built-in type. (Inside method bodies the built-ins are unaffected.)
def int(self, new_gain: int) -> None:
self.gain = float(new_gain)
def float(self, new_gain: float) -> None:
self.gain = new_gain
[tap.python~ default] ← loads python/default.py: a 'gain' attribute, process() on the signal
[tap.python] ← the same file, without audio: a bang outputs the gain
The package comes with CPython 3.13, numpy and attrs, on macOS (Apple Silicon and Intel) and Windows, for Max 9.
This book
- Installing the package, and adding Python packages to its runtime.
- Writing a class: the file, attributes, messages, hot reload — what is the same
for both objects. Then Audio and Worker mode for
tap.python~, and Objects without audio fortap.python. - Performance, measured, and Errors and limits: what the objects guard against and what they cannot.
- The examples in the package's
pythonfolder, and a notebook that runs one of them the way the object does. - For contributors: Building, Testing and the design.
In Max itself, the package's guide Writing Max Objects in Python and three tutorials are in the Documentation window (Package Docs › PythonTap), and PythonTap Overview is in the Extras menu.
Installing
Requirements
- Max 9 or later (macOS 11.0+ on Intel or Apple Silicon, Windows 10 22H2+ / Windows 11, 64-bit).
Download a package from the releases page — the Python runtime (CPython 3.13 with attrs and numpy) is included:
PythonTap-<version>.zip— every platform at once: Apple Silicon and Intel Macs and Windows, each with its own runtime (insupport/macos-arm64,support/macos-x86_64andsupport/windows-x64). The largest; the one to use when a patch travels between machines.PythonTap-<version>-macos-arm64.zip— Apple Silicon Macs.PythonTap-<version>-macos-x86_64.zip— Intel Macs, and Apple Silicon Macs running Max under Rosetta.PythonTap-<version>-windows-x64.zip— Windows.
Unzip it into your Documents/Max 9/Packages folder, restart Max, and open PythonTap Overview from the Extras menu — or the help patcher of tap.python~ or tap.python, or the package's guide and tutorials in the Documentation window. Each zip has a .sha256 beside it, and the release's SHA256SUMS lists them all. The package's licenses/ folder holds the license of everything it ships.
Releases are not code-signed yet. On a Mac, a downloaded unsigned external is quarantined and Max will not load it; clear the quarantine once after unzipping:
xattr -dr com.apple.quarantine ~/Documents/"Max 9"/Packages/PythonTap
On Windows, SmartScreen may warn about the download.
To add more Python packages to the bundled runtime:
./support/bin/python3 -m pip install <package> # macOS, from the package folder
.\support\python.exe -m pip install <package> # Windows
In the package for every platform, the runtime is in support/<platform>/ — ./support/macos-arm64/bin/python3, say, or .\support\windows-x64\python.exe — and a package you add goes into that platform's runtime only. If you use uv, uv pip install --python support/bin/python3 <package> (or support\python.exe on Windows) does the same, faster; nothing in the package needs uv.
From a clone of this repository
Max finds the package only in its Packages folder, so clone it there (or clone it anywhere and symlink it in, e.g. ln -s ~/src/PythonTap ~/Documents/"Max 9"/Packages/PythonTap), with its submodules:
cd ~/Documents/"Max 9"/Packages
git clone --recursive https://github.com/tap/PythonTap.git
A clone has no runtime: install it into the package's support folder (from python-build-standalone), then build (see Building from source):
macOS — in Terminal, from the package root:
./scripts/install-runtime.sh
Windows — in PowerShell, from the package root:
powershell -ExecutionPolicy Bypass -File scripts\install-runtime.ps1
The script verifies the download against the SHA256 committed in scripts/runtime.lock, and installs the Python packages used by the examples (attrs, numpy) at the versions and hashes pinned in scripts/requirements.lock. Re-running it replaces the runtime safely: the previous one is kept until the new one is complete and restored if anything fails — but packages you added yourself are not carried over.
Without a runtime the object still loads, and says in the Max console what is missing. On a Mac, restart Max after installing the runtime; on Windows the next tap.python~ you create picks it up.
Writing a class
Python sources live in the package's python folder. [tap.python~ name] loads python/name.py and instantiates the class name defined in it (the file, class, and argument must share the same name, which must be a valid Python identifier; with no argument, default is loaded). The file is loaded by its path, never through import, so a file named like a standard-library module (random.py, json.py) works and does not shadow that module for anyone else. Other files in the python folder can be imported by your class as helper modules (the folder is on sys.path, after the standard library) — a helper is imported afresh whenever a class file that changed is saved (the loader forgets every module imported from the folder first), but a save of the helper alone is not watched: save the class file too, with a change, to pick it up. No bytecode is written for anything in the folder. Text is UTF-8: the interpreter runs in Python's UTF-8 mode, so open() reads and writes UTF-8 unless you pass another encoding, whatever the machine's locale.
Attributes
Class-level annotated fields (e.g. via attrs) become Max attributes. int maps to a Max long, float to float64, bool to an on/off long (your field receives a real bool), anything else to a symbol; Optional[X], X | None, Annotated[X, …] and Final[X] count as X, a subclass of one of those types (np.float64, an IntEnum) as its base, and numpy's scalar types (np.int64, np.bool_) as the kind they hold. A union of several kinds (float | int, str | float) is a symbol attribute in Max whose value reaches your field as the atom carried it — an int, float or str — like an unannotated parameter. Names starting with _ are private, and ClassVars are not fields. If a hint cannot be resolved (say a name imported only under TYPE_CHECKING), the console says so and the annotation is read as written.
Messages
Public methods (including classmethods and staticmethods) become Max messages, called according to their signature: parameters with defaults are optional, *args takes any number of arguments, and a keyword-only parameter must have a default (a method with a required keyword-only parameter is not exposed). Argument hints (int, float, bool, str) drive the conversion from Max atoms; an unannotated parameter receives the atom as it is (an int, float or str). A last parameter hinted list[float] (or list[int], list[str], list) or np.ndarray, with no *args after it, takes every atom left after the others — as one list, or a float64 array — so def taps(self, values: list[float]) answers taps 0.1 0.2 0.3; with none left it is empty, or its default. Methods named int, float, symbol, and bang map to those standard Max messages. Names Max or the object handle themselves (filechanged, dsp64, notify, assist, loadbang, dblclick, anything, …, and the object's own attributes mode, latency and latencysamples) are not exposed — as methods, or as fields; the console names any skipped this way so you can rename it.
Hot reload
Saving the .py file reloads it in place, as a fresh module (names you deleted from the file are gone): the attributes and messages follow the new class, and audio resumes with the new code. Until the new code is ready the object keeps running the old one, so a successful reload swaps in without a gap in the audio. Attribute values carry over — set from the patcher or by your own code — for every attribute the new class still has with the same type; an attribute whose type changed starts from its new default, and one you removed disappears from the object. If the file has an error, the object prints the traceback to the Max console and outputs silence until the next successful reload (and the attribute values come back with it); a save that breaks a file shared by many objects is reported once, not by each, while an object created later with the file still broken says so again. Every object using the same file shares one execution of it per save, and the console says so once — Loaded name.py: process() bound, one call per sample (or per vector), from whichever object ran the file — along with anything true of the class, such as a method skipped for its name; a save that changes nothing reloads silently. Errors that belong to one object, such as an exception in its constructor, are still reported by each. Max's file watcher notices a save within a couple of seconds, but it coalesces saves made in quick succession — a script saving once a second, say — so the object then reloads for only some of them, and can lag behind until they stop; sending the object filechanged reloads it at once.
Audio: tap.python~
A method process() runs on the signal, in one of two forms chosen by its type hint:
process(self, x: np.ndarray) -> np.ndarrayis called once per signal vector with a numpy array of the input and must return an array of the same length (any numeric dtype, or a list; it is converted). This is the form to use for anything that must run in real time — seepython/numpy_gain.py, andpython/numpy_allpass.pyfor a filter with feedback. The input array is reused from one call to the next: copy it if you want to keep it.process(self, x: float) -> floatis called once per sample — simplest for sketching; the call is cheap, but every line of Python in it runs once per sample (see the performance chapter).
Its parameters are the object's signal inlets and its return hint its outlets: process(self, left: np.ndarray, right: np.ndarray) -> tuple[np.ndarray, np.ndarray] makes an object with two of each (see python/stereo_width.py), and process(self) -> float, with no inputs, a generator — the first inlet is always there, for messages. The inputs are all np.ndarray or all per sample, and a tuple return must say how many values it has (tuple[float, float]). A save that changes how many changes the object's inlets and outlets to match, keeping the patch cords of those that stay. Wrap the object in mc. — [mc.tap.python~ numpy_gain] — to run one instance per channel of a multichannel signal. A class's attributes and messages are each instance's own, so Max's MC wrapper passes them on through its own messages, not as they are: setvalue 2 gain 0.1 to the second instance, applyvalues gain 0.5 0.25 one value to each; a plain gain 0.5 is not understood. Output that is not a number, or not finite (NaN, infinity), is replaced with 0.0 and reported once in the Max console, as is a return with the wrong number of values.
Audio settings
An optional method prepare(self, sample_rate: float, vector_size: int) -> None is called with Max's sample rate and vector size before the object processes any audio, again whenever they change, and on every reload before the new code runs. python/allpass.py uses it to size its delay line.
Worker mode
By default process() runs on Max's audio thread, so whatever else holds Python's interpreter — a reload, a message, another instance — holds up the audio while it does. With @mode worker it runs on a thread of its own instead, @latency milliseconds behind the audio (30 by default, rounded up to whole signal vectors): the audio thread then only copies vectors to and from that thread, and never waits for Python. Max computes a whole I/O vector's worth of signal vectors at once, so @latency must be longer than the I/O vector (Options > Audio Status: 512 samples is 11.6 ms at 44.1 kHz) — what is left over is the time Python has to keep up; raise it with a larger I/O vector. If Python falls further behind, the vectors it is late for are output as silence and the console says so, once per load; the delay stays the same. The read-only @latencysamples gives the delay in samples, for aligning other signal paths (a delay~, say); in direct mode it is 0. The worker thread has the real-time scheduling of an audio thread. @mode and @latency take effect as soon as they are set, by rebuilding the signal chain. Stopping the worker (DSP off, a chain rebuild, the object deleted) waits up to 100 ms for the vector in progress; a process() that has not returned by then is interrupted with a WorkerStopped exception (a BaseException, so except Exception: does not swallow it), that vector is silence, the console says so once, and the class stays bound; one that still has not returned after a further 250 ms is blocked in a call Python cannot interrupt (time.sleep, a long C call) and is abandoned: audio continues on a new worker, while the abandoned thread keeps costing a core until Max quits.
Objects without audio: tap.python
[tap.python name] runs a class as an ordinary Max object — messages in, messages out, no signal — with everything Writing a class says but the audio: the same python folder and loader (one file can serve a tap.python~ and a tap.python at once, executed once per save), the same attributes, messages, hot reload, console and error guards. And one thing more: what a method returns is what the object outputs.
from attrs import define, field
@define
class euclid:
steps: int = field(default = 8)
pulses: int = field(default = 3)
def bang(self) -> list[int]:
steps = max(1, self.steps)
return [int((i * self.pulses) % steps < self.pulses) for i in range(steps)]
[tap.python euclid] ← a bang outputs 1 0 0 1 0 0 1 0; steps 16, @pulses 5 or an attrui change it
Output
What a method returns goes out of the object before the message returns, converted by its type:
| The method returns | The object outputs |
|---|---|
None | nothing |
a bool (numpy's too) | an int, 0 or 1 |
an int (anything with __index__: np.int64, an IntEnum) | an int (64-bit: a value past it is reported, and nothing is output) |
a float (anything with __float__: np.float32) | a float — NaN and infinity as they are |
a str | symbol <s>: one symbol, whatever it holds — "hello world" is one symbol, "60" the symbol and not the number |
a list, a range, a 1-D np.ndarray, or a tuple from a method not hinted -> tuple[…] | a list, each element by the rows above — or, when the first element is a str, the message it names (["note", 60, 100] outputs note 60 100); an empty one outputs nothing |
n values from a method hinted -> tuple[…] of n members | one value per outlet, right to left; None in a slot outputs nothing from that outlet |
anything else — a dict, a set, bytes, a 2-D array, an object — or a list holding None or another list | nothing, and the console says why |
A str is output as symbol <s> because that is what reaches every receiver as the string it is: as the message it names, "bang", "int" and "list" would not be data at all. [sel C] matches it; [route C] does not (put [route symbol] first), [prepend] keeps the word symbol, and a message box's [set $1( displays it. To output the message a string names, return it in a list — ["start"].
Outlets
The object has as many outlets as the widest tuple[…] return hint among its methods, at least one, plus a dumpout at the right: getsteps outputs steps 8 from it, as a Max object with attributes does. The hint is the one place a type hint changes what a value does: unhinted, a returned tuple is a list from the first outlet. A tuple of unsaid length (tuple[int, ...]) keeps one outlet and is output as a list, which the console says once. A save that changes how many outlets the class needs changes the object's outlets in place, keeping the patch cords of those that stay.
Messages
As in tap.python~, called according to their signatures, with int, float, symbol, bang and list answering those standard messages. A method named anything answers every message the class has no method or attribute for, with the selector first — def anything(self, selector: str, *args); without one, the object says it doesn't understand, as Max objects do. process() and prepare() are ordinary methods here: process 0.5 calls the class's process() and outputs what it returns, a way to try a filter sample by sample. The names Max or the object handle themselves are tap.python~'s less the audio ones (dsp64, mode, latency and the rest are free), plus dumpout.
Threads
A message runs on the thread it arrives on, as an ordinary Max object's does — Max's main thread, or its scheduler thread with Overdrive on — and its result is output before the message returns, so trigger, metro and the rest compose with it as with any object. The limits:
- A method holds the thread it runs on for as long as it takes: under Overdrive, a heavy method driven by a
metroholds Max's scheduler meanwhile. Sent through[deferlow], it runs on the main thread instead. - With Scheduler in Audio Interrupt on (and audio running), the scheduler thread is the audio thread, so a message from a
metroruns Python on the audio thread, where it waits for any other Python — a reload, another object's message — and can interrupt the audio. The object says so in the console, once per session;[deferlow]takes the work off the audio thread. - What a method returns is the only output: a thread your class starts (
threading.Thread) has no outlet to reach. - A
dictis not output as a Max dictionary yet.
Performance
A message costs the conversion of its arguments in, your method, and the conversion of what it returns out, all on the thread the message came on; the performance chapter has what the bridge itself costs.
Performance
process() runs on the audio thread, holding Python's global interpreter lock. The per-sample form (x: float) makes one Python call per sample. The call itself is cheap — a fraction of a percent of a core, in the measurements below — but every line of Python in it runs 48,000 or 96,000 times a second, so the cost is your code's: allpass.py, a few lines of indexing and arithmetic, costs more than twenty times the call — and numpy_allpass.py, the same filter written a vector at a time, a small fraction of it.
It is fantastic for sketching and live-coding an algorithm. The numpy form (x: np.ndarray) makes one call per signal vector, and numpy then works on the whole vector at C speed: it is the one to use when the algorithm has real work in it and has to keep up.
Either way, in the default direct mode, the audio thread waits while other Python code holds the interpreter — a reload, a message, or another instance: all tap.python~ instances share one interpreter, so heavy Python work in one can steal time from the others. Worker mode (@mode worker) takes Python off the audio thread, at the cost of a fixed delay.
The object has Python hand the interpreter to a waiting thread after half a millisecond — a tenth of CPython's default — so a reload, which holds it for a few milliseconds, delays the audio a little at a time instead of all at once: measured with core/bench's reload benchmark (96 kHz, 512-sample buffers, a save every 100 ms), no buffer was late at 0.5 ms, where CPython's default left some late in most runs.
Errors in process() are printed from Max's main thread, never from the audio thread — and so is anything process() prints, or a warning it raises (numpy's RuntimeWarning for a division by zero, say): on any thread but Max's main one, a complete line is queued, without locking or allocating, and posted from the main thread a moment later, so it may appear after lines printed meanwhile from the main thread. A flood is coalesced: the queue holds 256 lines, and what does not fit is dropped and counted in one line. What still happens on the audio thread is Python's own work of formatting a warning, which reads the source file once.
What process() costs, measured by core/bench — which calls it exactly as Max's audio thread does, one vector at a time — as the share of one CPU core it needs:
process() | Cost (at 48 kHz) | CPU at 48 kHz | CPU at 96 kHz |
|---|---|---|---|
| returns its input (the bridge alone) — per sample, vectors of 64 | 45 ns per sample | 0.22% | 0.43% |
default.py, a gain (an attrs class) — per sample, vectors of 64 | 58 ns per sample | 0.28% | 0.57% |
allpass.py, a Schroeder allpass filter — per sample, vectors of 64 | 1.2 µs per sample | 6% | 12% |
| returns its input (the bridge alone) — per vector of 64 | 0.23 µs per vector | 0.017% | 0.034% |
| returns its input (the bridge alone) — per vector of 512 | 0.52 µs per vector | 0.0049% | 0.0097% |
numpy_gain.py, a gain — per vector of 64 | 0.97 µs per vector | 0.073% | 0.15% |
numpy_gain.py, a gain — per vector of 512 | 1.5 µs per vector | 0.014% | 0.028% |
numpy_allpass.py, the same allpass filter — per vector of 64 | 7 µs per vector | 0.53% | 0.68% |
numpy_allpass.py, the same allpass filter — per vector of 512 | 31 µs per vector | 0.29% | 0.34% |
numpy_allpass.py computes exactly what allpass.py does — the core battery checks it sample for sample — and in 64-sample vectors needs 11× less at 48 kHz (0.53% of a core against 6%) and 18× less at 96 kHz (0.68% of a core against 12%). It computes a vector in stretches no longer than the delay, one numpy expression each: the default 1 ms delay is 48 samples at 48 kHz, so a 64-sample vector takes two, and 96 at 96 kHz, so it takes one — a delay shorter than the vector costs more.
The share of one core: each row timed once per round for seven rounds, interleaved, keeping its fastest; every median was within 32% of it. Measured 2026-09-30 on Intel(R) Core(TM) i9-8950HK CPU @ 2.90GHz, macOS 15.7.9 (x86_64), CPython 3.13.14, numpy 2.5.3, load average 2.4.
In Max itself, measured by Max's own CPU meter — which covers everything the audio thread does around process() too — beside what core/bench predicts:
| In Max at 96 kHz | Instances | Max's CPU meter | Each | core/bench, each |
|---|---|---|---|---|
default.py, per sample | 26 | 22% | 0.84% | 0.55% |
numpy_gain.py, per vector of 64 | 26 | 7% | 0.28% | 0.14% |
allpass.py, per sample | 1 | 18% | 18% | 12% |
numpy_allpass.py, per vector of 64 | 26 | 28% | 1.1% | 0.69% |
Max's own DSP CPU meter (adstatus cpu): the mean of ten readings a second apart with the instances running in a poly~, less the reading with no object (0.0%); each divides by the number running. The audio device ran at 96 kHz with 64-sample signal vectors and a 512-sample I/O vector. The meter reads in whole percent. Measured 2026-09-30 with Max 9.1.5, on Intel(R) Core(TM) i9-8950HK CPU @ 2.90GHz, macOS 15.7.9 (x86_64), CPython 3.13.14, numpy 2.5.3.
Errors and limits
An exception raised by your code (in process(), a message, an attribute setter, the constructor or at import) prints its traceback to the Max console and never takes Max down. That includes sys.exit(), which is reported like any other exception rather than quitting Max. What the object cannot catch is what never reaches Python's exception machinery: a process exit below it (os._exit(), os.abort()), a crash in a C extension (a broken wheel, ctypes), or code that never returns — an endless loop in a message or the constructor freezes Max's main thread, and in process() it stalls the audio thread (in worker mode, only the worker thread, which is interrupted or abandoned when the worker stops — see Worker mode). Another Max external that embeds its own CPython alongside this one is untested and unsupported: two interpreters in one Max share process-wide state neither expects to.
In tap.python, a method that raises outputs nothing: its traceback is printed instead. A file that
fails to load leaves a tap.python outputting nothing, and a tap.python~ outputting silence, until a
save fixes it.
The examples
The package's python folder ships these classes. Each is the file itself, included here as it
ships; open one in a text editor beside Max, save a change, and the objects using it reload.
For tap.python~
default.py — a gain, per sample
What both objects load with no argument: a gain attribute, the messages greet, int, float and
bang, and process() once per sample. In tap.python, process() is a message like the others.
from attrs import define, field
@define
class default:
"""The class loaded by tap.python~ and tap.python when no source argument is given.
Demonstrates the mapping from Python to Max:
- the annotated 'gain' field becomes a Max attribute
- any public method (like 'greet') becomes a Max message
- methods named 'int' and 'float' answer those standard Max messages
- process() runs on the audio signal in tap.python~, once per sample (see
numpy_gain.py for the much cheaper once-per-vector form)
- in tap.python, what a method returns is output: bang outputs the gain
"""
gain: float = field(default = 1.0)
def greet(self, name: str) -> None:
print(f"hello {name}, from python!")
def process(self, x: float) -> float:
return x * self.gain
def bang(self) -> float:
return self.gain
# These two come last on purpose: once `def float` has run, the name `float` in
# the class body means this method, so an annotation written after it would no
# longer mean the built-in type. (Inside method bodies the built-ins are unaffected.)
def int(self, new_gain: int) -> None:
self.gain = float(new_gain)
def float(self, new_gain: float) -> None:
self.gain = new_gain
numpy_gain.py — a gain, per vector
The same gain written for numpy: process() is hinted with arrays, so it runs once per signal
vector. See Performance for what that saves.
import numpy as np
from attrs import define, field
@define
class numpy_gain:
"""A gain, processed a whole signal vector at a time with numpy.
Because process() is annotated np.ndarray, tap.python~ calls it once per signal
vector instead of once per sample — far cheaper, and the way to write anything
that has to run in real time. x is reused from one call to the next: copy it
if you want to keep it.
"""
gain: float = field(default = 1.0)
def process(self, x: np.ndarray) -> np.ndarray:
return x * self.gain
allpass.py — a filter that knows the sample rate
A Schroeder allpass filter, per sample: prepare() turns its delay in milliseconds into samples, and
attrs validators guard its fields. The notebook runs it the way the object does.
import numpy as np
from attrs import define, field
@define
class allpass:
"""Schroeder allpass filter over a delay line, for use with tap.python~.
y[n] = alpha * x[n] + x[n - D] - alpha * y[n - D], with D the delay in samples:
unity gain at every frequency, with a frequency-dependent phase shift.
"""
delay: float = field(default = 1.0) # delay time in ms
alpha: float = field(default = 0.5) # allpass coefficient, strictly between -1 and 1
_fs: float = field(init = False, default = 48000.0) # set by prepare()
_delay_in_samples: int = field(init = False, default = 1)
_x: np.ndarray = field(init = False, factory = lambda: np.zeros(1))
_y: np.ndarray = field(init = False, factory = lambda: np.zeros(1))
_write_index: int = field(init = False, default = 0)
_read_index: int = field(init = False, default = 0)
# Validators also run on plain attribute assignment, which is how tap.python~
# sets attributes from Max. During __init__ they fire before the private
# fields exist, so guard with hasattr and do the initial setup in post-init.
@delay.validator
def _delay_changed(self, attribute, value):
if value < 0.0:
raise ValueError("delay must be non-negative")
if hasattr(self, "_delay_in_samples"):
self._update_delay(value, self._fs)
@alpha.validator
def _check_alpha(self, attribute, value):
# |alpha| = 1 puts the feedback pole on the unit circle: the filter no longer decays
if not -1.0 < value < 1.0:
raise ValueError("alpha must be strictly between -1.0 and 1.0")
def __attrs_post_init__(self):
self._update_delay(self.delay, self._fs)
self.clear()
def prepare(self, sample_rate: float, vector_size: int) -> None:
"""Called by tap.python~ with Max's audio settings, before audio and whenever they change."""
self._fs = sample_rate
self._update_delay(self.delay, sample_rate)
def _update_delay(self, delay_in_ms, sampling_frequency):
new_delay_in_samples = max(1, int((delay_in_ms / 1000.0) * sampling_frequency))
if new_delay_in_samples != self._delay_in_samples:
self._delay_in_samples = new_delay_in_samples
print(f"Setting delay to {delay_in_ms} ms ({new_delay_in_samples} samples @ {sampling_frequency:g} Hz)")
self.clear()
def clear(self) -> None:
self._x = np.zeros(self._delay_in_samples)
self._y = np.zeros(self._delay_in_samples)
# process() reads before it writes, so reading the slot about to be
# overwritten yields the value written exactly _delay_in_samples ago
self._write_index = 0
self._read_index = 0
def process(self, x: float) -> float:
x1 = self._x[self._read_index]
y1 = self._y[self._read_index]
y = x1 + (x - y1) * self.alpha
self._x[self._write_index] = x
self._y[self._write_index] = y
self._read_index = (self._read_index + 1) % self._delay_in_samples
self._write_index = (self._write_index + 1) % self._delay_in_samples
return y
numpy_allpass.py — the same filter, per vector
The same filter a vector at a time — the core battery checks the two sample for sample.
import numpy as np
from attrs import define, field
@define
class numpy_allpass:
"""The allpass filter of allpass.py, processed a whole signal vector at a time with numpy.
y[n] = alpha * x[n] + x[n - D] - alpha * y[n - D], with D the delay in samples. The
feedback reaches back D samples, so any stretch of up to D samples depends only on samples
before it: each stretch is one numpy expression, and a vector is split only when the delay
is shorter than it. The output is the same as allpass.py's, sample for sample, at a small
fraction of the cost — compare the two in the PythonTap book's Performance chapter.
"""
delay: float = field(default = 1.0) # delay time in ms
alpha: float = field(default = 0.5) # allpass coefficient, strictly between -1 and 1
_fs: float = field(init = False, default = 48000.0) # set by prepare()
_vector_size: int = field(init = False, default = 64) # set by prepare()
_delay_in_samples: int = field(init = False, default = 1)
# the last D inputs and outputs, then room for one vector
_x: np.ndarray = field(init = False, factory = lambda: np.zeros(1))
_y: np.ndarray = field(init = False, factory = lambda: np.zeros(1))
# As in allpass.py: validators also run when tap.python~ sets an attribute, and during
# __init__ they fire before the private fields exist, hence the hasattr guard.
@delay.validator
def _delay_changed(self, attribute, value):
if value < 0.0:
raise ValueError("delay must be non-negative")
if hasattr(self, "_delay_in_samples"):
self._update_delay(value, self._fs)
@alpha.validator
def _check_alpha(self, attribute, value):
# |alpha| = 1 puts the feedback pole on the unit circle: the filter no longer decays
if not -1.0 < value < 1.0:
raise ValueError("alpha must be strictly between -1.0 and 1.0")
def __attrs_post_init__(self):
self._update_delay(self.delay, self._fs)
self.clear()
def prepare(self, sample_rate: float, vector_size: int) -> None:
"""Called by tap.python~ with Max's audio settings, before audio and whenever they change."""
self._fs = sample_rate
if vector_size != self._vector_size:
self._vector_size = vector_size
self.clear()
self._update_delay(self.delay, sample_rate)
def _update_delay(self, delay_in_ms, sampling_frequency):
new_delay_in_samples = max(1, int((delay_in_ms / 1000.0) * sampling_frequency))
if new_delay_in_samples != self._delay_in_samples:
self._delay_in_samples = new_delay_in_samples
print(f"Setting delay to {delay_in_ms} ms ({new_delay_in_samples} samples @ {sampling_frequency:g} Hz)")
self.clear()
def clear(self) -> None:
self._x = np.zeros(self._delay_in_samples + self._vector_size)
self._y = np.zeros(self._delay_in_samples + self._vector_size)
def process(self, x: np.ndarray) -> np.ndarray:
d, n = self._delay_in_samples, len(x)
if d + n > len(self._x): # a longer vector than prepare() announced: make room, keeping the history
self._x = np.concatenate((self._x[:d], np.zeros(n)))
self._y = np.concatenate((self._y[:d], np.zeros(n)))
xs, ys, alpha = self._x, self._y, self.alpha
xs[d:d + n] = x
for start in range(0, n, d): # at most d samples at a time: each y[n - d] is already computed
end = min(start + d, n)
# written as allpass.py computes it, so the two agree to the last bit
ys[d + start:d + end] = xs[start:end] + (xs[d + start:d + end] - ys[start:end]) * alpha
y = ys[d:d + n].copy()
xs[:d] = xs[n:n + d] # keep the last d inputs and outputs for the next vector
ys[:d] = ys[n:n + d]
return y
stereo_width.py — two inputs, two outputs
process()'s parameters are the object's signal inlets and its return hint its outlets.
import numpy as np
from attrs import define, field
@define
class stereo_width:
"""Stereo width: two signal inputs and two outputs, left and right.
process()'s parameters are the object's signal inlets — [tap.python~ stereo_width] has two —
and its return hint, tuple[np.ndarray, np.ndarray], gives it two outlets. The signal is split
into what the channels share (mid) and what differs (side); width scales the side: 0 folds
the pair to mono, 1 leaves it as it was, more than 1 widens it.
"""
width: float = field(default = 1.0)
@width.validator
def _check_width(self, attribute, value):
if value < 0.0:
raise ValueError("width must be 0 or more")
def process(self, left: np.ndarray, right: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
mid = (left + right) * 0.5
side = (left - right) * (0.5 * self.width)
return mid + side, mid - side
For tap.python
euclid.py — a rhythm from a bang
from attrs import define, field
@define
class euclid:
"""A Euclidean rhythm, for tap.python: a bang outputs `steps` 0s and 1s with `pulses` of the 1s
spread as evenly as they go — 8 steps and 3 pulses is 1 0 0 1 0 0 1 0 — a list to drive a step
sequencer.
@steps and @pulses are its attributes, so `steps 16` or an attrui changes the pattern; what
bang() returns, a list of ints, is what the object outputs.
"""
steps: int = field(default = 8)
pulses: int = field(default = 3)
def bang(self) -> list[int]:
steps = max(1, self.steps)
return [int((i * self.pulses) % steps < self.pulses) for i in range(steps)]
scale.py — a list through numpy
import numpy as np
from attrs import define, field
@define
class scale:
"""A list in, scaled and offset with numpy, a list out, for tap.python: with @factor 2 and
@offset 1, the list 1 2 3 outputs 3 5 7.
A last parameter hinted np.ndarray takes every atom of the message as one float64 array, and an
array returned is output as a list — so a list message runs through numpy and back.
"""
factor: float = field(default = 1.0)
offset: float = field(default = 0.0)
# `list` comes last on purpose: once `def list` has run, the name `list` in the class body means
# this method, so an annotation written after it would no longer mean the built-in type. (Inside
# method bodies the built-ins are unaffected.)
def list(self, values: np.ndarray) -> np.ndarray:
return values * self.factor + self.offset
note_name.py — two outlets
from attrs import define
NAMES = ("C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B")
@define
class note_name:
"""A MIDI note number in, its name out, for tap.python: the pitch class from the left outlet and
the octave from the right — 60 outputs C and 4, middle C being C4.
The return hint tuple[str, int] gives the object two outlets, one value each, output right to
left as Max objects do: the octave, then the name (as `symbol C`).
"""
# `int` comes last on purpose: once `def int` has run, the name `int` in the class body means this
# method, so an annotation written after it would no longer mean the built-in type. (Inside method
# bodies the built-ins are unaffected.)
def int(self, note: int) -> tuple[str, int]:
return NAMES[note % 12], note // 12 - 1
allpass.py — the example, run the way tap.python~ runs it
[tap.python~ allpass] loads python/allpass.py and instantiates the class allpass. This notebook drives the same class in plain Python (no Max), the way the object does: it looks at what the object would expose, calls prepare() with the audio settings, then calls process() once per sample.
The object itself — loading, reloading, dispatch, the audio thread — is tested by the C++ core battery (core/tests/), which also runs this example. What this notebook shows is the DSP: that the filter really is an allpass, and that its delay follows the sample rate.
Rendered from python/allpass-doc.ipynb, as committed — executed, its outputs those it computed.
import inspect, typing
import numpy as np
import matplotlib.pyplot as plt
from allpass import allpass
What tap.python~ sees
The class's annotated public fields become Max attributes, typed by their hints; its public methods become messages. process() is the audio callback — per sample here, because its input is annotated float — and prepare() receives the audio settings. Names starting with _ are private.
hints = typing.get_type_hints(allpass)
attributes = {name: hint.__name__ for name, hint in hints.items() if not name.startswith('_')}
methods = sorted(name for name, member in inspect.getmembers(allpass, inspect.isfunction)
if not name.startswith('_'))
print('attributes:', attributes)
print('messages: ', [m for m in methods if m not in ('process', 'prepare')])
print('process: ', inspect.signature(allpass.process))
print('prepare: ', inspect.signature(allpass.prepare))
attributes: {'delay': 'float', 'alpha': 'float'}
messages: ['clear']
process: (self, x: float) -> float
prepare: (self, sample_rate: float, vector_size: int) -> None
Running it as the object does
The object calls prepare(sample_rate, vector_size) before any audio, then process(x) once per sample. With the defaults — a 1 ms delay and α = 0.5 — at 48 kHz the delay line is 48 samples.
fs = 48000.0
ap = allpass()
ap.prepare(fs, 64)
n = 48 * 400
impulse = np.zeros(n)
impulse[0] = 1.0
response = np.array([ap.process(x) for x in impulse])
print('first sample (alpha * x[0]): ', response[0])
print('first echo at sample 48: ', response[48])
print('energy of the impulse response: ', np.sum(response ** 2))
Setting delay to 1.0 ms (48 samples @ 48000 Hz)
first sample (alpha * x[0]): 0.5
first echo at sample 48: 0.75
energy of the impulse response: 1.0
An allpass passes every frequency at unity gain and only shifts phase, so the impulse response keeps all of the impulse's energy (1.0 above), and its magnitude response is flat at 0 dB:
spectrum = np.fft.rfft(response)
freqs = np.fft.rfftfreq(n, 1 / fs)
magnitude_db = 20 * np.log10(np.abs(spectrum))
print(f'magnitude response: {magnitude_db.min():.6f} dB to {magnitude_db.max():.6f} dB')
fig, (a, b) = plt.subplots(2, 1, figsize=(8, 5))
a.stem(np.arange(300), response[:300], markerfmt=' ', basefmt=' ')
a.set_title('impulse response (first 300 samples)')
a.set_xlabel('sample')
b.plot(freqs, magnitude_db)
b.set_ylim(-1, 1)
b.set_title('magnitude response')
b.set_xlabel('Hz')
b.set_ylabel('dB')
fig.tight_layout()
magnitude response: -0.000000 dB to 0.000000 dB

The delay follows the sample rate
Because the sample rate arrives through prepare(), the delay stays 1 ms at any rate — 96 samples at 96 kHz:
ap = allpass()
ap.prepare(96000.0, 64)
response = np.array([ap.process(x) for x in impulse[:400]])
print('silent until sample', np.flatnonzero(response[1:])[0] + 1)
Setting delay to 1.0 ms (48 samples @ 48000 Hz)
Setting delay to 1.0 ms (96 samples @ 96000 Hz)
silent until sample 96
α stays inside the unit circle
At |α| = 1 the feedback pole sits on the unit circle and the filter never decays, so the class rejects it. In Max, the rejection is printed to the console and the attribute keeps its value.
ap = allpass()
try:
ap.alpha = 1.0
except ValueError as e:
print('rejected:', e)
print('alpha is still', ap.alpha)
Setting delay to 1.0 ms (48 samples @ 48000 Hz)
rejected: alpha must be strictly between -1.0 and 1.0
alpha is still 0.5
Building from source
Requires CMake 3.19+ and a C++20 compiler (Xcode 12+ / Visual Studio 2019+). The build links against the embedded runtime, so install it first (see Installing).
git submodule update --init --recursive
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --config Release # externals land in externals/
ctest --test-dir build # unit tests (mock kernel, no Max needed)
On macOS the build matches the architectures of the installed runtime: a universal libpython (installed with ./scripts/install-runtime.sh --universal, as CI does) gives a universal (arm64 + x86_64) external — required for anything you ship — while a plain native install gives a faster native-only build for local iteration. Reinstalling the runtime (native ↔ universal) is picked up by an existing build folder on the next configure. An explicit -DCMAKE_OSX_ARCHITECTURES=... overrides the default, but configure refuses one wider than the runtime (the link would fail).
The runtime and packages are pinned in scripts/runtime.lock and scripts/requirements.lock; to move a pin, run python3 scripts/update-locks.py with the new versions (--help lists them), review the diff, and commit it.
./scripts/install-runtime.sh --universal # universal libpython → universal external (ship this)
./scripts/install-runtime.sh # native libpython → native external (fast iteration)
On Windows, configure with cmake -S . -B build -A x64.
Making a release
Push a tag vMAJOR.MINOR.PATCH: .github/workflows/release.yml builds and tests on each platform (both Mac architectures on their own runners), assembles the package with scripts/assemble-package.py (the platform's externals, help, docs, examples, the runtime, and licenses/), zips it with SHA256 checksums, merges the three into one package for every platform (assemble-package.py --merge: each runtime in support/<platform>, each platform's licenses in licenses/<platform>), and attaches everything to a release — published as a pre-release for a 0.x version, and a draft to review and publish from 1.0 on. The package version comes from the tag (min reads it from git; the assembly checks they agree). Running the workflow by hand builds the zips as workflow artifacts without a release. It signs and notarizes the Mac packages and signs the Windows binaries when the signing secrets it lists are set, and skips signing, with a warning, when they are not.
Testing
The core, and testing it on Linux
Everything that talks to CPython — starting the interpreter, loading and reloading the class, describing its attributes and messages, converting values, and running process() — lives in a host-independent core under core/include/tap/python/ (plain C++20 + CPython, no Max). The external in source/projects/ is a thin layer that maps the core onto Max. The core builds and tests on any platform with a CPython 3.13 — no Max, no runtime install:
cmake -S core -B build-core -DPython3_EXECUTABLE=$(which python3.13)
cmake --build build-core
ctest --test-dir build-core --output-on-failure
Any CPython 3.13 with its headers will do; if you have no python3.13, uv python install 3.13 gets one, and $(uv python find 3.13) names it. The example tests need attrs and numpy importable by that interpreter (or in a folder named by TAP_PYTHON_TEST_SITE); without them they are skipped. -DTAP_PYTHON_SANITIZE=address,undefined or =thread builds the battery under sanitizers, as CI does.
The core's build also makes core/bench, which times process() as Max's audio thread calls it; python3 scripts/update-perf-docs.py builds it (Release), runs it, and rewrites the tables in the performance chapter — with --max, the table measured in Max too. Measure on an idle machine; never edit those tables by hand.
The external and its mock-kernel unit test also build on Linux (Max does not run there, but its glue does), embedding the same CPython instead of a support/ runtime:
cmake -S . -B build-linux -DPython3_EXECUTABLE=$(which python3.13)
cmake --build build-linux
ctest --test-dir build-linux --output-on-failure
Runtime tests in Max
What only a real Max shows — the file watcher, attributes read through getattr, audio through the signal chain, poly~, the console, loading without a runtime — is tested by patchers that Max runs, with Cycling '74's max-test harness. On a Mac with Max 9, with the external built, the runtime installed and the package in Packages, quit Max and run:
python3 runtime-tests/run.py # launches Max, runs every test patcher, quits it (~2 minutes)
It installs the harness into Packages as max-test (and leaves it there). --session soak runs the hour-long soak instead — audio through many instances while their files are saved every second, a sample-rate change, memory sampled each minute — and --session perf reads Max's CPU meter under load. See runtime-tests/README.md for what they do and how to write a test.
The design
For contributors: how PythonTap is put together, and why. The decisions are recorded with their
reasons in the repository — docs/PRODUCTION-PLAN.md (D1–D6, and
the phases that built 1.0) and docs/TAP-PYTHON-PLAN.md (D7–D11, the
second object) — and CLAUDE.md keeps the rules a change must not break. This chapter is the map.
The decisions
| Decision | In short | |
|---|---|---|
| D1 | Where process() runs | On the audio thread, once per vector (the block path) or per sample; @mode worker moves it to a thread of its own, the audio thread then never taking the GIL. |
| D1a | Choosing the block path | By type hint: np.ndarray parameters, once per vector; float, once per sample. |
| D2 | How a class is loaded | By file path, python/<name>.py, compiled and executed by the loader as a fresh module — never imported, no bytecode written. |
| D3 | The runtime | Bundled in every release: CPython, attrs and numpy in support/, per platform. |
| D4 | The Python version | Pinned to 3.13; a move is its own change, with tests. |
| D5 | Compatibility | Before 1.0, breaking changes allowed for correctness; from 1.0, a change to the class contract is a major version (2.0.0 was one). |
| D6 | Architecture | A host-independent core plus a thin Max wrapper. |
| D7–D11 | tap.python | The same core, folder, loader and contract without audio; a method's return value is the output, its return hint the outlet count; one inlet; a message runs on the thread it arrives on; one binary registers both Max classes, the second a plain SDK class found through init/. |
The layers
- The core (
core/include/tap/python/, namespacetap::python) — everything that talks to CPython, in plain C++20 with no Max: the one process-wide interpreter and its thread states, the loader, class introspection, the value model and its coercions (which reproduce Max's ownatom_getlong/atom_getfloat/atom_getsym), message and attribute dispatch,process()per sample or per vector, the worker, and the conversion of what a method returns into output. Its Catch2 battery runs on Linux, under ASan/UBSan and TSan too. - The shared glue (
source/projects/tap.python_tilde/tap.python_glue.h) — what both Max objects need from Max: starting the runtime from the package, the file watcher, atom conversion, the names Max keeps and the guard against a class replacing one, and the attributes and messages made for a class, with the C trampolines Max calls, instantiated per host type. - The two objects —
tap.python~, a min object (tap.python_tilde.h), andtap.python, a plain SDK class (tap.python.h,tap.python.cpp), both registered by the one binary'sext_main.
The rules that hold it together
- The interpreter starts once per process and is never finalized; every entry point takes a
gil_lock. Holding the GIL does not serialize threads — CPython hands it over every switch interval, even mid-reload — so a reload builds the new binding completely and swaps it in with no Python call in between, and every caller takes its own references first. - Nothing prints on the audio thread: problems there are recorded and printed later from the main thread.
- No CPython data symbols in the core or the wrapper: the Windows external delay-loads
python313.dll, which MSVC refuses if the module imports CPython data. CI checks by symbol name. - What is true of a class is said once, by the object that ran its file; what is true of an instance, per instance.
- Honest limits are pinned, not hidden: a known limit gets a test that states it, and the documentation never claims more than the tests show.
Where the documentation comes from
This book is built from book/ by CI and published on every change to main. The examples are
included from python/ as they ship, the performance tables are written by
scripts/update-perf-docs.py, and the notebook is rendered from python/allpass-doc.ipynb at build
time — so none of them can drift from what ships. The object's reference pages, guide, tutorials and
help files, in Max's own documentation system, are in docs/ and help/.
Changelog
Changes to the Python class contract and to the object's behavior, newest first. Before 1.0,
breaking changes to the contract were allowed where they bought correctness (D5 in
docs/PRODUCTION-PLAN.md); each is recorded here. From 1.0.0, a change to the class contract is
a major version.
2.0.0 — 2026-10-10
The release that brings tap.python, a Python class as a Max object without audio, beside
tap.python~ in the same package and the same binary (docs/TAP-PYTHON-PLAN.md, Phase 9 of the
production plan). It is a major version because of one change to the class contract — a message's
last parameter hinted list[…] or np.ndarray now takes the remaining atoms (Changed, below);
classes that do not use such a hint load and behave as they did. Verified in Max 9.1.5 on macOS —
every runtime test, tap.python's included, and the hour-long soak — and on Windows 11, where both
objects, their attributes and messages, a reload and the help files were checked in Max by hand.
Added — tap.python, a Python class as a Max object without audio
[tap.python name]loadspython/name.pyastap.python~does — the same loader, attributes, messages, hot reload, console and error guards, and the same file may serve both — and outputs what each method returns:Nonenothing, a number or astrone value (astrassymbol <s>), a list or a 1-D array a list, a list whose first element is astrthe message it names, and a method hinted-> tuple[…]of n members one value per outlet, right to left. The object has as many outlets as the widest such hint, plus a dumpout at the right that answersget<attribute>; a save that changes the count changes the outlets in place, keeping their patch cords.process()andprepare()are ordinary messages there. A method namedanythinganswers every message the class has no method for, with the selector first. It lives intap.python~'s binary, and Max finds it through the package'sinit/tap.python.txt(plan 9.3).- Messages run on the thread they arrive on, as an ordinary Max object's do. With Scheduler in Audio Interrupt on that is the audio thread, which the object says once per session.
default.pyhas abang, which returns the gain:[tap.python]with no argument outputs it. Intap.python~abangnow calls that method, its result dropped there as every method's is.- Documentation in Max's own system (plan 9.4): a reference page for
tap.python, a guide — Writing Max Objects in Python — and three tutorials with their patchers in the Documentation window (Package Docs › PythonTap), the help patchers of both objects in tabs, and a PythonTap Overview in the Extras menu, which is also the package's home patcher.
Fixed
mc.tap.python~was "No such object". Max wraps an external inmc.through a mapping in aninit/file, as its owninit/mapsmc.cycle~; the package had none, so the ReadMe's "wrap the object inmc." did not work.init/tap.python.txtnow mapsmc.tap.python~to Max's MC wrapper aroundtap.python~(found writing the help patcher'smc.tab). The wrapper passes a class's attributes and messages to its instances throughsetvalueandapplyvalues; a plain message to every instance is not understood, as each instance's are its own. Pinned by a runtime test in Max.tap.python~: an attribute set or read on the scheduler thread while the class reloaded raced the reload's changes to the object's list of attributes, read on one thread while written on the other. The list now has a lock of its own. Found while buildingtap.python, which looks names up on any thread by design; never seen in Max.
Changed — a last parameter hinted list[…] or np.ndarray takes the remaining atoms
- A message's last parameter hinted
list[float],list[int],list[str],listornp.ndarray— with no*argsafter it — now takes every atom left after the parameters before it: as one list, each atom converted by the element hint as a single parameter's would be, or as a float64 array.def taps(self, values: list[float])answerstaps 0.1 0.2 0.3, anddef list(self, values: np.ndarray)a whole list message. With no atoms left it is an empty list, or its default if it has one. Before, such a parameter took one atom, converted as astr— so a number arrived as the empty string — and a message with more atoms than the method has parameters was refused. A class that relied on that now receives a list. It is a change to the class contract, and so the release is 2.0.0 (plan 9.1; decided 2026-10-09).
1.0.2 — 2026-10-08
1.0.1 was tagged but never released: its Windows package still had the first fault below. Its fixes are here, with that one completed.
Fixed
- In Max, the object had no attributes and no messages. 1.0.0's guard against a Python method
or field named like a message the Max object already answers (plan 8.2) asked Max's
object_getmethod()and took anything but null for an answer — but for a name the object does not have, Max returnsmethod_false(), a function, as the SDK documents, so every field and method of every class was "reserved by the host" and the console said so for each; only audio still worked. The mock kernel the unit tests run against answers null, which is why they passed. The guard now asks Max what it answers for a name the object does not have, and compares with that — first (in 1.0.1) it compared with the address ofmethod_falseas the external sees it, which on Windows is the external's own import thunk (the SDK declares the function withoutdllimport), never what Max returns, so on Windows every name was still reserved. The unit test's kernel answers as Max does on Windows too, and the Mac session (8.8) is what would have caught it on the Mac before the tag; on Windows, trying the package did. - A reload lost every attribute. With that fixed, the guard still took the attributes the object had added for the class's fields — which answer their names in Max, as messages do — for Max's own, so a save left the object with no attributes ("the field … is reserved by the host", then "doesn't understand …"). It now leaves out the object's own attributes as it did its own messages. Found in the Mac session; the unit test's kernel now answers an added attribute's name as Max does, and fails without the fix.
Changed
- The package has the Tap family's PythonTap icon in place of min's template icon: the
"ground" version from TapHouse's
brand/, rendered at 500×500.
1.0.0 — 2026-10-01
The first stable release: the class contract as the ReadMe states it, the production plan's Phases 0–5 and 8 complete, and Phase 6 validated in Max on Intel. Still to run in a Max session (plan 8.8): the runtime-test steps added in this cycle, the Windows checks of 6.4, and the arm64 and Windows release zips of 6.5 — the first 1.0.x work. The packages are unsigned until signing credentials exist (ReadMe: clear the quarantine once on a Mac).
Changed — type hints map as a reader expects
Annotated[X, …]andFinal[X]fields areX; a union of several kinds passes the atom as it is; numpy scalar types map to the kinds they hold;Optional[tuple[…]]names its outputs. Afloat | intfield used to become a symbol attribute that stored""for any number; now it takes the value as the atom carried it (anint,floatorstr), as an unannotated parameter does.AnnotatedandFinalused to be symbols;np.float64and friends too; and-> tuple[float, float] | Nonebound one output and then reported every sample as not a number. The same holds for hints read as written. (Plan 8.6, audit A8.)
Changed — helper modules follow the class file
- A helper module in
python/is imported afresh when a class file that changed is saved. Before each execution of a class file the loader drops fromsys.modulesevery source module imported from the folder (not the class modules themselves, nor compiled extensions), so the fresh class imports the helper's current source; and the interpreter writes no bytecode, so the stale-.pychazard fixed for class files in 0.9.0 cannot return for helpers. A save of the helper alone is still not watched. Helpers used to load once per Max session. (Plan 8.5, audit A7.)
Changed — nothing of yours posts from the audio thread
- What
process()prints, or warns, is posted from Max's main thread. Aprint()or a numpyRuntimeWarninginprocess()used to post to the console from the audio thread (or the worker thread), taking a lock on the way. Now a complete line printed on any thread but Max's main one is queued, lock-free and without allocating, and posted from the main thread a moment later; lines are assembled per thread, so two threads printing pieces of a line no longer mix them. A flood is coalesced: 256 lines queue, the rest are dropped and counted in one line. Lines printed on the main thread post at once, as before. (Plan 8.4, audit A5.)
Changed — worker mode never waits forever
- A
process()that does not return when the worker stops is interrupted, then abandoned. Stopping the worker — DSP off, a chain rebuild, the object deleted — waits up to 100 ms, then raisesWorkerStopped(aBaseException) into the thread: a pure-Python loop ends, that vector is silence, the console says so once per load, and the class's audio stays bound. If it still has not returned 250 ms later it is blocked in a call Python cannot interrupt, and the thread is abandoned: audio continues on a new worker, the console says the thread keeps costing a core until Max quits, and the object keeps its Python state alive for it. Before, stopping the worker waited forprocess()to return, so such a class froze Max when DSP toggled or the patch closed. The worker thread also runs on a 16 MiB stack, what CPython gives its own threads on macOS, where the default 512 KiB was too little for Python that recurses through a C boundary. (Plan 8.3, audit A1 and A6.)
Changed — more names are reserved
- A method or field named like a message Max sends with C arguments is not exposed, with the
console saying so:
dspstate,fileusage,patchlineupdate,inputchanged,multichanneloutputs,edclose,okclose,oksize,paint,dictionary,getplaystate,key, the mouse and focus messages,mousewheel, and the*_setupnames — every message min treats asA_CANT, plus Max's own — and, as a guard, any name the Max object already answers itself. A Python method of such a name used to be registered as an ordinary message, which Max then called with alongor a pointer: the crash that every save once caused throughfilechanged, in its general form. (Plan 8.2, audit A3.)
Fixed
- Windows: a package under a folder with a non-ASCII name is watched. The path handed to Max's
file watcher, and the paths in the console's "No file" and "Failed to load" lines, were converted
with the ANSI code page on Windows, where Max expects UTF-8; for a user whose
Documentsfolder has an accented character the watcher failed and hot reload was silently off. Every path shown to Max or the console is UTF-8 now. (Plan 8.7, audit A9.) - Samples computed before
process()raised are sanitized. Aprocess()that returned NaN for part of a vector and then raised let those NaNs through; the contract says non-finite output is replaced with 0.0, and now it is on that path too. (Plan 8.2, audit A2.) - The documents say what the guards cover. No Python exception,
sys.exit()included, takes Max down; what never reaches Python's exception machinery —os._exit(), a crash in a C extension, code that never returns — is not caught, and the ReadMe now says so, as it says that helper modules load once per Max session and that a second embedded CPython in the same Max is unsupported.assemble-package.py --mergerefuses a zip whose entries or symlinks would land outside the package. (Plan 8.1.)
0.10.0 — 2026-10-01
Added — one package for every platform
- Each release also has
PythonTap-<version>.zip, for every platform at once: the macOS and Windows externals, and each platform's runtime insupport/<platform>/. The external usessupport/<platform>/when its package has one — on macOS, each architecture its own — andsupport/otherwise, so the per-platform packages and a checkout work as before. 0.x releases are published as pre-releases rather than drafts. (Plan 4.8.)
Added — worker mode
@mode workerrunsprocess()on a thread of its own,@latencymilliseconds behind the audio (30 by default, rounded up to whole signal vectors; it must exceed Max's I/O vector), so that nothing else Python does — a reload, a message, another instance — can hold up the audio thread, which only copies vectors to and from it. Vectors the worker is late for are output as silence, reported once per load, and the delay stays the same; the read-only@latencysamplesgives it in samples. The worker thread has the real-time scheduling of an audio thread.@mode direct, the default, is as before. (Plan 2.5.) Changed with it: the object's own attributesmode,latencyandlatencysamplesare reserved — a class's field or method of one of those names is not exposed, and the console says so (before, fields were never checked against reserved names).
Added — several inputs and outputs
- process() can take several signal inputs and return several outputs. Its parameters are the
object's signal inlets and its return hint its outlets:
process(self, left: np.ndarray, right: np.ndarray) -> tuple[np.ndarray, np.ndarray]makes[tap.python~ …]with two of each, andprocess(self) -> floata generator (with one inlet still, for messages). The inputs are allnp.ndarrayor all per sample; a tuple return must say how many values it has. A return with the wrong number of values is output as silence and reported once. New example:stereo_width.py. A save that changes how many changes the object's inlets and outlets in place, keeping the patch cords of those that stay. (Plan 2.4.) Changed with it:process()taking*argsis now reported and not bound (before, it was bound and its extra arguments never arrived).
Changed — a broken file is reported once
- A save that breaks a class file is reported once, however many objects share it. Every object used to run the file and print its traceback: a syntax error with 25 objects printed 25 tracebacks. Now a failure of the same source already reported in the last two seconds — the same save, or a patch opening many objects at once — is not reported again, while an object created later with the file still broken says why it is silent. Every load still runs the file, in case something it imports has been fixed. (Plan 6.10.)
Changed — reloads and the audio thread
- A reload no longer holds up the audio for its whole length. The interpreter now hands itself to a waiting thread after 0.5 ms instead of CPython's 5 ms, so the audio thread waits at most about that long at a time while another thread — a reload, above all, which takes a few milliseconds — runs Python. Measured at 96 kHz with 512-sample buffers and a save every 100 ms, CPython's default made a buffer late in most runs (the worst took 45 ms); at 0.5 ms none was. (Plan 2.6.)
Fixed — the package
- The macOS external's bundle identifier is
com.74objects.tap.python-tilde; it shipped as an unexpanded template (com.74objects.${PRODUCT_NAME:rfc1034identifier}), which signing and notarizing would have refused. The development plan no longer ships inside the package, and the reference page now follows the object's descriptions (Max rewrites it from them).
0.9.0 — 2026-09-30
Changed — the console on reload
- One line per save, however many objects share the file. Every object used to post two lines
on every reload ("Source file update detected. Reloading." and "Audio process() bound: …"), so a
save with 25 objects of one class posted 50 — and Max keeps every line it is sent. Now the object
that runs the changed file posts one,
Loaded name.py: process() bound, one call per sample(or per vector, or that nothing is bound), and what is true of the class — a type-hint error, a method skipped for its name, aprocess()that cannot be bound — is reported with it, once. A reload of an unchanged file posts nothing. Errors particular to one object are still reported by each. (Plan 6.7, found by the soak test.)
Changed — text encoding
- Text files are UTF-8 by default. The interpreter now runs in Python's UTF-8 mode (PEP 540,
Python's own default from 3.15), so
open(),Path.read_text()and the like read and write UTF-8 unless given anotherencoding. Before, they used ASCII — whatever the machine's locale — and a class reading a UTF-8 file failed withUnicodeDecodeError. (Plan 6.6, found by the runtime tests.)
Added — examples
numpy_allpass.py: the allpass filter ofallpass.py, processed a vector at a time with numpy — the same output, sample for sample, at a small fraction of the cost (the ReadMe's performance note compares the two).
Fixed
- Saving the class file no longer crashes Max. Max's file watcher calls the object's
filechangedmethod with C arguments (a file name and a folder), and the object registered that message in a form that reads its first argument as a Max symbol — so every save while the object existed crashed Max. The watcher now belongs to a small helper object that passes each save on as an ordinaryfilechangedmessage; sendingfilechangedfrom a patcher works as before. (Found by the first runtime test in Max; the unit tests' mock kernel has no file watcher.)
Added — releases
- Release packages with the runtime bundled (D3):
PythonTap-<version>-macos-arm64.zip,-macos-x86_64.zipand-windows-x64.zip, built byrelease.ymlfrom a version tag into a draft release, with SHA256 checksums. Each carries alicenses/folder with every third-party license it ships. Not code-signed until signing credentials are configured. - The object loads without a runtime and says in the Max console what is missing and how to install it, instead of Max failing to load the external. (libpython is weakly linked on macOS and delay-loaded on Windows.)
Changed — documentation and examples
- The reference page is generated from the object's own metadata and now states the real
contract (the argument, attributes from annotated fields, signature-called messages, the two
process()forms,prepare(), hot reload, errors). Edit the descriptions intap.python_tilde.h, not the XML: min regenerates the page whenever the external is newer. default.py'sintmessage is annotatedintand works as a number. Its hint was writtenfloatafterdef float, so in the class body it named the method, not the type, and the argument arrived as a symbol. Itsintandfloatmethods now come last, with a comment saying why.allpass-doc.ipynbis rewritten as an executed document of theallpassexample: what the object exposes, running it withprepare()as the object does, its unity energy and flat magnitude response, the delay following the sample rate, and the rejected coefficient.- The help patcher explains the class contract and gains message boxes for the example's
messages and for
filechanged.
Changed — types and messages
- Messages follow the method's signature. Parameters with defaults are optional,
*argsaccepts any number of arguments, and unannotated parameters receive the value as the atom carried it. Previously the argument count had to equal the number of annotated parameters, so defaults and*argsdid not work and an unannotated parameter made a method uncallable. A method with a keyword-only parameter without a default is no longer exposed (it could never be called). boolfields and arguments are Max on/off longs and arrive in Python asbool(they were symbols, soflag 1stored"").Optional[X]/X | Nonecount asX(they were symbols).ClassVarannotations are no longer attributes.- Classmethods and staticmethods are called correctly (a classmethod received the instance as
cls). Describing a class no longer runs its property getters. - Unresolvable type hints are reported and read as written, instead of silently leaving the class with no attributes.
- Attribute reads never invent a value:
None, or a value that does not convert, reads as the type's empty value instead of-1; anintattribute holding a float truncates; a symbol attribute holding a non-string reads throughstr(). sys.stdout/sys.stderrare proper text streams (encoding,isatty(),fileno()raisingio.UnsupportedOperation);flush()forwards a partial line, and text containing NUL characters is written instead of raising.
Changed (breaking) — loading and reloading
- Class files are loaded by path, as the private module
_tap_python_<name>, instead of being imported throughsys.path. The argument must be a valid Python identifier (e.g.../xortwo wordsare refused), and it only ever names a file in thepythonfolder:[tap.python~ os]no longer imports the standard library'sos. A class file may now be named like a standard-library module without shadowing it. - The
pythonfolder moved to the end ofsys.path, so helper modules there are still importable but can no longer shadow the standard library or installed packages. - Attribute values carry over a reload (they used to reset to the class defaults), including through a failed reload that you then fix. Attributes removed from the class are removed from the Max object, and one whose type changed is recreated with the new type (previously both stayed, with the old type).
Fixed — loading and reloading
- A new object created after its file was edited ran the old code if another object had loaded that file earlier in the session.
- Two saves within the same second, of the same size, could reload stale compiled bytecode.
- A reload kept module-level names that had been deleted from the file.
- With several objects on one file, each save executed the module once per object; it now executes once.
Added
- numpy block processing. A
process(self, x: np.ndarray) -> np.ndarrayis called once per signal vector with a (reused) numpy array, instead of once per sample. The result may be any numeric dtype or a list; it is converted. New example:python/numpy_gain.py. prepare(self, sample_rate: float, vector_size: int), called with Max's audio settings before audio starts, whenever they change, and on every reload before the new code runs.
Changed
python/allpass.pytakes its sample rate fromprepare(); itsfsattribute is gone (it assumed 48 kHz unless set by hand).alphamust now be strictly between -1 and 1 (±1 put the filter's pole on the unit circle).- Problems in
process()are printed from Max's main thread, not the audio thread. A non-numeric result is now reported (once per load) instead of silently output as 0.0, and non-finite output (NaN, infinity) is replaced with 0.0 and reported once per load.
Installation
- The runtime installers verify the CPython download against a SHA256 committed in
scripts/runtime.lock, and install attrs and numpy at the versions and hashes pinned inscripts/requirements.lock(previously: the release's own checksum file, and whatever PyPI served that day).scripts/update-locks.pymoves the pins. - Re-running an installer no longer deletes the runtime first: the old one is kept until the new
one is complete and restored if anything fails. The Windows installer now stops on a failed
tarorpipinstead of reporting success. On an Apple Silicon Mac, a Terminal running under Rosetta now installs the arm64 runtime Max needs.install-runtime.shrejects unknown options. - The
PBS_RELEASE/PYTHON_SERIESenvironment overrides (and the.ps1parameters) are gone: the pins live in the lock files.
Changed (breaking)
- Reserved method names. A method named like a message Max or the object handles itself —
anything,appendtodictionary,assist,dblclick,dsp,dsp64,dspsetup,filechanged,getvalueof,inletinfo,loadbang,notify,preset,savestate,setvalueof,signal— is no longer exposed as a Max message; the console names it. Before, such a method replaced the object's own handler (a method namedfilechangedsilently disabled hot reload). Rename the method to expose it.
Fixed
sys.exit()(orraise SystemExit) anywhere in user code — a message,process(), an attribute setter, the constructor, or module top level — no longer quits Max; it is reported like any other exception.- A hot reload landing in the middle of an audio vector could crash Max. The audio vector now finishes on the code it started with, and a successful reload swaps in without interrupting the audio (it no longer outputs silence while the new code loads).
- The audio thread keeps one Python thread state for its lifetime instead of creating and
destroying one every vector, so per-thread Python state (
threading.local, decimal contexts) survives between vectors, and a vector no longer costs an allocation and an interpreter-wide lock. - Integer attribute values and message arguments cross as 64-bit on every platform (on Windows they were truncated to 32 bits).