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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions maxpylang/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Command-line interface for maxpylang."""
from __future__ import annotations

import argparse
import sys
from importlib.resources import files
from pathlib import Path


def setup_claude(args: argparse.Namespace) -> int:
"""Copy the CLAUDE.md template into the current working directory."""
dest = Path.cwd() / "CLAUDE.md"

if dest.exists() and not args.force:
print(
f"CLAUDE.md already exists at {dest}\n"
"Use --force to overwrite.",
file=sys.stderr,
)
return 1

try:
template = files("maxpylang").joinpath("data", "CLAUDE_TEMPLATE.md").read_text(encoding="utf-8")
except FileNotFoundError:
print("Error: CLAUDE_TEMPLATE.md not found in package data. Reinstall maxpylang.", file=sys.stderr)
return 1

try:
dest.write_text(template, encoding="utf-8")
except OSError as exc:
print(f"Error writing {dest}: {exc}", file=sys.stderr)
return 1

print(f"Created {dest}")
return 0


def main(argv: list[str] | None = None) -> None:
parser = argparse.ArgumentParser(
prog="maxpylang",
description="MaxPyLang command-line tools.",
)
subparsers = parser.add_subparsers(dest="command")

sp = subparsers.add_parser(
"setup-claude",
help="Copy the CLAUDE.md template into the current directory.",
)
sp.add_argument(
"--force", "-f",
action="store_true",
help="Overwrite existing CLAUDE.md if present.",
)
sp.set_defaults(func=setup_claude)

args = parser.parse_args(argv)

if not hasattr(args, "func"):
parser.print_help()
raise SystemExit(1)

raise SystemExit(args.func(args))


if __name__ == "__main__":
main()
327 changes: 327 additions & 0 deletions maxpylang/data/CLAUDE_TEMPLATE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,327 @@
# MaxPyLang

> Generated by `maxpylang setup-claude`. This file tells Claude Code how to use the MaxPyLang API.

Python library for programmatically creating and editing Max/MSP patches (`.maxpat` files).

## Quick Start

```python
import maxpylang as mp

patch = mp.MaxPatch()
osc = patch.place("cycle~ 440")[0]
dac = patch.place("ezdac~")[0]
patch.connect([osc.outs[0], dac.ins[0]])
patch.save("hello_world")
```

## Core API

### MaxPatch

```python
patch = mp.MaxPatch(template=None, load_file=None, reorder=True, verbose=True)
```

- `template` — path to a `.maxpat` template file
- `load_file` — path to an existing `.maxpat` or `.amxd` to load and modify

**Methods:**

```python
patch.place(*objs, num_objs=1, spacing_type="grid", spacing=[80,80],
starting_pos=None, verbose=False) -> list[MaxObject]
```
Places objects in the patch. Always returns a **list** — use `[0]` for a single object.

```python
patch.connect(*connections, verbose=True)
```
Each connection is `[outlet, inlet]`: `patch.connect([obj1.outs[0], obj2.ins[0]])`.

```python
patch.save(filename="default.maxpat", device_type=None, verbose=True, check=True)
```
Auto-appends `.maxpat` if missing. For Max for Live: `patch.save("device.amxd", device_type="instrument")`.

```python
patch.set_position(new_x, new_y)
```
Moves the internal cursor for next placement.

**Properties:** `patch.objs` (dict), `patch.num_objs` (int), `patch.curr_position` (list)

### MaxObject

```python
obj = mp.MaxObject("cycle~ 440")
obj = mp.MaxObject("metro 500 @active 1") # with attributes
```

**Properties:**
- `obj.name` — object class name (e.g. `"cycle~"`)
- `obj.ins` — list of Inlets (0-indexed)
- `obj.outs` — list of Outlets (0-indexed)

**Methods:**
```python
obj.edit(text_add="append", text=None, **extra_attribs) # modify object
obj.move(x, y) # reposition
```

### Connections

Inlets and outlets are 0-indexed. Wire them with `connect()`:

```python
patch.connect([src.outs[0], dst.ins[0]]) # single connection
patch.connect([a.outs[0], b.ins[0]],
[b.outs[0], c.ins[0]]) # multiple connections
```

## Stub Objects

Pre-instantiated MaxObject stubs for IDE autocomplete:

```python
from maxpylang.objects import cycle_tilde, ezdac_tilde, metro, toggle
```

**Naming rules:**
| Max name | Python name | Rule |
|----------|------------|------|
| `cycle~` | `cycle_tilde` | `~` becomes `_tilde` |
| `jit.movie` | `jit_movie` | `.` becomes `_` |
| `live.dial` | `live_dial` | `-` becomes `_` |
| `2d.wave~` | `_2d_wave_tilde` | leading digit gets `_` prefix |
| `in` | `in_` | Python keyword gets `_` suffix |

Stubs are real MaxObjects — pass them directly to `place()`:

```python
osc = patch.place(cycle_tilde)[0] # equivalent to patch.place("cycle~")[0]
```

Stubs have no arguments. Use `edit()` to add them, or use string syntax when you need arguments:

```python
# stub (no args) — use for objects that don't need arguments
dac = patch.place(ezdac_tilde)[0]

# string (with args) — simpler when arguments are needed
osc = patch.place("cycle~ 440")[0]
```

## Available Objects

All valid object names are listed in `maxpylang/objects/` (stubs organized by package: `max.py`, `msp.py`, `jit.py`). Use these as the source of truth for valid names.

**Common objects by category:**

| Category | Objects |
|----------|---------|
| Audio sources | `cycle~`, `noise~`, `pink~`, `rect~`, `saw~`, `tri~`, `phasor~` |
| Audio I/O | `adc~`, `dac~`, `ezdac~`, `ezadc~` |
| Audio processing | `gain~`, `lores~`, `reson~`, `biquad~`, `delay~`, `tapin~`, `tapout~` |
| Math (audio) | `+~`, `*~`, `-~`, `/~`, `clip~`, `abs~`, `scale~` |
| Control | `metro`, `counter`, `toggle`, `button`, `number`, `flonum`, `dial` |
| Routing | `gate`, `switch`, `route`, `select`, `trigger`, `pack`, `unpack` |
| Data | `coll`, `dict`, `table`, `buffer~`, `preset` |
| MIDI | `notein`, `noteout`, `ctlin`, `ctlout`, `midiin`, `midiout` |
| UI | `multislider`, `slider`, `comment`, `message`, `panel`, `live.dial` |
| Timing | `delay`, `pipe`, `timer`, `transport`, `tempo` |
| Math (control) | `+`, `*`, `-`, `/`, `%`, `random`, `drunk`, `scale` |

## Common Patterns

### Audio chain

```python
from maxpylang.objects import gain_tilde, ezdac_tilde

patch = mp.MaxPatch()
osc = patch.place("cycle~ 440")[0] # string syntax: has arguments
gain = patch.place(gain_tilde)[0] # stub syntax: no arguments needed
dac = patch.place(ezdac_tilde)[0] # stub syntax: no arguments needed
patch.connect([osc.outs[0], gain.ins[0]],
[gain.outs[0], dac.ins[0]],
[gain.outs[0], dac.ins[1]])
patch.save("audio_chain")
```

### Multiple objects with loops

```python
n = 10
toggles = patch.place("toggle", num_objs=n, starting_pos=[0, 100])
gates = patch.place("gate", num_objs=n, starting_pos=[0, 200])

for t, g in zip(toggles, gates):
patch.connect([t.outs[0], g.ins[0]])
```

### Attributes via `@` syntax

```python
patch.place("metro 500 @active 1")[0]
patch.place("jit.movie @moviefile crashtest.mov")[0]
```

### Loading and modifying existing patches

```python
patch = mp.MaxPatch(load_file="existing.maxpat")
for key, obj in patch.objs.items():
print(obj.name)
patch.save("modified")
```

## Abstractions

For custom Max abstractions (sub-patches) that don't exist in the current directory:

```python
# Declare abstraction with known I/O — no .maxpat file needed
synth = mp.MaxObject("my_synth", abstraction=True, inlets=2, outlets=2)
placed = patch.place(synth)[0]

# With arguments
synth = mp.MaxObject("my_synth 440 0.5", abstraction=True, inlets=2, outlets=2)

# If the .maxpat file exists in cwd, auto-detection still works
synth = mp.MaxObject("my_synth")
```

- `abstraction=True` — skip file lookup, treat as abstraction
- `inlets` / `outlets` — declare I/O count (default 0 if omitted)
- `notknown()` returns `False` for declared abstractions
- Create the `MaxObject` first, then pass to `place()` (place doesn't forward these kwargs)

## Key Rules

- `place()` **always returns a list** — use `[0]` for single objects
- Object names are **case-sensitive** and must match Max names exactly
- Coordinates are floats
- `save()` auto-appends `.maxpat`
- `verbose=False` suppresses console output
- Inlet/outlet indices are **0-based**
- **Typos raise `UnknownObjectWarning`** — if you see this warning, fix the object name immediately. The object will have 0 inlets/outlets and won't work. Use `obj.notknown()` to check programmatically.

## Patch Layout

Call `set_position(x, y)` **before every `place()` call**. Without it, objects pile up and cords cross.

```python
Y_STEP = 40 # between objects in a chain
SECTION_GAP = 80 # between logical sections
COL_WIDTH = 150 # between parallel columns

patch.set_position(30, 100)
osc = patch.place("cycle~")[0]

patch.set_position(30, 140) # +Y_STEP
filt = patch.place("lores~")[0]

patch.set_position(30 + COL_WIDTH, 100) # parallel column
lfo = patch.place("cycle~ 2")[0]
```

**Rules:**
- Top-to-bottom signal flow (increasing `y`)
- Parallel chains side by side (same `y`, different `x`)
- Labels (`comment`) 20px above their object
- Section headers: `patch.place("comment === SECTION NAME ===")`
- `loadbang`/defaults to the right of main flow
- Group `connect()` calls by section, not scattered throughout

## Max for Live (.amxd) Devices

MaxPyLang can save patches as Max for Live devices for use in Ableton Live.

### The `device_type` Flag

The `device_type` parameter on `save()` is the **explicit signal** for building M4L devices:

```python
# Regular Max patch (default)
patch.save("my_patch") # → .maxpat

# M4L Instrument (MIDI in, audio out)
patch.save("my_synth.amxd", device_type="instrument") # → .amxd

# M4L Audio Effect (audio in, audio out)
patch.save("my_reverb.amxd", device_type="audio_effect") # → .amxd

# M4L MIDI Effect (MIDI in, MIDI out)
patch.save("my_arp.amxd", device_type="midi_effect") # → .amxd
```

When `device_type` is set, the extension is forced to `.amxd`. When saving `.amxd` without `device_type`, a `ValueError` is raised.

### Device Types and I/O Objects

Each device type requires specific I/O objects. **Use these instead of `ezdac~`/`adc~`:**

| `device_type` | Input object | Output object | Ableton track |
|---------------|-------------|---------------|---------------|
| `"instrument"` | `notein` | `plugout~` | MIDI track |
| `"audio_effect"` | `plugin~` | `plugout~` | Audio track / after instrument |
| `"midi_effect"` | `midiin` → `midiparse` | `midiformat` → `midiout` | Before instrument on MIDI track |

### I/O Substitution Rules

| Regular Max | Max for Live | Why |
|---|---|---|
| `ezdac~` | `plugout~` | Audio output routes to Ableton's mixer |
| `adc~` | `plugin~` | Audio input comes from the track's signal chain |
| `toggle` on `ezdac~` | Nothing — always on | Ableton controls the audio engine |
| `dial`, `slider` | `live.dial`, `live.slider` | Exposes parameters to Ableton for automation |

### Signal Flow Patterns

**Instrument** (MIDI → audio):
```
notein → mtof → cycle~ → clip~ -1. 1. → plugout~
```

**Audio Effect** (audio → audio):
```
plugin~ → [processing] → clip~ -1. 1. → plugout~
```

**MIDI Effect** (MIDI → MIDI):
```
midiin → midiparse → [processing] → midiformat → midiout
```

### M4L Key Rules

- **Always use `clip~ -1. 1.` before `plugout~`** — speaker safety
- **No volume knob needed** — Ableton's mixer handles track volume
- **`plugout~` has 2 inlets** (L/R stereo) — connect both for stereo output
- **`plugin~` has 2 outlets** (L/R stereo) — process both channels

### Loading Existing .amxd Files

```python
patch = mp.MaxPatch(load_file="existing_device.amxd")
for key, obj in patch.objs.items():
print(obj.name)
patch.save("modified.amxd", device_type="instrument")
```

### Standalone amxd Functions

For advanced use (custom JSON manipulation before saving):

```python
from maxpylang import save_amxd, load_amxd

# Save with custom JSON
json_dict = patch.get_json()
save_amxd(json_dict, "device.amxd", device_type="instrument")

# Load .amxd back to JSON dict
json_dict = load_amxd("device.amxd")
```
Loading
Loading