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
18 changes: 18 additions & 0 deletions maxdiff/freezing_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,21 @@ def strip_trailing_nonjson(device_data_text: str) -> str:

# nothing worked; return original so caller can log/handle the parse error
return device_data_text


EXTERNAL_FILE_SUFFIXES = [".mxe64", ".mxo", ".mxe"]
EXTERNAL_BUNDLE_SEPARATOR = "/"


def get_external_name(file_name: str) -> str:
"""
Checks the file name of a bundled entry and returns the name of the external as it appears in the patch
"""
if EXTERNAL_BUNDLE_SEPARATOR in file_name:
return file_name.split(EXTERNAL_BUNDLE_SEPARATOR)[0]

for suffix in EXTERNAL_FILE_SUFFIXES:
if file_name.endswith(suffix):
return file_name[: -len(suffix)]

return None
15 changes: 10 additions & 5 deletions maxdiff/frozen_device_printer.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from freezing_utils import *
from get_frozen_stats import get_frozen_stats, get_used_files
from get_frozen_stats import get_frozen_stats, get_frozen_file_usage, get_external_name
from patch_printer import get_parameters_string_block


Expand Down Expand Up @@ -34,7 +34,7 @@ def print_frozen_device(data: bytes) -> str:
if "parameters" in main_patcher:
frozen_string += get_parameters_string_block(main_patcher, bundled_patchers)

used_files = get_used_files(device_entries)
frozen_file_usage = get_frozen_file_usage(device_entries)

i = 0
for entry in device_entries:
Expand All @@ -44,10 +44,15 @@ def print_frozen_device(data: bytes) -> str:
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"
if file_name in frozen_file_usage:
frozen_string += f"{description}, {frozen_file_usage[file_name]} instance{'s' if frozen_file_usage[file_name] > 1 else ''}\n"
else:
frozen_string += f"{description}, NOT FOUND IN PATCH\n"
# this could be an external
external_name = get_external_name(file_name)
if external_name in frozen_file_usage:
frozen_string += f"{description}, {frozen_file_usage[external_name]} instance{'s' if frozen_file_usage[external_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] = (
Expand Down
78 changes: 56 additions & 22 deletions maxdiff/get_frozen_stats.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from freezing_utils import device_entry_with_data, get_patcher_dict
from process_patcher import Processor, process_patch
from freezing_utils import device_entry_with_data, get_patcher_dict, get_external_name
from process_patcher import PatcherProcessor, walk_patch


