From 2fd3c91b066765f9e3804047763472a2051c573f Mon Sep 17 00:00:00 2001 From: Timothy Place Date: Sat, 10 Oct 2026 14:06:16 -0500 Subject: [PATCH 1/3] =?UTF-8?q?9.7:=20the=20book=20=E2=80=94=20PythonTap's?= =?UTF-8?q?=20documentation=20as=20an=20mdBook,=20published=20to=20Pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ReadMe's sections become the chapters of an mdBook under book/, in the family's shape (SampleRateTap's): installing, writing a class, audio, worker mode, objects without audio, performance, errors and limits; the examples and the notebook; building, testing and the design for contributors; and the CHANGELOG. Nothing that ships is copied into it: the examples and the CHANGELOG are included as they are, the notebook page is rendered from python/allpass-doc.ipynb as committed (scripts/notebook-to-book.py, at build time, gitignored), and the performance tables now live only in book/src/performance.md, where scripts/update-perf-docs.py writes them. The ReadMe shrinks to the front door: what it is, install, a quick start, and links. Everything that pointed at the ReadMe for a rule or a measurement — the guide, tutorial 3, the help file, the hand-written reference page, comments — points at the book. CI: build.yml's book job renders the notebook and builds the book with mdBook 0.4.40 (pinned by checksum), warnings as errors, and keeps it as an artifact; book-pages.yml publishes the same build to GitHub Pages from main. scripts/check-docs.py now checks the book and the ReadMe too — every chapter listed in SUMMARY.md, every include and relative link resolving, anchors included — since mdBook checks none of that. The favicons are TapHouse v7 copies, which the drift check already guards once book/book.toml exists. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019rVV9SkjkWmiBXFMT4whkz --- .github/workflows/book-pages.yml | 76 ++++++ .github/workflows/build.yml | 40 +++ .gitignore | 5 + CLAUDE.md | 33 ++- ReadMe.md | 246 +++--------------- book/book.toml | 14 + book/src/SUMMARY.md | 28 ++ book/src/audio.md | 11 + book/src/building.md | 25 ++ book/src/changelog.md | 1 + book/src/control-objects.md | 57 ++++ book/src/design.md | 57 ++++ book/src/errors-and-limits.md | 7 + book/src/examples.md | 69 +++++ book/src/installing.md | 52 ++++ book/src/introduction.md | 48 ++++ book/src/performance.md | 48 ++++ book/src/testing.md | 33 +++ book/src/worker-mode.md | 3 + book/src/writing-a-class.md | 15 ++ book/theme/favicon.png | Bin 0 -> 1065 bytes book/theme/favicon.svg | 1 + core/bench/CMakeLists.txt | 2 +- docs/tap.python.maxref.xml | 2 +- .../pythontap_03_control.maxtut.xml | 2 +- docs/vignettes/pythontap_writing.maxvig.xml | 6 +- help/tap.python~.maxhelp | 2 +- python/numpy_allpass.py | 2 +- runtime-tests/README.md | 5 +- runtime-tests/python/maxtest_outputs.py | 2 +- scripts/check-docs.py | 48 ++++ scripts/install-runtime.sh | 4 +- scripts/notebook-to-book.py | 89 +++++++ scripts/update-perf-docs.py | 25 +- .../tap.python_tilde_test.cpp | 2 +- 35 files changed, 809 insertions(+), 251 deletions(-) create mode 100644 .github/workflows/book-pages.yml create mode 100644 book/book.toml create mode 100644 book/src/SUMMARY.md create mode 100644 book/src/audio.md create mode 100644 book/src/building.md create mode 100644 book/src/changelog.md create mode 100644 book/src/control-objects.md create mode 100644 book/src/design.md create mode 100644 book/src/errors-and-limits.md create mode 100644 book/src/examples.md create mode 100644 book/src/installing.md create mode 100644 book/src/introduction.md create mode 100644 book/src/performance.md create mode 100644 book/src/testing.md create mode 100644 book/src/worker-mode.md create mode 100644 book/src/writing-a-class.md create mode 100644 book/theme/favicon.png create mode 100644 book/theme/favicon.svg create mode 100644 scripts/notebook-to-book.py diff --git a/.github/workflows/book-pages.yml b/.github/workflows/book-pages.yml new file mode 100644 index 0000000..ce21036 --- /dev/null +++ b/.github/workflows/book-pages.yml @@ -0,0 +1,76 @@ +name: book-pages + +# Publishes the book (book/, plan 9.7) to GitHub Pages on every push to main that changes what it is +# built from: its chapters, the examples and notebook it includes, the CHANGELOG, and the scripts. +# The site is public, at https://tap.github.io/PythonTap/. Pages must be enabled once, with +# Settings → Pages → Source: "GitHub Actions"; build.yml's book job builds the same thing on every +# push, so a broken book fails there first. + +on: + push: + branches: [main] + paths: + - "book/**" + - "python/**" + - "CHANGELOG.md" + - "scripts/notebook-to-book.py" + - ".github/workflows/book-pages.yml" + workflow_dispatch: + +# Pages deploys with an OIDC token; nothing else is written. Third-party actions are pinned by +# commit SHA (the tag each SHA was cut from is noted beside it). +permissions: + contents: read + pages: write + id-token: write + +# One deployment at a time, and never one cancelled halfway. +concurrency: + group: pages + cancel-in-progress: false + +env: + MDBOOK_URL: https://github.com/rust-lang/mdBook/releases/download/v0.4.40/mdbook-v0.4.40-x86_64-unknown-linux-gnu.tar.gz + MDBOOK_SHA256: "9ef07fd288ba58ff3b99d1c94e6d414d431c9a61fdb20348e5beb74b823d546b" + +jobs: + deploy: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install mdBook (pinned) + run: | + curl -sfLo /tmp/mdbook.tar.gz "$MDBOOK_URL" + actual=$(sha256sum /tmp/mdbook.tar.gz | cut -d' ' -f1) + if [ "$actual" != "$MDBOOK_SHA256" ]; then + echo "::error::mdbook checksum mismatch"; exit 1 + fi + tar -xzf /tmp/mdbook.tar.gz -C /tmp + + - name: Render the notebook + run: python3 scripts/notebook-to-book.py + + - name: Build the book (warnings are errors) + run: | + /tmp/mdbook build book 2>&1 | tee /tmp/book-build.log + if grep -qiE 'warning|error' /tmp/book-build.log; then + echo "::error::mdbook reported warnings/errors (an include naming no file?)" + exit 1 + fi + + - name: Configure Pages + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5.0.0 + + - name: Upload the site + uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3.0.1 + with: + path: book/book + + - name: Deploy + id: deployment + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 90effd3..7a1877b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -8,6 +8,8 @@ name: build # CPython 3.13, plain and under sanitizers — no Max, min-api or runtime install. # The external and its mock-kernel unit test build on Linux too (Max does not # run there, but the glue does), embedding the same CPython 3.13. +# +# And the book (book/), built as book-pages.yml publishes it. on: push: @@ -151,6 +153,44 @@ jobs: --support "$(python -c 'import sys; print(sys.base_prefix)')" --output build/licenses-check cat build/licenses-check/licenses/README.md + # The book (plan 9.7): rendered with the notebook as committed and built with mdBook, pinned by + # checksum, warnings as errors — an include that names no file fails here, not on the website. + # book-pages.yml publishes the same build from main; here it is an artifact to look at. + book: + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + MDBOOK_URL: https://github.com/rust-lang/mdBook/releases/download/v0.4.40/mdbook-v0.4.40-x86_64-unknown-linux-gnu.tar.gz + MDBOOK_SHA256: "9ef07fd288ba58ff3b99d1c94e6d414d431c9a61fdb20348e5beb74b823d546b" + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Install mdBook (pinned) + run: | + curl -sfLo /tmp/mdbook.tar.gz "$MDBOOK_URL" + actual=$(sha256sum /tmp/mdbook.tar.gz | cut -d' ' -f1) + if [ "$actual" != "$MDBOOK_SHA256" ]; then + echo "::error::mdbook checksum mismatch"; exit 1 + fi + tar -xzf /tmp/mdbook.tar.gz -C /tmp + + - name: Render the notebook + run: python3 scripts/notebook-to-book.py + + - name: Build the book (warnings are errors) + run: | + /tmp/mdbook build book 2>&1 | tee /tmp/book-build.log + if grep -qiE 'warning|error' /tmp/book-build.log; then + echo "::error::mdbook reported warnings/errors (an include naming no file?)" + exit 1 + fi + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: book + path: book/book + retention-days: 7 + macos: runs-on: macos-latest steps: diff --git a/.gitignore b/.gitignore index aa94dbd..3371db6 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,8 @@ support.previous/ # and Max's output /python/maxtest_*.py /runtime-tests/logs/ + +# The book (plan 9.7): mdBook's output, and the notebook page scripts/notebook-to-book.py renders +/book/book/ +/book/src/notebook.md +/book/src/notebook/ diff --git a/CLAUDE.md b/CLAUDE.md index 767dd20..6240270 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,12 +7,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co **PythonTap** — the `tap.python~` Max package: a Max external that embeds CPython 3.13 and runs a user's Python class as an audio object. `[tap.python~ name]` loads `python/name.py`, instantiates `class name`, turns its annotated public fields into Max attributes and its public methods into Max -messages, calls its `process()` on the signal, and hot-reloads on save. `ReadMe.md` is the user-facing -contract; **`docs/PRODUCTION-PLAN.md` is the authoritative roadmap** — its settled decisions (D1–D6), -its phases, and the audit findings behind them. Tick its items (with the PR) as they land. Its -Phase 9 is a second object, **`tap.python`**, a Python class as a Max object without audio (what a -method returns is what it outputs): designed in `docs/TAP-PYTHON-PLAN.md` (decisions D7–D11), built -in the same binary by 9.3; its documentation (9.4) and runtime tests (9.5) are still to come. +messages, calls its `process()` on the signal, and hot-reloads on save. Since 2.0.0 the package has a +second object, **`tap.python`**, a Python class as a Max object without audio (what a method returns +is what it outputs), in the same binary: designed in `docs/TAP-PYTHON-PLAN.md` (decisions D7–D11). +**The book (`book/`, published at https://tap.github.io/PythonTap/) is the user-facing contract**; +`ReadMe.md` is only the front door — what it is, install, a quick start, links — so each fact has +one home (plan 9.7). **`docs/PRODUCTION-PLAN.md` is the authoritative roadmap** — its settled +decisions (D1–D6), its phases, and the audit findings behind them. Tick its items (with the PR) as +they land. ## Layout (D6: a host-independent core plus a thin Max wrapper) @@ -37,7 +39,7 @@ in the same binary by 9.3; its documentation (9.4) and runtime tests (9.5) are s (plan 6.3); `tap_python_reload_bench` times the audio buffers while the class reloads, with the audio thread scheduled as Core Audio's are (plan 2.6: why the runtime sets a 0.5 ms switch interval). `scripts/update-perf-docs.py` builds it (Release), runs it, and - rewrites the ReadMe's generated performance tables — with `--max`, Max's CPU meter too, through + rewrites the book's generated performance tables (`book/src/performance.md`) — with `--max`, Max's CPU meter too, through `runtime-tests/run.py --session perf`. Never hand-edit those tables; measure on an idle machine. - **`source/projects/tap.python_tilde/`** — one binary, two Max classes (D11), its `ext_main` registering both: the Min external `tap.python~` (`tap.python_tilde.h`) and, after it, `tap.python`, @@ -61,6 +63,14 @@ in the same binary by 9.3; its documentation (9.4) and runtime tests (9.5) are s 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. +- **`book/`** — the book (plan 9.7), mdBook 0.4.40: `book/src/SUMMARY.md` and a chapter per + topic. The examples and the CHANGELOG are included as they are (`{{#include}}`), never copied; the + notebook page is rendered from `python/allpass-doc.ipynb` by `scripts/notebook-to-book.py` before + `mdbook build book` (generated, gitignored); the performance tables are written by + `scripts/update-perf-docs.py`. `scripts/check-docs.py` checks every chapter is listed and every + include and relative link (the ReadMe's too) resolves. CI's `book` job builds it, warnings as + errors; `book-pages.yml` publishes it from `main`. The favicons in `book/theme/` are TapHouse + copies (`sync.sh --icon PythonTap`), drift-checked like the rest. - **`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. @@ -122,7 +132,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; the release row also runs `scripts/check-docs.py`), `macos` (universal + `lipo`/`otool` checks, including +test's `TAP_PYTHON_SANITIZE`, as the core's; the release row also runs `scripts/check-docs.py`), `book`, `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 @@ -131,6 +141,8 @@ on runners of that architecture — the runtime is per arch, only libpython is u the secrets exist, merges the three into one package for every platform (`assemble-package.py --merge`: each runtime in `support/`, where the external looks before `support/`), and attaches all the zips + SHA256s to a release — a pre-release for 0.x, a draft from 1.0. +`book-pages.yml`: on a push to `main` that changes the book or what it includes, builds it and +deploys it to GitHub Pages (`pages: write`, `id-token: write` — the one workflow that writes). ## Threads and the GIL (load-bearing) @@ -208,7 +220,7 @@ attaches all the zips + SHA256s to a release — a pre-release for 0.x, a draft - **Honest limits are pinned, not hidden.** A known bug or limit gets a test that states it (named for the promise, with the plan item that will change it); fixes land against a test that reproduces the bug first. The core battery is where that happens. Limits no test can pin are written in the - ReadMe's errors paragraph and stay there: the guards catch every Python *exception*, not a + book's Errors and limits chapter and stay there: the guards catch every Python *exception*, not a process exit below Python (`os._exit()`), a crash in a C extension, or code that never returns; helper modules load once per session; a second embedded CPython in the same Max is unsupported. Never let a document claim more than that. @@ -219,7 +231,8 @@ attaches all the zips + SHA256s to a release — a pre-release for 0.x, a draft tests through its compile database. - **C++20** everywhere; the root `CMakeLists.txt` forces it on every object and `_test` target (Min pins C++17), as TapTools-Max does. -- **Keep in sync when behavior changes:** `ReadMe.md`, the object's min metadata +- **Keep in sync when behavior changes:** the book's chapters (and `ReadMe.md`, if its quick start + or install steps change), the object's min metadata (`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 diff --git a/ReadMe.md b/ReadMe.md index 2d47c9d..7e07b10 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -3,247 +3,63 @@ [![build](https://github.com/tap/PythonTap/actions/workflows/build.yml/badge.svg?branch=main)](https://github.com/tap/PythonTap/actions/workflows/build.yml) [![Max 9](https://img.shields.io/badge/Max-9%2B-9cf)](https://cycling74.com/products/max) [![Python 3.13](https://img.shields.io/badge/Python-3.13-3776AB?logo=python&logoColor=white)](https://www.python.org) -[![platforms](https://img.shields.io/badge/platforms-macOS%20universal%20%7C%20Windows%20x64-lightgrey)](#requirements) +[![platforms](https://img.shields.io/badge/platforms-macOS%20universal%20%7C%20Windows%20x64-lightgrey)](#install) [![license: MIT](https://img.shields.io/badge/license-MIT-green)](License.md) +[![book](https://img.shields.io/badge/docs-the%20book-orange)](https://tap.github.io/PythonTap/) -Write Max objects in Python. +Write Max objects in Python. PythonTap embeds CPython 3.13 in a Max package and runs a Python class as a Max object: -`tap.python~` embeds a CPython interpreter inside a Max external and runs a Python class as an audio object: +- **`tap.python~`** runs it as an **audio object**: its `process()` method is called on the signal — once per signal vector with numpy arrays, or once per sample with floats. +- **`tap.python`** runs it as an **object without audio**: messages in, messages out — and what a method returns is what the object outputs. -- The class's **`process()` method** is called on the audio signal — once per signal vector with numpy arrays, or once per sample with floats. -- The class's **type-annotated attributes** become Max attributes (`@gain 0.5` in 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 DSP 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. +Either way, the class's **type-annotated fields** become Max attributes, its **public methods** become Max messages, the file is **hot-reloaded** every time you save it, and Python's `print()` and tracebacks land in the **Max console** — no exception your code raises can take Max down. ```python from attrs import define, field @define -class default: - gain: float = field(default = 1.0) +class mygain: + gain: float = field(default = 0.5) def process(self, x: float) -> float: return x * self.gain ``` ``` -[tap.python~ default] ← loads python/default.py, exposes a 'gain' attribute +[tap.python~ mygain] ← loads python/mygain.py: a 'gain' attribute, process() on the signal ``` -`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). +**[Read the book](https://tap.github.io/PythonTap/)** for everything else: how a class maps onto Max, audio and worker mode, objects without audio, performance, errors and limits, the examples, and building and testing it yourself. -## Requirements +## Install -- **Max 9** or later (macOS 11.0+ on Intel or Apple Silicon, Windows 10 22H2+ / Windows 11, 64-bit). +Requires **Max 9** or later, on macOS 11.0+ (Apple Silicon or Intel) or Windows 10 22H2+ / 11 (64-bit). -## Installation +1. Download a package from the [releases page](https://github.com/tap/PythonTap/releases) — `PythonTap-.zip` for every platform at once, or the zip for yours. The Python runtime (CPython 3.13 with `attrs` and `numpy`) is included. +2. Unzip it into your `Documents/Max 9/Packages` folder. +3. Releases are not code-signed yet. On a Mac, clear the download's quarantine once, or Max will not load the external: + ```sh + xattr -dr com.apple.quarantine ~/Documents/"Max 9"/Packages/PythonTap + ``` + On Windows, SmartScreen may warn about the download. +4. Restart Max, and open **PythonTap Overview** from the Extras menu. -Download a package from the [releases page](https://github.com/tap/PythonTap/releases) — the Python runtime (CPython 3.13 with `attrs` and `numpy`) is included: +The book's [Installing](https://tap.github.io/PythonTap/installing.html) chapter says which zip is which, how to add Python packages to the runtime, and how to work from a clone of this repository. -- `PythonTap-.zip` — every platform at once: Apple Silicon and Intel Macs and Windows, each with its own runtime (in `support/macos-arm64`, `support/macos-x86_64` and `support/windows-x64`). The largest; the one to use when a patch travels between machines. -- `PythonTap--macos-arm64.zip` — Apple Silicon Macs. -- `PythonTap--macos-x86_64.zip` — Intel Macs, and Apple Silicon Macs running Max under Rosetta. -- `PythonTap--windows-x64.zip` — Windows. +## Quick start -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. +1. Save the class above as `mygain.py` in the package's `python` folder (`Documents/Max 9/Packages/PythonTap/python/`). The file, the class and the object's argument share one name. +2. In a patcher: `[cycle~ 440]` → `[tap.python~ mygain]` → `[ezdac~]`. Turn the audio on. +3. Send the object `gain 0.1`, or give it `@gain 0.1` in its box, or connect an `attrui`. +4. Change `process()` — `return x * x * self.gain`, say — and save: within a couple of seconds the object reloads, without a gap in the audio, keeping its gain. -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 -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: -```sh -./support/bin/python3 -m pip install # macOS, from the package folder -.\support\python.exe -m pip install # Windows -``` -In the package for every platform, the runtime is in `support//` — `./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](https://docs.astral.sh/uv/), `uv pip install --python support/bin/python3 ` (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: - -```sh -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](https://github.com/astral-sh/python-build-standalone)), then build (see [Building from source](#building-from-source)): - -**macOS** — in Terminal, from the package root: -```sh -./scripts/install-runtime.sh -``` - -**Windows** — in PowerShell, from the package root: -```powershell -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 `ClassVar`s 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. -- **Audio** — a method `process()` runs on the signal, in one of two forms chosen by its type hint: - - `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.` — `[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. -- **Errors** — 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 above). 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. - -### A note on 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](#writing-a-class) (`@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. - - - -## Objects without audio: `tap.python` +For an object without audio, give the class a method that returns something: with `def bang(self) -> float: return self.gain` added, a bang to `[tap.python mygain]` outputs the gain. The package's `python` folder has more [examples](https://tap.github.io/PythonTap/examples.html), and Max's Documentation window has the package's guide and three tutorials (Package Docs › PythonTap). -`[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 `: 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 ` 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)). - -```sh -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. - -```sh -./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/`, each platform's licenses in `licenses/`), 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. - -### 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: - -```sh -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 note](#a-note-on-performance) — 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: - -```sh -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: - -```sh -python3 runtime-tests/run.py # launches Max, runs every test patcher, quits it (~2 minutes) -``` +## More -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](runtime-tests/README.md) for what they do and how to write a test. +- [The book](https://tap.github.io/PythonTap/) — the whole documentation, and the measurements. +- [CHANGELOG.md](CHANGELOG.md) — what changed in each release. +- [Building from source](https://tap.github.io/PythonTap/building.html) and [testing](https://tap.github.io/PythonTap/testing.html), for contributors; [the design](https://tap.github.io/PythonTap/design.html) and [docs/PRODUCTION-PLAN.md](docs/PRODUCTION-PLAN.md), the roadmap. ## License diff --git a/book/book.toml b/book/book.toml new file mode 100644 index 0000000..157e4f0 --- /dev/null +++ b/book/book.toml @@ -0,0 +1,14 @@ +[book] +title = "PythonTap: Write Max Objects in Python" +description = "tap.python~ runs a Python class as a Max audio object, and tap.python as a Max object without audio: attributes and messages from the class's type hints, hot reload on save, and the measurements behind it." +authors = ["Timothy Place"] +language = "en" +src = "src" + +[build] +create-missing = false + +[output.html] +default-theme = "rust" +git-repository-url = "https://github.com/tap/PythonTap" +site-url = "/PythonTap/" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md new file mode 100644 index 0000000..989e57d --- /dev/null +++ b/book/src/SUMMARY.md @@ -0,0 +1,28 @@ +# Summary + +[PythonTap](introduction.md) + +# Using it + +- [Installing](installing.md) +- [Writing a class](writing-a-class.md) +- [Audio: tap.python~](audio.md) +- [Worker mode](worker-mode.md) +- [Objects without audio: tap.python](control-objects.md) +- [Performance](performance.md) +- [Errors and limits](errors-and-limits.md) + +# Examples + +- [The examples](examples.md) +- [allpass.py in a notebook](notebook.md) + +# Contributing + +- [Building from source](building.md) +- [Testing](testing.md) +- [The design](design.md) + +--- + +[Changelog](changelog.md) diff --git a/book/src/audio.md b/book/src/audio.md new file mode 100644 index 0000000..0058b13 --- /dev/null +++ b/book/src/audio.md @@ -0,0 +1,11 @@ +# 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.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 chapter](performance.md)). + +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. diff --git a/book/src/building.md b/book/src/building.md new file mode 100644 index 0000000..965b629 --- /dev/null +++ b/book/src/building.md @@ -0,0 +1,25 @@ +# 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](installing.md)). + +```sh +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. + +```sh +./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/`, each platform's licenses in `licenses/`), 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. diff --git a/book/src/changelog.md b/book/src/changelog.md new file mode 100644 index 0000000..c16cb03 --- /dev/null +++ b/book/src/changelog.md @@ -0,0 +1 @@ +{{#include ../../CHANGELOG.md}} diff --git a/book/src/control-objects.md b/book/src/control-objects.md new file mode 100644 index 0000000..6d9b003 --- /dev/null +++ b/book/src/control-objects.md @@ -0,0 +1,57 @@ +# 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.md) 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 `: 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 ` 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 chapter](performance.md) has what the bridge itself costs. diff --git a/book/src/design.md b/book/src/design.md new file mode 100644 index 0000000..bf815e1 --- /dev/null +++ b/book/src/design.md @@ -0,0 +1,57 @@ +# 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`](https://github.com/tap/PythonTap/blob/main/docs/PRODUCTION-PLAN.md) (D1–D6, and +the phases that built 1.0) and [`docs/TAP-PYTHON-PLAN.md`](https://github.com/tap/PythonTap/blob/main/docs/TAP-PYTHON-PLAN.md) (D7–D11, the +second object) — and [`CLAUDE.md`](https://github.com/tap/PythonTap/blob/main/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/.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/`, namespace `tap::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 own + `atom_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`), and `tap.python`, a plain + SDK class (`tap.python.h`, `tap.python.cpp`), both registered by the one binary's `ext_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/`. diff --git a/book/src/errors-and-limits.md b/book/src/errors-and-limits.md new file mode 100644 index 0000000..f2f6671 --- /dev/null +++ b/book/src/errors-and-limits.md @@ -0,0 +1,7 @@ +# 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](worker-mode.md)). 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. diff --git a/book/src/examples.md b/book/src/examples.md new file mode 100644 index 0000000..bd80b5d --- /dev/null +++ b/book/src/examples.md @@ -0,0 +1,69 @@ +# 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. + +```python +{{#include ../../python/default.py}} +``` + +### `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](performance.md) for what that saves. + +```python +{{#include ../../python/numpy_gain.py}} +``` + +### `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](notebook.md) runs it the way the object does. + +```python +{{#include ../../python/allpass.py}} +``` + +### `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. + +```python +{{#include ../../python/numpy_allpass.py}} +``` + +### `stereo_width.py` — two inputs, two outputs + +`process()`'s parameters are the object's signal inlets and its return hint its outlets. + +```python +{{#include ../../python/stereo_width.py}} +``` + +## For `tap.python` + +### `euclid.py` — a rhythm from a bang + +```python +{{#include ../../python/euclid.py}} +``` + +### `scale.py` — a list through numpy + +```python +{{#include ../../python/scale.py}} +``` + +### `note_name.py` — two outlets + +```python +{{#include ../../python/note_name.py}} +``` diff --git a/book/src/installing.md b/book/src/installing.md new file mode 100644 index 0000000..cfe15df --- /dev/null +++ b/book/src/installing.md @@ -0,0 +1,52 @@ +# 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](https://github.com/tap/PythonTap/releases) — the Python runtime (CPython 3.13 with `attrs` and `numpy`) is included: + +- `PythonTap-.zip` — every platform at once: Apple Silicon and Intel Macs and Windows, each with its own runtime (in `support/macos-arm64`, `support/macos-x86_64` and `support/windows-x64`). The largest; the one to use when a patch travels between machines. +- `PythonTap--macos-arm64.zip` — Apple Silicon Macs. +- `PythonTap--macos-x86_64.zip` — Intel Macs, and Apple Silicon Macs running Max under Rosetta. +- `PythonTap--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: +```sh +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: +```sh +./support/bin/python3 -m pip install # macOS, from the package folder +.\support\python.exe -m pip install # Windows +``` +In the package for every platform, the runtime is in `support//` — `./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](https://docs.astral.sh/uv/), `uv pip install --python support/bin/python3 ` (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: + +```sh +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](https://github.com/astral-sh/python-build-standalone)), then build (see [Building from source](building.md)): + +**macOS** — in Terminal, from the package root: +```sh +./scripts/install-runtime.sh +``` + +**Windows** — in PowerShell, from the package root: +```powershell +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. diff --git a/book/src/introduction.md b/book/src/introduction.md new file mode 100644 index 0000000..ade9e7e --- /dev/null +++ b/book/src/introduction.md @@ -0,0 +1,48 @@ +# 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**: its `process()` method is called on the + signal — once per signal vector with numpy arrays, or once per sample with floats. +- **`tap.python`** runs 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.5` in 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. + +```python +{{#include ../../python/default.py}} +``` + +``` +[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](installing.md) the package, and adding Python packages to its runtime. +- [Writing a class](writing-a-class.md): the file, attributes, messages, hot reload — what is the same + for both objects. Then [Audio](audio.md) and [Worker mode](worker-mode.md) for `tap.python~`, and + [Objects without audio](control-objects.md) for `tap.python`. +- [Performance](performance.md), measured, and [Errors and limits](errors-and-limits.md): what the + objects guard against and what they cannot. +- [The examples](examples.md) in the package's `python` folder, and [a notebook](notebook.md) that + runs one of them the way the object does. +- For contributors: [Building](building.md), [Testing](testing.md) and [the design](design.md). + +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. diff --git a/book/src/performance.md b/book/src/performance.md new file mode 100644 index 0000000..0d54048 --- /dev/null +++ b/book/src/performance.md @@ -0,0 +1,48 @@ +# 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](worker-mode.md) (`@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. + + diff --git a/book/src/testing.md b/book/src/testing.md new file mode 100644 index 0000000..f694e1d --- /dev/null +++ b/book/src/testing.md @@ -0,0 +1,33 @@ +# 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: + +```sh +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](performance.md) — 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: + +```sh +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: + +```sh +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](https://github.com/tap/PythonTap/blob/main/runtime-tests/README.md) for what they do and how to write a test. diff --git a/book/src/worker-mode.md b/book/src/worker-mode.md new file mode 100644 index 0000000..97f3226 --- /dev/null +++ b/book/src/worker-mode.md @@ -0,0 +1,3 @@ +# 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. diff --git a/book/src/writing-a-class.md b/book/src/writing-a-class.md new file mode 100644 index 0000000..f645a5e --- /dev/null +++ b/book/src/writing-a-class.md @@ -0,0 +1,15 @@ +# 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 `ClassVar`s 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. diff --git a/book/theme/favicon.png b/book/theme/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..658f5bb421e43f240c2d8e94e256ddfd323d7518 GIT binary patch literal 1065 zcmV+^1lIeBP)0p2`GUQW07#pJ21~xXS!5g7EWx+U; ziN=_@1k;3@nYqMZ^vcbsFpZOWVWw`oaT%~J@nW`sUlZy+7_jPexNIORutEW$-Fdyh zHgqknK(Wuw|D5xG-t+uU-t(R}M`50-ca^26BEp1%5x5^%MQG$e(kGE$x-_xPr<+dC zcp8f5VC!p!XB8uh-SZSu1Uunk=1jRUWTj38)$%+xHODuke1X8@qxYC%=il?6D}*~Yet z8cub#0btNQM%{BJ02CCy?K-n001>RwHgMUe22yF!Ms~lnhti^r%L7PDN#o$&Lmb?D zh_sZnfCDX7GdIVExj8nB#cBqiZo7$+4W-m=H!Tl9SEwf`A(^CvWORl4fCE)kWnyxi ziOF&A{Eyxm@u^D&u=aJ~a=UQ3U0D0Ng1*_&Z@%)W!6@KMczyUf*M5o#MOxurGBi`kea!RBkpB6&b||^KlF5XO*O#!VxD0^r&woeHm7awG65|rt_IM3r z^Inv4odM&cTRGX$;thwn&+ijuRpe(CP_8rh^z<7_IN8yHrQ3|9+e~Wmy+|ZO_CY^b z&AO7T2G2?FAH5W27gDvw2nxhR$58&Tf!^OQ(R8d4DShYL+->&yXJ-GVFnc{Y>A4Kt zxXS)7_IdrzsR^7@PQP$J04Y_@Uiyjq(sIa6&qEud<(%yo-aGs*v#uGx!Snr&j)IMsfaw09>%059&r4Ycm0YTAuT@;3^0<*rUY;dn5qI zl17F`<3cK&3tPP8iV$!(YygUE6}OVb=fK4<#5+`%rb(C^%x-~N0|dW`FJDqhUT*!= zY!?8>KkBrpir515{SV;|DXD4sxczGYjpwTE>{WZQF}C^kl=d$mfw|wcm=J|J73cxk jthzKiT0gOLdm8@$Z0l@BN|9-i00000NkvXXu0mjf%}EeA literal 0 HcmV?d00001 diff --git a/book/theme/favicon.svg b/book/theme/favicon.svg new file mode 100644 index 0000000..91fadf2 --- /dev/null +++ b/book/theme/favicon.svg @@ -0,0 +1 @@ +PythonTap diff --git a/core/bench/CMakeLists.txt b/core/bench/CMakeLists.txt index 7e681e7..6bd5389 100644 --- a/core/bench/CMakeLists.txt +++ b/core/bench/CMakeLists.txt @@ -3,7 +3,7 @@ # # The process() benchmark (plan 6.3): how much of a CPU core a class's audio callback needs. # Measure from a Release build; scripts/update-perf-docs.py builds it, runs it, and writes the -# ReadMe's table. +# book's table (book/src/performance.md). find_package(Python3 3.13 EXACT REQUIRED COMPONENTS Interpreter Development.Embed) execute_process( diff --git a/docs/tap.python.maxref.xml b/docs/tap.python.maxref.xml index bc0e801..7ae9927 100644 --- a/docs/tap.python.maxref.xml +++ b/docs/tap.python.maxref.xml @@ -3,7 +3,7 @@ diff --git a/docs/tutorials/pythontap-tut/pythontap_03_control.maxtut.xml b/docs/tutorials/pythontap-tut/pythontap_03_control.maxtut.xml index 6b1bc4e..15512ae 100644 --- a/docs/tutorials/pythontap-tut/pythontap_03_control.maxtut.xml +++ b/docs/tutorials/pythontap-tut/pythontap_03_control.maxtut.xml @@ -73,7 +73,7 @@ class euclid: tap.python outputs what a method returns: a number as a number, a str as a symbol, a list or an array as a list, and the values of a method hinted -> tuple[...] one per outlet. - The ReadMe and the help file have the whole table. + The help file and the PythonTap book have the whole table. diff --git a/docs/vignettes/pythontap_writing.maxvig.xml b/docs/vignettes/pythontap_writing.maxvig.xml index 2dda281..0e49d73 100644 --- a/docs/vignettes/pythontap_writing.maxvig.xml +++ b/docs/vignettes/pythontap_writing.maxvig.xml @@ -22,8 +22,8 @@

