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
29 changes: 20 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,28 @@ jobs:
python -m pip install --upgrade pip
pip install "setuptools>=77.0.0" wheel
pip install -e .
pip install pytest

- name: Run hello_world example
- name: Run examples
run: |
cd examples/hello_world
python main.py
cd examples/hello_world && python main.py && cd ../..
cd examples/m4l_instrument && python main.py && cd ../..
cd examples/m4l_audio_effect && python main.py && cd ../..
cd examples/attributes && python main.py && cd ../..
cd examples/random_pitch_generator && python tester3.py && cd ../..

- name: Verify output file was created
- name: Verify output files were created
run: |
test -f examples/hello_world/hello_world.maxpat
echo "✓ hello_world.maxpat successfully created"

- name: Display output file
run: |
cat examples/hello_world/hello_world.maxpat
echo "hello_world.maxpat created"
test -f examples/m4l_instrument/m4l_instrument.amxd
echo "m4l_instrument.amxd created"
test -f examples/m4l_audio_effect/m4l_audio_effect.amxd
echo "m4l_audio_effect.amxd created"
test -f examples/attributes/attributes.maxpat
echo "attributes.maxpat created"
test -f examples/random_pitch_generator/tester3.maxpat
echo "random_pitch_generator/tester3.maxpat created"

- name: Run tests
run: python -m pytest tests/ -v
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ maxpylang/data/OBJ_ALIASES/

# macOS
.DS_Store
tasks/
66 changes: 66 additions & 0 deletions examples/m4l_audio_effect/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""
Max for Live Audio Effect Example
===================================
A simple lowpass filter that runs as a Max for Live audio effect in Ableton.

Signal chain:
plugin~ (stereo from track) → lores~ (lowpass filter) → clip~ → plugout~ (stereo)

Usage:
python main.py
→ Generates m4l_audio_effect.amxd
→ Drag onto an audio track (or after an instrument) in Ableton Live
"""

import maxpylang as mp

patch = mp.MaxPatch()

# === AUDIO INPUT ===
patch.set_position(30, 30)
patch.place("comment === AUDIO INPUT ===")[0]

patch.set_position(30, 60)
plugin = patch.place("plugin~")[0] # stereo audio from track

# === FILTER ===
patch.set_position(30, 120)
patch.place("comment === FILTER ===")[0]

# Left channel filter
patch.set_position(30, 150)
filt_l = patch.place("lores~ 2000 0.5")[0]

# Right channel filter
patch.set_position(200, 150)
filt_r = patch.place("lores~ 2000 0.5")[0]

# === OUTPUT ===
patch.set_position(30, 220)
patch.place("comment === OUTPUT ===")[0]

patch.set_position(30, 250)
clip_l = patch.place("clip~ -1. 1.")[0]

patch.set_position(200, 250)
clip_r = patch.place("clip~ -1. 1.")[0]

patch.set_position(30, 290)
plugout = patch.place("plugout~")[0]

# === CONNECTIONS ===
patch.connect(
# plugin~ stereo → filters
[plugin.outs[0], filt_l.ins[0]], # left in → left filter
[plugin.outs[1], filt_r.ins[0]], # right in → right filter
# filters → safety limiters
[filt_l.outs[0], clip_l.ins[0]],
[filt_r.outs[0], clip_r.ins[0]],
# limiters → plugout~ stereo
[clip_l.outs[0], plugout.ins[0]], # left → Ableton
[clip_r.outs[0], plugout.ins[1]], # right → Ableton
)

# === SAVE ===
# device_type="audio_effect" is the explicit flag for M4L audio effects
patch.save("m4l_audio_effect.amxd", device_type="audio_effect")
57 changes: 57 additions & 0 deletions examples/m4l_instrument/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""
Max for Live Instrument Example
================================
A minimal sine synth that runs as a Max for Live MIDI instrument in Ableton.

Signal chain:
notein → mtof → cycle~ → clip~ -1. 1. → plugout~ (stereo)

Usage:
python main.py
→ Generates m4l_instrument.amxd
→ Drag onto a MIDI track in Ableton Live
"""

import maxpylang as mp

patch = mp.MaxPatch()

# === MIDI INPUT ===
patch.set_position(30, 30)
patch.place("comment === MIDI INPUT ===")[0]

patch.set_position(30, 60)
notein = patch.place("notein")[0]

patch.set_position(30, 100)
mtof = patch.place("mtof")[0]

# === OSCILLATOR ===
patch.set_position(30, 160)
patch.place("comment === OSCILLATOR ===")[0]

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

# === OUTPUT ===
patch.set_position(30, 250)
patch.place("comment === OUTPUT ===")[0]

patch.set_position(30, 280)
clip = patch.place("clip~ -1. 1.")[0]

patch.set_position(30, 320)
plugout = patch.place("plugout~")[0]

# === CONNECTIONS ===
patch.connect(
[notein.outs[0], mtof.ins[0]], # note number → mtof
[mtof.outs[0], osc.ins[0]], # frequency → oscillator
[osc.outs[0], clip.ins[0]], # audio → safety limiter
[clip.outs[0], plugout.ins[0]], # left channel → Ableton
[clip.outs[0], plugout.ins[1]], # right channel → Ableton
)