def get_frozen_stats(entries: list[device_entry_with_data]) -> tuple[int, int, int, int]:
Expand All @@ -17,7 +17,7 @@ def get_frozen_stats(entries: list[device_entry_with_data]) -> tuple[int, int, i

# get total counts: parse every instance of every abstraction
count_processor = CountProcessor()
process_patch(
walk_patch(
device_patch, abstraction_entries, count_processor # do recurse into abstractions
)
object_count_total, line_count_total = count_processor.get_results()
Expand All @@ -35,7 +35,7 @@ def get_frozen_stats(entries: list[device_entry_with_data]) -> tuple[int, int, i
continue

count_processor = CountProcessor()
process_patch(entry_patch, [], count_processor) # don't recurse into abstractions
walk_patch(entry_patch, [], count_processor) # don't recurse into abstractions
o, l = count_processor.get_results()

object_count_unique += o
Expand All @@ -44,63 +44,97 @@ def get_frozen_stats(entries: list[device_entry_with_data]) -> tuple[int, int, i
return object_count_total, line_count_total, object_count_unique, line_count_unique


class CountProcessor(Processor):
class CountProcessor(PatcherProcessor):
def __init__(self):
self.object_count = 0
self.line_count = 0

def process_elements(self, patcher, voice_count: int, abstraction_name=""):
def process_patcher(self, patcher, poly_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
self.object_count += len(patcher.get("boxes", [])) * poly_voice_count
self.line_count += len(patcher.get("lines", [])) * poly_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]:
def get_frozen_file_usage(entries: list[device_entry_with_data]) -> dict[str, int]:
"""Returns a dict with the names of all the files that are frozen into this device
with how often they are used in the device.

It does this by parsing the top patcher and the abstractions found in `entries` recursively
and finding how many objects match with the file names of the bundled dependencies.

Externals are included in the dict just by name. These names can be matched later with
the actual bundled external files.
"""
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")
]

external_names = list(
dict.fromkeys(
get_external_name(item["file_name"])
for item in entries
if get_external_name(item["file_name"]) != ""
)
)

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()
dependency_file_names_processor = DependencyUsageCounter(external_names)
walk_patch(device_patch, abstraction_entries, dependency_file_names_processor)
return dependency_file_names_processor.get_results()


class FileNamesProcessor(Processor):
def __init__(self):
self.found_filenames = {}
class DependencyUsageCounter(PatcherProcessor):
def __init__(self, external_names):
self.external_names = external_names
self.filename_occurrences = {}

def process_elements(self, patcher, voice_count: int, abstraction_name=""):
def process_patcher(self, patcher, poly_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)
filenames.extend(get_externals(patcher, self.external_names))
if abstraction_name != "":
filenames.append(abstraction_name)

for filename in filenames:
if filename in self.found_filenames:
self.found_filenames[filename] += voice_count
if filename in self.filename_occurrences:
self.filename_occurrences[filename] += poly_voice_count
else:
self.found_filenames[filename] = voice_count
self.filename_occurrences[filename] = poly_voice_count

def get_results(self):
"""Returns a dict of used abstractions mapped to how oftern they are used."""
return self.found_filenames
"""Returns a dict of used abstractions mapped to how often they are used."""
return self.filename_occurrences


def get_externals(patcher, external_names):
"""Check all boxes in this patcher (don't recurse into subpatchers) and collect any objects that are externals"""
externals = []
for box_entry in patcher["boxes"]:
box = box_entry["box"]
object_type = box["maxclass"]
if object_type == "newobj":
if "text" in box:
boxtext = box["text"]
object_name = boxtext.split(" ")[0]
if object_name in external_names:
externals.append(object_name)
return externals


def get_dependency_filenames(patcher):
"""Check all boxes in this patcher and report any dependencies they might be referring to"""
"""Check all boxes in this patcher (don't recurse into subpatchers) and collect any dependencies they might be referring to"""
filenames = []
for box_entry in patcher["boxes"]:
box = box_entry["box"]
Expand Down
2 changes: 1 addition & 1 deletion maxdiff/print_unicode.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""
Module that shares a function to gracefully handle printing unicode characters
if the user has the PYTHONIOENCODING environment vairable set to 'ascii'.
if the user has the PYTHONIOENCODING environment variable set to 'ascii'.
In that case, replace any characters that can't be encoded with ?.
"""

Expand Down
32 changes: 16 additions & 16 deletions maxdiff/process_patcher.py
Original file line number Diff line number Diff line change
@@ -1,38 +1,38 @@
from freezing_utils import get_patcher_dict


class Processor:
def process_elements(self, patcher, voice_count: int, abstraction_name=""):
class PatcherProcessor:
def process_patcher(self, patcher, poly_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 walk_patch(patcher, abstraction_entries: list[dict], processor: PatcherProcessor):
walk_patch_recursive(patcher, abstraction_entries, processor, 1, "")


def process_patch_recursive(
def walk_patch_recursive(
patcher,
abstraction_entries: list[dict],
processor: Processor,
voice_count: int,
processor: PatcherProcessor,
poly_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
including 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
poly_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)
processor.process_patcher(patcher, poly_voice_count, abstraction_file_name)

for box_entry in patcher["boxes"]:
box = box_entry["box"]
Expand All @@ -42,9 +42,9 @@ def process_patch_recursive(
):
patch = box["patcher"]
# get subpatcher or embedded bpatcher count
process_patch_recursive(patch, abstraction_entries, processor, 1, "")
walk_patch_recursive(patch, abstraction_entries, processor, 1, "")

# if no abstractions were passed in, we assume we don't want to recurse into abstarctions
# if no abstractions were passed in, we assume we don't want to recurse into abstractions
if len(abstraction_entries) == 0:
continue

Expand All @@ -60,17 +60,17 @@ def process_patch_recursive(
if patch == {}:
continue # something went wrong when parsing the abstraction

voice_count = 1
poly_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
poly_voice_count = int(tokens[2]) if len(tokens) > 2 else 1

process_patch_recursive(
walk_patch_recursive(
patch,
abstraction_entries,
processor,
voice_count,
poly_voice_count,
file_name,
)

Expand Down
18 changes: 12 additions & 6 deletions maxdiff/tests/test_baselines/FrozenTest.amxd.txt
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,10 @@ parameters:
banks:
0: (MyBank) encoders: ['live.dial', 'InsideBpatcher', '-', '-', '-', '-', '-', '-'] buttons: ['live.text', '-', '-', '-', '-', '-', '-', 'live.menu']
1: encoders: ['-', '-', 'live.dial', 'MyParameter', 'live.dial', 'live.dial[1]', '-', '-'] buttons: ['-', '-', '-', '-', '-', 'live.text', 'live.menu', '-']
Test.amxd: 57703 bytes, modified at 2026/08/31 09:52:24 UTC <= Device
MyAbstraction.maxpat: 2027 bytes, modified at 2026/08/31 09:51:02 UTC, 6 instances
ParamAbstraction.maxpat: 1491 bytes, modified at 2026/08/31 09:51:02 UTC, 2 instances
AbstractionWithParameter.maxpat: 1334 bytes, modified at 2026/08/31 09:51:02 UTC, 2 instances
Test.amxd: 58702 bytes, modified at 2026/09/03 12:38:17 UTC <= Device
MyAbstraction.maxpat: 2027 bytes, modified at 2026/08/31 10:48:30 UTC, 6 instances
ParamAbstraction.maxpat: 1491 bytes, modified at 2026/08/31 10:48:30 UTC, 2 instances
AbstractionWithParameter.maxpat: 1334 bytes, modified at 2026/08/31 10:48:30 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
Expand All @@ -140,10 +140,16 @@ mystorage.json: 239 bytes, modified at 2026/08/27 14:58:36 UTC, 1 instance
myTestTable: 410 bytes, modified at 2026/08/27 14:58:36 UTC, 1 instance
TestScript.js: 87 bytes, modified at 2026/08/27 14:58:36 UTC, 1 instance
collContent.txt: 8 bytes, modified at 2024/05/24 13:59:36 UTC, NOT FOUND IN PATCH
shell.mxo: 4 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances
shell/zipfile: 28122 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances
shell/executable: 182544 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances
shell/plist: 1441 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances
shell.mxe: 30720 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances
shell.mxe64: 37888 bytes, modified at 2026/08/31 10:47:56 UTC, 2 instances

Total - Counting every abstraction instance - Indicates loading time
Object instances: 90
Object instances: 92
Connections: 26
Unique - Counting abstractions once - Indicates maintainability
Object instances: 73
Object instances: 75
Connections: 16
2 changes: 2 additions & 0 deletions maxdiff/tests/test_baselines/Test.amxd.txt
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ project:
----------- patcher -----------
appversion: 9.1.4-x64-1 | rect: [91, 153, 1288, 310] | openrect: [0, 0, 0, 169] | openrectmode: 0 | default_fontsize: 10.0 | default_fontname: Arial Bold | gridsize: [8, 8] | boxanimatetime: 500 | latency: 0 | is_mpe: 0 | external_mpe_tuning_enabled: 0 | platform_compatibility: 0 | autosave: 0
----------- objects -----------
[shell] shell: (default)
[shell dummyArgument] shell: (default)
[comment NOTE: after any changes to this device, also update FrozenTest.amxd]
[poly~ MyAbstraction]
[poly~ MyAbstraction 3]
Expand Down
Binary file modified maxdiff/tests/test_files/FrozenTest.amxd
Binary file not shown.
Binary file modified maxdiff/tests/test_files/Test.amxd
Binary file not shown.
Loading
Loading