Skip to main content

Roundtrip parsing and generation of pure-data patches from python

Project description

py2pd - Python <-> PureData

Roundtrip parsing and generation of pure-data patches from python.

py2pd is a fork and extensive rewrite of Dylan Burati's puredata-compiler using some of the ideas from py2max.

Features

  • Builder API -- imperative patch construction with add(), link(), and automatic layout
  • AST API -- lossless round-trip parsing of .pd files via frozen dataclasses
  • Bridging -- convert freely between Builder and AST with from_builder() / to_builder()
  • All GUI types -- Bang, Toggle, NumberBox, Symbol, HSlider, VSlider, HRadio, VRadio, Canvas, VU
  • Subpatches -- nested patches with auto-inferred inlet/outlet counts and graph-on-parent support
  • Abstractions -- reference external .pd files with auto-inferred I/O
  • Connection validation -- eager index checking against a registry of ~80 common Pd objects
  • Patch optimization -- deduplicate connections, collapse pass-throughs, remove unused nodes
  • SVG export -- visualize patches as SVG
  • Externals discovery -- platform-aware scanning for .pd abstractions and binary externals
  • libpd validation -- load patches into libpd via cypd and check for errors (pip install py2pd[extras])
  • hvcc integration -- validate and compile patches with the Heavy Compiler Collection (pip install py2pd[extras])
  • Zero runtime dependencies -- Python 3.13+, no required dependencies

Install

pip install py2pd

Quick Start

from py2pd import Patcher

# Create a simple synthesizer patch
p = Patcher('synth.pd')

osc = p.add('osc~ 440')
gain = p.add('*~ 0.3')
dac = p.add('dac~')

p.link(osc, gain)
p.link(gain, dac)
p.link(gain, dac, inlet=1)  # stereo

p.save()

Builder API

The Patcher class provides methods to add nodes and connect them.

Adding Nodes

from py2pd import Patcher

p = Patcher('example.pd')

# Objects
osc = p.add('osc~ 440')
filter_obj = p.add('lop~ 1000')

# Messages
bang = p.add_msg('bang')
freq_msg = p.add_msg('440')

# GUI Elements
slider = p.add_hslider(min_val=20, max_val=20000, width=150)
toggle = p.add_toggle(default_value=1, send='onoff')
numbox = p.add_numberbox(min_val=0, max_val=127)
bang_btn = p.add_bang(send='trigger', label='Click')

Connecting Nodes

Use link() to connect nodes. By default, outlet 0 connects to inlet 0:

p.link(osc, gain)              # outlet 0 -> inlet 0
p.link(gain, dac)              # left channel
p.link(gain, dac, inlet=1)     # right channel (stereo)
p.link(trigger, pack, outlet=1, inlet=2)  # specific ports

Outlet and inlet indices are validated eagerly when the node's I/O counts are known (from PD_OBJECT_REGISTRY or explicit num_inlets/num_outlets). Out-of-range indices raise PdConnectionError at link() time rather than silently creating invalid connections. Objects with unknown I/O counts (e.g., trigger, pack, route) skip validation.

Subpatches

Create reusable subpatches:

def make_envelope() -> Patcher:
    p = Patcher()
    inlet = p.add('inlet')
    vline = p.add('vline~')
    outlet = p.add('outlet~')
    p.link(inlet, vline)
    p.link(vline, outlet)
    return p

main = Patcher('main.pd')
osc = main.add('osc~ 440')
env = main.add_subpatch('envelope', make_envelope())
vca = main.add('*~')

main.link(osc, vca)
main.link(env, vca, inlet=1)

Graph-on-Parent

Subpatches can expose their GUI elements to the parent patch using graph-on-parent mode:

inner = Patcher()
slider = inner.add_hslider(min_val=0, max_val=1000, label='freq')
outlet = inner.add('outlet')
inner.link(slider, outlet)

main = Patcher('main.pd')
ctrl = main.add_subpatch(
    'controls', inner,
    graph_on_parent=True,
    hide_name=True,
    gop_width=150,
    gop_height=40,
)

Abstractions

Reference external .pd files as objects in your patch:

from py2pd import Patcher