# === SAVE ===
# device_type="instrument" is the explicit flag for M4L instruments
patch.save("m4l_instrument.amxd", device_type="instrument")
4 changes: 3 additions & 1 deletion maxpylang/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@
patch.place(*objs, num_objs=1, spacing_type="grid", spacing=[80,80],
starting_pos=None, verbose=False) -> list[MaxObject]
patch.connect(*connections, verbose=True)
patch.save(filename="default.maxpat", verbose=True, check=True)
patch.save(filename="default.maxpat", device_type=None, verbose=True, check=True)
patch.set_position(new_x, new_y)

- ``place()`` **always returns a list** — use ``[0]`` for a single object.
- Each connection is ``[outlet, inlet]``: ``patch.connect([obj1.outs[0], obj2.ins[0]])``.
- ``save()`` auto-appends ``.maxpat`` if missing.
- For Max for Live devices: ``patch.save("device.amxd", device_type="instrument")``.

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

Expand Down Expand Up @@ -185,6 +186,7 @@
from .maxpatch import MaxPatch
from .importobjs import import_objs
from .xlet import Inlet, Outlet
from .amxd import save_amxd, load_amxd, DEVICE_TYPES

try:
from . import objects
Expand Down
64 changes: 64 additions & 0 deletions maxpylang/amxd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""
amxd.py — Save and load Max for Live .amxd files.

The .amxd format is a binary wrapper around the same JSON that .maxpat uses.
Three chunks: ampf (device type), meta (reserved), ptch (patcher JSON + null).
"""

import struct
import json

DEVICE_TYPES = {
"audio_effect": b"aaaa",
"midi_effect": b"mmmm",
"instrument": b"iiii",
}


def save_amxd(patcher_json, filename, device_type="instrument"):
"""Wrap a patcher JSON dict in .amxd binary format and write to file."""
if not isinstance(patcher_json, dict):
raise TypeError(f"patcher_json must be a dict, got {type(patcher_json).__name__}")
if device_type not in DEVICE_TYPES:
raise ValueError(f"Unknown device_type {device_type!r}. "
f"Choose from: {', '.join(DEVICE_TYPES)}")

json_bytes = json.dumps(patcher_json, indent=2).encode("utf-8") + b"\x00"

with open(filename, "wb") as f:
# ampf chunk — device type identifier
f.write(b"ampf")
f.write(struct.pack("<I", 4))
f.write(DEVICE_TYPES[device_type])
# meta chunk — reserved, 4 null bytes
f.write(b"meta")
f.write(struct.pack("<I", 4))
f.write(b"\x00\x00\x00\x00")
# ptch chunk — the patcher JSON, null-terminated
f.write(b"ptch")
f.write(struct.pack("<I", len(json_bytes)))
f.write(json_bytes)


def load_amxd(filename):
"""Read an .amxd file and return the patcher JSON dict."""
with open(filename, "rb") as f:
data = f.read()

offset = 0
while offset + 8 <= len(data):
tag = data[offset:offset + 4]
size = struct.unpack("<I", data[offset + 4:offset + 8])[0]
if offset + 8 + size > len(data):
raise ValueError(f"Chunk {tag!r} size {size} extends past end of file")
if tag == b"ptch":
json_bytes = data[offset + 8:offset + 8 + size]
try:
return json.loads(json_bytes.rstrip(b"\x00"))
except json.JSONDecodeError as e:
raise ValueError(
f"ptch chunk in '{filename}' contains invalid JSON: {e}"
) from e
offset += 8 + size

raise ValueError(f"No ptch chunk found in '{filename}'")
7 changes: 3 additions & 4 deletions maxpylang/data/OBJ_INFO/max/notein.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
}
},
"args": {
"required": [
"required": [],
"optional": [
{
"name": "port-channel",
"type": [
Expand All @@ -32,9 +33,7 @@
"type": [
"int"
]
}
],
"optional": [
},
{
"name": "port",
"type": [
Expand Down
25 changes: 18 additions & 7 deletions maxpylang/tools/patchfuncs/instantiation.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@

load_template() --> create patch from template

load_file() --> create patch from existing .maxpat file
load_objs_from_dict() --> create objects from existing .maxpat file dict
load_patchcords_from_dict() --> create patchcords from existing .maxpat file dict
load_file() --> create patch from existing .maxpat or .amxd file
load_objs_from_dict() --> create objects from existing file dict
load_patchcords_from_dict() --> create patchcords from existing file dict
clean_patcher_dict() --> get cleaned patcher dict

"""

import os
import json
from pathlib import Path
from maxpylang.maxobject import MaxObject


Expand Down Expand Up @@ -49,7 +50,7 @@ def load_template(self, t, verbose=True):
def load_file(self, f, reorder=True, verbose=True):
"""
Helper function for instantiation.
Loads in an existing .maxpat file.
Loads in an existing .maxpat or .amxd file.

reorder --> re-number objects, starting from 1
verbose --> log to console
Expand All @@ -59,9 +60,19 @@ def load_file(self, f, reorder=True, verbose=True):
if verbose:
print("Patcher: loading patch from existing file:", os.path.split(f)[-1])

#read .maxpat file into dict
with open(f, 'r') as file:
patch_dict = json.loads(file.read())
#read .maxpat or .amxd file into dict
ext = Path(f).suffix
if ext == ".amxd":
from ...amxd import load_amxd
patch_dict = load_amxd(f)
elif ext == ".maxpat":
with open(f, 'r') as file:
patch_dict = json.loads(file.read())
else:
raise ValueError(
f"Unsupported file extension '{ext}'. "
f"Expected '.maxpat' or '.amxd'."
)


#load in objs
Expand Down
Loading
Loading