diff --git a/maxdiff/README.md b/maxdiff/README.md index daf3147..351b9d9 100644 --- a/maxdiff/README.md +++ b/maxdiff/README.md @@ -18,12 +18,34 @@ Removing the comments from the new MIDI Effect: For `.amxd` files: * The scripts will also tell you what device type it is. -* If a device is frozen, you will see an overview of the content frozen into the deivce. NOTE: We recommend never to commit frozen devices to a git repository, instead to include the dependencies as separate files. +* If a device is frozen, you will see an overview and statistics of the content frozen into the device. NOTE: We recommend never to commit frozen devices to a git repository, instead to include the dependencies as separate files. ### Why? Readable diffs are very useful for patch code review, or for a sanity check before committing (did I really change nothing else expect removing all my debug messages and prints?). +### Using the frozen device statistics + +For frozen devices, `maxdiff` reports statistics. This feature can compared to [`git diff --stat`](https://git-scm.com/docs/git-diff#Documentation/git-diff.txt---statltwidthgtltname-widthgtltcountgt) to indicate the amount of change in a commit. + +Currently, it reports the number of object instances and connections of a device in two different ways: + +* Total - Counting every abstraction instance - Indicates loading time + * This recursively counts the contents of all subpatchers and abstraction instances +* Unique - Counting abstractions once - Indicates maintainability + * This counts the contents once of every dependency frozen into the device. + +Apart from that it reports the amount of times dependencies are used. + +We typically don't commit frozen devices to a repo, so the typical way to use this feature is: + +1. Commit the device and its dependencies in **un**frozen state. +2. Temporarily freeze the device. +3. Run `python3 ./maxdiff/amxd_textconv.py ` to get the statistics or view the file in your git client. +4. Discard the freezing. + +The reason this only works with frozen devices is that frozen devices unambiguously capture all dependencies. + ### What does not work Typical things you can do with text-based code that will not work with Max patches or devices: diff --git a/maxdiff/freezing_utils.py b/maxdiff/freezing_utils.py new file mode 100644 index 0000000..cafe935 --- /dev/null +++ b/maxdiff/freezing_utils.py @@ -0,0 +1,108 @@ +import json +import datetime +from typing import Optional + +footer_entry = dict[str, str | int | datetime.datetime | None] +device_entry_with_data = dict[str, str | bytes] + + +def parse_footer(data: bytes) -> list[footer_entry]: + """Parses the byte data of a frozen device footer and returns a list of frozen dependencies.""" + footer_entries: list[footer_entry] = [] + while data[:4].decode("ascii") == "dire": + size = int.from_bytes(data[4:8], byteorder="big") + fields = get_fields(data[8 : 8 + size]) + footer_entries.append(fields) + data = data[size:] + return footer_entries + + +def get_fields(data: bytes) -> footer_entry: + """Parses the data for a frozen dependency and returns a dict of its fields and their contents.""" + fields = {} + while len(data) >= 12: + field_type = data[:4].decode("ascii") + field_size = int.from_bytes(data[4:8], byteorder="big") + field_data = data[8:field_size] + fields[field_type] = parse_field_data(field_type, field_data) + + data = data[field_size:] + return fields + + +def parse_field_data( + field_type: str, data: bytes +) -> Optional[str | int | datetime.datetime]: + """Parses the data of a field. Depending on the field type, returns its data as the correct type""" + match field_type: + case "type": + return remove_trailing_zeros(data).decode("ascii") + case "fnam": + return remove_trailing_zeros(data).decode("ascii") + case "sz32": + return int.from_bytes(data, byteorder="big") + case "of32": + return int.from_bytes(data, byteorder="big") + case "vers": + return int.from_bytes(data, byteorder="big") + case "flag": + return int.from_bytes(data, byteorder="big") + case "mdat": + return get_hfs_date(data) + return None + + +def remove_trailing_zeros(data: bytes) -> bytes: + """Remove trailing zeros from a zero-padded byte representation of a string""" + return data.rstrip(b"\x00") + + +def get_hfs_date(data: bytes) -> datetime.datetime: + """Converts a byte sequence that represents a HFS+ date to a Python datetime object""" + seconds_offset_from_unix = 2082844800 # Mac HFS+ is time since 1 Jan 1904 while Unix time is since 1 Jan 1970 + seconds_in_hfs_plus = int.from_bytes(data, byteorder="big") + return datetime.datetime.fromtimestamp( + seconds_in_hfs_plus - seconds_offset_from_unix, datetime.UTC + ) + + +def get_patcher_dict(entry: device_entry_with_data): + """Returns the dict that represents the given patcher data. + Prints errors if parsing fails""" + + if not "data" in entry: + return {} + + patch_data = entry["data"] + if not isinstance(patch_data, bytes): + return {} + + if not "file_name" in entry: + return {} + + name = entry["file_name"] + if not isinstance(name, str): + return {} + + device_data_text = "" + try: + if patch_data[len(patch_data) - 1] == 0: + device_data_text = patch_data[: len(patch_data) - 1].decode("utf-8") + else: + device_data_text = patch_data.decode("utf-8") + except Exception as e: + print(f"Error getting patch data as text for entry {name}: {e}") + return {} + + try: + patcher_dict = json.loads(device_data_text) + except ValueError as e: + print(f"Error parsing device patch data as json for entry {name}: {e}") + return {} + + try: + patcher = patcher_dict["patcher"] + return patcher + except: + print(f"Content of entry {name} does not seem to be a patcher") + return {} diff --git a/maxdiff/frozen_device_printer.py b/maxdiff/frozen_device_printer.py index 41e5e9b..f15ce1f 100644 --- a/maxdiff/frozen_device_printer.py +++ b/maxdiff/frozen_device_printer.py @@ -1,13 +1,13 @@ -import datetime -from typing import Optional +from freezing_utils import * +from get_frozen_stats import get_frozen_stats, get_used_files def print_frozen_device(data: bytes) -> str: """Parses a frozen device represented as bytes and returns a string representation of it.""" - dependency_data_size = int.from_bytes( + footer_data_size = int.from_bytes( data[8:16], byteorder="big" ) # data size int is 64 bit - footer_data = data[dependency_data_size:] + footer_data = data[footer_data_size:] if footer_data[:4].decode("ascii") != "dlst": return "Error parsing footer data; footer data does not start with 'dlst'" @@ -16,80 +16,77 @@ def print_frozen_device(data: bytes) -> str: return "Error parsing footer data; recorded size does not match actual size" frozen_string = "Device is frozen\n----- Contents -----\n" - dependencies = parse_footer(footer_data[8:]) - for dependency in dependencies: - frozen_string += dependency + "\n" + + footer_entries = parse_footer(footer_data[8:]) + device_entries = get_device_entries(data, footer_entries) + used_files = get_used_files(device_entries) + + i = 0 + for entry in device_entries: + description = entry["description"] + if isinstance(description, str): + file_name = str(entry["file_name"]) + if i == 0: + frozen_string += f"{description} <= Device \n" + else: + if file_name in used_files: + frozen_string += f"{description}, {used_files[file_name]} instance{'s' if used_files[file_name] > 1 else ''}\n" + else: + frozen_string += f"{description}, NOT FOUND IN PATCH\n" + i += 1 + + [object_count_total, line_count_total, object_count_unique, line_count_unique] = ( + get_frozen_stats(device_entries) + ) + + frozen_string += "\n" + frozen_string += ( + "Total - Counting every abstraction instance - Indicates loading time\n" + ) + frozen_string += f" Object instances: {object_count_total}\n" + frozen_string += f" Connections: {line_count_total}\n" + frozen_string += "Unique - Counting abstractions once - Indicates maintainability\n" + frozen_string += f" Object instances: {object_count_unique}\n" + frozen_string += f" Connections: {line_count_unique}\n" + return frozen_string -def parse_footer(data: bytes) -> list[str]: - """Parses the footer byte data of a frozen device and returns an array of - string representations of the frozen dependencies.""" - dependencies: list[str] = [] - while data[:4].decode("ascii") == "dire": - size = int.from_bytes(data[4:8], byteorder="big") - fields = get_fields(data[8 : 8 + size]) - if "fnam" in fields and "sz32" in fields and "mdat" in fields: +def get_device_entries( + data: bytes, footer_entries: list[footer_entry] +) -> list[device_entry_with_data]: + """Converts footer entries to dict containing footer entry information and data.""" + entries: list[device_entry_with_data] = [] + + for fields in footer_entries: + if ( + "type" in fields + and "fnam" in fields + and "of32" in fields + and "sz32" in fields + and "mdat" in fields + ): + type_field = fields["type"] name_field = fields["fnam"] + offset_field = fields["of32"] size_field = fields["sz32"] date_field = fields["mdat"] if not ( - isinstance(name_field, str) + isinstance(type_field, str) + and isinstance(name_field, str) + and isinstance(offset_field, int) and isinstance(size_field, int) and isinstance(date_field, datetime.datetime) ): raise Exception("Incorrect type for parsed footer fields") - dependencies.append( - f'{fields["fnam"]}: {fields["sz32"]} bytes, modified at {date_field.strftime("%Y/%m/%d %T")} UTC' - ) - data = data[size:] - return dependencies - -def get_fields(data: bytes) -> dict[str, str | int | datetime.datetime | None]: - """Parses the data for a frozen dependency and returns a dict of its fields and their contents.""" - fields = {} - while len(data) >= 12: - field_type = data[:4].decode("ascii") - field_size = int.from_bytes(data[4:8], byteorder="big") - field_data = data[8:field_size] - fields[field_type] = parse_field_data(field_type, field_data) - - data = data[field_size:] - return fields - - -def parse_field_data( - field_type: str, data: bytes -) -> Optional[str | int | datetime.datetime]: - """Parses the data of a field. Depending on the field type, returns its data as the correct type""" - match field_type: - case "type": - return remove_trailing_zeros(data).decode("ascii") - case "fnam": - return remove_trailing_zeros(data).decode("ascii") - case "sz32": - return int.from_bytes(data, byteorder="big") - case "of32": - return int.from_bytes(data, byteorder="big") - case "vers": - return int.from_bytes(data, byteorder="big") - case "flag": - return int.from_bytes(data, byteorder="big") - case "mdat": - return get_hfs_date(data) - return None - - -def remove_trailing_zeros(data: bytes) -> bytes: - """Remove trailing zeros from a zero-padded byte representation of a string""" - return data.rstrip(b"\x00") - - -def get_hfs_date(data: bytes) -> datetime.datetime: - """Converts a byte sequence that represents a HFS+ date to a Python datetime object""" - seconds_offset_from_unix = 2082844800 # Mac HFS+ is time since 1 Jan 1904 while Unix time is since 1 Jan 1970 - seconds_in_hfs_plus = int.from_bytes(data, byteorder="big") - return datetime.datetime.fromtimestamp( - seconds_in_hfs_plus - seconds_offset_from_unix, datetime.UTC - ) + description = f'{name_field}: {size_field} bytes, modified at {date_field.strftime("%Y/%m/%d %T")} UTC' + entry_data = data[offset_field : offset_field + size_field] + entry: device_entry_with_data = { + "file_name": name_field, + "description": description, + "type": type_field, + "data": entry_data, + } + entries.append(entry) + return entries diff --git a/maxdiff/get_frozen_stats.py b/maxdiff/get_frozen_stats.py new file mode 100644 index 0000000..640119a --- /dev/null +++ b/maxdiff/get_frozen_stats.py @@ -0,0 +1,202 @@ +from freezing_utils import device_entry_with_data, get_patcher_dict +from process_patcher import Processor, process_patch + + +def get_frozen_stats(entries: list[device_entry_with_data]) -> tuple[int, int, int, int]: + """Returns statistics for the passed list of entries found in a frozen device""" + + device = entries[0] # the first entry is always the device file + + abstraction_entries = [ + item for item in entries if str(item["file_name"]).endswith(".maxpat") + ] + + device_patch = get_patcher_dict(device) + if device_patch == {}: + return 0, 0, 0, 0 + + # get total counts: parse every instance of every abstraction + count_processor = CountProcessor() + process_patch( + device_patch, abstraction_entries, count_processor # do recurse into abstractions + ) + object_count_total, line_count_total = count_processor.get_results() + + # get unique counts: parse every entry included in the frozen device once + object_count_unique = 0 + line_count_unique = 0 + for entry in entries: + filename = str(entry["file_name"]) + if not (filename.endswith(".amxd") or filename.endswith(".maxpat")): + continue + + entry_patch = get_patcher_dict(entry) + if entry_patch == {}: + continue + + count_processor = CountProcessor() + process_patch(entry_patch, [], count_processor) # don't recurse into abstractions + o, l = count_processor.get_results() + + object_count_unique += o + line_count_unique += l + + return object_count_total, line_count_total, object_count_unique, line_count_unique + + +class CountProcessor(Processor): + def __init__(self): + self.object_count = 0 + self.line_count = 0 + + def process_elements(self, patcher, voice_count: int, abstraction_name=""): + """Counts objects and lines in the given patcher.""" + self.object_count += len(patcher.get("boxes", [])) * voice_count + self.line_count += len(patcher.get("lines", [])) * voice_count + + def get_results(self): + """Returns the current counts.""" + return self.object_count, self.line_count + + +def get_used_files(entries: list[device_entry_with_data]) -> dict[str, int]: + device = entries[0] # the first entry is always the device file + + abstraction_entries = [ + item for item in entries if str(item["file_name"]).endswith(".maxpat") + ] + + device_patch = get_patcher_dict(device) + if device_patch == {}: + return {} + + abstractions_processor = FileNamesProcessor() + process_patch(device_patch, abstraction_entries, abstractions_processor) + return abstractions_processor.get_results() + + +class FileNamesProcessor(Processor): + def __init__(self): + self.found_filenames = {} + + def process_elements(self, patcher, voice_count: int, abstraction_name=""): + """If this patcher is an abstraction, i.e. when an abstraction_name is passed in, increment the entry in the dict. + For other patchers, scan them for objects that use files. + """ + + filenames = get_dependency_filenames(patcher) + if abstraction_name != "": + filenames.append(abstraction_name) + + for filename in filenames: + if filename in self.found_filenames: + self.found_filenames[filename] += voice_count + else: + self.found_filenames[filename] = voice_count + + def get_results(self): + """Returns a dict of used abstractions mapped to how oftern they are used.""" + return self.found_filenames + + +def get_dependency_filenames(patcher): + """Check all boxes in this patcher and report any dependencies they might be referring to""" + filenames = [] + for box_entry in patcher["boxes"]: + box = box_entry["box"] + + if box.get("pic"): # fic + filenames.append(box.get("pic")) + continue + + if box.get("pictures"): # live.text, live.tab and live.menu + for name in box.get("pictures"): + filenames.append(name) + continue + + if box.get("maxclass") == "pictctrl": + if box.get("name"): + filenames.append(box.get("name")) + continue + + if box.get("bkgndpict"): # pictslider and matrixctrl + filenames.append(box.get("bkgndpict")) + continue + + if box.get("knobpict"): # pictslider + filenames.append(box.get("knobpict")) + continue + + if box.get("cellpict"): # pictslider + filenames.append(box.get("cellpict")) + continue + + if box.get("filename"): # jsui + filenames.append(box.get("filename")) + continue + + if box.get("filename"): # jsui + filenames.append(box.get("filename")) + continue + + if box.get("text") and box.get("text").startswith("sfplay~"): # sfplay~ + audiofile = get_max_attribute(box, "audiofile") + if audiofile: + filenames.append(audiofile) + continue + + if box.get("text") and box.get("text").startswith("gen~"): # gen~ + filename = get_max_attribute(box, "gen") + if filename: + filenames.append(add_if_needed(filename, ".gendsp")) + continue + + if box.get("text") and ( + box.get("text").startswith("jit.gen") or box.get("text").startswith("jit.pix") + ): # jit.gen and jit.pix + filename = get_max_attribute(box, "gen") + if filename: + filenames.append(add_if_needed(filename, ".genjit")) + continue + + if box.get("text") and box.get("text").startswith("node.script"): # node + filename = box.get("text").split("node.script ")[1].split()[0] + filenames.append(add_if_needed(filename, ".js")) + + if box.get("text") and box.get("text").startswith("pattrstorage"): # pattrstorage + filename = box.get("text").split("pattrstorage ")[1].split()[0] + filenames.append(add_if_needed(filename, ".json")) + continue + + if box.get("text") and box.get("text").startswith("table"): # pattrstorage + filename = box.get("text").split("table ")[1].split()[0] + filenames.append(filename) + continue + + if box.get("maxclass") == "playlist~" and box.get("data"): # playlist~ + for clip in box["data"].get("clips"): + if clip.get("filename"): + filenames.append(clip.get("filename")) + continue + + if box.get("saved_object_attributes") and box["saved_object_attributes"].get( + "filename" + ): # js + filename = box["saved_object_attributes"].get("filename") + filenames.append(add_if_needed(filename, ".js")) + continue + + return filenames + + +def get_max_attribute(box, attribute_name: str): + """parse the box text for a specific attribute value""" + attrstring = "@" + attribute_name + " " + if box.get("text") and attrstring in box.get("text"): + return box.get("text").split(attrstring)[1].split()[0] + return None + + +def add_if_needed(s: str, ext: str): + """add an extension to a string if it doesn't exist yet""" + return s + ext if not s.endswith(ext) else s diff --git a/maxdiff/process_patcher.py b/maxdiff/process_patcher.py new file mode 100644 index 0000000..305707d --- /dev/null +++ b/maxdiff/process_patcher.py @@ -0,0 +1,110 @@ +from freezing_utils import get_patcher_dict + + +class Processor: + def process_elements(self, patcher, voice_count: int, abstraction_name=""): + """Processes patchers.""" + + def get_results(self): + """Returns the current results.""" + return None + + +def process_patch(patcher, abstraction_entries: list[dict], processor: Processor): + process_patch_recursive(patcher, abstraction_entries, processor, 1, "") + + +def process_patch_recursive( + patcher, + abstraction_entries: list[dict], + processor: Processor, + voice_count: int, + abstraction_file_name: str, +): + """Recursively progress through subpatchers, invoking the processor for every patcher + inluding every instance of the patch's dependencies that can be found among the + abstraction files that were passed in. + + Arguments + patcher: patcher to process + abstraction_entries: list of abstraction entries in frozen device + processor: instance of a Processor that is invoked for this patch + voice_count: the amount of voices when this patcher occurs in a poly~ in its parent patch + abstraction_file_name: the file name of the abstraction this patch is in, if it is in an abstraction + """ + processor.process_elements(patcher, voice_count, abstraction_file_name) + + for box_entry in patcher["boxes"]: + box = box_entry["box"] + + if "patcher" in box and ( + box.get("maxclass") != "bpatcher" or box.get("embed") == 1 + ): + patch = box["patcher"] + # get subpatcher or embedded bpatcher count + process_patch_recursive(patch, abstraction_entries, processor, 1, "") + + # if no abstractions were passed in, we assume we don't want to recurse into abstarctions + if len(abstraction_entries) == 0: + continue + + # check for known abstraction + file_name = get_abstraction_name(box, abstraction_entries) + if file_name is None: + continue + + abstraction = [ + item for item in abstraction_entries if item["file_name"] == file_name + ][0] + patch = get_patcher_dict(abstraction) + if patch == {}: + continue # something went wrong when parsing the abstraction + + voice_count = 1 + if "text" in box and box["text"].startswith("poly~"): + # get poly abstraction count + tokens = box["text"].split(" ") + voice_count = int(tokens[2]) if len(tokens) > 2 else 1 + + process_patch_recursive( + patch, + abstraction_entries, + processor, + voice_count, + file_name, + ) + + +def get_abstraction_name(box, abstraction_entries: list[dict]): + """ + Checks if this box is an abstraction and if so, return the name of the abstraction file. + - returns None if this is not an abstraction + - throws error if an abstraction name was expected but it was not found in the list of known names + """ + # cache names of known abstractions. TODO: find a way to do this more efficiently. + abstraction_filenames = [str(item["file_name"]) for item in abstraction_entries] + + if "text" in box: + if box["text"].startswith("poly~"): + name = box["text"].split(" ")[1] + ".maxpat" + if name in abstraction_filenames: + return name + else: + raise ValueError( + "poly~ pointing to file that is not known as a dependency: " + name + ) + else: + name = box["text"].split(" ")[0] + ".maxpat" + if name in abstraction_filenames: + return name + + if box.get("maxclass") == "bpatcher" and box.get("embed") != 1: + if box.get("name") in abstraction_filenames: + return box["name"] + else: + raise ValueError( + "Non-embedded bpatcher pointing to file that is not known as a dependency: " + + box["name"] + ) + + return None diff --git a/maxdiff/tests/test_baselines/FrozenTest.amxd.txt b/maxdiff/tests/test_baselines/FrozenTest.amxd.txt index 554b9eb..3b54a00 100644 --- a/maxdiff/tests/test_baselines/FrozenTest.amxd.txt +++ b/maxdiff/tests/test_baselines/FrozenTest.amxd.txt @@ -2,11 +2,26 @@ MIDI Effect Device ------------------- Device is frozen ----- Contents ----- -Test.amxd: 23958 bytes, modified at 2024/10/15 14:21:04 UTC -ParamAbstraction.maxpat: 1570 bytes, modified at 2024/05/24 13:59:36 UTC -MyAbstraction.maxpat: 1625 bytes, modified at 2024/10/15 14:20:41 UTC -AbstractionWithParameter.maxpat: 1569 bytes, modified at 2024/05/24 13:59:36 UTC -hz-icon.svg: 484 bytes, modified at 2024/05/24 13:59:36 UTC -beat-icon.svg: 533 bytes, modified at 2024/05/24 13:59:36 UTC -fpic.png: 7094 bytes, modified at 2024/05/24 13:59:36 UTC -collContent.txt: 8 bytes, modified at 2024/05/24 13:59:36 UTC +Test.amxd: 39511 bytes, modified at 2024/10/21 12:50:20 UTC <= Device +ParamAbstraction.maxpat: 1570 bytes, modified at 2024/05/24 13:59:36 UTC, 2 instances +MyAbstraction.maxpat: 2015 bytes, modified at 2024/10/21 12:18:20 UTC, 6 instances +AbstractionWithParameter.maxpat: 1569 bytes, modified at 2024/05/24 13:59:36 UTC, 2 instances +hz-icon.svg: 484 bytes, modified at 2024/05/24 13:59:36 UTC, 3 instances +beat-icon.svg: 533 bytes, modified at 2024/05/24 13:59:36 UTC, 3 instances +fpic.png: 7094 bytes, modified at 2024/05/24 13:59:36 UTC, 5 instances +shakerkicksnare.aif: 352854 bytes, modified at 2024/10/21 12:21:12 UTC, 2 instances +VisibleBang.js: 317 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +my-maxnode-basic.js: 464 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +times3.gendsp: 2047 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +myxfade.genjit: 3156 bytes, modified at 2024/10/21 12:21:12 UTC, 2 instances +mystorage.json: 239 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +myTestTable: 410 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +TestScript.js: 87 bytes, modified at 2024/10/21 12:21:12 UTC, 1 instance +collContent.txt: 8 bytes, modified at 2024/05/24 13:59:36 UTC, NOT FOUND IN PATCH + +Total - Counting every abstraction instance - Indicates loading time + Object instances: 90 + Connections: 26 +Unique - Counting abstractions once - Indicates maintainability + Object instances: 73 + Connections: 16 diff --git a/maxdiff/tests/test_baselines/Test.amxd.txt b/maxdiff/tests/test_baselines/Test.amxd.txt index 4fa0d95..6669878 100644 --- a/maxdiff/tests/test_baselines/Test.amxd.txt +++ b/maxdiff/tests/test_baselines/Test.amxd.txt @@ -5,8 +5,10 @@ parameters: [bpatcher ParamAbstraction.maxpat]/[obj-1]: ['InsideBpatcher', 'live.dial', 0] [AbstractionWithParameter]/[obj-1]: ['MyParameter', 'MyParameter', 0] [AbstractionWithParameter]/[obj-1]: ['MyParameter[1]', 'MyParameter', 0] > override > ['MyParameter[1]', '-', '-'] + [p ImplicitDependencies]/[live.tab]: ['Time Mode', 'Time Mode', 0] + [p ImplicitDependencies]/[live.text]: ['live.text', 'live.text', 0] + [p ImplicitDependencies]/[live.menu]: ['live.menu', 'live.menu', 0] [bpatcher ParamAbstraction.maxpat]/[obj-1]: ['OverruledParamLongName', 'OverruledParamShortName', 0] > override > ['OverruledParamLongName', 'OverruledParamShortName', '-'] - [live.tab]: ['Time Mode', 'Time Mode', 0] [bpatcher ThisWasAnAbstractionBeforeEmbeddingIt.maxpat ]/[live.numbox]: ['EmbeddedParam', 'Embedded', 0] [live.dial]: ['live.dial', 'live.dial', 0] [live.dial]: ['live.dial[1]', 'live.dial', 0] @@ -17,10 +19,18 @@ dependency_cache: {'name': 'AbstractionWithParameter.maxpat', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'JSON', 'implicit': 1} {'name': 'MyAbstraction.maxpat', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'JSON', 'implicit': 1} {'name': 'ParamAbstraction.maxpat', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'JSON', 'implicit': 1} + {'name': 'TestScript.js', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'TEXT', 'implicit': 1} + {'name': 'VisibleBang.js', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'TEXT', 'implicit': 1} {'name': 'beat-icon.svg', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'svg', 'implicit': 1} {'name': 'collContent.txt', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'TEXT', 'implicit': 1} {'name': 'fpic.png', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'PNG', 'implicit': 1} {'name': 'hz-icon.svg', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'svg', 'implicit': 1} + {'name': 'my-maxnode-basic.js', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'TEXT', 'implicit': 1} + {'name': 'myTestTable', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'TEXT', 'implicit': 1} + {'name': 'mystorage.json', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'JSON', 'implicit': 1} + {'name': 'myxfade.genjit', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'gJIT', 'implicit': 1} + {'name': 'shakerkicksnare.aif', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'AIFF', 'implicit': 1} + {'name': 'times3.gendsp', 'bootpath': '~/maxdevtools/maxdiff/tests/test_files', 'type': 'gDSP', 'implicit': 1} project: version: 1 | creationdate: 3590052786 | modificationdate: 3590052786 | viewrect: [25, 106, 300, 500] | autoorganize: 1 | hideprojectwindow: 1 | showdependencies: 1 | autolocalize: 0 | layout: {} | searchpath: {} | detailsvisible: 0 | amxdtype: 1835887981 | readonly: 0 | devpathtype: 0 | devpath: . | sortmode: 0 | viewmode: 0 | includepackages: 0 @@ -29,11 +39,51 @@ project: collContent.txt ----------- patcher ----------- -appversion: 8.6.2-x64-1 | rect: [65, 399, 927, 289] | openrect: [0, 0, 0, 169] | default_fontsize: 10.0 | default_fontname: Arial Bold | gridsize: [8, 8] | boxanimatetime: 500 | latency: 0 | is_mpe: 0 | platform_compatibility: 0 | autosave: 0 +appversion: 8.6.4-x64-1 | rect: [91, 153, 1288, 310] | openrect: [0, 0, 0, 169] | default_fontsize: 10.0 | default_fontname: Arial Bold | gridsize: [8, 8] | boxanimatetime: 500 | latency: 0 | is_mpe: 0 | platform_compatibility: 0 | autosave: 0 ----------- objects ----------- +[comment NOTE: after any changes to this device, also update FrozenTest.amxd] +[poly~ MyAbstraction] +[poly~ MyAbstraction 3] +[comment Explicit dependency (included in project)] bubble: 1 | linecount: 2 +[loadbang] +[message read collContent.txt] +[coll] +[p ImplicitDependencies] varname: ImplicitDependencies + ----------- patcher ----------- + appversion: 8.6.4-x64-1 | rect: [60, 130, 639, 710] + ----------- objects ----------- + [js TestScript] filename: TestScript | parameter_enable: 0 + [comment We would expect implicit dependencies for these but they don't seem to be added.] bubble: 1 | bubbleside: 2 | linecount: 2 + [jit.movie @moviefile ChoppedBipBop.m4v @autostart 0] + [jit.gen @gen myxfade] + [imovie] + [message read myTestTable] fontname: Arial | fontsize: 13.0 + [table myTestTable] name: myTestTable | parameter_enable: 0 | parameter_mappable: 0 | range: 128 | size: 128 + [matrixctrl] bkgndpict: fpic.png | cellpict: fpic.png | columns: 2 | rows: 2 + [nodes] displayknob: 2 | knobpict: fpic.png + [number] varname: number + [pattr test] restore: [1337] | varname: test + [pattrstorage mystorage @savemode 0] client_rect: [4, 44, 358, 172] | parameter_enable: 0 | parameter_mappable: 0 | storage_rect: [583, 69, 1034, 197] | varname: mystorage + [jit.pix @gen myxfade] + [buffer~ test shakerkicksnare.aif] + [gen~ @gen times3.gendsp] + [sfplay~ @audiofile shakerkicksnare.aif] + [node.script my-maxnode-basic.js] autostart: 0 | defer: 0 | watch: 0 + [comment JSUI using script file] bubble: 1 + [jsui] filename: VisibleBang.js + [live.menu] pictures: [hz-icon.svg, beat-icon.svg] | remapsvgcolors: 1 | parameter: | usepicture: 1 | usesvgviewbox: 1 + [live.text] pictures: [hz-icon.svg, beat-icon.svg] | remapsvgcolors: 1 | parameter: | usepicture: 1 | usesvgviewbox: 1 + [playlist~] basictuning: 440 | clipheight: 28.0 | data: {'clips': [{'absolutepath': 'shakerkicksnare.aif', 'filename': 'shakerkicksnare.aif', 'filekind': 'audiofile', 'id': 'u400003311', 'loop': 1, 'content_state': {'loop': 1}}]} | mode: basic | originallength: [0, ticks] | originaltempo: 120.0 | quality: basic + [pictslider] bkgndpict: fpic.png | clickedimage: 0 | knobpict: fpic.png + [pictctrl] name: fpic.png + [fpic] pic: fpic.png + [live.tab] annotation: Toggles between Beat Sync and Free running (Hz). | annotation_name: Time Mode | fontsize: 9.0 | livemode: 1 | num_lines_patching: 2 | num_lines_presentation: 2 | pictures: [hz-icon.svg, beat-icon.svg] | remapsvgcolors: 1 | parameter: | usepicture: 1 | usesvgviewbox: 1 | varname: Time Mode + ----------- patch cords ----------- + [pattr test] (1) => (0) [number] + [message read myTestTable] (0) => (0) [table myTestTable] [bpatcher ThisWasAnAbstractionBeforeEmbeddingIt.maxpat] embed: 1 ----------- patcher ----------- - appversion: 8.6.2-x64-1 | rect: [927, 431, 640, 480] + appversion: 8.6.4-x64-1 | rect: [927, 431, 640, 480] ----------- objects ----------- [live.comment Embedded] [live.numbox] parameter: @@ -47,7 +97,7 @@ appversion: 8.6.2-x64-1 | rect: [65, 399, 927, 289] | openrect: [0, 0, 0, 169] | [button] [gen @t exponent] ----------- patcher ----------- - appversion: 8.6.2-x64-1 | classnamespace: dsp.gen | rect: [84, 144, 653, 641] + appversion: 8.6.4-x64-1 | classnamespace: dsp.gen | rect: [84, 144, 653, 641] ----------- objects ----------- [codebox] //============================================================ @@ -90,20 +140,15 @@ appversion: 8.6.2-x64-1 | rect: [65, 399, 927, 289] | openrect: [0, 0, 0, 169] | [coll @embed 1] coll_data: {'count': 2, 'data': [{'key': 0, 'value': ['test']}, {'key': 1, 'value': ['another', 'test']}]} | embed: 1 | precision: 6 [p MySubpatcher] ----------- patcher ----------- - appversion: 8.6.2-x64-1 | rect: [805, 282, 271, 250] + appversion: 8.6.4-x64-1 | rect: [805, 282, 271, 250] ----------- objects ----------- [live.dial] parameter: [t b b] -[loadbang] -[message read collContent.txt] -[coll] -[fpic] pic: fpic.png -[live.tab] annotation: Toggles between Beat Sync and Free running (Hz). | annotation_name: Time Mode | fontsize: 9.0 | livemode: 1 | num_lines_patching: 2 | num_lines_presentation: 2 | pictures: [hz-icon.svg, beat-icon.svg] | remapsvgcolors: 1 | parameter: | usepicture: 1 | usesvgviewbox: 1 | varname: Time Mode [live.banks] [bpatcher ParamAbstraction.maxpat] offset: [-73, -62] [bpatcher ParamAbstraction.maxpat] offset: [-73, -62] -[live.dial] activefgdialcolor: live_display_handle_two | parameter: | varname: live.dial[1] -[live.dial] parameter: +[live.dial] activefgdialcolor: live_display_handle_two | appearance: 1 | parameter: | varname: live.dial[1] +[live.dial] appearance: 1 | parameter: [button] presentation: 1 | presentation_rect: [265, 26, 24, 24] [bpatcher MyAbstraction.maxpat] offset: [-4, -12] [comment A comment] diff --git a/maxdiff/tests/test_files/FrozenTest.amxd b/maxdiff/tests/test_files/FrozenTest.amxd index 6cb2c20..a7c18a4 100644 Binary files a/maxdiff/tests/test_files/FrozenTest.amxd and b/maxdiff/tests/test_files/FrozenTest.amxd differ diff --git a/maxdiff/tests/test_files/MyAbstraction.maxpat b/maxdiff/tests/test_files/MyAbstraction.maxpat index 283c322..9bb3c77 100644 --- a/maxdiff/tests/test_files/MyAbstraction.maxpat +++ b/maxdiff/tests/test_files/MyAbstraction.maxpat @@ -3,8 +3,8 @@ "fileversion" : 1, "appversion" : { "major" : 8, - "minor" : 5, - "revision" : 5, + "minor" : 6, + "revision" : 2, "architecture" : "x64", "modernui" : 1 } @@ -39,6 +39,18 @@ "subpatcher_template" : "", "assistshowspatchername" : 0, "boxes" : [ { + "box" : { + "id" : "obj-3", + "maxclass" : "button", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "bang" ], + "parameter_enable" : 0, + "patching_rect" : [ 113.0, 60.0, 24.0, 24.0 ] + } + + } +, { "box" : { "comment" : "", "id" : "obj-2", @@ -67,6 +79,15 @@ "lines" : [ { "patchline" : { "destination" : [ "obj-2", 0 ], + "order" : 1, + "source" : [ "obj-1", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-3", 0 ], + "order" : 0, "source" : [ "obj-1", 0 ] } diff --git a/maxdiff/tests/test_files/Test.amxd b/maxdiff/tests/test_files/Test.amxd index cf5f281..fca4290 100644 Binary files a/maxdiff/tests/test_files/Test.amxd and b/maxdiff/tests/test_files/Test.amxd differ diff --git a/maxdiff/tests/test_files/TestScript.js b/maxdiff/tests/test_files/TestScript.js new file mode 100644 index 0000000..805e6f5 --- /dev/null +++ b/maxdiff/tests/test_files/TestScript.js @@ -0,0 +1,9 @@ +"use strict"; + +/* + An empty test script file +*/ + +function bang() { + outlet(0,"yes") +} diff --git a/maxdiff/tests/test_files/UnusedAbstraction.maxpat b/maxdiff/tests/test_files/UnusedAbstraction.maxpat new file mode 100644 index 0000000..9bb3c77 --- /dev/null +++ b/maxdiff/tests/test_files/UnusedAbstraction.maxpat @@ -0,0 +1,105 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 6, + "revision" : 2, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "box", + "rect" : [ 312.0, 228.0, 221.0, 176.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Arial", + "gridonopen" : 1, + "gridsize" : [ 15.0, 15.0 ], + "gridsnaponopen" : 1, + "objectsnaponopen" : 1, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 0, + "toptoolbarpinned" : 0, + "righttoolbarpinned" : 0, + "bottomtoolbarpinned" : 0, + "toolbars_unpinned_last_save" : 0, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "", + "subpatcher_template" : "", + "assistshowspatchername" : 0, + "boxes" : [ { + "box" : { + "id" : "obj-3", + "maxclass" : "button", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "bang" ], + "parameter_enable" : 0, + "patching_rect" : [ 113.0, 60.0, 24.0, 24.0 ] + } + + } +, { + "box" : { + "comment" : "", + "id" : "obj-2", + "index" : 1, + "maxclass" : "outlet", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 40.0, 60.0, 30.0, 30.0 ] + } + + } +, { + "box" : { + "comment" : "", + "id" : "obj-1", + "index" : 1, + "maxclass" : "inlet", + "numinlets" : 0, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 40.0, 22.0, 30.0, 30.0 ] + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-2", 0 ], + "order" : 1, + "source" : [ "obj-1", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-3", 0 ], + "order" : 0, + "source" : [ "obj-1", 0 ] + } + + } + ], + "saved_attribute_attributes" : { + "default_plcolor" : { + "expression" : "" + } + + } + + } + +} diff --git a/maxdiff/tests/test_files/VisibleBang.js b/maxdiff/tests/test_files/VisibleBang.js new file mode 100644 index 0000000..4a4157a --- /dev/null +++ b/maxdiff/tests/test_files/VisibleBang.js @@ -0,0 +1,17 @@ +"use strict"; + +/* + Outputs a bang when it becomes visible. Only passes on incoming bangs when visible. + + Note that to be visible for a device in presentation mode, this object must be included in presentation. +*/ + +mgraphics.init() + +function paint() { + outlet(0, "bang") +} + +function bang() { + mgraphics.redraw() +} diff --git a/maxdiff/tests/test_files/my-maxnode-basic.js b/maxdiff/tests/test_files/my-maxnode-basic.js new file mode 100644 index 0000000..fa12188 --- /dev/null +++ b/maxdiff/tests/test_files/my-maxnode-basic.js @@ -0,0 +1,15 @@ +const path = require('path'); +const Max = require('max-api'); + +// This will be printed directly to the Max console +Max.post(`Loaded the ${path.basename(__filename)} script`); + +// Use the 'addHandler' function to register a function for a particular message +Max.addHandler("bang", () => { + Max.post("Who you think you bangin'?"); +}); + +// Use the 'outlet' function to send messages out of node.script's outlet +Max.addHandler("echo", (msg) => { + Max.outlet(msg); +}); diff --git a/maxdiff/tests/test_files/myTestTable b/maxdiff/tests/test_files/myTestTable new file mode 100644 index 0000000..221f6c1 --- /dev/null +++ b/maxdiff/tests/test_files/myTestTable @@ -0,0 +1 @@ +table 0 0 0 0 0 100 97 91 86 84 76 73 71 69 65 57 55 50 49 47 43 39 37 32 29 28 28 29 32 33 34 35 37 38 39 42 43 44 45 46 48 49 51 52 53 54 56 59 61 62 64 67 70 71 72 74 82 84 86 92 104 112 117 124 123 118 114 101 95 92 84 72 65 49 39 37 35 32 29 27 25 25 25 25 25 25 26 27 27 27 28 28 28 29 30 31 32 33 34 35 36 37 38 40 44 45 76 80 88 91 99 101 102 104 106 106 107 108 108 108 110 110 110 110 111 111 111 112 \ No newline at end of file diff --git a/maxdiff/tests/test_files/mystorage.json b/maxdiff/tests/test_files/mystorage.json new file mode 100644 index 0000000..f5046f9 --- /dev/null +++ b/maxdiff/tests/test_files/mystorage.json @@ -0,0 +1,25 @@ +{ + "pattrstorage" : { + "name" : "mystorage", + "slots" : { + "1" : { + "id" : 1, + "data" : { + "test" : [ 1337 ] + } + + } +, + "2" : { + "id" : 2, + "data" : { + "test" : [ 74 ] + } + + } + + } + + } + +} diff --git a/maxdiff/tests/test_files/myxfade.genjit b/maxdiff/tests/test_files/myxfade.genjit new file mode 100644 index 0000000..7e1692f --- /dev/null +++ b/maxdiff/tests/test_files/myxfade.genjit @@ -0,0 +1,158 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 6, + "revision" : 4, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "jit.gen", + "rect" : [ 180.0, 523.0, 408.0, 167.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Arial", + "gridonopen" : 1, + "gridsize" : [ 15.0, 15.0 ], + "gridsnaponopen" : 1, + "objectsnaponopen" : 1, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 0, + "toptoolbarpinned" : 0, + "righttoolbarpinned" : 0, + "bottomtoolbarpinned" : 0, + "toolbars_unpinned_last_save" : 0, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "", + "subpatcher_template" : "", + "assistshowspatchername" : 0, + "title" : "myxfade.genjit", + "boxes" : [ { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-7", + "maxclass" : "comment", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 144.0, 67.0, 161.0, 20.0 ], + "text" : "mix = in1 * (1-amt) + in2*amt" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-5", + "maxclass" : "newobj", + "numinlets" : 0, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 121.0, 27.0, 79.0, 22.0 ], + "text" : "param xfade" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-4", + "maxclass" : "newobj", + "numinlets" : 3, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 35.0, 66.0, 105.0, 22.0 ], + "text" : "mix" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-3", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 35.0, 105.0, 38.0, 22.0 ], + "text" : "out 1" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-2", + "maxclass" : "newobj", + "numinlets" : 0, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 78.0, 27.0, 30.0, 22.0 ], + "text" : "in 2" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-1", + "maxclass" : "newobj", + "numinlets" : 0, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 35.0, 27.0, 30.0, 22.0 ], + "text" : "in 1" + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-4", 0 ], + "source" : [ "obj-1", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-4", 1 ], + "source" : [ "obj-2", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-3", 0 ], + "source" : [ "obj-4", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-4", 2 ], + "source" : [ "obj-5", 0 ] + } + + } + ], + "autosave" : 0, + "bgcolor" : [ 0.9, 0.9, 0.9, 0.9 ] + } + +} diff --git a/maxdiff/tests/test_files/shakerkicksnare.aif b/maxdiff/tests/test_files/shakerkicksnare.aif new file mode 100755 index 0000000..10cb235 Binary files /dev/null and b/maxdiff/tests/test_files/shakerkicksnare.aif differ diff --git a/maxdiff/tests/test_files/times3.gendsp b/maxdiff/tests/test_files/times3.gendsp new file mode 100644 index 0000000..a2a8e4d --- /dev/null +++ b/maxdiff/tests/test_files/times3.gendsp @@ -0,0 +1,102 @@ +{ + "patcher" : { + "fileversion" : 1, + "appversion" : { + "major" : 8, + "minor" : 6, + "revision" : 4, + "architecture" : "x64", + "modernui" : 1 + } +, + "classnamespace" : "dsp.gen", + "rect" : [ 50.0, 94.0, 212.0, 208.0 ], + "bglocked" : 0, + "openinpresentation" : 0, + "default_fontsize" : 12.0, + "default_fontface" : 0, + "default_fontname" : "Arial", + "gridonopen" : 1, + "gridsize" : [ 15.0, 15.0 ], + "gridsnaponopen" : 1, + "objectsnaponopen" : 1, + "statusbarvisible" : 2, + "toolbarvisible" : 1, + "lefttoolbarpinned" : 0, + "toptoolbarpinned" : 0, + "righttoolbarpinned" : 0, + "bottomtoolbarpinned" : 0, + "toolbars_unpinned_last_save" : 0, + "tallnewobj" : 0, + "boxanimatetime" : 200, + "enablehscroll" : 1, + "enablevscroll" : 1, + "devicewidth" : 0.0, + "description" : "", + "digest" : "", + "tags" : "", + "style" : "", + "subpatcher_template" : "", + "assistshowspatchername" : 0, + "boxes" : [ { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-1", + "maxclass" : "newobj", + "numinlets" : 0, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 33.0, 28.0, 30.0, 22.0 ], + "text" : "in 1" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-3", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 1, + "outlettype" : [ "" ], + "patching_rect" : [ 33.0, 67.0, 23.0, 22.0 ], + "text" : "* 3" + } + + } +, { + "box" : { + "fontname" : "Arial", + "fontsize" : 12.0, + "id" : "obj-4", + "maxclass" : "newobj", + "numinlets" : 1, + "numoutlets" : 0, + "patching_rect" : [ 33.0, 106.0, 37.0, 22.0 ], + "text" : "out 1" + } + + } + ], + "lines" : [ { + "patchline" : { + "destination" : [ "obj-3", 0 ], + "source" : [ "obj-1", 0 ] + } + + } +, { + "patchline" : { + "destination" : [ "obj-4", 0 ], + "source" : [ "obj-3", 0 ] + } + + } + ], + "autosave" : 0, + "bgcolor" : [ 0.9, 0.9, 0.9, 1.0 ] + } + +}