p = Patcher('main.pd')

# With explicit inlet/outlet counts
synth = p.add_abstraction('my-synth 440 0.5',
                          num_inlets=2, num_outlets=1)

# Auto-infer I/O from the source file
synth = p.add_abstraction('my-synth 440',
                          source_path='my-synth.pd')

dac = p.add('dac~')
p.link(synth, dac)
p.link(synth, dac, inlet=1)

Layout Options

Default layout - nodes flow top-to-bottom:

p = Patcher('patch.pd')
p.add('osc~ 440')   # Row 1
p.add('*~ 0.5')     # Row 2
p.add('dac~')       # Row 3

Grid layout - organized columns:

from py2pd import Patcher, GridLayoutManager

grid = GridLayoutManager(columns=4, cell_width=80, cell_height=35)
p = Patcher('grid.pd', layout=grid)

Auto layout - arrange by signal flow:

p = Patcher('patch.pd')
# Add nodes in any order...
p.auto_layout(margin=50, row_spacing=50, col_spacing=100)

Saving and Export

p.save()                    # Save to filename from constructor
p.save('other.pd')          # Save to specific file
p.save_svg('patch.svg')     # Export visualization as SVG
svg_str = p.to_svg()        # Get SVG as string

Optimization

Clean up patches by removing unused elements and simplifying connections:

p = Patcher()
osc = p.add('osc~ 440')
gain = p.add('*~ 0.5')
unused = p.add('+~ 0.1')  # not connected to anything
dac = p.add('dac~')
p.link(osc, gain)
p.link(gain, dac)
p.link(gain, dac)  # accidental duplicate

result = p.optimize()
# result == {'nodes_removed': 1, 'connections_removed': 2,
#            'duplicates_removed': 1, 'pass_throughs_collapsed': 0,
#            'subpatches_optimized': 0}

The three passes run in order:

  1. Deduplicate connections -- removes exact-duplicate patch cords.
  2. Pass-through collapse -- bypasses single-in/single-out nodes (opt-in via collapsible_objects).
  3. Unused element removal -- removes disconnected Obj nodes. GUI elements, comments, subpatches, abstractions, arrays, messages, and floats are never removed. Nodes with active send/receive parameters are preserved.

Use recursive=True to optimize inner subpatches as well:

result = p.optimize(recursive=True)

Validation

p.validate_connections(check_cycles=True)  # Raises on invalid connections

AST API (Round-trip Parsing)

For modifying existing patches with immutable AST nodes:

from py2pd import parse_file, serialize

# Parse existing patch
ast = parse_file('input.pd')

# Modify the AST...

# Write back
with open('output.pd', 'w') as f:
    f.write(serialize(ast))

Converting Between APIs

You can convert between AST and Builder representations:

from py2pd import parse_file, to_builder, from_builder

# AST -> Builder: parse then edit with the more convenient API
ast = parse_file('input.pd')
patch = to_builder(ast)
patch.add('osc~ 880')
patch.save('output.pd')

# Builder -> AST: for analysis or transformation
ast = from_builder(patch)

When to Use Each API

Use Case Recommended API
Creating patches from scratch Builder
Modifying existing patches Builder (via to_builder())
Lossless round-trip of complex patches AST
Building analysis/refactoring tools AST
Batch search/replace across .pd files AST

For most workflows, parse to AST then convert to Builder for editing. Use the AST API directly when you need to preserve elements the Builder doesn't model (e.g., coords, comments) or need immutable transformations.

AST node types are available from the py2pd.ast module:

from py2pd.ast import PdPatch, PdObj, PdMsg, Position, transform, find_objects

GUI Elements

Method Description
add_bang() Bang button
add_toggle() On/off toggle
add_numberbox() Editable number
add_float() Float atom
add_symbol() Symbol/text input
add_hslider() Horizontal slider
add_vslider() Vertical slider
add_hradio() Horizontal radio buttons
add_vradio() Vertical radio buttons
add_canvas() Background/label area
add_vu() VU meter

All GUI add_* methods accept every parameter from the underlying constructor, including IEM styling options (label_x, label_y, font, font_size, bg_color, fg_color, label_color, etc.). Defaults match PureData's standard values.

