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 kHzCPU at 96 kHz
returns its input (the bridge alone) — per sample, vectors of 6445 ns per sample0.22%0.43%
default.py, a gain (an attrs class) — per sample, vectors of 6458 ns per sample0.28%0.57%
allpass.py, a Schroeder allpass filter — per sample, vectors of 641.2 µs per sample6%12%
returns its input (the bridge alone) — per vector of 640.23 µs per vector0.017%0.034%
returns its input (the bridge alone) — per vector of 5120.52 µs per vector0.0049%0.0097%
numpy_gain.py, a gain — per vector of 640.97 µs per vector0.073%0.15%
numpy_gain.py, a gain — per vector of 5121.5 µs per vector0.014%0.028%
numpy_allpass.py, the same allpass filter — per vector of 647 µs per vector0.53%0.68%
numpy_allpass.py, the same allpass filter — per vector of 51231 µs per vector0.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 kHzInstancesMax's CPU meterEachcore/bench, each
default.py, per sample2622%0.84%0.55%
numpy_gain.py, per vector of 64267%0.28%0.14%
allpass.py, per sample118%18%12%
numpy_allpass.py, per vector of 642628%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.