Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,12 @@ jobs:
libpython=$(python -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR') + '/' + sysconfig.get_config_var('LDLIBRARY'))")
scripts/check-data-imports.sh externals/tap.python~.mxl_x86_64 "$libpython"

# What is written by hand, which no build makes and no test runs (plan 9.4): the reference
# pages, the guide and the tutorials well-formed and linked, the patchers' patch cords whole.
- name: Check the hand-made documentation
if: matrix.sanitize == ''
run: python scripts/check-docs.py

# The release packaging's license collection (plan 4.6), against this interpreter's own
# install, which is laid out like the macOS runtime and has the locked packages in it.
- name: Collect the licenses a package would ship
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,20 @@ lands.
- **`default.py` has a `bang`**, which returns the gain: `[tap.python]` with no argument outputs it.
In `tap.python~` a `bang` now 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 in `mc.` through a mapping in
an `init/` file, as its own `init/` maps `mc.cycle~`; the package had none, so the ReadMe's "wrap
the object in `mc.`" did not work. `init/tap.python.txt` now maps `mc.tap.python~` to Max's MC
wrapper around `tap.python~` (found writing the help patcher's `mc.` tab). The wrapper passes a
class's attributes and messages to its instances through `setvalue` and `applyvalues`; 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 building `tap.python`, which looks
Expand Down
21 changes: 18 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ in the same binary by 9.3; its documentation (9.4) and runtime tests (9.5) are s
(`tap.python_package.h`) and the file watcher (`tap.python_filewatch.h`). Its `_test.cpp` drives
the Max glue of both through min-api's mock kernel, with stubs for what that kernel lacks made
faithful to the SDK (the obex's dumpout, `outlet_insert_after`, the box's dynlets).
- **Documentation in Max's own system** (plan 9.4) β€” `docs/tap.python~.maxref.xml` (min's, never by
hand) and `docs/tap.python.maxref.xml` (by hand: a plain SDK class has no generator); the package
topic in `docs/topics/`, the guide *Writing Max Objects in Python* in `docs/vignettes/`, and three
tutorials with their patchers in `docs/tutorials/pythontap-tut/` β€” the Documentation window's
Package Docs β€Ί PythonTap lists them under Topics, Guides and Tutorials, by those folders; the help
patchers `help/tap.python~.maxhelp` and `help/tap.python.maxhelp`, in tabs (`[p name]` with
`showontab`, and an empty `[p ?]` that Max fills); and `extras/PythonTap Overview.maxpat`, in the
Extras menu and the package's home patcher (`package-info.json.in`). The Documentation window
lists `tap.python` as a reference page, not an object β€” it has no file of its own, as Max's
`init`-mapped objects (`mc.abs~`) have not. `scripts/check-docs.py` (CI) checks them all: the XML
well-formed, every link resolving, every patch cord whole.
- **`init/tap.python.txt`** β€” read by Max at launch: `tap.python` and `mc.tap.python~` are mapped to
the `tap.python~` binary (the second through Max's MC wrapper, as Max's own `init/` maps
`mc.cycle~`); `assemble-package.py` fails a package without both lines.
- **`python/`** β€” the user's script folder in the package (and the examples: `default.py`,
`numpy_gain.py`, `allpass.py`, `numpy_allpass.py` β€” the same filter per vector, checked equal
sample for sample by the core battery β€” `stereo_width.py`, two inputs and two outputs, and
Expand Down Expand Up @@ -106,7 +120,7 @@ python3 runtime-tests/run.py
```

CI (`build.yml`): `linux-core` and `linux-max-glue` (each release, asan-ubsan, tsan β€” the glue
test's `TAP_PYTHON_SANITIZE`, as the core's), `macos` (universal + `lipo`/`otool` checks, including
test's `TAP_PYTHON_SANITIZE`, as the core's; the release row also runs `scripts/check-docs.py`), `macos` (universal + `lipo`/`otool` checks, including
no absolute rpath, and the package assembled, which fails without `init/tap.python.txt`), `windows`. `style.yml`: TapHouse drift check,
clang-format, clang-tidy (a clang-tidy failure or crash fails the gate, not just a finding). Workflows
run with `contents: read`, pin third-party actions by commit SHA (tag noted beside it), and cancel
Expand Down Expand Up @@ -207,7 +221,8 @@ attaches all the zips + SHA256s to a release β€” a pre-release for 0.x, a draft
(`MIN_DESCRIPTION`, argument and message descriptions β€” min regenerates
`docs/tap.python~.maxref.xml` from them when Max loads an external newer than the page, so never
hand-edit the page: rebuild, run Max once β€” `runtime-tests/run.py` says when the page was
rewritten β€” and commit it), `help/tap.python~.maxhelp`, the examples and their notebook
(committed executed), and the plan.
rewritten β€” and commit it), `docs/tap.python.maxref.xml` (by hand), the guide and tutorials in
`docs/`, both help patchers and the Overview β€” checked open in Max, not only by
`scripts/check-docs.py` β€” the examples and their notebook (committed executed), and the plan.
- **Implement from documentation and published sources only** β€” the CPython C-API docs, the Max SDK
docs β€” never by reverse-engineering another product.
50 changes: 48 additions & 2 deletions ReadMe.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ class default:
[tap.python~ default] ← loads python/default.py, exposes a 'gain' attribute
```

`tap.python`, in the same package, runs a class as an object without audio β€” messages in, messages out β€” and **outputs what its methods return**: a bang to `[tap.python euclid]` outputs a Euclidean rhythm as a list. See [Objects without audio](#objects-without-audio-tappython).

## Requirements

- **Max 9** or later (macOS 11.0+ on Intel or Apple Silicon, Windows 10 22H2+ / Windows 11, 64-bit).
Expand All @@ -44,7 +46,7 @@ Download a package from the [releases page](https://github.com/tap/PythonTap/rel
- `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 the `tap.python~` help patcher. 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.
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:
```sh
Expand Down Expand Up @@ -94,7 +96,7 @@ Python sources live in the package's `python` folder. `[tap.python~ name]` loads
- `process(self, x: np.ndarray) -> np.ndarray` is 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 β€” see `python/numpy_gain.py`, and `python/numpy_allpass.py` for 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) -> float` is 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 note](#a-note-on-performance)).

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.` to run one instance per channel of a multichannel signal. 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.
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.
- **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.
- **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.
- **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.
Expand Down Expand Up @@ -141,6 +143,50 @@ Max's own DSP CPU meter (`adstatus cpu`): the mean of ten readings a second apar

<!-- perf-max:end -->

## 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](#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**.

```python
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 `metro` holds 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 `metro` runs 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 `dict` is 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 note](#a-note-on-performance) has what the bridge itself costs.

## 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 [Installation](#installation)).
Expand Down
Loading
Loading