Both come with CPython 3.13, numpy and attrs, and share one interpreter: Python's own modules, anything you install into the package's runtime, and your own helper modules are there for every - class. This guide is the short form; the package's ReadMe - says every rule in full, and the tutorials build three objects step by step. + class. This guide is the short form; the PythonTap book, online, says every rule in + full, and the tutorials build three objects step by step.

The class and its file

@@ -137,7 +137,7 @@ class euclid:
  • The tutorials: A gain, per sample and per vector, A filter that knows the sample rate and An object without audio.
  • The help files: tap.python~ and tap.python.
  • The examples, in the package's python folder: default.py, numpy_gain.py, allpass.py and numpy_allpass.py, stereo_width.py, euclid.py, scale.py and note_name.py.
  • -
  • The package's ReadMe: every rule, the performance measurements, and how to add Python packages to the runtime.
  • +
  • The PythonTap book: every rule, the performance measurements, and how to add Python packages to the runtime.
  • diff --git a/help/tap.python~.maxhelp b/help/tap.python~.maxhelp index ad0c637..cab2a82 100644 --- a/help/tap.python~.maxhelp +++ b/help/tap.python~.maxhelp @@ -477,7 +477,7 @@ "numinlets": 1, "numoutlets": 0, "patching_rect": [ 20.0, 285.0, 330.0, 69.0 ], - "text": "Choose one with the message boxes and watch the CPU meter: the ReadMe's performance note has the measurements, per sample and per vector, at 48 and 96 kHz." + "text": "Choose one with the message boxes and watch the CPU meter. The PythonTap book has the measurements, per sample and per vector, at 48 and 96 kHz." } }, { diff --git a/python/numpy_allpass.py b/python/numpy_allpass.py index fb54235..6e70bc2 100644 --- a/python/numpy_allpass.py +++ b/python/numpy_allpass.py @@ -10,7 +10,7 @@ class numpy_allpass: 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 ReadMe's performance note. + fraction of the cost — compare the two in the PythonTap book's Performance chapter. """ delay: float = field(default = 1.0) # delay time in ms diff --git a/runtime-tests/README.md b/runtime-tests/README.md index f26c397..e2ddacc 100644 --- a/runtime-tests/README.md +++ b/runtime-tests/README.md @@ -21,7 +21,8 @@ instead of this checkout: its external, its runtime and its `python/` folder; th patchers and the fixtures still come from here (plan 6.5). Needs macOS, Max 9 in `/Applications` (`--max` for another), the external built, the runtime -installed, and the package in Max's `Packages` folder as `PythonTap` (see the ReadMe). Quit Max +installed, and the package in Max's `Packages` folder as `PythonTap` (see the book's +[Installing](https://tap.github.io/PythonTap/installing.html)). Quit Max first: the runner launches its own, drives it over OSC (UDP 4791/4792 on localhost) and quits it. Unlicensed Max runs patchers, which is all this needs. @@ -66,7 +67,7 @@ runtime-tests/ hour, sampling Max's memory every minute, and writes a summary — memory, reloads, and what the patcher logged each minute — to `logs/soak.summary`; **session `perf`** (plan 6.3) runs `perf/tap.python~.perf.maxpat` and averages Max's CPU-meter readings into `logs/perf.json`, - which `scripts/update-perf-docs.py --max` turns into the ReadMe's table. + which `scripts/update-perf-docs.py --max` turns into the book's table (`book/src/performance.md`). 7. Reads the results from the harness's SQLite database (in the installed `max-test`) and prints them. Max's standard output goes to `runtime-tests/logs/` — only min's own lines appear there; the console errors seen during a failing test are printed from its log instead. diff --git a/runtime-tests/python/maxtest_outputs.py b/runtime-tests/python/maxtest_outputs.py index ff909e9..58d16de 100644 --- a/runtime-tests/python/maxtest_outputs.py +++ b/runtime-tests/python/maxtest_outputs.py @@ -1,5 +1,5 @@ # Runtime-test fixture (runtime-tests/run.py copies it into python/): for tap.python, one method per row -# of the output table (docs/TAP-PYTHON-PLAN.md; the ReadMe's "Objects without audio"). spread() and +# of the output table (docs/TAP-PYTHON-PLAN.md; the book's "Objects without audio"). spread() and # partial() are hinted tuple[str, int], so the object has two value outlets. import numpy as np diff --git a/scripts/check-docs.py b/scripts/check-docs.py index 2e763aa..27de24f 100644 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -14,6 +14,9 @@ - Every patcher — the help files, the tutorials' patchers, the Overview — is JSON in which, in each patcher and subpatcher, box ids are unique and every patch cord joins two boxes that exist, from an outlet the source has to an inlet the destination has. +- The book (plan 9.7) and the ReadMe: every chapter is in book/src/SUMMARY.md, every file a chapter + includes exists, and every relative link — in the book or the ReadMe — names a file that exists + and, for a heading, one that page has. (mdBook checks none of that; a broken link is a 404.) Standard library only. Exits non-zero, naming each problem, if anything is wrong. """ @@ -21,12 +24,14 @@ from __future__ import annotations import json +import re import sys import xml.etree.ElementTree as ElementTree from pathlib import Path ROOT = Path(__file__).resolve().parent.parent DOCS = ROOT / "docs" +BOOK = ROOT / "book" / "src" problems: list[str] = [] @@ -128,9 +133,52 @@ def check_patchers() -> None: check_patcher(path, document["patcher"], "the patcher") +def anchor(heading: str) -> str: + """The id mdBook and GitHub give a heading: lower case, punctuation dropped, spaces as hyphens.""" + heading = re.sub(r"<[^>]+>", "", heading).strip().lower() + return re.sub(r"[^\w\- ]", "", heading).replace(" ", "-") + + +def anchors(path: Path) -> set[str]: + text = re.sub(r"^```.*?^```", "", path.read_text(encoding="utf-8"), flags=re.M | re.S) + return {anchor(heading) for heading in re.findall(r"^#+\s+(.*)$", text, re.M)} + + +def check_links(path: Path) -> None: + text = re.sub(r"^```.*?^```", "", path.read_text(encoding="utf-8"), flags=re.M | re.S) + for target in re.findall(r"\]\(([^)\s]+)\)|srcset=\"([^\"]+)\"|src=\"([^\"]+)\"", text): + target = next(t for t in target if t) + if target.startswith(("http:", "https:", "mailto:")): + continue + file, _, heading = target.partition("#") + destination = (path.parent / file) if file else path + if not destination.exists(): + problem(path, f"links to {target}, which does not exist") + elif heading and destination.suffix == ".md" and heading not in anchors(destination): + problem(path, f"links to {target}, a heading {destination.name} does not have") + + +def check_book() -> None: + summary = (BOOK / "SUMMARY.md").read_text(encoding="utf-8") + listed = set(re.findall(r"\]\(([^)]+\.md)\)", summary)) + generated = {"notebook.md"} # rendered by scripts/notebook-to-book.py; CI's book job checks it + for path in sorted(BOOK.glob("*.md")): + if path.name != "SUMMARY.md" and path.name not in listed: + problem(path, "is not in SUMMARY.md") + for name in sorted(listed - {p.name for p in BOOK.glob("*.md")} - generated): + problem(BOOK / "SUMMARY.md", f"lists {name}, which does not exist") + for path in sorted(BOOK.glob("*.md")): + for include in re.findall(r"\{\{#include ([^}:\s]+)", path.read_text(encoding="utf-8")): + if not (path.parent / include).exists(): + problem(path, f"includes {include}, which does not exist") + check_links(path) + check_links(ROOT / "ReadMe.md") + + def main() -> int: check_pages() check_patchers() + check_book() for line in problems: print(line, file=sys.stderr) if problems: diff --git a/scripts/install-runtime.sh b/scripts/install-runtime.sh index 0875901..b81b3ae 100755 --- a/scripts/install-runtime.sh +++ b/scripts/install-runtime.sh @@ -18,7 +18,7 @@ # Usage: # ./scripts/install-runtime.sh # this Mac's architecture (what users run) # ./scripts/install-runtime.sh --universal # ALSO lipo a universal libpython, for building -# # universal externals (CI; see ReadMe) +# # universal externals (CI; see the book's Building chapter) set -euo pipefail @@ -37,7 +37,7 @@ done if [[ "$(uname -s)" != "Darwin" ]]; then echo "error: this installer is for macOS; on Windows run scripts/install-runtime.ps1." >&2 - echo " (On Linux, development builds embed a system CPython 3.13 — see ReadMe.md.)" >&2 + echo " (On Linux, development builds embed a system CPython 3.13 — see the book's Testing chapter.)" >&2 exit 1 fi diff --git a/scripts/notebook-to-book.py b/scripts/notebook-to-book.py new file mode 100644 index 0000000..fed016b --- /dev/null +++ b/scripts/notebook-to-book.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +# Copyright 2022-2026 Timothy Place. +"""Render python/allpass-doc.ipynb, as committed (executed), into the book (plan 9.7). + + python3 scripts/notebook-to-book.py # before `mdbook build book`; CI runs it + +writes book/src/notebook.md — the notebook's markdown cells as they are, its code cells as Python +blocks, and their outputs: text as text blocks, images as files beside the page +(book/src/notebook/) — so that the book shows what the notebook computed, and cannot drift from it. +Both are generated, and ignored by git; never edit them. Standard library only. +""" + +from __future__ import annotations + +import base64 +import json +import shutil +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +NOTEBOOK = ROOT / "python" / "allpass-doc.ipynb" +PAGE = ROOT / "book" / "src" / "notebook.md" +IMAGES = ROOT / "book" / "src" / "notebook" +NOTE = ("*Rendered from [`python/allpass-doc.ipynb`](https://github.com/tap/PythonTap/blob/main/python/" + "allpass-doc.ipynb), as committed — executed, its outputs those it computed.*") + + +def text(value) -> str: + return "".join(value) if isinstance(value, list) else str(value) + + +def fenced(body: str, language: str = "") -> list[str]: + body = body.rstrip("\n") + fence = "```" + while fence in body: + fence += "`" + return [f"{fence}{language}", body, fence, ""] + + +def render(notebook: dict) -> tuple[list[str], dict[str, bytes]]: + lines: list[str] = [] + images: dict[str, bytes] = {} + for index, cell in enumerate(notebook["cells"]): + source = text(cell.get("source", "")) + if cell["cell_type"] == "markdown": + lines += [source.rstrip("\n"), ""] + if index == 0: + lines += [NOTE, ""] + continue + if cell["cell_type"] != "code" or not source.strip(): + continue + lines += fenced(source, "python") + for output in cell.get("outputs", []): + kind = output.get("output_type") + if kind == "stream": + lines += fenced(text(output.get("text", "")), "text") + elif kind in ("display_data", "execute_result"): + data = output.get("data", {}) + if "image/png" in data: + name = f"cell-{index}-{len(images) + 1}.png" + images[name] = base64.b64decode(text(data["image/png"])) + lines += [f"![The output of the cell above](notebook/{name})", ""] + elif "text/plain" in data: + lines += fenced(text(data["text/plain"]), "text") + elif kind == "error": + lines += fenced("\n".join(output.get("traceback", [])), "text") + return lines, images + + +def main() -> int: + notebook = json.loads(NOTEBOOK.read_text(encoding="utf-8")) + if notebook.get("nbformat") != 4: + print(f"error: {NOTEBOOK.name} is not an nbformat 4 notebook", file=sys.stderr) + return 1 + lines, images = render(notebook) + shutil.rmtree(IMAGES, ignore_errors=True) + if images: + IMAGES.mkdir(parents=True) + for name, data in images.items(): + (IMAGES / name).write_bytes(data) + PAGE.write_text("\n".join(lines).rstrip("\n") + "\n", encoding="utf-8") + print(f"wrote {PAGE.relative_to(ROOT)} and {len(images)} image(s)") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update-perf-docs.py b/scripts/update-perf-docs.py index a85a76e..351e4a8 100644 --- a/scripts/update-perf-docs.py +++ b/scripts/update-perf-docs.py @@ -1,14 +1,15 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: MIT # Copyright 2022-2026 Timothy Place. -"""Measure what process() costs, and a tap.python message, and write them into ReadMe.md (plan 6.3, 9.1). +"""Measure what process() costs, and a tap.python message, and write them into the book (plan 6.3, 9.1, 9.7). - python3 scripts/update-perf-docs.py # build core/bench (Release), run it, update ReadMe.md + python3 scripts/update-perf-docs.py # build core/bench (Release), run it, update the book python3 scripts/update-perf-docs.py --max # also measure in Max (macOS; quit Max first) python3 scripts/update-perf-docs.py --check # print the tables without writing them Builds the benchmark against the runtime in support/ (or --python), runs it, and replaces the text -between the perf markers in ReadMe.md with the measurements and the machine they were taken on. +between the perf markers in book/src/performance.md with the measurements and the machine they were +taken on. With --max it also runs `runtime-tests/run.py --session perf` — Max's own DSP CPU meter with instances of the examples — and replaces the text between the perf-max markers. Never edit those tables by hand: rerun this on an otherwise idle machine, and commit the result. @@ -27,7 +28,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -README = ROOT / "ReadMe.md" +PAGE = ROOT / "book" / "src" / "performance.md" # the tables' one home (plan 9.7) BUILD = ROOT / "build-bench" BEGIN = "" END = "" @@ -194,7 +195,7 @@ def table(results: dict, python: Path) -> str: def main() -> int: parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) parser.add_argument("--python", type=Path, help="the CPython 3.13 to build against (default: support/)") - parser.add_argument("--check", action="store_true", help="print the tables instead of writing ReadMe.md") + parser.add_argument("--check", action="store_true", help="print the tables instead of writing them") parser.add_argument("--max", action="store_true", help="also measure in Max (runtime-tests/run.py)") args = parser.parse_args() python = args.python or default_python() @@ -229,14 +230,14 @@ def main() -> int: if args.check: print("\n\n".join(blocks.values())) return 0 - readme = README.read_text() + page = PAGE.read_text() for (begin, end), text in blocks.items(): - if begin not in readme or end not in readme: - sys.exit(f"error: {README.name} has no {begin} … {end} markers") - start, stop = readme.index(begin), readme.index(end) + len(end) - readme = readme[:start] + text + readme[stop:] - README.write_text(readme) - print(f"updated {README.relative_to(ROOT)}") + if begin not in page or end not in page: + sys.exit(f"error: {PAGE.name} has no {begin} … {end} markers") + start, stop = page.index(begin), page.index(end) + len(end) + page = page[:start] + text + page[stop:] + PAGE.write_text(page) + print(f"updated {PAGE.relative_to(ROOT)}") return 0 diff --git a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp index 94b03e1..9c19206 100644 --- a/source/projects/tap.python_tilde/tap.python_tilde_test.cpp +++ b/source/projects/tap.python_tilde/tap.python_tilde_test.cpp @@ -629,7 +629,7 @@ SCENARIO("Methods named like messages Max sends with C arguments are not exposed // a name only the guard reserves: the test's object_getmethod answers it as // Max's would for a method the class registered " def maxtest_host_answers(self) -> None:\n pass\n" - // what the ReadMe promises stays a message, whatever the guard answers + // what the documentation promises stays a message, whatever the guard answers " def int(self, n: int) -> None:\n pass\n" " def float(self, x: float) -> None:\n pass\n" " def symbol(self, s: str) -> None:\n pass\n" From 71cf7e3789a4937c13ffe83870eb4e905cd1f172 Mon Sep 17 00:00:00 2001 From: Timothy Place Date: Sat, 10 Oct 2026 14:06:49 -0500 Subject: [PATCH 2/3] The plans: 9.7 done (#52) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019rVV9SkjkWmiBXFMT4whkz --- docs/PRODUCTION-PLAN.md | 2 +- docs/TAP-PYTHON-PLAN.md | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/PRODUCTION-PLAN.md b/docs/PRODUCTION-PLAN.md index ef48215..14259e0 100644 --- a/docs/PRODUCTION-PLAN.md +++ b/docs/PRODUCTION-PLAN.md @@ -833,7 +833,7 @@ row shipped what only a host platform could show. - [x] **9.6 The release session, Mac and Windows** (#51)**:** the whole suite on the Mac, the help patcher checked and re-saved, Max's page for `tap.python~` committed, the hand checks; the package by hand on Windows; then tag `v2.0.0`. -- [ ] **9.7 The book:** after 2.0.0, the family's mdBook under `book/`, published by CI, with the +- [x] **9.7 The book** (#52)**:** after 2.0.0, the family's mdBook under `book/`, published by CI, with the examples and performance tables pulled from what ships; the ReadMe becomes the front door. ## Sequencing (one PR each) diff --git a/docs/TAP-PYTHON-PLAN.md b/docs/TAP-PYTHON-PLAN.md index 4499c76..70f10e9 100644 --- a/docs/TAP-PYTHON-PLAN.md +++ b/docs/TAP-PYTHON-PLAN.md @@ -489,7 +489,7 @@ third was Windows-only. the Mac was not idle (media analysis on a core, load average 4; the same code measured 25–50% slower), so Sep 30's stay, by the maintainer's decision, and the message sentence 9.1 measured joins them when the Mac is next idle, after the tag. v2.0.0 is tagged on #51's merge.)* -- [ ] **9.7 The book.** After 2.0.0, the family's shape: an mdBook under `book/` (TapHouse's icon +- [x] **9.7 The book** (#52). After 2.0.0, the family's shape: an mdBook under `book/` (TapHouse's icon rule already knows `book/book.toml` and guards `book/theme/favicon.*`), built and published by CI. Chapters from the ReadMe's sections — install, writing a class, audio, worker mode, control objects, performance, errors and limits, building, testing — plus the examples, the notebook @@ -499,7 +499,15 @@ third was Windows-only. what ships. The ReadMe then shrinks to the front door — what it is, install, a quick start, and links — so that each fact has one home. *Decided 2026-10-09 (the maintainer):* the vignette is the short form, written separately — a few screens in Max's own conventions that point to the - ReadMe and the book for the contract's facts — not generated from the book's source. + ReadMe and the book for the contract's facts — not generated from the book's source. *(2026-10-10: the + book is built and published from `main` by `book-pages.yml`, at https://tap.github.io/PythonTap/, + and checked on every push by `build.yml`'s `book` job. The examples and the CHANGELOG are + included, and the notebook page is rendered at build time (`scripts/notebook-to-book.py`), so + nothing in it is a copy. The performance tables were moved, not doubled: their one home is + `book/src/performance.md`, still Sep 30's measurements until the Mac is next idle. The ReadMe is + 66 lines, and what pointed to it for a rule now points to the book, the vignette included. + `check-docs.py` checks what mdBook does not: every chapter listed, every include and relative + link resolving, anchors included, in the book and the ReadMe.)* ## What 9.0 found From 300469670eec2729fcba8b1ebf68894a63052c80 Mon Sep 17 00:00:00 2001 From: Timothy Place Date: Sat, 10 Oct 2026 14:09:50 -0500 Subject: [PATCH 3/3] check-docs: the notebook page is generated, so a fresh checkout has none Links to book/src/notebook.md failed the check in CI, where the page is not rendered before it runs. The page is exempt, as it already was from the summary listing; the book job renders it and builds the book with warnings as errors. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_019rVV9SkjkWmiBXFMT4whkz --- scripts/check-docs.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/scripts/check-docs.py b/scripts/check-docs.py index 27de24f..44363f3 100644 --- a/scripts/check-docs.py +++ b/scripts/check-docs.py @@ -32,6 +32,9 @@ ROOT = Path(__file__).resolve().parent.parent DOCS = ROOT / "docs" BOOK = ROOT / "book" / "src" +# Rendered by scripts/notebook-to-book.py at build time, so absent from a fresh checkout; CI's book +# job renders it and builds the book with warnings as errors. +GENERATED = {BOOK / "notebook.md"} problems: list[str] = [] @@ -152,6 +155,8 @@ def check_links(path: Path) -> None: continue file, _, heading = target.partition("#") destination = (path.parent / file) if file else path + if destination in GENERATED: + continue if not destination.exists(): problem(path, f"links to {target}, which does not exist") elif heading and destination.suffix == ".md" and heading not in anchors(destination): @@ -161,13 +166,12 @@ def check_links(path: Path) -> None: def check_book() -> None: summary = (BOOK / "SUMMARY.md").read_text(encoding="utf-8") listed = set(re.findall(r"\]\(([^)]+\.md)\)", summary)) - generated = {"notebook.md"} # rendered by scripts/notebook-to-book.py; CI's book job checks it for path in sorted(BOOK.glob("*.md")): - if path.name != "SUMMARY.md" and path.name not in listed: + if path.name != "SUMMARY.md" and path.name not in listed and path not in GENERATED: problem(path, "is not in SUMMARY.md") - for name in sorted(listed - {p.name for p in BOOK.glob("*.md")} - generated): + for name in sorted(listed - {p.name for p in BOOK.glob("*.md")} - {p.name for p in GENERATED}): problem(BOOK / "SUMMARY.md", f"lists {name}, which does not exist") - for path in sorted(BOOK.glob("*.md")): + for path in sorted(set(BOOK.glob("*.md")) - GENERATED): for include in re.findall(r"\{\{#include ([^}:\s]+)", path.read_text(encoding="utf-8")): if not (path.parent / include).exists(): problem(path, f"includes {include}, which does not exist")