diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 211e856..94f06da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index afb674a..1cd8c92 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ maxpylang/data/OBJ_ALIASES/ # macOS .DS_Store +tasks/ diff --git a/examples/m4l_audio_effect/main.py b/examples/m4l_audio_effect/main.py new file mode 100644 index 0000000..29b0fa3 --- /dev/null +++ b/examples/m4l_audio_effect/main.py @@ -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") diff --git a/examples/m4l_instrument/main.py b/examples/m4l_instrument/main.py new file mode 100644 index 0000000..7ac7cc9 --- /dev/null +++ b/examples/m4l_instrument/main.py @@ -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") diff --git a/maxpylang/__init__.py b/maxpylang/__init__.py index 99a2854..516b3d6 100644 --- a/maxpylang/__init__.py +++ b/maxpylang/__init__.py @@ -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). @@ -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 diff --git a/maxpylang/amxd.py b/maxpylang/amxd.py new file mode 100644 index 0000000..2053d1f --- /dev/null +++ b/maxpylang/amxd.py @@ -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(" 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}'") diff --git a/maxpylang/data/OBJ_INFO/max/notein.json b/maxpylang/data/OBJ_INFO/max/notein.json index 3594c32..291698b 100644 --- a/maxpylang/data/OBJ_INFO/max/notein.json +++ b/maxpylang/data/OBJ_INFO/max/notein.json @@ -20,7 +20,8 @@ } }, "args": { - "required": [ + "required": [], + "optional": [ { "name": "port-channel", "type": [ @@ -32,9 +33,7 @@ "type": [ "int" ] - } - ], - "optional": [ + }, { "name": "port", "type": [ diff --git a/maxpylang/tools/patchfuncs/instantiation.py b/maxpylang/tools/patchfuncs/instantiation.py index 7ec86d6..3f96dda 100644 --- a/maxpylang/tools/patchfuncs/instantiation.py +++ b/maxpylang/tools/patchfuncs/instantiation.py @@ -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 @@ -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 @@ -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 diff --git a/maxpylang/tools/patchfuncs/saving.py b/maxpylang/tools/patchfuncs/saving.py index 8d9d736..f1afcce 100644 --- a/maxpylang/tools/patchfuncs/saving.py +++ b/maxpylang/tools/patchfuncs/saving.py @@ -3,7 +3,7 @@ Methods related to saving MaxPatches to files. - save() --> save MaxPatch to file + save() --> save MaxPatch to file (.maxpat or .amxd) get_json() --> get json representation of MaxPatch """ @@ -12,40 +12,63 @@ import copy #save patch to file -def save(self, filename="default.maxpat", verbose=True, check=True): +def save(self, filename="default.maxpat", device_type=None, verbose=True, check=True): """ - Save to .maxpat file. + Save to .maxpat or .amxd file. Usage: - filename --> savefile name + filename --> savefile name (.maxpat or .amxd) + device_type --> for Max for Live .amxd files: "instrument", "audio_effect", or "midi_effect" + required when saving as .amxd; when set, forces .amxd extension verbose --> print log message to console check --> run check_patch before saving """ - #check proper extension - if ".maxpat" not in Path(filename).suffixes: - filename += ".maxpat" + ext = Path(filename).suffix - #get json rep - json_dict = self.get_json() + if ext == ".amxd" or device_type is not None: + # Max for Live save path + from ...amxd import save_amxd + + if device_type is None: + raise ValueError( + "device_type is required for .amxd files. " + "Choose from: 'instrument', 'audio_effect', 'midi_effect'" + ) + + if ".amxd" not in Path(filename).suffixes: + # replace existing extension (e.g. .maxpat) with .amxd, or append .amxd if none + filename = str(Path(filename).with_suffix(".amxd")) + + json_dict = self.get_json() + save_amxd(json_dict, filename, device_type=device_type) + + else: + # Standard .maxpat save path + if ".maxpat" not in Path(filename).suffixes: + filename += ".maxpat" + + json_dict = self.get_json() + + with open(filename, 'w') as f: + json.dump(json_dict, f, indent=2) - #write json to file - with open(filename, 'w') as f: - json.dump(json_dict, f, indent=2) - #save filepath for later saving self._filename = filename - + #log unknown objs and unlinked js objs #(abstractions only get marked as abstractions if the file is found) - #also log linked abstractions and linked js files + #also log linked abstractions and linked js files if check: self.check('unknown', 'js', 'abstractions') - + #log messages if verbose: - print("maxpatch saved to", filename) + if device_type is not None: + print(f"maxpatch saved to {filename} (M4L {device_type})") + else: + print("maxpatch saved to", filename) return diff --git a/tests/test_amxd.py b/tests/test_amxd.py new file mode 100644 index 0000000..9ce8f6d --- /dev/null +++ b/tests/test_amxd.py @@ -0,0 +1,531 @@ +""" +Exhaustive tests for Max for Live (.amxd) support in MaxPyLang. + +Tests: +1. Save all 3 device types +2. Binary format validation +3. Round-trip for each device type +4. Extension handling edge cases +5. Standalone functions (save_amxd / load_amxd) +6. M4L I/O objects +7. Complex M4L instrument patch +8. Example files execution +""" + +import os +import sys +import json +import struct +import tempfile +import warnings + +import pytest + +# Ensure the package is importable from the repo root +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +import maxpylang as mp +from maxpylang.amxd import save_amxd, load_amxd, DEVICE_TYPES + + +@pytest.fixture +def tmpdir_amxd(tmp_path): + """Return a temporary directory path for test outputs.""" + return tmp_path + + +def _make_simple_patch(): + """Create a simple patch with one oscillator and one dac.""" + patch = mp.MaxPatch(verbose=False) + osc = patch.place("cycle~ 440", verbose=False)[0] + dac = patch.place("ezdac~", verbose=False)[0] + patch.connect([osc.outs[0], dac.ins[0]], verbose=False) + return patch + + +# ===================================================================== +# TEST 1: Save all 3 device types +# ===================================================================== + +class TestSaveAllDeviceTypes: + @pytest.mark.parametrize("device_type", ["instrument", "audio_effect", "midi_effect"]) + def test_save_device_type(self, tmpdir_amxd, device_type): + """Each device type should produce a valid .amxd binary file.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / f"test_{device_type}.amxd") + patch.save(filename, device_type=device_type, verbose=False, check=False) + + assert os.path.exists(filename), f"File not created: {filename}" + fsize = os.path.getsize(filename) + assert fsize > 0, f"File is empty: {filename}" + # Minimum: ampf(12) + meta(12) + ptch(8 + json) > 32 bytes + assert fsize > 32, f"File too small ({fsize} bytes), likely malformed" + + +# ===================================================================== +# TEST 2: Binary format validation +# ===================================================================== + +class TestBinaryFormatValidation: + @pytest.mark.parametrize("device_type,expected_code", [ + ("instrument", b"iiii"), + ("audio_effect", b"aaaa"), + ("midi_effect", b"mmmm"), + ]) + def test_ampf_chunk_device_code(self, tmpdir_amxd, device_type, expected_code): + """ampf chunk must contain the correct 4-byte device code.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / f"test_{device_type}.amxd") + patch.save(filename, device_type=device_type, verbose=False, check=False) + + with open(filename, "rb") as f: + data = f.read() + + # ampf chunk starts at byte 0 + assert data[:4] == b"ampf", f"First chunk is not 'ampf': {data[:4]}" + ampf_size = struct.unpack(" load -> verify objects match -> re-save as .maxpat -> verify JSON identical.""" + # Create and save + patch1 = _make_simple_patch() + amxd_file = str(tmpdir_amxd / f"rt_{device_type}.amxd") + patch1.save(amxd_file, device_type=device_type, verbose=False, check=False) + + # Load back from .amxd + patch2 = mp.MaxPatch(load_file=amxd_file, verbose=False) + + # Verify same number of objects + assert patch2.num_objs == patch1.num_objs, ( + f"Object count mismatch: original={patch1.num_objs}, loaded={patch2.num_objs}" + ) + + # Verify object names match + orig_names = sorted(obj.name for obj in patch1.objs.values()) + loaded_names = sorted(obj.name for obj in patch2.objs.values()) + assert orig_names == loaded_names, ( + f"Object names mismatch: {orig_names} vs {loaded_names}" + ) + + # Re-save as .maxpat + maxpat_file = str(tmpdir_amxd / f"rt_{device_type}.maxpat") + patch2.save(maxpat_file, verbose=False, check=False) + + # Also re-save original as .maxpat + maxpat_orig = str(tmpdir_amxd / f"rt_{device_type}_orig.maxpat") + patch1.save(maxpat_orig, verbose=False, check=False) + + # Load both and compare JSON structure + with open(maxpat_file, "r") as f: + json2 = json.load(f) + with open(maxpat_orig, "r") as f: + json1 = json.load(f) + + # Compare box counts + assert len(json1["patcher"]["boxes"]) == len(json2["patcher"]["boxes"]), ( + f"Box count mismatch in JSON: {len(json1['patcher']['boxes'])} vs {len(json2['patcher']['boxes'])}" + ) + # Compare line counts + assert len(json1["patcher"]["lines"]) == len(json2["patcher"]["lines"]), ( + f"Line count mismatch in JSON: {len(json1['patcher']['lines'])} vs {len(json2['patcher']['lines'])}" + ) + + +# ===================================================================== +# TEST 4: Extension handling edge cases +# ===================================================================== + +class TestExtensionHandling: + def test_amxd_extension_with_device_type(self, tmpdir_amxd): + """save('foo.amxd', device_type='instrument') -> saves as .amxd.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + patch.save(filename, device_type="instrument", verbose=False, check=False) + assert os.path.exists(filename) + + def test_no_extension_with_device_type(self, tmpdir_amxd): + """save('foo', device_type='instrument') -> forces .amxd extension.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo") + patch.save(filename, device_type="instrument", verbose=False, check=False) + expected = str(tmpdir_amxd / "foo.amxd") + assert os.path.exists(expected), ( + f"Expected {expected} to be created. " + f"Files: {os.listdir(tmpdir_amxd)}" + ) + + def test_maxpat_extension_with_device_type_changes_to_amxd(self, tmpdir_amxd): + """save('foo.maxpat', device_type='instrument') -> changes to .amxd.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.maxpat") + patch.save(filename, device_type="instrument", verbose=False, check=False) + expected = str(tmpdir_amxd / "foo.amxd") + assert os.path.exists(expected), ( + f"Expected {expected} to be created. " + f"Files: {os.listdir(tmpdir_amxd)}" + ) + + def test_amxd_extension_without_device_type_raises(self, tmpdir_amxd): + """save('foo.amxd') without device_type -> should raise ValueError.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + with pytest.raises(ValueError, match="device_type is required"): + patch.save(filename, verbose=False, check=False) + + def test_invalid_device_type_raises(self, tmpdir_amxd): + """save('foo.amxd', device_type='invalid_type') -> should raise error.""" + patch = _make_simple_patch() + filename = str(tmpdir_amxd / "foo.amxd") + with pytest.raises(ValueError): + patch.save(filename, device_type="invalid_type", verbose=False, check=False) + + +# ===================================================================== +# TEST 5: Standalone functions (save_amxd / load_amxd) +# ===================================================================== + +class TestStandaloneFunctions: + def test_save_amxd_creates_file(self, tmpdir_amxd): + """save_amxd() should write a binary file.""" + patcher_json = { + "patcher": { + "boxes": [], + "lines": [], + } + } + filename = str(tmpdir_amxd / "standalone.amxd") + save_amxd(patcher_json, filename, device_type="instrument") + assert os.path.exists(filename) + assert os.path.getsize(filename) > 0 + + def test_load_amxd_returns_json(self, tmpdir_amxd): + """load_amxd() should return the same JSON dict that was saved.""" + patcher_json = { + "patcher": { + "boxes": [{"box": {"maxclass": "newobj", "text": "cycle~ 440"}}], + "lines": [], + } + } + filename = str(tmpdir_amxd / "standalone_rt.amxd") + save_amxd(patcher_json, filename, device_type="audio_effect") + + loaded = load_amxd(filename) + assert loaded == patcher_json, ( + f"JSON mismatch:\n saved: {patcher_json}\n loaded: {loaded}" + ) + + def test_save_amxd_invalid_device_type(self, tmpdir_amxd): + """save_amxd() with bad device_type should raise ValueError.""" + patcher_json = {"patcher": {"boxes": [], "lines": []}} + filename = str(tmpdir_amxd / "bad_type.amxd") + with pytest.raises(ValueError, match="Unknown device_type"): + save_amxd(patcher_json, filename, device_type="bogus") + + def test_load_amxd_no_ptch_raises(self, tmpdir_amxd): + """load_amxd() on a file with no ptch chunk should raise ValueError.""" + filename = str(tmpdir_amxd / "no_ptch.amxd") + with open(filename, "wb") as f: + f.write(b"ampf") + f.write(struct.pack("= 2, f"plugin~ should have >= 2 outlets, got {len(obj.outs)}" + + def test_plugout_tilde(self): + """plugout~ should create with proper inlet/outlet counts.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("plugout~", verbose=False)[0] + assert obj.name == "plugout~" + assert not obj.notknown(), "plugout~ should be a known object" + assert len(obj.ins) >= 2, f"plugout~ should have >= 2 inlets, got {len(obj.ins)}" + + def test_notein(self): + """notein should create with proper inlet/outlet counts.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("notein", verbose=False)[0] + assert obj.name == "notein" + assert not obj.notknown(), "notein should be a known object" + assert len(obj.outs) >= 3, f"notein should have >= 3 outlets (pitch, vel, channel), got {len(obj.outs)}" + + def test_midiin(self): + """midiin should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiin", verbose=False)[0] + assert obj.name == "midiin" + assert not obj.notknown(), "midiin should be a known object" + assert len(obj.outs) >= 1, f"midiin should have >= 1 outlet, got {len(obj.outs)}" + + def test_midiout(self): + """midiout should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiout", verbose=False)[0] + assert obj.name == "midiout" + assert not obj.notknown(), "midiout should be a known object" + assert len(obj.ins) >= 1, f"midiout should have >= 1 inlet, got {len(obj.ins)}" + + def test_midiparse(self): + """midiparse should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiparse", verbose=False)[0] + assert obj.name == "midiparse" + assert not obj.notknown(), "midiparse should be a known object" + assert len(obj.outs) >= 1, f"midiparse should have >= 1 outlet, got {len(obj.outs)}" + + def test_midiformat(self): + """midiformat should create correctly.""" + patch = mp.MaxPatch(verbose=False) + obj = patch.place("midiformat", verbose=False)[0] + assert obj.name == "midiformat" + assert not obj.notknown(), "midiformat should be a known object" + assert len(obj.ins) >= 1, f"midiformat should have >= 1 inlet, got {len(obj.ins)}" + + +# ===================================================================== +# TEST 7: Complex M4L instrument patch +# ===================================================================== + +class TestComplexM4LPatch: + def test_full_instrument_round_trip(self, tmpdir_amxd): + """Build notein->mtof->cycle~->*~->clip~->plugout~, save as .amxd, load back, verify.""" + patch = mp.MaxPatch(verbose=False) + + # Build the chain + patch.set_position(30, 60) + notein = patch.place("notein", verbose=False)[0] + + patch.set_position(30, 100) + mtof = patch.place("mtof", verbose=False)[0] + + patch.set_position(30, 140) + osc = patch.place("cycle~", verbose=False)[0] + + patch.set_position(30, 180) + mult = patch.place("*~ 0.5", verbose=False)[0] + + patch.set_position(30, 220) + clip = patch.place("clip~ -1. 1.", verbose=False)[0] + + patch.set_position(30, 260) + plugout = patch.place("plugout~", verbose=False)[0] + + # Connect the chain + patch.connect( + [notein.outs[0], mtof.ins[0]], + [mtof.outs[0], osc.ins[0]], + [osc.outs[0], mult.ins[0]], + [mult.outs[0], clip.ins[0]], + [clip.outs[0], plugout.ins[0]], + [clip.outs[0], plugout.ins[1]], + verbose=False, + ) + + # Verify objects were created + expected_names = {"notein", "mtof", "cycle~", "*~", "clip~", "plugout~"} + actual_names = {obj.name for obj in patch.objs.values()} + assert expected_names == actual_names, ( + f"Object names mismatch: expected {expected_names}, got {actual_names}" + ) + + # Save as .amxd + amxd_file = str(tmpdir_amxd / "complex_instrument.amxd") + patch.save(amxd_file, device_type="instrument", verbose=False, check=False) + assert os.path.exists(amxd_file) + + # Load back + patch2 = mp.MaxPatch(load_file=amxd_file, verbose=False) + + # Verify same object count + assert patch2.num_objs == patch.num_objs, ( + f"Object count mismatch after load: {patch.num_objs} vs {patch2.num_objs}" + ) + + # Verify object names survived + loaded_names = {obj.name for obj in patch2.objs.values()} + assert expected_names == loaded_names, ( + f"Object names after load: expected {expected_names}, got {loaded_names}" + ) + + # Verify connections survived - count total connections + orig_connections = 0 + for obj in patch.objs.values(): + for outlet in obj.outs: + orig_connections += len(outlet.destinations) + + loaded_connections = 0 + for obj in patch2.objs.values(): + for outlet in obj.outs: + loaded_connections += len(outlet.destinations) + + assert orig_connections == loaded_connections, ( + f"Connection count mismatch: original={orig_connections}, loaded={loaded_connections}" + ) + + # Re-save as .maxpat and verify JSON is valid + maxpat_file = str(tmpdir_amxd / "complex_instrument.maxpat") + patch2.save(maxpat_file, verbose=False, check=False) + with open(maxpat_file, "r") as f: + json_data = json.load(f) + assert len(json_data["patcher"]["boxes"]) == 6 + assert len(json_data["patcher"]["lines"]) == 6 + + +# ===================================================================== +# TEST 8: Run example files +# ===================================================================== + +class TestExampleFiles: + def test_m4l_instrument_example(self, tmpdir_amxd): + """examples/m4l_instrument/main.py should run without errors.""" + example_path = os.path.join( + os.path.dirname(__file__), "..", "examples", "m4l_instrument", "main.py" + ) + assert os.path.exists(example_path), f"Example file not found: {example_path}" + + # Change to tmpdir so output files go there + old_cwd = os.getcwd() + os.chdir(str(tmpdir_amxd)) + try: + exec(open(example_path).read(), {"__name__": "__main__"}) + finally: + os.chdir(old_cwd) + + # Verify the output file was created + amxd_file = str(tmpdir_amxd / "m4l_instrument.amxd") + assert os.path.exists(amxd_file), ( + f"Expected output file not found. Files: {os.listdir(tmpdir_amxd)}" + ) + assert os.path.getsize(amxd_file) > 0 + + def test_m4l_audio_effect_example(self, tmpdir_amxd): + """examples/m4l_audio_effect/main.py should run without errors.""" + example_path = os.path.join( + os.path.dirname(__file__), "..", "examples", "m4l_audio_effect", "main.py" + ) + assert os.path.exists(example_path), f"Example file not found: {example_path}" + + old_cwd = os.getcwd() + os.chdir(str(tmpdir_amxd)) + try: + exec(open(example_path).read(), {"__name__": "__main__"}) + finally: + os.chdir(old_cwd) + + amxd_file = str(tmpdir_amxd / "m4l_audio_effect.amxd") + assert os.path.exists(amxd_file), ( + f"Expected output file not found. Files: {os.listdir(tmpdir_amxd)}" + ) + assert os.path.getsize(amxd_file) > 0