diff --git a/maxpylang/cli.py b/maxpylang/cli.py new file mode 100644 index 0000000..5b0b835 --- /dev/null +++ b/maxpylang/cli.py @@ -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() diff --git a/maxpylang/data/CLAUDE_TEMPLATE.md b/maxpylang/data/CLAUDE_TEMPLATE.md new file mode 100644 index 0000000..8f92137 --- /dev/null +++ b/maxpylang/data/CLAUDE_TEMPLATE.md @@ -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") +``` diff --git a/pyproject.toml b/pyproject.toml index 5cb69d7..f49cf9b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,9 @@ dependencies = [ "tabulate", ] +[project.scripts] +maxpylang = "maxpylang.cli:main" + [project.urls] Homepage = "http://pypi.python.org/pypi/MaxPyLang/"