Discovery

Scan the filesystem for installed PureData externals:

from py2pd import discover_externals, default_search_paths, extract_declare_paths

# Find all externals on platform-default search paths
registry = discover_externals()
# registry == {'my-external': (2, 1), 'reverb~': (None, None), ...}

# See which paths are searched
paths = default_search_paths()

# Extract -path declarations from a parsed patch
from py2pd import parse_file
ast = parse_file('input.pd')
declared = extract_declare_paths(ast)

discover_externals() returns a dict mapping external names to (num_inlets, num_outlets) tuples. For .pd abstractions the counts are inferred from the file; binary externals get (None, None).

Integrations

Validation (cypd/libpd)

Validate patches by loading them into libpd and checking for errors:

pip install py2pd[extras]
from py2pd.integrations.cypd import validate_patch

p = Patcher('synth.pd')
osc = p.add('osc~ 440')
dac = p.add('dac~')
p.link(osc, dac)

result = validate_patch(p)
if not result.ok:
    print("Errors:", result.errors)
    print("Warnings:", result.warnings)

hvcc (Heavy Compiler Collection)

Build patches compatible with the hvcc compiler for generating C/C++ code:

pip install py2pd[extras]

HeavyPatcher validates objects at add-time:

from py2pd.integrations.hvcc import HeavyPatcher, HvccGenerator

p = HeavyPatcher(generators=[HvccGenerator.DPF])

# add_param creates an [r __hv_param_name ...] receive object
freq = p.add_param('freq', min_val=20.0, max_val=20000.0, default=440.0)
osc = p.add('osc~')
dac = p.add('dac~')
p.link(freq, osc)
p.link(osc, dac)
p.link(osc, dac, inlet=1)

Validate existing patches:

from py2pd.integrations.hvcc import validate_for_hvcc

result = validate_for_hvcc(p)
if not result.ok:
    print("Unsupported objects:", result.unsupported)

Compile to C/C++ (requires hvcc installed):

from py2pd.integrations.hvcc import compile_hvcc

result = compile_hvcc(p, name='MySynth', out_dir='build/')
if not result.ok:
    print(result.stderr)

Error Handling

from py2pd import (
    PdConnectionError,      # Invalid connection arguments (including out-of-range indices)
    NodeNotFoundError,      # Node not in patch
    InvalidConnectionError, # Bad inlet/outlet index (from validate_connections)
    CycleWarning,           # Feedback loop detected
)

PdConnectionError is raised eagerly by link() when outlet or inlet indices exceed the node's known I/O counts. Node.__getitem__ (e.g., osc[2]) raises ValueError for out-of-range outlet indices. Both checks are skipped for objects with unknown counts (num_outlets=None / num_inlets=None).

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

py2pd-0.1.3.tar.gz (45.3 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

py2pd-0.1.3-py3-none-any.whl (48.3 kB view details)

Uploaded Python 3

File details

Details for the file py2pd-0.1.3.tar.gz.

File metadata

  • Download URL: py2pd-0.1.3.tar.gz
  • Upload date:
  • Size: 45.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for py2pd-0.1.3.tar.gz
Algorithm Hash digest
SHA256 e1e9fc9703a4fdabf934517e70443b254046c39322f510a137d4577d88770ac9
MD5 769363c1a591d4435a8215f243925c6b
BLAKE2b-256 094e3859bf4f080bb3d39ce246a4c71c11427bc2787b3be4a08897174123422b

See more details on using hashes here.

File details

Details for the file py2pd-0.1.3-py3-none-any.whl.

File metadata

  • Download URL: py2pd-0.1.3-py3-none-any.whl
  • Upload date:
  • Size: 48.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for py2pd-0.1.3-py3-none-any.whl
Algorithm Hash digest
SHA256 83677267765f2238a973a7f0b51649c3b016b1b356b1492c67d80b409098fe0a
MD5 403154b1c032889a1b2065d1d7355c9b
BLAKE2b-256 a403ba7bad8fd5e97502ed701005c85fe1c970663ee02cdf36938bfbcc6db136

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page