diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..b8ea408 --- /dev/null +++ b/.flake8 @@ -0,0 +1,51 @@ +[flake8] +ignore= + E127, + E128, + E301, + E302, + E303, + E501, + E722, + E731, + F401, + F405, + F821, + F841, + W391, + W503, + +# E127, # over indented +# E128, # under indented +# E301, # 0 blank lines -- good test but something going wrong in new algorithm +# E302, # blank lines +# E303, # blank lines +# E501, # let pylint check line length +# E722, # let pylint check bare except +# E731, # do not assign a lambda +# F401, # let pylint check for imported but unused (wildcards and exports trigger) +# F405, # __all__ is okay to get from wildcard. +# F821, # let pylint check for undefined (del at end of module makes undefined) +# F841, # let pylint check for unused +# W391, # extra blank lines at end of file +# W503, # line break BEFORE binary operator + +exclude= + .git, + __pycache__, + *.pyc, + ext, + +# F403 = from module import * + +per-file-ignores = + music21/chord/tables.py:E122,E124,E201,E202,E203,E221,E231,E241 + music21/common/__init__.py:F403 + music21/features/__init__.py:F403 + music21/search/__init__.py:F403 + +max-line-length=100 + +inline-quotes = single +multiline-quotes = ''' +docstring-quotes = ''' diff --git a/.pylintrc b/.pylintrc index e281f9e..f363fd2 100644 --- a/.pylintrc +++ b/.pylintrc @@ -1,370 +1,240 @@ [MASTER] -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code. -extension-pkg-whitelist= +# Specify a configuration file. +# rcfile= -# Specify a score threshold to be exceeded before program exits with error. -fail-under=10.0 +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +# init-hook= # Add files or directories to the blacklist. They should be base names, not # paths. ignore=CVS -# Add files or directories matching the regex patterns to the blacklist. The -# regex matches against base names, not paths. -ignore-patterns= - -# Python code to execute, usually for sys.path manipulation such as -# pygtk.require(). -#init-hook= - -# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the -# number of processors available to use. -jobs=1 - -# Control the amount of potential inferred values when inferring a single -# object. This can help the performance when dealing with large functions or -# complex, nested conditions. -limit-inference-results=100 +# Pickle collected data for later comparisons. +persistent=yes -# List of plugins (as comma separated values of python module names) to load, +# List of plugins (as comma separated values of python modules names) to load, # usually to register additional checkers. load-plugins= -# Pickle collected data for later comparisons. -persistent=yes - -# When enabled, pylint would attempt to guess common misconfiguration and emit -# user-friendly hints instead of false-positive error messages. -suggestion-mode=yes +## Use multiple processes to speed up Pylint. +## jobs=1 # Allow loading of arbitrary C extensions. Extensions are imported into the # active Python interpreter and may run arbitrary code. unsafe-load-any-extension=no +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + [MESSAGES CONTROL] # Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED confidence= +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time. See also the "--disable" option for examples. +#enable= + # Disable the message, report, category or checker with the given id(s). You # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration -# file where it should appear only once). You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have -# no Warning level messages displayed, use "--disable=all --enable=classes -# --disable=W". -disable=too-many-lines, - line-too-long, - pointless-string-statement, - too-many-return-statements, - too-many-instance-attributes, - too-many-branches, - too-many-statements, - too-many-locals, - too-many-arguments, - too-few-public-methods, - too-many-public-methods, - missing-function-docstring, - missing-class-docstring, - missing-module-docstring, - import-outside-toplevel, - import-error, - bare-except, - fixme, - consider-using-enumerate, - cyclic-import, - invalid-name, - duplicate-code, - print-statement, # above this are mine - parameter-unpacking, - unpacking-in-except, - old-raise-syntax, - backtick, - long-suffix, - old-ne-operator, - old-octal-literal, - import-star-module-level, - non-ascii-bytes-literal, - raw-checker-failed, - bad-inline-option, - locally-disabled, - file-ignored, - suppressed-message, - useless-suppression, - deprecated-pragma, - use-symbolic-message-instead, - apply-builtin, - basestring-builtin, - buffer-builtin, - cmp-builtin, - coerce-builtin, - execfile-builtin, - file-builtin, - long-builtin, - raw_input-builtin, - reduce-builtin, - standarderror-builtin, - unicode-builtin, - xrange-builtin, - coerce-method, - delslice-method, - getslice-method, - setslice-method, - no-absolute-import, - old-division, - dict-iter-method, - dict-view-method, - next-method-called, - metaclass-assignment, - indexing-exception, - raising-string, - reload-builtin, - oct-method, - hex-method, - nonzero-method, - cmp-method, - input-builtin, - round-builtin, - intern-builtin, - unichr-builtin, - map-builtin-not-iterating, - zip-builtin-not-iterating, - range-builtin-not-iterating, - filter-builtin-not-iterating, - using-cmp-argument, - eq-without-hash, - div-method, - idiv-method, - rdiv-method, - exception-message-attribute, - invalid-str-codec, - sys-max-int, - bad-python3-import, - deprecated-string-function, - deprecated-str-translate-call, - deprecated-itertools-function, - deprecated-types-field, - next-method-defined, - dict-items-not-iterating, - dict-keys-not-iterating, - dict-values-not-iterating, - deprecated-operator-function, - deprecated-urllib-function, - xreadlines-attribute, - deprecated-sys-function, - exception-escape, - comprehension-escape - -# Enable the message, report, category or checker with the given id(s). You can -# either give multiple identifier separated by comma (,) or put this option -# multiple time (only on the command line, not in the configuration file where -# it should appear only once). See also the "--disable" option for examples. -enable=c-extension-no-member +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" + +# anything changed here, also change in test/testLint.py for now + +disable= + pointless-string-statement, # I will fix these at some point + cyclic-import, # we use these inside functions when there's a deep problem. + unnecessary-pass, # not really a problem.. + locally-disabled, # test for this later, but hopefully will know what we're doing + duplicate-code, # needs to ignore strings -- keeps getting doctests... + fixme, # obviously known + superfluous-parens, # nope -- if they make things clearer... + too-many-statements, # someday + too-many-arguments, # definitely! but takes too long to get a fix now... + too-many-public-methods, # maybe... + too-many-branches, # yes, someday + too-many-locals, # no + too-many-lines, # yes, someday + too-many-return-statements, # we'll see + too-many-instance-attributes, # maybe later + # no-self-use, # moved to optional extension. + invalid-name, # these are good music21 names; fix the regexp instead... + too-few-public-methods, # never remove or set to 1 + trailing-whitespace, # should ignore blank lines with tabs + missing-docstring, # gets too many well-documented properties + protected-access, # this is an important one, but see below... + unused-argument, + import-self, # fix is either to get rid of it or move away many tests... + wrong-import-position, + no-else-return, + broad-exception-caught, + too-many-ancestors, + inconsistent-return-statements, + keyword-arg-before-vararg, + consider-using-get, + chained-comparison, + import-outside-toplevel, + trailing-newlines, # is this really the worst thing in the world? + no-else-continue, # not so bad... + no-else-break, # same... + consider-using-enumerate, # sometimes parallelism is better than enumerate. + consider-using-dict-items, # readability improvement depends on excellent variable names + arguments-differ, # we are not purists. + arguments-renamed, # same + simplifiable-if-statement, # simpler to a computer maybe... + unsubscriptable-object, # too many errors + import-outside-toplevel, # allow for import music21, etc. + not-callable, # false positives, for instance on x.next() + raise-missing-from, # want to do this eventually, but adding 1000 msgs not helpful + consider-using-f-string, # future? + unnecessary-lambda-assignment, # opinionated + consider-using-generator, # generators are less performant for small container sizes, like most of ours + +# 'protected-access', # this is an important one, but for now we do a lot of +# # x = copy.deepcopy(self); x._volume = ... which is not a problem... [REPORTS] -# Python expression which should return a score less than or equal to 10. You -# have access to the variables 'error', 'warning', 'refactor', and 'convention' -# which contain the number of messages in each category, as well as 'statement' -# which is the total number of statements analyzed. This score is used by the -# global evaluation report (RP0004). -evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) - -# Template used to display messages. This is a python new-style format string -# used to format the message information. See doc for all details. -#msg-template= - -# Set the output format. Available formats are text, parseable, colorized, json -# and msvs (visual studio). You can also give a reporter class, e.g. +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg # mypackage.mymodule.MyReporterClass. output-format=text +msg-template="{path}:{line}: [{msg_id}({symbol}), {obj}] {msg}" -# Tells whether to display a full report or only the messages. +# Tells whether to display a full report or only the messages reports=no -# Activate the evaluation score. -score=yes - - -[REFACTORING] - -# Maximum number of nested blocks for function / method body -max-nested-blocks=8 - -# Complete name of functions that never returns. When checking for -# inconsistent-return-statements if a never returning function is called then -# it will be considered as an explicit return statement and no message will be -# printed. -never-returning-functions=sys.exit - - -[LOGGING] - -# The type of string formatting that logging methods do. `old` means using % -# formatting, `new` is for `{}` formatting. -logging-format-style=old - -# Logging modules to check that the string format arguments are in logging -# function parameter format. -logging-modules=logging - - -[SPELLING] - -# Limits count of emitted suggestions for spelling mistakes. -max-spelling-suggestions=4 - -# Spelling dictionary name. Available dictionaries: none. To make it work, -# install the python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) -# A path to a file that contains the private dictionary; one word per line. -spelling-private-dict-file= +# Add a comment according to your evaluation note. This is used by the global +# evaluation report (RP0004). +# comment=no -# Tells whether to store unknown words to the private dictionary (see the -# --spelling-private-dict-file option) instead of raising a message. -spelling-store-unknown-words=no +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= -[MISCELLANEOUS] +[BASIC] -# List of note tags to take in consideration, separated by a comma. -notes=FIXME, - XXX, - TODO +# Required attributes for module, separated by a comma +# required-attributes= -# Regular expression of note tags to take in consideration. -#notes-rgx= +# Good variable names which should always be accepted, separated by a comma +good-names=i,j,k,ex,Run,_ +# Bad variable names which should always be refused, separated by a comma +bad-names=foo,baz,toto,tutu,tata,shit,fuck,stuff -[TYPECHECK] +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= -# List of decorators that produce context managers, such as -# contextlib.contextmanager. Add to this list to register other decorators that -# produce valid context managers. -contextmanager-decorators=contextlib.contextmanager +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no -# List of members which are set dynamically and missed by pylint inference -# system, and so shouldn't trigger E1101 when accessed. Python regular -# expressions are accepted. -generated-members= +# Regular expression matching correct function names +function-rgx=[a-z_][A-Za-z0-9_]{2,30}$ -# Tells whether missing members accessed in mixin class should be ignored. A -# mixin class is detected if its name ends with "mixin" (case insensitive). -ignore-mixin-members=yes +# Regular expression matching correct variable names +variable-rgx=[a-z_][A-Za-z0-9_]{2,30}$ -# Tells whether to warn about missing members when the owner of the attribute -# is inferred to be None. -ignore-none=yes +# Regular expression matching correct constant names +const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ -# This flag controls whether pylint should warn about no-member and similar -# checks whenever an opaque object is returned when inferring. The inference -# can return multiple potential results while evaluating a Python object, but -# some branches might not be evaluated, which results in partial inference. In -# that case, it might be useful to still emit no-member and other checks for -# the rest of the inferred objects. -ignore-on-opaque-inference=yes +# Regular expression matching correct attribute names +attr-rgx=[a-z_][A-Za-z0-9_]{2,30}$ -# List of class names for which member attributes should not be checked (useful -# for classes with dynamically set attributes). This supports the use of -# qualified names. -ignored-classes=optparse.Values,thread._local,_thread._local +# Regular expression matching correct argument names +argument-rgx=[a-z_][A-Za-z0-9_]{2,30}$ -# List of module names for which member attributes should not be checked -# (useful for modules/projects where namespaces are manipulated during runtime -# and thus existing member attributes cannot be deduced by static analysis). It -# supports qualified module names, as well as Unix pattern matching. -ignored-modules= +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ -# Show a hint with possible names when a member name was not found. The aspect -# of finding the hint is based on edit distance. -missing-member-hint=yes +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ -# The minimum edit distance a name should have in order to be considered a -# similar match for a missing member name. -missing-member-hint-distance=1 +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ -# The total number of similar names that should be taken in consideration when -# showing a hint for a missing member. -missing-member-max-choices=1 +# Regular expression matching correct module names +module-rgx=(([a-z_][a-zA-Z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ -# List of decorators that change the signature of a decorated function. -signature-mutators= +# Regular expression matching correct method names +method-rgx=[a-z_][a-zA-Z0-9_]{2,30}$ +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=__.*__ -[VARIABLES] - -# List of additional names supposed to be defined in builtins. Remember that -# you should avoid defining new builtins when possible. -additional-builtins= +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-3 -# Tells whether unused global variables should be treated as a violation. -allow-global-unused-variables=yes -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_, - _cb +[FORMAT] -# A regular expression matching the name of dummy variables (i.e. expected to -# not be used). -dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ +# Maximum number of characters on a single line. +max-line-length=100 -# Argument names that match this expression will be ignored. Default to name -# with leading underscore. -ignored-argument-names=_.*|^ignored_|^unused_ +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$|converter\.parse -# Tells whether we should check for unused import in __init__ files. -init-import=no +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no -# List of qualified module names which can have objects that can redefine -# builtins. -redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io +# Maximum number of lines in a module +max-module-lines=1000 +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' -[FORMAT] +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format=LF +expected-line-ending-format= -# Regexp for a line that is allowed to be longer than the limit. -ignore-long-lines=^\s*(# )??$ -# Number of spaces of indent required inside a hanging or continued line. -indent-after-paren=4 - -# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 -# tab). -indent-string=' ' +[LOGGING] -# Maximum number of characters on a single line. -max-line-length=100 +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging -# Maximum number of lines in a module. -max-module-lines=1000 -# Allow the body of a class to be on the same line as the declaration if body -# contains single statement. -single-line-class-stmt=no +[MISCELLANEOUS] -# Allow the body of an if to be on the same line as the test if there is no -# else. -single-line-if-stmt=no +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO [SIMILARITIES] +# Minimum lines number of a similarity. +min-similarity-lines=8 + # Ignore comments when computing similarities. ignore-comments=yes @@ -372,239 +242,145 @@ ignore-comments=yes ignore-docstrings=yes # Ignore imports when computing similarities. -ignore-imports=no +ignore-imports=yes -# Minimum lines number of a similarity. -min-similarity-lines=4 +[SPELLING] -[BASIC] - -# Naming style matching correct argument names. -argument-naming-style=camelCase +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= -# Regular expression matching correct argument names. Overrides argument- -# naming-style. -#argument-rgx= +# List of comma separated words that should not be checked. +spelling-ignore-words= -# Naming style matching correct attribute names. -attr-naming-style=camelCase +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= -# Regular expression matching correct attribute names. Overrides attr-naming- -# style. -#attr-rgx= +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no -# Bad variable names which should always be refused, separated by a comma. -bad-names=foo, - bar, - baz, - toto, - tutu, - tata -# Bad variable names regexes, separated by a comma. If names match any regex, -# they will always be refused -bad-names-rgxs= +[TYPECHECK] -# Naming style matching correct class attribute names. -class-attribute-naming-style=any +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes -# Regular expression matching correct class attribute names. Overrides class- -# attribute-naming-style. -#class-attribute-rgx= +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis +ignored-modules= -# Naming style matching correct class names. -class-naming-style=PascalCase +# List of classes names for which member attributes should not be checked +# (useful for classes with attributes dynamically set). +ignored-classes=StreamCore -# Regular expression matching correct class names. Overrides class-naming- -# style. -#class-rgx= +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E0201 when accessed. Python regular +# expressions are accepted. +generated-members=REQUEST,acl_users,aq_parent -# Naming style matching correct constant names. -const-naming-style=UPPER_CASE -# Regular expression matching correct constant names. Overrides const-naming- -# style. -#const-rgx= +[VARIABLES] -# Minimum line length for functions/classes that require docstrings, shorter -# ones are exempt. -docstring-min-length=-1 +# Tells whether we should check for unused import in __init__ files. +init-import=no -# Naming style matching correct function names. -function-naming-style=camelCase +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=_$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|unused|i$|j$|junk|counter -# Regular expression matching correct function names. Overrides function- -# naming-style. -#function-rgx= +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= -# Good variable names which should always be accepted, separated by a comma. -good-names=i, - j, - k, - ex, - Run, - _ +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb -# Good variable names regexes, separated by a comma. If names match any regex, -# they will always be accepted -good-names-rgxs= -# Include a hint for the correct naming format with invalid-name. -include-naming-hint=no +[CLASSES] -# Naming style matching correct inline iteration names. -inlinevar-naming-style=any +# List of interface methods to ignore, separated by a comma. This is used for +# instance to not check methods defines in Zope's Interface base class. +# ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by -# Regular expression matching correct inline iteration names. Overrides -# inlinevar-naming-style. -#inlinevar-rgx= +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp,setup,run -# Naming style matching correct method names. -method-naming-style=camelCase +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls -# Regular expression matching correct method names. Overrides method-naming- -# style. -#method-rgx= +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs -# Naming style matching correct module names. -module-naming-style=any +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make -# Regular expression matching correct module names. Overrides module-naming- -# style. -#module-rgx= -# Colon-delimited sets of names that determine each other's naming style when -# the name regexes allow several styles. -name-group= +[DESIGN] -# Regular expression which should only match function or class names that do -# not require a docstring. -no-docstring-rgx=^_ +# Maximum number of arguments for function / method +max-args=5 -# List of decorators that produce properties, such as abc.abstractproperty. Add -# to this list to register other decorators that produce valid properties. -# These decorators are taken in consideration only for invalid-name. -property-classes=abc.abstractproperty +# maximum boolean expressions in a line (too-many-boolean-expressions) +max-bool-expr=10 -# Naming style matching correct variable names. -variable-naming-style=any +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* -# Regular expression matching correct variable names. Overrides variable- -# naming-style. -#variable-rgx= +# Maximum number of locals for function / method body +max-locals=15 +# Maximum number of return / yield for function / method body +max-returns=6 -[STRING] +# Maximum number of branch for function / method body +max-branches=20 -# This flag controls whether inconsistent-quotes generates a warning when the -# character used as a quote delimiter is used inconsistently within a module. -check-quote-consistency=no +# Maximum number of statements in function / method body +max-statements=100 -# This flag controls whether the implicit-str-concat should generate a warning -# on implicit string concatenation in sequences defined over several lines. -check-str-concat-over-line-jumps=no +# Maximum number of parents for a class (see R0901). +max-parents=7 +# Maximum number of nested blocks for function / method body +max-nested-blocks=10 -[IMPORTS] +# Maximum number of attributes for a class (see R0902). +max-attributes=20 -# List of modules that can be imported at any level, not just the top level -# one. -allow-any-import-level= +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 -# Allow wildcard imports from modules that define __all__. -allow-wildcard-with-all=no +# Maximum number of public methods for a class (see R0904). +max-public-methods=40 -# Analyse import fallback blocks. This can be used to support both Python 2 and -# 3 compatible code, which means that the block might have code that exists -# only in one or another interpreter, leading to false positives when analysed. -analyse-fallback-blocks=no -# Deprecated modules which should not be used, separated by a comma. -deprecated-modules=optparse,tkinter.tix +[IMPORTS] -# Create a graph of external dependencies in the given file (report RP0402 must -# not be disabled). -ext-import-graph= +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec # Create a graph of every (i.e. internal and external) dependencies in the -# given file (report RP0402 must not be disabled). +# given file (report RP0402 must not be disabled) import-graph= +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + # Create a graph of internal dependencies in the given file (report RP0402 must -# not be disabled). +# not be disabled) int-import-graph= -# Force import order to recognize a module as part of the standard -# compatibility libraries. -known-standard-library= - -# Force import order to recognize a module as part of a third party library. -known-third-party=enchant - -# Couples of modules and preferred modules, separated by a comma. -preferred-modules= - - -[CLASSES] - -# List of method names used to declare (i.e. assign) instance attributes. -defining-attr-methods=__init__, - __new__, - setUp, - __post_init__ - -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict, - _fields, - _replace, - _source, - _make - -# List of valid names for the first argument in a class method. -valid-classmethod-first-arg=cls - -# List of valid names for the first argument in a metaclass class method. -valid-metaclass-classmethod-first-arg=cls - - -[DESIGN] - -# Maximum number of arguments for function / method. -max-args=5 - -# Maximum number of attributes for a class (see R0902). -max-attributes=7 - -# Maximum number of boolean expressions in an if statement (see R0916). -max-bool-expr=5 - -# Maximum number of branch for function / method body. -max-branches=12 - -# Maximum number of locals for function / method body. -max-locals=15 - -# Maximum number of parents for a class (see R0901). -max-parents=7 - -# Maximum number of public methods for a class (see R0904). -max-public-methods=20 - -# Maximum number of return / yield for function / method body. -max-returns=6 - -# Maximum number of statements in function / method body. -max-statements=50 - -# Minimum number of public methods for a class (see R0903). -min-public-methods=2 - [EXCEPTIONS] # Exceptions that will emit a warning when being caught. Defaults to -# "BaseException, Exception". -overgeneral-exceptions=BaseException, - Exception +# "Exception" +# overgeneral-exceptions=Exception diff --git a/LICENSE b/LICENSE index 3ab8b53..2f4228f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2022, Francesco Foscarin, Greg Chapman +Copyright (c) 2022, 2023 Francesco Foscarin, Greg Chapman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 50cc46d..b6eeb50 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ musicdiff is derived from: [music-score-diff](https://github.com/fosfrancesco/mu by [Francesco Foscarin](https://github.com/fosfrancesco). ## Setup -Depends on [music21](https://pypi.org/project/music21) (version 8.1+), [numpy](https://pypi.org/project/numpy), and [converter21](https://pypi.org/project/converter21) (version 2.0+). You also will need to configure music21 (instructions [here](https://web.mit.edu/music21/doc/usersGuide/usersGuide_01_installing.html)) to display a musical score (e.g. with MuseScore). +Depends on [music21](https://pypi.org/project/music21) (version 9.1+), [numpy](https://pypi.org/project/numpy), and [converter21](https://pypi.org/project/converter21) (version 3.0+). You also will need to configure music21 (instructions [here](https://web.mit.edu/music21/doc/usersGuide/usersGuide_01_installing.html)) to display a musical score (e.g. with MuseScore). Requires Python 3.10+. ## Usage On the command line: diff --git a/ReleaseNotes_3.0.0.txt b/ReleaseNotes_3.0.0.txt new file mode 100644 index 0000000..5f73603 --- /dev/null +++ b/ReleaseNotes_3.0.0.txt @@ -0,0 +1,24 @@ +Changes since 2.0.1: + - Require music21 v9 (for several features/fixes) and converter21 v3 + (for improved Humdrum and MEI import) + - Remove all checks for music21 features (they're all there in v9) + - compare Mordent/Trill/Turn better, to include ornament accidentals + (newly supported in music21 v9) + - Compare metadata if requested (e.g. AllObjectsAndMetadata) + - compare StaffLayout.staffLines and StaffLayout.staffSize + - support tuplet.type = 'startStop' + - fix tie annotation + - finish tremolo annotation: + notice fingered tremolos (TremoloSpanner) + annotate tremolos as 'bTrem' (Tremolo, a.k.a. bowed tremolo, a.k.a. one note tremolo) + vs. 'fTrem' (TremoloSpanner, a.k.a. fingered tremolo, a.k.a. two note tremolo) + - compare rest positioning + - better comparison of RepeatBrackets (support .overrideDisplay) + - compare placement (above/below) of articulations and expressions (AllObjectsWithStyle) + - support comparison of StaffGroups as part of AllObjects. + StaffGroup bracket shape is relegated to AllObjectsWithStyle. + - compare (and fill and transpose) Ottavas, maintaining accidental display status + (newly possible in music21 v9) + - compare delayed turns (new in music21 v9) + - ignore redundant clefs during comparison + - compare tuplet number/bracket visibility and format (and placement if diffing WithStyle) diff --git a/docs/.nojekyll b/docs/.nojekyll deleted file mode 100644 index e69de29..0000000 diff --git a/docs/index.html b/docs/index.html index 1dd670f..66ce234 100644 --- a/docs/index.html +++ b/docs/index.html @@ -2,6 +2,6 @@ - + diff --git a/docs/musicdiff.html b/docs/musicdiff.html index 174b4be..2624a39 100644 --- a/docs/musicdiff.html +++ b/docs/musicdiff.html @@ -3,34 +3,34 @@ - + musicdiff API documentation - - - - - - -
-
+

musicdiff

-
- View Source -
# ------------------------------------------------------------------------------
-# Purpose:       musicdiff is a package for comparing music scores using music21.
-#
-# Authors:       Greg Chapman <gregc@mac.com>
-#                musicdiff is derived from:
-#                   https://github.com/fosfrancesco/music-score-diff.git
-#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
-#
-# Copyright:     (c) 2022 Francesco Foscarin, Greg Chapman
-# License:       MIT, see LICENSE
-# ------------------------------------------------------------------------------
-
-__docformat__ = "google"
-
-import sys
-import os
-from typing import Union, List, Tuple
-from pathlib import Path
-
-import music21 as m21
-
-from musicdiff.m21utils import M21Utils
-from musicdiff.m21utils import DetailLevel
-from musicdiff.annotation import AnnScore
-from musicdiff.comparison import Comparison
-from musicdiff.visualization import Visualization
-
-def _getInputExtensionsList() -> [str]:
-    c = m21.converter.Converter()
-    inList = c.subconvertersList('input')
-    result = []
-    for subc in inList:
-        for inputExt in subc.registerInputExtensions:
-            result.append('.' + inputExt)
-    return result
-
-def _printSupportedInputFormats():
-    c = m21.converter.Converter()
-    inList = c.subconvertersList('input')
-    print("Supported input formats are:", file=sys.stderr)
-    for subc in inList:
-        if subc.registerInputExtensions:
-            print('\tformats   : ' + ', '.join(subc.registerFormats)
-                    + '\textensions: ' + ', '.join(subc.registerInputExtensions), file=sys.stderr)
-
-def diff(score1: Union[str, Path, m21.stream.Score],
-         score2: Union[str, Path, m21.stream.Score],
-         out_path1:  Union[str, Path] = None,
-         out_path2:  Union[str, Path] = None,
-         force_parse: bool = True,
-         visualize_diffs: bool = True,
-         detail: DetailLevel = DetailLevel.Default
-        ) -> int:
-    '''
-    Compare two musical scores and optionally save/display the differences as two marked-up
-    rendered PDFs.
-
-    Args:
-        score1 (str, Path, music21.stream.Score): The first music score to compare. The score
-            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
-            etc), or a music21 Score object.
-        score2 (str, Path, music21.stream.Score): The second musical score to compare. The score
-            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
-            etc), or a music21 Score object.
-        out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
-            If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
-            (default is None)
-        out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
-            If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
-            (default is None)
-        force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed
-            previously.
-            (default is True)
-        visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,
-            the only result of the call will be the return value (the number of differences).
-            (default is True)
-        detail (DetailLevel): What level of detail to use during the diff.  Can be
-            GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-            currently equivalent to AllObjects).
-
-    Returns:
-        int: The number of differences found (0 means the scores were identical, None means the diff failed)
-    '''
-    badArg1: bool = False
-    badArg2: bool = False
-
-    # Convert input strings to Paths
-    if isinstance(score1, str):
-        try:
-            score1 = Path(score1)
-        except:
-            print(f'score1 ({score1}) is not a valid path.', file=sys.stderr)
-            badArg1 = True
-
-    if isinstance(score2, str):
-        try:
-            score2 = Path(score2)
-        except:
-            print(f'score2 ({score2}) is not a valid path.', file=sys.stderr)
-            badArg2 = True
-
-    if badArg1 or badArg2:
-        return None
-
-    if isinstance(score1, Path):
-        fileName1 = score1.name
-        fileExt1 = score1.suffix
-
-        if fileExt1 not in _getInputExtensionsList():
-            print(f'score1 file extension ({fileExt1}) not supported by music21.', file=sys.stderr)
-            badArg1 = True
-
-        if not badArg1:
-            # pylint: disable=broad-except
-            try:
-                score1 = m21.converter.parse(score1, forceSource = force_parse)
-            except Exception as e:
-                print(f'score1 ({fileName1}) could not be parsed by music21', file=sys.stderr)
-                print(e, file=sys.stderr)
-                badArg1 = True
-            # pylint: enable=broad-except
-
-    if isinstance(score2, Path):
-        fileName2: str = score2.name
-        fileExt2: str = score2.suffix
-
-        if fileExt2 not in _getInputExtensionsList():
-            print(f'score2 file extension ({fileExt2}) not supported by music21.', file=sys.stderr)
-            badArg2 = True
-
-        if not badArg2:
-            # pylint: disable=broad-except
-            try:
-                score2 = m21.converter.parse(score2, forceSource = force_parse)
-            except Exception as e:
-                print(f'score2 ({fileName2}) could not be parsed by music21', file=sys.stderr)
-                print(e, file=sys.stderr)
-                badArg2 = True
-            # pylint: enable=broad-except
-
-    if badArg1 or badArg2:
-        return None
-
-    # scan each score, producing an annotated wrapper
-    annotated_score1: AnnScore = AnnScore(score1, detail)
-    annotated_score2: AnnScore = AnnScore(score2, detail)
-
-    diff_list: List = None
-    _cost: int = None
-    diff_list, _cost = Comparison.annotated_scores_diff(annotated_score1, annotated_score2)
-
-    numDiffs: int = len(diff_list)
-    if visualize_diffs and numDiffs != 0:
-        # you can change these three colors as you like...
-        #Visualization.INSERTED_COLOR = 'red'
-        #Visualization.DELETED_COLOR = 'red'
-        #Visualization.CHANGED_COLOR = 'red'
-
-        # color changed/deleted/inserted notes, add descriptive text for each change, etc
-        Visualization.mark_diffs(score1, score2, diff_list)
-
-        # ask music21 to display the scores as PDFs.  Composer's name will be prepended with
-        # 'score1 ' and 'score2 ', respectively, so you can see which is which.
-        Visualization.show_diffs(score1, score2, out_path1, out_path2)
-
-    return numDiffs
-
- -
+ + + + +
  1# ------------------------------------------------------------------------------
+  2# Purpose:       musicdiff is a package for comparing music scores using music21.
+  3#
+  4# Authors:       Greg Chapman <gregc@mac.com>
+  5#                musicdiff is derived from:
+  6#                   https://github.com/fosfrancesco/music-score-diff.git
+  7#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
+  8#
+  9# Copyright:     (c) 2022, 2023 Francesco Foscarin, Greg Chapman
+ 10# License:       MIT, see LICENSE
+ 11# ------------------------------------------------------------------------------
+ 12
+ 13__docformat__ = "google"
+ 14
+ 15import sys
+ 16import os
+ 17import typing as t
+ 18from pathlib import Path
+ 19
+ 20import music21 as m21
+ 21import converter21
+ 22
+ 23from musicdiff.m21utils import M21Utils
+ 24from musicdiff.m21utils import DetailLevel
+ 25from musicdiff.annotation import AnnScore
+ 26from musicdiff.comparison import Comparison
+ 27from musicdiff.visualization import Visualization
+ 28
+ 29def _getInputExtensionsList() -> list[str]:
+ 30    c = m21.converter.Converter()
+ 31    inList = c.subconvertersList('input')
+ 32    result = []
+ 33    for subc in inList:
+ 34        for inputExt in subc.registerInputExtensions:
+ 35            result.append('.' + inputExt)
+ 36    return result
+ 37
+ 38def _printSupportedInputFormats() -> None:
+ 39    c = m21.converter.Converter()
+ 40    inList = c.subconvertersList('input')
+ 41    print("Supported input formats are:", file=sys.stderr)
+ 42    for subc in inList:
+ 43        if subc.registerInputExtensions:
+ 44            print('\tformats   : ' + ', '.join(subc.registerFormats)
+ 45                    + '\textensions: ' + ', '.join(subc.registerInputExtensions), file=sys.stderr)
+ 46
+ 47def diff(
+ 48    score1: str | Path | m21.stream.Score,
+ 49    score2: str | Path | m21.stream.Score,
+ 50    out_path1: str | Path | None = None,
+ 51    out_path2: str | Path | None = None,
+ 52    force_parse: bool = True,
+ 53    visualize_diffs: bool = True,
+ 54    detail: DetailLevel = DetailLevel.Default
+ 55) -> int | None:
+ 56    '''
+ 57    Compare two musical scores and optionally save/display the differences as two marked-up
+ 58    rendered PDFs.
+ 59
+ 60    Args:
+ 61        score1 (str, Path, music21.stream.Score): The first music score to compare. The score
+ 62            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
+ 63            etc), or a music21 Score object.
+ 64        score2 (str, Path, music21.stream.Score): The second musical score to compare. The score
+ 65            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
+ 66            etc), or a music21 Score object.
+ 67        out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
+ 68            If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
+ 69            (default is None)
+ 70        out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
+ 71            If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
+ 72            (default is None)
+ 73        force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed
+ 74            previously.
+ 75            (default is True)
+ 76        visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,
+ 77            the only result of the call will be the return value (the number of differences).
+ 78            (default is True)
+ 79        detail (DetailLevel): What level of detail to use during the diff.
+ 80            Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+ 81            GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+ 82            or Default (Default is currently equivalent to AllObjects).
+ 83
+ 84    Returns:
+ 85        int | None: The number of differences found (0 means the scores were identical,
+ 86            None means the diff failed)
+ 87    '''
+ 88    # Use the new Humdrum/MEI importers from converter21 in place of the ones in music21...
+ 89    # Comment out this line to go back to music21's built-in Humdrum/MEI importers.
+ 90    converter21.register()
+ 91
+ 92    badArg1: bool = False
+ 93    badArg2: bool = False
+ 94
+ 95    # Convert input strings to Paths
+ 96    if isinstance(score1, str):
+ 97        try:
+ 98            score1 = Path(score1)
+ 99        except Exception:  # pylint: disable=broad-exception-caught
+100            print(f'score1 ({score1}) is not a valid path.', file=sys.stderr)
+101            badArg1 = True
+102
+103    if isinstance(score2, str):
+104        try:
+105            score2 = Path(score2)
+106        except Exception:  # pylint: disable=broad-exception-caught
+107            print(f'score2 ({score2}) is not a valid path.', file=sys.stderr)
+108            badArg2 = True
+109
+110    if badArg1 or badArg2:
+111        return None
+112
+113    if isinstance(score1, Path):
+114        fileName1 = score1.name
+115        fileExt1 = score1.suffix
+116
+117        if fileExt1 not in _getInputExtensionsList():
+118            print(f'score1 file extension ({fileExt1}) not supported by music21.', file=sys.stderr)
+119            badArg1 = True
+120
+121        if not badArg1:
+122            # pylint: disable=broad-except
+123            try:
+124                sc = m21.converter.parse(score1, forceSource=force_parse)
+125                if t.TYPE_CHECKING:
+126                    assert isinstance(sc, m21.stream.Score)
+127                score1 = sc
+128
+129            except Exception as e:
+130                print(f'score1 ({fileName1}) could not be parsed by music21', file=sys.stderr)
+131                print(e, file=sys.stderr)
+132                badArg1 = True
+133            # pylint: enable=broad-except
+134
+135    if isinstance(score2, Path):
+136        fileName2: str = score2.name
+137        fileExt2: str = score2.suffix
+138
+139        if fileExt2 not in _getInputExtensionsList():
+140            print(f'score2 file extension ({fileExt2}) not supported by music21.', file=sys.stderr)
+141            badArg2 = True
+142
+143        if not badArg2:
+144            # pylint: disable=broad-except
+145            try:
+146                sc = m21.converter.parse(score2, forceSource=force_parse)
+147                if t.TYPE_CHECKING:
+148                    assert isinstance(sc, m21.stream.Score)
+149                score2 = sc
+150            except Exception as e:
+151                print(f'score2 ({fileName2}) could not be parsed by music21', file=sys.stderr)
+152                print(e, file=sys.stderr)
+153                badArg2 = True
+154            # pylint: enable=broad-except
+155
+156    if badArg1 or badArg2:
+157        return None
+158
+159    if t.TYPE_CHECKING:
+160        assert isinstance(score1, m21.stream.Score)
+161        assert isinstance(score2, m21.stream.Score)
+162
+163    # scan each score, producing an annotated wrapper
+164    annotated_score1: AnnScore = AnnScore(score1, detail)
+165    annotated_score2: AnnScore = AnnScore(score2, detail)
+166
+167    diff_list: list
+168    _cost: int
+169    diff_list, _cost = Comparison.annotated_scores_diff(annotated_score1, annotated_score2)
+170
+171    numDiffs: int = len(diff_list)
+172    if visualize_diffs and numDiffs != 0:
+173        # you can change these three colors as you like...
+174        # Visualization.INSERTED_COLOR = 'red'
+175        # Visualization.DELETED_COLOR = 'red'
+176        # Visualization.CHANGED_COLOR = 'red'
+177
+178        # color changed/deleted/inserted notes, add descriptive text for each change, etc
+179        Visualization.mark_diffs(score1, score2, diff_list)
+180
+181        # ask music21 to display the scores as PDFs.  Composer's name will be prepended with
+182        # 'score1 ' and 'score2 ', respectively, so you can see which is which.
+183        Visualization.show_diffs(score1, score2, out_path1, out_path2)
+184
+185    return numDiffs
+
+
-
#   - - - def - diff( - score1: Union[str, pathlib.Path, music21.stream.base.Score], - score2: Union[str, pathlib.Path, music21.stream.base.Score], - out_path1: Union[str, pathlib.Path] = None, - out_path2: Union[str, pathlib.Path] = None, - force_parse: bool = True, - visualize_diffs: bool = True, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -> int: + +
+ + def + diff( score1: str | pathlib.Path | music21.stream.base.Score, score2: str | pathlib.Path | music21.stream.base.Score, out_path1: str | pathlib.Path | None = None, out_path2: str | pathlib.Path | None = None, force_parse: bool = True, visualize_diffs: bool = True, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) -> int | None: + + +
+ +
 48def diff(
+ 49    score1: str | Path | m21.stream.Score,
+ 50    score2: str | Path | m21.stream.Score,
+ 51    out_path1: str | Path | None = None,
+ 52    out_path2: str | Path | None = None,
+ 53    force_parse: bool = True,
+ 54    visualize_diffs: bool = True,
+ 55    detail: DetailLevel = DetailLevel.Default
+ 56) -> int | None:
+ 57    '''
+ 58    Compare two musical scores and optionally save/display the differences as two marked-up
+ 59    rendered PDFs.
+ 60
+ 61    Args:
+ 62        score1 (str, Path, music21.stream.Score): The first music score to compare. The score
+ 63            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
+ 64            etc), or a music21 Score object.
+ 65        score2 (str, Path, music21.stream.Score): The second musical score to compare. The score
+ 66            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
+ 67            etc), or a music21 Score object.
+ 68        out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
+ 69            If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
+ 70            (default is None)
+ 71        out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
+ 72            If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
+ 73            (default is None)
+ 74        force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed
+ 75            previously.
+ 76            (default is True)
+ 77        visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,
+ 78            the only result of the call will be the return value (the number of differences).
+ 79            (default is True)
+ 80        detail (DetailLevel): What level of detail to use during the diff.
+ 81            Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+ 82            GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+ 83            or Default (Default is currently equivalent to AllObjects).
+ 84
+ 85    Returns:
+ 86        int | None: The number of differences found (0 means the scores were identical,
+ 87            None means the diff failed)
+ 88    '''
+ 89    # Use the new Humdrum/MEI importers from converter21 in place of the ones in music21...
+ 90    # Comment out this line to go back to music21's built-in Humdrum/MEI importers.
+ 91    converter21.register()
+ 92
+ 93    badArg1: bool = False
+ 94    badArg2: bool = False
+ 95
+ 96    # Convert input strings to Paths
+ 97    if isinstance(score1, str):
+ 98        try:
+ 99            score1 = Path(score1)
+100        except Exception:  # pylint: disable=broad-exception-caught
+101            print(f'score1 ({score1}) is not a valid path.', file=sys.stderr)
+102            badArg1 = True
+103
+104    if isinstance(score2, str):
+105        try:
+106            score2 = Path(score2)
+107        except Exception:  # pylint: disable=broad-exception-caught
+108            print(f'score2 ({score2}) is not a valid path.', file=sys.stderr)
+109            badArg2 = True
+110
+111    if badArg1 or badArg2:
+112        return None
+113
+114    if isinstance(score1, Path):
+115        fileName1 = score1.name
+116        fileExt1 = score1.suffix
+117
+118        if fileExt1 not in _getInputExtensionsList():
+119            print(f'score1 file extension ({fileExt1}) not supported by music21.', file=sys.stderr)
+120            badArg1 = True
+121
+122        if not badArg1:
+123            # pylint: disable=broad-except
+124            try:
+125                sc = m21.converter.parse(score1, forceSource=force_parse)
+126                if t.TYPE_CHECKING:
+127                    assert isinstance(sc, m21.stream.Score)
+128                score1 = sc
+129
+130            except Exception as e:
+131                print(f'score1 ({fileName1}) could not be parsed by music21', file=sys.stderr)
+132                print(e, file=sys.stderr)
+133                badArg1 = True
+134            # pylint: enable=broad-except
+135
+136    if isinstance(score2, Path):
+137        fileName2: str = score2.name
+138        fileExt2: str = score2.suffix
+139
+140        if fileExt2 not in _getInputExtensionsList():
+141            print(f'score2 file extension ({fileExt2}) not supported by music21.', file=sys.stderr)
+142            badArg2 = True
+143
+144        if not badArg2:
+145            # pylint: disable=broad-except
+146            try:
+147                sc = m21.converter.parse(score2, forceSource=force_parse)
+148                if t.TYPE_CHECKING:
+149                    assert isinstance(sc, m21.stream.Score)
+150                score2 = sc
+151            except Exception as e:
+152                print(f'score2 ({fileName2}) could not be parsed by music21', file=sys.stderr)
+153                print(e, file=sys.stderr)
+154                badArg2 = True
+155            # pylint: enable=broad-except
+156
+157    if badArg1 or badArg2:
+158        return None
+159
+160    if t.TYPE_CHECKING:
+161        assert isinstance(score1, m21.stream.Score)
+162        assert isinstance(score2, m21.stream.Score)
+163
+164    # scan each score, producing an annotated wrapper
+165    annotated_score1: AnnScore = AnnScore(score1, detail)
+166    annotated_score2: AnnScore = AnnScore(score2, detail)
+167
+168    diff_list: list
+169    _cost: int
+170    diff_list, _cost = Comparison.annotated_scores_diff(annotated_score1, annotated_score2)
+171
+172    numDiffs: int = len(diff_list)
+173    if visualize_diffs and numDiffs != 0:
+174        # you can change these three colors as you like...
+175        # Visualization.INSERTED_COLOR = 'red'
+176        # Visualization.DELETED_COLOR = 'red'
+177        # Visualization.CHANGED_COLOR = 'red'
+178
+179        # color changed/deleted/inserted notes, add descriptive text for each change, etc
+180        Visualization.mark_diffs(score1, score2, diff_list)
+181
+182        # ask music21 to display the scores as PDFs.  Composer's name will be prepended with
+183        # 'score1 ' and 'score2 ', respectively, so you can see which is which.
+184        Visualization.show_diffs(score1, score2, out_path1, out_path2)
+185
+186    return numDiffs
+
-
- View Source -
def diff(score1: Union[str, Path, m21.stream.Score],
-         score2: Union[str, Path, m21.stream.Score],
-         out_path1:  Union[str, Path] = None,
-         out_path2:  Union[str, Path] = None,
-         force_parse: bool = True,
-         visualize_diffs: bool = True,
-         detail: DetailLevel = DetailLevel.Default
-        ) -> int:
-    '''
-    Compare two musical scores and optionally save/display the differences as two marked-up
-    rendered PDFs.
-
-    Args:
-        score1 (str, Path, music21.stream.Score): The first music score to compare. The score
-            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
-            etc), or a music21 Score object.
-        score2 (str, Path, music21.stream.Score): The second musical score to compare. The score
-            can be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,
-            etc), or a music21 Score object.
-        out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
-            If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
-            (default is None)
-        out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
-            If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
-            (default is None)
-        force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed
-            previously.
-            (default is True)
-        visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,
-            the only result of the call will be the return value (the number of differences).
-            (default is True)
-        detail (DetailLevel): What level of detail to use during the diff.  Can be
-            GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-            currently equivalent to AllObjects).
-
-    Returns:
-        int: The number of differences found (0 means the scores were identical, None means the diff failed)
-    '''
-    badArg1: bool = False
-    badArg2: bool = False
-
-    # Convert input strings to Paths
-    if isinstance(score1, str):
-        try:
-            score1 = Path(score1)
-        except:
-            print(f'score1 ({score1}) is not a valid path.', file=sys.stderr)
-            badArg1 = True
-
-    if isinstance(score2, str):
-        try:
-            score2 = Path(score2)
-        except:
-            print(f'score2 ({score2}) is not a valid path.', file=sys.stderr)
-            badArg2 = True
-
-    if badArg1 or badArg2:
-        return None
-
-    if isinstance(score1, Path):
-        fileName1 = score1.name
-        fileExt1 = score1.suffix
-
-        if fileExt1 not in _getInputExtensionsList():
-            print(f'score1 file extension ({fileExt1}) not supported by music21.', file=sys.stderr)
-            badArg1 = True
-
-        if not badArg1:
-            # pylint: disable=broad-except
-            try:
-                score1 = m21.converter.parse(score1, forceSource = force_parse)
-            except Exception as e:
-                print(f'score1 ({fileName1}) could not be parsed by music21', file=sys.stderr)
-                print(e, file=sys.stderr)
-                badArg1 = True
-            # pylint: enable=broad-except
-
-    if isinstance(score2, Path):
-        fileName2: str = score2.name
-        fileExt2: str = score2.suffix
-
-        if fileExt2 not in _getInputExtensionsList():
-            print(f'score2 file extension ({fileExt2}) not supported by music21.', file=sys.stderr)
-            badArg2 = True
-
-        if not badArg2:
-            # pylint: disable=broad-except
-            try:
-                score2 = m21.converter.parse(score2, forceSource = force_parse)
-            except Exception as e:
-                print(f'score2 ({fileName2}) could not be parsed by music21', file=sys.stderr)
-                print(e, file=sys.stderr)
-                badArg2 = True
-            # pylint: enable=broad-except
-
-    if badArg1 or badArg2:
-        return None
-
-    # scan each score, producing an annotated wrapper
-    annotated_score1: AnnScore = AnnScore(score1, detail)
-    annotated_score2: AnnScore = AnnScore(score2, detail)
-
-    diff_list: List = None
-    _cost: int = None
-    diff_list, _cost = Comparison.annotated_scores_diff(annotated_score1, annotated_score2)
-
-    numDiffs: int = len(diff_list)
-    if visualize_diffs and numDiffs != 0:
-        # you can change these three colors as you like...
-        #Visualization.INSERTED_COLOR = 'red'
-        #Visualization.DELETED_COLOR = 'red'
-        #Visualization.CHANGED_COLOR = 'red'
-
-        # color changed/deleted/inserted notes, add descriptive text for each change, etc
-        Visualization.mark_diffs(score1, score2, diff_list)
-
-        # ask music21 to display the scores as PDFs.  Composer's name will be prepended with
-        # 'score1 ' and 'score2 ', respectively, so you can see which is which.
-        Visualization.show_diffs(score1, score2, out_path1, out_path2)
-
-    return numDiffs
-
- -

Compare two musical scores and optionally save/display the differences as two marked-up rendered PDFs.

-
Args
+
Arguments:
  • score1 (str, Path, music21.stream.Score): The first music score to compare. The score @@ -391,15 +421,17 @@
    Args
  • visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False, the only result of the call will be the return value (the number of differences). (default is True)
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
-
Returns
+
Returns:
-

int: The number of differences found (0 means the scores were identical, None means the diff failed)

+

int | None: The number of differences found (0 means the scores were identical, + None means the diff failed)

@@ -506,9 +538,13 @@
Returns
} let heading; - switch (result.doc.type) { + switch (result.doc.kind) { case "function": - heading = `${doc.funcdef} ${doc.fullname}${doc.signature}:`; + if (doc.fullname.endsWith(".__init__")) { + heading = `${doc.fullname.replace(/\.__init__$/, "")}${doc.signature}`; + } else { + heading = `${doc.funcdef} ${doc.fullname}${doc.signature}`; + } break; case "class": heading = `class ${doc.fullname}`; @@ -521,7 +557,7 @@
Returns
if (doc.annotation) heading += `${doc.annotation}`; if (doc.default_value) - heading += `${doc.default_value}`; + heading += ` = ${doc.default_value}`; break; default: heading = `${doc.fullname}`; @@ -529,7 +565,7 @@
Returns
} html += `
- ${heading} + ${heading}
${doc.doc}
`; diff --git a/docs/musicdiff/__main__.html b/docs/musicdiff/__main__.html deleted file mode 100644 index afbdd1c..0000000 --- a/docs/musicdiff/__main__.html +++ /dev/null @@ -1,298 +0,0 @@ - - - - - - - musicdiff.__main__ API documentation - - - - - - - - -
-
-

-musicdiff.__main__

- - -
- View Source -
# ------------------------------------------------------------------------------
-# Purpose:       __main__.py is a music file comparison tool built on musicdiff.
-#                musicdiff is a package for comparing music scores using music21.
-#                Usage:
-#                   python3 -m musicdiff filePath1 filePath2
-#
-# Authors:       Greg Chapman <gregc@mac.com>
-#                musicdiff is derived from:
-#                   https://github.com/fosfrancesco/music-score-diff.git
-#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
-#
-# Copyright:     (c) 2022 Francesco Foscarin, Greg Chapman
-# License:       MIT, see LICENSE
-# ------------------------------------------------------------------------------
-import sys
-import argparse
-
-from musicdiff import diff
-from musicdiff import DetailLevel
-
-# To use the new Humdrum importer from converter21 in place of the one in music21:
-# pip install converter21
-# Then uncomment all lines in this file marked "# c21"
-# import music21 as m21 # c21
-# from converter21 import HumdrumConverter # c21
-
-# ------------------------------------------------------------------------------
-
-'''
-    main entry point (parse arguments and do conversion)
-'''
-if __name__ == "__main__":
-
-    # to use the new Humdrum importer from converter21 in place of the one in music21...
-    # m21.converter.unregisterSubconverter(m21.converter.subConverters.ConverterHumdrum) # c21
-    # m21.converter.registerSubconverter(HumdrumConverter)                               # c21
-    # print('registered converter21 humdrum importer', file=sys.stderr)                  # c21
-
-    parser = argparse.ArgumentParser(
-                prog='python3 -m musicdiff',
-                description='Music score notation diff (MusicXML, MEI, Humdrum, etc)')
-    parser.add_argument("file1",
-                        help="first music score file to compare (any format music21 can parse)")
-    parser.add_argument("file2",
-                        help="second music score file to compare (any format music21 can parse)")
-    parser.add_argument("-d", "--detail", default="Default",
-                        choices=["GeneralNotesOnly", "AllObjects", "AllObjectsWithStyle", "Default"],
-                        help="set detail level")
-    args = parser.parse_args()
-
-    detail: DetailLevel = DetailLevel.Default
-    if args.detail == "GeneralNotesOnly":
-        detail = DetailLevel.GeneralNotesOnly
-    elif args.detail == "AllObjects":
-        detail = DetailLevel.AllObjects
-    elif args.detail == "AllObjectsWithStyle":
-        detail = DetailLevel.AllObjectsWithStyle
-    elif args.detail == "Default":
-        detail = DetailLevel.Default
-
-    # Note that diff() can take a music21 Score instead of a file, for either
-    # or both arguments.
-    # Note also that diff() can take str or pathlib.Path for files.
-    numDiffs: int = diff(args.file1, args.file2, detail=detail)
-    if numDiffs is not None and numDiffs == 0:
-        print(f'Scores in {args.file1} and {args.file2} are identical.', file=sys.stderr)
-
- -
- -
-
- - \ No newline at end of file diff --git a/docs/musicdiff/annotation.html b/docs/musicdiff/annotation.html index bb930c1..1ac9122 100644 --- a/docs/musicdiff/annotation.html +++ b/docs/musicdiff/annotation.html @@ -3,39 +3,90 @@ - + musicdiff.annotation API documentation - - - - - - - -
-
+

musicdiff.annotation

-
- View Source -
# ------------------------------------------------------------------------------
-# Purpose:       notation is a set of annotated music21 notation wrappers for use
-#                by musicdiff.
-#                musicdiff is a package for comparing music scores using music21.
-#
-# Authors:       Greg Chapman <gregc@mac.com>
-#                musicdiff is derived from:
-#                   https://github.com/fosfrancesco/music-score-diff.git
-#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
-#
-# Copyright:     (c) 2022 Francesco Foscarin, Greg Chapman
-# License:       MIT, see LICENSE
-# ------------------------------------------------------------------------------
-
-__docformat__ = "google"
-
-from fractions import Fraction
-from typing import Optional
-
-import music21 as m21
-
-from musicdiff import M21Utils
-from musicdiff import DetailLevel
-
-class AnnNote:
-    def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuplet_list, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 GeneralNote with some precomputed, easily compared information about it.
-
-        Args:
-            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
-            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
-            tuplet_list (list): A list of tuplet info about this GeneralNote.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-
-        """
-        self.general_note = general_note.id
-        self.beamings = enhanced_beam_list
-        self.tuplets = tuplet_list
-
-        self.stylestr: str = ''
-        self.styledict: dict = {}
-        if M21Utils.has_style(general_note):
-            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
-        self.noteshape: str = 'normal'
-        self.noteheadFill: Optional[bool] = None
-        self.noteheadParenthesis: bool = False
-        self.stemDirection: str = 'unspecified'
-        if detail >= DetailLevel.AllObjectsWithStyle and isinstance(general_note, m21.note.NotRest):
-            self.noteshape = general_note.notehead
-            self.noteheadFill = general_note.noteheadFill
-            self.noteheadParenthesis = general_note.noteheadParenthesis
-            self.stemDirection = general_note.stemDirection
-
-        # compute the representation of NoteNode as in the paper
-        # pitches is a list  of elements, each one is (pitchposition, accidental, tie)
-        if general_note.isRest:
-            self.pitches = [
-                ("R", "None", False)
-            ]  # accidental and tie are automaticaly set for rests
-        elif general_note.isChord or "ChordBase" in general_note.classSet:
-            # ChordBase/PercussionChord is new in v7, so I am being careful to use
-            # it only as a string so v6 will still work.
-            noteList: [m21.note.GeneralNote] = general_note.notes
-            if hasattr(general_note, "sortDiatonicAscending"): # PercussionChords don't have this
-                noteList = general_note.sortDiatonicAscending().notes
-            self.pitches = [
-                M21Utils.note2tuple(p) for p in noteList
-            ]
-        elif general_note.isNote or isinstance(general_note, m21.note.Unpitched):
-            self.pitches = [M21Utils.note2tuple(general_note)]
-        else:
-            raise TypeError("The generalNote must be a Chord, a Rest or a Note")
-        # note head
-        type_number = Fraction(
-            M21Utils.get_type_num(general_note.duration)
-        )
-        if type_number >= 4:
-            self.note_head = 4
-        else:
-            self.note_head = type_number
-        # dots
-        self.dots = general_note.duration.dots
-        # articulations
-        self.articulations = [a.name for a in general_note.articulations]
-        if self.articulations:
-            self.articulations.sort()
-        # expressions
-        self.expressions = [a.name for a in general_note.expressions]
-        if self.expressions:
-            self.expressions.sort()
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
-
-        Returns:
-            int: The notation size of the annotated note
-        """
-        size = 0
-        # add for the pitches
-        for pitch in self.pitches:
-            size += M21Utils.pitch_size(pitch)
-        # add for the dots
-        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
-        # add for the beamings
-        size += len(self.beamings)
-        # add for the tuplets
-        size += len(self.tuplets)
-        # add for the articulations
-        size += len(self.articulations)
-        # add for the expressions
-        size += len(self.expressions)
-        return size
-
-    def __repr__(self):
-        # does consider the MEI id!
-        return (f"{self.pitches},{self.note_head},{self.dots},{self.beamings}," +
-                f"{self.tuplets},{self.general_note},{self.articulations},{self.expressions}" +
-                f"{self.styledict}")
-
-    def __str__(self):
-        """
-        Returns:
-            str: the representation of the Annotated note. Does not consider MEI id
-        """
-        string = "["
-        for p in self.pitches:  # add for pitches
-            string += p[0]
-            if p[1] != "None":
-                string += p[1]
-            if p[2]:
-                string += "T"
-            string += ","
-        string = string[:-1]  # delete the last comma
-        string += "]"
-        string += str(self.note_head)  # add for notehead
-        for _ in range(self.dots):  # add for dots
-            string += "*"
-        if len(self.beamings) > 0:  # add for beaming
-            string += "B"
-            for b in self.beamings:
-                if b == "start":
-                    string += "sr"
-                elif b == "continue":
-                    string += "co"
-                elif b == "stop":
-                    string += "sp"
-                elif b == "partial":
-                    string += "pa"
-                else:
-                    raise Exception(f"Incorrect beaming type: {b}")
-        if len(self.tuplets) > 0:  # add for tuplets
-            string += "T"
-            for t in self.tuplets:
-                if t == "start":
-                    string += "sr"
-                elif t == "continue":
-                    string += "co"
-                elif t == "stop":
-                    string += "sp"
-                else:
-                    raise Exception(f"Incorrect tuplets type: {t}")
-        if len(self.articulations) > 0:  # add for articulations
-            for a in self.articulations:
-                string += a
-        if len(self.expressions) > 0:  # add for articulations
-            for e in self.expressions:
-                string += e
-
-        if self.noteshape != 'normal':
-            string += f"noteshape={self.noteshape}"
-        if self.noteheadFill is not None:
-            string += f"noteheadFill={self.noteheadFill}"
-        if self.noteheadParenthesis:
-            string += f"noteheadParenthesis={self.noteheadParenthesis}"
-        if self.stemDirection != 'unspecified':
-            string += f"stemDirection={self.stemDirection}"
-
-        # and then the style fields
-        for i, (k, v) in enumerate(self.styledict.items()):
-            if i > 0:
-                string += ","
-            string += f"{k}={v}"
-
-        return string
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
-        is only one GeneralNote here, this will always be a single-element list.
-
-        Returns:
-            [int]: A list containing the single GeneralNote id for this note.
-        """
-        return [self.general_note]
-
-    def __eq__(self, other):
-        # equality does not consider the MEI id!
-        return self.precomputed_str == other.precomputed_str
-
-        # if not isinstance(other, AnnNote):
-        #     return False
-        # elif self.pitches != other.pitches:
-        #     return False
-        # elif self.note_head != other.note_head:
-        #     return False
-        # elif self.dots != other.dots:
-        #     return False
-        # elif self.beamings != other.beamings:
-        #     return False
-        # elif self.tuplets != other.tuplets:
-        #     return False
-        # elif self.articulations != other.articulations:
-        #     return False
-        # elif self.expressions != other.expressions:
-        #     return False
-        # else:
-        #     return True
-
-
-class AnnExtra:
-    def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it.
-        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
-
-        Args:
-            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
-            measure (music21.stream.Measure): The music21 Measure the extra was found in.  If the extra
-                was found in a Voice, this is the Measure that the Voice was found in.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.extra = extra.id
-        self.offset: float
-        self.duration: float
-        if isinstance(extra, m21.spanner.Spanner):
-            firstNote: m21.note.GeneralNote = extra.getFirst()
-            lastNote: m21.note.GeneralNote = extra.getLast()
-            self.offset = float(firstNote.getOffsetInHierarchy(measure))
-            # to compute duration we need to use offset-in-score, since the end note might be in another Measure
-            startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
-            endOffsetInScore: float = float(lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength)
-            self.duration = endOffsetInScore - startOffsetInScore
-        else:
-            self.offset = float(extra.getOffsetInHierarchy(measure))
-            self.duration = float(extra.duration.quarterLength)
-        self.content: str = M21Utils.extra_to_string(extra)
-        self.styledict: str = {}
-        if M21Utils.has_style(extra):
-            self.styledict = M21Utils.obj_to_styledict(extra, detail) # includes extra.placement if present
-        self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
-
-        Returns:
-            int: The notation size of the annotated extra
-        """
-        return self._notation_size
-
-    def __repr__(self):
-        return str(self)
-
-    def __str__(self):
-        """
-        Returns:
-            str: the compared representation of the AnnExtra. Does not consider music21 id.
-        """
-        string = f'{self.content},off={self.offset},dur={self.duration}'
-        # and then any style fields
-        for k, v in self.styledict.items():
-            string += f",{k}={v}"
-        return string
-
-    def __eq__(self, other):
-        # equality does not consider the MEI id!
-        return self.precomputed_str == other.precomputed_str
-
-
-class AnnVoice:
-    def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Voice with some precomputed, easily compared information about it.
-
-        Args:
-            voice (music21.stream.Voice): The music21 voice to extend.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.voice = voice.id
-        note_list = M21Utils.get_notes(voice)
-        if not note_list:
-            self.en_beam_list = []
-            self.tuplet_list = []
-            self.tuple_info = []
-            self.annot_notes = []
-        else:
-            self.en_beam_list = M21Utils.get_enhance_beamings(
-                note_list
-            )  # beams and type (type for note shorter than quarter notes)
-            self.tuplet_list = M21Utils.get_tuplets_type(
-                note_list
-            )  # corrected tuplets (with "start" and "continue")
-            self.tuple_info = M21Utils.get_tuplets_info(note_list)
-            # create a list of notes with beaming and tuplets information attached
-            self.annot_notes = []
-            for i, n in enumerate(note_list):
-                self.annot_notes.append(
-                    AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail)
-                )
-
-        self.n_of_notes = len(self.annot_notes)
-        self.precomputed_str = self.__str__()
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnVoice):
-            return False
-
-        if len(self.annot_notes) != len(other.annot_notes):
-            return False
-
-        return self.precomputed_str == other.precomputed_str
-        # return all(
-        #     [an[0] == an[1] for an in zip(self.annot_notes, other.annot_notes)]
-        # )
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
-
-        Returns:
-            int: The notation size of the annotated voice
-        """
-        return sum([an.notation_size() for an in self.annot_notes])
-
-    def __repr__(self):
-        return self.annot_notes.__repr__()
-
-    def __str__(self):
-        string = "["
-        for an in self.annot_notes:
-            string += str(an)
-            string += ","
-
-        if string[-1] == ",":
-            string = string[:-1] # delete the last comma
-
-        string += "]"
-        return string
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnVoice`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this voice
-        """
-        return [an.general_note for an in self.annot_notes]
-
-
-class AnnMeasure:
-    def __init__(self, measure: m21.stream.Measure,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Measure with some precomputed, easily compared information about it.
-
-        Args:
-            measure (music21.stream.Measure): The music21 measure to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.measure = measure.id
-        self.voices_list = []
-        if (
-            len(measure.voices) == 0
-        ):  # there is a single AnnVoice ( == for the library there are no voices)
-            ann_voice = AnnVoice(measure, detail)
-            if ann_voice.n_of_notes > 0:
-                self.voices_list.append(ann_voice)
-        else:  # there are multiple voices (or an array with just one voice)
-            for voice in measure.voices:
-                ann_voice = AnnVoice(voice, detail)
-                if ann_voice.n_of_notes > 0:
-                    self.voices_list.append(ann_voice)
-        self.n_of_voices = len(self.voices_list)
-
-        self.extras_list = []
-        if detail >= DetailLevel.AllObjects:
-            for extra in M21Utils.get_extras(measure, spannerBundle):
-                self.extras_list.append(AnnExtra(extra, measure, score, detail))
-
-            # For correct comparison, sort the extras_list, so that any list slices
-            # that all have the same offset are sorted alphabetically.
-            self.extras_list.sort(key=lambda e: ( e.offset, str(e) ))
-
-        # precomputed values to speed up the computation. As they start to be long, they are hashed
-        self.precomputed_str = hash(self.__str__())
-        self.precomputed_repr = hash(self.__repr__())
-
-    def __str__(self):
-        return str([str(v) for v in self.voices_list]) + ' Extras:' + str([str(e) for e in self.extras_list])
-
-    def __repr__(self):
-        return self.voices_list.__repr__() + ' Extras:' + self.extras_list.__repr__()
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnMeasure):
-            return False
-
-        if len(self.voices_list) != len(other.voices_list):
-            return False
-
-        if len(self.extras_list) != len(other.extras_list):
-            return False
-
-        return self.precomputed_str == other.precomputed_str
-        # return all([v[0] == v[1] for v in zip(self.voices_list, other.voices_list)])
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
-
-        Returns:
-            int: The notation size of the annotated measure
-        """
-        return sum([v.notation_size() for v in self.voices_list]) + sum([e.notation_size() for e in self.extras_list])
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnMeasure`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this measure
-        """
-        notes_id = []
-        for v in self.voices_list:
-            notes_id.extend(v.get_note_ids())
-        return notes_id
-
-
-class AnnPart:
-    def __init__(self, part: m21.stream.Part,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
-
-        Args:
-            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.part = part.id
-        self.bar_list = []
-        for measure in part.getElementsByClass("Measure"):
-            ann_bar = AnnMeasure(measure, score, spannerBundle, detail)  # create the bar objects
-            if ann_bar.n_of_voices > 0:
-                self.bar_list.append(ann_bar)
-        self.n_of_bars = len(self.bar_list)
-        # precomputed str to speed up the computation. String itself start to be long, so it is hashed
-        self.precomputed_str = hash(self.__str__())
-
-    def __str__(self):
-        return str([str(b) for b in self.bar_list])
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnPart):
-            return False
-
-        if len(self.bar_list) != len(other.bar_list):
-            return False
-
-        return all(b[0] == b[1] for b in zip(self.bar_list, other.bar_list))
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
-
-        Returns:
-            int: The notation size of the annotated part
-        """
-        return sum([b.notation_size() for b in self.bar_list])
-
-    def __repr__(self):
-        return self.bar_list.__repr__()
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnPart`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this part
-        """
-        notes_id = []
-        for b in self.bar_list:
-            notes_id.extend(b.get_note_ids())
-        return notes_id
-
-
-class AnnScore:
-    def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Take a music21 score and store it as a sequence of Full Trees.
-        The hierarchy is "score -> parts -> measures -> voices -> notes"
-        Args:
-            score (music21.stream.Score): The music21 score
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.score = score.id
-        self.part_list = []
-        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
-        for part in score.parts.stream():
-            # create and add the AnnPart object to part_list
-            ann_part = AnnPart(part, score, spannerBundle, detail)
-            if ann_part.n_of_bars > 0:
-                self.part_list.append(ann_part)
-        self.n_of_parts = len(self.part_list)
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnScore):
-            return False
-
-        if len(self.part_list) != len(other.part_list):
-            return False
-
-        return all(p[0] == p[1] for p in zip(self.part_list, other.part_list))
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
-
-        Returns:
-            int: The notation size of the annotated score
-        """
-        return sum([p.notation_size() for p in self.part_list])
-
-    def __repr__(self):
-        return self.part_list.__repr__()
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnScore`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this score
-        """
-        notes_id = []
-        for p in self.part_list:
-            notes_id.extend(p.get_note_ids())
-        return notes_id
-
-    # return the sequences of measures for a specified part
-    def _measures_from_part(self, part_number):
-        # only used by tests/test_scl.py
-        if part_number not in range(0, len(self.part_list)):
-            raise Exception(
-                f"parameter 'part_number' should be between 0 and {len(self.part_list) - 1}"
-            )
-        return self.part_list[part_number].bar_list
-
- -
+ + + + +
  1# ------------------------------------------------------------------------------
+  2# Purpose:       notation is a set of annotated music21 notation wrappers for use
+  3#                by musicdiff.
+  4#                musicdiff is a package for comparing music scores using music21.
+  5#
+  6# Authors:       Greg Chapman <gregc@mac.com>
+  7#                musicdiff is derived from:
+  8#                   https://github.com/fosfrancesco/music-score-diff.git
+  9#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
+ 10#
+ 11# Copyright:     (c) 2022, 2023 Francesco Foscarin, Greg Chapman
+ 12# License:       MIT, see LICENSE
+ 13# ------------------------------------------------------------------------------
+ 14
+ 15__docformat__ = "google"
+ 16
+ 17from fractions import Fraction
+ 18
+ 19import typing as t
+ 20
+ 21import music21 as m21
+ 22
+ 23from musicdiff import M21Utils
+ 24from musicdiff import DetailLevel
+ 25
+ 26class AnnNote:
+ 27    def __init__(
+ 28        self,
+ 29        general_note: m21.note.GeneralNote,
+ 30        enhanced_beam_list: list[str],
+ 31        tuplet_list: list[str],
+ 32        tuplet_info: list[str],
+ 33        detail: DetailLevel = DetailLevel.Default
+ 34    ) -> None:
+ 35        """
+ 36        Extend music21 GeneralNote with some precomputed, easily compared information about it.
+ 37
+ 38        Args:
+ 39            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
+ 40            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
+ 41            tuplet_list (list): A list of tuplet info about this GeneralNote.
+ 42            detail (DetailLevel): What level of detail to use during the diff.
+ 43                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+ 44                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+ 45                or Default (Default is currently equivalent to AllObjects).
+ 46
+ 47        """
+ 48        self.general_note: int | str = general_note.id
+ 49        self.beamings: list[str] = enhanced_beam_list
+ 50        self.tuplets: list[str] = tuplet_list
+ 51        self.tuplet_info: list[str] = tuplet_info
+ 52
+ 53        self.stylestr: str = ''
+ 54        self.styledict: dict = {}
+ 55        if M21Utils.has_style(general_note):
+ 56            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
+ 57        self.noteshape: str = 'normal'
+ 58        self.noteheadFill: bool | None = None
+ 59        self.noteheadParenthesis: bool = False
+ 60        self.stemDirection: str = 'unspecified'
+ 61        if DetailLevel.includesStyle(detail) and isinstance(general_note, m21.note.NotRest):
+ 62            self.noteshape = general_note.notehead
+ 63            self.noteheadFill = general_note.noteheadFill
+ 64            self.noteheadParenthesis = general_note.noteheadParenthesis
+ 65            self.stemDirection = general_note.stemDirection
+ 66
+ 67        # compute the representation of NoteNode as in the paper
+ 68        # pitches is a list  of elements, each one is (pitchposition, accidental, tied)
+ 69        self.pitches: list[tuple[str, str, bool]]
+ 70        if isinstance(general_note, m21.chord.ChordBase):
+ 71            notes: tuple[m21.note.NotRest, ...] = general_note.notes
+ 72            if hasattr(general_note, "sortDiatonicAscending"):
+ 73                # PercussionChords don't have this
+ 74                notes = general_note.sortDiatonicAscending().notes
+ 75            self.pitches = []
+ 76            for p in notes:
+ 77                if not isinstance(p, (m21.note.Note, m21.note.Unpitched)):
+ 78                    raise TypeError("The chord must contain only Note or Unpitched")
+ 79                self.pitches.append(M21Utils.note2tuple(p, detail))
+ 80
+ 81        elif isinstance(general_note, (m21.note.Note, m21.note.Unpitched, m21.note.Rest)):
+ 82            self.pitches = [M21Utils.note2tuple(general_note, detail)]
+ 83        else:
+ 84            raise TypeError("The generalNote must be a Chord, a Rest, a Note, or an Unpitched")
+ 85
+ 86        # note head
+ 87        type_number = Fraction(
+ 88            M21Utils.get_type_num(general_note.duration)
+ 89        )
+ 90        self.note_head: int | Fraction
+ 91        if type_number >= 4:
+ 92            self.note_head = 4
+ 93        else:
+ 94            self.note_head = type_number
+ 95        # dots
+ 96        self.dots: int = general_note.duration.dots
+ 97        # graceness
+ 98        if isinstance(general_note.duration, m21.duration.AppoggiaturaDuration):
+ 99            self.graceType: str = 'acc'
+100            self.graceSlash: bool | None = general_note.duration.slash
+101        elif isinstance(general_note.duration, m21.duration.GraceDuration):
+102            self.graceType = 'nonacc'
+103            self.graceSlash = general_note.duration.slash
+104        else:
+105            self.graceType = ''
+106            self.graceSlash = False
+107        # articulations
+108        self.articulations: list[str] = [
+109            M21Utils.articulation_to_string(a, detail) for a in general_note.articulations
+110        ]
+111        if self.articulations:
+112            self.articulations.sort()
+113        # expressions
+114        self.expressions: list[str] = [
+115            M21Utils.expression_to_string(a, detail) for a in general_note.expressions
+116        ]
+117        if self.expressions:
+118            self.expressions.sort()
+119
+120        # lyrics
+121        self.lyrics: list[str] = []
+122        for lyric in general_note.lyrics:
+123            lyricStr: str = ""
+124            if lyric.number is not None:
+125                lyricStr += f"number={lyric.number}"
+126            if lyric._identifier is not None:
+127                lyricStr += f" identifier={lyric._identifier}"
+128            if lyric.syllabic is not None:
+129                lyricStr += f" syllabic={lyric.syllabic}"
+130            if lyric.text is not None:
+131                lyricStr += f" text={lyric.text}"
+132            lyricStr += f" rawText={lyric.rawText}"
+133            if M21Utils.has_style(lyric):
+134                lyricStr += f" style={M21Utils.obj_to_styledict(lyric, detail)}"
+135            self.lyrics.append(lyricStr)
+136
+137        # precomputed representations for faster comparison
+138        self.precomputed_str: str = self.__str__()
+139
+140    def notation_size(self) -> int:
+141        """
+142        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
+143
+144        Returns:
+145            int: The notation size of the annotated note
+146        """
+147        size: int = 0
+148        # add for the pitches
+149        for pitch in self.pitches:
+150            size += M21Utils.pitch_size(pitch)
+151        # add for the dots
+152        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
+153        # add for the beamings
+154        size += len(self.beamings)
+155        # add for the tuplets
+156        size += len(self.tuplets)
+157        # add for the articulations
+158        size += len(self.articulations)
+159        # add for the expressions
+160        size += len(self.expressions)
+161        # add for the lyrics
+162        size += len(self.lyrics)
+163        return size
+164
+165    def __repr__(self) -> str:
+166        # does consider the MEI id!
+167        return (
+168            f"{self.pitches},{self.note_head},{self.dots},B:{self.beamings},"
+169            + f"T:{self.tuplets},TI:{self.tuplet_info},{self.general_note},"
+170            + f"{self.articulations},{self.expressions},{self.lyrics},{self.styledict}"
+171        )
+172
+173    def __str__(self) -> str:
+174        """
+175        Returns:
+176            str: the representation of the Annotated note. Does not consider MEI id
+177        """
+178        string: str = "["
+179        for p in self.pitches:  # add for pitches
+180            string += p[0]
+181            if p[1] != "None":
+182                string += p[1]
+183            if p[2]:
+184                string += "T"
+185            string += ","
+186        string = string[:-1]  # delete the last comma
+187        string += "]"
+188        string += str(self.note_head)  # add for notehead
+189        for _ in range(self.dots):  # add for dots
+190            string += "*"
+191        if self.graceType:
+192            string += self.graceType
+193            if self.graceSlash:
+194                string += '/'
+195        if len(self.beamings) > 0:  # add for beaming
+196            string += "B"
+197            for b in self.beamings:
+198                if b == "start":
+199                    string += "sr"
+200                elif b == "continue":
+201                    string += "co"
+202                elif b == "stop":
+203                    string += "sp"
+204                elif b == "partial":
+205                    string += "pa"
+206                else:
+207                    raise ValueError(f"Incorrect beaming type: {b}")
+208
+209        if len(self.tuplets) > 0:  # add for tuplets
+210            string += "T"
+211            for tup, ti in zip(self.tuplets, self.tuplet_info):
+212                if ti != "":
+213                    ti = "(" + ti + ")"
+214                if tup == "start":
+215                    string += "sr" + ti
+216                elif tup == "continue":
+217                    string += "co" + ti
+218                elif tup == "stop":
+219                    string += "sp" + ti
+220                else:
+221                    raise ValueError(f"Incorrect tuplet type: {tup}")
+222
+223        if len(self.articulations) > 0:  # add for articulations
+224            for a in self.articulations:
+225                string += a
+226        if len(self.expressions) > 0:  # add for articulations
+227            for e in self.expressions:
+228                string += e
+229        if len(self.lyrics) > 0:  # add for lyrics
+230            for lyric in self.lyrics:
+231                string += lyric
+232
+233        if self.noteshape != 'normal':
+234            string += f"noteshape={self.noteshape}"
+235        if self.noteheadFill is not None:
+236            string += f"noteheadFill={self.noteheadFill}"
+237        if self.noteheadParenthesis:
+238            string += f"noteheadParenthesis={self.noteheadParenthesis}"
+239        if self.stemDirection != 'unspecified':
+240            string += f"stemDirection={self.stemDirection}"
+241
+242        # and then the style fields
+243        for i, (k, v) in enumerate(self.styledict.items()):
+244            if i > 0:
+245                string += ","
+246            string += f"{k}={v}"
+247
+248        return string
+249
+250    def get_note_ids(self) -> list[str | int]:
+251        """
+252        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
+253        is only one GeneralNote here, this will always be a single-element list.
+254
+255        Returns:
+256            [int]: A list containing the single GeneralNote id for this note.
+257        """
+258        return [self.general_note]
+259
+260    def __eq__(self, other) -> bool:
+261        # equality does not consider the MEI id!
+262        return self.precomputed_str == other.precomputed_str
+263
+264        # if not isinstance(other, AnnNote):
+265        #     return False
+266        # elif self.pitches != other.pitches:
+267        #     return False
+268        # elif self.note_head != other.note_head:
+269        #     return False
+270        # elif self.dots != other.dots:
+271        #     return False
+272        # elif self.beamings != other.beamings:
+273        #     return False
+274        # elif self.tuplets != other.tuplets:
+275        #     return False
+276        # elif self.articulations != other.articulations:
+277        #     return False
+278        # elif self.expressions != other.expressions:
+279        #     return False
+280        # else:
+281        #     return True
+282
+283
+284class AnnExtra:
+285    def __init__(
+286        self,
+287        extra: m21.base.Music21Object,
+288        measure: m21.stream.Measure,
+289        score: m21.stream.Score,
+290        detail: DetailLevel = DetailLevel.Default
+291    ) -> None:
+292        """
+293        Extend music21 non-GeneralNote and non-Stream objects with some precomputed,
+294        easily compared information about it.
+295
+296        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
+297
+298        Args:
+299            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream
+300                object to extend.
+301            measure (music21.stream.Measure): The music21 Measure the extra was found in.
+302                If the extra was found in a Voice, this is the Measure that the Voice was
+303                found in.
+304            detail (DetailLevel): What level of detail to use during the diff.
+305                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+306                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+307                or Default (Default is currently equivalent to AllObjects).
+308        """
+309        self.extra = extra.id
+310        self.offset: float
+311        self.duration: float
+312        self.numNotes: int = 1
+313
+314        if isinstance(extra, m21.spanner.Spanner):
+315            self.numNotes = len(extra)
+316            firstNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+317                M21Utils.getPrimarySpannerElement(extra)
+318            )
+319            lastNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+320                extra.getLast()
+321            )
+322            self.offset = float(firstNote.getOffsetInHierarchy(measure))
+323            # to compute duration we need to use offset-in-score, since the end note might
+324            # be in another Measure.  Except for ArpeggioMarkSpanners, where the duration
+325            # doesn't matter, so we just set it to 0, rather than figuring out the longest
+326            # duration in all the notes/chords in the arpeggio.
+327            if isinstance(extra, m21.expressions.ArpeggioMarkSpanner):
+328                self.duration = 0.
+329            else:
+330                startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
+331                try:
+332                    endOffsetInScore: float = float(
+333                        lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength
+334                    )
+335                except m21.sites.SitesException:
+336                    endOffsetInScore = startOffsetInScore
+337                self.duration = endOffsetInScore - startOffsetInScore
+338        else:
+339            self.offset = float(extra.getOffsetInHierarchy(measure))
+340            self.duration = float(extra.duration.quarterLength)
+341
+342        self.content: str = M21Utils.extra_to_string(extra, detail)
+343        self.styledict: dict = {}
+344
+345        if M21Utils.has_style(extra):
+346            # includes extra.placement if present
+347            self.styledict = M21Utils.obj_to_styledict(extra, detail)
+348
+349        # so far, always 1, but maybe some extra will be bigger someday
+350        self._notation_size: int = 1
+351
+352        # precomputed representations for faster comparison
+353        self.precomputed_str: str = self.__str__()
+354
+355    def notation_size(self) -> int:
+356        """
+357        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
+358
+359        Returns:
+360            int: The notation size of the annotated extra
+361        """
+362        return self._notation_size
+363
+364    def __repr__(self) -> str:
+365        return str(self)
+366
+367    def __str__(self) -> str:
+368        """
+369        Returns:
+370            str: the compared representation of the AnnExtra. Does not consider music21 id.
+371        """
+372        string = f'{self.content},off={self.offset},dur={self.duration}'
+373        if self.numNotes != 1:
+374            string += f',numNotes={self.numNotes}'
+375        # and then any style fields
+376        for k, v in self.styledict.items():
+377            string += f",{k}={v}"
+378        return string
+379
+380    def __eq__(self, other) -> bool:
+381        # equality does not consider the MEI id!
+382        return self.precomputed_str == other.precomputed_str
+383
+384
+385class AnnVoice:
+386    def __init__(
+387        self,
+388        voice: m21.stream.Voice | m21.stream.Measure,
+389        detail: DetailLevel = DetailLevel.Default
+390    ) -> None:
+391        """
+392        Extend music21 Voice with some precomputed, easily compared information about it.
+393
+394        Args:
+395            voice (music21.stream.Voice or Measure): The music21 voice to extend. This
+396                can be a Measure, but only if it contains no Voices.
+397            detail (DetailLevel): What level of detail to use during the diff.
+398                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+399                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+400                or Default (Default is currently equivalent to AllObjects).
+401        """
+402        self.voice: int | str = voice.id
+403        note_list: list[m21.note.GeneralNote] = []
+404
+405        if DetailLevel.includesGeneralNotes(detail):
+406            note_list = M21Utils.get_notes_and_gracenotes(voice)
+407
+408        if not note_list:
+409            self.en_beam_list: list[list[str]] = []
+410            self.tuplet_list: list[list[str]] = []
+411            self.tuplet_info: list[list[str]] = []
+412            self.annot_notes: list[AnnNote] = []
+413        else:
+414            self.en_beam_list = M21Utils.get_enhance_beamings(
+415                note_list
+416            )  # beams and type (type for note shorter than quarter notes)
+417            self.tuplet_list = M21Utils.get_tuplets_type(
+418                note_list
+419            )  # corrected tuplets (with "start" and "continue")
+420            self.tuplet_info = M21Utils.get_tuplets_info(note_list)
+421            # create a list of notes with beaming and tuplets information attached
+422            self.annot_notes = []
+423            for i, n in enumerate(note_list):
+424                self.annot_notes.append(
+425                    AnnNote(
+426                        n,
+427                        self.en_beam_list[i],
+428                        self.tuplet_list[i],
+429                        self.tuplet_info[i],
+430                        detail
+431                    )
+432                )
+433
+434        self.n_of_notes: int = len(self.annot_notes)
+435        self.precomputed_str: str = self.__str__()
+436
+437    def __eq__(self, other) -> bool:
+438        # equality does not consider MEI id!
+439        if not isinstance(other, AnnVoice):
+440            return False
+441
+442        if len(self.annot_notes) != len(other.annot_notes):
+443            return False
+444
+445        return self.precomputed_str == other.precomputed_str
+446        # return all(
+447        #     [an[0] == an[1] for an in zip(self.annot_notes, other.annot_notes)]
+448        # )
+449
+450    def notation_size(self) -> int:
+451        """
+452        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
+453
+454        Returns:
+455            int: The notation size of the annotated voice
+456        """
+457        return sum([an.notation_size() for an in self.annot_notes])
+458
+459    def __repr__(self) -> str:
+460        return self.annot_notes.__repr__()
+461
+462    def __str__(self) -> str:
+463        string = "["
+464        for an in self.annot_notes:
+465            string += str(an)
+466            string += ","
+467
+468        if string[-1] == ",":
+469            # delete the last comma
+470            string = string[:-1]
+471
+472        string += "]"
+473        return string
+474
+475    def get_note_ids(self) -> list[str | int]:
+476        """
+477        Computes a list of the GeneralNote ids for this `AnnVoice`.
+478
+479        Returns:
+480            [int]: A list containing the GeneralNote ids contained in this voice
+481        """
+482        return [an.general_note for an in self.annot_notes]
+483
+484
+485class AnnMeasure:
+486    def __init__(
+487        self,
+488        measure: m21.stream.Measure,
+489        part: m21.stream.Part,
+490        score: m21.stream.Score,
+491        spannerBundle: m21.spanner.SpannerBundle,
+492        detail: DetailLevel = DetailLevel.Default
+493    ) -> None:
+494        """
+495        Extend music21 Measure with some precomputed, easily compared information about it.
+496
+497        Args:
+498            measure (music21.stream.Measure): The music21 Measure to extend.
+499            part (music21.stream.Part): the enclosing music21 Part
+500            score (music21.stream.Score): the enclosing music21 Score.
+501            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners
+502                in the score.
+503            detail (DetailLevel): What level of detail to use during the diff.
+504                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+505                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+506                or Default (Default is currently equivalent to AllObjects).
+507        """
+508        self.measure: int | str = measure.id
+509        self.voices_list: list[AnnVoice] = []
+510
+511        if len(measure.voices) == 0:
+512            # there is a single AnnVoice (i.e. in the music21 Measure there are no voices)
+513            ann_voice = AnnVoice(measure, detail)
+514            if ann_voice.n_of_notes > 0:
+515                self.voices_list.append(ann_voice)
+516        else:  # there are multiple voices (or an array with just one voice)
+517            for voice in measure.voices:
+518                ann_voice = AnnVoice(voice, detail)
+519                if ann_voice.n_of_notes > 0:
+520                    self.voices_list.append(ann_voice)
+521        self.n_of_voices: int = len(self.voices_list)
+522
+523        self.extras_list: list[AnnExtra] = []
+524        if DetailLevel.includesOtherMusicObjects(detail):
+525            for extra in M21Utils.get_extras(measure, part, spannerBundle, detail):
+526                self.extras_list.append(AnnExtra(extra, measure, score, detail))
+527
+528            # For correct comparison, sort the extras_list, so that any list slices
+529            # that all have the same offset are sorted alphabetically.
+530            self.extras_list.sort(key=lambda e: (e.offset, str(e)))
+531
+532        # precomputed values to speed up the computation. As they start to be long, they are hashed
+533        self.precomputed_str: int = hash(self.__str__())
+534        self.precomputed_repr: int = hash(self.__repr__())
+535
+536    def __str__(self) -> str:
+537        return (
+538            str([str(v) for v in self.voices_list])
+539            + ' Extras:'
+540            + str([str(e) for e in self.extras_list])
+541        )
+542
+543    def __repr__(self) -> str:
+544        return self.voices_list.__repr__() + ' Extras:' + self.extras_list.__repr__()
+545
+546    def __eq__(self, other) -> bool:
+547        # equality does not consider MEI id!
+548        if not isinstance(other, AnnMeasure):
+549            return False
+550
+551        if len(self.voices_list) != len(other.voices_list):
+552            return False
+553
+554        if len(self.extras_list) != len(other.extras_list):
+555            return False
+556
+557        return self.precomputed_str == other.precomputed_str
+558        # return all([v[0] == v[1] for v in zip(self.voices_list, other.voices_list)])
+559
+560    def notation_size(self) -> int:
+561        """
+562        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
+563
+564        Returns:
+565            int: The notation size of the annotated measure
+566        """
+567        return (
+568            sum([v.notation_size() for v in self.voices_list])
+569            + sum([e.notation_size() for e in self.extras_list])
+570        )
+571
+572    def get_note_ids(self) -> list[str | int]:
+573        """
+574        Computes a list of the GeneralNote ids for this `AnnMeasure`.
+575
+576        Returns:
+577            [int]: A list containing the GeneralNote ids contained in this measure
+578        """
+579        notes_id = []
+580        for v in self.voices_list:
+581            notes_id.extend(v.get_note_ids())
+582        return notes_id
+583
+584
+585class AnnPart:
+586    def __init__(
+587        self,
+588        part: m21.stream.Part,
+589        score: m21.stream.Score,
+590        spannerBundle: m21.spanner.SpannerBundle,
+591        detail: DetailLevel = DetailLevel.Default
+592    ):
+593        """
+594        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
+595
+596        Args:
+597            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff
+598                to extend.
+599            score (music21.stream.Score): the enclosing music21 Score.
+600            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in
+601                the score.
+602            detail (DetailLevel): What level of detail to use during the diff.
+603                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+604                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+605                or Default (Default is currently equivalent to AllObjects).
+606        """
+607        self.part: int | str = part.id
+608        self.bar_list: list[AnnMeasure] = []
+609        for measure in part.getElementsByClass("Measure"):
+610            # create the bar objects
+611            ann_bar = AnnMeasure(measure, part, score, spannerBundle, detail)
+612            if ann_bar.n_of_voices > 0:
+613                self.bar_list.append(ann_bar)
+614        self.n_of_bars: int = len(self.bar_list)
+615        # Precomputed str to speed up the computation.
+616        # String itself is pretty long, so it is hashed
+617        self.precomputed_str: int = hash(self.__str__())
+618
+619    def __str__(self) -> str:
+620        output: str = 'Part: '
+621        output += str([str(b) for b in self.bar_list])
+622        return output
+623
+624    def __eq__(self, other) -> bool:
+625        # equality does not consider MEI id!
+626        if not isinstance(other, AnnPart):
+627            return False
+628
+629        if len(self.bar_list) != len(other.bar_list):
+630            return False
+631
+632        return all(b[0] == b[1] for b in zip(self.bar_list, other.bar_list))
+633
+634    def notation_size(self) -> int:
+635        """
+636        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
+637
+638        Returns:
+639            int: The notation size of the annotated part
+640        """
+641        return sum([b.notation_size() for b in self.bar_list])
+642
+643    def __repr__(self) -> str:
+644        return self.bar_list.__repr__()
+645
+646    def get_note_ids(self) -> list[str | int]:
+647        """
+648        Computes a list of the GeneralNote ids for this `AnnPart`.
+649
+650        Returns:
+651            [int]: A list containing the GeneralNote ids contained in this part
+652        """
+653        notes_id = []
+654        for b in self.bar_list:
+655            notes_id.extend(b.get_note_ids())
+656        return notes_id
+657
+658
+659class AnnStaffGroup:
+660    def __init__(
+661        self,
+662        staff_group: m21.layout.StaffGroup,
+663        part_to_index: dict[m21.stream.Part, int],
+664        detail: DetailLevel = DetailLevel.Default
+665    ) -> None:
+666        """
+667        Take a StaffGroup and store it as an annotated object.
+668        """
+669        self.staff_group: int | str = staff_group.id
+670        self.name: str = staff_group.name or ''
+671        self.abbreviation: str = staff_group.abbreviation or ''
+672        self.symbol: str | None = None
+673        self.barTogether: bool | str | None = staff_group.barTogether
+674
+675        if DetailLevel.includesStyle(detail):
+676            # symbol (brace, bracket, line, etc) is considered to be style
+677            self.symbol = staff_group.symbol
+678
+679        self.part_indices: list[int] = []
+680        for part in staff_group:
+681            self.part_indices.append(part_to_index.get(part, -1))
+682
+683        # sort so simple list comparison can work
+684        self.part_indices.sort()
+685
+686        self.n_of_parts: int = len(self.part_indices)
+687
+688        # precomputed representations for faster comparison
+689        self.precomputed_str: str = self.__str__()
+690
+691    def __str__(self) -> str:
+692        output: str = "StaffGroup"
+693        if self.name and self.abbreviation:
+694            output += f"({self.name},{self.abbreviation})"
+695        elif self.name:
+696            output += f"({self.name})"
+697        elif self.abbreviation:
+698            output += f"(,{self.abbreviation})"
+699        else:
+700            output += "(,)"
+701
+702        output += f", symbol={self.symbol}"
+703        output += f", barTogether={self.barTogether}"
+704        output += f", partIndices={self.part_indices}"
+705        return output
+706
+707    def __eq__(self, other) -> bool:
+708        # equality does not consider MEI id (or MEI ids of parts included in the group)
+709        if not isinstance(other, AnnStaffGroup):
+710            return False
+711
+712        if self.name != other.name:
+713            return False
+714
+715        if self.abbreviation != other.abbreviation:
+716            return False
+717
+718        if self.symbol != other.symbol:
+719            return False
+720
+721        if self.barTogether != other.barTogether:
+722            return False
+723
+724        if self.part_indices != other.part_indices:
+725            return False
+726
+727        return True
+728
+729    def notation_size(self) -> int:
+730        """
+731        Compute a measure of how many symbols are displayed in the score for this `AnnStaffGroup`.
+732
+733        Returns:
+734            int: The notation size of the annotated staff group
+735        """
+736        # notation_size = 5 because there are 5 main visible things about a StaffGroup:
+737        #   name, abbreviation, symbol shape, barline type, and which parts it encloses
+738        return 5
+739
+740    def __repr__(self) -> str:
+741        # does consider the MEI id!
+742        output: str = f"StaffGroup({self.staff_group}):"
+743        output += f" name={self.name}, abbrev={self.abbreviation},"
+744        output += f" symbol={self.symbol}, barTogether={self.barTogether}"
+745        output += f", partIndices={self.part_indices}"
+746        return output
+747
+748
+749class AnnMetadataItem:
+750    def __init__(
+751        self,
+752        key: str,
+753        value: t.Any
+754    ) -> None:
+755        self.key = key
+756        if isinstance(value, m21.metadata.Text):
+757            # Create a string representing both the text and the language, but not isTranslated,
+758            # since isTranslated cannot be represented in many file formats.
+759            self.value = str(value) + f'(language={value.language})'
+760        elif isinstance(value, m21.metadata.Contributor):
+761            # Create a string (same thing: value.name.isTranslated will differ randomly)
+762            # Currently I am also ignoring more than one name, and birth/death.
+763            self.value = str(value) + f'(role={value.role}, language={value._names[0].language})'
+764        else:
+765            self.value = value
+766
+767    def __eq__(self, other) -> bool:
+768        if not isinstance(other, AnnMetadataItem):
+769            return False
+770
+771        if self.key != other.key:
+772            return False
+773
+774        if self.value != other.value:
+775            return False
+776
+777        return True
+778
+779    def __str__(self) -> str:
+780        return self.__repr__()
+781
+782    def __repr__(self) -> str:
+783        return self.key + ':' + str(self.value)
+784
+785    def notation_size(self) -> int:
+786        """
+787        Compute a measure of how many symbols are displayed in the score for this `AnnMetadataItem`.
+788
+789        Returns:
+790            int: The notation size of the annotated metadata item
+791        """
+792        return 1
+793
+794
+795class AnnScore:
+796    def __init__(
+797        self,
+798        score: m21.stream.Score,
+799        detail: DetailLevel = DetailLevel.Default
+800    ) -> None:
+801        """
+802        Take a music21 score and store it as a sequence of Full Trees.
+803        The hierarchy is "score -> parts -> measures -> voices -> notes"
+804        Args:
+805            score (music21.stream.Score): The music21 score
+806            detail (DetailLevel): What level of detail to use during the diff.
+807                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+808                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+809                or Default (Default is currently equivalent to AllObjects).
+810        """
+811        self.score: int | str = score.id
+812        self.part_list: list[AnnPart] = []
+813        self.staff_group_list: list[AnnStaffGroup] = []
+814        self.metadata_items_list: list[AnnMetadataItem] = []
+815
+816        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
+817        part_to_index: dict[m21.stream.Part, int] = {}
+818
+819        # Before we start, transpose all notes to written pitch, both for transposing
+820        # instruments and Ottavas. Be careful to preserve accidental.displayStatus
+821        # during transposition, since we use that visibility indicator when comparing
+822        # accidentals.
+823        score.toWrittenPitch(inPlace=True, preserveAccidentalDisplay=True)
+824
+825        for idx, part in enumerate(score.parts):
+826            # create and add the AnnPart object to part_list
+827            # and to part_to_index dict
+828            part_to_index[part] = idx
+829            ann_part = AnnPart(part, score, spannerBundle, detail)
+830            self.part_list.append(ann_part)
+831
+832        self.n_of_parts: int = len(self.part_list)
+833
+834        if DetailLevel.includesOtherMusicObjects(detail):
+835            # staffgroups are extras (a.k.a. OtherMusicObjects)
+836            for staffGroup in score[m21.layout.StaffGroup]:
+837                ann_staff_group = AnnStaffGroup(staffGroup, part_to_index, detail)
+838                if ann_staff_group.n_of_parts > 0:
+839                    self.staff_group_list.append(ann_staff_group)
+840
+841        if DetailLevel.includesMetadata(detail) and score.metadata is not None:
+842            # m21 metadata.all() can't sort primitives, so we'll have to sort by hand.
+843            allItems: list[tuple[str, t.Any]] = list(
+844                score.metadata.all(returnPrimitives=True, returnSorted=False)
+845            )
+846            allItems.sort(key=lambda each: (each[0], str(each[1])))
+847            for key, value in allItems:
+848                if key in ('fileFormat', 'filePath', 'software'):
+849                    # Don't compare metadata items that are uninterestingly different.
+850                    continue
+851                if (key.startswith('raw:')
+852                        or key.startswith('meiraw:')
+853                        or key.startswith('humdrumraw:')):
+854                    # Don't compare verbatim/raw metadata ('meiraw:meihead',
+855                    # 'raw:freeform', 'humdrumraw:XXX'), it's often deleted
+856                    # when made obsolete by conversions/edits.
+857                    continue
+858                self.metadata_items_list.append(AnnMetadataItem(key, value))
+859
+860    def __eq__(self, other) -> bool:
+861        # equality does not consider MEI id!
+862        if not isinstance(other, AnnScore):
+863            return False
+864
+865        if len(self.part_list) != len(other.part_list):
+866            return False
+867
+868        return all(p[0] == p[1] for p in zip(self.part_list, other.part_list))
+869
+870    def notation_size(self) -> int:
+871        """
+872        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
+873
+874        Returns:
+875            int: The notation size of the annotated score
+876        """
+877        return sum([p.notation_size() for p in self.part_list])
+878
+879    def __repr__(self) -> str:
+880        return self.part_list.__repr__()
+881
+882    def get_note_ids(self) -> list[str | int]:
+883        """
+884        Computes a list of the GeneralNote ids for this `AnnScore`.
+885
+886        Returns:
+887            [int]: A list containing the GeneralNote ids contained in this score
+888        """
+889        notes_id = []
+890        for p in self.part_list:
+891            notes_id.extend(p.get_note_ids())
+892        return notes_id
+893
+894    # return the sequences of measures for a specified part
+895    def _measures_from_part(self, part_number) -> list[AnnMeasure]:
+896        # only used by tests/test_scl.py
+897        if part_number not in range(0, len(self.part_list)):
+898            raise ValueError(
+899                f"parameter 'part_number' should be between 0 and {len(self.part_list) - 1}"
+900            )
+901        return self.part_list[part_number].bar_list
+
+
-
- #   + +
+ + class + AnnNote: + + + +
+ +
 27class AnnNote:
+ 28    def __init__(
+ 29        self,
+ 30        general_note: m21.note.GeneralNote,
+ 31        enhanced_beam_list: list[str],
+ 32        tuplet_list: list[str],
+ 33        tuplet_info: list[str],
+ 34        detail: DetailLevel = DetailLevel.Default
+ 35    ) -> None:
+ 36        """
+ 37        Extend music21 GeneralNote with some precomputed, easily compared information about it.
+ 38
+ 39        Args:
+ 40            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
+ 41            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
+ 42            tuplet_list (list): A list of tuplet info about this GeneralNote.
+ 43            detail (DetailLevel): What level of detail to use during the diff.
+ 44                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+ 45                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+ 46                or Default (Default is currently equivalent to AllObjects).
+ 47
+ 48        """
+ 49        self.general_note: int | str = general_note.id
+ 50        self.beamings: list[str] = enhanced_beam_list
+ 51        self.tuplets: list[str] = tuplet_list
+ 52        self.tuplet_info: list[str] = tuplet_info
+ 53
+ 54        self.stylestr: str = ''
+ 55        self.styledict: dict = {}
+ 56        if M21Utils.has_style(general_note):
+ 57            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
+ 58        self.noteshape: str = 'normal'
+ 59        self.noteheadFill: bool | None = None
+ 60        self.noteheadParenthesis: bool = False
+ 61        self.stemDirection: str = 'unspecified'
+ 62        if DetailLevel.includesStyle(detail) and isinstance(general_note, m21.note.NotRest):
+ 63            self.noteshape = general_note.notehead
+ 64            self.noteheadFill = general_note.noteheadFill
+ 65            self.noteheadParenthesis = general_note.noteheadParenthesis
+ 66            self.stemDirection = general_note.stemDirection
+ 67
+ 68        # compute the representation of NoteNode as in the paper
+ 69        # pitches is a list  of elements, each one is (pitchposition, accidental, tied)
+ 70        self.pitches: list[tuple[str, str, bool]]
+ 71        if isinstance(general_note, m21.chord.ChordBase):
+ 72            notes: tuple[m21.note.NotRest, ...] = general_note.notes
+ 73            if hasattr(general_note, "sortDiatonicAscending"):
+ 74                # PercussionChords don't have this
+ 75                notes = general_note.sortDiatonicAscending().notes
+ 76            self.pitches = []
+ 77            for p in notes:
+ 78                if not isinstance(p, (m21.note.Note, m21.note.Unpitched)):
+ 79                    raise TypeError("The chord must contain only Note or Unpitched")
+ 80                self.pitches.append(M21Utils.note2tuple(p, detail))
+ 81
+ 82        elif isinstance(general_note, (m21.note.Note, m21.note.Unpitched, m21.note.Rest)):
+ 83            self.pitches = [M21Utils.note2tuple(general_note, detail)]
+ 84        else:
+ 85            raise TypeError("The generalNote must be a Chord, a Rest, a Note, or an Unpitched")
+ 86
+ 87        # note head
+ 88        type_number = Fraction(
+ 89            M21Utils.get_type_num(general_note.duration)
+ 90        )
+ 91        self.note_head: int | Fraction
+ 92        if type_number >= 4:
+ 93            self.note_head = 4
+ 94        else:
+ 95            self.note_head = type_number
+ 96        # dots
+ 97        self.dots: int = general_note.duration.dots
+ 98        # graceness
+ 99        if isinstance(general_note.duration, m21.duration.AppoggiaturaDuration):
+100            self.graceType: str = 'acc'
+101            self.graceSlash: bool | None = general_note.duration.slash
+102        elif isinstance(general_note.duration, m21.duration.GraceDuration):
+103            self.graceType = 'nonacc'
+104            self.graceSlash = general_note.duration.slash
+105        else:
+106            self.graceType = ''
+107            self.graceSlash = False
+108        # articulations
+109        self.articulations: list[str] = [
+110            M21Utils.articulation_to_string(a, detail) for a in general_note.articulations
+111        ]
+112        if self.articulations:
+113            self.articulations.sort()
+114        # expressions
+115        self.expressions: list[str] = [
+116            M21Utils.expression_to_string(a, detail) for a in general_note.expressions
+117        ]
+118        if self.expressions:
+119            self.expressions.sort()
+120
+121        # lyrics
+122        self.lyrics: list[str] = []
+123        for lyric in general_note.lyrics:
+124            lyricStr: str = ""
+125            if lyric.number is not None:
+126                lyricStr += f"number={lyric.number}"
+127            if lyric._identifier is not None:
+128                lyricStr += f" identifier={lyric._identifier}"
+129            if lyric.syllabic is not None:
+130                lyricStr += f" syllabic={lyric.syllabic}"
+131            if lyric.text is not None:
+132                lyricStr += f" text={lyric.text}"
+133            lyricStr += f" rawText={lyric.rawText}"
+134            if M21Utils.has_style(lyric):
+135                lyricStr += f" style={M21Utils.obj_to_styledict(lyric, detail)}"
+136            self.lyrics.append(lyricStr)
+137
+138        # precomputed representations for faster comparison
+139        self.precomputed_str: str = self.__str__()
+140
+141    def notation_size(self) -> int:
+142        """
+143        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
+144
+145        Returns:
+146            int: The notation size of the annotated note
+147        """
+148        size: int = 0
+149        # add for the pitches
+150        for pitch in self.pitches:
+151            size += M21Utils.pitch_size(pitch)
+152        # add for the dots
+153        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
+154        # add for the beamings
+155        size += len(self.beamings)
+156        # add for the tuplets
+157        size += len(self.tuplets)
+158        # add for the articulations
+159        size += len(self.articulations)
+160        # add for the expressions
+161        size += len(self.expressions)
+162        # add for the lyrics
+163        size += len(self.lyrics)
+164        return size
+165
+166    def __repr__(self) -> str:
+167        # does consider the MEI id!
+168        return (
+169            f"{self.pitches},{self.note_head},{self.dots},B:{self.beamings},"
+170            + f"T:{self.tuplets},TI:{self.tuplet_info},{self.general_note},"
+171            + f"{self.articulations},{self.expressions},{self.lyrics},{self.styledict}"
+172        )
+173
+174    def __str__(self) -> str:
+175        """
+176        Returns:
+177            str: the representation of the Annotated note. Does not consider MEI id
+178        """
+179        string: str = "["
+180        for p in self.pitches:  # add for pitches
+181            string += p[0]
+182            if p[1] != "None":
+183                string += p[1]
+184            if p[2]:
+185                string += "T"
+186            string += ","
+187        string = string[:-1]  # delete the last comma
+188        string += "]"
+189        string += str(self.note_head)  # add for notehead
+190        for _ in range(self.dots):  # add for dots
+191            string += "*"
+192        if self.graceType:
+193            string += self.graceType
+194            if self.graceSlash:
+195                string += '/'
+196        if len(self.beamings) > 0:  # add for beaming
+197            string += "B"
+198            for b in self.beamings:
+199                if b == "start":
+200                    string += "sr"
+201                elif b == "continue":
+202                    string += "co"
+203                elif b == "stop":
+204                    string += "sp"
+205                elif b == "partial":
+206                    string += "pa"
+207                else:
+208                    raise ValueError(f"Incorrect beaming type: {b}")
+209
+210        if len(self.tuplets) > 0:  # add for tuplets
+211            string += "T"
+212            for tup, ti in zip(self.tuplets, self.tuplet_info):
+213                if ti != "":
+214                    ti = "(" + ti + ")"
+215                if tup == "start":
+216                    string += "sr" + ti
+217                elif tup == "continue":
+218                    string += "co" + ti
+219                elif tup == "stop":
+220                    string += "sp" + ti
+221                else:
+222                    raise ValueError(f"Incorrect tuplet type: {tup}")
+223
+224        if len(self.articulations) > 0:  # add for articulations
+225            for a in self.articulations:
+226                string += a
+227        if len(self.expressions) > 0:  # add for articulations
+228            for e in self.expressions:
+229                string += e
+230        if len(self.lyrics) > 0:  # add for lyrics
+231            for lyric in self.lyrics:
+232                string += lyric
+233
+234        if self.noteshape != 'normal':
+235            string += f"noteshape={self.noteshape}"
+236        if self.noteheadFill is not None:
+237            string += f"noteheadFill={self.noteheadFill}"
+238        if self.noteheadParenthesis:
+239            string += f"noteheadParenthesis={self.noteheadParenthesis}"
+240        if self.stemDirection != 'unspecified':
+241            string += f"stemDirection={self.stemDirection}"
+242
+243        # and then the style fields
+244        for i, (k, v) in enumerate(self.styledict.items()):
+245            if i > 0:
+246                string += ","
+247            string += f"{k}={v}"
+248
+249        return string
+250
+251    def get_note_ids(self) -> list[str | int]:
+252        """
+253        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
+254        is only one GeneralNote here, this will always be a single-element list.
+255
+256        Returns:
+257            [int]: A list containing the single GeneralNote id for this note.
+258        """
+259        return [self.general_note]
+260
+261    def __eq__(self, other) -> bool:
+262        # equality does not consider the MEI id!
+263        return self.precomputed_str == other.precomputed_str
+264
+265        # if not isinstance(other, AnnNote):
+266        #     return False
+267        # elif self.pitches != other.pitches:
+268        #     return False
+269        # elif self.note_head != other.note_head:
+270        #     return False
+271        # elif self.dots != other.dots:
+272        #     return False
+273        # elif self.beamings != other.beamings:
+274        #     return False
+275        # elif self.tuplets != other.tuplets:
+276        #     return False
+277        # elif self.articulations != other.articulations:
+278        #     return False
+279        # elif self.expressions != other.expressions:
+280        #     return False
+281        # else:
+282        #     return True
+
+ + + + +
+ +
+ + AnnNote( general_note: music21.note.GeneralNote, enhanced_beam_list: list[str], tuplet_list: list[str], tuplet_info: list[str], detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) + + + +
+ +
 28    def __init__(
+ 29        self,
+ 30        general_note: m21.note.GeneralNote,
+ 31        enhanced_beam_list: list[str],
+ 32        tuplet_list: list[str],
+ 33        tuplet_info: list[str],
+ 34        detail: DetailLevel = DetailLevel.Default
+ 35    ) -> None:
+ 36        """
+ 37        Extend music21 GeneralNote with some precomputed, easily compared information about it.
+ 38
+ 39        Args:
+ 40            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
+ 41            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
+ 42            tuplet_list (list): A list of tuplet info about this GeneralNote.
+ 43            detail (DetailLevel): What level of detail to use during the diff.
+ 44                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+ 45                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+ 46                or Default (Default is currently equivalent to AllObjects).
+ 47
+ 48        """
+ 49        self.general_note: int | str = general_note.id
+ 50        self.beamings: list[str] = enhanced_beam_list
+ 51        self.tuplets: list[str] = tuplet_list
+ 52        self.tuplet_info: list[str] = tuplet_info
+ 53
+ 54        self.stylestr: str = ''
+ 55        self.styledict: dict = {}
+ 56        if M21Utils.has_style(general_note):
+ 57            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
+ 58        self.noteshape: str = 'normal'
+ 59        self.noteheadFill: bool | None = None
+ 60        self.noteheadParenthesis: bool = False
+ 61        self.stemDirection: str = 'unspecified'
+ 62        if DetailLevel.includesStyle(detail) and isinstance(general_note, m21.note.NotRest):
+ 63            self.noteshape = general_note.notehead
+ 64            self.noteheadFill = general_note.noteheadFill
+ 65            self.noteheadParenthesis = general_note.noteheadParenthesis
+ 66            self.stemDirection = general_note.stemDirection
+ 67
+ 68        # compute the representation of NoteNode as in the paper
+ 69        # pitches is a list  of elements, each one is (pitchposition, accidental, tied)
+ 70        self.pitches: list[tuple[str, str, bool]]
+ 71        if isinstance(general_note, m21.chord.ChordBase):
+ 72            notes: tuple[m21.note.NotRest, ...] = general_note.notes
+ 73            if hasattr(general_note, "sortDiatonicAscending"):
+ 74                # PercussionChords don't have this
+ 75                notes = general_note.sortDiatonicAscending().notes
+ 76            self.pitches = []
+ 77            for p in notes:
+ 78                if not isinstance(p, (m21.note.Note, m21.note.Unpitched)):
+ 79                    raise TypeError("The chord must contain only Note or Unpitched")
+ 80                self.pitches.append(M21Utils.note2tuple(p, detail))
+ 81
+ 82        elif isinstance(general_note, (m21.note.Note, m21.note.Unpitched, m21.note.Rest)):
+ 83            self.pitches = [M21Utils.note2tuple(general_note, detail)]
+ 84        else:
+ 85            raise TypeError("The generalNote must be a Chord, a Rest, a Note, or an Unpitched")
+ 86
+ 87        # note head
+ 88        type_number = Fraction(
+ 89            M21Utils.get_type_num(general_note.duration)
+ 90        )
+ 91        self.note_head: int | Fraction
+ 92        if type_number >= 4:
+ 93            self.note_head = 4
+ 94        else:
+ 95            self.note_head = type_number
+ 96        # dots
+ 97        self.dots: int = general_note.duration.dots
+ 98        # graceness
+ 99        if isinstance(general_note.duration, m21.duration.AppoggiaturaDuration):
+100            self.graceType: str = 'acc'
+101            self.graceSlash: bool | None = general_note.duration.slash
+102        elif isinstance(general_note.duration, m21.duration.GraceDuration):
+103            self.graceType = 'nonacc'
+104            self.graceSlash = general_note.duration.slash
+105        else:
+106            self.graceType = ''
+107            self.graceSlash = False
+108        # articulations
+109        self.articulations: list[str] = [
+110            M21Utils.articulation_to_string(a, detail) for a in general_note.articulations
+111        ]
+112        if self.articulations:
+113            self.articulations.sort()
+114        # expressions
+115        self.expressions: list[str] = [
+116            M21Utils.expression_to_string(a, detail) for a in general_note.expressions
+117        ]
+118        if self.expressions:
+119            self.expressions.sort()
+120
+121        # lyrics
+122        self.lyrics: list[str] = []
+123        for lyric in general_note.lyrics:
+124            lyricStr: str = ""
+125            if lyric.number is not None:
+126                lyricStr += f"number={lyric.number}"
+127            if lyric._identifier is not None:
+128                lyricStr += f" identifier={lyric._identifier}"
+129            if lyric.syllabic is not None:
+130                lyricStr += f" syllabic={lyric.syllabic}"
+131            if lyric.text is not None:
+132                lyricStr += f" text={lyric.text}"
+133            lyricStr += f" rawText={lyric.rawText}"
+134            if M21Utils.has_style(lyric):
+135                lyricStr += f" style={M21Utils.obj_to_styledict(lyric, detail)}"
+136            self.lyrics.append(lyricStr)
+137
+138        # precomputed representations for faster comparison
+139        self.precomputed_str: str = self.__str__()
+
+ + +

Extend music21 GeneralNote with some precomputed, easily compared information about it.

+ +
Arguments:
+ +
    +
  • general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
  • +
  • enhanced_beam_list (list): A list of beaming information about this GeneralNote.
  • +
  • tuplet_list (list): A list of tuplet info about this GeneralNote.
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
  • +
+
+ + +
+
+
+ general_note: int | str + + +
+ + + + +
+
+
+ beamings: list[str] + + +
+ + + + +
+
+
+ tuplets: list[str] + + +
+ + + + +
+
+
+ tuplet_info: list[str] + + +
+ + + + +
+
+
+ stylestr: str + + +
+ + + + +
+
+
+ styledict: dict + + +
+ + + + +
+
+
+ noteshape: str + + +
+ + + + +
+
+
+ noteheadFill: bool | None + + +
+ + + + +
+
+
+ noteheadParenthesis: bool + + +
+ + + + +
+
+
+ stemDirection: str + + +
+ + + + +
+
+
+ pitches: list[tuple[str, str, bool]] + + +
+ + + + +
+
+
+ note_head: int | fractions.Fraction + + +
+ + + + +
+
+
+ dots: int + + +
+ + + + +
+
+
+ articulations: list[str] + + +
+ + + + +
+
+
+ expressions: list[str] + + +
+ + + + +
+
+
+ lyrics: list[str] + + +
+ + + + +
+
+
+ precomputed_str: str + + +
+ + + + +
+
+ +
+ + def + notation_size(self) -> int: + + + +
+ +
141    def notation_size(self) -> int:
+142        """
+143        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
+144
+145        Returns:
+146            int: The notation size of the annotated note
+147        """
+148        size: int = 0
+149        # add for the pitches
+150        for pitch in self.pitches:
+151            size += M21Utils.pitch_size(pitch)
+152        # add for the dots
+153        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
+154        # add for the beamings
+155        size += len(self.beamings)
+156        # add for the tuplets
+157        size += len(self.tuplets)
+158        # add for the articulations
+159        size += len(self.articulations)
+160        # add for the expressions
+161        size += len(self.expressions)
+162        # add for the lyrics
+163        size += len(self.lyrics)
+164        return size
+
+ + +

Compute a measure of how many symbols are displayed in the score for this AnnNote.

+ +
Returns:
+ +
+

int: The notation size of the annotated note

+
+
+ + +
+
+ +
+ + def + get_note_ids(self) -> list[str | int]: + + + +
+ +
251    def get_note_ids(self) -> list[str | int]:
+252        """
+253        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
+254        is only one GeneralNote here, this will always be a single-element list.
+255
+256        Returns:
+257            [int]: A list containing the single GeneralNote id for this note.
+258        """
+259        return [self.general_note]
+
+ + +

Computes a list of the GeneralNote ids for this AnnNote. Since there +is only one GeneralNote here, this will always be a single-element list.

+ +
Returns:
+ +
+

[int]: A list containing the single GeneralNote id for this note.

+
+
+ + +
+
+
+ +
+ + class + AnnExtra: + + + +
+ +
285class AnnExtra:
+286    def __init__(
+287        self,
+288        extra: m21.base.Music21Object,
+289        measure: m21.stream.Measure,
+290        score: m21.stream.Score,
+291        detail: DetailLevel = DetailLevel.Default
+292    ) -> None:
+293        """
+294        Extend music21 non-GeneralNote and non-Stream objects with some precomputed,
+295        easily compared information about it.
+296
+297        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
+298
+299        Args:
+300            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream
+301                object to extend.
+302            measure (music21.stream.Measure): The music21 Measure the extra was found in.
+303                If the extra was found in a Voice, this is the Measure that the Voice was
+304                found in.
+305            detail (DetailLevel): What level of detail to use during the diff.
+306                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+307                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+308                or Default (Default is currently equivalent to AllObjects).
+309        """
+310        self.extra = extra.id
+311        self.offset: float
+312        self.duration: float
+313        self.numNotes: int = 1
+314
+315        if isinstance(extra, m21.spanner.Spanner):
+316            self.numNotes = len(extra)
+317            firstNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+318                M21Utils.getPrimarySpannerElement(extra)
+319            )
+320            lastNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+321                extra.getLast()
+322            )
+323            self.offset = float(firstNote.getOffsetInHierarchy(measure))
+324            # to compute duration we need to use offset-in-score, since the end note might
+325            # be in another Measure.  Except for ArpeggioMarkSpanners, where the duration
+326            # doesn't matter, so we just set it to 0, rather than figuring out the longest
+327            # duration in all the notes/chords in the arpeggio.
+328            if isinstance(extra, m21.expressions.ArpeggioMarkSpanner):
+329                self.duration = 0.
+330            else:
+331                startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
+332                try:
+333                    endOffsetInScore: float = float(
+334                        lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength
+335                    )
+336                except m21.sites.SitesException:
+337                    endOffsetInScore = startOffsetInScore
+338                self.duration = endOffsetInScore - startOffsetInScore
+339        else:
+340            self.offset = float(extra.getOffsetInHierarchy(measure))
+341            self.duration = float(extra.duration.quarterLength)
+342
+343        self.content: str = M21Utils.extra_to_string(extra, detail)
+344        self.styledict: dict = {}
+345
+346        if M21Utils.has_style(extra):
+347            # includes extra.placement if present
+348            self.styledict = M21Utils.obj_to_styledict(extra, detail)
+349
+350        # so far, always 1, but maybe some extra will be bigger someday
+351        self._notation_size: int = 1
+352
+353        # precomputed representations for faster comparison
+354        self.precomputed_str: str = self.__str__()
+355
+356    def notation_size(self) -> int:
+357        """
+358        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
+359
+360        Returns:
+361            int: The notation size of the annotated extra
+362        """
+363        return self._notation_size
+364
+365    def __repr__(self) -> str:
+366        return str(self)
+367
+368    def __str__(self) -> str:
+369        """
+370        Returns:
+371            str: the compared representation of the AnnExtra. Does not consider music21 id.
+372        """
+373        string = f'{self.content},off={self.offset},dur={self.duration}'
+374        if self.numNotes != 1:
+375            string += f',numNotes={self.numNotes}'
+376        # and then any style fields
+377        for k, v in self.styledict.items():
+378            string += f",{k}={v}"
+379        return string
+380
+381    def __eq__(self, other) -> bool:
+382        # equality does not consider the MEI id!
+383        return self.precomputed_str == other.precomputed_str
+
+ + + + +
+ +
+ + AnnExtra( extra: music21.base.Music21Object, measure: music21.stream.base.Measure, score: music21.stream.base.Score, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) + + + +
+ +
286    def __init__(
+287        self,
+288        extra: m21.base.Music21Object,
+289        measure: m21.stream.Measure,
+290        score: m21.stream.Score,
+291        detail: DetailLevel = DetailLevel.Default
+292    ) -> None:
+293        """
+294        Extend music21 non-GeneralNote and non-Stream objects with some precomputed,
+295        easily compared information about it.
+296
+297        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
+298
+299        Args:
+300            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream
+301                object to extend.
+302            measure (music21.stream.Measure): The music21 Measure the extra was found in.
+303                If the extra was found in a Voice, this is the Measure that the Voice was
+304                found in.
+305            detail (DetailLevel): What level of detail to use during the diff.
+306                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+307                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+308                or Default (Default is currently equivalent to AllObjects).
+309        """
+310        self.extra = extra.id
+311        self.offset: float
+312        self.duration: float
+313        self.numNotes: int = 1
+314
+315        if isinstance(extra, m21.spanner.Spanner):
+316            self.numNotes = len(extra)
+317            firstNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+318                M21Utils.getPrimarySpannerElement(extra)
+319            )
+320            lastNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = (
+321                extra.getLast()
+322            )
+323            self.offset = float(firstNote.getOffsetInHierarchy(measure))
+324            # to compute duration we need to use offset-in-score, since the end note might
+325            # be in another Measure.  Except for ArpeggioMarkSpanners, where the duration
+326            # doesn't matter, so we just set it to 0, rather than figuring out the longest
+327            # duration in all the notes/chords in the arpeggio.
+328            if isinstance(extra, m21.expressions.ArpeggioMarkSpanner):
+329                self.duration = 0.
+330            else:
+331                startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
+332                try:
+333                    endOffsetInScore: float = float(
+334                        lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength
+335                    )
+336                except m21.sites.SitesException:
+337                    endOffsetInScore = startOffsetInScore
+338                self.duration = endOffsetInScore - startOffsetInScore
+339        else:
+340            self.offset = float(extra.getOffsetInHierarchy(measure))
+341            self.duration = float(extra.duration.quarterLength)
+342
+343        self.content: str = M21Utils.extra_to_string(extra, detail)
+344        self.styledict: dict = {}
+345
+346        if M21Utils.has_style(extra):
+347            # includes extra.placement if present
+348            self.styledict = M21Utils.obj_to_styledict(extra, detail)
+349
+350        # so far, always 1, but maybe some extra will be bigger someday
+351        self._notation_size: int = 1
+352
+353        # precomputed representations for faster comparison
+354        self.precomputed_str: str = self.__str__()
+
+ + +

Extend music21 non-GeneralNote and non-Stream objects with some precomputed, +easily compared information about it.

+ +

Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.

+ +
Arguments:
+ +
    +
  • extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream +object to extend.
  • +
  • measure (music21.stream.Measure): The music21 Measure the extra was found in. +If the extra was found in a Voice, this is the Measure that the Voice was +found in.
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
  • +
+
+ + +
+
+
+ extra + + +
+ + + + +
+
+
+ offset: float + + +
+ + + + +
+
+
+ duration: float + + +
+ + + + +
+
+
+ numNotes: int + + +
+ + + + +
+
+
+ content: str + + +
+ + + + +
+
+
+ styledict: dict + + +
+ + + + +
+
+
+ precomputed_str: str + + +
+ + + + +
+
+ +
+ + def + notation_size(self) -> int: + + + +
+ +
356    def notation_size(self) -> int:
+357        """
+358        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
+359
+360        Returns:
+361            int: The notation size of the annotated extra
+362        """
+363        return self._notation_size
+
+ + +

Compute a measure of how many symbols are displayed in the score for this AnnExtra.

+ +
Returns:
+ +
+

int: The notation size of the annotated extra

+
+
+ + +
+
+
+ +
+ + class + AnnVoice: + + + +
+ +
386class AnnVoice:
+387    def __init__(
+388        self,
+389        voice: m21.stream.Voice | m21.stream.Measure,
+390        detail: DetailLevel = DetailLevel.Default
+391    ) -> None:
+392        """
+393        Extend music21 Voice with some precomputed, easily compared information about it.
+394
+395        Args:
+396            voice (music21.stream.Voice or Measure): The music21 voice to extend. This
+397                can be a Measure, but only if it contains no Voices.
+398            detail (DetailLevel): What level of detail to use during the diff.
+399                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+400                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+401                or Default (Default is currently equivalent to AllObjects).
+402        """
+403        self.voice: int | str = voice.id
+404        note_list: list[m21.note.GeneralNote] = []
+405
+406        if DetailLevel.includesGeneralNotes(detail):
+407            note_list = M21Utils.get_notes_and_gracenotes(voice)
+408
+409        if not note_list:
+410            self.en_beam_list: list[list[str]] = []
+411            self.tuplet_list: list[list[str]] = []
+412            self.tuplet_info: list[list[str]] = []
+413            self.annot_notes: list[AnnNote] = []
+414        else:
+415            self.en_beam_list = M21Utils.get_enhance_beamings(
+416                note_list
+417            )  # beams and type (type for note shorter than quarter notes)
+418            self.tuplet_list = M21Utils.get_tuplets_type(
+419                note_list
+420            )  # corrected tuplets (with "start" and "continue")
+421            self.tuplet_info = M21Utils.get_tuplets_info(note_list)
+422            # create a list of notes with beaming and tuplets information attached
+423            self.annot_notes = []
+424            for i, n in enumerate(note_list):
+425                self.annot_notes.append(
+426                    AnnNote(
+427                        n,
+428                        self.en_beam_list[i],
+429                        self.tuplet_list[i],
+430                        self.tuplet_info[i],
+431                        detail
+432                    )
+433                )
+434
+435        self.n_of_notes: int = len(self.annot_notes)
+436        self.precomputed_str: str = self.__str__()
+437
+438    def __eq__(self, other) -> bool:
+439        # equality does not consider MEI id!
+440        if not isinstance(other, AnnVoice):
+441            return False
+442
+443        if len(self.annot_notes) != len(other.annot_notes):
+444            return False
+445
+446        return self.precomputed_str == other.precomputed_str
+447        # return all(
+448        #     [an[0] == an[1] for an in zip(self.annot_notes, other.annot_notes)]
+449        # )
+450
+451    def notation_size(self) -> int:
+452        """
+453        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
+454
+455        Returns:
+456            int: The notation size of the annotated voice
+457        """
+458        return sum([an.notation_size() for an in self.annot_notes])
+459
+460    def __repr__(self) -> str:
+461        return self.annot_notes.__repr__()
+462
+463    def __str__(self) -> str:
+464        string = "["
+465        for an in self.annot_notes:
+466            string += str(an)
+467            string += ","
+468
+469        if string[-1] == ",":
+470            # delete the last comma
+471            string = string[:-1]
+472
+473        string += "]"
+474        return string
+475
+476    def get_note_ids(self) -> list[str | int]:
+477        """
+478        Computes a list of the GeneralNote ids for this `AnnVoice`.
+479
+480        Returns:
+481            [int]: A list containing the GeneralNote ids contained in this voice
+482        """
+483        return [an.general_note for an in self.annot_notes]
+
+ + + + +
+ +
+ + AnnVoice( voice: music21.stream.base.Voice | music21.stream.base.Measure, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) + + + +
+ +
387    def __init__(
+388        self,
+389        voice: m21.stream.Voice | m21.stream.Measure,
+390        detail: DetailLevel = DetailLevel.Default
+391    ) -> None:
+392        """
+393        Extend music21 Voice with some precomputed, easily compared information about it.
+394
+395        Args:
+396            voice (music21.stream.Voice or Measure): The music21 voice to extend. This
+397                can be a Measure, but only if it contains no Voices.
+398            detail (DetailLevel): What level of detail to use during the diff.
+399                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+400                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+401                or Default (Default is currently equivalent to AllObjects).
+402        """
+403        self.voice: int | str = voice.id
+404        note_list: list[m21.note.GeneralNote] = []
+405
+406        if DetailLevel.includesGeneralNotes(detail):
+407            note_list = M21Utils.get_notes_and_gracenotes(voice)
+408
+409        if not note_list:
+410            self.en_beam_list: list[list[str]] = []
+411            self.tuplet_list: list[list[str]] = []
+412            self.tuplet_info: list[list[str]] = []
+413            self.annot_notes: list[AnnNote] = []
+414        else:
+415            self.en_beam_list = M21Utils.get_enhance_beamings(
+416                note_list
+417            )  # beams and type (type for note shorter than quarter notes)
+418            self.tuplet_list = M21Utils.get_tuplets_type(
+419                note_list
+420            )  # corrected tuplets (with "start" and "continue")
+421            self.tuplet_info = M21Utils.get_tuplets_info(note_list)
+422            # create a list of notes with beaming and tuplets information attached
+423            self.annot_notes = []
+424            for i, n in enumerate(note_list):
+425                self.annot_notes.append(
+426                    AnnNote(
+427                        n,
+428                        self.en_beam_list[i],
+429                        self.tuplet_list[i],
+430                        self.tuplet_info[i],
+431                        detail
+432                    )
+433                )
+434
+435        self.n_of_notes: int = len(self.annot_notes)
+436        self.precomputed_str: str = self.__str__()
+
+ + +

Extend music21 Voice with some precomputed, easily compared information about it.

+ +
Arguments:
+ +
    +
  • voice (music21.stream.Voice or Measure): The music21 voice to extend. This +can be a Measure, but only if it contains no Voices.
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
  • +
+
+ + +
+
+
+ voice: int | str + + +
+ + + + +
+
+
+ n_of_notes: int + + +
+ + + + +
+
+
+ precomputed_str: str + + +
+ + + + +
+
+ +
+ + def + notation_size(self) -> int: + + + +
+ +
451    def notation_size(self) -> int:
+452        """
+453        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
+454
+455        Returns:
+456            int: The notation size of the annotated voice
+457        """
+458        return sum([an.notation_size() for an in self.annot_notes])
+
+ + +

Compute a measure of how many symbols are displayed in the score for this AnnVoice.

+ +
Returns:
+ +
+

int: The notation size of the annotated voice

+
+
+ + +
+
+ +
+ + def + get_note_ids(self) -> list[str | int]: + + + +
+ +
476    def get_note_ids(self) -> list[str | int]:
+477        """
+478        Computes a list of the GeneralNote ids for this `AnnVoice`.
+479
+480        Returns:
+481            [int]: A list containing the GeneralNote ids contained in this voice
+482        """
+483        return [an.general_note for an in self.annot_notes]
+
+ + +

Computes a list of the GeneralNote ids for this AnnVoice.

+ +
Returns:
+ +
+

[int]: A list containing the GeneralNote ids contained in this voice

+
+
+ + +
+
+
+ +
+ + class + AnnMeasure: + + + +
+ +
486class AnnMeasure:
+487    def __init__(
+488        self,
+489        measure: m21.stream.Measure,
+490        part: m21.stream.Part,
+491        score: m21.stream.Score,
+492        spannerBundle: m21.spanner.SpannerBundle,
+493        detail: DetailLevel = DetailLevel.Default
+494    ) -> None:
+495        """
+496        Extend music21 Measure with some precomputed, easily compared information about it.
+497
+498        Args:
+499            measure (music21.stream.Measure): The music21 Measure to extend.
+500            part (music21.stream.Part): the enclosing music21 Part
+501            score (music21.stream.Score): the enclosing music21 Score.
+502            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners
+503                in the score.
+504            detail (DetailLevel): What level of detail to use during the diff.
+505                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+506                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+507                or Default (Default is currently equivalent to AllObjects).
+508        """
+509        self.measure: int | str = measure.id
+510        self.voices_list: list[AnnVoice] = []
+511
+512        if len(measure.voices) == 0:
+513            # there is a single AnnVoice (i.e. in the music21 Measure there are no voices)
+514            ann_voice = AnnVoice(measure, detail)
+515            if ann_voice.n_of_notes > 0:
+516                self.voices_list.append(ann_voice)
+517        else:  # there are multiple voices (or an array with just one voice)
+518            for voice in measure.voices:
+519                ann_voice = AnnVoice(voice, detail)
+520                if ann_voice.n_of_notes > 0:
+521                    self.voices_list.append(ann_voice)
+522        self.n_of_voices: int = len(self.voices_list)
+523
+524        self.extras_list: list[AnnExtra] = []
+525        if DetailLevel.includesOtherMusicObjects(detail):
+526            for extra in M21Utils.get_extras(measure, part, spannerBundle, detail):
+527                self.extras_list.append(AnnExtra(extra, measure, score, detail))
+528
+529            # For correct comparison, sort the extras_list, so that any list slices
+530            # that all have the same offset are sorted alphabetically.
+531            self.extras_list.sort(key=lambda e: (e.offset, str(e)))
+532
+533        # precomputed values to speed up the computation. As they start to be long, they are hashed
+534        self.precomputed_str: int = hash(self.__str__())
+535        self.precomputed_repr: int = hash(self.__repr__())
+536
+537    def __str__(self) -> str:
+538        return (
+539            str([str(v) for v in self.voices_list])
+540            + ' Extras:'
+541            + str([str(e) for e in self.extras_list])
+542        )
+543
+544    def __repr__(self) -> str:
+545        return self.voices_list.__repr__() + ' Extras:' + self.extras_list.__repr__()
+546
+547    def __eq__(self, other) -> bool:
+548        # equality does not consider MEI id!
+549        if not isinstance(other, AnnMeasure):
+550            return False
+551
+552        if len(self.voices_list) != len(other.voices_list):
+553            return False
+554
+555        if len(self.extras_list) != len(other.extras_list):
+556            return False
+557
+558        return self.precomputed_str == other.precomputed_str
+559        # return all([v[0] == v[1] for v in zip(self.voices_list, other.voices_list)])
+560
+561    def notation_size(self) -> int:
+562        """
+563        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
+564
+565        Returns:
+566            int: The notation size of the annotated measure
+567        """
+568        return (
+569            sum([v.notation_size() for v in self.voices_list])
+570            + sum([e.notation_size() for e in self.extras_list])
+571        )
+572
+573    def get_note_ids(self) -> list[str | int]:
+574        """
+575        Computes a list of the GeneralNote ids for this `AnnMeasure`.
+576
+577        Returns:
+578            [int]: A list containing the GeneralNote ids contained in this measure
+579        """
+580        notes_id = []
+581        for v in self.voices_list:
+582            notes_id.extend(v.get_note_ids())
+583        return notes_id
+
+ + + + +
+ +
+ + AnnMeasure( measure: music21.stream.base.Measure, part: music21.stream.base.Part, score: music21.stream.base.Score, spannerBundle: music21.spanner.SpannerBundle, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) + + + +
+ +
487    def __init__(
+488        self,
+489        measure: m21.stream.Measure,
+490        part: m21.stream.Part,
+491        score: m21.stream.Score,
+492        spannerBundle: m21.spanner.SpannerBundle,
+493        detail: DetailLevel = DetailLevel.Default
+494    ) -> None:
+495        """
+496        Extend music21 Measure with some precomputed, easily compared information about it.
+497
+498        Args:
+499            measure (music21.stream.Measure): The music21 Measure to extend.
+500            part (music21.stream.Part): the enclosing music21 Part
+501            score (music21.stream.Score): the enclosing music21 Score.
+502            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners
+503                in the score.
+504            detail (DetailLevel): What level of detail to use during the diff.
+505                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+506                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+507                or Default (Default is currently equivalent to AllObjects).
+508        """
+509        self.measure: int | str = measure.id
+510        self.voices_list: list[AnnVoice] = []
+511
+512        if len(measure.voices) == 0:
+513            # there is a single AnnVoice (i.e. in the music21 Measure there are no voices)
+514            ann_voice = AnnVoice(measure, detail)
+515            if ann_voice.n_of_notes > 0:
+516                self.voices_list.append(ann_voice)
+517        else:  # there are multiple voices (or an array with just one voice)
+518            for voice in measure.voices:
+519                ann_voice = AnnVoice(voice, detail)
+520                if ann_voice.n_of_notes > 0:
+521                    self.voices_list.append(ann_voice)
+522        self.n_of_voices: int = len(self.voices_list)
+523
+524        self.extras_list: list[AnnExtra] = []
+525        if DetailLevel.includesOtherMusicObjects(detail):
+526            for extra in M21Utils.get_extras(measure, part, spannerBundle, detail):
+527                self.extras_list.append(AnnExtra(extra, measure, score, detail))
+528
+529            # For correct comparison, sort the extras_list, so that any list slices
+530            # that all have the same offset are sorted alphabetically.
+531            self.extras_list.sort(key=lambda e: (e.offset, str(e)))
+532
+533        # precomputed values to speed up the computation. As they start to be long, they are hashed
+534        self.precomputed_str: int = hash(self.__str__())
+535        self.precomputed_repr: int = hash(self.__repr__())
+
+ + +

Extend music21 Measure with some precomputed, easily compared information about it.

+ +
Arguments:
+ +
    +
  • measure (music21.stream.Measure): The music21 Measure to extend.
  • +
  • part (music21.stream.Part): the enclosing music21 Part
  • +
  • score (music21.stream.Score): the enclosing music21 Score.
  • +
  • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners +in the score.
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
  • +
+
+ + +
+
+
+ measure: int | str + + +
+ + + + +
+
+
+ voices_list: list[AnnVoice] + + +
+ + + + +
+
+
+ n_of_voices: int - class - AnnNote: -
- -
- View Source -
class AnnNote:
-    def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuplet_list, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 GeneralNote with some precomputed, easily compared information about it.
-
-        Args:
-            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
-            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
-            tuplet_list (list): A list of tuplet info about this GeneralNote.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-
-        """
-        self.general_note = general_note.id
-        self.beamings = enhanced_beam_list
-        self.tuplets = tuplet_list
-
-        self.stylestr: str = ''
-        self.styledict: dict = {}
-        if M21Utils.has_style(general_note):
-            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
-        self.noteshape: str = 'normal'
-        self.noteheadFill: Optional[bool] = None
-        self.noteheadParenthesis: bool = False
-        self.stemDirection: str = 'unspecified'
-        if detail >= DetailLevel.AllObjectsWithStyle and isinstance(general_note, m21.note.NotRest):
-            self.noteshape = general_note.notehead
-            self.noteheadFill = general_note.noteheadFill
-            self.noteheadParenthesis = general_note.noteheadParenthesis
-            self.stemDirection = general_note.stemDirection
-
-        # compute the representation of NoteNode as in the paper
-        # pitches is a list  of elements, each one is (pitchposition, accidental, tie)
-        if general_note.isRest:
-            self.pitches = [
-                ("R", "None", False)
-            ]  # accidental and tie are automaticaly set for rests
-        elif general_note.isChord or "ChordBase" in general_note.classSet:
-            # ChordBase/PercussionChord is new in v7, so I am being careful to use
-            # it only as a string so v6 will still work.
-            noteList: [m21.note.GeneralNote] = general_note.notes
-            if hasattr(general_note, "sortDiatonicAscending"): # PercussionChords don't have this
-                noteList = general_note.sortDiatonicAscending().notes
-            self.pitches = [
-                M21Utils.note2tuple(p) for p in noteList
-            ]
-        elif general_note.isNote or isinstance(general_note, m21.note.Unpitched):
-            self.pitches = [M21Utils.note2tuple(general_note)]
-        else:
-            raise TypeError("The generalNote must be a Chord, a Rest or a Note")
-        # note head
-        type_number = Fraction(
-            M21Utils.get_type_num(general_note.duration)
-        )
-        if type_number >= 4:
-            self.note_head = 4
-        else:
-            self.note_head = type_number
-        # dots
-        self.dots = general_note.duration.dots
-        # articulations
-        self.articulations = [a.name for a in general_note.articulations]
-        if self.articulations:
-            self.articulations.sort()
-        # expressions
-        self.expressions = [a.name for a in general_note.expressions]
-        if self.expressions:
-            self.expressions.sort()
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
-
-        Returns:
-            int: The notation size of the annotated note
-        """
-        size = 0
-        # add for the pitches
-        for pitch in self.pitches:
-            size += M21Utils.pitch_size(pitch)
-        # add for the dots
-        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
-        # add for the beamings
-        size += len(self.beamings)
-        # add for the tuplets
-        size += len(self.tuplets)
-        # add for the articulations
-        size += len(self.articulations)
-        # add for the expressions
-        size += len(self.expressions)
-        return size
-
-    def __repr__(self):
-        # does consider the MEI id!
-        return (f"{self.pitches},{self.note_head},{self.dots},{self.beamings}," +
-                f"{self.tuplets},{self.general_note},{self.articulations},{self.expressions}" +
-                f"{self.styledict}")
-
-    def __str__(self):
-        """
-        Returns:
-            str: the representation of the Annotated note. Does not consider MEI id
-        """
-        string = "["
-        for p in self.pitches:  # add for pitches
-            string += p[0]
-            if p[1] != "None":
-                string += p[1]
-            if p[2]:
-                string += "T"
-            string += ","
-        string = string[:-1]  # delete the last comma
-        string += "]"
-        string += str(self.note_head)  # add for notehead
-        for _ in range(self.dots):  # add for dots
-            string += "*"
-        if len(self.beamings) > 0:  # add for beaming
-            string += "B"
-            for b in self.beamings:
-                if b == "start":
-                    string += "sr"
-                elif b == "continue":
-                    string += "co"
-                elif b == "stop":
-                    string += "sp"
-                elif b == "partial":
-                    string += "pa"
-                else:
-                    raise Exception(f"Incorrect beaming type: {b}")
-        if len(self.tuplets) > 0:  # add for tuplets
-            string += "T"
-            for t in self.tuplets:
-                if t == "start":
-                    string += "sr"
-                elif t == "continue":
-                    string += "co"
-                elif t == "stop":
-                    string += "sp"
-                else:
-                    raise Exception(f"Incorrect tuplets type: {t}")
-        if len(self.articulations) > 0:  # add for articulations
-            for a in self.articulations:
-                string += a
-        if len(self.expressions) > 0:  # add for articulations
-            for e in self.expressions:
-                string += e
-
-        if self.noteshape != 'normal':
-            string += f"noteshape={self.noteshape}"
-        if self.noteheadFill is not None:
-            string += f"noteheadFill={self.noteheadFill}"
-        if self.noteheadParenthesis:
-            string += f"noteheadParenthesis={self.noteheadParenthesis}"
-        if self.stemDirection != 'unspecified':
-            string += f"stemDirection={self.stemDirection}"
-
-        # and then the style fields
-        for i, (k, v) in enumerate(self.styledict.items()):
-            if i > 0:
-                string += ","
-            string += f"{k}={v}"
-
-        return string
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
-        is only one GeneralNote here, this will always be a single-element list.
-
-        Returns:
-            [int]: A list containing the single GeneralNote id for this note.
-        """
-        return [self.general_note]
-
-    def __eq__(self, other):
-        # equality does not consider the MEI id!
-        return self.precomputed_str == other.precomputed_str
-
-        # if not isinstance(other, AnnNote):
-        #     return False
-        # elif self.pitches != other.pitches:
-        #     return False
-        # elif self.note_head != other.note_head:
-        #     return False
-        # elif self.dots != other.dots:
-        #     return False
-        # elif self.beamings != other.beamings:
-        #     return False
-        # elif self.tuplets != other.tuplets:
-        #     return False
-        # elif self.articulations != other.articulations:
-        #     return False
-        # elif self.expressions != other.expressions:
-        #     return False
-        # else:
-        #     return True
-
- -
- +
+ + -
- +
+
+ extras_list: list[AnnExtra] - AnnNote( - general_note: music21.note.GeneralNote, - enhanced_beam_list, - tuplet_list, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuplet_list, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 GeneralNote with some precomputed, easily compared information about it.
-
-        Args:
-            general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
-            enhanced_beam_list (list): A list of beaming information about this GeneralNote.
-            tuplet_list (list): A list of tuplet info about this GeneralNote.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-
-        """
-        self.general_note = general_note.id
-        self.beamings = enhanced_beam_list
-        self.tuplets = tuplet_list
-
-        self.stylestr: str = ''
-        self.styledict: dict = {}
-        if M21Utils.has_style(general_note):
-            self.styledict = M21Utils.obj_to_styledict(general_note, detail)
-        self.noteshape: str = 'normal'
-        self.noteheadFill: Optional[bool] = None
-        self.noteheadParenthesis: bool = False
-        self.stemDirection: str = 'unspecified'
-        if detail >= DetailLevel.AllObjectsWithStyle and isinstance(general_note, m21.note.NotRest):
-            self.noteshape = general_note.notehead
-            self.noteheadFill = general_note.noteheadFill
-            self.noteheadParenthesis = general_note.noteheadParenthesis
-            self.stemDirection = general_note.stemDirection
-
-        # compute the representation of NoteNode as in the paper
-        # pitches is a list  of elements, each one is (pitchposition, accidental, tie)
-        if general_note.isRest:
-            self.pitches = [
-                ("R", "None", False)
-            ]  # accidental and tie are automaticaly set for rests
-        elif general_note.isChord or "ChordBase" in general_note.classSet:
-            # ChordBase/PercussionChord is new in v7, so I am being careful to use
-            # it only as a string so v6 will still work.
-            noteList: [m21.note.GeneralNote] = general_note.notes
-            if hasattr(general_note, "sortDiatonicAscending"): # PercussionChords don't have this
-                noteList = general_note.sortDiatonicAscending().notes
-            self.pitches = [
-                M21Utils.note2tuple(p) for p in noteList
-            ]
-        elif general_note.isNote or isinstance(general_note, m21.note.Unpitched):
-            self.pitches = [M21Utils.note2tuple(general_note)]
-        else:
-            raise TypeError("The generalNote must be a Chord, a Rest or a Note")
-        # note head
-        type_number = Fraction(
-            M21Utils.get_type_num(general_note.duration)
-        )
-        if type_number >= 4:
-            self.note_head = 4
-        else:
-            self.note_head = type_number
-        # dots
-        self.dots = general_note.duration.dots
-        # articulations
-        self.articulations = [a.name for a in general_note.articulations]
-        if self.articulations:
-            self.articulations.sort()
-        # expressions
-        self.expressions = [a.name for a in general_note.expressions]
-        if self.expressions:
-            self.expressions.sort()
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
- -
+
+ + + -

Extend music21 GeneralNote with some precomputed, easily compared information about it.

+
+
+
+ precomputed_str: int -
Args
+ +
+ + + -
    -
  • general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
  • -
  • enhanced_beam_list (list): A list of beaming information about this GeneralNote.
  • -
  • tuplet_list (list): A list of tuplet info about this GeneralNote.
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • -
-
+
+
+
+ precomputed_repr: int + +
+ + +
-
-
#   +
+ +
+ + def + notation_size(self) -> int: - - def - notation_size(self): -
- -
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnNote`.
-
-        Returns:
-            int: The notation size of the annotated note
-        """
-        size = 0
-        # add for the pitches
-        for pitch in self.pitches:
-            size += M21Utils.pitch_size(pitch)
-        # add for the dots
-        size += self.dots * len(self.pitches)  # one dot for each note if it's a chord
-        # add for the beamings
-        size += len(self.beamings)
-        # add for the tuplets
-        size += len(self.tuplets)
-        # add for the articulations
-        size += len(self.articulations)
-        # add for the expressions
-        size += len(self.expressions)
-        return size
-
- -
+ + +
+ +
561    def notation_size(self) -> int:
+562        """
+563        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
+564
+565        Returns:
+566            int: The notation size of the annotated measure
+567        """
+568        return (
+569            sum([v.notation_size() for v in self.voices_list])
+570            + sum([e.notation_size() for e in self.extras_list])
+571        )
+
-

Compute a measure of how many symbols are displayed in the score for this AnnNote.

-
Returns
+

Compute a measure of how many symbols are displayed in the score for this AnnMeasure.

+ +
Returns:
-

int: The notation size of the annotated note

+

int: The notation size of the annotated measure

-
-
#   - - - def - get_note_ids(self): -
+
+ +
+ + def + get_note_ids(self) -> list[str | int]: -
- View Source -
    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnNote`.  Since there
-        is only one GeneralNote here, this will always be a single-element list.
+                
 
-        Returns:
-            [int]: A list containing the single GeneralNote id for this note.
-        """
-        return [self.general_note]
-
+
+ +
573    def get_note_ids(self) -> list[str | int]:
+574        """
+575        Computes a list of the GeneralNote ids for this `AnnMeasure`.
+576
+577        Returns:
+578            [int]: A list containing the GeneralNote ids contained in this measure
+579        """
+580        notes_id = []
+581        for v in self.voices_list:
+582            notes_id.extend(v.get_note_ids())
+583        return notes_id
+
- -

Computes a list of the GeneralNote ids for this AnnNote. Since there -is only one GeneralNote here, this will always be a single-element list.

+

Computes a list of the GeneralNote ids for this AnnMeasure.

-
Returns
+
Returns:
-

[int]: A list containing the single GeneralNote id for this note.

+

[int]: A list containing the GeneralNote ids contained in this measure

-
-
- #   - - - class - AnnExtra: -
- -
- View Source -
class AnnExtra:
-    def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it.
-        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
-
-        Args:
-            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
-            measure (music21.stream.Measure): The music21 Measure the extra was found in.  If the extra
-                was found in a Voice, this is the Measure that the Voice was found in.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.extra = extra.id
-        self.offset: float
-        self.duration: float
-        if isinstance(extra, m21.spanner.Spanner):
-            firstNote: m21.note.GeneralNote = extra.getFirst()
-            lastNote: m21.note.GeneralNote = extra.getLast()
-            self.offset = float(firstNote.getOffsetInHierarchy(measure))
-            # to compute duration we need to use offset-in-score, since the end note might be in another Measure
-            startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
-            endOffsetInScore: float = float(lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength)
-            self.duration = endOffsetInScore - startOffsetInScore
-        else:
-            self.offset = float(extra.getOffsetInHierarchy(measure))
-            self.duration = float(extra.duration.quarterLength)
-        self.content: str = M21Utils.extra_to_string(extra)
-        self.styledict: str = {}
-        if M21Utils.has_style(extra):
-            self.styledict = M21Utils.obj_to_styledict(extra, detail) # includes extra.placement if present
-        self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
-
-        Returns:
-            int: The notation size of the annotated extra
-        """
-        return self._notation_size
-
-    def __repr__(self):
-        return str(self)
-
-    def __str__(self):
-        """
-        Returns:
-            str: the compared representation of the AnnExtra. Does not consider music21 id.
-        """
-        string = f'{self.content},off={self.offset},dur={self.duration}'
-        # and then any style fields
-        for k, v in self.styledict.items():
-            string += f",{k}={v}"
-        return string
-
-    def __eq__(self, other):
-        # equality does not consider the MEI id!
-        return self.precomputed_str == other.precomputed_str
-
- -
+
+ +
+ + class + AnnPart: - + -
- + +
586class AnnPart:
+587    def __init__(
+588        self,
+589        part: m21.stream.Part,
+590        score: m21.stream.Score,
+591        spannerBundle: m21.spanner.SpannerBundle,
+592        detail: DetailLevel = DetailLevel.Default
+593    ):
+594        """
+595        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
+596
+597        Args:
+598            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff
+599                to extend.
+600            score (music21.stream.Score): the enclosing music21 Score.
+601            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in
+602                the score.
+603            detail (DetailLevel): What level of detail to use during the diff.
+604                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+605                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+606                or Default (Default is currently equivalent to AllObjects).
+607        """
+608        self.part: int | str = part.id
+609        self.bar_list: list[AnnMeasure] = []
+610        for measure in part.getElementsByClass("Measure"):
+611            # create the bar objects
+612            ann_bar = AnnMeasure(measure, part, score, spannerBundle, detail)
+613            if ann_bar.n_of_voices > 0:
+614                self.bar_list.append(ann_bar)
+615        self.n_of_bars: int = len(self.bar_list)
+616        # Precomputed str to speed up the computation.
+617        # String itself is pretty long, so it is hashed
+618        self.precomputed_str: int = hash(self.__str__())
+619
+620    def __str__(self) -> str:
+621        output: str = 'Part: '
+622        output += str([str(b) for b in self.bar_list])
+623        return output
+624
+625    def __eq__(self, other) -> bool:
+626        # equality does not consider MEI id!
+627        if not isinstance(other, AnnPart):
+628            return False
+629
+630        if len(self.bar_list) != len(other.bar_list):
+631            return False
+632
+633        return all(b[0] == b[1] for b in zip(self.bar_list, other.bar_list))
+634
+635    def notation_size(self) -> int:
+636        """
+637        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
+638
+639        Returns:
+640            int: The notation size of the annotated part
+641        """
+642        return sum([b.notation_size() for b in self.bar_list])
+643
+644    def __repr__(self) -> str:
+645        return self.bar_list.__repr__()
+646
+647    def get_note_ids(self) -> list[str | int]:
+648        """
+649        Computes a list of the GeneralNote ids for this `AnnPart`.
+650
+651        Returns:
+652            [int]: A list containing the GeneralNote ids contained in this part
+653        """
+654        notes_id = []
+655        for b in self.bar_list:
+656            notes_id.extend(b.get_note_ids())
+657        return notes_id
+
- - AnnExtra( - extra: music21.base.Music21Object, - measure: music21.stream.base.Measure, - score: music21.stream.base.Score, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it.
-        Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
-
-        Args:
-            extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
-            measure (music21.stream.Measure): The music21 Measure the extra was found in.  If the extra
-                was found in a Voice, this is the Measure that the Voice was found in.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.extra = extra.id
-        self.offset: float
-        self.duration: float
-        if isinstance(extra, m21.spanner.Spanner):
-            firstNote: m21.note.GeneralNote = extra.getFirst()
-            lastNote: m21.note.GeneralNote = extra.getLast()
-            self.offset = float(firstNote.getOffsetInHierarchy(measure))
-            # to compute duration we need to use offset-in-score, since the end note might be in another Measure
-            startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score))
-            endOffsetInScore: float = float(lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength)
-            self.duration = endOffsetInScore - startOffsetInScore
-        else:
-            self.offset = float(extra.getOffsetInHierarchy(measure))
-            self.duration = float(extra.duration.quarterLength)
-        self.content: str = M21Utils.extra_to_string(extra)
-        self.styledict: str = {}
-        if M21Utils.has_style(extra):
-            self.styledict = M21Utils.obj_to_styledict(extra, detail) # includes extra.placement if present
-        self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday
-
-        # precomputed representations for faster comparison
-        self.precomputed_str = self.__str__()
-
- -
- -

Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it. -Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.

- -
Args
-
    -
  • extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
  • -
  • measure (music21.stream.Measure): The music21 Measure the extra was found in. If the extra -was found in a Voice, this is the Measure that the Voice was found in.
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • -
-
+ +
+ +
+ + AnnPart( part: music21.stream.base.Part, score: music21.stream.base.Score, spannerBundle: music21.spanner.SpannerBundle, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) -
-
-
#   + - - def - notation_size(self):
+ +
587    def __init__(
+588        self,
+589        part: m21.stream.Part,
+590        score: m21.stream.Score,
+591        spannerBundle: m21.spanner.SpannerBundle,
+592        detail: DetailLevel = DetailLevel.Default
+593    ):
+594        """
+595        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
+596
+597        Args:
+598            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff
+599                to extend.
+600            score (music21.stream.Score): the enclosing music21 Score.
+601            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in
+602                the score.
+603            detail (DetailLevel): What level of detail to use during the diff.
+604                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+605                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+606                or Default (Default is currently equivalent to AllObjects).
+607        """
+608        self.part: int | str = part.id
+609        self.bar_list: list[AnnMeasure] = []
+610        for measure in part.getElementsByClass("Measure"):
+611            # create the bar objects
+612            ann_bar = AnnMeasure(measure, part, score, spannerBundle, detail)
+613            if ann_bar.n_of_voices > 0:
+614                self.bar_list.append(ann_bar)
+615        self.n_of_bars: int = len(self.bar_list)
+616        # Precomputed str to speed up the computation.
+617        # String itself is pretty long, so it is hashed
+618        self.precomputed_str: int = hash(self.__str__())
+
-
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnExtra`.
-
-        Returns:
-            int: The notation size of the annotated extra
-        """
-        return self._notation_size
-
-
- -

Compute a measure of how many symbols are displayed in the score for this AnnExtra.

+

Extend music21 Part/PartStaff with some precomputed, easily compared information about it.

-
Returns
+
Arguments:
-
-

int: The notation size of the annotated extra

-
+
    +
  • part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff +to extend.
  • +
  • score (music21.stream.Score): the enclosing music21 Score.
  • +
  • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in +the score.
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
  • +
-
-
-
- #   +
+
+ part: int | str - class - AnnVoice: -
- -
- View Source -
class AnnVoice:
-    def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Voice with some precomputed, easily compared information about it.
-
-        Args:
-            voice (music21.stream.Voice): The music21 voice to extend.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.voice = voice.id
-        note_list = M21Utils.get_notes(voice)
-        if not note_list:
-            self.en_beam_list = []
-            self.tuplet_list = []
-            self.tuple_info = []
-            self.annot_notes = []
-        else:
-            self.en_beam_list = M21Utils.get_enhance_beamings(
-                note_list
-            )  # beams and type (type for note shorter than quarter notes)
-            self.tuplet_list = M21Utils.get_tuplets_type(
-                note_list
-            )  # corrected tuplets (with "start" and "continue")
-            self.tuple_info = M21Utils.get_tuplets_info(note_list)
-            # create a list of notes with beaming and tuplets information attached
-            self.annot_notes = []
-            for i, n in enumerate(note_list):
-                self.annot_notes.append(
-                    AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail)
-                )
-
-        self.n_of_notes = len(self.annot_notes)
-        self.precomputed_str = self.__str__()
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnVoice):
-            return False
-
-        if len(self.annot_notes) != len(other.annot_notes):
-            return False
-
-        return self.precomputed_str == other.precomputed_str
-        # return all(
-        #     [an[0] == an[1] for an in zip(self.annot_notes, other.annot_notes)]
-        # )
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
-
-        Returns:
-            int: The notation size of the annotated voice
-        """
-        return sum([an.notation_size() for an in self.annot_notes])
-
-    def __repr__(self):
-        return self.annot_notes.__repr__()
-
-    def __str__(self):
-        string = "["
-        for an in self.annot_notes:
-            string += str(an)
-            string += ","
-
-        if string[-1] == ",":
-            string = string[:-1] # delete the last comma
-
-        string += "]"
-        return string
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnVoice`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this voice
-        """
-        return [an.general_note for an in self.annot_notes]
-
- -
- +
+ + -
- +
+
+ bar_list: list[AnnMeasure] - AnnVoice( - voice: music21.stream.base.Voice, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Voice with some precomputed, easily compared information about it.
-
-        Args:
-            voice (music21.stream.Voice): The music21 voice to extend.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.voice = voice.id
-        note_list = M21Utils.get_notes(voice)
-        if not note_list:
-            self.en_beam_list = []
-            self.tuplet_list = []
-            self.tuple_info = []
-            self.annot_notes = []
-        else:
-            self.en_beam_list = M21Utils.get_enhance_beamings(
-                note_list
-            )  # beams and type (type for note shorter than quarter notes)
-            self.tuplet_list = M21Utils.get_tuplets_type(
-                note_list
-            )  # corrected tuplets (with "start" and "continue")
-            self.tuple_info = M21Utils.get_tuplets_info(note_list)
-            # create a list of notes with beaming and tuplets information attached
-            self.annot_notes = []
-            for i, n in enumerate(note_list):
-                self.annot_notes.append(
-                    AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail)
-                )
-
-        self.n_of_notes = len(self.annot_notes)
-        self.precomputed_str = self.__str__()
-
- -
- -

Extend music21 Voice with some precomputed, easily compared information about it.

- -
Args
+
+ + + -
    -
  • voice (music21.stream.Voice): The music21 voice to extend.
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • -
-
+
+
+
+ n_of_bars: int + +
+ + +
-
-
#   +
+
+ precomputed_str: int - def - notation_size(self):
+ + + + +
+
+ +
+ + def + notation_size(self) -> int: -
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnVoice`.
+                
 
-        Returns:
-            int: The notation size of the annotated voice
-        """
-        return sum([an.notation_size() for an in self.annot_notes])
-
+
+ +
635    def notation_size(self) -> int:
+636        """
+637        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
+638
+639        Returns:
+640            int: The notation size of the annotated part
+641        """
+642        return sum([b.notation_size() for b in self.bar_list])
+
- -

Compute a measure of how many symbols are displayed in the score for this AnnVoice.

+

Compute a measure of how many symbols are displayed in the score for this AnnPart.

-
Returns
+
Returns:
-

int: The notation size of the annotated voice

+

int: The notation size of the annotated part

-
-
#   - - - def - get_note_ids(self): -
+
+ +
+ + def + get_note_ids(self) -> list[str | int]: -
- View Source -
    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnVoice`.
+                
 
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this voice
-        """
-        return [an.general_note for an in self.annot_notes]
-
+
+ +
647    def get_note_ids(self) -> list[str | int]:
+648        """
+649        Computes a list of the GeneralNote ids for this `AnnPart`.
+650
+651        Returns:
+652            [int]: A list containing the GeneralNote ids contained in this part
+653        """
+654        notes_id = []
+655        for b in self.bar_list:
+656            notes_id.extend(b.get_note_ids())
+657        return notes_id
+
- -

Computes a list of the GeneralNote ids for this AnnVoice.

+

Computes a list of the GeneralNote ids for this AnnPart.

-
Returns
+
Returns:
-

[int]: A list containing the GeneralNote ids contained in this voice

+

[int]: A list containing the GeneralNote ids contained in this part

-
-
- #   +
+ +
+ + class + AnnStaffGroup: - - class - AnnMeasure: -
- -
- View Source -
class AnnMeasure:
-    def __init__(self, measure: m21.stream.Measure,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Measure with some precomputed, easily compared information about it.
-
-        Args:
-            measure (music21.stream.Measure): The music21 measure to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.measure = measure.id
-        self.voices_list = []
-        if (
-            len(measure.voices) == 0
-        ):  # there is a single AnnVoice ( == for the library there are no voices)
-            ann_voice = AnnVoice(measure, detail)
-            if ann_voice.n_of_notes > 0:
-                self.voices_list.append(ann_voice)
-        else:  # there are multiple voices (or an array with just one voice)
-            for voice in measure.voices:
-                ann_voice = AnnVoice(voice, detail)
-                if ann_voice.n_of_notes > 0:
-                    self.voices_list.append(ann_voice)
-        self.n_of_voices = len(self.voices_list)
-
-        self.extras_list = []
-        if detail >= DetailLevel.AllObjects:
-            for extra in M21Utils.get_extras(measure, spannerBundle):
-                self.extras_list.append(AnnExtra(extra, measure, score, detail))
-
-            # For correct comparison, sort the extras_list, so that any list slices
-            # that all have the same offset are sorted alphabetically.
-            self.extras_list.sort(key=lambda e: ( e.offset, str(e) ))
-
-        # precomputed values to speed up the computation. As they start to be long, they are hashed
-        self.precomputed_str = hash(self.__str__())
-        self.precomputed_repr = hash(self.__repr__())
-
-    def __str__(self):
-        return str([str(v) for v in self.voices_list]) + ' Extras:' + str([str(e) for e in self.extras_list])
-
-    def __repr__(self):
-        return self.voices_list.__repr__() + ' Extras:' + self.extras_list.__repr__()
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnMeasure):
-            return False
-
-        if len(self.voices_list) != len(other.voices_list):
-            return False
-
-        if len(self.extras_list) != len(other.extras_list):
-            return False
-
-        return self.precomputed_str == other.precomputed_str
-        # return all([v[0] == v[1] for v in zip(self.voices_list, other.voices_list)])
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
-
-        Returns:
-            int: The notation size of the annotated measure
-        """
-        return sum([v.notation_size() for v in self.voices_list]) + sum([e.notation_size() for e in self.extras_list])
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnMeasure`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this measure
-        """
-        notes_id = []
-        for v in self.voices_list:
-            notes_id.extend(v.get_note_ids())
-        return notes_id
-
- -
+ - +
+ +
660class AnnStaffGroup:
+661    def __init__(
+662        self,
+663        staff_group: m21.layout.StaffGroup,
+664        part_to_index: dict[m21.stream.Part, int],
+665        detail: DetailLevel = DetailLevel.Default
+666    ) -> None:
+667        """
+668        Take a StaffGroup and store it as an annotated object.
+669        """
+670        self.staff_group: int | str = staff_group.id
+671        self.name: str = staff_group.name or ''
+672        self.abbreviation: str = staff_group.abbreviation or ''
+673        self.symbol: str | None = None
+674        self.barTogether: bool | str | None = staff_group.barTogether
+675
+676        if DetailLevel.includesStyle(detail):
+677            # symbol (brace, bracket, line, etc) is considered to be style
+678            self.symbol = staff_group.symbol
+679
+680        self.part_indices: list[int] = []
+681        for part in staff_group:
+682            self.part_indices.append(part_to_index.get(part, -1))
+683
+684        # sort so simple list comparison can work
+685        self.part_indices.sort()
+686
+687        self.n_of_parts: int = len(self.part_indices)
+688
+689        # precomputed representations for faster comparison
+690        self.precomputed_str: str = self.__str__()
+691
+692    def __str__(self) -> str:
+693        output: str = "StaffGroup"
+694        if self.name and self.abbreviation:
+695            output += f"({self.name},{self.abbreviation})"
+696        elif self.name:
+697            output += f"({self.name})"
+698        elif self.abbreviation:
+699            output += f"(,{self.abbreviation})"
+700        else:
+701            output += "(,)"
+702
+703        output += f", symbol={self.symbol}"
+704        output += f", barTogether={self.barTogether}"
+705        output += f", partIndices={self.part_indices}"
+706        return output
+707
+708    def __eq__(self, other) -> bool:
+709        # equality does not consider MEI id (or MEI ids of parts included in the group)
+710        if not isinstance(other, AnnStaffGroup):
+711            return False
+712
+713        if self.name != other.name:
+714            return False
+715
+716        if self.abbreviation != other.abbreviation:
+717            return False
+718
+719        if self.symbol != other.symbol:
+720            return False
+721
+722        if self.barTogether != other.barTogether:
+723            return False
+724
+725        if self.part_indices != other.part_indices:
+726            return False
+727
+728        return True
+729
+730    def notation_size(self) -> int:
+731        """
+732        Compute a measure of how many symbols are displayed in the score for this `AnnStaffGroup`.
+733
+734        Returns:
+735            int: The notation size of the annotated staff group
+736        """
+737        # notation_size = 5 because there are 5 main visible things about a StaffGroup:
+738        #   name, abbreviation, symbol shape, barline type, and which parts it encloses
+739        return 5
+740
+741    def __repr__(self) -> str:
+742        # does consider the MEI id!
+743        output: str = f"StaffGroup({self.staff_group}):"
+744        output += f" name={self.name}, abbrev={self.abbreviation},"
+745        output += f" symbol={self.symbol}, barTogether={self.barTogether}"
+746        output += f", partIndices={self.part_indices}"
+747        return output
+
-
-
#   - - AnnMeasure( - measure: music21.stream.base.Measure, - score: music21.stream.base.Score, - spannerBundle: music21.spanner.SpannerBundle, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, measure: m21.stream.Measure,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Measure with some precomputed, easily compared information about it.
-
-        Args:
-            measure (music21.stream.Measure): The music21 measure to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.measure = measure.id
-        self.voices_list = []
-        if (
-            len(measure.voices) == 0
-        ):  # there is a single AnnVoice ( == for the library there are no voices)
-            ann_voice = AnnVoice(measure, detail)
-            if ann_voice.n_of_notes > 0:
-                self.voices_list.append(ann_voice)
-        else:  # there are multiple voices (or an array with just one voice)
-            for voice in measure.voices:
-                ann_voice = AnnVoice(voice, detail)
-                if ann_voice.n_of_notes > 0:
-                    self.voices_list.append(ann_voice)
-        self.n_of_voices = len(self.voices_list)
-
-        self.extras_list = []
-        if detail >= DetailLevel.AllObjects:
-            for extra in M21Utils.get_extras(measure, spannerBundle):
-                self.extras_list.append(AnnExtra(extra, measure, score, detail))
-
-            # For correct comparison, sort the extras_list, so that any list slices
-            # that all have the same offset are sorted alphabetically.
-            self.extras_list.sort(key=lambda e: ( e.offset, str(e) ))
-
-        # precomputed values to speed up the computation. As they start to be long, they are hashed
-        self.precomputed_str = hash(self.__str__())
-        self.precomputed_repr = hash(self.__repr__())
-
- -
+ -

Extend music21 Measure with some precomputed, easily compared information about it.

+
+ +
+ + AnnStaffGroup( staff_group: music21.layout.StaffGroup, part_to_index: dict[music21.stream.base.Part, int], detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) -
Args
+ -
    -
  • measure (music21.stream.Measure): The music21 measure to extend.
  • -
  • score (music21.stream.Score): the enclosing music21 Score.
  • -
  • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • -
+
+ +
661    def __init__(
+662        self,
+663        staff_group: m21.layout.StaffGroup,
+664        part_to_index: dict[m21.stream.Part, int],
+665        detail: DetailLevel = DetailLevel.Default
+666    ) -> None:
+667        """
+668        Take a StaffGroup and store it as an annotated object.
+669        """
+670        self.staff_group: int | str = staff_group.id
+671        self.name: str = staff_group.name or ''
+672        self.abbreviation: str = staff_group.abbreviation or ''
+673        self.symbol: str | None = None
+674        self.barTogether: bool | str | None = staff_group.barTogether
+675
+676        if DetailLevel.includesStyle(detail):
+677            # symbol (brace, bracket, line, etc) is considered to be style
+678            self.symbol = staff_group.symbol
+679
+680        self.part_indices: list[int] = []
+681        for part in staff_group:
+682            self.part_indices.append(part_to_index.get(part, -1))
+683
+684        # sort so simple list comparison can work
+685        self.part_indices.sort()
+686
+687        self.n_of_parts: int = len(self.part_indices)
+688
+689        # precomputed representations for faster comparison
+690        self.precomputed_str: str = self.__str__()
+
+ + +

Take a StaffGroup and store it as an annotated object.

-
-
#   +
+
+ staff_group: int | str - def - notation_size(self):
+ + + -
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`.
-
-        Returns:
-            int: The notation size of the annotated measure
-        """
-        return sum([v.notation_size() for v in self.voices_list]) + sum([e.notation_size() for e in self.extras_list])
-
- -
- -

Compute a measure of how many symbols are displayed in the score for this AnnMeasure.

+
+
+
+ name: str -
Returns
+ +
+ + + -
-

int: The notation size of the annotated measure

-
-
+
+
+
+ abbreviation: str + +
+ + +
-
-
#   +
+
+ symbol: str | None - def - get_note_ids(self):
+ + + -
- View Source -
    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnMeasure`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this measure
-        """
-        notes_id = []
-        for v in self.voices_list:
-            notes_id.extend(v.get_note_ids())
-        return notes_id
-
+
+
+
+ barTogether: bool | str | None - + +
+ + + -

Computes a list of the GeneralNote ids for this AnnMeasure.

+
+
+
+ part_indices: list[int] -
Returns
+ +
+ + + -
-

[int]: A list containing the GeneralNote ids contained in this measure

-
-
+
+
+
+ n_of_parts: int + +
+ + +
-
-
-
- #   +
+
+ precomputed_str: str - class - AnnPart: -
- -
- View Source -
class AnnPart:
-    def __init__(self, part: m21.stream.Part,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
-
-        Args:
-            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.part = part.id
-        self.bar_list = []
-        for measure in part.getElementsByClass("Measure"):
-            ann_bar = AnnMeasure(measure, score, spannerBundle, detail)  # create the bar objects
-            if ann_bar.n_of_voices > 0:
-                self.bar_list.append(ann_bar)
-        self.n_of_bars = len(self.bar_list)
-        # precomputed str to speed up the computation. String itself start to be long, so it is hashed
-        self.precomputed_str = hash(self.__str__())
-
-    def __str__(self):
-        return str([str(b) for b in self.bar_list])
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnPart):
-            return False
-
-        if len(self.bar_list) != len(other.bar_list):
-            return False
-
-        return all(b[0] == b[1] for b in zip(self.bar_list, other.bar_list))
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
-
-        Returns:
-            int: The notation size of the annotated part
-        """
-        return sum([b.notation_size() for b in self.bar_list])
-
-    def __repr__(self):
-        return self.bar_list.__repr__()
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnPart`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this part
-        """
-        notes_id = []
-        for b in self.bar_list:
-            notes_id.extend(b.get_note_ids())
-        return notes_id
-
- -
- +
+ + -
- +
+ +
+ + def + notation_size(self) -> int: - - AnnPart( - part: music21.stream.base.Part, - score: music21.stream.base.Score, - spannerBundle: music21.spanner.SpannerBundle, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, part: m21.stream.Part,
-                       score: m21.stream.Score,
-                       spannerBundle: m21.spanner.SpannerBundle,
-                       detail: DetailLevel = DetailLevel.Default):
-        """
-        Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
-
-        Args:
-            part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
-            score (music21.stream.Score): the enclosing music21 Score.
-            spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.part = part.id
-        self.bar_list = []
-        for measure in part.getElementsByClass("Measure"):
-            ann_bar = AnnMeasure(measure, score, spannerBundle, detail)  # create the bar objects
-            if ann_bar.n_of_voices > 0:
-                self.bar_list.append(ann_bar)
-        self.n_of_bars = len(self.bar_list)
-        # precomputed str to speed up the computation. String itself start to be long, so it is hashed
-        self.precomputed_str = hash(self.__str__())
-
- -
+ -

Extend music21 Part/PartStaff with some precomputed, easily compared information about it.

+
+ +
730    def notation_size(self) -> int:
+731        """
+732        Compute a measure of how many symbols are displayed in the score for this `AnnStaffGroup`.
+733
+734        Returns:
+735            int: The notation size of the annotated staff group
+736        """
+737        # notation_size = 5 because there are 5 main visible things about a StaffGroup:
+738        #   name, abbreviation, symbol shape, barline type, and which parts it encloses
+739        return 5
+
-
Args
-
    -
  • part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
  • -
  • score (music21.stream.Score): the enclosing music21 Score.
  • -
  • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • -
+

Compute a measure of how many symbols are displayed in the score for this AnnStaffGroup.

+ +
Returns:
+ +
+

int: The notation size of the annotated staff group

+
-
-
+
+ +
+ + class + AnnMetadataItem: + + - - def - notation_size(self):
+ +
750class AnnMetadataItem:
+751    def __init__(
+752        self,
+753        key: str,
+754        value: t.Any
+755    ) -> None:
+756        self.key = key
+757        if isinstance(value, m21.metadata.Text):
+758            # Create a string representing both the text and the language, but not isTranslated,
+759            # since isTranslated cannot be represented in many file formats.
+760            self.value = str(value) + f'(language={value.language})'
+761        elif isinstance(value, m21.metadata.Contributor):
+762            # Create a string (same thing: value.name.isTranslated will differ randomly)
+763            # Currently I am also ignoring more than one name, and birth/death.
+764            self.value = str(value) + f'(role={value.role}, language={value._names[0].language})'
+765        else:
+766            self.value = value
+767
+768    def __eq__(self, other) -> bool:
+769        if not isinstance(other, AnnMetadataItem):
+770            return False
+771
+772        if self.key != other.key:
+773            return False
+774
+775        if self.value != other.value:
+776            return False
+777
+778        return True
+779
+780    def __str__(self) -> str:
+781        return self.__repr__()
+782
+783    def __repr__(self) -> str:
+784        return self.key + ':' + str(self.value)
+785
+786    def notation_size(self) -> int:
+787        """
+788        Compute a measure of how many symbols are displayed in the score for this `AnnMetadataItem`.
+789
+790        Returns:
+791            int: The notation size of the annotated metadata item
+792        """
+793        return 1
+
-
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnPart`.
 
-        Returns:
-            int: The notation size of the annotated part
-        """
-        return sum([b.notation_size() for b in self.bar_list])
-
+ -
+
+ +
+ + AnnMetadataItem(key: str, value: Any) -

Compute a measure of how many symbols are displayed in the score for this AnnPart.

+ -
Returns
+
+ +
751    def __init__(
+752        self,
+753        key: str,
+754        value: t.Any
+755    ) -> None:
+756        self.key = key
+757        if isinstance(value, m21.metadata.Text):
+758            # Create a string representing both the text and the language, but not isTranslated,
+759            # since isTranslated cannot be represented in many file formats.
+760            self.value = str(value) + f'(language={value.language})'
+761        elif isinstance(value, m21.metadata.Contributor):
+762            # Create a string (same thing: value.name.isTranslated will differ randomly)
+763            # Currently I am also ignoring more than one name, and birth/death.
+764            self.value = str(value) + f'(role={value.role}, language={value._names[0].language})'
+765        else:
+766            self.value = value
+
-
-

int: The notation size of the annotated part

-
-
+
-
-
#   +
+
+ key - def - get_note_ids(self):
+ + + -
- View Source -
    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnPart`.
+                            
+
+ +
+ + def + notation_size(self) -> int: + + - Returns: - [int]: A list containing the GeneralNote ids contained in this part - """ - notes_id = [] - for b in self.bar_list: - notes_id.extend(b.get_note_ids()) - return notes_id -
+
+ +
786    def notation_size(self) -> int:
+787        """
+788        Compute a measure of how many symbols are displayed in the score for this `AnnMetadataItem`.
+789
+790        Returns:
+791            int: The notation size of the annotated metadata item
+792        """
+793        return 1
+
-
-

Computes a list of the GeneralNote ids for this AnnPart.

+

Compute a measure of how many symbols are displayed in the score for this AnnMetadataItem.

-
Returns
+
Returns:
-

[int]: A list containing the GeneralNote ids contained in this part

+

int: The notation size of the annotated metadata item

@@ -2001,160 +3543,300 @@
Returns
-
- #   + +
+ + class + AnnScore: + + + +
+ +
796class AnnScore:
+797    def __init__(
+798        self,
+799        score: m21.stream.Score,
+800        detail: DetailLevel = DetailLevel.Default
+801    ) -> None:
+802        """
+803        Take a music21 score and store it as a sequence of Full Trees.
+804        The hierarchy is "score -> parts -> measures -> voices -> notes"
+805        Args:
+806            score (music21.stream.Score): The music21 score
+807            detail (DetailLevel): What level of detail to use during the diff.
+808                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+809                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+810                or Default (Default is currently equivalent to AllObjects).
+811        """
+812        self.score: int | str = score.id
+813        self.part_list: list[AnnPart] = []
+814        self.staff_group_list: list[AnnStaffGroup] = []
+815        self.metadata_items_list: list[AnnMetadataItem] = []
+816
+817        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
+818        part_to_index: dict[m21.stream.Part, int] = {}
+819
+820        # Before we start, transpose all notes to written pitch, both for transposing
+821        # instruments and Ottavas. Be careful to preserve accidental.displayStatus
+822        # during transposition, since we use that visibility indicator when comparing
+823        # accidentals.
+824        score.toWrittenPitch(inPlace=True, preserveAccidentalDisplay=True)
+825
+826        for idx, part in enumerate(score.parts):
+827            # create and add the AnnPart object to part_list
+828            # and to part_to_index dict
+829            part_to_index[part] = idx
+830            ann_part = AnnPart(part, score, spannerBundle, detail)
+831            self.part_list.append(ann_part)
+832
+833        self.n_of_parts: int = len(self.part_list)
+834
+835        if DetailLevel.includesOtherMusicObjects(detail):
+836            # staffgroups are extras (a.k.a. OtherMusicObjects)
+837            for staffGroup in score[m21.layout.StaffGroup]:
+838                ann_staff_group = AnnStaffGroup(staffGroup, part_to_index, detail)
+839                if ann_staff_group.n_of_parts > 0:
+840                    self.staff_group_list.append(ann_staff_group)
+841
+842        if DetailLevel.includesMetadata(detail) and score.metadata is not None:
+843            # m21 metadata.all() can't sort primitives, so we'll have to sort by hand.
+844            allItems: list[tuple[str, t.Any]] = list(
+845                score.metadata.all(returnPrimitives=True, returnSorted=False)
+846            )
+847            allItems.sort(key=lambda each: (each[0], str(each[1])))
+848            for key, value in allItems:
+849                if key in ('fileFormat', 'filePath', 'software'):
+850                    # Don't compare metadata items that are uninterestingly different.
+851                    continue
+852                if (key.startswith('raw:')
+853                        or key.startswith('meiraw:')
+854                        or key.startswith('humdrumraw:')):
+855                    # Don't compare verbatim/raw metadata ('meiraw:meihead',
+856                    # 'raw:freeform', 'humdrumraw:XXX'), it's often deleted
+857                    # when made obsolete by conversions/edits.
+858                    continue
+859                self.metadata_items_list.append(AnnMetadataItem(key, value))
+860
+861    def __eq__(self, other) -> bool:
+862        # equality does not consider MEI id!
+863        if not isinstance(other, AnnScore):
+864            return False
+865
+866        if len(self.part_list) != len(other.part_list):
+867            return False
+868
+869        return all(p[0] == p[1] for p in zip(self.part_list, other.part_list))
+870
+871    def notation_size(self) -> int:
+872        """
+873        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
+874
+875        Returns:
+876            int: The notation size of the annotated score
+877        """
+878        return sum([p.notation_size() for p in self.part_list])
+879
+880    def __repr__(self) -> str:
+881        return self.part_list.__repr__()
+882
+883    def get_note_ids(self) -> list[str | int]:
+884        """
+885        Computes a list of the GeneralNote ids for this `AnnScore`.
+886
+887        Returns:
+888            [int]: A list containing the GeneralNote ids contained in this score
+889        """
+890        notes_id = []
+891        for p in self.part_list:
+892            notes_id.extend(p.get_note_ids())
+893        return notes_id
+894
+895    # return the sequences of measures for a specified part
+896    def _measures_from_part(self, part_number) -> list[AnnMeasure]:
+897        # only used by tests/test_scl.py
+898        if part_number not in range(0, len(self.part_list)):
+899            raise ValueError(
+900                f"parameter 'part_number' should be between 0 and {len(self.part_list) - 1}"
+901            )
+902        return self.part_list[part_number].bar_list
+
- - class - AnnScore: -
- -
- View Source -
class AnnScore:
-    def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Take a music21 score and store it as a sequence of Full Trees.
-        The hierarchy is "score -> parts -> measures -> voices -> notes"
-        Args:
-            score (music21.stream.Score): The music21 score
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.score = score.id
-        self.part_list = []
-        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
-        for part in score.parts.stream():
-            # create and add the AnnPart object to part_list
-            ann_part = AnnPart(part, score, spannerBundle, detail)
-            if ann_part.n_of_bars > 0:
-                self.part_list.append(ann_part)
-        self.n_of_parts = len(self.part_list)
-
-    def __eq__(self, other):
-        # equality does not consider MEI id!
-        if not isinstance(other, AnnScore):
-            return False
-
-        if len(self.part_list) != len(other.part_list):
-            return False
-
-        return all(p[0] == p[1] for p in zip(self.part_list, other.part_list))
-
-    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
-
-        Returns:
-            int: The notation size of the annotated score
-        """
-        return sum([p.notation_size() for p in self.part_list])
-
-    def __repr__(self):
-        return self.part_list.__repr__()
-
-    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnScore`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this score
-        """
-        notes_id = []
-        for p in self.part_list:
-            notes_id.extend(p.get_note_ids())
-        return notes_id
-
-    # return the sequences of measures for a specified part
-    def _measures_from_part(self, part_number):
-        # only used by tests/test_scl.py
-        if part_number not in range(0, len(self.part_list)):
-            raise Exception(
-                f"parameter 'part_number' should be between 0 and {len(self.part_list) - 1}"
-            )
-        return self.part_list[part_number].bar_list
-
- -
-
#   + +
+ + AnnScore( score: music21.stream.base.Score, detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) + + + +
+ +
797    def __init__(
+798        self,
+799        score: m21.stream.Score,
+800        detail: DetailLevel = DetailLevel.Default
+801    ) -> None:
+802        """
+803        Take a music21 score and store it as a sequence of Full Trees.
+804        The hierarchy is "score -> parts -> measures -> voices -> notes"
+805        Args:
+806            score (music21.stream.Score): The music21 score
+807            detail (DetailLevel): What level of detail to use during the diff.
+808                Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,
+809                GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,
+810                or Default (Default is currently equivalent to AllObjects).
+811        """
+812        self.score: int | str = score.id
+813        self.part_list: list[AnnPart] = []
+814        self.staff_group_list: list[AnnStaffGroup] = []
+815        self.metadata_items_list: list[AnnMetadataItem] = []
+816
+817        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
+818        part_to_index: dict[m21.stream.Part, int] = {}
+819
+820        # Before we start, transpose all notes to written pitch, both for transposing
+821        # instruments and Ottavas. Be careful to preserve accidental.displayStatus
+822        # during transposition, since we use that visibility indicator when comparing
+823        # accidentals.
+824        score.toWrittenPitch(inPlace=True, preserveAccidentalDisplay=True)
+825
+826        for idx, part in enumerate(score.parts):
+827            # create and add the AnnPart object to part_list
+828            # and to part_to_index dict
+829            part_to_index[part] = idx
+830            ann_part = AnnPart(part, score, spannerBundle, detail)
+831            self.part_list.append(ann_part)
+832
+833        self.n_of_parts: int = len(self.part_list)
+834
+835        if DetailLevel.includesOtherMusicObjects(detail):
+836            # staffgroups are extras (a.k.a. OtherMusicObjects)
+837            for staffGroup in score[m21.layout.StaffGroup]:
+838                ann_staff_group = AnnStaffGroup(staffGroup, part_to_index, detail)
+839                if ann_staff_group.n_of_parts > 0:
+840                    self.staff_group_list.append(ann_staff_group)
+841
+842        if DetailLevel.includesMetadata(detail) and score.metadata is not None:
+843            # m21 metadata.all() can't sort primitives, so we'll have to sort by hand.
+844            allItems: list[tuple[str, t.Any]] = list(
+845                score.metadata.all(returnPrimitives=True, returnSorted=False)
+846            )
+847            allItems.sort(key=lambda each: (each[0], str(each[1])))
+848            for key, value in allItems:
+849                if key in ('fileFormat', 'filePath', 'software'):
+850                    # Don't compare metadata items that are uninterestingly different.
+851                    continue
+852                if (key.startswith('raw:')
+853                        or key.startswith('meiraw:')
+854                        or key.startswith('humdrumraw:')):
+855                    # Don't compare verbatim/raw metadata ('meiraw:meihead',
+856                    # 'raw:freeform', 'humdrumraw:XXX'), it's often deleted
+857                    # when made obsolete by conversions/edits.
+858                    continue
+859                self.metadata_items_list.append(AnnMetadataItem(key, value))
+
- - AnnScore( - score: music21.stream.base.Score, - detail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 2> -) -
- -
- View Source -
    def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
-        """
-        Take a music21 score and store it as a sequence of Full Trees.
-        The hierarchy is "score -> parts -> measures -> voices -> notes"
-        Args:
-            score (music21.stream.Score): The music21 score
-            detail (DetailLevel): What level of detail to use during the diff.  Can be
-                GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
-                currently equivalent to AllObjects).
-        """
-        self.score = score.id
-        self.part_list = []
-        spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
-        for part in score.parts.stream():
-            # create and add the AnnPart object to part_list
-            ann_part = AnnPart(part, score, spannerBundle, detail)
-            if ann_part.n_of_bars > 0:
-                self.part_list.append(ann_part)
-        self.n_of_parts = len(self.part_list)
-
- -

Take a music21 score and store it as a sequence of Full Trees. The hierarchy is "score -> parts -> measures -> voices -> notes"

-
Args
+
Arguments:
  • score (music21.stream.Score): The music21 score
  • -
  • detail (DetailLevel): What level of detail to use during the diff. Can be -GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is -currently equivalent to AllObjects).
  • +
  • detail (DetailLevel): What level of detail to use during the diff. +Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, +GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, +or Default (Default is currently equivalent to AllObjects).
-
-
#   +
+
+ score: int | str + + +
+ + + + +
+
+
+ part_list: list[AnnPart] + + +
+ + + + +
+
+
+ staff_group_list: list[AnnStaffGroup] + + +
+ + + + +
+
+
+ metadata_items_list: list[AnnMetadataItem] + + +
+ + + + +
+
+
+ n_of_parts: int - def - notation_size(self):
+ + + -
- View Source -
    def notation_size(self):
-        """
-        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
+                            
+
+ +
+ + def + notation_size(self) -> int: - Returns: - int: The notation size of the annotated score - """ - return sum([p.notation_size() for p in self.part_list]) -
+ + +
+ +
871    def notation_size(self) -> int:
+872        """
+873        Compute a measure of how many symbols are displayed in the score for this `AnnScore`.
+874
+875        Returns:
+876            int: The notation size of the annotated score
+877        """
+878        return sum([p.notation_size() for p in self.part_list])
+
-

Compute a measure of how many symbols are displayed in the score for this AnnScore.

-
Returns
+
Returns:

int: The notation size of the annotated score

@@ -2164,33 +3846,33 @@
Returns
-
#   + +
+ + def + get_note_ids(self) -> list[str | int]: - - def - get_note_ids(self): -
+ -
- View Source -
    def get_note_ids(self):
-        """
-        Computes a list of the GeneralNote ids for this `AnnScore`.
-
-        Returns:
-            [int]: A list containing the GeneralNote ids contained in this score
-        """
-        notes_id = []
-        for p in self.part_list:
-            notes_id.extend(p.get_note_ids())
-        return notes_id
-
+
+ +
883    def get_note_ids(self) -> list[str | int]:
+884        """
+885        Computes a list of the GeneralNote ids for this `AnnScore`.
+886
+887        Returns:
+888            [int]: A list containing the GeneralNote ids contained in this score
+889        """
+890        notes_id = []
+891        for p in self.part_list:
+892            notes_id.extend(p.get_note_ids())
+893        return notes_id
+
-

Computes a list of the GeneralNote ids for this AnnScore.

-
Returns
+
Returns:

[int]: A list containing the GeneralNote ids contained in this score

@@ -2301,9 +3983,13 @@
Returns
} let heading; - switch (result.doc.type) { + switch (result.doc.kind) { case "function": - heading = `${doc.funcdef} ${doc.fullname}${doc.signature}:`; + if (doc.fullname.endsWith(".__init__")) { + heading = `${doc.fullname.replace(/\.__init__$/, "")}${doc.signature}`; + } else { + heading = `${doc.funcdef} ${doc.fullname}${doc.signature}`; + } break; case "class": heading = `class ${doc.fullname}`; @@ -2316,7 +4002,7 @@
Returns
if (doc.annotation) heading += `${doc.annotation}`; if (doc.default_value) - heading += `${doc.default_value}`; + heading += ` = ${doc.default_value}`; break; default: heading = `${doc.fullname}`; @@ -2324,7 +4010,7 @@
Returns
} html += `
- ${heading} + ${heading}
${doc.doc}
`; diff --git a/docs/musicdiff/comparison.html b/docs/musicdiff/comparison.html index cd64936..2fe5d7f 100644 --- a/docs/musicdiff/comparison.html +++ b/docs/musicdiff/comparison.html @@ -3,39 +3,36 @@ - + musicdiff.comparison API documentation - - - - - - -
-
+

musicdiff.comparison

-
- View Source -
# ------------------------------------------------------------------------------
-# Purpose:       comparison is a music comparison package for use by musicdiff.
-#                musicdiff is a package for comparing music scores using music21.
-#
-# Authors:       Greg Chapman <gregc@mac.com>
-#                musicdiff is derived from:
-#                   https://github.com/fosfrancesco/music-score-diff.git
-#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
-#
-# Copyright:     (c) 2022 Francesco Foscarin, Greg Chapman
-# License:       MIT, see LICENSE
-# ------------------------------------------------------------------------------
-
-__docformat__ = "google"
-
-import copy
-from typing import List, Tuple
-from collections import namedtuple
-from difflib import ndiff
-
-import numpy as np
-
-from musicdiff.annotation import AnnScore, AnnNote, AnnVoice, AnnExtra
-from musicdiff import M21Utils
-
-# memoizers to speed up the recursive computation
-def _memoize_inside_bars_diff_lin(func):
-    mem = {}
-
-    def memoizer(original, compare_to):
-        key = repr(original) + repr(compare_to)
-        if key not in mem:
-            mem[key] = func(original, compare_to)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-def _memoize_extras_diff_lin(func):
-    mem = {}
-
-    def memoizer(original, compare_to):
-        key = repr(original) + repr(compare_to)
-        if key not in mem:
-            mem[key] = func(original, compare_to)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-def _memoize_block_diff_lin(func):
-    mem = {}
-
-    def memoizer(original, compare_to):
-        key = repr(original) + repr(compare_to)
-        if key not in mem:
-            mem[key] = func(original, compare_to)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-def _memoize_pitches_lev_diff(func):
-    mem = {}
-
-    def memoizer(original, compare_to, noteNode1, noteNode2, ids):
-        key = (
-            repr(original)
-            + repr(compare_to)
-            + repr(noteNode1)
-            + repr(noteNode2)
-            + repr(ids)
-        )
-        if key not in mem:
-            mem[key] = func(original, compare_to, noteNode1, noteNode2, ids)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-def _memoize_beamtuplet_lev_diff(func):
-    mem = {}
-
-    def memoizer(original, compare_to, noteNode1, noteNode2, which):
-        key = (
-            repr(original) + repr(compare_to) + repr(noteNode1) + repr(noteNode2) + which
-        )
-        if key not in mem:
-            mem[key] = func(original, compare_to, noteNode1, noteNode2, which)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-def _memoize_generic_lev_diff(func):
-    mem = {}
-
-    def memoizer(original, compare_to, noteNode1, noteNode2, which):
-        key = (
-            repr(original) + repr(compare_to) + repr(noteNode1) + repr(noteNode2) + which
-        )
-        if key not in mem:
-            mem[key] = func(original, compare_to, noteNode1, noteNode2, which)
-        return copy.deepcopy(mem[key])
-
-    return memoizer
-
-class Comparison:
-    @staticmethod
-    def _myers_diff(a_lines, b_lines):
-        # Myers algorithm for LCS of bars (instead of the recursive algorithm in section 3.2)
-        # This marks the farthest-right point along each diagonal in the edit
-        # graph, along with the history that got it there
-        Frontier = namedtuple("Frontier", ["x", "history"])
-        frontier = {1: Frontier(0, [])}
-
-        a_max = len(a_lines)
-        b_max = len(b_lines)
-        for d in range(0, a_max + b_max + 1):
-            for k in range(-d, d + 1, 2):
-                # This determines whether our next search point will be going down
-                # in the edit graph, or to the right.
-                #
-                # The intuition for this is that we should go down if we're on the
-                # left edge (k == -d) to make sure that the left edge is fully
-                # explored.
-                #
-                # If we aren't on the top (k != d), then only go down if going down
-                # would take us to territory that hasn't sufficiently been explored
-                # yet.
-                go_down = k == -d or (k != d and frontier[k - 1].x < frontier[k + 1].x)
-
-                # Figure out the starting point of this iteration. The diagonal
-                # offsets come from the geometry of the edit grid - if you're going
-                # down, your diagonal is lower, and if you're going right, your
-                # diagonal is higher.
-                if go_down:
-                    old_x, history = frontier[k + 1]
-                    x = old_x
-                else:
-                    old_x, history = frontier[k - 1]
-                    x = old_x + 1
-
-                # We want to avoid modifying the old history, since some other step
-                # may decide to use it.
-                history = history[:]
-                y = x - k
-
-                # We start at the invalid point (0, 0) - we should only start building
-                # up history when we move off of it.
-                if 1 <= y <= b_max and go_down:
-                    history.append((1, b_lines[y - 1][1]))  # add comparetostep
-                elif 1 <= x <= a_max:
-                    history.append((0, a_lines[x - 1][1]))  # add originalstep
-
-                # Chew up as many diagonal moves as we can - these correspond to common lines,
-                # and they're considered "free" by the algorithm because we want to maximize
-                # the number of these in the output.
-                while x < a_max and y < b_max and a_lines[x][0] == b_lines[y][0]:
-                    x += 1
-                    y += 1
-                    history.append((2, a_lines[x - 1][1]))  # add equal step
-
-                if x >= a_max and y >= b_max:
-                    # If we're here, then we've traversed through the bottom-left corner,
-                    # and are done.
-                    return np.array(history)
-
-                frontier[k] = Frontier(x, history)
-
-        assert False, "Could not find edit script"
-
-    @staticmethod
-    def _non_common_subsequences_myers(original, compare_to):
-        ### Both original and compare_to are list of lists, or numpy arrays with 2 columns.
-        ### This is necessary because bars need two representation at the same time.
-        ### One without the id (for comparison), and one with the id (to retrieve the bar at the end)
-        # get the list of operations
-        op_list = Comparison._myers_diff(
-            np.array(original, dtype=np.int64), np.array(compare_to, dtype=np.int64)
-        )[::-1]
-        # retrieve the non common subsequences
-        non_common_subsequences = []
-        non_common_subsequences.append({"original": [], "compare_to": []})
-        ind = 0
-        for op in op_list[::-1]:
-            if op[0] == 2:  # equal
-                non_common_subsequences.append({"original": [], "compare_to": []})
-                ind += 1
-            elif op[0] == 0:  # original step
-                non_common_subsequences[ind]["original"].append(op[1])
-            elif op[0] == 1:  # compare to step
-                non_common_subsequences[ind]["compare_to"].append(op[1])
-        # remove the empty dict from the list
-        non_common_subsequences = [
-            s for s in non_common_subsequences if s != {"original": [], "compare_to": []}
-        ]
-        return non_common_subsequences
-
-    @staticmethod
-    def _non_common_subsequences_of_measures(original_m, compare_to_m):
-        # Take the hash for each measure to run faster comparison
-        # We need two hashes: one that is independent of the IDs (precomputed_str, for comparison),
-        # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after computation)
-        original_int = [[o.precomputed_str, o.precomputed_repr] for o in original_m]
-        compare_to_int = [[c.precomputed_str, c.precomputed_repr] for c in compare_to_m]
-        ncs = Comparison._non_common_subsequences_myers(original_int, compare_to_int)
-        # retrieve the original pointers to measures
-        new_out = []
-        for e in ncs:
-            new_out.append({})
-            for k in e.keys():
-                new_out[-1][k] = []
-                for repr_hash in e[k]:
-                    if k == "original":
-                        new_out[-1][k].append(
-                            next(m for m in original_m if m.precomputed_repr == repr_hash)
-                        )
-                    else:
-                        new_out[-1][k].append(
-                            next(m for m in compare_to_m if m.precomputed_repr == repr_hash)
-                        )
-
-        return new_out
-
-    @staticmethod
-    @_memoize_pitches_lev_diff
-    def _pitches_leveinsthein_diff(original, compare_to, noteNode1, noteNode2, ids):
-        """Compute the leveinsthein distance between two sequences of pitches
-        Arguments:
-            original {list} -- list of pitches
-            compare_to {list} -- list of pitches
-            noteNode1 {annotatedNote} --for referencing
-            noteNode2 {annotatedNote} --for referencing
-            ids {tuple} -- a tuple of 2 elements with the indices of the notes considered
-        """
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            cost = M21Utils.pitch_size(compare_to[0])
-            op_list, cost = Comparison._pitches_leveinsthein_diff(
-                original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
-            )
-            op_list.append(
-                ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
-            )
-            cost += M21Utils.pitch_size(compare_to[0])
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            cost = M21Utils.pitch_size(original[0])
-            op_list, cost = Comparison._pitches_leveinsthein_diff(
-                original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
-            )
-            op_list.append(
-                ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
-            )
-            cost += M21Utils.pitch_size(original[0])
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost_dict = {}
-        op_list_dict = {}
-        # del-pitch
-        op_list_dict["delpitch"], cost_dict["delpitch"] = Comparison._pitches_leveinsthein_diff(
-            original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
-        )
-        cost_dict["delpitch"] += M21Utils.pitch_size(original[0])
-        op_list_dict["delpitch"].append(
-            ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
-        )
-        # ins-pitch
-        op_list_dict["inspitch"], cost_dict["inspitch"] = Comparison._pitches_leveinsthein_diff(
-            original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
-        )
-        cost_dict["inspitch"] += M21Utils.pitch_size(compare_to[0])
-        op_list_dict["inspitch"].append(
-            ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
-        )
-        # edit-pitch
-        op_list_dict["editpitch"], cost_dict["editpitch"] = Comparison._pitches_leveinsthein_diff(
-            original[1:], compare_to[1:], noteNode1, noteNode2, (ids[0] + 1, ids[1] + 1)
-        )
-        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
-            pitch_diff_op_list = []
-            pitch_diff_cost = 0
-        else:
-            pitch_diff_op_list, pitch_diff_cost = Comparison._pitches_diff(
-                original[0], compare_to[0], noteNode1, noteNode2, (ids[0], ids[1])
-            )
-        cost_dict["editpitch"] += pitch_diff_cost
-        op_list_dict["editpitch"].extend(pitch_diff_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost_dict, key=lambda k: cost_dict[k])
-        out = op_list_dict[min_key], cost_dict[min_key]
-        return out
-
-    @staticmethod
-    def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids):
-        """compute the differences between two pitch (definition from the paper).
-        a pitch consist of a tuple: pitch name (letter+number), accidental, tie.
-        param : pitch1. The music_notation_repr tuple of note1
-        param : pitch2. The music_notation_repr tuple of note2
-        param : noteNode1. The noteNode where pitch1 belongs
-        param : noteNode2. The noteNode where pitch2 belongs
-        param : ids. (id_from_note1,id_from_note2) The indices of the notes in case of a chord
-        Returns:
-            [list] -- the list of differences
-            [int] -- the cost of diff
-        """
-        cost = 0
-        op_list = []
-        # add for pitch name differences
-        if pitch1[0] != pitch2[0]:
-            cost += 1
-            # TODO: select the note in a more precise way in case of a chord
-            # rest to note
-            if (pitch1[0][0] == "R") != (pitch2[0][0] == "R"):  # xor
-                op_list.append(("pitchtypeedit", noteNode1, noteNode2, 1, ids))
-            else:  # they are two notes
-                op_list.append(("pitchnameedit", noteNode1, noteNode2, 1, ids))
-        # add for the accidentals
-        if pitch1[1] != pitch2[1]:  # if the accidental is different
-            cost += 1
-            if pitch1[1] == "None":
-                assert pitch2[1] != "None"
-                op_list.append(("accidentins", noteNode1, noteNode2, 1, ids))
-            elif pitch2[1] == "None":
-                assert pitch1[1] != "None"
-                op_list.append(("accidentdel", noteNode1, noteNode2, 1, ids))
-            else:  # a different tipe of alteration is present
-                op_list.append(("accidentedit", noteNode1, noteNode2, 1, ids))
-        # add for the ties
-        if pitch1[2] != pitch2[2]:  # exclusive or. Add if one is tied and not the other
-            ################probably to revise for chords
-            cost += 1
-            if pitch1[2]:
-                assert not pitch2[2]
-                op_list.append(("tiedel", noteNode1, noteNode2, 1, ids))
-            elif pitch2[2]:
-                assert not pitch1[2]
-                op_list.append(("tieins", noteNode1, noteNode2, 1, ids))
-        return op_list, cost
-
-    @staticmethod
-    @_memoize_block_diff_lin
-    def _block_diff_lin(original, compare_to):
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            op_list, cost = Comparison._block_diff_lin(original, compare_to[1:])
-            cost += compare_to[0].notation_size()
-            op_list.append(("insbar", None, compare_to[0], compare_to[0].notation_size()))
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            op_list, cost = Comparison._block_diff_lin(original[1:], compare_to)
-            cost += original[0].notation_size()
-            op_list.append(("delbar", original[0], None, original[0].notation_size()))
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost_dict = {}
-        op_list_dict = {}
-        # del-bar
-        op_list_dict["delbar"], cost_dict["delbar"] = Comparison._block_diff_lin(
-            original[1:], compare_to
-        )
-        cost_dict["delbar"] += original[0].notation_size()
-        op_list_dict["delbar"].append(
-            ("delbar", original[0], None, original[0].notation_size())
-        )
-        # ins-bar
-        op_list_dict["insbar"], cost_dict["insbar"] = Comparison._block_diff_lin(
-            original, compare_to[1:]
-        )
-        cost_dict["insbar"] += compare_to[0].notation_size()
-        op_list_dict["insbar"].append(
-            ("insbar", None, compare_to[0], compare_to[0].notation_size())
-        )
-        # edit-bar
-        op_list_dict["editbar"], cost_dict["editbar"] = Comparison._block_diff_lin(
-            original[1:], compare_to[1:]
-        )
-        if (
-            original[0] == compare_to[0]
-        ):  # to avoid performing the _voices_coupling_recursive if it's not needed
-            inside_bar_op_list = []
-            inside_bar_cost = 0
-        else:
-            # diff the bar extras (like _inside_bars_diff_lin, but with lists of AnnExtras
-            # instead of lists of AnnNotes)
-            extras_op_list, extras_cost = Comparison._extras_diff_lin(
-                original[0].extras_list, compare_to[0].extras_list
-            )
-
-            # run the voice coupling algorithm, and add to inside_bar_op_list and inside_bar_cost
-            inside_bar_op_list, inside_bar_cost = Comparison._voices_coupling_recursive(
-                original[0].voices_list, compare_to[0].voices_list
-            )
-            inside_bar_op_list.extend(extras_op_list)
-            inside_bar_cost += extras_cost
-        cost_dict["editbar"] += inside_bar_cost
-        op_list_dict["editbar"].extend(inside_bar_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost_dict, key=lambda k: cost_dict[k])
-        out = op_list_dict[min_key], cost_dict[min_key]
-        return out
-
-    @staticmethod
-    @_memoize_extras_diff_lin
-    def _extras_diff_lin(original, compare_to):
-        # original and compare to are two lists of AnnExtra
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            cost = 0
-            op_list, cost = Comparison._extras_diff_lin(original, compare_to[1:])
-            op_list.append(("extrains", None, compare_to[0], compare_to[0].notation_size()))
-            cost += compare_to[0].notation_size()
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            cost = 0
-            op_list, cost = Comparison._extras_diff_lin(original[1:], compare_to)
-            op_list.append(("extradel", original[0], None, original[0].notation_size()))
-            cost += original[0].notation_size()
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost = {}
-        op_list = {}
-        # extradel
-        op_list["extradel"], cost["extradel"] = Comparison._extras_diff_lin(
-            original[1:], compare_to
-        )
-        cost["extradel"] += original[0].notation_size()
-        op_list["extradel"].append(
-            ("extradel", original[0], None, original[0].notation_size())
-        )
-        # extrains
-        op_list["extrains"], cost["extrains"] = Comparison._extras_diff_lin(
-            original, compare_to[1:]
-        )
-        cost["extrains"] += compare_to[0].notation_size()
-        op_list["extrains"].append(
-            ("extrains", None, compare_to[0], compare_to[0].notation_size())
-        )
-        # extrasub
-        op_list["extrasub"], cost["extrasub"] = Comparison._extras_diff_lin(
-            original[1:], compare_to[1:]
-        )
-        if (
-            original[0] == compare_to[0]
-        ):  # avoid call another function if they are equal
-            extrasub_op, extrasub_cost = [], 0
-        else:
-            extrasub_op, extrasub_cost = Comparison._annotated_extra_diff(original[0], compare_to[0])
-        cost["extrasub"] += extrasub_cost
-        op_list["extrasub"].extend(extrasub_op)
-        # compute the minimum of the possibilities
-        min_key = min(cost, key=cost.get)
-        out = op_list[min_key], cost[min_key]
-        return out
-
-    @staticmethod
-    def _strings_leveinshtein_distance(str1: str, str2: str):
-        counter: dict = {"+": 0, "-": 0}
-        distance: int = 0
-        for edit_code, *_ in ndiff(str1, str2):
-            if edit_code == " ":
-                distance += max(counter.values())
-                counter = {"+": 0, "-": 0}
-            else:
-                counter[edit_code] += 1
-        distance += max(counter.values())
-        return distance
-
-    @staticmethod
-    def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
-        """compute the differences between two annotated extras
-        Each annotated extra consists of three values: content, offset, and duration
-        """
-        cost = 0
-        op_list = []
-
-        # add for the content
-        if annExtra1.content != annExtra2.content:
-            content_cost: int = Comparison._strings_leveinshtein_distance(
-                                            annExtra1.content, annExtra2.content)
-            cost += content_cost
-            op_list.append(("extracontentedit", annExtra1, annExtra2, content_cost))
-
-        # add for the offset
-        if annExtra1.offset != annExtra2.offset:
-            # offset is in quarter-notes, so let's make the cost in quarter-notes as well.
-            # min cost is 1, though, don't round down to zero.
-            offset_cost: int = min(1, abs(annExtra1.duration - annExtra2.duration))
-            cost += offset_cost
-            op_list.append(("extraoffsetedit", annExtra1, annExtra2, offset_cost))
-
-        # add for the duration
-        if annExtra1.duration != annExtra2.duration:
-            # duration is in quarter-notes, so let's make the cost in quarter-notes as well.
-            duration_cost = min(1, abs(annExtra1.duration - annExtra2.duration))
-            cost += duration_cost
-            op_list.append(("extradurationedit", annExtra1, annExtra2, duration_cost))
-
-        # add for the style
-        if annExtra1.styledict != annExtra2.styledict:
-            cost += 1
-            op_list.append(("extrastyleedit", annExtra1, annExtra2, 1))
-
-        return op_list, cost
-
-    @staticmethod
-    @_memoize_inside_bars_diff_lin
-    def _inside_bars_diff_lin(original, compare_to):
-        # original and compare to are two lists of annotatedNote
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            cost = 0
-            op_list, cost = Comparison._inside_bars_diff_lin(original, compare_to[1:])
-            op_list.append(("noteins", None, compare_to[0], compare_to[0].notation_size()))
-            cost += compare_to[0].notation_size()
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            cost = 0
-            op_list, cost = Comparison._inside_bars_diff_lin(original[1:], compare_to)
-            op_list.append(("notedel", original[0], None, original[0].notation_size()))
-            cost += original[0].notation_size()
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost = {}
-        op_list = {}
-        # notedel
-        op_list["notedel"], cost["notedel"] = Comparison._inside_bars_diff_lin(
-            original[1:], compare_to
-        )
-        cost["notedel"] += original[0].notation_size()
-        op_list["notedel"].append(
-            ("notedel", original[0], None, original[0].notation_size())
-        )
-        # noteins
-        op_list["noteins"], cost["noteins"] = Comparison._inside_bars_diff_lin(
-            original, compare_to[1:]
-        )
-        cost["noteins"] += compare_to[0].notation_size()
-        op_list["noteins"].append(
-            ("noteins", None, compare_to[0], compare_to[0].notation_size())
-        )
-        # notesub
-        op_list["notesub"], cost["notesub"] = Comparison._inside_bars_diff_lin(
-            original[1:], compare_to[1:]
-        )
-        if (
-            original[0] == compare_to[0]
-        ):  # avoid call another function if they are equal
-            notesub_op, notesub_cost = [], 0
-        else:
-            notesub_op, notesub_cost = Comparison._annotated_note_diff(original[0], compare_to[0])
-        cost["notesub"] += notesub_cost
-        op_list["notesub"].extend(notesub_op)
-        # compute the minimum of the possibilities
-        min_key = min(cost, key=cost.get)
-        out = op_list[min_key], cost[min_key]
-        return out
-
-    @staticmethod
-    def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote):
-        """compute the differences between two annotated notes
-        Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets) where pitches is a list
-        Arguments:
-            noteNode1 {[AnnNote]} -- original AnnNote
-            noteNode2 {[AnnNote]} -- compare_to AnnNote
-        """
-        cost = 0
-        op_list = []
-        # add for the pitches
-        # if they are equal
-        if annNote1.pitches == annNote2.pitches:
-            op_list_pitch, cost_pitch = [], 0
-        else:
-            # pitches diff is computed using leveinshtein differences (they are already ordered)
-            op_list_pitch, cost_pitch = Comparison._pitches_leveinsthein_diff(
-                annNote1.pitches, annNote2.pitches, annNote1, annNote2, (0, 0)
-            )
-        op_list.extend(op_list_pitch)
-        cost += cost_pitch
-        # add for the notehead
-        if annNote1.note_head != annNote2.note_head:
-            cost += 1
-            op_list.append(("headedit", annNote1, annNote2, 1))
-        # add for the dots
-        if annNote1.dots != annNote2.dots:
-            dots_diff = abs(annNote1.dots - annNote2.dots)  # add one for each dot
-            cost += dots_diff
-            if annNote1.dots > annNote2.dots:
-                op_list.append(("dotdel", annNote1, annNote2, dots_diff))
-            else:
-                op_list.append(("dotins", annNote1, annNote2, dots_diff))
-        # add for the beamings
-        if annNote1.beamings != annNote2.beamings:
-            beam_op_list, beam_cost = Comparison._beamtuplet_leveinsthein_diff(
-                annNote1.beamings, annNote2.beamings, annNote1, annNote2, "beam"
-            )
-            op_list.extend(beam_op_list)
-            cost += beam_cost
-        # add for the tuplets
-        if annNote1.tuplets != annNote2.tuplets:
-            tuplet_op_list, tuplet_cost = Comparison._beamtuplet_leveinsthein_diff(
-                annNote1.tuplets, annNote2.tuplets, annNote1, annNote2, "tuplet"
-            )
-            op_list.extend(tuplet_op_list)
-            cost += tuplet_cost
-        # add for the articulations
-        if annNote1.articulations != annNote2.articulations:
-            artic_op_list, artic_cost = Comparison._generic_leveinsthein_diff(
-                annNote1.articulations,
-                annNote2.articulations,
-                annNote1,
-                annNote2,
-                "articulation",
-            )
-            op_list.extend(artic_op_list)
-            cost += artic_cost
-        # add for the expressions
-        if annNote1.expressions != annNote2.expressions:
-            expr_op_list, expr_cost = Comparison._generic_leveinsthein_diff(
-                annNote1.expressions,
-                annNote2.expressions,
-                annNote1,
-                annNote2,
-                "expression",
-            )
-            op_list.extend(expr_op_list)
-            cost += expr_cost
-        # add for noteshape
-        if annNote1.noteshape != annNote2.noteshape:
-            cost += 1
-            op_list.append(("editnoteshape", annNote1, annNote2, 1))
-        # add for noteheadFill
-        if annNote1.noteheadFill != annNote2.noteheadFill:
-            cost += 1
-            op_list.append(("editnoteheadfill", annNote1, annNote2, 1))
-        # add for noteheadParenthesis
-        if annNote1.noteheadParenthesis != annNote2.noteheadParenthesis:
-            cost += 1
-            op_list.append(("editnoteheadparenthesis", annNote1, annNote2, 1))
-        # add for stemDirection
-        if annNote1.stemDirection != annNote2.stemDirection:
-            cost += 1
-            op_list.append(("editstemdirection", annNote1, annNote2, 1))
-        # add for the styledict
-        if annNote1.styledict != annNote2.styledict:
-            cost += 1
-            op_list.append(("editstyle", annNote1, annNote2, 1))
-
-        return op_list, cost
-
-    @staticmethod
-    @_memoize_beamtuplet_lev_diff
-    def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which):
-        """Compute the leveinsthein distance between two sequences of beaming or tuples
-        Arguments:
-            original {list} -- list of strings (start, stop, continue or partial)
-            compare_to {list} -- list of strings (start, stop, continue or partial)
-            note1 {AnnNote} -- the note for referencing in the score
-            note2 {AnnNote} -- the note for referencing in the score
-            which -- a string: "beam" or "tuplet" depending what we are comparing
-        """
-        if not which in ("beam", "tuplet"):
-            raise Exception("Argument 'which' must be either 'beam' or 'tuplet'")
-
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
-                original, compare_to[1:], note1, note2, which
-            )
-            op_list.append(("ins" + which, note1, note2, 1))
-            cost += 1
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
-                original[1:], compare_to, note1, note2, which
-            )
-            op_list.append(("del" + which, note1, note2, 1))
-            cost += 1
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost = {}
-        op_list = {}
-        # del-pitch
-        op_list["del" + which], cost["del" + which] = Comparison._beamtuplet_leveinsthein_diff(
-            original[1:], compare_to, note1, note2, which
-        )
-        cost["del" + which] += 1
-        op_list["del" + which].append(("del" + which, note1, note2, 1))
-        # ins-pitch
-        op_list["ins" + which], cost["ins" + which] = Comparison._beamtuplet_leveinsthein_diff(
-            original, compare_to[1:], note1, note2, which
-        )
-        cost["ins" + which] += 1
-        op_list["ins" + which].append(("ins" + which, note1, note2, 1))
-        # edit-pitch
-        op_list["edit" + which], cost["edit" + which] = Comparison._beamtuplet_leveinsthein_diff(
-            original[1:], compare_to[1:], note1, note2, which
-        )
-        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
-            beam_diff_op_list = []
-            beam_diff_cost = 0
-        else:
-            beam_diff_op_list, beam_diff_cost = [("edit" + which, note1, note2, 1)], 1
-        cost["edit" + which] += beam_diff_cost
-        op_list["edit" + which].extend(beam_diff_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost, key=cost.get)
-        out = op_list[min_key], cost[min_key]
-        return out
-
-    @staticmethod
-    @_memoize_generic_lev_diff
-    def _generic_leveinsthein_diff(original, compare_to, note1, note2, which):
-        """Compute the leveinsthein distance between two generic sequences of symbols (e.g., articulations)
-        Arguments:
-            original {list} -- list of strings
-            compare_to {list} -- list of strings
-            note1 {AnnNote} -- the note for referencing in the score
-            note2 {AnnNote} -- the note for referencing in the score
-            which -- a string: e.g. "articulation" depending what we are comparing
-        """
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            op_list, cost = Comparison._generic_leveinsthein_diff(
-                original, compare_to[1:], note1, note2, which
-            )
-            op_list.append(("ins" + which, note1, note2, 1))
-            cost += 1
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            op_list, cost = Comparison._generic_leveinsthein_diff(
-                original[1:], compare_to, note1, note2, which
-            )
-            op_list.append(("del" + which, note1, note2, 1))
-            cost += 1
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost = {}
-        op_list = {}
-        # del-pitch
-        op_list["del" + which], cost["del" + which] = Comparison._generic_leveinsthein_diff(
-            original[1:], compare_to, note1, note2, which
-        )
-        cost["del" + which] += 1
-        op_list["del" + which].append(("del" + which, note1, note2, 1))
-        # ins-pitch
-        op_list["ins" + which], cost["ins" + which] = Comparison._generic_leveinsthein_diff(
-            original, compare_to[1:], note1, note2, which
-        )
-        cost["ins" + which] += 1
-        op_list["ins" + which].append(("ins" + which, None, compare_to[0], 1))
-        # edit-pitch
-        op_list["edit" + which], cost["edit" + which] = Comparison._generic_leveinsthein_diff(
-            original[1:], compare_to[1:], note1, note2, which
-        )
-        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
-            generic_diff_op_list = []
-            generic_diff_cost = 0
-        else:
-            generic_diff_op_list, generic_diff_cost = (
-                [("edit" + which, note1, note2, 1)],
-                1,
-            )
-        cost["edit" + which] += generic_diff_cost
-        op_list["edit" + which].extend(generic_diff_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost, key=cost.get)
-        out = op_list[min_key], cost[min_key]
-        return out
-
-    @staticmethod
-    def _voices_coupling_recursive(original: List[AnnVoice], compare_to: List[AnnVoice]):
-        """compare all the possible voices permutations, considering also deletion and insertion (equation on office lens)
-        original [list] -- a list of Voice
-        compare_to [list] -- a list of Voice
-        """
-        if len(original) == 0 and len(compare_to) == 0:  # stop the recursion
-            return [], 0
+                        
+
+                        
+
+                        
   1# ------------------------------------------------------------------------------
+   2# Purpose:       comparison is a music comparison package for use by musicdiff.
+   3#                musicdiff is a package for comparing music scores using music21.
+   4#
+   5# Authors:       Greg Chapman <gregc@mac.com>
+   6#                musicdiff is derived from:
+   7#                   https://github.com/fosfrancesco/music-score-diff.git
+   8#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
+   9#
+  10# Copyright:     (c) 2022, 2023 Francesco Foscarin, Greg Chapman
+  11# License:       MIT, see LICENSE
+  12# ------------------------------------------------------------------------------
+  13
+  14__docformat__ = "google"
+  15
+  16import copy
+  17from collections import namedtuple
+  18from difflib import ndiff
+  19
+  20# import typing as t
+  21import numpy as np
+  22
+  23from musicdiff.annotation import AnnScore, AnnNote, AnnVoice, AnnExtra, AnnStaffGroup
+  24from musicdiff.annotation import AnnMetadataItem
+  25from musicdiff import M21Utils
+  26
+  27# memoizers to speed up the recursive computation
+  28def _memoize_inside_bars_diff_lin(func):
+  29    mem = {}
+  30
+  31    def memoizer(original, compare_to):
+  32        key = repr(original) + repr(compare_to)
+  33        if key not in mem:
+  34            mem[key] = func(original, compare_to)
+  35        return copy.deepcopy(mem[key])
+  36
+  37    return memoizer
+  38
+  39def _memoize_extras_diff_lin(func):
+  40    mem = {}
+  41
+  42    def memoizer(original, compare_to):
+  43        key = repr(original) + repr(compare_to)
+  44        if key not in mem:
+  45            mem[key] = func(original, compare_to)
+  46        return copy.deepcopy(mem[key])
+  47
+  48    return memoizer
+  49
+  50def _memoize_staff_groups_diff_lin(func):
+  51    mem = {}
+  52
+  53    def memoizer(original, compare_to):
+  54        key = repr(original) + repr(compare_to)
+  55        if key not in mem:
+  56            mem[key] = func(original, compare_to)
+  57        return copy.deepcopy(mem[key])
+  58
+  59    return memoizer
+  60
+  61def _memoize_metadata_items_diff_lin(func):
+  62    mem = {}
+  63
+  64    def memoizer(original, compare_to):
+  65        key = repr(original) + repr(compare_to)
+  66        if key not in mem:
+  67            mem[key] = func(original, compare_to)
+  68        return copy.deepcopy(mem[key])
+  69
+  70    return memoizer
+  71
+  72def _memoize_block_diff_lin(func):
+  73    mem = {}
+  74
+  75    def memoizer(original, compare_to):
+  76        key = repr(original) + repr(compare_to)
+  77        if key not in mem:
+  78            mem[key] = func(original, compare_to)
+  79        return copy.deepcopy(mem[key])
+  80
+  81    return memoizer
+  82
+  83def _memoize_pitches_lev_diff(func):
+  84    mem = {}
+  85
+  86    def memoizer(original, compare_to, noteNode1, noteNode2, ids):
+  87        key = (
+  88            repr(original)
+  89            + repr(compare_to)
+  90            + repr(noteNode1)
+  91            + repr(noteNode2)
+  92            + repr(ids)
+  93        )
+  94        if key not in mem:
+  95            mem[key] = func(original, compare_to, noteNode1, noteNode2, ids)
+  96        return copy.deepcopy(mem[key])
+  97
+  98    return memoizer
+  99
+ 100def _memoize_beamtuplet_lev_diff(func):
+ 101    mem = {}
+ 102
+ 103    def memoizer(original, compare_to, noteNode1, noteNode2, which):
+ 104        key = (
+ 105            repr(original) + repr(compare_to) + repr(noteNode1) + repr(noteNode2) + which
+ 106        )
+ 107        if key not in mem:
+ 108            mem[key] = func(original, compare_to, noteNode1, noteNode2, which)
+ 109        return copy.deepcopy(mem[key])
+ 110
+ 111    return memoizer
+ 112
+ 113def _memoize_generic_lev_diff(func):
+ 114    mem = {}
+ 115
+ 116    def memoizer(original, compare_to, noteNode1, noteNode2, which):
+ 117        key = (
+ 118            repr(original) + repr(compare_to) + repr(noteNode1) + repr(noteNode2) + which
+ 119        )
+ 120        if key not in mem:
+ 121            mem[key] = func(original, compare_to, noteNode1, noteNode2, which)
+ 122        return copy.deepcopy(mem[key])
+ 123
+ 124    return memoizer
+ 125
+ 126class Comparison:
+ 127    @staticmethod
+ 128    def _myers_diff(a_lines, b_lines):
+ 129        # Myers algorithm for LCS of bars (instead of the recursive algorithm in section 3.2)
+ 130        # This marks the farthest-right point along each diagonal in the edit
+ 131        # graph, along with the history that got it there
+ 132        Frontier = namedtuple("Frontier", ["x", "history"])
+ 133        frontier = {1: Frontier(0, [])}
+ 134
+ 135        a_max = len(a_lines)
+ 136        b_max = len(b_lines)
+ 137        for d in range(0, a_max + b_max + 1):
+ 138            for k in range(-d, d + 1, 2):
+ 139                # This determines whether our next search point will be going down
+ 140                # in the edit graph, or to the right.
+ 141                #
+ 142                # The intuition for this is that we should go down if we're on the
+ 143                # left edge (k == -d) to make sure that the left edge is fully
+ 144                # explored.
+ 145                #
+ 146                # If we aren't on the top (k != d), then only go down if going down
+ 147                # would take us to territory that hasn't sufficiently been explored
+ 148                # yet.
+ 149                go_down = k == -d or (k != d and frontier[k - 1].x < frontier[k + 1].x)
+ 150
+ 151                # Figure out the starting point of this iteration. The diagonal
+ 152                # offsets come from the geometry of the edit grid - if you're going
+ 153                # down, your diagonal is lower, and if you're going right, your
+ 154                # diagonal is higher.
+ 155                if go_down:
+ 156                    old_x, history = frontier[k + 1]
+ 157                    x = old_x
+ 158                else:
+ 159                    old_x, history = frontier[k - 1]
+ 160                    x = old_x + 1
+ 161
+ 162                # We want to avoid modifying the old history, since some other step
+ 163                # may decide to use it.
+ 164                history = history[:]
+ 165                y = x - k
+ 166
+ 167                # We start at the invalid point (0, 0) - we should only start building
+ 168                # up history when we move off of it.
+ 169                if 1 <= y <= b_max and go_down:
+ 170                    history.append((1, b_lines[y - 1][1]))  # add comparetostep
+ 171                elif 1 <= x <= a_max:
+ 172                    history.append((0, a_lines[x - 1][1]))  # add originalstep
+ 173
+ 174                # Chew up as many diagonal moves as we can - these correspond to common lines,
+ 175                # and they're considered "free" by the algorithm because we want to maximize
+ 176                # the number of these in the output.
+ 177                while x < a_max and y < b_max and a_lines[x][0] == b_lines[y][0]:
+ 178                    x += 1
+ 179                    y += 1
+ 180                    history.append((2, a_lines[x - 1][1]))  # add equal step
+ 181
+ 182                if x >= a_max and y >= b_max:
+ 183                    # If we're here, then we've traversed through the bottom-left corner,
+ 184                    # and are done.
+ 185                    return np.array(history)
+ 186
+ 187                frontier[k] = Frontier(x, history)
+ 188
+ 189        assert False, "Could not find edit script"
+ 190
+ 191    @staticmethod
+ 192    def _non_common_subsequences_myers(original, compare_to):
+ 193        # Both original and compare_to are list of lists, or numpy arrays with 2 columns.
+ 194        # This is necessary because bars need two representation at the same time.
+ 195        # One without the id (for comparison), and one with the id (to retrieve the bar
+ 196        # at the end).
+ 197
+ 198        # get the list of operations
+ 199        op_list = Comparison._myers_diff(
+ 200            np.array(original, dtype=np.int64), np.array(compare_to, dtype=np.int64)
+ 201        )[::-1]
+ 202        # retrieve the non common subsequences
+ 203        non_common_subsequences = []
+ 204        non_common_subsequences.append({"original": [], "compare_to": []})
+ 205        ind = 0
+ 206        for op in op_list[::-1]:
+ 207            if op[0] == 2:  # equal
+ 208                non_common_subsequences.append({"original": [], "compare_to": []})
+ 209                ind += 1
+ 210            elif op[0] == 0:  # original step
+ 211                non_common_subsequences[ind]["original"].append(op[1])
+ 212            elif op[0] == 1:  # compare to step
+ 213                non_common_subsequences[ind]["compare_to"].append(op[1])
+ 214        # remove the empty dict from the list
+ 215        non_common_subsequences = [
+ 216            s for s in non_common_subsequences if s != {"original": [], "compare_to": []}
+ 217        ]
+ 218        return non_common_subsequences
+ 219
+ 220    @staticmethod
+ 221    def _non_common_subsequences_of_measures(original_m, compare_to_m):
+ 222        # Take the hash for each measure to run faster comparison
+ 223        # We need two hashes: one that is independent of the IDs (precomputed_str, for comparison),
+ 224        # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after
+ 225        # computation)
+ 226        original_int = [[o.precomputed_str, o.precomputed_repr] for o in original_m]
+ 227        compare_to_int = [[c.precomputed_str, c.precomputed_repr] for c in compare_to_m]
+ 228        ncs = Comparison._non_common_subsequences_myers(original_int, compare_to_int)
+ 229        # retrieve the original pointers to measures
+ 230        new_out = []
+ 231        for e in ncs:
+ 232            new_out.append({})
+ 233            for k in e.keys():
+ 234                new_out[-1][k] = []
+ 235                for repr_hash in e[k]:
+ 236                    if k == "original":
+ 237                        new_out[-1][k].append(
+ 238                            next(m for m in original_m if m.precomputed_repr == repr_hash)
+ 239                        )
+ 240                    else:
+ 241                        new_out[-1][k].append(
+ 242                            next(m for m in compare_to_m if m.precomputed_repr == repr_hash)
+ 243                        )
+ 244
+ 245        return new_out
+ 246
+ 247    @staticmethod
+ 248    @_memoize_pitches_lev_diff
+ 249    def _pitches_leveinsthein_diff(
+ 250        original: list[tuple[str, str, bool]],
+ 251        compare_to: list[tuple[str, str, bool]],
+ 252        noteNode1: AnnNote,
+ 253        noteNode2: AnnNote,
+ 254        ids: tuple[int, int]
+ 255    ):
+ 256        """
+ 257        Compute the leveinsthein distance between two sequences of pitches.
+ 258        Arguments:
+ 259            original {list} -- list of pitches
+ 260            compare_to {list} -- list of pitches
+ 261            noteNode1 {annotatedNote} --for referencing
+ 262            noteNode2 {annotatedNote} --for referencing
+ 263            ids {tuple} -- a tuple of 2 elements with the indices of the notes considered
+ 264        """
+ 265        if len(original) == 0 and len(compare_to) == 0:
+ 266            return [], 0
+ 267
+ 268        if len(original) == 0:
+ 269            cost = M21Utils.pitch_size(compare_to[0])
+ 270            op_list, cost = Comparison._pitches_leveinsthein_diff(
+ 271                original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
+ 272            )
+ 273            op_list.append(
+ 274                ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
+ 275            )
+ 276            cost += M21Utils.pitch_size(compare_to[0])
+ 277            return op_list, cost
+ 278
+ 279        if len(compare_to) == 0:
+ 280            cost = M21Utils.pitch_size(original[0])
+ 281            op_list, cost = Comparison._pitches_leveinsthein_diff(
+ 282                original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
+ 283            )
+ 284            op_list.append(
+ 285                ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
+ 286            )
+ 287            cost += M21Utils.pitch_size(original[0])
+ 288            return op_list, cost
+ 289
+ 290        # compute the cost and the op_list for the many possibilities of recursion
+ 291        cost_dict = {}
+ 292        op_list_dict = {}
+ 293        # del-pitch
+ 294        op_list_dict["delpitch"], cost_dict["delpitch"] = Comparison._pitches_leveinsthein_diff(
+ 295            original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
+ 296        )
+ 297        cost_dict["delpitch"] += M21Utils.pitch_size(original[0])
+ 298        op_list_dict["delpitch"].append(
+ 299            ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
+ 300        )
+ 301        # ins-pitch
+ 302        op_list_dict["inspitch"], cost_dict["inspitch"] = Comparison._pitches_leveinsthein_diff(
+ 303            original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
+ 304        )
+ 305        cost_dict["inspitch"] += M21Utils.pitch_size(compare_to[0])
+ 306        op_list_dict["inspitch"].append(
+ 307            ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
+ 308        )
+ 309        # edit-pitch
+ 310        op_list_dict["editpitch"], cost_dict["editpitch"] = Comparison._pitches_leveinsthein_diff(
+ 311            original[1:], compare_to[1:], noteNode1, noteNode2, (ids[0] + 1, ids[1] + 1)
+ 312        )
+ 313        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
+ 314            pitch_diff_op_list = []
+ 315            pitch_diff_cost = 0
+ 316        else:
+ 317            pitch_diff_op_list, pitch_diff_cost = Comparison._pitches_diff(
+ 318                original[0], compare_to[0], noteNode1, noteNode2, (ids[0], ids[1])
+ 319            )
+ 320        cost_dict["editpitch"] += pitch_diff_cost
+ 321        op_list_dict["editpitch"].extend(pitch_diff_op_list)
+ 322        # compute the minimum of the possibilities
+ 323        min_key = min(cost_dict, key=lambda k: cost_dict[k])
+ 324        out = op_list_dict[min_key], cost_dict[min_key]
+ 325        return out
+ 326
+ 327    @staticmethod
+ 328    def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids):
+ 329        """
+ 330        Compute the differences between two pitch (definition from the paper).
+ 331        a pitch consist of a tuple: pitch name (letter+number), accidental, tie.
+ 332        param : pitch1. The music_notation_repr tuple of note1
+ 333        param : pitch2. The music_notation_repr tuple of note2
+ 334        param : noteNode1. The noteNode where pitch1 belongs
+ 335        param : noteNode2. The noteNode where pitch2 belongs
+ 336        param : ids. (id_from_note1,id_from_note2) The indices of the notes in case of a chord
+ 337        Returns:
+ 338            [list] -- the list of differences
+ 339            [int] -- the cost of diff
+ 340        """
+ 341        cost = 0
+ 342        op_list = []
+ 343        # add for pitch name differences
+ 344        if pitch1[0] != pitch2[0]:
+ 345            cost += 1
+ 346            # TODO: select the note in a more precise way in case of a chord
+ 347            # rest to note
+ 348            if (pitch1[0][0] == "R") != (pitch2[0][0] == "R"):  # xor
+ 349                op_list.append(("pitchtypeedit", noteNode1, noteNode2, 1, ids))
+ 350            else:  # they are two notes
+ 351                op_list.append(("pitchnameedit", noteNode1, noteNode2, 1, ids))
+ 352        # add for the accidentals
+ 353        if pitch1[1] != pitch2[1]:  # if the accidental is different
+ 354            cost += 1
+ 355            if pitch1[1] == "None":
+ 356                assert pitch2[1] != "None"
+ 357                op_list.append(("accidentins", noteNode1, noteNode2, 1, ids))
+ 358            elif pitch2[1] == "None":
+ 359                assert pitch1[1] != "None"
+ 360                op_list.append(("accidentdel", noteNode1, noteNode2, 1, ids))
+ 361            else:  # a different tipe of alteration is present
+ 362                op_list.append(("accidentedit", noteNode1, noteNode2, 1, ids))
+ 363        # add for the ties
+ 364        if pitch1[2] != pitch2[2]:
+ 365            # exclusive or. Add if one is tied and not the other.
+ 366            # probably to revise for chords
+ 367            cost += 1
+ 368            if pitch1[2]:
+ 369                assert not pitch2[2]
+ 370                op_list.append(("tiedel", noteNode1, noteNode2, 1, ids))
+ 371            elif pitch2[2]:
+ 372                assert not pitch1[2]
+ 373                op_list.append(("tieins", noteNode1, noteNode2, 1, ids))
+ 374        return op_list, cost
+ 375
+ 376    @staticmethod
+ 377    @_memoize_block_diff_lin
+ 378    def _block_diff_lin(original, compare_to):
+ 379        if len(original) == 0 and len(compare_to) == 0:
+ 380            return [], 0
+ 381
+ 382        if len(original) == 0:
+ 383            op_list, cost = Comparison._block_diff_lin(original, compare_to[1:])
+ 384            cost += compare_to[0].notation_size()
+ 385            op_list.append(("insbar", None, compare_to[0], compare_to[0].notation_size()))
+ 386            return op_list, cost
+ 387
+ 388        if len(compare_to) == 0:
+ 389            op_list, cost = Comparison._block_diff_lin(original[1:], compare_to)
+ 390            cost += original[0].notation_size()
+ 391            op_list.append(("delbar", original[0], None, original[0].notation_size()))
+ 392            return op_list, cost
+ 393
+ 394        # compute the cost and the op_list for the many possibilities of recursion
+ 395        cost_dict = {}
+ 396        op_list_dict = {}
+ 397        # del-bar
+ 398        op_list_dict["delbar"], cost_dict["delbar"] = Comparison._block_diff_lin(
+ 399            original[1:], compare_to
+ 400        )
+ 401        cost_dict["delbar"] += original[0].notation_size()
+ 402        op_list_dict["delbar"].append(
+ 403            ("delbar", original[0], None, original[0].notation_size())
+ 404        )
+ 405        # ins-bar
+ 406        op_list_dict["insbar"], cost_dict["insbar"] = Comparison._block_diff_lin(
+ 407            original, compare_to[1:]
+ 408        )
+ 409        cost_dict["insbar"] += compare_to[0].notation_size()
+ 410        op_list_dict["insbar"].append(
+ 411            ("insbar", None, compare_to[0], compare_to[0].notation_size())
+ 412        )
+ 413        # edit-bar
+ 414        op_list_dict["editbar"], cost_dict["editbar"] = Comparison._block_diff_lin(
+ 415            original[1:], compare_to[1:]
+ 416        )
+ 417        if (
+ 418            original[0] == compare_to[0]
+ 419        ):  # to avoid performing the _voices_coupling_recursive if it's not needed
+ 420            inside_bar_op_list = []
+ 421            inside_bar_cost = 0
+ 422        else:
+ 423            # diff the bar extras (like _inside_bars_diff_lin, but with lists of AnnExtras
+ 424            # instead of lists of AnnNotes)
+ 425            extras_op_list, extras_cost = Comparison._extras_diff_lin(
+ 426                original[0].extras_list, compare_to[0].extras_list
+ 427            )
+ 428
+ 429            # run the voice coupling algorithm, and add to inside_bar_op_list and inside_bar_cost
+ 430            inside_bar_op_list, inside_bar_cost = Comparison._voices_coupling_recursive(
+ 431                original[0].voices_list, compare_to[0].voices_list
+ 432            )
+ 433            inside_bar_op_list.extend(extras_op_list)
+ 434            inside_bar_cost += extras_cost
+ 435        cost_dict["editbar"] += inside_bar_cost
+ 436        op_list_dict["editbar"].extend(inside_bar_op_list)
+ 437        # compute the minimum of the possibilities
+ 438        min_key = min(cost_dict, key=lambda k: cost_dict[k])
+ 439        out = op_list_dict[min_key], cost_dict[min_key]
+ 440        return out
+ 441
+ 442    @staticmethod
+ 443    @_memoize_extras_diff_lin
+ 444    def _extras_diff_lin(original, compare_to):
+ 445        # original and compare to are two lists of AnnExtra
+ 446        if len(original) == 0 and len(compare_to) == 0:
+ 447            return [], 0
+ 448
+ 449        if len(original) == 0:
+ 450            cost = 0
+ 451            op_list, cost = Comparison._extras_diff_lin(original, compare_to[1:])
+ 452            op_list.append(("extrains", None, compare_to[0], compare_to[0].notation_size()))
+ 453            cost += compare_to[0].notation_size()
+ 454            return op_list, cost
+ 455
+ 456        if len(compare_to) == 0:
+ 457            cost = 0
+ 458            op_list, cost = Comparison._extras_diff_lin(original[1:], compare_to)
+ 459            op_list.append(("extradel", original[0], None, original[0].notation_size()))
+ 460            cost += original[0].notation_size()
+ 461            return op_list, cost
+ 462
+ 463        # compute the cost and the op_list for the many possibilities of recursion
+ 464        cost = {}
+ 465        op_list = {}
+ 466        # extradel
+ 467        op_list["extradel"], cost["extradel"] = Comparison._extras_diff_lin(
+ 468            original[1:], compare_to
+ 469        )
+ 470        cost["extradel"] += original[0].notation_size()
+ 471        op_list["extradel"].append(
+ 472            ("extradel", original[0], None, original[0].notation_size())
+ 473        )
+ 474        # extrains
+ 475        op_list["extrains"], cost["extrains"] = Comparison._extras_diff_lin(
+ 476            original, compare_to[1:]
+ 477        )
+ 478        cost["extrains"] += compare_to[0].notation_size()
+ 479        op_list["extrains"].append(
+ 480            ("extrains", None, compare_to[0], compare_to[0].notation_size())
+ 481        )
+ 482        # extrasub
+ 483        op_list["extrasub"], cost["extrasub"] = Comparison._extras_diff_lin(
+ 484            original[1:], compare_to[1:]
+ 485        )
+ 486        if (
+ 487            original[0] == compare_to[0]
+ 488        ):  # avoid call another function if they are equal
+ 489            extrasub_op, extrasub_cost = [], 0
+ 490        else:
+ 491            extrasub_op, extrasub_cost = (
+ 492                Comparison._annotated_extra_diff(original[0], compare_to[0])
+ 493            )
+ 494        cost["extrasub"] += extrasub_cost
+ 495        op_list["extrasub"].extend(extrasub_op)
+ 496        # compute the minimum of the possibilities
+ 497        min_key = min(cost, key=cost.get)
+ 498        out = op_list[min_key], cost[min_key]
+ 499        return out
+ 500
+ 501    @staticmethod
+ 502    @_memoize_metadata_items_diff_lin
+ 503    def _metadata_items_diff_lin(original, compare_to):
+ 504        # original and compare to are two lists of tuple[str, t.Any]
+ 505        if len(original) == 0 and len(compare_to) == 0:
+ 506            return [], 0
+ 507
+ 508        if len(original) == 0:
+ 509            cost = 0
+ 510            op_list, cost = Comparison._metadata_items_diff_lin(original, compare_to[1:])
+ 511            op_list.append(("mditemins", None, compare_to[0], compare_to[0].notation_size()))
+ 512            cost += compare_to[0].notation_size()
+ 513            return op_list, cost
+ 514
+ 515        if len(compare_to) == 0:
+ 516            cost = 0
+ 517            op_list, cost = Comparison._metadata_items_diff_lin(original[1:], compare_to)
+ 518            op_list.append(("mditemdel", original[0], None, original[0].notation_size()))
+ 519            cost += original[0].notation_size()
+ 520            return op_list, cost
+ 521
+ 522        # compute the cost and the op_list for the many possibilities of recursion
+ 523        cost = {}
+ 524        op_list = {}
+ 525        # mditemdel
+ 526        op_list["mditemdel"], cost["mditemdel"] = Comparison._metadata_items_diff_lin(
+ 527            original[1:], compare_to
+ 528        )
+ 529        cost["mditemdel"] += original[0].notation_size()
+ 530        op_list["mditemdel"].append(
+ 531            ("mditemdel", original[0], None, original[0].notation_size())
+ 532        )
+ 533        # mditemins
+ 534        op_list["mditemins"], cost["mditemins"] = Comparison._metadata_items_diff_lin(
+ 535            original, compare_to[1:]
+ 536        )
+ 537        cost["mditemins"] += compare_to[0].notation_size()
+ 538        op_list["mditemins"].append(
+ 539            ("mditemins", None, compare_to[0], compare_to[0].notation_size())
+ 540        )
+ 541        # mditemsub
+ 542        op_list["mditemsub"], cost["mditemsub"] = Comparison._metadata_items_diff_lin(
+ 543            original[1:], compare_to[1:]
+ 544        )
+ 545        if (
+ 546            original[0] == compare_to[0]
+ 547        ):  # avoid call another function if they are equal
+ 548            mditemsub_op, mditemsub_cost = [], 0
+ 549        else:
+ 550            mditemsub_op, mditemsub_cost = (
+ 551                Comparison._annotated_metadata_item_diff(original[0], compare_to[0])
+ 552            )
+ 553        cost["mditemsub"] += mditemsub_cost
+ 554        op_list["mditemsub"].extend(mditemsub_op)
+ 555        # compute the minimum of the possibilities
+ 556        min_key = min(cost, key=cost.get)
+ 557        out = op_list[min_key], cost[min_key]
+ 558        return out
+ 559
+ 560    @staticmethod
+ 561    @_memoize_staff_groups_diff_lin
+ 562    def _staff_groups_diff_lin(original, compare_to):
+ 563        # original and compare to are two lists of AnnStaffGroup
+ 564        if len(original) == 0 and len(compare_to) == 0:
+ 565            return [], 0
+ 566
+ 567        if len(original) == 0:
+ 568            cost = 0
+ 569            op_list, cost = Comparison._staff_groups_diff_lin(original, compare_to[1:])
+ 570            op_list.append(("staffgrpins", None, compare_to[0], compare_to[0].notation_size()))
+ 571            cost += compare_to[0].notation_size()
+ 572            return op_list, cost
+ 573
+ 574        if len(compare_to) == 0:
+ 575            cost = 0
+ 576            op_list, cost = Comparison._staff_groups_diff_lin(original[1:], compare_to)
+ 577            op_list.append(("staffgrpdel", original[0], None, original[0].notation_size()))
+ 578            cost += original[0].notation_size()
+ 579            return op_list, cost
+ 580
+ 581        # compute the cost and the op_list for the many possibilities of recursion
+ 582        cost = {}
+ 583        op_list = {}
+ 584        # staffgrpdel
+ 585        op_list["staffgrpdel"], cost["staffgrpdel"] = Comparison._staff_groups_diff_lin(
+ 586            original[1:], compare_to
+ 587        )
+ 588        cost["staffgrpdel"] += original[0].notation_size()
+ 589        op_list["staffgrpdel"].append(
+ 590            ("staffgrpdel", original[0], None, original[0].notation_size())
+ 591        )
+ 592        # staffgrpins
+ 593        op_list["staffgrpins"], cost["staffgrpins"] = Comparison._staff_groups_diff_lin(
+ 594            original, compare_to[1:]
+ 595        )
+ 596        cost["staffgrpins"] += compare_to[0].notation_size()
+ 597        op_list["staffgrpins"].append(
+ 598            ("staffgrpins", None, compare_to[0], compare_to[0].notation_size())
+ 599        )
+ 600        # staffgrpsub
+ 601        op_list["staffgrpsub"], cost["staffgrpsub"] = Comparison._staff_groups_diff_lin(
+ 602            original[1:], compare_to[1:]
+ 603        )
+ 604        if (
+ 605            original[0] == compare_to[0]
+ 606        ):  # avoid call another function if they are equal
+ 607            staffgrpsub_op, staffgrpsub_cost = [], 0
+ 608        else:
+ 609            staffgrpsub_op, staffgrpsub_cost = (
+ 610                Comparison._annotated_staff_group_diff(original[0], compare_to[0])
+ 611            )
+ 612        cost["staffgrpsub"] += staffgrpsub_cost
+ 613        op_list["staffgrpsub"].extend(staffgrpsub_op)
+ 614        # compute the minimum of the possibilities
+ 615        min_key = min(cost, key=cost.get)
+ 616        out = op_list[min_key], cost[min_key]
+ 617        return out
+ 618
+ 619    @staticmethod
+ 620    def _strings_leveinshtein_distance(str1: str, str2: str):
+ 621        counter: dict = {"+": 0, "-": 0}
+ 622        distance: int = 0
+ 623        for edit_code in ndiff(str1, str2):
+ 624            if edit_code[0] == " ":
+ 625                distance += max(counter.values())
+ 626                counter = {"+": 0, "-": 0}
+ 627            else:
+ 628                counter[edit_code[0]] += 1
+ 629        distance += max(counter.values())
+ 630        return distance
+ 631
+ 632    @staticmethod
+ 633    def _areDifferentEnough(flt1: float, flt2: float) -> bool:
+ 634        diff: float = flt1 - flt2
+ 635        if diff < 0:
+ 636            diff = -diff
+ 637
+ 638        if diff > 0.0001:
+ 639            return True
+ 640        return False
+ 641
+ 642    @staticmethod
+ 643    def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
+ 644        """
+ 645        Compute the differences between two annotated extras.
+ 646        Each annotated extra consists of three values: content, offset, and duration
+ 647        """
+ 648        cost = 0
+ 649        op_list = []
+ 650
+ 651        # add for the content
+ 652        if annExtra1.content != annExtra2.content:
+ 653            content_cost: int = (
+ 654                Comparison._strings_leveinshtein_distance(annExtra1.content, annExtra2.content)
+ 655            )
+ 656            cost += content_cost
+ 657            op_list.append(("extracontentedit", annExtra1, annExtra2, content_cost))
+ 658
+ 659        # add for the offset
+ 660        # Note: offset here is a float, and some file formats have only four
+ 661        # decimal places of precision.  So we should not compare exactly here.
+ 662        if Comparison._areDifferentEnough(annExtra1.offset, annExtra2.offset):
+ 663            # offset is in quarter-notes, so let's make the cost in quarter-notes as well.
+ 664            # min cost is 1, though, don't round down to zero.
+ 665            offset_cost: int = int(min(1, abs(annExtra1.offset - annExtra2.offset)))
+ 666            cost += offset_cost
+ 667            op_list.append(("extraoffsetedit", annExtra1, annExtra2, offset_cost))
+ 668
+ 669        # add for the duration
+ 670        # Note: duration here is a float, and some file formats have only four
+ 671        # decimal places of precision.  So we should not compare exactly here.
+ 672        if Comparison._areDifferentEnough(annExtra1.duration, annExtra2.duration):
+ 673            # duration is in quarter-notes, so let's make the cost in quarter-notes as well.
+ 674            duration_cost = int(min(1, abs(annExtra1.duration - annExtra2.duration)))
+ 675            cost += duration_cost
+ 676            op_list.append(("extradurationedit", annExtra1, annExtra2, duration_cost))
+ 677
+ 678        # add for the style
+ 679        if annExtra1.styledict != annExtra2.styledict:
+ 680            cost += 1
+ 681            op_list.append(("extrastyleedit", annExtra1, annExtra2, 1))
+ 682
+ 683        return op_list, cost
+ 684
+ 685    @staticmethod
+ 686    def _annotated_metadata_item_diff(
+ 687        annMetadataItem1: AnnMetadataItem,
+ 688        annMetadataItem2: AnnMetadataItem
+ 689    ):
+ 690        """
+ 691        Compute the differences between two annotated metadata items.
+ 692        Each annotated metadata item has two values: key: str, value: t.Any,
+ 693        """
+ 694        cost = 0
+ 695        op_list = []
+ 696
+ 697        # add for the key
+ 698        if annMetadataItem1.key != annMetadataItem2.key:
+ 699            key_cost: int = (
+ 700                Comparison._strings_leveinshtein_distance(
+ 701                    annMetadataItem1.key,
+ 702                    annMetadataItem2.key
+ 703                )
+ 704            )
+ 705            cost += key_cost
+ 706            op_list.append(("mditemkeyedit", annMetadataItem1, annMetadataItem2, key_cost))
+ 707
+ 708        # add for the value
+ 709        if annMetadataItem1.value != annMetadataItem2.value:
+ 710            value_cost: int = (
+ 711                Comparison._strings_leveinshtein_distance(
+ 712                    str(annMetadataItem1.value),
+ 713                    str(annMetadataItem2.value)
+ 714                )
+ 715            )
+ 716            cost += value_cost
+ 717            op_list.append(
+ 718                ("mditemvalueedit", annMetadataItem1, annMetadataItem2, value_cost)
+ 719            )
+ 720
+ 721        return op_list, cost
+ 722
+ 723    @staticmethod
+ 724    def _annotated_staff_group_diff(annStaffGroup1: AnnStaffGroup, annStaffGroup2: AnnStaffGroup):
+ 725        """
+ 726        Compute the differences between two annotated staff groups.
+ 727        Each annotated staff group consists of five values: name, abbreviation,
+ 728        symbol, barTogether, part_indices.
+ 729        """
+ 730        cost = 0
+ 731        op_list = []
+ 732
+ 733        # add for the name
+ 734        if annStaffGroup1.name != annStaffGroup2.name:
+ 735            name_cost: int = (
+ 736                Comparison._strings_leveinshtein_distance(annStaffGroup1.name, annStaffGroup2.name)
+ 737            )
+ 738            cost += name_cost
+ 739            op_list.append(("staffgrpnameedit", annStaffGroup1, annStaffGroup2, name_cost))
+ 740
+ 741        # add for the abbreviation
+ 742        if annStaffGroup1.abbreviation != annStaffGroup2.abbreviation:
+ 743            abbreviation_cost: int = (
+ 744                Comparison._strings_leveinshtein_distance(
+ 745                    annStaffGroup1.abbreviation,
+ 746                    annStaffGroup2.abbreviation
+ 747                )
+ 748            )
+ 749            cost += abbreviation_cost
+ 750            op_list.append(
+ 751                ("staffgrpabbreviationedit", annStaffGroup1, annStaffGroup2, abbreviation_cost)
+ 752            )
+ 753
+ 754        # add for the symbol
+ 755        if annStaffGroup1.symbol != annStaffGroup2.symbol:
+ 756            symbol_cost: int = 1
+ 757            cost += symbol_cost
+ 758            op_list.append(
+ 759                ("staffgrpsymboledit", annStaffGroup1, annStaffGroup2, symbol_cost)
+ 760            )
+ 761
+ 762        # add for barTogether
+ 763        if annStaffGroup1.barTogether != annStaffGroup2.barTogether:
+ 764            barTogether_cost: int = 1
+ 765            cost += barTogether_cost
+ 766            op_list.append(
+ 767                ("staffgrpbartogetheredit", annStaffGroup1, annStaffGroup2, barTogether_cost)
+ 768            )
+ 769
+ 770        # add for partIndices (sorted list of int)
+ 771        if annStaffGroup1.part_indices != annStaffGroup2.part_indices:
+ 772            parts1: str = str(annStaffGroup1.part_indices)
+ 773            parts2: str = str(annStaffGroup2.part_indices)
+ 774            partIndices_cost: int = (
+ 775                Comparison._strings_leveinshtein_distance(
+ 776                    parts1,
+ 777                    parts2
+ 778                )
+ 779            )
+ 780            cost += partIndices_cost
+ 781            op_list.append(
+ 782                ("staffgrppartindicesedit", annStaffGroup1, annStaffGroup2, partIndices_cost)
+ 783            )
+ 784
+ 785        return op_list, cost
+ 786
+ 787    @staticmethod
+ 788    @_memoize_inside_bars_diff_lin
+ 789    def _inside_bars_diff_lin(original, compare_to):
+ 790        # original and compare to are two lists of annotatedNote
+ 791        if len(original) == 0 and len(compare_to) == 0:
+ 792            return [], 0
+ 793
+ 794        if len(original) == 0:
+ 795            cost = 0
+ 796            op_list, cost = Comparison._inside_bars_diff_lin(original, compare_to[1:])
+ 797            op_list.append(("noteins", None, compare_to[0], compare_to[0].notation_size()))
+ 798            cost += compare_to[0].notation_size()
+ 799            return op_list, cost
+ 800
+ 801        if len(compare_to) == 0:
+ 802            cost = 0
+ 803            op_list, cost = Comparison._inside_bars_diff_lin(original[1:], compare_to)
+ 804            op_list.append(("notedel", original[0], None, original[0].notation_size()))
+ 805            cost += original[0].notation_size()
+ 806            return op_list, cost
+ 807
+ 808        # compute the cost and the op_list for the many possibilities of recursion
+ 809        cost = {}
+ 810        op_list = {}
+ 811        # notedel
+ 812        op_list["notedel"], cost["notedel"] = Comparison._inside_bars_diff_lin(
+ 813            original[1:], compare_to
+ 814        )
+ 815        cost["notedel"] += original[0].notation_size()
+ 816        op_list["notedel"].append(
+ 817            ("notedel", original[0], None, original[0].notation_size())
+ 818        )
+ 819        # noteins
+ 820        op_list["noteins"], cost["noteins"] = Comparison._inside_bars_diff_lin(
+ 821            original, compare_to[1:]
+ 822        )
+ 823        cost["noteins"] += compare_to[0].notation_size()
+ 824        op_list["noteins"].append(
+ 825            ("noteins", None, compare_to[0], compare_to[0].notation_size())
+ 826        )
+ 827        # notesub
+ 828        op_list["notesub"], cost["notesub"] = Comparison._inside_bars_diff_lin(
+ 829            original[1:], compare_to[1:]
+ 830        )
+ 831        if (
+ 832            original[0] == compare_to[0]
+ 833        ):  # avoid call another function if they are equal
+ 834            notesub_op, notesub_cost = [], 0
+ 835        else:
+ 836            notesub_op, notesub_cost = Comparison._annotated_note_diff(original[0], compare_to[0])
+ 837        cost["notesub"] += notesub_cost
+ 838        op_list["notesub"].extend(notesub_op)
+ 839        # compute the minimum of the possibilities
+ 840        min_key = min(cost, key=cost.get)
+ 841        out = op_list[min_key], cost[min_key]
+ 842        return out
+ 843
+ 844    @staticmethod
+ 845    def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote):
+ 846        """
+ 847        Compute the differences between two annotated notes.
+ 848        Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets)
+ 849        where pitches is a list.
+ 850        Arguments:
+ 851            noteNode1 {[AnnNote]} -- original AnnNote
+ 852            noteNode2 {[AnnNote]} -- compare_to AnnNote
+ 853        """
+ 854        cost = 0
+ 855        op_list = []
+ 856        # add for the pitches
+ 857        # if they are equal
+ 858        if annNote1.pitches == annNote2.pitches:
+ 859            op_list_pitch, cost_pitch = [], 0
+ 860        else:
+ 861            # pitches diff is computed using leveinshtein differences (they are already ordered)
+ 862            op_list_pitch, cost_pitch = Comparison._pitches_leveinsthein_diff(
+ 863                annNote1.pitches, annNote2.pitches, annNote1, annNote2, (0, 0)
+ 864            )
+ 865        op_list.extend(op_list_pitch)
+ 866        cost += cost_pitch
+ 867        # add for the notehead
+ 868        if annNote1.note_head != annNote2.note_head:
+ 869            cost += 1
+ 870            op_list.append(("headedit", annNote1, annNote2, 1))
+ 871        # add for the dots
+ 872        if annNote1.dots != annNote2.dots:
+ 873            dots_diff = abs(annNote1.dots - annNote2.dots)  # add one for each dot
+ 874            cost += dots_diff
+ 875            if annNote1.dots > annNote2.dots:
+ 876                op_list.append(("dotdel", annNote1, annNote2, dots_diff))
+ 877            else:
+ 878                op_list.append(("dotins", annNote1, annNote2, dots_diff))
+ 879        if annNote1.graceType != annNote2.graceType:
+ 880            cost += 1
+ 881            op_list.append(("graceedit", annNote1, annNote2, 1))
+ 882        if annNote1.graceSlash != annNote2.graceSlash:
+ 883            cost += 1
+ 884            op_list.append(("graceslashedit", annNote1, annNote2, 1))
+ 885        # add for the beamings
+ 886        if annNote1.beamings != annNote2.beamings:
+ 887            beam_op_list, beam_cost = Comparison._beamtuplet_leveinsthein_diff(
+ 888                annNote1.beamings, annNote2.beamings, annNote1, annNote2, "beam"
+ 889            )
+ 890            op_list.extend(beam_op_list)
+ 891            cost += beam_cost
+ 892        # add for the tuplets
+ 893        if annNote1.tuplets != annNote2.tuplets:
+ 894            tuplet_op_list, tuplet_cost = Comparison._beamtuplet_leveinsthein_diff(
+ 895                annNote1.tuplets, annNote2.tuplets, annNote1, annNote2, "tuplet"
+ 896            )
+ 897            op_list.extend(tuplet_op_list)
+ 898            cost += tuplet_cost
+ 899        # add for the articulations
+ 900        if annNote1.articulations != annNote2.articulations:
+ 901            artic_op_list, artic_cost = Comparison._generic_leveinsthein_diff(
+ 902                annNote1.articulations,
+ 903                annNote2.articulations,
+ 904                annNote1,
+ 905                annNote2,
+ 906                "articulation",
+ 907            )
+ 908            op_list.extend(artic_op_list)
+ 909            cost += artic_cost
+ 910        # add for the expressions
+ 911        if annNote1.expressions != annNote2.expressions:
+ 912            expr_op_list, expr_cost = Comparison._generic_leveinsthein_diff(
+ 913                annNote1.expressions,
+ 914                annNote2.expressions,
+ 915                annNote1,
+ 916                annNote2,
+ 917                "expression",
+ 918            )
+ 919            op_list.extend(expr_op_list)
+ 920            cost += expr_cost
+ 921        # add for the lyrics
+ 922        if annNote1.lyrics != annNote2.lyrics:
+ 923            lyr_op_list, lyr_cost = Comparison._generic_leveinsthein_diff(
+ 924                annNote1.lyrics,
+ 925                annNote2.lyrics,
+ 926                annNote1,
+ 927                annNote2,
+ 928                "lyric",
+ 929            )
+ 930            op_list.extend(lyr_op_list)
+ 931            cost += lyr_cost
+ 932
+ 933        # add for noteshape
+ 934        if annNote1.noteshape != annNote2.noteshape:
+ 935            cost += 1
+ 936            op_list.append(("editnoteshape", annNote1, annNote2, 1))
+ 937        # add for noteheadFill
+ 938        if annNote1.noteheadFill != annNote2.noteheadFill:
+ 939            cost += 1
+ 940            op_list.append(("editnoteheadfill", annNote1, annNote2, 1))
+ 941        # add for noteheadParenthesis
+ 942        if annNote1.noteheadParenthesis != annNote2.noteheadParenthesis:
+ 943            cost += 1
+ 944            op_list.append(("editnoteheadparenthesis", annNote1, annNote2, 1))
+ 945        # add for stemDirection
+ 946        if annNote1.stemDirection != annNote2.stemDirection:
+ 947            cost += 1
+ 948            op_list.append(("editstemdirection", annNote1, annNote2, 1))
+ 949        # add for the styledict
+ 950        if annNote1.styledict != annNote2.styledict:
+ 951            cost += 1
+ 952            op_list.append(("editstyle", annNote1, annNote2, 1))
+ 953
+ 954        return op_list, cost
+ 955
+ 956    @staticmethod
+ 957    @_memoize_beamtuplet_lev_diff
+ 958    def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which):
+ 959        """
+ 960        Compute the leveinsthein distance between two sequences of beaming or tuples.
+ 961        Arguments:
+ 962            original {list} -- list of strings (start, stop, continue or partial)
+ 963            compare_to {list} -- list of strings (start, stop, continue or partial)
+ 964            note1 {AnnNote} -- the note for referencing in the score
+ 965            note2 {AnnNote} -- the note for referencing in the score
+ 966            which -- a string: "beam" or "tuplet" depending what we are comparing
+ 967        """
+ 968        if which not in ("beam", "tuplet"):
+ 969            raise ValueError("Argument 'which' must be either 'beam' or 'tuplet'")
+ 970
+ 971        if len(original) == 0 and len(compare_to) == 0:
+ 972            return [], 0
+ 973
+ 974        if len(original) == 0:
+ 975            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
+ 976                original, compare_to[1:], note1, note2, which
+ 977            )
+ 978            op_list.append(("ins" + which, note1, note2, 1))
+ 979            cost += 1
+ 980            return op_list, cost
+ 981
+ 982        if len(compare_to) == 0:
+ 983            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
+ 984                original[1:], compare_to, note1, note2, which
+ 985            )
+ 986            op_list.append(("del" + which, note1, note2, 1))
+ 987            cost += 1
+ 988            return op_list, cost
+ 989
+ 990        # compute the cost and the op_list for the many possibilities of recursion
+ 991        cost = {}
+ 992        op_list = {}
+ 993        # del-pitch
+ 994        op_list["del" + which], cost["del" + which] = Comparison._beamtuplet_leveinsthein_diff(
+ 995            original[1:], compare_to, note1, note2, which
+ 996        )
+ 997        cost["del" + which] += 1
+ 998        op_list["del" + which].append(("del" + which, note1, note2, 1))
+ 999        # ins-pitch
+1000        op_list["ins" + which], cost["ins" + which] = Comparison._beamtuplet_leveinsthein_diff(
+1001            original, compare_to[1:], note1, note2, which
+1002        )
+1003        cost["ins" + which] += 1
+1004        op_list["ins" + which].append(("ins" + which, note1, note2, 1))
+1005        # edit-pitch
+1006        op_list["edit" + which], cost["edit" + which] = Comparison._beamtuplet_leveinsthein_diff(
+1007            original[1:], compare_to[1:], note1, note2, which
+1008        )
+1009        if original[0] == compare_to[0]:
+1010            beam_diff_op_list = []
+1011            beam_diff_cost = 0
+1012        else:
+1013            beam_diff_op_list, beam_diff_cost = [("edit" + which, note1, note2, 1)], 1
+1014        cost["edit" + which] += beam_diff_cost
+1015        op_list["edit" + which].extend(beam_diff_op_list)
+1016        # compute the minimum of the possibilities
+1017        min_key = min(cost, key=cost.get)
+1018        out = op_list[min_key], cost[min_key]
+1019        return out
+1020
+1021    @staticmethod
+1022    @_memoize_generic_lev_diff
+1023    def _generic_leveinsthein_diff(original, compare_to, note1, note2, which):
+1024        """
+1025        Compute the leveinsthein distance between two generic sequences of symbols
+1026        (e.g., articulations).
+1027        Arguments:
+1028            original {list} -- list of strings
+1029            compare_to {list} -- list of strings
+1030            note1 {AnnNote} -- the note for referencing in the score
+1031            note2 {AnnNote} -- the note for referencing in the score
+1032            which -- a string: e.g. "articulation" depending what we are comparing
+1033        """
+1034        if len(original) == 0 and len(compare_to) == 0:
+1035            return [], 0
+1036
+1037        if len(original) == 0:
+1038            op_list, cost = Comparison._generic_leveinsthein_diff(
+1039                original, compare_to[1:], note1, note2, which
+1040            )
+1041            op_list.append(("ins" + which, note1, note2, 1))
+1042            cost += 1
+1043            return op_list, cost
+1044
+1045        if len(compare_to) == 0:
+1046            op_list, cost = Comparison._generic_leveinsthein_diff(
+1047                original[1:], compare_to, note1, note2, which
+1048            )
+1049            op_list.append(("del" + which, note1, note2, 1))
+1050            cost += 1
+1051            return op_list, cost
+1052
+1053        # compute the cost and the op_list for the many possibilities of recursion
+1054        cost = {}
+1055        op_list = {}
+1056        # del-pitch
+1057        op_list["del" + which], cost["del" + which] = Comparison._generic_leveinsthein_diff(
+1058            original[1:], compare_to, note1, note2, which
+1059        )
+1060        cost["del" + which] += 1
+1061        op_list["del" + which].append(("del" + which, note1, note2, 1))
+1062        # ins-pitch
+1063        op_list["ins" + which], cost["ins" + which] = Comparison._generic_leveinsthein_diff(
+1064            original, compare_to[1:], note1, note2, which
+1065        )
+1066        cost["ins" + which] += 1
+1067        op_list["ins" + which].append(("ins" + which, note1, note2, 1))
+1068        # edit-pitch
+1069        op_list["edit" + which], cost["edit" + which] = Comparison._generic_leveinsthein_diff(
+1070            original[1:], compare_to[1:], note1, note2, which
+1071        )
+1072        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
+1073            generic_diff_op_list = []
+1074            generic_diff_cost = 0
+1075        else:
+1076            generic_diff_op_list, generic_diff_cost = (
+1077                [("edit" + which, note1, note2, 1)],
+1078                1,
+1079            )
+1080        cost["edit" + which] += generic_diff_cost
+1081        op_list["edit" + which].extend(generic_diff_op_list)
+1082        # compute the minimum of the possibilities
+1083        min_key = min(cost, key=cost.get)
+1084        out = op_list[min_key], cost[min_key]
+1085        return out
+1086
+1087    @staticmethod
+1088    def _voices_coupling_recursive(original: list[AnnVoice], compare_to: list[AnnVoice]):
+1089        """
+1090        Compare all the possible voices permutations, considering also deletion and
+1091        insertion (equation on office lens).
+1092        original [list] -- a list of Voice
+1093        compare_to [list] -- a list of Voice
+1094        """
+1095        if len(original) == 0 and len(compare_to) == 0:  # stop the recursion
+1096            return [], 0
+1097
+1098        if len(original) == 0:
+1099            # insertion
+1100            op_list, cost = Comparison._voices_coupling_recursive(original, compare_to[1:])
+1101            # add for the inserted voice
+1102            op_list.append(("voiceins", None, compare_to[0], compare_to[0].notation_size()))
+1103            cost += compare_to[0].notation_size()
+1104            return op_list, cost
+1105
+1106        if len(compare_to) == 0:
+1107            # deletion
+1108            op_list, cost = Comparison._voices_coupling_recursive(original[1:], compare_to)
+1109            # add for the deleted voice
+1110            op_list.append(("voicedel", original[0], None, original[0].notation_size()))
+1111            cost += original[0].notation_size()
+1112            return op_list, cost
+1113
+1114        cost = {}
+1115        op_list = {}
+1116        # deletion
+1117        op_list["voicedel"], cost["voicedel"] = Comparison._voices_coupling_recursive(
+1118            original[1:], compare_to
+1119        )
+1120        op_list["voicedel"].append(
+1121            ("voicedel", original[0], None, original[0].notation_size())
+1122        )
+1123        cost["voicedel"] += original[0].notation_size()
+1124        for i, c in enumerate(compare_to):
+1125            # substitution
+1126            (
+1127                op_list["voicesub" + str(i)],
+1128                cost["voicesub" + str(i)],
+1129            ) = Comparison._voices_coupling_recursive(
+1130                original[1:], compare_to[:i] + compare_to[i + 1:]
+1131            )
+1132            if (
+1133                compare_to[0] != original[0]
+1134            ):  # add the cost of the sub and the operations from inside_bar_diff
+1135                op_list_inside_bar, cost_inside_bar = Comparison._inside_bars_diff_lin(
+1136                    original[0].annot_notes, c.annot_notes
+1137                )  # compute the distance from original[0] and compare_to[i]
+1138                op_list["voicesub" + str(i)].extend(op_list_inside_bar)
+1139                cost["voicesub" + str(i)] += cost_inside_bar
+1140        # compute the minimum of the possibilities
+1141        min_key = min(cost, key=cost.get)
+1142        out = op_list[min_key], cost[min_key]
+1143        return out
+1144
+1145    @staticmethod
+1146    def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> tuple[list[tuple], int]:
+1147        '''
+1148        Compare two annotated scores, computing an operations list and the cost of applying those
+1149        operations to the first score to generate the second score.
+1150
+1151        Args:
+1152            score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare.
+1153            score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare.
+1154
+1155        Returns:
+1156            list[tuple], int: The operations list and the cost
+1157        '''
+1158        # for now just working with equal number of parts that are already pairs
+1159        # TODO : extend to different number of parts
+1160        assert score1.n_of_parts == score2.n_of_parts
+1161        n_of_parts = score1.n_of_parts
+1162        op_list_total, cost_total = [], 0
+1163        # iterate for all parts in the score
+1164        for p_number in range(n_of_parts):
+1165            # compute non-common-subseq
+1166            ncs = Comparison._non_common_subsequences_of_measures(
+1167                score1.part_list[p_number].bar_list,
+1168                score2.part_list[p_number].bar_list,
+1169            )
+1170            # compute blockdiff
+1171            for subseq in ncs:
+1172                op_list_block, cost_block = Comparison._block_diff_lin(
+1173                    subseq["original"], subseq["compare_to"]
+1174                )
+1175                op_list_total.extend(op_list_block)
+1176                cost_total += cost_block
+1177
+1178        # compare the staff groups
+1179        groups_op_list, groups_cost = Comparison._staff_groups_diff_lin(
+1180            score1.staff_group_list, score2.staff_group_list
+1181        )
+1182        op_list_total.extend(groups_op_list)
+1183        cost_total += groups_cost
+1184
+1185        # compare the metadata items
+1186        mditems_op_list, mditems_cost = Comparison._metadata_items_diff_lin(
+1187            score1.metadata_items_list, score2.metadata_items_list
+1188        )
+1189        op_list_total.extend(mditems_op_list)
+1190        cost_total += mditems_cost
+1191
+1192        return op_list_total, cost_total
+
- if len(original) == 0: - # insertion - op_list, cost = Comparison._voices_coupling_recursive(original, compare_to[1:]) - # add for the inserted voice - op_list.append(("voiceins", None, compare_to[0], compare_to[0].notation_size())) - cost += compare_to[0].notation_size() - return op_list, cost - - if len(compare_to) == 0: - # deletion - op_list, cost = Comparison._voices_coupling_recursive(original[1:], compare_to) - # add for the deleted voice - op_list.append(("voicedel", original[0], None, original[0].notation_size())) - cost += original[0].notation_size() - return op_list, cost - - cost = {} - op_list = {} - # deletion - op_list["voicedel"], cost["voicedel"] = Comparison._voices_coupling_recursive( - original[1:], compare_to - ) - op_list["voicedel"].append( - ("voicedel", original[0], None, original[0].notation_size()) - ) - cost["voicedel"] += original[0].notation_size() - for i, c in enumerate(compare_to): - # substitution - ( - op_list["voicesub" + str(i)], - cost["voicesub" + str(i)], - ) = Comparison._voices_coupling_recursive( - original[1:], compare_to[:i] + compare_to[i + 1 :] - ) - if ( - compare_to[0] != original[0] - ): # add the cost of the sub and the operations from inside_bar_diff - op_list_inside_bar, cost_inside_bar = Comparison._inside_bars_diff_lin( - original[0].annot_notes, c.annot_notes - ) # compute the distance from original[0] and compare_to[i] - op_list["voicesub" + str(i)].extend(op_list_inside_bar) - cost["voicesub" + str(i)] += cost_inside_bar - # compute the minimum of the possibilities - min_key = min(cost, key=cost.get) - out = op_list[min_key], cost[min_key] - return out - - @staticmethod - def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tuple], int]: - ''' - Compare two annotated scores, computing an operations list and the cost of applying those - operations to the first score to generate the second score. - - Args: - score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare. - score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare. - - Returns: - List[Tuple], int: The operations list and the cost - ''' - # for now just working with equal number of parts that are already pairs - # TODO : extend to different number of parts - assert score1.n_of_parts == score2.n_of_parts - n_of_parts = score1.n_of_parts - op_list_total, cost_total = [], 0 - # iterate for all parts in the score - for p_number in range(n_of_parts): - # compute non-common-subseq - ncs = Comparison._non_common_subsequences_of_measures( - score1.part_list[p_number].bar_list, - score2.part_list[p_number].bar_list, - ) - # compute blockdiff - for subseq in ncs: - op_list_block, cost_block = Comparison._block_diff_lin( - subseq["original"], subseq["compare_to"] - ) - op_list_total.extend(op_list_block) - cost_total += cost_block - - return op_list_total, cost_total -
- -
-
- #   - - - class - Comparison: -
- -
- View Source -
class Comparison:
-    @staticmethod
-    def _myers_diff(a_lines, b_lines):
-        # Myers algorithm for LCS of bars (instead of the recursive algorithm in section 3.2)
-        # This marks the farthest-right point along each diagonal in the edit
-        # graph, along with the history that got it there
-        Frontier = namedtuple("Frontier", ["x", "history"])
-        frontier = {1: Frontier(0, [])}
-
-        a_max = len(a_lines)
-        b_max = len(b_lines)
-        for d in range(0, a_max + b_max + 1):
-            for k in range(-d, d + 1, 2):
-                # This determines whether our next search point will be going down
-                # in the edit graph, or to the right.
-                #
-                # The intuition for this is that we should go down if we're on the
-                # left edge (k == -d) to make sure that the left edge is fully
-                # explored.
-                #
-                # If we aren't on the top (k != d), then only go down if going down
-                # would take us to territory that hasn't sufficiently been explored
-                # yet.
-                go_down = k == -d or (k != d and frontier[k - 1].x < frontier[k + 1].x)
-
-                # Figure out the starting point of this iteration. The diagonal
-                # offsets come from the geometry of the edit grid - if you're going
-                # down, your diagonal is lower, and if you're going right, your
-                # diagonal is higher.
-                if go_down:
-                    old_x, history = frontier[k + 1]
-                    x = old_x
-                else:
-                    old_x, history = frontier[k - 1]
-                    x = old_x + 1
-
-                # We want to avoid modifying the old history, since some other step
-                # may decide to use it.
-                history = history[:]
-                y = x - k
-
-                # We start at the invalid point (0, 0) - we should only start building
-                # up history when we move off of it.
-                if 1 <= y <= b_max and go_down:
-                    history.append((1, b_lines[y - 1][1]))  # add comparetostep
-                elif 1 <= x <= a_max:
-                    history.append((0, a_lines[x - 1][1]))  # add originalstep
-
-                # Chew up as many diagonal moves as we can - these correspond to common lines,
-                # and they're considered "free" by the algorithm because we want to maximize
-                # the number of these in the output.
-                while x < a_max and y < b_max and a_lines[x][0] == b_lines[y][0]:
-                    x += 1
-                    y += 1
-                    history.append((2, a_lines[x - 1][1]))  # add equal step
-
-                if x >= a_max and y >= b_max:
-                    # If we're here, then we've traversed through the bottom-left corner,
-                    # and are done.
-                    return np.array(history)
-
-                frontier[k] = Frontier(x, history)
-
-        assert False, "Could not find edit script"
-
-    @staticmethod
-    def _non_common_subsequences_myers(original, compare_to):
-        ### Both original and compare_to are list of lists, or numpy arrays with 2 columns.
-        ### This is necessary because bars need two representation at the same time.
-        ### One without the id (for comparison), and one with the id (to retrieve the bar at the end)
-        # get the list of operations
-        op_list = Comparison._myers_diff(
-            np.array(original, dtype=np.int64), np.array(compare_to, dtype=np.int64)
-        )[::-1]
-        # retrieve the non common subsequences
-        non_common_subsequences = []
-        non_common_subsequences.append({"original": [], "compare_to": []})
-        ind = 0
-        for op in op_list[::-1]:
-            if op[0] == 2:  # equal
-                non_common_subsequences.append({"original": [], "compare_to": []})
-                ind += 1
-            elif op[0] == 0:  # original step
-                non_common_subsequences[ind]["original"].append(op[1])
-            elif op[0] == 1:  # compare to step
-                non_common_subsequences[ind]["compare_to"].append(op[1])
-        # remove the empty dict from the list
-        non_common_subsequences = [
-            s for s in non_common_subsequences if s != {"original": [], "compare_to": []}
-        ]
-        return non_common_subsequences
-
-    @staticmethod
-    def _non_common_subsequences_of_measures(original_m, compare_to_m):
-        # Take the hash for each measure to run faster comparison
-        # We need two hashes: one that is independent of the IDs (precomputed_str, for comparison),
-        # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after computation)
-        original_int = [[o.precomputed_str, o.precomputed_repr] for o in original_m]
-        compare_to_int = [[c.precomputed_str, c.precomputed_repr] for c in compare_to_m]
-        ncs = Comparison._non_common_subsequences_myers(original_int, compare_to_int)
-        # retrieve the original pointers to measures
-        new_out = []
-        for e in ncs:
-            new_out.append({})
-            for k in e.keys():
-                new_out[-1][k] = []
-                for repr_hash in e[k]:
-                    if k == "original":
-                        new_out[-1][k].append(
-                            next(m for m in original_m if m.precomputed_repr == repr_hash)
-                        )
-                    else:
-                        new_out[-1][k].append(
-                            next(m for m in compare_to_m if m.precomputed_repr == repr_hash)
-                        )
-
-        return new_out
-
-    @staticmethod
-    @_memoize_pitches_lev_diff
-    def _pitches_leveinsthein_diff(original, compare_to, noteNode1, noteNode2, ids):
-        """Compute the leveinsthein distance between two sequences of pitches
-        Arguments:
-            original {list} -- list of pitches
-            compare_to {list} -- list of pitches
-            noteNode1 {annotatedNote} --for referencing
-            noteNode2 {annotatedNote} --for referencing
-            ids {tuple} -- a tuple of 2 elements with the indices of the notes considered
-        """
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            cost = M21Utils.pitch_size(compare_to[0])
-            op_list, cost = Comparison._pitches_leveinsthein_diff(
-                original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
-            )
-            op_list.append(
-                ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
-            )
-            cost += M21Utils.pitch_size(compare_to[0])
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            cost = M21Utils.pitch_size(original[0])
-            op_list, cost = Comparison._pitches_leveinsthein_diff(
-                original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
-            )
-            op_list.append(
-                ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
-            )
-            cost += M21Utils.pitch_size(original[0])
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost_dict = {}
-        op_list_dict = {}
-        # del-pitch
-        op_list_dict["delpitch"], cost_dict["delpitch"] = Comparison._pitches_leveinsthein_diff(
-            original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
-        )
-        cost_dict["delpitch"] += M21Utils.pitch_size(original[0])
-        op_list_dict["delpitch"].append(
-            ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
-        )
-        # ins-pitch
-        op_list_dict["inspitch"], cost_dict["inspitch"] = Comparison._pitches_leveinsthein_diff(
-            original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
-        )
-        cost_dict["inspitch"] += M21Utils.pitch_size(compare_to[0])
-        op_list_dict["inspitch"].append(
-            ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
-        )
-        # edit-pitch
-        op_list_dict["editpitch"], cost_dict["editpitch"] = Comparison._pitches_leveinsthein_diff(
-            original[1:], compare_to[1:], noteNode1, noteNode2, (ids[0] + 1, ids[1] + 1)
-        )
-        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
-            pitch_diff_op_list = []
-            pitch_diff_cost = 0
-        else:
-            pitch_diff_op_list, pitch_diff_cost = Comparison._pitches_diff(
-                original[0], compare_to[0], noteNode1, noteNode2, (ids[0], ids[1])
-            )
-        cost_dict["editpitch"] += pitch_diff_cost
-        op_list_dict["editpitch"].extend(pitch_diff_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost_dict, key=lambda k: cost_dict[k])
-        out = op_list_dict[min_key], cost_dict[min_key]
-        return out
-
-    @staticmethod
-    def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids):
-        """compute the differences between two pitch (definition from the paper).
-        a pitch consist of a tuple: pitch name (letter+number), accidental, tie.
-        param : pitch1. The music_notation_repr tuple of note1
-        param : pitch2. The music_notation_repr tuple of note2
-        param : noteNode1. The noteNode where pitch1 belongs
-        param : noteNode2. The noteNode where pitch2 belongs
-        param : ids. (id_from_note1,id_from_note2) The indices of the notes in case of a chord
-        Returns:
-            [list] -- the list of differences
-            [int] -- the cost of diff
-        """
-        cost = 0
-        op_list = []
-        # add for pitch name differences
-        if pitch1[0] != pitch2[0]:
-            cost += 1
-            # TODO: select the note in a more precise way in case of a chord
-            # rest to note
-            if (pitch1[0][0] == "R") != (pitch2[0][0] == "R"):  # xor
-                op_list.append(("pitchtypeedit", noteNode1, noteNode2, 1, ids))
-            else:  # they are two notes
-                op_list.append(("pitchnameedit", noteNode1, noteNode2, 1, ids))
-        # add for the accidentals
-        if pitch1[1] != pitch2[1]:  # if the accidental is different
-            cost += 1
-            if pitch1[1] == "None":
-                assert pitch2[1] != "None"
-                op_list.append(("accidentins", noteNode1, noteNode2, 1, ids))
-            elif pitch2[1] == "None":
-                assert pitch1[1] != "None"
-                op_list.append(("accidentdel", noteNode1, noteNode2, 1, ids))
-            else:  # a different tipe of alteration is present
-                op_list.append(("accidentedit", noteNode1, noteNode2, 1, ids))
-        # add for the ties
-        if pitch1[2] != pitch2[2]:  # exclusive or. Add if one is tied and not the other
-            ################probably to revise for chords
-            cost += 1
-            if pitch1[2]:
-                assert not pitch2[2]
-                op_list.append(("tiedel", noteNode1, noteNode2, 1, ids))
-            elif pitch2[2]:
-                assert not pitch1[2]
-                op_list.append(("tieins", noteNode1, noteNode2, 1, ids))
-        return op_list, cost
-
-    @staticmethod
-    @_memoize_block_diff_lin
-    def _block_diff_lin(original, compare_to):
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            op_list, cost = Comparison._block_diff_lin(original, compare_to[1:])
-            cost += compare_to[0].notation_size()
-            op_list.append(("insbar", None, compare_to[0], compare_to[0].notation_size()))
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            op_list, cost = Comparison._block_diff_lin(original[1:], compare_to)
-            cost += original[0].notation_size()
-            op_list.append(("delbar", original[0], None, original[0].notation_size()))
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost_dict = {}
-        op_list_dict = {}
-        # del-bar
-        op_list_dict["delbar"], cost_dict["delbar"] = Comparison._block_diff_lin(
-            original[1:], compare_to
-        )
-        cost_dict["delbar"] += original[0].notation_size()
-        op_list_dict["delbar"].append(
-            ("delbar", original[0], None, original[0].notation_size())
-        )
-        # ins-bar
-        op_list_dict["insbar"], cost_dict["insbar"] = Comparison._block_diff_lin(
-            original, compare_to[1:]
-        )
-        cost_dict["insbar"] += compare_to[0].notation_size()
-        op_list_dict["insbar"].append(
-            ("insbar", None, compare_to[0], compare_to[0].notation_size())
-        )
-        # edit-bar
-        op_list_dict["editbar"], cost_dict["editbar"] = Comparison._block_diff_lin(
-            original[1:], compare_to[1:]
-        )
-        if (
-            original[0] == compare_to[0]
-        ):  # to avoid performing the _voices_coupling_recursive if it's not needed
-            inside_bar_op_list = []
-            inside_bar_cost = 0
-        else:
-            # diff the bar extras (like _inside_bars_diff_lin, but with lists of AnnExtras
-            # instead of lists of AnnNotes)
-            extras_op_list, extras_cost = Comparison._extras_diff_lin(
-                original[0].extras_list, compare_to[0].extras_list
-            )
-
-            # run the voice coupling algorithm, and add to inside_bar_op_list and inside_bar_cost
-            inside_bar_op_list, inside_bar_cost = Comparison._voices_coupling_recursive(
-                original[0].voices_list, compare_to[0].voices_list
-            )
-            inside_bar_op_list.extend(extras_op_list)
-            inside_bar_cost += extras_cost
-        cost_dict["editbar"] += inside_bar_cost
-        op_list_dict["editbar"].extend(inside_bar_op_list)
-        # compute the minimum of the possibilities
-        min_key = min(cost_dict, key=lambda k: cost_dict[k])
-        out = op_list_dict[min_key], cost_dict[min_key]
-        return out
-
-    @staticmethod
-    @_memoize_extras_diff_lin
-    def _extras_diff_lin(original, compare_to):
-        # original and compare to are two lists of AnnExtra
-        if len(original) == 0 and len(compare_to) == 0:
-            return [], 0
-
-        if len(original) == 0:
-            cost = 0
-            op_list, cost = Comparison._extras_diff_lin(original, compare_to[1:])
-            op_list.append(("extrains", None, compare_to[0], compare_to[0].notation_size()))
-            cost += compare_to[0].notation_size()
-            return op_list, cost
-
-        if len(compare_to) == 0:
-            cost = 0
-            op_list, cost = Comparison._extras_diff_lin(original[1:], compare_to)
-            op_list.append(("extradel", original[0], None, original[0].notation_size()))
-            cost += original[0].notation_size()
-            return op_list, cost
-
-        # compute the cost and the op_list for the many possibilities of recursion
-        cost = {}
-        op_list = {}
-        # extradel
-        op_list["extradel"], cost["extradel"] = Comparison._extras_diff_lin(
-            original[1:], compare_to
-        )
-        cost["extradel"] += original[0].notation_size()
-        op_list["extradel"].append(
-            ("extradel", original[0], None, original[0].notation_size())
-        )
-        # extrains
-        op_list["extrains"], cost["extrains"] = Comparison._extras_diff_lin(
-            original, compare_to[1:]
-        )
-        cost["extrains"] += compare_to[0].notation_size()
-        op_list["extrains"].append(
-            ("extrains", None, compare_to[0], compare_to[0].notation_size())
-        )
-        # extrasub
-        op_list["extrasub"], cost["extrasub"] = Comparison._extras_diff_lin(
-            original[1:], compare_to[1:]
-        )
-        if (
-            original[0] == compare_to[0]
-        ):  # avoid call another function if they are equal
-            extrasub_op, extrasub_cost = [], 0
-        else:
-            extrasub_op, extrasub_cost = Comparison._annotated_extra_diff(original[0], compare_to[0])
-        cost["extrasub"] += extrasub_cost
-        op_list["extrasub"].extend(extrasub_op)
-        # compute the minimum of the possibilities
-        min_key = min(cost, key=cost.get)
-        out = op_list[min_key], cost[min_key]
-        return out
-
-    @staticmethod
-    def _strings_leveinshtein_distance(str1: str, str2: str):
-        counter: dict = {"+": 0, "-": 0}
-        distance: int = 0
-        for edit_code, *_ in ndiff(str1, str2):
-            if edit_code == " ":
-                distance += max(counter.values())
-                counter = {"+": 0, "-": 0}
-            else:
-                counter[edit_code] += 1
-        distance += max(counter.values())
-        return distance
-
-    @staticmethod
-    def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
-        """compute the differences between two annotated extras
-        Each annotated extra consists of three values: content, offset, and duration
-        """
-        cost = 0
-        op_list = []
-
-        # add for the content
-        if annExtra1.content != annExtra2.content:
-            content_cost: int = Comparison._strings_leveinshtein_distance(
-                                            annExtra1.content, annExtra2.content)
-            cost += content_cost
-            op_list.append(("extracontentedit", annExtra1, annExtra2, content_cost))
+                            
+
+ + class + Comparison: - # add for the offset - if annExtra1.offset != annExtra2.offset: - # offset is in quarter-notes, so let's make the cost in quarter-notes as well. - # min cost is 1, though, don't round down to zero. - offset_cost: int = min(1, abs(annExtra1.duration - annExtra2.duration)) - cost += offset_cost - op_list.append(("extraoffsetedit", annExtra1, annExtra2, offset_cost)) + - # add for the duration - if annExtra1.duration != annExtra2.duration: - # duration is in quarter-notes, so let's make the cost in quarter-notes as well. - duration_cost = min(1, abs(annExtra1.duration - annExtra2.duration)) - cost += duration_cost - op_list.append(("extradurationedit", annExtra1, annExtra2, duration_cost)) - - # add for the style - if annExtra1.styledict != annExtra2.styledict: - cost += 1 - op_list.append(("extrastyleedit", annExtra1, annExtra2, 1)) - - return op_list, cost - - @staticmethod - @_memoize_inside_bars_diff_lin - def _inside_bars_diff_lin(original, compare_to): - # original and compare to are two lists of annotatedNote - if len(original) == 0 and len(compare_to) == 0: - return [], 0 - - if len(original) == 0: - cost = 0 - op_list, cost = Comparison._inside_bars_diff_lin(original, compare_to[1:]) - op_list.append(("noteins", None, compare_to[0], compare_to[0].notation_size())) - cost += compare_to[0].notation_size() - return op_list, cost - - if len(compare_to) == 0: - cost = 0 - op_list, cost = Comparison._inside_bars_diff_lin(original[1:], compare_to) - op_list.append(("notedel", original[0], None, original[0].notation_size())) - cost += original[0].notation_size() - return op_list, cost - - # compute the cost and the op_list for the many possibilities of recursion - cost = {} - op_list = {} - # notedel - op_list["notedel"], cost["notedel"] = Comparison._inside_bars_diff_lin( - original[1:], compare_to - ) - cost["notedel"] += original[0].notation_size() - op_list["notedel"].append( - ("notedel", original[0], None, original[0].notation_size()) - ) - # noteins - op_list["noteins"], cost["noteins"] = Comparison._inside_bars_diff_lin( - original, compare_to[1:] - ) - cost["noteins"] += compare_to[0].notation_size() - op_list["noteins"].append( - ("noteins", None, compare_to[0], compare_to[0].notation_size()) - ) - # notesub - op_list["notesub"], cost["notesub"] = Comparison._inside_bars_diff_lin( - original[1:], compare_to[1:] - ) - if ( - original[0] == compare_to[0] - ): # avoid call another function if they are equal - notesub_op, notesub_cost = [], 0 - else: - notesub_op, notesub_cost = Comparison._annotated_note_diff(original[0], compare_to[0]) - cost["notesub"] += notesub_cost - op_list["notesub"].extend(notesub_op) - # compute the minimum of the possibilities - min_key = min(cost, key=cost.get) - out = op_list[min_key], cost[min_key] - return out - - @staticmethod - def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote): - """compute the differences between two annotated notes - Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets) where pitches is a list - Arguments: - noteNode1 {[AnnNote]} -- original AnnNote - noteNode2 {[AnnNote]} -- compare_to AnnNote - """ - cost = 0 - op_list = [] - # add for the pitches - # if they are equal - if annNote1.pitches == annNote2.pitches: - op_list_pitch, cost_pitch = [], 0 - else: - # pitches diff is computed using leveinshtein differences (they are already ordered) - op_list_pitch, cost_pitch = Comparison._pitches_leveinsthein_diff( - annNote1.pitches, annNote2.pitches, annNote1, annNote2, (0, 0) - ) - op_list.extend(op_list_pitch) - cost += cost_pitch - # add for the notehead - if annNote1.note_head != annNote2.note_head: - cost += 1 - op_list.append(("headedit", annNote1, annNote2, 1)) - # add for the dots - if annNote1.dots != annNote2.dots: - dots_diff = abs(annNote1.dots - annNote2.dots) # add one for each dot - cost += dots_diff - if annNote1.dots > annNote2.dots: - op_list.append(("dotdel", annNote1, annNote2, dots_diff)) - else: - op_list.append(("dotins", annNote1, annNote2, dots_diff)) - # add for the beamings - if annNote1.beamings != annNote2.beamings: - beam_op_list, beam_cost = Comparison._beamtuplet_leveinsthein_diff( - annNote1.beamings, annNote2.beamings, annNote1, annNote2, "beam" - ) - op_list.extend(beam_op_list) - cost += beam_cost - # add for the tuplets - if annNote1.tuplets != annNote2.tuplets: - tuplet_op_list, tuplet_cost = Comparison._beamtuplet_leveinsthein_diff( - annNote1.tuplets, annNote2.tuplets, annNote1, annNote2, "tuplet" - ) - op_list.extend(tuplet_op_list) - cost += tuplet_cost - # add for the articulations - if annNote1.articulations != annNote2.articulations: - artic_op_list, artic_cost = Comparison._generic_leveinsthein_diff( - annNote1.articulations, - annNote2.articulations, - annNote1, - annNote2, - "articulation", - ) - op_list.extend(artic_op_list) - cost += artic_cost - # add for the expressions - if annNote1.expressions != annNote2.expressions: - expr_op_list, expr_cost = Comparison._generic_leveinsthein_diff( - annNote1.expressions, - annNote2.expressions, - annNote1, - annNote2, - "expression", - ) - op_list.extend(expr_op_list) - cost += expr_cost - # add for noteshape - if annNote1.noteshape != annNote2.noteshape: - cost += 1 - op_list.append(("editnoteshape", annNote1, annNote2, 1)) - # add for noteheadFill - if annNote1.noteheadFill != annNote2.noteheadFill: - cost += 1 - op_list.append(("editnoteheadfill", annNote1, annNote2, 1)) - # add for noteheadParenthesis - if annNote1.noteheadParenthesis != annNote2.noteheadParenthesis: - cost += 1 - op_list.append(("editnoteheadparenthesis", annNote1, annNote2, 1)) - # add for stemDirection - if annNote1.stemDirection != annNote2.stemDirection: - cost += 1 - op_list.append(("editstemdirection", annNote1, annNote2, 1)) - # add for the styledict - if annNote1.styledict != annNote2.styledict: - cost += 1 - op_list.append(("editstyle", annNote1, annNote2, 1)) - - return op_list, cost - - @staticmethod - @_memoize_beamtuplet_lev_diff - def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which): - """Compute the leveinsthein distance between two sequences of beaming or tuples - Arguments: - original {list} -- list of strings (start, stop, continue or partial) - compare_to {list} -- list of strings (start, stop, continue or partial) - note1 {AnnNote} -- the note for referencing in the score - note2 {AnnNote} -- the note for referencing in the score - which -- a string: "beam" or "tuplet" depending what we are comparing - """ - if not which in ("beam", "tuplet"): - raise Exception("Argument 'which' must be either 'beam' or 'tuplet'") - - if len(original) == 0 and len(compare_to) == 0: - return [], 0 - - if len(original) == 0: - op_list, cost = Comparison._beamtuplet_leveinsthein_diff( - original, compare_to[1:], note1, note2, which - ) - op_list.append(("ins" + which, note1, note2, 1)) - cost += 1 - return op_list, cost - - if len(compare_to) == 0: - op_list, cost = Comparison._beamtuplet_leveinsthein_diff( - original[1:], compare_to, note1, note2, which - ) - op_list.append(("del" + which, note1, note2, 1)) - cost += 1 - return op_list, cost - - # compute the cost and the op_list for the many possibilities of recursion - cost = {} - op_list = {} - # del-pitch - op_list["del" + which], cost["del" + which] = Comparison._beamtuplet_leveinsthein_diff( - original[1:], compare_to, note1, note2, which - ) - cost["del" + which] += 1 - op_list["del" + which].append(("del" + which, note1, note2, 1)) - # ins-pitch - op_list["ins" + which], cost["ins" + which] = Comparison._beamtuplet_leveinsthein_diff( - original, compare_to[1:], note1, note2, which - ) - cost["ins" + which] += 1 - op_list["ins" + which].append(("ins" + which, note1, note2, 1)) - # edit-pitch - op_list["edit" + which], cost["edit" + which] = Comparison._beamtuplet_leveinsthein_diff( - original[1:], compare_to[1:], note1, note2, which - ) - if original[0] == compare_to[0]: # to avoid perform the pitch_diff - beam_diff_op_list = [] - beam_diff_cost = 0 - else: - beam_diff_op_list, beam_diff_cost = [("edit" + which, note1, note2, 1)], 1 - cost["edit" + which] += beam_diff_cost - op_list["edit" + which].extend(beam_diff_op_list) - # compute the minimum of the possibilities - min_key = min(cost, key=cost.get) - out = op_list[min_key], cost[min_key] - return out - - @staticmethod - @_memoize_generic_lev_diff - def _generic_leveinsthein_diff(original, compare_to, note1, note2, which): - """Compute the leveinsthein distance between two generic sequences of symbols (e.g., articulations) - Arguments: - original {list} -- list of strings - compare_to {list} -- list of strings - note1 {AnnNote} -- the note for referencing in the score - note2 {AnnNote} -- the note for referencing in the score - which -- a string: e.g. "articulation" depending what we are comparing - """ - if len(original) == 0 and len(compare_to) == 0: - return [], 0 - - if len(original) == 0: - op_list, cost = Comparison._generic_leveinsthein_diff( - original, compare_to[1:], note1, note2, which - ) - op_list.append(("ins" + which, note1, note2, 1)) - cost += 1 - return op_list, cost - - if len(compare_to) == 0: - op_list, cost = Comparison._generic_leveinsthein_diff( - original[1:], compare_to, note1, note2, which - ) - op_list.append(("del" + which, note1, note2, 1)) - cost += 1 - return op_list, cost - - # compute the cost and the op_list for the many possibilities of recursion - cost = {} - op_list = {} - # del-pitch - op_list["del" + which], cost["del" + which] = Comparison._generic_leveinsthein_diff( - original[1:], compare_to, note1, note2, which - ) - cost["del" + which] += 1 - op_list["del" + which].append(("del" + which, note1, note2, 1)) - # ins-pitch - op_list["ins" + which], cost["ins" + which] = Comparison._generic_leveinsthein_diff( - original, compare_to[1:], note1, note2, which - ) - cost["ins" + which] += 1 - op_list["ins" + which].append(("ins" + which, None, compare_to[0], 1)) - # edit-pitch - op_list["edit" + which], cost["edit" + which] = Comparison._generic_leveinsthein_diff( - original[1:], compare_to[1:], note1, note2, which - ) - if original[0] == compare_to[0]: # to avoid perform the pitch_diff - generic_diff_op_list = [] - generic_diff_cost = 0 - else: - generic_diff_op_list, generic_diff_cost = ( - [("edit" + which, note1, note2, 1)], - 1, - ) - cost["edit" + which] += generic_diff_cost - op_list["edit" + which].extend(generic_diff_op_list) - # compute the minimum of the possibilities - min_key = min(cost, key=cost.get) - out = op_list[min_key], cost[min_key] - return out - - @staticmethod - def _voices_coupling_recursive(original: List[AnnVoice], compare_to: List[AnnVoice]): - """compare all the possible voices permutations, considering also deletion and insertion (equation on office lens) - original [list] -- a list of Voice - compare_to [list] -- a list of Voice - """ - if len(original) == 0 and len(compare_to) == 0: # stop the recursion - return [], 0 - - if len(original) == 0: - # insertion - op_list, cost = Comparison._voices_coupling_recursive(original, compare_to[1:]) - # add for the inserted voice - op_list.append(("voiceins", None, compare_to[0], compare_to[0].notation_size())) - cost += compare_to[0].notation_size() - return op_list, cost - - if len(compare_to) == 0: - # deletion - op_list, cost = Comparison._voices_coupling_recursive(original[1:], compare_to) - # add for the deleted voice - op_list.append(("voicedel", original[0], None, original[0].notation_size())) - cost += original[0].notation_size() - return op_list, cost - - cost = {} - op_list = {} - # deletion - op_list["voicedel"], cost["voicedel"] = Comparison._voices_coupling_recursive( - original[1:], compare_to - ) - op_list["voicedel"].append( - ("voicedel", original[0], None, original[0].notation_size()) - ) - cost["voicedel"] += original[0].notation_size() - for i, c in enumerate(compare_to): - # substitution - ( - op_list["voicesub" + str(i)], - cost["voicesub" + str(i)], - ) = Comparison._voices_coupling_recursive( - original[1:], compare_to[:i] + compare_to[i + 1 :] - ) - if ( - compare_to[0] != original[0] - ): # add the cost of the sub and the operations from inside_bar_diff - op_list_inside_bar, cost_inside_bar = Comparison._inside_bars_diff_lin( - original[0].annot_notes, c.annot_notes - ) # compute the distance from original[0] and compare_to[i] - op_list["voicesub" + str(i)].extend(op_list_inside_bar) - cost["voicesub" + str(i)] += cost_inside_bar - # compute the minimum of the possibilities - min_key = min(cost, key=cost.get) - out = op_list[min_key], cost[min_key] - return out - - @staticmethod - def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tuple], int]: - ''' - Compare two annotated scores, computing an operations list and the cost of applying those - operations to the first score to generate the second score. - - Args: - score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare. - score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare. - - Returns: - List[Tuple], int: The operations list and the cost - ''' - # for now just working with equal number of parts that are already pairs - # TODO : extend to different number of parts - assert score1.n_of_parts == score2.n_of_parts - n_of_parts = score1.n_of_parts - op_list_total, cost_total = [], 0 - # iterate for all parts in the score - for p_number in range(n_of_parts): - # compute non-common-subseq - ncs = Comparison._non_common_subsequences_of_measures( - score1.part_list[p_number].bar_list, - score2.part_list[p_number].bar_list, - ) - # compute blockdiff - for subseq in ncs: - op_list_block, cost_block = Comparison._block_diff_lin( - subseq["original"], subseq["compare_to"] - ) - op_list_total.extend(op_list_block) - cost_total += cost_block - - return op_list_total, cost_total -
- -
- - - -
-
#   - - - Comparison()
+ +
 127class Comparison:
+ 128    @staticmethod
+ 129    def _myers_diff(a_lines, b_lines):
+ 130        # Myers algorithm for LCS of bars (instead of the recursive algorithm in section 3.2)
+ 131        # This marks the farthest-right point along each diagonal in the edit
+ 132        # graph, along with the history that got it there
+ 133        Frontier = namedtuple("Frontier", ["x", "history"])
+ 134        frontier = {1: Frontier(0, [])}
+ 135
+ 136        a_max = len(a_lines)
+ 137        b_max = len(b_lines)
+ 138        for d in range(0, a_max + b_max + 1):
+ 139            for k in range(-d, d + 1, 2):
+ 140                # This determines whether our next search point will be going down
+ 141                # in the edit graph, or to the right.
+ 142                #
+ 143                # The intuition for this is that we should go down if we're on the
+ 144                # left edge (k == -d) to make sure that the left edge is fully
+ 145                # explored.
+ 146                #
+ 147                # If we aren't on the top (k != d), then only go down if going down
+ 148                # would take us to territory that hasn't sufficiently been explored
+ 149                # yet.
+ 150                go_down = k == -d or (k != d and frontier[k - 1].x < frontier[k + 1].x)
+ 151
+ 152                # Figure out the starting point of this iteration. The diagonal
+ 153                # offsets come from the geometry of the edit grid - if you're going
+ 154                # down, your diagonal is lower, and if you're going right, your
+ 155                # diagonal is higher.
+ 156                if go_down:
+ 157                    old_x, history = frontier[k + 1]
+ 158                    x = old_x
+ 159                else:
+ 160                    old_x, history = frontier[k - 1]
+ 161                    x = old_x + 1
+ 162
+ 163                # We want to avoid modifying the old history, since some other step
+ 164                # may decide to use it.
+ 165                history = history[:]
+ 166                y = x - k
+ 167
+ 168                # We start at the invalid point (0, 0) - we should only start building
+ 169                # up history when we move off of it.
+ 170                if 1 <= y <= b_max and go_down:
+ 171                    history.append((1, b_lines[y - 1][1]))  # add comparetostep
+ 172                elif 1 <= x <= a_max:
+ 173                    history.append((0, a_lines[x - 1][1]))  # add originalstep
+ 174
+ 175                # Chew up as many diagonal moves as we can - these correspond to common lines,
+ 176                # and they're considered "free" by the algorithm because we want to maximize
+ 177                # the number of these in the output.
+ 178                while x < a_max and y < b_max and a_lines[x][0] == b_lines[y][0]:
+ 179                    x += 1
+ 180                    y += 1
+ 181                    history.append((2, a_lines[x - 1][1]))  # add equal step
+ 182
+ 183                if x >= a_max and y >= b_max:
+ 184                    # If we're here, then we've traversed through the bottom-left corner,
+ 185                    # and are done.
+ 186                    return np.array(history)
+ 187
+ 188                frontier[k] = Frontier(x, history)
+ 189
+ 190        assert False, "Could not find edit script"
+ 191
+ 192    @staticmethod
+ 193    def _non_common_subsequences_myers(original, compare_to):
+ 194        # Both original and compare_to are list of lists, or numpy arrays with 2 columns.
+ 195        # This is necessary because bars need two representation at the same time.
+ 196        # One without the id (for comparison), and one with the id (to retrieve the bar
+ 197        # at the end).
+ 198
+ 199        # get the list of operations
+ 200        op_list = Comparison._myers_diff(
+ 201            np.array(original, dtype=np.int64), np.array(compare_to, dtype=np.int64)
+ 202        )[::-1]
+ 203        # retrieve the non common subsequences
+ 204        non_common_subsequences = []
+ 205        non_common_subsequences.append({"original": [], "compare_to": []})
+ 206        ind = 0
+ 207        for op in op_list[::-1]:
+ 208            if op[0] == 2:  # equal
+ 209                non_common_subsequences.append({"original": [], "compare_to": []})
+ 210                ind += 1
+ 211            elif op[0] == 0:  # original step
+ 212                non_common_subsequences[ind]["original"].append(op[1])
+ 213            elif op[0] == 1:  # compare to step
+ 214                non_common_subsequences[ind]["compare_to"].append(op[1])
+ 215        # remove the empty dict from the list
+ 216        non_common_subsequences = [
+ 217            s for s in non_common_subsequences if s != {"original": [], "compare_to": []}
+ 218        ]
+ 219        return non_common_subsequences
+ 220
+ 221    @staticmethod
+ 222    def _non_common_subsequences_of_measures(original_m, compare_to_m):
+ 223        # Take the hash for each measure to run faster comparison
+ 224        # We need two hashes: one that is independent of the IDs (precomputed_str, for comparison),
+ 225        # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after
+ 226        # computation)
+ 227        original_int = [[o.precomputed_str, o.precomputed_repr] for o in original_m]
+ 228        compare_to_int = [[c.precomputed_str, c.precomputed_repr] for c in compare_to_m]
+ 229        ncs = Comparison._non_common_subsequences_myers(original_int, compare_to_int)
+ 230        # retrieve the original pointers to measures
+ 231        new_out = []
+ 232        for e in ncs:
+ 233            new_out.append({})
+ 234            for k in e.keys():
+ 235                new_out[-1][k] = []
+ 236                for repr_hash in e[k]:
+ 237                    if k == "original":
+ 238                        new_out[-1][k].append(
+ 239                            next(m for m in original_m if m.precomputed_repr == repr_hash)
+ 240                        )
+ 241                    else:
+ 242                        new_out[-1][k].append(
+ 243                            next(m for m in compare_to_m if m.precomputed_repr == repr_hash)
+ 244                        )
+ 245
+ 246        return new_out
+ 247
+ 248    @staticmethod
+ 249    @_memoize_pitches_lev_diff
+ 250    def _pitches_leveinsthein_diff(
+ 251        original: list[tuple[str, str, bool]],
+ 252        compare_to: list[tuple[str, str, bool]],
+ 253        noteNode1: AnnNote,
+ 254        noteNode2: AnnNote,
+ 255        ids: tuple[int, int]
+ 256    ):
+ 257        """
+ 258        Compute the leveinsthein distance between two sequences of pitches.
+ 259        Arguments:
+ 260            original {list} -- list of pitches
+ 261            compare_to {list} -- list of pitches
+ 262            noteNode1 {annotatedNote} --for referencing
+ 263            noteNode2 {annotatedNote} --for referencing
+ 264            ids {tuple} -- a tuple of 2 elements with the indices of the notes considered
+ 265        """
+ 266        if len(original) == 0 and len(compare_to) == 0:
+ 267            return [], 0
+ 268
+ 269        if len(original) == 0:
+ 270            cost = M21Utils.pitch_size(compare_to[0])
+ 271            op_list, cost = Comparison._pitches_leveinsthein_diff(
+ 272                original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
+ 273            )
+ 274            op_list.append(
+ 275                ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
+ 276            )
+ 277            cost += M21Utils.pitch_size(compare_to[0])
+ 278            return op_list, cost
+ 279
+ 280        if len(compare_to) == 0:
+ 281            cost = M21Utils.pitch_size(original[0])
+ 282            op_list, cost = Comparison._pitches_leveinsthein_diff(
+ 283                original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
+ 284            )
+ 285            op_list.append(
+ 286                ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
+ 287            )
+ 288            cost += M21Utils.pitch_size(original[0])
+ 289            return op_list, cost
+ 290
+ 291        # compute the cost and the op_list for the many possibilities of recursion
+ 292        cost_dict = {}
+ 293        op_list_dict = {}
+ 294        # del-pitch
+ 295        op_list_dict["delpitch"], cost_dict["delpitch"] = Comparison._pitches_leveinsthein_diff(
+ 296            original[1:], compare_to, noteNode1, noteNode2, (ids[0] + 1, ids[1])
+ 297        )
+ 298        cost_dict["delpitch"] += M21Utils.pitch_size(original[0])
+ 299        op_list_dict["delpitch"].append(
+ 300            ("delpitch", noteNode1, noteNode2, M21Utils.pitch_size(original[0]), ids)
+ 301        )
+ 302        # ins-pitch
+ 303        op_list_dict["inspitch"], cost_dict["inspitch"] = Comparison._pitches_leveinsthein_diff(
+ 304            original, compare_to[1:], noteNode1, noteNode2, (ids[0], ids[1] + 1)
+ 305        )
+ 306        cost_dict["inspitch"] += M21Utils.pitch_size(compare_to[0])
+ 307        op_list_dict["inspitch"].append(
+ 308            ("inspitch", noteNode1, noteNode2, M21Utils.pitch_size(compare_to[0]), ids)
+ 309        )
+ 310        # edit-pitch
+ 311        op_list_dict["editpitch"], cost_dict["editpitch"] = Comparison._pitches_leveinsthein_diff(
+ 312            original[1:], compare_to[1:], noteNode1, noteNode2, (ids[0] + 1, ids[1] + 1)
+ 313        )
+ 314        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
+ 315            pitch_diff_op_list = []
+ 316            pitch_diff_cost = 0
+ 317        else:
+ 318            pitch_diff_op_list, pitch_diff_cost = Comparison._pitches_diff(
+ 319                original[0], compare_to[0], noteNode1, noteNode2, (ids[0], ids[1])
+ 320            )
+ 321        cost_dict["editpitch"] += pitch_diff_cost
+ 322        op_list_dict["editpitch"].extend(pitch_diff_op_list)
+ 323        # compute the minimum of the possibilities
+ 324        min_key = min(cost_dict, key=lambda k: cost_dict[k])
+ 325        out = op_list_dict[min_key], cost_dict[min_key]
+ 326        return out
+ 327
+ 328    @staticmethod
+ 329    def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids):
+ 330        """
+ 331        Compute the differences between two pitch (definition from the paper).
+ 332        a pitch consist of a tuple: pitch name (letter+number), accidental, tie.
+ 333        param : pitch1. The music_notation_repr tuple of note1
+ 334        param : pitch2. The music_notation_repr tuple of note2
+ 335        param : noteNode1. The noteNode where pitch1 belongs
+ 336        param : noteNode2. The noteNode where pitch2 belongs
+ 337        param : ids. (id_from_note1,id_from_note2) The indices of the notes in case of a chord
+ 338        Returns:
+ 339            [list] -- the list of differences
+ 340            [int] -- the cost of diff
+ 341        """
+ 342        cost = 0
+ 343        op_list = []
+ 344        # add for pitch name differences
+ 345        if pitch1[0] != pitch2[0]:
+ 346            cost += 1
+ 347            # TODO: select the note in a more precise way in case of a chord
+ 348            # rest to note
+ 349            if (pitch1[0][0] == "R") != (pitch2[0][0] == "R"):  # xor
+ 350                op_list.append(("pitchtypeedit", noteNode1, noteNode2, 1, ids))
+ 351            else:  # they are two notes
+ 352                op_list.append(("pitchnameedit", noteNode1, noteNode2, 1, ids))
+ 353        # add for the accidentals
+ 354        if pitch1[1] != pitch2[1]:  # if the accidental is different
+ 355            cost += 1
+ 356            if pitch1[1] == "None":
+ 357                assert pitch2[1] != "None"
+ 358                op_list.append(("accidentins", noteNode1, noteNode2, 1, ids))
+ 359            elif pitch2[1] == "None":
+ 360                assert pitch1[1] != "None"
+ 361                op_list.append(("accidentdel", noteNode1, noteNode2, 1, ids))
+ 362            else:  # a different tipe of alteration is present
+ 363                op_list.append(("accidentedit", noteNode1, noteNode2, 1, ids))
+ 364        # add for the ties
+ 365        if pitch1[2] != pitch2[2]:
+ 366            # exclusive or. Add if one is tied and not the other.
+ 367            # probably to revise for chords
+ 368            cost += 1
+ 369            if pitch1[2]:
+ 370                assert not pitch2[2]
+ 371                op_list.append(("tiedel", noteNode1, noteNode2, 1, ids))
+ 372            elif pitch2[2]:
+ 373                assert not pitch1[2]
+ 374                op_list.append(("tieins", noteNode1, noteNode2, 1, ids))
+ 375        return op_list, cost
+ 376
+ 377    @staticmethod
+ 378    @_memoize_block_diff_lin
+ 379    def _block_diff_lin(original, compare_to):
+ 380        if len(original) == 0 and len(compare_to) == 0:
+ 381            return [], 0
+ 382
+ 383        if len(original) == 0:
+ 384            op_list, cost = Comparison._block_diff_lin(original, compare_to[1:])
+ 385            cost += compare_to[0].notation_size()
+ 386            op_list.append(("insbar", None, compare_to[0], compare_to[0].notation_size()))
+ 387            return op_list, cost
+ 388
+ 389        if len(compare_to) == 0:
+ 390            op_list, cost = Comparison._block_diff_lin(original[1:], compare_to)
+ 391            cost += original[0].notation_size()
+ 392            op_list.append(("delbar", original[0], None, original[0].notation_size()))
+ 393            return op_list, cost
+ 394
+ 395        # compute the cost and the op_list for the many possibilities of recursion
+ 396        cost_dict = {}
+ 397        op_list_dict = {}
+ 398        # del-bar
+ 399        op_list_dict["delbar"], cost_dict["delbar"] = Comparison._block_diff_lin(
+ 400            original[1:], compare_to
+ 401        )
+ 402        cost_dict["delbar"] += original[0].notation_size()
+ 403        op_list_dict["delbar"].append(
+ 404            ("delbar", original[0], None, original[0].notation_size())
+ 405        )
+ 406        # ins-bar
+ 407        op_list_dict["insbar"], cost_dict["insbar"] = Comparison._block_diff_lin(
+ 408            original, compare_to[1:]
+ 409        )
+ 410        cost_dict["insbar"] += compare_to[0].notation_size()
+ 411        op_list_dict["insbar"].append(
+ 412            ("insbar", None, compare_to[0], compare_to[0].notation_size())
+ 413        )
+ 414        # edit-bar
+ 415        op_list_dict["editbar"], cost_dict["editbar"] = Comparison._block_diff_lin(
+ 416            original[1:], compare_to[1:]
+ 417        )
+ 418        if (
+ 419            original[0] == compare_to[0]
+ 420        ):  # to avoid performing the _voices_coupling_recursive if it's not needed
+ 421            inside_bar_op_list = []
+ 422            inside_bar_cost = 0
+ 423        else:
+ 424            # diff the bar extras (like _inside_bars_diff_lin, but with lists of AnnExtras
+ 425            # instead of lists of AnnNotes)
+ 426            extras_op_list, extras_cost = Comparison._extras_diff_lin(
+ 427                original[0].extras_list, compare_to[0].extras_list
+ 428            )
+ 429
+ 430            # run the voice coupling algorithm, and add to inside_bar_op_list and inside_bar_cost
+ 431            inside_bar_op_list, inside_bar_cost = Comparison._voices_coupling_recursive(
+ 432                original[0].voices_list, compare_to[0].voices_list
+ 433            )
+ 434            inside_bar_op_list.extend(extras_op_list)
+ 435            inside_bar_cost += extras_cost
+ 436        cost_dict["editbar"] += inside_bar_cost
+ 437        op_list_dict["editbar"].extend(inside_bar_op_list)
+ 438        # compute the minimum of the possibilities
+ 439        min_key = min(cost_dict, key=lambda k: cost_dict[k])
+ 440        out = op_list_dict[min_key], cost_dict[min_key]
+ 441        return out
+ 442
+ 443    @staticmethod
+ 444    @_memoize_extras_diff_lin
+ 445    def _extras_diff_lin(original, compare_to):
+ 446        # original and compare to are two lists of AnnExtra
+ 447        if len(original) == 0 and len(compare_to) == 0:
+ 448            return [], 0
+ 449
+ 450        if len(original) == 0:
+ 451            cost = 0
+ 452            op_list, cost = Comparison._extras_diff_lin(original, compare_to[1:])
+ 453            op_list.append(("extrains", None, compare_to[0], compare_to[0].notation_size()))
+ 454            cost += compare_to[0].notation_size()
+ 455            return op_list, cost
+ 456
+ 457        if len(compare_to) == 0:
+ 458            cost = 0
+ 459            op_list, cost = Comparison._extras_diff_lin(original[1:], compare_to)
+ 460            op_list.append(("extradel", original[0], None, original[0].notation_size()))
+ 461            cost += original[0].notation_size()
+ 462            return op_list, cost
+ 463
+ 464        # compute the cost and the op_list for the many possibilities of recursion
+ 465        cost = {}
+ 466        op_list = {}
+ 467        # extradel
+ 468        op_list["extradel"], cost["extradel"] = Comparison._extras_diff_lin(
+ 469            original[1:], compare_to
+ 470        )
+ 471        cost["extradel"] += original[0].notation_size()
+ 472        op_list["extradel"].append(
+ 473            ("extradel", original[0], None, original[0].notation_size())
+ 474        )
+ 475        # extrains
+ 476        op_list["extrains"], cost["extrains"] = Comparison._extras_diff_lin(
+ 477            original, compare_to[1:]
+ 478        )
+ 479        cost["extrains"] += compare_to[0].notation_size()
+ 480        op_list["extrains"].append(
+ 481            ("extrains", None, compare_to[0], compare_to[0].notation_size())
+ 482        )
+ 483        # extrasub
+ 484        op_list["extrasub"], cost["extrasub"] = Comparison._extras_diff_lin(
+ 485            original[1:], compare_to[1:]
+ 486        )
+ 487        if (
+ 488            original[0] == compare_to[0]
+ 489        ):  # avoid call another function if they are equal
+ 490            extrasub_op, extrasub_cost = [], 0
+ 491        else:
+ 492            extrasub_op, extrasub_cost = (
+ 493                Comparison._annotated_extra_diff(original[0], compare_to[0])
+ 494            )
+ 495        cost["extrasub"] += extrasub_cost
+ 496        op_list["extrasub"].extend(extrasub_op)
+ 497        # compute the minimum of the possibilities
+ 498        min_key = min(cost, key=cost.get)
+ 499        out = op_list[min_key], cost[min_key]
+ 500        return out
+ 501
+ 502    @staticmethod
+ 503    @_memoize_metadata_items_diff_lin
+ 504    def _metadata_items_diff_lin(original, compare_to):
+ 505        # original and compare to are two lists of tuple[str, t.Any]
+ 506        if len(original) == 0 and len(compare_to) == 0:
+ 507            return [], 0
+ 508
+ 509        if len(original) == 0:
+ 510            cost = 0
+ 511            op_list, cost = Comparison._metadata_items_diff_lin(original, compare_to[1:])
+ 512            op_list.append(("mditemins", None, compare_to[0], compare_to[0].notation_size()))
+ 513            cost += compare_to[0].notation_size()
+ 514            return op_list, cost
+ 515
+ 516        if len(compare_to) == 0:
+ 517            cost = 0
+ 518            op_list, cost = Comparison._metadata_items_diff_lin(original[1:], compare_to)
+ 519            op_list.append(("mditemdel", original[0], None, original[0].notation_size()))
+ 520            cost += original[0].notation_size()
+ 521            return op_list, cost
+ 522
+ 523        # compute the cost and the op_list for the many possibilities of recursion
+ 524        cost = {}
+ 525        op_list = {}
+ 526        # mditemdel
+ 527        op_list["mditemdel"], cost["mditemdel"] = Comparison._metadata_items_diff_lin(
+ 528            original[1:], compare_to
+ 529        )
+ 530        cost["mditemdel"] += original[0].notation_size()
+ 531        op_list["mditemdel"].append(
+ 532            ("mditemdel", original[0], None, original[0].notation_size())
+ 533        )
+ 534        # mditemins
+ 535        op_list["mditemins"], cost["mditemins"] = Comparison._metadata_items_diff_lin(
+ 536            original, compare_to[1:]
+ 537        )
+ 538        cost["mditemins"] += compare_to[0].notation_size()
+ 539        op_list["mditemins"].append(
+ 540            ("mditemins", None, compare_to[0], compare_to[0].notation_size())
+ 541        )
+ 542        # mditemsub
+ 543        op_list["mditemsub"], cost["mditemsub"] = Comparison._metadata_items_diff_lin(
+ 544            original[1:], compare_to[1:]
+ 545        )
+ 546        if (
+ 547            original[0] == compare_to[0]
+ 548        ):  # avoid call another function if they are equal
+ 549            mditemsub_op, mditemsub_cost = [], 0
+ 550        else:
+ 551            mditemsub_op, mditemsub_cost = (
+ 552                Comparison._annotated_metadata_item_diff(original[0], compare_to[0])
+ 553            )
+ 554        cost["mditemsub"] += mditemsub_cost
+ 555        op_list["mditemsub"].extend(mditemsub_op)
+ 556        # compute the minimum of the possibilities
+ 557        min_key = min(cost, key=cost.get)
+ 558        out = op_list[min_key], cost[min_key]
+ 559        return out
+ 560
+ 561    @staticmethod
+ 562    @_memoize_staff_groups_diff_lin
+ 563    def _staff_groups_diff_lin(original, compare_to):
+ 564        # original and compare to are two lists of AnnStaffGroup
+ 565        if len(original) == 0 and len(compare_to) == 0:
+ 566            return [], 0
+ 567
+ 568        if len(original) == 0:
+ 569            cost = 0
+ 570            op_list, cost = Comparison._staff_groups_diff_lin(original, compare_to[1:])
+ 571            op_list.append(("staffgrpins", None, compare_to[0], compare_to[0].notation_size()))
+ 572            cost += compare_to[0].notation_size()
+ 573            return op_list, cost
+ 574
+ 575        if len(compare_to) == 0:
+ 576            cost = 0
+ 577            op_list, cost = Comparison._staff_groups_diff_lin(original[1:], compare_to)
+ 578            op_list.append(("staffgrpdel", original[0], None, original[0].notation_size()))
+ 579            cost += original[0].notation_size()
+ 580            return op_list, cost
+ 581
+ 582        # compute the cost and the op_list for the many possibilities of recursion
+ 583        cost = {}
+ 584        op_list = {}
+ 585        # staffgrpdel
+ 586        op_list["staffgrpdel"], cost["staffgrpdel"] = Comparison._staff_groups_diff_lin(
+ 587            original[1:], compare_to
+ 588        )
+ 589        cost["staffgrpdel"] += original[0].notation_size()
+ 590        op_list["staffgrpdel"].append(
+ 591            ("staffgrpdel", original[0], None, original[0].notation_size())
+ 592        )
+ 593        # staffgrpins
+ 594        op_list["staffgrpins"], cost["staffgrpins"] = Comparison._staff_groups_diff_lin(
+ 595            original, compare_to[1:]
+ 596        )
+ 597        cost["staffgrpins"] += compare_to[0].notation_size()
+ 598        op_list["staffgrpins"].append(
+ 599            ("staffgrpins", None, compare_to[0], compare_to[0].notation_size())
+ 600        )
+ 601        # staffgrpsub
+ 602        op_list["staffgrpsub"], cost["staffgrpsub"] = Comparison._staff_groups_diff_lin(
+ 603            original[1:], compare_to[1:]
+ 604        )
+ 605        if (
+ 606            original[0] == compare_to[0]
+ 607        ):  # avoid call another function if they are equal
+ 608            staffgrpsub_op, staffgrpsub_cost = [], 0
+ 609        else:
+ 610            staffgrpsub_op, staffgrpsub_cost = (
+ 611                Comparison._annotated_staff_group_diff(original[0], compare_to[0])
+ 612            )
+ 613        cost["staffgrpsub"] += staffgrpsub_cost
+ 614        op_list["staffgrpsub"].extend(staffgrpsub_op)
+ 615        # compute the minimum of the possibilities
+ 616        min_key = min(cost, key=cost.get)
+ 617        out = op_list[min_key], cost[min_key]
+ 618        return out
+ 619
+ 620    @staticmethod
+ 621    def _strings_leveinshtein_distance(str1: str, str2: str):
+ 622        counter: dict = {"+": 0, "-": 0}
+ 623        distance: int = 0
+ 624        for edit_code in ndiff(str1, str2):
+ 625            if edit_code[0] == " ":
+ 626                distance += max(counter.values())
+ 627                counter = {"+": 0, "-": 0}
+ 628            else:
+ 629                counter[edit_code[0]] += 1
+ 630        distance += max(counter.values())
+ 631        return distance
+ 632
+ 633    @staticmethod
+ 634    def _areDifferentEnough(flt1: float, flt2: float) -> bool:
+ 635        diff: float = flt1 - flt2
+ 636        if diff < 0:
+ 637            diff = -diff
+ 638
+ 639        if diff > 0.0001:
+ 640            return True
+ 641        return False
+ 642
+ 643    @staticmethod
+ 644    def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
+ 645        """
+ 646        Compute the differences between two annotated extras.
+ 647        Each annotated extra consists of three values: content, offset, and duration
+ 648        """
+ 649        cost = 0
+ 650        op_list = []
+ 651
+ 652        # add for the content
+ 653        if annExtra1.content != annExtra2.content:
+ 654            content_cost: int = (
+ 655                Comparison._strings_leveinshtein_distance(annExtra1.content, annExtra2.content)
+ 656            )
+ 657            cost += content_cost
+ 658            op_list.append(("extracontentedit", annExtra1, annExtra2, content_cost))
+ 659
+ 660        # add for the offset
+ 661        # Note: offset here is a float, and some file formats have only four
+ 662        # decimal places of precision.  So we should not compare exactly here.
+ 663        if Comparison._areDifferentEnough(annExtra1.offset, annExtra2.offset):
+ 664            # offset is in quarter-notes, so let's make the cost in quarter-notes as well.
+ 665            # min cost is 1, though, don't round down to zero.
+ 666            offset_cost: int = int(min(1, abs(annExtra1.offset - annExtra2.offset)))
+ 667            cost += offset_cost
+ 668            op_list.append(("extraoffsetedit", annExtra1, annExtra2, offset_cost))
+ 669
+ 670        # add for the duration
+ 671        # Note: duration here is a float, and some file formats have only four
+ 672        # decimal places of precision.  So we should not compare exactly here.
+ 673        if Comparison._areDifferentEnough(annExtra1.duration, annExtra2.duration):
+ 674            # duration is in quarter-notes, so let's make the cost in quarter-notes as well.
+ 675            duration_cost = int(min(1, abs(annExtra1.duration - annExtra2.duration)))
+ 676            cost += duration_cost
+ 677            op_list.append(("extradurationedit", annExtra1, annExtra2, duration_cost))
+ 678
+ 679        # add for the style
+ 680        if annExtra1.styledict != annExtra2.styledict:
+ 681            cost += 1
+ 682            op_list.append(("extrastyleedit", annExtra1, annExtra2, 1))
+ 683
+ 684        return op_list, cost
+ 685
+ 686    @staticmethod
+ 687    def _annotated_metadata_item_diff(
+ 688        annMetadataItem1: AnnMetadataItem,
+ 689        annMetadataItem2: AnnMetadataItem
+ 690    ):
+ 691        """
+ 692        Compute the differences between two annotated metadata items.
+ 693        Each annotated metadata item has two values: key: str, value: t.Any,
+ 694        """
+ 695        cost = 0
+ 696        op_list = []
+ 697
+ 698        # add for the key
+ 699        if annMetadataItem1.key != annMetadataItem2.key:
+ 700            key_cost: int = (
+ 701                Comparison._strings_leveinshtein_distance(
+ 702                    annMetadataItem1.key,
+ 703                    annMetadataItem2.key
+ 704                )
+ 705            )
+ 706            cost += key_cost
+ 707            op_list.append(("mditemkeyedit", annMetadataItem1, annMetadataItem2, key_cost))
+ 708
+ 709        # add for the value
+ 710        if annMetadataItem1.value != annMetadataItem2.value:
+ 711            value_cost: int = (
+ 712                Comparison._strings_leveinshtein_distance(
+ 713                    str(annMetadataItem1.value),
+ 714                    str(annMetadataItem2.value)
+ 715                )
+ 716            )
+ 717            cost += value_cost
+ 718            op_list.append(
+ 719                ("mditemvalueedit", annMetadataItem1, annMetadataItem2, value_cost)
+ 720            )
+ 721
+ 722        return op_list, cost
+ 723
+ 724    @staticmethod
+ 725    def _annotated_staff_group_diff(annStaffGroup1: AnnStaffGroup, annStaffGroup2: AnnStaffGroup):
+ 726        """
+ 727        Compute the differences between two annotated staff groups.
+ 728        Each annotated staff group consists of five values: name, abbreviation,
+ 729        symbol, barTogether, part_indices.
+ 730        """
+ 731        cost = 0
+ 732        op_list = []
+ 733
+ 734        # add for the name
+ 735        if annStaffGroup1.name != annStaffGroup2.name:
+ 736            name_cost: int = (
+ 737                Comparison._strings_leveinshtein_distance(annStaffGroup1.name, annStaffGroup2.name)
+ 738            )
+ 739            cost += name_cost
+ 740            op_list.append(("staffgrpnameedit", annStaffGroup1, annStaffGroup2, name_cost))
+ 741
+ 742        # add for the abbreviation
+ 743        if annStaffGroup1.abbreviation != annStaffGroup2.abbreviation:
+ 744            abbreviation_cost: int = (
+ 745                Comparison._strings_leveinshtein_distance(
+ 746                    annStaffGroup1.abbreviation,
+ 747                    annStaffGroup2.abbreviation
+ 748                )
+ 749            )
+ 750            cost += abbreviation_cost
+ 751            op_list.append(
+ 752                ("staffgrpabbreviationedit", annStaffGroup1, annStaffGroup2, abbreviation_cost)
+ 753            )
+ 754
+ 755        # add for the symbol
+ 756        if annStaffGroup1.symbol != annStaffGroup2.symbol:
+ 757            symbol_cost: int = 1
+ 758            cost += symbol_cost
+ 759            op_list.append(
+ 760                ("staffgrpsymboledit", annStaffGroup1, annStaffGroup2, symbol_cost)
+ 761            )
+ 762
+ 763        # add for barTogether
+ 764        if annStaffGroup1.barTogether != annStaffGroup2.barTogether:
+ 765            barTogether_cost: int = 1
+ 766            cost += barTogether_cost
+ 767            op_list.append(
+ 768                ("staffgrpbartogetheredit", annStaffGroup1, annStaffGroup2, barTogether_cost)
+ 769            )
+ 770
+ 771        # add for partIndices (sorted list of int)
+ 772        if annStaffGroup1.part_indices != annStaffGroup2.part_indices:
+ 773            parts1: str = str(annStaffGroup1.part_indices)
+ 774            parts2: str = str(annStaffGroup2.part_indices)
+ 775            partIndices_cost: int = (
+ 776                Comparison._strings_leveinshtein_distance(
+ 777                    parts1,
+ 778                    parts2
+ 779                )
+ 780            )
+ 781            cost += partIndices_cost
+ 782            op_list.append(
+ 783                ("staffgrppartindicesedit", annStaffGroup1, annStaffGroup2, partIndices_cost)
+ 784            )
+ 785
+ 786        return op_list, cost
+ 787
+ 788    @staticmethod
+ 789    @_memoize_inside_bars_diff_lin
+ 790    def _inside_bars_diff_lin(original, compare_to):
+ 791        # original and compare to are two lists of annotatedNote
+ 792        if len(original) == 0 and len(compare_to) == 0:
+ 793            return [], 0
+ 794
+ 795        if len(original) == 0:
+ 796            cost = 0
+ 797            op_list, cost = Comparison._inside_bars_diff_lin(original, compare_to[1:])
+ 798            op_list.append(("noteins", None, compare_to[0], compare_to[0].notation_size()))
+ 799            cost += compare_to[0].notation_size()
+ 800            return op_list, cost
+ 801
+ 802        if len(compare_to) == 0:
+ 803            cost = 0
+ 804            op_list, cost = Comparison._inside_bars_diff_lin(original[1:], compare_to)
+ 805            op_list.append(("notedel", original[0], None, original[0].notation_size()))
+ 806            cost += original[0].notation_size()
+ 807            return op_list, cost
+ 808
+ 809        # compute the cost and the op_list for the many possibilities of recursion
+ 810        cost = {}
+ 811        op_list = {}
+ 812        # notedel
+ 813        op_list["notedel"], cost["notedel"] = Comparison._inside_bars_diff_lin(
+ 814            original[1:], compare_to
+ 815        )
+ 816        cost["notedel"] += original[0].notation_size()
+ 817        op_list["notedel"].append(
+ 818            ("notedel", original[0], None, original[0].notation_size())
+ 819        )
+ 820        # noteins
+ 821        op_list["noteins"], cost["noteins"] = Comparison._inside_bars_diff_lin(
+ 822            original, compare_to[1:]
+ 823        )
+ 824        cost["noteins"] += compare_to[0].notation_size()
+ 825        op_list["noteins"].append(
+ 826            ("noteins", None, compare_to[0], compare_to[0].notation_size())
+ 827        )
+ 828        # notesub
+ 829        op_list["notesub"], cost["notesub"] = Comparison._inside_bars_diff_lin(
+ 830            original[1:], compare_to[1:]
+ 831        )
+ 832        if (
+ 833            original[0] == compare_to[0]
+ 834        ):  # avoid call another function if they are equal
+ 835            notesub_op, notesub_cost = [], 0
+ 836        else:
+ 837            notesub_op, notesub_cost = Comparison._annotated_note_diff(original[0], compare_to[0])
+ 838        cost["notesub"] += notesub_cost
+ 839        op_list["notesub"].extend(notesub_op)
+ 840        # compute the minimum of the possibilities
+ 841        min_key = min(cost, key=cost.get)
+ 842        out = op_list[min_key], cost[min_key]
+ 843        return out
+ 844
+ 845    @staticmethod
+ 846    def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote):
+ 847        """
+ 848        Compute the differences between two annotated notes.
+ 849        Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets)
+ 850        where pitches is a list.
+ 851        Arguments:
+ 852            noteNode1 {[AnnNote]} -- original AnnNote
+ 853            noteNode2 {[AnnNote]} -- compare_to AnnNote
+ 854        """
+ 855        cost = 0
+ 856        op_list = []
+ 857        # add for the pitches
+ 858        # if they are equal
+ 859        if annNote1.pitches == annNote2.pitches:
+ 860            op_list_pitch, cost_pitch = [], 0
+ 861        else:
+ 862            # pitches diff is computed using leveinshtein differences (they are already ordered)
+ 863            op_list_pitch, cost_pitch = Comparison._pitches_leveinsthein_diff(
+ 864                annNote1.pitches, annNote2.pitches, annNote1, annNote2, (0, 0)
+ 865            )
+ 866        op_list.extend(op_list_pitch)
+ 867        cost += cost_pitch
+ 868        # add for the notehead
+ 869        if annNote1.note_head != annNote2.note_head:
+ 870            cost += 1
+ 871            op_list.append(("headedit", annNote1, annNote2, 1))
+ 872        # add for the dots
+ 873        if annNote1.dots != annNote2.dots:
+ 874            dots_diff = abs(annNote1.dots - annNote2.dots)  # add one for each dot
+ 875            cost += dots_diff
+ 876            if annNote1.dots > annNote2.dots:
+ 877                op_list.append(("dotdel", annNote1, annNote2, dots_diff))
+ 878            else:
+ 879                op_list.append(("dotins", annNote1, annNote2, dots_diff))
+ 880        if annNote1.graceType != annNote2.graceType:
+ 881            cost += 1
+ 882            op_list.append(("graceedit", annNote1, annNote2, 1))
+ 883        if annNote1.graceSlash != annNote2.graceSlash:
+ 884            cost += 1
+ 885            op_list.append(("graceslashedit", annNote1, annNote2, 1))
+ 886        # add for the beamings
+ 887        if annNote1.beamings != annNote2.beamings:
+ 888            beam_op_list, beam_cost = Comparison._beamtuplet_leveinsthein_diff(
+ 889                annNote1.beamings, annNote2.beamings, annNote1, annNote2, "beam"
+ 890            )
+ 891            op_list.extend(beam_op_list)
+ 892            cost += beam_cost
+ 893        # add for the tuplets
+ 894        if annNote1.tuplets != annNote2.tuplets:
+ 895            tuplet_op_list, tuplet_cost = Comparison._beamtuplet_leveinsthein_diff(
+ 896                annNote1.tuplets, annNote2.tuplets, annNote1, annNote2, "tuplet"
+ 897            )
+ 898            op_list.extend(tuplet_op_list)
+ 899            cost += tuplet_cost
+ 900        # add for the articulations
+ 901        if annNote1.articulations != annNote2.articulations:
+ 902            artic_op_list, artic_cost = Comparison._generic_leveinsthein_diff(
+ 903                annNote1.articulations,
+ 904                annNote2.articulations,
+ 905                annNote1,
+ 906                annNote2,
+ 907                "articulation",
+ 908            )
+ 909            op_list.extend(artic_op_list)
+ 910            cost += artic_cost
+ 911        # add for the expressions
+ 912        if annNote1.expressions != annNote2.expressions:
+ 913            expr_op_list, expr_cost = Comparison._generic_leveinsthein_diff(
+ 914                annNote1.expressions,
+ 915                annNote2.expressions,
+ 916                annNote1,
+ 917                annNote2,
+ 918                "expression",
+ 919            )
+ 920            op_list.extend(expr_op_list)
+ 921            cost += expr_cost
+ 922        # add for the lyrics
+ 923        if annNote1.lyrics != annNote2.lyrics:
+ 924            lyr_op_list, lyr_cost = Comparison._generic_leveinsthein_diff(
+ 925                annNote1.lyrics,
+ 926                annNote2.lyrics,
+ 927                annNote1,
+ 928                annNote2,
+ 929                "lyric",
+ 930            )
+ 931            op_list.extend(lyr_op_list)
+ 932            cost += lyr_cost
+ 933
+ 934        # add for noteshape
+ 935        if annNote1.noteshape != annNote2.noteshape:
+ 936            cost += 1
+ 937            op_list.append(("editnoteshape", annNote1, annNote2, 1))
+ 938        # add for noteheadFill
+ 939        if annNote1.noteheadFill != annNote2.noteheadFill:
+ 940            cost += 1
+ 941            op_list.append(("editnoteheadfill", annNote1, annNote2, 1))
+ 942        # add for noteheadParenthesis
+ 943        if annNote1.noteheadParenthesis != annNote2.noteheadParenthesis:
+ 944            cost += 1
+ 945            op_list.append(("editnoteheadparenthesis", annNote1, annNote2, 1))
+ 946        # add for stemDirection
+ 947        if annNote1.stemDirection != annNote2.stemDirection:
+ 948            cost += 1
+ 949            op_list.append(("editstemdirection", annNote1, annNote2, 1))
+ 950        # add for the styledict
+ 951        if annNote1.styledict != annNote2.styledict:
+ 952            cost += 1
+ 953            op_list.append(("editstyle", annNote1, annNote2, 1))
+ 954
+ 955        return op_list, cost
+ 956
+ 957    @staticmethod
+ 958    @_memoize_beamtuplet_lev_diff
+ 959    def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which):
+ 960        """
+ 961        Compute the leveinsthein distance between two sequences of beaming or tuples.
+ 962        Arguments:
+ 963            original {list} -- list of strings (start, stop, continue or partial)
+ 964            compare_to {list} -- list of strings (start, stop, continue or partial)
+ 965            note1 {AnnNote} -- the note for referencing in the score
+ 966            note2 {AnnNote} -- the note for referencing in the score
+ 967            which -- a string: "beam" or "tuplet" depending what we are comparing
+ 968        """
+ 969        if which not in ("beam", "tuplet"):
+ 970            raise ValueError("Argument 'which' must be either 'beam' or 'tuplet'")
+ 971
+ 972        if len(original) == 0 and len(compare_to) == 0:
+ 973            return [], 0
+ 974
+ 975        if len(original) == 0:
+ 976            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
+ 977                original, compare_to[1:], note1, note2, which
+ 978            )
+ 979            op_list.append(("ins" + which, note1, note2, 1))
+ 980            cost += 1
+ 981            return op_list, cost
+ 982
+ 983        if len(compare_to) == 0:
+ 984            op_list, cost = Comparison._beamtuplet_leveinsthein_diff(
+ 985                original[1:], compare_to, note1, note2, which
+ 986            )
+ 987            op_list.append(("del" + which, note1, note2, 1))
+ 988            cost += 1
+ 989            return op_list, cost
+ 990
+ 991        # compute the cost and the op_list for the many possibilities of recursion
+ 992        cost = {}
+ 993        op_list = {}
+ 994        # del-pitch
+ 995        op_list["del" + which], cost["del" + which] = Comparison._beamtuplet_leveinsthein_diff(
+ 996            original[1:], compare_to, note1, note2, which
+ 997        )
+ 998        cost["del" + which] += 1
+ 999        op_list["del" + which].append(("del" + which, note1, note2, 1))
+1000        # ins-pitch
+1001        op_list["ins" + which], cost["ins" + which] = Comparison._beamtuplet_leveinsthein_diff(
+1002            original, compare_to[1:], note1, note2, which
+1003        )
+1004        cost["ins" + which] += 1
+1005        op_list["ins" + which].append(("ins" + which, note1, note2, 1))
+1006        # edit-pitch
+1007        op_list["edit" + which], cost["edit" + which] = Comparison._beamtuplet_leveinsthein_diff(
+1008            original[1:], compare_to[1:], note1, note2, which
+1009        )
+1010        if original[0] == compare_to[0]:
+1011            beam_diff_op_list = []
+1012            beam_diff_cost = 0
+1013        else:
+1014            beam_diff_op_list, beam_diff_cost = [("edit" + which, note1, note2, 1)], 1
+1015        cost["edit" + which] += beam_diff_cost
+1016        op_list["edit" + which].extend(beam_diff_op_list)
+1017        # compute the minimum of the possibilities
+1018        min_key = min(cost, key=cost.get)
+1019        out = op_list[min_key], cost[min_key]
+1020        return out
+1021
+1022    @staticmethod
+1023    @_memoize_generic_lev_diff
+1024    def _generic_leveinsthein_diff(original, compare_to, note1, note2, which):
+1025        """
+1026        Compute the leveinsthein distance between two generic sequences of symbols
+1027        (e.g., articulations).
+1028        Arguments:
+1029            original {list} -- list of strings
+1030            compare_to {list} -- list of strings
+1031            note1 {AnnNote} -- the note for referencing in the score
+1032            note2 {AnnNote} -- the note for referencing in the score
+1033            which -- a string: e.g. "articulation" depending what we are comparing
+1034        """
+1035        if len(original) == 0 and len(compare_to) == 0:
+1036            return [], 0
+1037
+1038        if len(original) == 0:
+1039            op_list, cost = Comparison._generic_leveinsthein_diff(
+1040                original, compare_to[1:], note1, note2, which
+1041            )
+1042            op_list.append(("ins" + which, note1, note2, 1))
+1043            cost += 1
+1044            return op_list, cost
+1045
+1046        if len(compare_to) == 0:
+1047            op_list, cost = Comparison._generic_leveinsthein_diff(
+1048                original[1:], compare_to, note1, note2, which
+1049            )
+1050            op_list.append(("del" + which, note1, note2, 1))
+1051            cost += 1
+1052            return op_list, cost
+1053
+1054        # compute the cost and the op_list for the many possibilities of recursion
+1055        cost = {}
+1056        op_list = {}
+1057        # del-pitch
+1058        op_list["del" + which], cost["del" + which] = Comparison._generic_leveinsthein_diff(
+1059            original[1:], compare_to, note1, note2, which
+1060        )
+1061        cost["del" + which] += 1
+1062        op_list["del" + which].append(("del" + which, note1, note2, 1))
+1063        # ins-pitch
+1064        op_list["ins" + which], cost["ins" + which] = Comparison._generic_leveinsthein_diff(
+1065            original, compare_to[1:], note1, note2, which
+1066        )
+1067        cost["ins" + which] += 1
+1068        op_list["ins" + which].append(("ins" + which, note1, note2, 1))
+1069        # edit-pitch
+1070        op_list["edit" + which], cost["edit" + which] = Comparison._generic_leveinsthein_diff(
+1071            original[1:], compare_to[1:], note1, note2, which
+1072        )
+1073        if original[0] == compare_to[0]:  # to avoid perform the pitch_diff
+1074            generic_diff_op_list = []
+1075            generic_diff_cost = 0
+1076        else:
+1077            generic_diff_op_list, generic_diff_cost = (
+1078                [("edit" + which, note1, note2, 1)],
+1079                1,
+1080            )
+1081        cost["edit" + which] += generic_diff_cost
+1082        op_list["edit" + which].extend(generic_diff_op_list)
+1083        # compute the minimum of the possibilities
+1084        min_key = min(cost, key=cost.get)
+1085        out = op_list[min_key], cost[min_key]
+1086        return out
+1087
+1088    @staticmethod
+1089    def _voices_coupling_recursive(original: list[AnnVoice], compare_to: list[AnnVoice]):
+1090        """
+1091        Compare all the possible voices permutations, considering also deletion and
+1092        insertion (equation on office lens).
+1093        original [list] -- a list of Voice
+1094        compare_to [list] -- a list of Voice
+1095        """
+1096        if len(original) == 0 and len(compare_to) == 0:  # stop the recursion
+1097            return [], 0
+1098
+1099        if len(original) == 0:
+1100            # insertion
+1101            op_list, cost = Comparison._voices_coupling_recursive(original, compare_to[1:])
+1102            # add for the inserted voice
+1103            op_list.append(("voiceins", None, compare_to[0], compare_to[0].notation_size()))
+1104            cost += compare_to[0].notation_size()
+1105            return op_list, cost
+1106
+1107        if len(compare_to) == 0:
+1108            # deletion
+1109            op_list, cost = Comparison._voices_coupling_recursive(original[1:], compare_to)
+1110            # add for the deleted voice
+1111            op_list.append(("voicedel", original[0], None, original[0].notation_size()))
+1112            cost += original[0].notation_size()
+1113            return op_list, cost
+1114
+1115        cost = {}
+1116        op_list = {}
+1117        # deletion
+1118        op_list["voicedel"], cost["voicedel"] = Comparison._voices_coupling_recursive(
+1119            original[1:], compare_to
+1120        )
+1121        op_list["voicedel"].append(
+1122            ("voicedel", original[0], None, original[0].notation_size())
+1123        )
+1124        cost["voicedel"] += original[0].notation_size()
+1125        for i, c in enumerate(compare_to):
+1126            # substitution
+1127            (
+1128                op_list["voicesub" + str(i)],
+1129                cost["voicesub" + str(i)],
+1130            ) = Comparison._voices_coupling_recursive(
+1131                original[1:], compare_to[:i] + compare_to[i + 1:]
+1132            )
+1133            if (
+1134                compare_to[0] != original[0]
+1135            ):  # add the cost of the sub and the operations from inside_bar_diff
+1136                op_list_inside_bar, cost_inside_bar = Comparison._inside_bars_diff_lin(
+1137                    original[0].annot_notes, c.annot_notes
+1138                )  # compute the distance from original[0] and compare_to[i]
+1139                op_list["voicesub" + str(i)].extend(op_list_inside_bar)
+1140                cost["voicesub" + str(i)] += cost_inside_bar
+1141        # compute the minimum of the possibilities
+1142        min_key = min(cost, key=cost.get)
+1143        out = op_list[min_key], cost[min_key]
+1144        return out
+1145
+1146    @staticmethod
+1147    def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> tuple[list[tuple], int]:
+1148        '''
+1149        Compare two annotated scores, computing an operations list and the cost of applying those
+1150        operations to the first score to generate the second score.
+1151
+1152        Args:
+1153            score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare.
+1154            score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare.
+1155
+1156        Returns:
+1157            list[tuple], int: The operations list and the cost
+1158        '''
+1159        # for now just working with equal number of parts that are already pairs
+1160        # TODO : extend to different number of parts
+1161        assert score1.n_of_parts == score2.n_of_parts
+1162        n_of_parts = score1.n_of_parts
+1163        op_list_total, cost_total = [], 0
+1164        # iterate for all parts in the score
+1165        for p_number in range(n_of_parts):
+1166            # compute non-common-subseq
+1167            ncs = Comparison._non_common_subsequences_of_measures(
+1168                score1.part_list[p_number].bar_list,
+1169                score2.part_list[p_number].bar_list,
+1170            )
+1171            # compute blockdiff
+1172            for subseq in ncs:
+1173                op_list_block, cost_block = Comparison._block_diff_lin(
+1174                    subseq["original"], subseq["compare_to"]
+1175                )
+1176                op_list_total.extend(op_list_block)
+1177                cost_total += cost_block
+1178
+1179        # compare the staff groups
+1180        groups_op_list, groups_cost = Comparison._staff_groups_diff_lin(
+1181            score1.staff_group_list, score2.staff_group_list
+1182        )
+1183        op_list_total.extend(groups_op_list)
+1184        cost_total += groups_cost
+1185
+1186        # compare the metadata items
+1187        mditems_op_list, mditems_cost = Comparison._metadata_items_diff_lin(
+1188            score1.metadata_items_list, score2.metadata_items_list
+1189        )
+1190        op_list_total.extend(mditems_op_list)
+1191        cost_total += mditems_cost
+1192
+1193        return op_list_total, cost_total
+
+ - -
-
#   + +
+
@staticmethod
-
@staticmethod
+ def + annotated_scores_diff( score1: musicdiff.annotation.AnnScore, score2: musicdiff.annotation.AnnScore) -> tuple[list[tuple], int]: - def - annotated_scores_diff( - score1: musicdiff.annotation.AnnScore, - score2: musicdiff.annotation.AnnScore -) -> Tuple[List[Tuple], int]: -
+ -
- View Source -
    @staticmethod
-    def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tuple], int]:
-        '''
-        Compare two annotated scores, computing an operations list and the cost of applying those
-        operations to the first score to generate the second score.
-
-        Args:
-            score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare.
-            score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare.
-
-        Returns:
-            List[Tuple], int: The operations list and the cost
-        '''
-        # for now just working with equal number of parts that are already pairs
-        # TODO : extend to different number of parts
-        assert score1.n_of_parts == score2.n_of_parts
-        n_of_parts = score1.n_of_parts
-        op_list_total, cost_total = [], 0
-        # iterate for all parts in the score
-        for p_number in range(n_of_parts):
-            # compute non-common-subseq
-            ncs = Comparison._non_common_subsequences_of_measures(
-                score1.part_list[p_number].bar_list,
-                score2.part_list[p_number].bar_list,
-            )
-            # compute blockdiff
-            for subseq in ncs:
-                op_list_block, cost_block = Comparison._block_diff_lin(
-                    subseq["original"], subseq["compare_to"]
-                )
-                op_list_total.extend(op_list_block)
-                cost_total += cost_block
-
-        return op_list_total, cost_total
-
+
+ +
1146    @staticmethod
+1147    def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> tuple[list[tuple], int]:
+1148        '''
+1149        Compare two annotated scores, computing an operations list and the cost of applying those
+1150        operations to the first score to generate the second score.
+1151
+1152        Args:
+1153            score1 (`musicdiff.annotation.AnnScore`): The first annotated score to compare.
+1154            score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare.
+1155
+1156        Returns:
+1157            list[tuple], int: The operations list and the cost
+1158        '''
+1159        # for now just working with equal number of parts that are already pairs
+1160        # TODO : extend to different number of parts
+1161        assert score1.n_of_parts == score2.n_of_parts
+1162        n_of_parts = score1.n_of_parts
+1163        op_list_total, cost_total = [], 0
+1164        # iterate for all parts in the score
+1165        for p_number in range(n_of_parts):
+1166            # compute non-common-subseq
+1167            ncs = Comparison._non_common_subsequences_of_measures(
+1168                score1.part_list[p_number].bar_list,
+1169                score2.part_list[p_number].bar_list,
+1170            )
+1171            # compute blockdiff
+1172            for subseq in ncs:
+1173                op_list_block, cost_block = Comparison._block_diff_lin(
+1174                    subseq["original"], subseq["compare_to"]
+1175                )
+1176                op_list_total.extend(op_list_block)
+1177                cost_total += cost_block
+1178
+1179        # compare the staff groups
+1180        groups_op_list, groups_cost = Comparison._staff_groups_diff_lin(
+1181            score1.staff_group_list, score2.staff_group_list
+1182        )
+1183        op_list_total.extend(groups_op_list)
+1184        cost_total += groups_cost
+1185
+1186        # compare the metadata items
+1187        mditems_op_list, mditems_cost = Comparison._metadata_items_diff_lin(
+1188            score1.metadata_items_list, score2.metadata_items_list
+1189        )
+1190        op_list_total.extend(mditems_op_list)
+1191        cost_total += mditems_cost
+1192
+1193        return op_list_total, cost_total
+
-

Compare two annotated scores, computing an operations list and the cost of applying those operations to the first score to generate the second score.

-
Args
+
Arguments:
-
Returns
+
Returns:
-

List[Tuple], int: The operations list and the cost

+

list[tuple], int: The operations list and the cost

@@ -1925,9 +2523,13 @@
Returns
} let heading; - switch (result.doc.type) { + switch (result.doc.kind) { case "function": - heading = `${doc.funcdef} ${doc.fullname}${doc.signature}:`; + if (doc.fullname.endsWith(".__init__")) { + heading = `${doc.fullname.replace(/\.__init__$/, "")}${doc.signature}`; + } else { + heading = `${doc.funcdef} ${doc.fullname}${doc.signature}`; + } break; case "class": heading = `class ${doc.fullname}`; @@ -1940,7 +2542,7 @@
Returns
if (doc.annotation) heading += `${doc.annotation}`; if (doc.default_value) - heading += `${doc.default_value}`; + heading += ` = ${doc.default_value}`; break; default: heading = `${doc.fullname}`; @@ -1948,7 +2550,7 @@
Returns
} html += `
- ${heading} + ${heading}
${doc.doc}
`; diff --git a/docs/musicdiff/visualization.html b/docs/musicdiff/visualization.html index aaddfcb..9e7b124 100644 --- a/docs/musicdiff/visualization.html +++ b/docs/musicdiff/visualization.html @@ -3,39 +3,36 @@ - + musicdiff.visualization API documentation - - - - - - -
-
+

musicdiff.visualization

-
- View Source -
# ------------------------------------------------------------------------------
-# Purpose:       visualization is a diff visualization package for use by musicdiff.
-#                musicdiff is a package for comparing music scores using music21.
-#
-# Authors:       Greg Chapman <gregc@mac.com>
-#                musicdiff is derived from:
-#                   https://github.com/fosfrancesco/music-score-diff.git
-#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
-#
-# Copyright:     (c) 2022 Francesco Foscarin, Greg Chapman
-# License:       MIT, see LICENSE
-# ------------------------------------------------------------------------------
-
-__docformat__ = "google"
-
-from typing import List, Tuple, Union
-from pathlib import Path
-import sys
-
-import music21 as m21
-
-from musicdiff.annotation import AnnMeasure, AnnVoice, AnnNote, AnnExtra
-
-
-class Visualization:
-    # These can be set by the client to different colors
-    INSERTED_COLOR = "red"
-    """
-    `INSERTED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-    DELETED_COLOR = "red"
-    """
-    `DELETED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-    CHANGED_COLOR = "red"
-    """
-    `CHANGED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-
-    @staticmethod
-    def mark_diffs(
-        score1: m21.stream.Score, score2: m21.stream.Score, operations: List[Tuple]
-    ):
-        """
-        Mark up two music21 scores with the differences described by an operations
-        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
-
-        Args:
-            score1 (music21.stream.Score): The first score to mark up
-            score2 (music21.stream.Score): The second score to mark up
-            operations (List[Tuple]): The operations list that describes the difference
-                between the two scores
-        """
-        for op in operations:
-            # bar
-            if op[0] == "insbar":
-                assert isinstance(op[2], AnnMeasure)
-                # color all the notes in the inserted score2 measure using Visualization.INSERTED_COLOR
-                measure2 = score2.recurse().getElementById(op[2].measure)
-                textExp = m21.expressions.TextExpression("inserted measure")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                measure2.insert(0, textExp)
-                measure2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in measure2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "delbar":
-                assert isinstance(op[1], AnnMeasure)
-                # color all the notes in the deleted score1 measure using Visualization.DELETED_COLOR
-                measure1 = score1.recurse().getElementById(op[1].measure)
-                textExp = m21.expressions.TextExpression("deleted measure")
-                textExp.style.color = Visualization.DELETED_COLOR
-                measure1.insert(0, textExp)
-                measure1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in measure1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # voices
-            elif op[0] == "voiceins":
-                assert isinstance(op[2], AnnVoice)
-                # color all the notes in the inserted score2 voice using Visualization.INSERTED_COLOR
-                voice2 = score2.recurse().getElementById(op[2].voice)
-                textExp = m21.expressions.TextExpression("inserted voice")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                voice2.insert(0, textExp)
-
-                voice2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in voice2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "voicedel":
-                assert isinstance(op[1], AnnVoice)
-                # color all the notes in the deleted score1 voice using Visualization.DELETED_COLOR
-                voice1 = score1.recurse().getElementById(op[1].voice)
-                textExp = m21.expressions.TextExpression("deleted voice")
-                textExp.style.color = Visualization.DELETED_COLOR
-                voice1.insert(0, textExp)
-
-                voice1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in voice1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # extra
-            elif op[0] == "extrains":
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.INSERTED_COLOR, and add a textExpression
-                # describing the insertion.
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if isinstance(extra2, m21.spanner.Spanner):
-                    insertionPoint = extra2.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra2.activeSite.insert(extra2.offset, textExp)
-
-            elif op[0] == "extradel":
-                assert isinstance(op[1], AnnExtra)
-                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
-                # describing the deletion.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint = extra1.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp)
-
-            elif op[0] == "extrasub":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                if extra1.classes[0] != extra2.classes[0]:
-                    textExp1 = m21.expressions.TextExpression(
-                                    f"changed to {extra2.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(
-                                    f"changed from {extra1.classes[0]}")
-                else:
-                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extracontentedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extraoffsetedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extradurationedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extrastyleedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                sd1 = op[1].styledict
-                sd2 = op[2].styledict
-                changedStr: str = ""
-                for k1, v1 in sd1.items():
-                    if k1 not in sd2 or sd2[k1] != v1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k1
-
-                # one last thing: check for keys in sd2 that aren't in sd1
-                for k2 in sd2:
-                    if k2 not in sd1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k2
-
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            # note
-            elif op[0] == "noteins":
-                assert isinstance(op[2], AnnNote)
-                # color the inserted score2 general note (note, chord, or rest) using Visualization.INSERTED_COLOR
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression(f"inserted {note2.classes[0]}")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "notedel":
-                assert isinstance(op[1], AnnNote)
-                # color the deleted score1 general note (note, chord, or rest) using Visualization.DELETED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.DELETED_COLOR
-                textExp = m21.expressions.TextExpression(f"deleted {note2.classes[0]}")
-                textExp.style.color = Visualization.DELETED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-            # pitch
-            elif op[0] == "pitchnameedit":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the changed note (in both scores) using Visualization.CHANGED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed pitch")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed pitch")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "inspitch":
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the inserted note in score2 using Visualization.INSERTED_COLOR
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                note2.style.color = Visualization.INSERTED_COLOR
-                if "Rest" in note2.classes:
-                    textExp = m21.expressions.TextExpression("inserted rest")
-                else:
-                    textExp = m21.expressions.TextExpression("inserted note")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "delpitch":
-                assert isinstance(op[1], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the deleted note in score1 using Visualization.DELETED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                note1.style.color = Visualization.DELETED_COLOR
-                if "Rest" in note1.classes:
-                    textExp = m21.expressions.TextExpression("deleted rest")
-                else:
-                    textExp = m21.expressions.TextExpression("deleted note")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-            elif op[0] == "headedit":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # beam
-            elif op[0] == "insbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the modified note in both scores using Visualization.INSERTED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.INSERTED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.INSERTED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("increased flags")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.INSERTED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.INSERTED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("increased flags")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "delbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the modified note in both scores using Visualization.DELETED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.DELETED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.DELETED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("decreased flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.DELETED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.DELETED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("decreased flags")
-                textExp.style.color = Visualization.DELETED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.CHANGED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("changed flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.CHANGED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("changed flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteshape":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note shape")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note shape")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteheadfill":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head fill")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head fill")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteheadparenthesis":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head paren")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head paren")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editstemdirection":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed stem direction")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed stem direction")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editstyle":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                sd1 = op[1].styledict
-                sd2 = op[2].styledict
-                changedStr: str = ""
-                for k1, v1 in sd1.items():
-                    if k1 not in sd2 or sd2[k1] != v1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k1
-
-                # one last thing: check for keys in sd2 that aren't in sd1
-                for k2 in sd2:
-                    if k2 not in sd1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k2
-
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # accident
-            elif op[0] == "accidentins":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the modified note in both scores using Visualization.INSERTED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color only the indexed note's accidental in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                if note1.pitch.accidental:
-                    note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR
-                note1.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression("inserted accidental")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color only the indexed note's accidental in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                if note2.pitch.accidental:
-                    note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR
-                note2.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression("inserted accidental")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "accidentdel":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the modified note in both scores using Visualization.DELETED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color only the indexed note's accidental in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                if note1.pitch.accidental:
-                    note1.pitch.accidental.style.color = Visualization.DELETED_COLOR
-                note1.style.color = Visualization.DELETED_COLOR
-                textExp = m21.expressions.TextExpression("deleted accidental")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color only the indexed note's accidental in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                if note2.pitch.accidental:
-                    note2.pitch.accidental.style.color = Visualization.DELETED_COLOR
-                note2.style.color = Visualization.DELETED_COLOR
-                textExp = m21.expressions.TextExpression("deleted accidental")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "accidentedit":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the changed accidental (in both scores) using Visualization.CHANGED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                if note1.pitch.accidental:
-                    note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed accidental")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                if note2.pitch.accidental:
-                    note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed accidental")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "dotins":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # In music21, the dots are not separately colorable from the note,
-                # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("inserted dot")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("inserted dot")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "dotdel":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # In music21, the dots are not separately colorable from the note,
-                # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("deleted dot")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("deleted dot")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # tuplets
-            elif op[0] == "instuplet":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("inserted tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("inserted tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "deltuplet":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("deleted tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("deleted tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "edittuplet":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed tuplet")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # ties
-            elif op[0] == "tieins":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # Color the modified note here in both scores, using Visualization.INSERTED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                note1.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression("inserted tie")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
+                        
+
+                        
+
+                        
   1# ------------------------------------------------------------------------------
+   2# Purpose:       visualization is a diff visualization package for use by musicdiff.
+   3#                musicdiff is a package for comparing music scores using music21.
+   4#
+   5# Authors:       Greg Chapman <gregc@mac.com>
+   6#                musicdiff is derived from:
+   7#                   https://github.com/fosfrancesco/music-score-diff.git
+   8#                   by Francesco Foscarin <foscarin.francesco@gmail.com>
+   9#
+  10# Copyright:     (c) 2022, 2023 Francesco Foscarin, Greg Chapman
+  11# License:       MIT, see LICENSE
+  12# ------------------------------------------------------------------------------
+  13
+  14__docformat__ = "google"
+  15
+  16from pathlib import Path
+  17import sys
+  18import typing as t
+  19
+  20import music21 as m21
+  21
+  22from musicdiff.annotation import AnnMeasure, AnnVoice, AnnNote, AnnExtra, AnnStaffGroup
+  23
+  24
+  25class Visualization:
+  26    # These can be set by the client to different colors
+  27    INSERTED_COLOR = "red"
+  28    """
+  29    `INSERTED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  30    """
+  31    DELETED_COLOR = "red"
+  32    """
+  33    `DELETED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  34    """
+  35    CHANGED_COLOR = "red"
+  36    """
+  37    `CHANGED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  38    """
+  39
+  40    @staticmethod
+  41    def mark_diffs(
+  42        score1: m21.stream.Score,
+  43        score2: m21.stream.Score,
+  44        operations: list[tuple]
+  45    ) -> None:
+  46        """
+  47        Mark up two music21 scores with the differences described by an operations
+  48        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
+  49
+  50        Args:
+  51            score1 (music21.stream.Score): The first score to mark up
+  52            score2 (music21.stream.Score): The second score to mark up
+  53            operations (list[tuple]): The operations list that describes the difference
+  54                between the two scores
+  55        """
+  56        changedStr: str
+  57        for op in operations:
+  58            # bar
+  59            if op[0] == "insbar":
+  60                assert isinstance(op[2], AnnMeasure)
+  61                # color all the notes in the inserted score2 measure
+  62                # using Visualization.INSERTED_COLOR
+  63                measure2 = score2.recurse().getElementById(op[2].measure)  # type: ignore
+  64                if t.TYPE_CHECKING:
+  65                    assert measure2 is not None
+  66                textExp = m21.expressions.TextExpression("inserted measure")
+  67                textExp.style.color = Visualization.INSERTED_COLOR
+  68                measure2.insert(0, textExp)
+  69                measure2.style.color = (
+  70                    Visualization.INSERTED_COLOR
+  71                )  # this apparently does nothing
+  72                for el in measure2.recurse().notesAndRests:
+  73                    el.style.color = Visualization.INSERTED_COLOR
+  74
+  75            elif op[0] == "delbar":
+  76                assert isinstance(op[1], AnnMeasure)
+  77                # color all the notes in the deleted score1 measure
+  78                # using Visualization.DELETED_COLOR
+  79                measure1 = score1.recurse().getElementById(op[1].measure)  # type: ignore
+  80                if t.TYPE_CHECKING:
+  81                    assert measure1 is not None
+  82                textExp = m21.expressions.TextExpression("deleted measure")
+  83                textExp.style.color = Visualization.DELETED_COLOR
+  84                measure1.insert(0, textExp)
+  85                measure1.style.color = (
+  86                    Visualization.DELETED_COLOR
+  87                )  # this apparently does nothing
+  88                for el in measure1.recurse().notesAndRests:
+  89                    el.style.color = Visualization.DELETED_COLOR
+  90
+  91            # voices
+  92            elif op[0] == "voiceins":
+  93                assert isinstance(op[2], AnnVoice)
+  94                # color all the notes in the inserted score2 voice
+  95                # using Visualization.INSERTED_COLOR
+  96                voice2 = score2.recurse().getElementById(op[2].voice)  # type: ignore
+  97                if t.TYPE_CHECKING:
+  98                    assert voice2 is not None
+  99                textExp = m21.expressions.TextExpression("inserted voice")
+ 100                textExp.style.color = Visualization.INSERTED_COLOR
+ 101                voice2.insert(0, textExp)
+ 102
+ 103                voice2.style.color = (
+ 104                    Visualization.INSERTED_COLOR
+ 105                )  # this apparently does nothing
+ 106                for el in voice2.recurse().notesAndRests:
+ 107                    el.style.color = Visualization.INSERTED_COLOR
+ 108
+ 109            elif op[0] == "voicedel":
+ 110                assert isinstance(op[1], AnnVoice)
+ 111                # color all the notes in the deleted score1 voice
+ 112                # using Visualization.DELETED_COLOR
+ 113                voice1 = score1.recurse().getElementById(op[1].voice)  # type: ignore
+ 114                if t.TYPE_CHECKING:
+ 115                    assert voice1 is not None
+ 116                textExp = m21.expressions.TextExpression("deleted voice")
+ 117                textExp.style.color = Visualization.DELETED_COLOR
+ 118                voice1.insert(0, textExp)
+ 119
+ 120                voice1.style.color = (
+ 121                    Visualization.DELETED_COLOR
+ 122                )  # this apparently does nothing
+ 123                for el in voice1.recurse().notesAndRests:
+ 124                    el.style.color = Visualization.DELETED_COLOR
+ 125
+ 126            # extra
+ 127            elif op[0] == "extrains":
+ 128                assert isinstance(op[2], AnnExtra)
+ 129                # color the extra using Visualization.INSERTED_COLOR,
+ 130                # and add a textExpression describing the insertion.
+ 131                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 132                if t.TYPE_CHECKING:
+ 133                    assert extra2 is not None
+ 134                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
+ 135                textExp.style.color = Visualization.INSERTED_COLOR
+ 136                if isinstance(extra2, m21.spanner.Spanner):
+ 137                    insertionPoint = extra2.getFirst()
+ 138                    if isinstance(insertionPoint, m21.stream.Measure):
+ 139                        # insertionPoint is a measure, put the textExp at offset 0
+ 140                        # inside the measure
+ 141                        insertionPoint.insert(0, textExp)
+ 142                    else:
+ 143                        # insertionPoint is something else, put the textExp right next to it.
+ 144                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 145                else:
+ 146                    # extra2 is not a spanner, put the textExp right next to it
+ 147                    extra2.activeSite.insert(extra2.offset, textExp)
+ 148
+ 149            elif op[0] == "extradel":
+ 150                assert isinstance(op[1], AnnExtra)
+ 151                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
+ 152                # describing the deletion.
+ 153                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 154                if t.TYPE_CHECKING:
+ 155                    assert extra1 is not None
+ 156                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
+ 157                textExp.style.color = Visualization.DELETED_COLOR
+ 158                if isinstance(extra1, m21.spanner.Spanner):
+ 159                    insertionPoint = extra1.getFirst()
+ 160                    if isinstance(insertionPoint, m21.stream.Measure):
+ 161                        # insertionPoint is a measure, put the textExp at offset 0
+ 162                        # inside the measure
+ 163                        insertionPoint.insert(0, textExp)
+ 164                    else:
+ 165                        # insertionPoint is something else, put the textExp right next to it.
+ 166                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 167                else:
+ 168                    # extra1 is not a spanner, put the textExp right next to it
+ 169                    extra1.activeSite.insert(extra1.offset, textExp)
+ 170
+ 171            elif op[0] == "extrasub":
+ 172                assert isinstance(op[1], AnnExtra)
+ 173                assert isinstance(op[2], AnnExtra)
+ 174                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 175                # describing the change.
+ 176                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 177                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 178                if t.TYPE_CHECKING:
+ 179                    assert extra1 is not None
+ 180                    assert extra2 is not None
+ 181                if extra1.classes[0] != extra2.classes[0]:
+ 182                    textExp1 = m21.expressions.TextExpression(
+ 183                        f"changed to {extra2.classes[0]}"
+ 184                    )
+ 185                    textExp2 = m21.expressions.TextExpression(
+ 186                        f"changed from {extra1.classes[0]}"
+ 187                    )
+ 188                else:
+ 189                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 190                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 191                textExp1.style.color = Visualization.CHANGED_COLOR
+ 192                textExp2.style.color = Visualization.CHANGED_COLOR
+ 193                if isinstance(extra1, m21.spanner.Spanner):
+ 194                    insertionPoint1 = extra1.getFirst()
+ 195                    insertionPoint2 = extra2.getFirst()
+ 196                    if isinstance(insertionPoint1, m21.stream.Measure):
+ 197                        # insertionPoint1 is a measure, put the textExp at offset 0
+ 198                        # inside the measure
+ 199                        insertionPoint1.insert(0, textExp)
+ 200                    else:
+ 201                        # insertionPoint1 is something else, put the textExp right next to it.
+ 202                        insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp)
+ 203                    if isinstance(insertionPoint2, m21.stream.Measure):
+ 204                        # insertionPoint2 is a measure, put the textExp at offset 0
+ 205                        # inside the measure
+ 206                        insertionPoint2.insert(0, textExp)
+ 207                    else:
+ 208                        # insertionPoint2 is something else, put the textExp right next to it.
+ 209                        insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp)
+ 210                else:
+ 211                    # extra is not a spanner, put the textExp right next to it
+ 212                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 213                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 214
+ 215            elif op[0] == "extracontentedit":
+ 216                assert isinstance(op[1], AnnExtra)
+ 217                assert isinstance(op[2], AnnExtra)
+ 218                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 219                # describing the change.
+ 220                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 221                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 222                if t.TYPE_CHECKING:
+ 223                    assert extra1 is not None
+ 224                    assert extra2 is not None
+ 225                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 226                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 227                textExp1.style.color = Visualization.CHANGED_COLOR
+ 228                textExp2.style.color = Visualization.CHANGED_COLOR
+ 229                if isinstance(extra1, m21.spanner.Spanner):
+ 230                    insertionPoint1 = extra1.getFirst()
+ 231                    insertionPoint2 = extra2.getFirst()
+ 232                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 233                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 234                else:
+ 235                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 236                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 237
+ 238            elif op[0] == "extraoffsetedit":
+ 239                assert isinstance(op[1], AnnExtra)
+ 240                assert isinstance(op[2], AnnExtra)
+ 241                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 242                # describing the change.
+ 243                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 244                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 245                if t.TYPE_CHECKING:
+ 246                    assert extra1 is not None
+ 247                    assert extra2 is not None
+ 248                textExp1 = m21.expressions.TextExpression(
+ 249                    f"changed {extra1.classes[0]} offset")
+ 250                textExp2 = m21.expressions.TextExpression(
+ 251                    f"changed {extra1.classes[0]} offset")
+ 252                textExp1.style.color = Visualization.CHANGED_COLOR
+ 253                textExp2.style.color = Visualization.CHANGED_COLOR
+ 254                if isinstance(extra1, m21.spanner.Spanner):
+ 255                    insertionPoint1 = extra1.getFirst()
+ 256                    insertionPoint2 = extra2.getFirst()
+ 257                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 258                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 259                else:
+ 260                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 261                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 262
+ 263            elif op[0] == "extradurationedit":
+ 264                assert isinstance(op[1], AnnExtra)
+ 265                assert isinstance(op[2], AnnExtra)
+ 266                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 267                # describing the change.
+ 268                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 269                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 270                if t.TYPE_CHECKING:
+ 271                    assert extra1 is not None
+ 272                    assert extra2 is not None
+ 273                textExp1 = m21.expressions.TextExpression(
+ 274                    f"changed {extra1.classes[0]} duration")
+ 275                textExp2 = m21.expressions.TextExpression(
+ 276                    f"changed {extra1.classes[0]} duration")
+ 277                textExp1.style.color = Visualization.CHANGED_COLOR
+ 278                textExp2.style.color = Visualization.CHANGED_COLOR
+ 279                if isinstance(extra1, m21.spanner.Spanner):
+ 280                    insertionPoint1 = extra1.getFirst()
+ 281                    insertionPoint2 = extra2.getFirst()
+ 282                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 283                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 284                else:
+ 285                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 286                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 287
+ 288            elif op[0] == "extrastyleedit":
+ 289                assert isinstance(op[1], AnnExtra)
+ 290                assert isinstance(op[2], AnnExtra)
+ 291                sd1 = op[1].styledict
+ 292                sd2 = op[2].styledict
+ 293                changedStr = ""
+ 294                for k1, v1 in sd1.items():
+ 295                    if k1 not in sd2 or sd2[k1] != v1:
+ 296                        if changedStr:
+ 297                            changedStr += ","
+ 298                        changedStr += k1
+ 299
+ 300                # one last thing: check for keys in sd2 that aren't in sd1
+ 301                for k2 in sd2:
+ 302                    if k2 not in sd1:
+ 303                        if changedStr:
+ 304                            changedStr += ","
+ 305                        changedStr += k2
+ 306
+ 307                # color the extra using Visualization.CHANGED_COLOR,
+ 308                # and add a textExpression describing the change.
+ 309                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 310                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 311                if t.TYPE_CHECKING:
+ 312                    assert extra1 is not None
+ 313                    assert extra2 is not None
+ 314
+ 315                textExp1 = m21.expressions.TextExpression(
+ 316                    f"changed {extra1.classes[0]} {changedStr}")
+ 317                textExp2 = m21.expressions.TextExpression(
+ 318                    f"changed {extra1.classes[0]} {changedStr}")
+ 319                textExp1.style.color = Visualization.CHANGED_COLOR
+ 320                textExp2.style.color = Visualization.CHANGED_COLOR
+ 321                if isinstance(extra1, m21.spanner.Spanner):
+ 322                    insertionPoint1 = extra1.getFirst()
+ 323                    insertionPoint2 = extra2.getFirst()
+ 324                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 325                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 326                else:
+ 327                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 328                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 329
+ 330            # staff groups
+ 331            elif op[0] == "staffgrpins":
+ 332                assert isinstance(op[2], AnnStaffGroup)
+ 333                # add a textExpression describing the insertion.
+ 334                staffGroup2 = score2.recurse().getElementById(
+ 335                    op[2].staff_group  # type: ignore
+ 336                )
+ 337                if t.TYPE_CHECKING:
+ 338                    assert staffGroup2 is not None
+ 339                textExp = m21.expressions.TextExpression("inserted StaffGroup")
+ 340                textExp.style.color = Visualization.INSERTED_COLOR
+ 341                # insert text at offset 0 in first measure of first part in group
+ 342                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 343                insertionSite.insert(0, textExp)
+ 344
+ 345            elif op[0] == "staffgrpdel":
+ 346                assert isinstance(op[1], AnnStaffGroup)
+ 347                # add a textExpression describing the deletion.
+ 348                staffGroup1 = score1.recurse().getElementById(
+ 349                    op[1].staff_group  # type: ignore
+ 350                )
+ 351                if t.TYPE_CHECKING:
+ 352                    assert staffGroup1 is not None
+ 353                textExp = m21.expressions.TextExpression("deleted StaffGroup")
+ 354                textExp.style.color = Visualization.DELETED_COLOR
+ 355                # insert text at offset 0 in first measure of first part in group
+ 356                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 357                insertionSite.insert(0, textExp)
+ 358
+ 359            elif op[0] == "staffgrpsub":
+ 360                assert isinstance(op[1], AnnStaffGroup)
+ 361                assert isinstance(op[2], AnnStaffGroup)
+ 362                # add a textExpression describing the change.
+ 363                staffGroup1 = score1.recurse().getElementById(
+ 364                    op[1].staff_group  # type: ignore
+ 365                )
+ 366                staffGroup2 = score2.recurse().getElementById(
+ 367                    op[2].staff_group  # type: ignore
+ 368                )
+ 369                if t.TYPE_CHECKING:
+ 370                    assert staffGroup1 is not None
+ 371                    assert staffGroup2 is not None
+ 372                textExp1 = m21.expressions.TextExpression("changed StaffGroup")
+ 373                textExp2 = m21.expressions.TextExpression("changed StaffGroup")
+ 374                textExp1.style.color = Visualization.CHANGED_COLOR
+ 375                textExp2.style.color = Visualization.CHANGED_COLOR
+ 376                # insert text at offset 0 in first measure of first part in group
+ 377                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 378                insertionSite.insert(0, textExp1)
+ 379                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 380                insertionSite.insert(0, textExp2)
+ 381
+ 382            elif op[0] == "staffgrpnameedit":
+ 383                assert isinstance(op[1], AnnStaffGroup)
+ 384                assert isinstance(op[2], AnnStaffGroup)
+ 385                # add a textExpression describing the change.
+ 386                staffGroup1 = score1.recurse().getElementById(
+ 387                    op[1].staff_group  # type: ignore
+ 388                )
+ 389                staffGroup2 = score2.recurse().getElementById(
+ 390                    op[2].staff_group  # type: ignore
+ 391                )
+ 392                if t.TYPE_CHECKING:
+ 393                    assert staffGroup1 is not None
+ 394                    assert staffGroup2 is not None
+ 395                textExp1 = m21.expressions.TextExpression("changed StaffGroup name")
+ 396                textExp2 = m21.expressions.TextExpression("changed StaffGroup name")
+ 397                textExp1.style.color = Visualization.CHANGED_COLOR
+ 398                textExp2.style.color = Visualization.CHANGED_COLOR
+ 399                # insert text at offset 0 in first measure of first part in group
+ 400                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 401                insertionSite.insert(0, textExp1)
+ 402                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 403                insertionSite.insert(0, textExp2)
+ 404
+ 405            elif op[0] == "staffgrpabbreviationedit":
+ 406                assert isinstance(op[1], AnnStaffGroup)
+ 407                assert isinstance(op[2], AnnStaffGroup)
+ 408                # add a textExpression describing the change.
+ 409                staffGroup1 = score1.recurse().getElementById(
+ 410                    op[1].staff_group  # type: ignore
+ 411                )
+ 412                staffGroup2 = score2.recurse().getElementById(
+ 413                    op[2].staff_group  # type: ignore
+ 414                )
+ 415                if t.TYPE_CHECKING:
+ 416                    assert staffGroup1 is not None
+ 417                    assert staffGroup2 is not None
+ 418                textExp1 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 419                textExp2 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 420                textExp1.style.color = Visualization.CHANGED_COLOR
+ 421                textExp2.style.color = Visualization.CHANGED_COLOR
+ 422                # insert text at offset 0 in first measure of first part in group
+ 423                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 424                insertionSite.insert(0, textExp1)
+ 425                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 426                insertionSite.insert(0, textExp2)
+ 427
+ 428            elif op[0] == "staffgrpsymboledit":
+ 429                assert isinstance(op[1], AnnStaffGroup)
+ 430                assert isinstance(op[2], AnnStaffGroup)
+ 431                # add a textExpression describing the change.
+ 432                staffGroup1 = score1.recurse().getElementById(
+ 433                    op[1].staff_group  # type: ignore
+ 434                )
+ 435                staffGroup2 = score2.recurse().getElementById(
+ 436                    op[2].staff_group  # type: ignore
+ 437                )
+ 438                if t.TYPE_CHECKING:
+ 439                    assert staffGroup1 is not None
+ 440                    assert staffGroup2 is not None
+ 441                textExp1 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 442                textExp2 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 443                textExp1.style.color = Visualization.CHANGED_COLOR
+ 444                textExp2.style.color = Visualization.CHANGED_COLOR
+ 445                # insert text at offset 0 in first measure of first part in group
+ 446                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 447                insertionSite.insert(0, textExp1)
+ 448                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 449                insertionSite.insert(0, textExp2)
+ 450
+ 451            elif op[0] == "staffgrpbartogetheredit":
+ 452                assert isinstance(op[1], AnnStaffGroup)
+ 453                assert isinstance(op[2], AnnStaffGroup)
+ 454                # add a textExpression describing the change.
+ 455                staffGroup1 = score1.recurse().getElementById(
+ 456                    op[1].staff_group  # type: ignore
+ 457                )
+ 458                staffGroup2 = score2.recurse().getElementById(
+ 459                    op[2].staff_group  # type: ignore
+ 460                )
+ 461                if t.TYPE_CHECKING:
+ 462                    assert staffGroup1 is not None
+ 463                    assert staffGroup2 is not None
+ 464                textExp1 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 465                textExp2 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 466                textExp1.style.color = Visualization.CHANGED_COLOR
+ 467                textExp2.style.color = Visualization.CHANGED_COLOR
+ 468                # insert text at offset 0 in first measure of first part in group
+ 469                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 470                insertionSite.insert(0, textExp1)
+ 471                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 472                insertionSite.insert(0, textExp2)
+ 473
+ 474            elif op[0] == "staffgrppartindicesedit":
+ 475                assert isinstance(op[1], AnnStaffGroup)
+ 476                assert isinstance(op[2], AnnStaffGroup)
+ 477                # add a textExpression describing the change.
+ 478                staffGroup1 = score1.recurse().getElementById(
+ 479                    op[1].staff_group  # type: ignore
+ 480                )
+ 481                staffGroup2 = score2.recurse().getElementById(
+ 482                    op[2].staff_group  # type: ignore
+ 483                )
+ 484                if t.TYPE_CHECKING:
+ 485                    assert staffGroup1 is not None
+ 486                    assert staffGroup2 is not None
+ 487                textExp1 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 488                textExp2 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 489                textExp1.style.color = Visualization.CHANGED_COLOR
+ 490                textExp2.style.color = Visualization.CHANGED_COLOR
+ 491                # insert text at offset 0 in first measure of first part in group
+ 492                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 493                insertionSite.insert(0, textExp1)
+ 494                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 495                insertionSite.insert(0, textExp2)
+ 496
+ 497            # note
+ 498            elif op[0] == "noteins":
+ 499                assert isinstance(op[2], AnnNote)
+ 500                # color the inserted score2 general note (note, chord, or rest)
+ 501                # using Visualization.INSERTED_COLOR
+ 502                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 503                if t.TYPE_CHECKING:
+ 504                    assert note2 is not None
+ 505                note2.style.color = Visualization.INSERTED_COLOR
+ 506                textExp = m21.expressions.TextExpression(
+ 507                    f"inserted {note2.classes[0]}")
+ 508                textExp.style.color = Visualization.INSERTED_COLOR
+ 509                note2.activeSite.insert(note2.offset, textExp)
+ 510
+ 511            elif op[0] == "notedel":
+ 512                assert isinstance(op[1], AnnNote)
+ 513                # color the deleted score1 general note (note, chord, or rest)
+ 514                # using Visualization.DELETED_COLOR
+ 515                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 516                if t.TYPE_CHECKING:
+ 517                    assert note1 is not None
+ 518                note1.style.color = Visualization.DELETED_COLOR
+ 519                textExp = m21.expressions.TextExpression(f"deleted {note1.classes[0]}")
+ 520                textExp.style.color = Visualization.DELETED_COLOR
+ 521                note1.activeSite.insert(note1.offset, textExp)
+ 522
+ 523            # pitch
+ 524            elif op[0] == "pitchnameedit":
+ 525                assert isinstance(op[1], AnnNote)
+ 526                assert isinstance(op[2], AnnNote)
+ 527                assert len(op) == 5  # the indices must be there
+ 528                # color the changed note (in both scores) using Visualization.CHANGED_COLOR
+ 529                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 530                if t.TYPE_CHECKING:
+ 531                    assert chord1 is not None
+ 532                note1 = chord1
+ 533                if "Chord" in chord1.classes:
+ 534                    # color just the indexed note in the chord
+ 535                    idx = op[4][0]
+ 536                    note1 = chord1.notes[idx]
+ 537                if t.TYPE_CHECKING:
+ 538                    assert note1 is not None
+ 539                note1.style.color = Visualization.CHANGED_COLOR
+ 540                textExp = m21.expressions.TextExpression("changed pitch")
+ 541                textExp.style.color = Visualization.CHANGED_COLOR
+ 542                if note1.activeSite is not None:
+ 543                    note1.activeSite.insert(note1.offset, textExp)
+ 544                else:
+ 545                    chord1.activeSite.insert(chord1.offset, textExp)
+ 546
+ 547                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 548                if t.TYPE_CHECKING:
+ 549                    assert chord2 is not None
+ 550                note2 = chord2
+ 551                if "Chord" in chord2.classes:
+ 552                    # color just the indexed note in the chord
+ 553                    idx = op[4][1]
+ 554                    note2 = chord2.notes[idx]
+ 555                if t.TYPE_CHECKING:
+ 556                    assert note2 is not None
+ 557                note2.style.color = Visualization.CHANGED_COLOR
+ 558                textExp = m21.expressions.TextExpression("changed pitch")
+ 559                textExp.style.color = Visualization.CHANGED_COLOR
+ 560                if note2.activeSite is not None:
+ 561                    note2.activeSite.insert(note2.offset, textExp)
+ 562                else:
+ 563                    chord2.activeSite.insert(chord2.offset, textExp)
+ 564
+ 565            elif op[0] == "inspitch":
+ 566                assert isinstance(op[2], AnnNote)
+ 567                assert len(op) == 5  # the indices must be there
+ 568                # color the inserted note in score2 using Visualization.INSERTED_COLOR
+ 569                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 570                if t.TYPE_CHECKING:
+ 571                    assert chord2 is not None
+ 572                note2 = chord2
+ 573                if "Chord" in chord2.classes:
+ 574                    # color just the indexed note in the chord
+ 575                    idx = op[4][1]
+ 576                    note2 = chord2.notes[idx]
+ 577                if t.TYPE_CHECKING:
+ 578                    assert note2 is not None
+ 579                note2.style.color = Visualization.INSERTED_COLOR
+ 580                if "Rest" in note2.classes:
+ 581                    textExp = m21.expressions.TextExpression("inserted rest")
+ 582                else:
+ 583                    textExp = m21.expressions.TextExpression("inserted note")
+ 584                textExp.style.color = Visualization.INSERTED_COLOR
+ 585                if note2.activeSite is not None:
+ 586                    note2.activeSite.insert(note2.offset, textExp)
+ 587                else:
+ 588                    chord2.activeSite.insert(chord2.offset, textExp)
+ 589
+ 590            elif op[0] == "delpitch":
+ 591                assert isinstance(op[1], AnnNote)
+ 592                assert len(op) == 5  # the indices must be there
+ 593                # color the deleted note in score1 using Visualization.DELETED_COLOR
+ 594                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 595                if t.TYPE_CHECKING:
+ 596                    assert chord1 is not None
+ 597                note1 = chord1
+ 598                if "Chord" in chord1.classes:
+ 599                    # color just the indexed note in the chord
+ 600                    idx = op[4][0]
+ 601                    note1 = chord1.notes[idx]
+ 602                if t.TYPE_CHECKING:
+ 603                    assert note1 is not None
+ 604                note1.style.color = Visualization.DELETED_COLOR
+ 605                if "Rest" in note1.classes:
+ 606                    textExp = m21.expressions.TextExpression("deleted rest")
+ 607                else:
+ 608                    textExp = m21.expressions.TextExpression("deleted note")
+ 609                textExp.style.color = Visualization.DELETED_COLOR
+ 610                if note1.activeSite is not None:
+ 611                    note1.activeSite.insert(note1.offset, textExp)
+ 612                else:
+ 613                    chord1.activeSite.insert(chord1.offset, textExp)
+ 614
+ 615            elif op[0] == "headedit":
+ 616                assert isinstance(op[1], AnnNote)
+ 617                assert isinstance(op[2], AnnNote)
+ 618                # color the changed note/rest/chord (in both scores)
+ 619                # using Visualization.CHANGED_COLOR
+ 620                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 621                if t.TYPE_CHECKING:
+ 622                    assert note1 is not None
+ 623                note1.style.color = Visualization.CHANGED_COLOR
+ 624                textExp = m21.expressions.TextExpression("changed note head")
+ 625                textExp.style.color = Visualization.CHANGED_COLOR
+ 626                note1.activeSite.insert(note1.offset, textExp)
+ 627
+ 628                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 629                if t.TYPE_CHECKING:
+ 630                    assert note2 is not None
+ 631                note2.style.color = Visualization.CHANGED_COLOR
+ 632                textExp = m21.expressions.TextExpression("changed note head")
+ 633                textExp.style.color = Visualization.CHANGED_COLOR
+ 634                note2.activeSite.insert(note2.offset, textExp)
+ 635
+ 636            elif op[0] == "graceedit":
+ 637                assert isinstance(op[1], AnnNote)
+ 638                assert isinstance(op[2], AnnNote)
+ 639                # color the changed note/rest/chord (in both scores)
+ 640                # using Visualization.CHANGED_COLOR
+ 641                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 642                if t.TYPE_CHECKING:
+ 643                    assert note1 is not None
+ 644                note1.style.color = Visualization.CHANGED_COLOR
+ 645                textExp = m21.expressions.TextExpression("changed grace note")
+ 646                textExp.style.color = Visualization.CHANGED_COLOR
+ 647                note1.activeSite.insert(note1.offset, textExp)
+ 648
+ 649                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 650                if t.TYPE_CHECKING:
+ 651                    assert note2 is not None
+ 652                note2.style.color = Visualization.CHANGED_COLOR
+ 653                textExp = m21.expressions.TextExpression("changed grace note")
+ 654                textExp.style.color = Visualization.CHANGED_COLOR
+ 655                note2.activeSite.insert(note2.offset, textExp)
+ 656
+ 657            elif op[0] == "graceslashedit":
+ 658                assert isinstance(op[1], AnnNote)
+ 659                assert isinstance(op[2], AnnNote)
+ 660                # color the changed note/rest/chord (in both scores)
+ 661                # using Visualization.CHANGED_COLOR
+ 662                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 663                if t.TYPE_CHECKING:
+ 664                    assert note1 is not None
+ 665                note1.style.color = Visualization.CHANGED_COLOR
+ 666                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 667                textExp.style.color = Visualization.CHANGED_COLOR
+ 668                note1.activeSite.insert(note1.offset, textExp)
+ 669
+ 670                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 671                if t.TYPE_CHECKING:
+ 672                    assert note2 is not None
+ 673                note2.style.color = Visualization.CHANGED_COLOR
+ 674                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 675                textExp.style.color = Visualization.CHANGED_COLOR
+ 676                note2.activeSite.insert(note2.offset, textExp)
+ 677
+ 678            # beam
+ 679            elif op[0] == "insbeam":
+ 680                assert isinstance(op[1], AnnNote)
+ 681                assert isinstance(op[2], AnnNote)
+ 682                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 683                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 684                if t.TYPE_CHECKING:
+ 685                    assert note1 is not None
+ 686                note1.style.color = Visualization.INSERTED_COLOR
+ 687                if hasattr(note1, "beams"):
+ 688                    for beam in note1.beams:
+ 689                        beam.style.color = (
+ 690                            Visualization.INSERTED_COLOR
+ 691                        )  # this apparently does nothing
+ 692                textExp = m21.expressions.TextExpression("increased flags")
+ 693                textExp.style.color = Visualization.INSERTED_COLOR
+ 694                note1.activeSite.insert(note1.offset, textExp)
+ 695
+ 696                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 697                if t.TYPE_CHECKING:
+ 698                    assert note2 is not None
+ 699                note2.style.color = Visualization.INSERTED_COLOR
+ 700                if hasattr(note2, "beams"):
+ 701                    for beam in note2.beams:
+ 702                        beam.style.color = (
+ 703                            Visualization.INSERTED_COLOR
+ 704                        )  # this apparently does nothing
+ 705                textExp = m21.expressions.TextExpression("increased flags")
+ 706                textExp.style.color = Visualization.INSERTED_COLOR
+ 707                note2.activeSite.insert(note2.offset, textExp)
+ 708
+ 709            elif op[0] == "delbeam":
+ 710                assert isinstance(op[1], AnnNote)
+ 711                assert isinstance(op[2], AnnNote)
+ 712                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 713                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 714                if t.TYPE_CHECKING:
+ 715                    assert note1 is not None
+ 716                note1.style.color = Visualization.DELETED_COLOR
+ 717                if hasattr(note1, "beams"):
+ 718                    for beam in note1.beams:
+ 719                        beam.style.color = (
+ 720                            Visualization.DELETED_COLOR
+ 721                        )  # this apparently does nothing
+ 722                textExp = m21.expressions.TextExpression("decreased flags")
+ 723                textExp.style.color = Visualization.CHANGED_COLOR
+ 724                note1.activeSite.insert(note1.offset, textExp)
+ 725
+ 726                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 727                if t.TYPE_CHECKING:
+ 728                    assert note2 is not None
+ 729                note2.style.color = Visualization.DELETED_COLOR
+ 730                if hasattr(note2, "beams"):
+ 731                    for beam in note2.beams:
+ 732                        beam.style.color = (
+ 733                            Visualization.DELETED_COLOR
+ 734                        )  # this apparently does nothing
+ 735                textExp = m21.expressions.TextExpression("decreased flags")
+ 736                textExp.style.color = Visualization.DELETED_COLOR
+ 737                note2.activeSite.insert(note2.offset, textExp)
+ 738
+ 739            elif op[0] == "editbeam":
+ 740                assert isinstance(op[1], AnnNote)
+ 741                assert isinstance(op[2], AnnNote)
+ 742                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+ 743                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 744                if t.TYPE_CHECKING:
+ 745                    assert note1 is not None
+ 746                note1.style.color = Visualization.CHANGED_COLOR
+ 747                if hasattr(note1, "beams"):
+ 748                    for beam in note1.beams:
+ 749                        beam.style.color = (
+ 750                            Visualization.CHANGED_COLOR
+ 751                        )  # this apparently does nothing
+ 752                textExp = m21.expressions.TextExpression("changed flags")
+ 753                textExp.style.color = Visualization.CHANGED_COLOR
+ 754                note1.activeSite.insert(note1.offset, textExp)
+ 755
+ 756                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 757                if t.TYPE_CHECKING:
+ 758                    assert note2 is not None
+ 759                note2.style.color = Visualization.CHANGED_COLOR
+ 760                if hasattr(note2, "beams"):
+ 761                    for beam in note2.beams:
+ 762                        beam.style.color = (
+ 763                            Visualization.CHANGED_COLOR
+ 764                        )  # this apparently does nothing
+ 765                textExp = m21.expressions.TextExpression("changed flags")
+ 766                textExp.style.color = Visualization.CHANGED_COLOR
+ 767                note2.activeSite.insert(note2.offset, textExp)
+ 768
+ 769            elif op[0] == "editnoteshape":
+ 770                assert isinstance(op[1], AnnNote)
+ 771                assert isinstance(op[2], AnnNote)
+ 772                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 773                if t.TYPE_CHECKING:
+ 774                    assert note1 is not None
+ 775                note1.style.color = Visualization.CHANGED_COLOR
+ 776                textExp = m21.expressions.TextExpression("changed note shape")
+ 777                textExp.style.color = Visualization.CHANGED_COLOR
+ 778                note1.activeSite.insert(note1.offset, textExp)
+ 779
+ 780                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 781                if t.TYPE_CHECKING:
+ 782                    assert note2 is not None
+ 783                note2.style.color = Visualization.CHANGED_COLOR
+ 784                textExp = m21.expressions.TextExpression("changed note shape")
+ 785                textExp.style.color = Visualization.CHANGED_COLOR
+ 786                note2.activeSite.insert(note2.offset, textExp)
+ 787
+ 788            elif op[0] == "editnoteheadfill":
+ 789                assert isinstance(op[1], AnnNote)
+ 790                assert isinstance(op[2], AnnNote)
+ 791                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 792                if t.TYPE_CHECKING:
+ 793                    assert note1 is not None
+ 794                note1.style.color = Visualization.CHANGED_COLOR
+ 795                textExp = m21.expressions.TextExpression("changed note head fill")
+ 796                textExp.style.color = Visualization.CHANGED_COLOR
+ 797                note1.activeSite.insert(note1.offset, textExp)
+ 798
+ 799                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 800                if t.TYPE_CHECKING:
+ 801                    assert note2 is not None
+ 802                note2.style.color = Visualization.CHANGED_COLOR
+ 803                textExp = m21.expressions.TextExpression("changed note head fill")
+ 804                textExp.style.color = Visualization.CHANGED_COLOR
+ 805                note2.activeSite.insert(note2.offset, textExp)
+ 806
+ 807            elif op[0] == "editnoteheadparenthesis":
+ 808                assert isinstance(op[1], AnnNote)
+ 809                assert isinstance(op[2], AnnNote)
+ 810                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 811                if t.TYPE_CHECKING:
+ 812                    assert note1 is not None
+ 813                note1.style.color = Visualization.CHANGED_COLOR
+ 814                textExp = m21.expressions.TextExpression("changed note head paren")
+ 815                textExp.style.color = Visualization.CHANGED_COLOR
+ 816                note1.activeSite.insert(note1.offset, textExp)
+ 817
+ 818                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 819                if t.TYPE_CHECKING:
+ 820                    assert note2 is not None
+ 821                note2.style.color = Visualization.CHANGED_COLOR
+ 822                textExp = m21.expressions.TextExpression("changed note head paren")
+ 823                textExp.style.color = Visualization.CHANGED_COLOR
+ 824                note2.activeSite.insert(note2.offset, textExp)
+ 825
+ 826            elif op[0] == "editstemdirection":
+ 827                assert isinstance(op[1], AnnNote)
+ 828                assert isinstance(op[2], AnnNote)
+ 829                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 830                if t.TYPE_CHECKING:
+ 831                    assert note1 is not None
+ 832                note1.style.color = Visualization.CHANGED_COLOR
+ 833                textExp = m21.expressions.TextExpression("changed stem direction")
+ 834                textExp.style.color = Visualization.CHANGED_COLOR
+ 835                note1.activeSite.insert(note1.offset, textExp)
+ 836
+ 837                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 838                if t.TYPE_CHECKING:
+ 839                    assert note2 is not None
+ 840                note2.style.color = Visualization.CHANGED_COLOR
+ 841                textExp = m21.expressions.TextExpression("changed stem direction")
+ 842                textExp.style.color = Visualization.CHANGED_COLOR
+ 843                note2.activeSite.insert(note2.offset, textExp)
+ 844
+ 845            elif op[0] == "editstyle":
+ 846                assert isinstance(op[1], AnnNote)
+ 847                assert isinstance(op[2], AnnNote)
+ 848                sd1 = op[1].styledict
+ 849                sd2 = op[2].styledict
+ 850                changedStr = ""
+ 851                for k1, v1 in sd1.items():
+ 852                    if k1 not in sd2 or sd2[k1] != v1:
+ 853                        if changedStr:
+ 854                            changedStr += ","
+ 855                        changedStr += k1
+ 856
+ 857                # one last thing: check for keys in sd2 that aren't in sd1
+ 858                for k2 in sd2:
+ 859                    if k2 not in sd1:
+ 860                        if changedStr:
+ 861                            changedStr += ","
+ 862                        changedStr += k2
+ 863
+ 864                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 865                if t.TYPE_CHECKING:
+ 866                    assert note1 is not None
+ 867                note1.style.color = Visualization.CHANGED_COLOR
+ 868                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 869                textExp.style.color = Visualization.CHANGED_COLOR
+ 870                note1.activeSite.insert(note1.offset, textExp)
+ 871
+ 872                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 873                if t.TYPE_CHECKING:
+ 874                    assert note2 is not None
+ 875                note2.style.color = Visualization.CHANGED_COLOR
+ 876                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 877                textExp.style.color = Visualization.CHANGED_COLOR
+ 878                note2.activeSite.insert(note2.offset, textExp)
+ 879
+ 880            # accident
+ 881            elif op[0] == "accidentins":
+ 882                assert isinstance(op[1], AnnNote)
+ 883                assert isinstance(op[2], AnnNote)
+ 884                assert len(op) == 5  # the indices must be there
+ 885                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 886                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 887                if t.TYPE_CHECKING:
+ 888                    assert chord1 is not None
+ 889                note1 = chord1
+ 890                if "Chord" in chord1.classes:
+ 891                    # color only the indexed note's accidental in the chord
+ 892                    idx = op[4][0]
+ 893                    note1 = chord1.notes[idx]
+ 894                if t.TYPE_CHECKING:
+ 895                    assert note1 is not None
+ 896                if note1.pitch.accidental:
+ 897                    note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 898                note1.style.color = Visualization.INSERTED_COLOR
+ 899                textExp = m21.expressions.TextExpression("inserted accidental")
+ 900                textExp.style.color = Visualization.INSERTED_COLOR
+ 901                if note1.activeSite is not None:
+ 902                    note1.activeSite.insert(note1.offset, textExp)
+ 903                else:
+ 904                    chord1.activeSite.insert(chord1.offset, textExp)
+ 905
+ 906                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 907                if t.TYPE_CHECKING:
+ 908                    assert chord2 is not None
+ 909                note2 = chord2
+ 910                if "Chord" in chord2.classes:
+ 911                    # color only the indexed note's accidental in the chord
+ 912                    idx = op[4][1]
+ 913                    note2 = chord2.notes[idx]
+ 914                if t.TYPE_CHECKING:
+ 915                    assert note2 is not None
+ 916                if note2.pitch.accidental:
+ 917                    note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 918                note2.style.color = Visualization.INSERTED_COLOR
+ 919                textExp = m21.expressions.TextExpression("inserted accidental")
+ 920                textExp.style.color = Visualization.INSERTED_COLOR
+ 921                if note2.activeSite is not None:
+ 922                    note2.activeSite.insert(note2.offset, textExp)
+ 923                else:
+ 924                    chord2.activeSite.insert(chord2.offset, textExp)
+ 925
+ 926            elif op[0] == "accidentdel":
+ 927                assert isinstance(op[1], AnnNote)
+ 928                assert isinstance(op[2], AnnNote)
+ 929                assert len(op) == 5  # the indices must be there
+ 930                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 931                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 932                if t.TYPE_CHECKING:
+ 933                    assert chord1 is not None
+ 934                note1 = chord1
+ 935                if "Chord" in chord1.classes:
+ 936                    # color only the indexed note's accidental in the chord
+ 937                    idx = op[4][0]
+ 938                    note1 = chord1.notes[idx]
+ 939                if t.TYPE_CHECKING:
+ 940                    assert note1 is not None
+ 941                if note1.pitch.accidental:
+ 942                    note1.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 943                note1.style.color = Visualization.DELETED_COLOR
+ 944                textExp = m21.expressions.TextExpression("deleted accidental")
+ 945                textExp.style.color = Visualization.DELETED_COLOR
+ 946                if note1.activeSite is not None:
+ 947                    note1.activeSite.insert(note1.offset, textExp)
+ 948                else:
+ 949                    chord1.activeSite.insert(chord1.offset, textExp)
+ 950
+ 951                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 952                if t.TYPE_CHECKING:
+ 953                    assert chord2 is not None
+ 954                note2 = chord2
+ 955                if "Chord" in chord2.classes:
+ 956                    # color only the indexed note's accidental in the chord
+ 957                    idx = op[4][1]
+ 958                    note2 = chord2.notes[idx]
+ 959                if t.TYPE_CHECKING:
+ 960                    assert note2 is not None
+ 961                if note2.pitch.accidental:
+ 962                    note2.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 963                note2.style.color = Visualization.DELETED_COLOR
+ 964                textExp = m21.expressions.TextExpression("deleted accidental")
+ 965                textExp.style.color = Visualization.DELETED_COLOR
+ 966                if note2.activeSite is not None:
+ 967                    note2.activeSite.insert(note2.offset, textExp)
+ 968                else:
+ 969                    chord2.activeSite.insert(chord2.offset, textExp)
+ 970
+ 971            elif op[0] == "accidentedit":
+ 972                assert isinstance(op[1], AnnNote)
+ 973                assert isinstance(op[2], AnnNote)
+ 974                assert len(op) == 5  # the indices must be there
+ 975                # color the changed accidental (in both scores)
+ 976                # using Visualization.CHANGED_COLOR
+ 977                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 978                if t.TYPE_CHECKING:
+ 979                    assert chord1 is not None
+ 980                note1 = chord1
+ 981                if "Chord" in chord1.classes:
+ 982                    # color just the indexed note in the chord
+ 983                    idx = op[4][0]
+ 984                    note1 = chord1.notes[idx]
+ 985                if t.TYPE_CHECKING:
+ 986                    assert note1 is not None
+ 987                if note1.pitch.accidental:
+ 988                    note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+ 989                note1.style.color = Visualization.CHANGED_COLOR
+ 990                textExp = m21.expressions.TextExpression("changed accidental")
+ 991                textExp.style.color = Visualization.CHANGED_COLOR
+ 992                if note1.activeSite is not None:
+ 993                    note1.activeSite.insert(note1.offset, textExp)
+ 994                else:
+ 995                    chord1.activeSite.insert(chord1.offset, textExp)
+ 996
+ 997                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 998                if t.TYPE_CHECKING:
+ 999                    assert chord2 is not None
+1000                note2 = chord2
+1001                if "Chord" in chord2.classes:
+1002                    # color just the indexed note in the chord
+1003                    idx = op[4][1]
+1004                    note2 = chord2.notes[idx]
+1005                if t.TYPE_CHECKING:
+1006                    assert note2 is not None
+1007                if note2.pitch.accidental:
+1008                    note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+1009                note2.style.color = Visualization.CHANGED_COLOR
+1010                textExp = m21.expressions.TextExpression("changed accidental")
+1011                textExp.style.color = Visualization.CHANGED_COLOR
+1012                if note2.activeSite is not None:
+1013                    note2.activeSite.insert(note2.offset, textExp)
+1014                else:
+1015                    chord2.activeSite.insert(chord2.offset, textExp)
+1016
+1017            elif op[0] == "dotins":
+1018                assert isinstance(op[1], AnnNote)
+1019                assert isinstance(op[2], AnnNote)
+1020                # In music21, the dots are not separately colorable from the note,
+1021                # so we will just color the modified note here in both scores,
+1022                # using Visualization.CHANGED_COLOR
+1023                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1024                if t.TYPE_CHECKING:
+1025                    assert note1 is not None
+1026                note1.style.color = Visualization.CHANGED_COLOR
+1027                textExp = m21.expressions.TextExpression("inserted dot")
+1028                textExp.style.color = Visualization.CHANGED_COLOR
+1029                note1.activeSite.insert(note1.offset, textExp)
+1030
+1031                if t.TYPE_CHECKING:
+1032                    assert note2 is not None
+1033                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1034                note2.style.color = Visualization.CHANGED_COLOR
+1035                textExp = m21.expressions.TextExpression("inserted dot")
+1036                textExp.style.color = Visualization.CHANGED_COLOR
+1037                note2.activeSite.insert(note2.offset, textExp)
+1038
+1039            elif op[0] == "dotdel":
+1040                assert isinstance(op[1], AnnNote)
+1041                assert isinstance(op[2], AnnNote)
+1042                # In music21, the dots are not separately colorable from the note,
+1043                # so we will just color the modified note here in both scores,
+1044                # using Visualization.CHANGED_COLOR
+1045                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1046                if t.TYPE_CHECKING:
+1047                    assert note1 is not None
+1048                note1.style.color = Visualization.CHANGED_COLOR
+1049                textExp = m21.expressions.TextExpression("deleted dot")
+1050                textExp.style.color = Visualization.CHANGED_COLOR
+1051                note1.activeSite.insert(note1.offset, textExp)
+1052
+1053                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1054                if t.TYPE_CHECKING:
+1055                    assert note2 is not None
+1056                note2.style.color = Visualization.CHANGED_COLOR
+1057                textExp = m21.expressions.TextExpression("deleted dot")
+1058                textExp.style.color = Visualization.CHANGED_COLOR
+1059                note2.activeSite.insert(note2.offset, textExp)
+1060
+1061            # tuplets
+1062            elif op[0] == "instuplet":
+1063                assert isinstance(op[1], AnnNote)
+1064                assert isinstance(op[2], AnnNote)
+1065                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1066                if t.TYPE_CHECKING:
+1067                    assert note1 is not None
+1068                note1.style.color = Visualization.CHANGED_COLOR
+1069                textExp = m21.expressions.TextExpression("inserted tuplet")
+1070                textExp.style.color = Visualization.CHANGED_COLOR
+1071                note1.activeSite.insert(note1.offset, textExp)
+1072
+1073                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1074                if t.TYPE_CHECKING:
+1075                    assert note2 is not None
+1076                note2.style.color = Visualization.CHANGED_COLOR
+1077                textExp = m21.expressions.TextExpression("inserted tuplet")
+1078                textExp.style.color = Visualization.CHANGED_COLOR
+1079                note2.activeSite.insert(note2.offset, textExp)
+1080
+1081            elif op[0] == "deltuplet":
+1082                assert isinstance(op[1], AnnNote)
+1083                assert isinstance(op[2], AnnNote)
+1084                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1085                if t.TYPE_CHECKING:
+1086                    assert note1 is not None
+1087                note1.style.color = Visualization.CHANGED_COLOR
+1088                textExp = m21.expressions.TextExpression("deleted tuplet")
+1089                textExp.style.color = Visualization.CHANGED_COLOR
+1090                note1.activeSite.insert(note1.offset, textExp)
+1091
+1092                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1093                if t.TYPE_CHECKING:
+1094                    assert note2 is not None
+1095                note2.style.color = Visualization.CHANGED_COLOR
+1096                textExp = m21.expressions.TextExpression("deleted tuplet")
+1097                textExp.style.color = Visualization.CHANGED_COLOR
+1098                note2.activeSite.insert(note2.offset, textExp)
+1099
+1100            elif op[0] == "edittuplet":
+1101                assert isinstance(op[1], AnnNote)
+1102                assert isinstance(op[2], AnnNote)
+1103                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1104                if t.TYPE_CHECKING:
+1105                    assert note1 is not None
+1106                note1.style.color = Visualization.CHANGED_COLOR
+1107                textExp = m21.expressions.TextExpression("changed tuplet")
+1108                textExp.style.color = Visualization.CHANGED_COLOR
+1109                note1.activeSite.insert(note1.offset, textExp)
+1110
+1111                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1112                if t.TYPE_CHECKING:
+1113                    assert note2 is not None
+1114                note2.style.color = Visualization.CHANGED_COLOR
+1115                textExp = m21.expressions.TextExpression("changed tuplet")
+1116                textExp.style.color = Visualization.CHANGED_COLOR
+1117                note2.activeSite.insert(note2.offset, textExp)
+1118
+1119            # ties
+1120            elif op[0] == "tieins":
+1121                assert isinstance(op[1], AnnNote)
+1122                assert isinstance(op[2], AnnNote)
+1123                assert len(op) == 5  # the indices must be there
+1124                # Color the modified note here in both scores,
+1125                # using Visualization.INSERTED_COLOR
+1126                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1127                if t.TYPE_CHECKING:
+1128                    assert chord1 is not None
+1129                note1 = chord1
+1130                if "Chord" in chord1.classes:
+1131                    # color just the indexed note in the chord
+1132                    idx = op[4][0]
+1133                    note1 = chord1.notes[idx]
+1134                if t.TYPE_CHECKING:
+1135                    assert note1 is not None
+1136                note1.style.color = Visualization.INSERTED_COLOR
+1137                textExp = m21.expressions.TextExpression("inserted tie")
+1138                textExp.style.color = Visualization.INSERTED_COLOR
+1139                if note1.activeSite is not None:
+1140                    note1.activeSite.insert(note1.offset, textExp)
+1141                else:
+1142                    chord1.activeSite.insert(chord1.offset, textExp)
+1143
+1144                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1145                if t.TYPE_CHECKING:
+1146                    assert chord2 is not None
+1147                note2 = chord2
+1148                if "Chord" in chord2.classes:
+1149                    # color just the indexed note in the chord
+1150                    idx = op[4][1]
+1151                    note2 = chord2.notes[idx]
+1152                if t.TYPE_CHECKING:
+1153                    assert note2 is not None
+1154                note2.style.color = Visualization.INSERTED_COLOR
+1155                textExp = m21.expressions.TextExpression("inserted tie")
+1156                textExp.style.color = Visualization.INSERTED_COLOR
+1157                if note2.activeSite is not None:
+1158                    note2.activeSite.insert(note2.offset, textExp)
+1159                else:
+1160                    chord2.activeSite.insert(chord2.offset, textExp)
+1161
+1162            elif op[0] == "tiedel":
+1163                assert isinstance(op[1], AnnNote)
+1164                assert isinstance(op[2], AnnNote)
+1165                assert len(op) == 5  # the indices must be there
+1166                # Color the modified note in both scores, using Visualization.DELETED_COLOR
+1167                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1168                if t.TYPE_CHECKING:
+1169                    assert chord1 is not None
+1170                note1 = chord1
+1171                if "Chord" in chord1.classes:
+1172                    # color just the indexed note in the chord
+1173                    idx = op[4][0]
+1174                    note1 = chord1.notes[idx]
+1175                if t.TYPE_CHECKING:
+1176                    assert note1 is not None
+1177                note1.style.color = Visualization.DELETED_COLOR
+1178                textExp = m21.expressions.TextExpression("deleted tie")
+1179                textExp.style.color = Visualization.DELETED_COLOR
+1180                if note1.activeSite is not None:
+1181                    note1.activeSite.insert(note1.offset, textExp)
+1182                else:
+1183                    chord1.activeSite.insert(chord1.offset, textExp)
+1184
+1185                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1186                if t.TYPE_CHECKING:
+1187                    assert chord2 is not None
+1188                note2 = chord2
+1189                if "Chord" in chord2.classes:
+1190                    # color just the indexed note in the chord
+1191                    idx = op[4][1]
+1192                    note2 = chord2.notes[idx]
+1193                if t.TYPE_CHECKING:
+1194                    assert note2 is not None
+1195                note2.style.color = Visualization.DELETED_COLOR
+1196                textExp = m21.expressions.TextExpression("deleted tie")
+1197                textExp.style.color = Visualization.DELETED_COLOR
+1198                if note2.activeSite is not None:
+1199                    note2.activeSite.insert(note2.offset, textExp)
+1200                else:
+1201                    chord2.activeSite.insert(chord2.offset, textExp)
+1202
+1203            # expressions
+1204            elif op[0] == "insexpression":
+1205                assert isinstance(op[1], AnnNote)
+1206                assert isinstance(op[2], AnnNote)
+1207                # color the note in both scores using Visualization.INSERTED_COLOR
+1208                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1209                if t.TYPE_CHECKING:
+1210                    assert note1 is not None
+1211                note1.style.color = Visualization.INSERTED_COLOR
+1212                textExp = m21.expressions.TextExpression("inserted expression")
+1213                textExp.style.color = Visualization.INSERTED_COLOR
+1214                note1.activeSite.insert(note1.offset, textExp)
+1215
+1216                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1217                if t.TYPE_CHECKING:
+1218                    assert note2 is not None
+1219                note2.style.color = Visualization.INSERTED_COLOR
+1220                textExp = m21.expressions.TextExpression("inserted expression")
+1221                textExp.style.color = Visualization.INSERTED_COLOR
+1222                note2.activeSite.insert(note2.offset, textExp)
+1223
+1224            elif op[0] == "delexpression":
+1225                assert isinstance(op[1], AnnNote)
+1226                assert isinstance(op[2], AnnNote)
+1227                # color the deleted expression in score1 using Visualization.DELETED_COLOR
+1228                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1229                if t.TYPE_CHECKING:
+1230                    assert note1 is not None
+1231                note1.style.color = Visualization.DELETED_COLOR
+1232                textExp = m21.expressions.TextExpression("deleted expression")
+1233                textExp.style.color = Visualization.DELETED_COLOR
+1234                note1.activeSite.insert(note1.offset, textExp)
+1235
+1236                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1237                if t.TYPE_CHECKING:
+1238                    assert note2 is not None
+1239                note2.style.color = Visualization.DELETED_COLOR
+1240                textExp = m21.expressions.TextExpression("deleted expression")
+1241                textExp.style.color = Visualization.DELETED_COLOR
+1242                note2.activeSite.insert(note2.offset, textExp)
+1243
+1244            elif op[0] == "editexpression":
+1245                assert isinstance(op[1], AnnNote)
+1246                assert isinstance(op[2], AnnNote)
+1247                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+1248                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1249                if t.TYPE_CHECKING:
+1250                    assert note1 is not None
+1251                note1.style.color = Visualization.CHANGED_COLOR
+1252                textExp = m21.expressions.TextExpression("changed expression")
+1253                textExp.style.color = Visualization.CHANGED_COLOR
+1254                note1.activeSite.insert(note1.offset, textExp)
+1255
+1256                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1257                if t.TYPE_CHECKING:
+1258                    assert note2 is not None
+1259                note2.style.color = Visualization.CHANGED_COLOR
+1260                textExp = m21.expressions.TextExpression("changed expression")
+1261                textExp.style.color = Visualization.CHANGED_COLOR
+1262                note2.activeSite.insert(note2.offset, textExp)
+1263
+1264            # articulations
+1265            elif op[0] == "insarticulation":
+1266                assert isinstance(op[1], AnnNote)
+1267                assert isinstance(op[2], AnnNote)
+1268                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1269                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1270                if t.TYPE_CHECKING:
+1271                    assert note1 is not None
+1272                note1.style.color = Visualization.INSERTED_COLOR
+1273                textExp = m21.expressions.TextExpression("inserted articulation")
+1274                textExp.style.color = Visualization.INSERTED_COLOR
+1275                note1.activeSite.insert(note1.offset, textExp)
+1276
+1277                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1278                if t.TYPE_CHECKING:
+1279                    assert note2 is not None
+1280                note2.style.color = Visualization.INSERTED_COLOR
+1281                textExp = m21.expressions.TextExpression("inserted articulation")
+1282                textExp.style.color = Visualization.INSERTED_COLOR
+1283                note2.activeSite.insert(note2.offset, textExp)
+1284
+1285            elif op[0] == "delarticulation":
+1286                assert isinstance(op[1], AnnNote)
+1287                assert isinstance(op[2], AnnNote)
+1288                # color the modified note in both scores using Visualization.DELETED_COLOR
+1289                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1290                if t.TYPE_CHECKING:
+1291                    assert note1 is not None
+1292                note1.style.color = Visualization.DELETED_COLOR
+1293                textExp = m21.expressions.TextExpression("deleted articulation")
+1294                textExp.style.color = Visualization.DELETED_COLOR
+1295                note1.activeSite.insert(note1.offset, textExp)
+1296
+1297                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1298                if t.TYPE_CHECKING:
+1299                    assert note2 is not None
+1300                note2.style.color = Visualization.DELETED_COLOR
+1301                textExp = m21.expressions.TextExpression("deleted articulation")
+1302                textExp.style.color = Visualization.DELETED_COLOR
+1303                note2.activeSite.insert(note2.offset, textExp)
+1304
+1305            elif op[0] == "editarticulation":
+1306                assert isinstance(op[1], AnnNote)
+1307                assert isinstance(op[2], AnnNote)
+1308                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1309                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1310                if t.TYPE_CHECKING:
+1311                    assert note1 is not None
+1312                note1.style.color = Visualization.CHANGED_COLOR
+1313                textExp = m21.expressions.TextExpression("changed articulation")
+1314                textExp.style.color = Visualization.CHANGED_COLOR
+1315                note1.activeSite.insert(note1.offset, textExp)
+1316
+1317                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1318                if t.TYPE_CHECKING:
+1319                    assert note2 is not None
+1320                note2.style.color = Visualization.CHANGED_COLOR
+1321                textExp = m21.expressions.TextExpression("changed articulation")
+1322                textExp.style.color = Visualization.CHANGED_COLOR
+1323                note2.activeSite.insert(note2.offset, textExp)
+1324
+1325            # lyrics
+1326            elif op[0] == "inslyric":
+1327                assert isinstance(op[1], AnnNote)
+1328                assert isinstance(op[2], AnnNote)
+1329                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1330                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1331                if t.TYPE_CHECKING:
+1332                    assert note1 is not None
+1333                note1.style.color = Visualization.INSERTED_COLOR
+1334                textExp = m21.expressions.TextExpression("inserted lyric")
+1335                textExp.style.color = Visualization.INSERTED_COLOR
+1336                note1.activeSite.insert(note1.offset, textExp)
+1337
+1338                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1339                if t.TYPE_CHECKING:
+1340                    assert note2 is not None
+1341                note2.style.color = Visualization.INSERTED_COLOR
+1342                textExp = m21.expressions.TextExpression("inserted lyric")
+1343                textExp.style.color = Visualization.INSERTED_COLOR
+1344                note2.activeSite.insert(note2.offset, textExp)
+1345
+1346            elif op[0] == "dellyric":
+1347                assert isinstance(op[1], AnnNote)
+1348                assert isinstance(op[2], AnnNote)
+1349                # color the modified note in both scores using Visualization.DELETED_COLOR
+1350                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1351                if t.TYPE_CHECKING:
+1352                    assert note1 is not None
+1353                note1.style.color = Visualization.DELETED_COLOR
+1354                textExp = m21.expressions.TextExpression("deleted lyric")
+1355                textExp.style.color = Visualization.DELETED_COLOR
+1356                note1.activeSite.insert(note1.offset, textExp)
+1357
+1358                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1359                if t.TYPE_CHECKING:
+1360                    assert note2 is not None
+1361                note2.style.color = Visualization.DELETED_COLOR
+1362                textExp = m21.expressions.TextExpression("deleted lyric")
+1363                textExp.style.color = Visualization.DELETED_COLOR
+1364                note2.activeSite.insert(note2.offset, textExp)
+1365
+1366            elif op[0] == "editlyric":
+1367                assert isinstance(op[1], AnnNote)
+1368                assert isinstance(op[2], AnnNote)
+1369                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1370                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1371                if t.TYPE_CHECKING:
+1372                    assert note1 is not None
+1373                note1.style.color = Visualization.CHANGED_COLOR
+1374                textExp = m21.expressions.TextExpression("changed lyric")
+1375                textExp.style.color = Visualization.CHANGED_COLOR
+1376                note1.activeSite.insert(note1.offset, textExp)
+1377
+1378                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1379                if t.TYPE_CHECKING:
+1380                    assert note2 is not None
+1381                note2.style.color = Visualization.CHANGED_COLOR
+1382                textExp = m21.expressions.TextExpression("changed lyric")
+1383                textExp.style.color = Visualization.CHANGED_COLOR
+1384                note2.activeSite.insert(note2.offset, textExp)
+1385
+1386            else:
+1387                print(
+1388                    f"Annotation type {op[0]} not yet supported for visualization",
+1389                    file=sys.stderr
+1390                )
+1391
+1392    @staticmethod
+1393    def show_diffs(
+1394        score1: m21.stream.Score,
+1395        score2: m21.stream.Score,
+1396        out_path1: str | Path | None = None,
+1397        out_path2: str | Path | None = None
+1398    ) -> None:
+1399        """
+1400        Render two (presumably marked-up) music21 scores.  If both out_path1 and
+1401        out_path2 are not None, save the rendered PDFs at those two locations,
+1402        otherwise just display them using the default PDF viewer on the system.
+1403
+1404        Args:
+1405            score1 (music21.stream.Score): The first score to render
+1406            score2 (music21.stream.Score): The second score to render
+1407            out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
+1408                If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
+1409                (default is None)
+1410            out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
+1411                If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
+1412                (default is None)
+1413        """
+1414        # display the two (presumably annotated) scores
+1415        originalComposer1: str | None = None
+1416        originalComposer2: str | None = None
+1417
+1418        if score1.metadata is None:
+1419            score1.metadata = m21.metadata.Metadata()
+1420        if score2.metadata is None:
+1421            score2.metadata = m21.metadata.Metadata()
+1422
+1423        originalComposer1 = score1.metadata.composer
+1424        if originalComposer1 is None:
+1425            score1.metadata.composer = "score1"
+1426        else:
+1427            score1.metadata.composer = "score1          " + originalComposer1
+1428
+1429        originalComposer2 = score2.metadata.composer
+1430        if originalComposer2 is None:
+1431            score2.metadata.composer = "score2"
+1432        else:
+1433            score2.metadata.composer = "score2          " + originalComposer2
+1434
+1435        # save files if requested
+1436        if (out_path1 is not None) and (out_path2 is not None):
+1437            score1.write("musicxml.pdf", makeNotation=False, fp=out_path1)
+1438            score2.write("musicxml.pdf", makeNotation=False, fp=out_path2)
+1439            print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr)
+1440        else:
+1441            # just display the scores
+1442            score1.show("musicxml.pdf", makeNotation=False)
+1443            score2.show("musicxml.pdf", makeNotation=False)
+
- chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted tie") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "tiedel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # Color the modified note in both scores, using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - # expressions - elif op[0] == "insexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the deleted expression in score1 using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # articulations - elif op[0] == "insarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - else: - print(f"Annotation type {op[0]} not yet supported for visualization", file=sys.stderr) - - @staticmethod - def show_diffs(score1: m21.stream.Score, - score2: m21.stream.Score, - out_path1: Union[str, Path] = None, - out_path2: Union[str, Path] = None): - """ - Render two (presumably marked-up) music21 scores. If both out_path1 and out_path2 are not None, - save the rendered PDFs at those two locations, otherwise just display them using the default - PDF viewer on the system. - - Args: - score1 (music21.stream.Score): The first score to render - score2 (music21.stream.Score): The second score to render - out_path1 (str, Path): Where to save the first marked-up rendered score PDF. - If out_path1 is None, both PDFs will be displayed in the default PDF viewer. - (default is None) - out_path2 (str, Path): Where to save the second marked-up rendered score PDF. - If out_path2 is None, both PDFs will be displayed in the default PDF viewer. - (default is None) - """ - # display the two (presumably annotated) scores - originalComposer1: str = None - originalComposer2: str = None - - if score1.metadata is None: - score1.metadata = m21.metadata.Metadata() - if score2.metadata is None: - score2.metadata = m21.metadata.Metadata() - - originalComposer1 = score1.metadata.composer - if originalComposer1 is None: - score1.metadata.composer = "score1" - else: - score1.metadata.composer = "score1 " + originalComposer1 - - originalComposer2 = score2.metadata.composer - if originalComposer2 is None: - score2.metadata.composer = "score2" - else: - score2.metadata.composer = "score2 " + originalComposer2 - - #save files if requested - if (out_path1 is not None) and (out_path2 is not None): - score1.write("musicxml.pdf", makeNotation=False, fp=out_path1) - score2.write("musicxml.pdf", makeNotation=False, fp=out_path2) - print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr) - else: # just display the scores - score1.show("musicxml.pdf", makeNotation=False) - score2.show("musicxml.pdf", makeNotation=False) -
- -
-
- #   - - - class - Visualization: -
- -
- View Source -
class Visualization:
-    # These can be set by the client to different colors
-    INSERTED_COLOR = "red"
-    """
-    `INSERTED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-    DELETED_COLOR = "red"
-    """
-    `DELETED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-    CHANGED_COLOR = "red"
-    """
-    `CHANGED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
-    """
-
-    @staticmethod
-    def mark_diffs(
-        score1: m21.stream.Score, score2: m21.stream.Score, operations: List[Tuple]
-    ):
-        """
-        Mark up two music21 scores with the differences described by an operations
-        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
-
-        Args:
-            score1 (music21.stream.Score): The first score to mark up
-            score2 (music21.stream.Score): The second score to mark up
-            operations (List[Tuple]): The operations list that describes the difference
-                between the two scores
-        """
-        for op in operations:
-            # bar
-            if op[0] == "insbar":
-                assert isinstance(op[2], AnnMeasure)
-                # color all the notes in the inserted score2 measure using Visualization.INSERTED_COLOR
-                measure2 = score2.recurse().getElementById(op[2].measure)
-                textExp = m21.expressions.TextExpression("inserted measure")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                measure2.insert(0, textExp)
-                measure2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in measure2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "delbar":
-                assert isinstance(op[1], AnnMeasure)
-                # color all the notes in the deleted score1 measure using Visualization.DELETED_COLOR
-                measure1 = score1.recurse().getElementById(op[1].measure)
-                textExp = m21.expressions.TextExpression("deleted measure")
-                textExp.style.color = Visualization.DELETED_COLOR
-                measure1.insert(0, textExp)
-                measure1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in measure1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # voices
-            elif op[0] == "voiceins":
-                assert isinstance(op[2], AnnVoice)
-                # color all the notes in the inserted score2 voice using Visualization.INSERTED_COLOR
-                voice2 = score2.recurse().getElementById(op[2].voice)
-                textExp = m21.expressions.TextExpression("inserted voice")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                voice2.insert(0, textExp)
-
-                voice2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in voice2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "voicedel":
-                assert isinstance(op[1], AnnVoice)
-                # color all the notes in the deleted score1 voice using Visualization.DELETED_COLOR
-                voice1 = score1.recurse().getElementById(op[1].voice)
-                textExp = m21.expressions.TextExpression("deleted voice")
-                textExp.style.color = Visualization.DELETED_COLOR
-                voice1.insert(0, textExp)
-
-                voice1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in voice1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # extra
-            elif op[0] == "extrains":
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.INSERTED_COLOR, and add a textExpression
-                # describing the insertion.
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if isinstance(extra2, m21.spanner.Spanner):
-                    insertionPoint = extra2.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra2.activeSite.insert(extra2.offset, textExp)
-
-            elif op[0] == "extradel":
-                assert isinstance(op[1], AnnExtra)
-                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
-                # describing the deletion.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint = extra1.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp)
-
-            elif op[0] == "extrasub":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                if extra1.classes[0] != extra2.classes[0]:
-                    textExp1 = m21.expressions.TextExpression(
-                                    f"changed to {extra2.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(
-                                    f"changed from {extra1.classes[0]}")
-                else:
-                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extracontentedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extraoffsetedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extradurationedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extrastyleedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                sd1 = op[1].styledict
-                sd2 = op[2].styledict
-                changedStr: str = ""
-                for k1, v1 in sd1.items():
-                    if k1 not in sd2 or sd2[k1] != v1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k1
-
-                # one last thing: check for keys in sd2 that aren't in sd1
-                for k2 in sd2:
-                    if k2 not in sd1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k2
-
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            # note
-            elif op[0] == "noteins":
-                assert isinstance(op[2], AnnNote)
-                # color the inserted score2 general note (note, chord, or rest) using Visualization.INSERTED_COLOR
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression(f"inserted {note2.classes[0]}")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "notedel":
-                assert isinstance(op[1], AnnNote)
-                # color the deleted score1 general note (note, chord, or rest) using Visualization.DELETED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.DELETED_COLOR
-                textExp = m21.expressions.TextExpression(f"deleted {note2.classes[0]}")
-                textExp.style.color = Visualization.DELETED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-            # pitch
-            elif op[0] == "pitchnameedit":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the changed note (in both scores) using Visualization.CHANGED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed pitch")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed pitch")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "inspitch":
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the inserted note in score2 using Visualization.INSERTED_COLOR
-                chord2 = score2.recurse().getElementById(op[2].general_note)
-                note2 = chord2
-                if "Chord" in note2.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][1]
-                    note2 = note2.notes[idx]
-                note2.style.color = Visualization.INSERTED_COLOR
-                if "Rest" in note2.classes:
-                    textExp = m21.expressions.TextExpression("inserted rest")
-                else:
-                    textExp = m21.expressions.TextExpression("inserted note")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note2.activeSite is not None:
-                    note2.activeSite.insert(note2.offset, textExp)
-                else:
-                    chord2.activeSite.insert(chord2.offset, textExp)
-
-            elif op[0] == "delpitch":
-                assert isinstance(op[1], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the deleted note in score1 using Visualization.DELETED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color just the indexed note in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                note1.style.color = Visualization.DELETED_COLOR
-                if "Rest" in note1.classes:
-                    textExp = m21.expressions.TextExpression("deleted rest")
-                else:
-                    textExp = m21.expressions.TextExpression("deleted note")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
-
-            elif op[0] == "headedit":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # beam
-            elif op[0] == "insbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the modified note in both scores using Visualization.INSERTED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.INSERTED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.INSERTED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("increased flags")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.INSERTED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.INSERTED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("increased flags")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "delbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the modified note in both scores using Visualization.DELETED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.DELETED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.DELETED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("decreased flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.DELETED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.DELETED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("decreased flags")
-                textExp.style.color = Visualization.DELETED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editbeam":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                if hasattr(note1, "beams"):
-                    for beam in note1.beams:
-                        beam.style.color = (
-                            Visualization.CHANGED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("changed flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                if hasattr(note2, "beams"):
-                    for beam in note2.beams:
-                        beam.style.color = (
-                            Visualization.CHANGED_COLOR
-                        )  # this apparently does nothing
-                textExp = m21.expressions.TextExpression("changed flags")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteshape":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note shape")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note shape")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteheadfill":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head fill")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head fill")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editnoteheadparenthesis":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head paren")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed note head paren")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editstemdirection":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed stem direction")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression("changed stem direction")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            elif op[0] == "editstyle":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                sd1 = op[1].styledict
-                sd2 = op[2].styledict
-                changedStr: str = ""
-                for k1, v1 in sd1.items():
-                    if k1 not in sd2 or sd2[k1] != v1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k1
-
-                # one last thing: check for keys in sd2 that aren't in sd1
-                for k2 in sd2:
-                    if k2 not in sd1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k2
-
-                note1 = score1.recurse().getElementById(op[1].general_note)
-                note1.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note1.activeSite.insert(note1.offset, textExp)
-
-                note2 = score2.recurse().getElementById(op[2].general_note)
-                note2.style.color = Visualization.CHANGED_COLOR
-                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
-                textExp.style.color = Visualization.CHANGED_COLOR
-                note2.activeSite.insert(note2.offset, textExp)
-
-            # accident
-            elif op[0] == "accidentins":
-                assert isinstance(op[1], AnnNote)
-                assert isinstance(op[2], AnnNote)
-                assert len(op) == 5  # the indices must be there
-                # color the modified note in both scores using Visualization.INSERTED_COLOR
-                chord1 = score1.recurse().getElementById(op[1].general_note)
-                note1 = chord1
-                if "Chord" in note1.classes:
-                    # color only the indexed note's accidental in the chord
-                    idx = op[4][0]
-                    note1 = note1.notes[idx]
-                if note1.pitch.accidental:
-                    note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR
-                note1.style.color = Visualization.INSERTED_COLOR
-                textExp = m21.expressions.TextExpression("inserted accidental")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if note1.activeSite is not None:
-                    note1.activeSite.insert(note1.offset, textExp)
-                else:
-                    chord1.activeSite.insert(chord1.offset, textExp)
+                            
+
+ + class + Visualization: - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color only the indexed note's accidental in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted accidental") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) + - elif op[0] == "accidentdel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the modified note in both scores using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color only the indexed note's accidental in the chord - idx = op[4][0] - note1 = note1.notes[idx] - if note1.pitch.accidental: - note1.pitch.accidental.style.color = Visualization.DELETED_COLOR - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted accidental") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color only the indexed note's accidental in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.DELETED_COLOR - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted accidental") - textExp.style.color = Visualization.DELETED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "accidentedit": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the changed accidental (in both scores) using Visualization.CHANGED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - if note1.pitch.accidental: - note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed accidental") - textExp.style.color = Visualization.CHANGED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed accidental") - textExp.style.color = Visualization.CHANGED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "dotins": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "dotdel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # tuplets - elif op[0] == "instuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "deltuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "edittuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # ties - elif op[0] == "tieins": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # Color the modified note here in both scores, using Visualization.INSERTED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted tie") - textExp.style.color = Visualization.INSERTED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted tie") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "tiedel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # Color the modified note in both scores, using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - # expressions - elif op[0] == "insexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the deleted expression in score1 using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # articulations - elif op[0] == "insarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - else: - print(f"Annotation type {op[0]} not yet supported for visualization", file=sys.stderr) - - @staticmethod - def show_diffs(score1: m21.stream.Score, - score2: m21.stream.Score, - out_path1: Union[str, Path] = None, - out_path2: Union[str, Path] = None): - """ - Render two (presumably marked-up) music21 scores. If both out_path1 and out_path2 are not None, - save the rendered PDFs at those two locations, otherwise just display them using the default - PDF viewer on the system. - - Args: - score1 (music21.stream.Score): The first score to render - score2 (music21.stream.Score): The second score to render - out_path1 (str, Path): Where to save the first marked-up rendered score PDF. - If out_path1 is None, both PDFs will be displayed in the default PDF viewer. - (default is None) - out_path2 (str, Path): Where to save the second marked-up rendered score PDF. - If out_path2 is None, both PDFs will be displayed in the default PDF viewer. - (default is None) - """ - # display the two (presumably annotated) scores - originalComposer1: str = None - originalComposer2: str = None - - if score1.metadata is None: - score1.metadata = m21.metadata.Metadata() - if score2.metadata is None: - score2.metadata = m21.metadata.Metadata() - - originalComposer1 = score1.metadata.composer - if originalComposer1 is None: - score1.metadata.composer = "score1" - else: - score1.metadata.composer = "score1 " + originalComposer1 - - originalComposer2 = score2.metadata.composer - if originalComposer2 is None: - score2.metadata.composer = "score2" - else: - score2.metadata.composer = "score2 " + originalComposer2 - - #save files if requested - if (out_path1 is not None) and (out_path2 is not None): - score1.write("musicxml.pdf", makeNotation=False, fp=out_path1) - score2.write("musicxml.pdf", makeNotation=False, fp=out_path2) - print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr) - else: # just display the scores - score1.show("musicxml.pdf", makeNotation=False) - score2.show("musicxml.pdf", makeNotation=False) -
+
+ +
  26class Visualization:
+  27    # These can be set by the client to different colors
+  28    INSERTED_COLOR = "red"
+  29    """
+  30    `INSERTED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  31    """
+  32    DELETED_COLOR = "red"
+  33    """
+  34    `DELETED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  35    """
+  36    CHANGED_COLOR = "red"
+  37    """
+  38    `CHANGED_COLOR` can be set to customize the rendered score markup that `mark_diffs` does.
+  39    """
+  40
+  41    @staticmethod
+  42    def mark_diffs(
+  43        score1: m21.stream.Score,
+  44        score2: m21.stream.Score,
+  45        operations: list[tuple]
+  46    ) -> None:
+  47        """
+  48        Mark up two music21 scores with the differences described by an operations
+  49        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
+  50
+  51        Args:
+  52            score1 (music21.stream.Score): The first score to mark up
+  53            score2 (music21.stream.Score): The second score to mark up
+  54            operations (list[tuple]): The operations list that describes the difference
+  55                between the two scores
+  56        """
+  57        changedStr: str
+  58        for op in operations:
+  59            # bar
+  60            if op[0] == "insbar":
+  61                assert isinstance(op[2], AnnMeasure)
+  62                # color all the notes in the inserted score2 measure
+  63                # using Visualization.INSERTED_COLOR
+  64                measure2 = score2.recurse().getElementById(op[2].measure)  # type: ignore
+  65                if t.TYPE_CHECKING:
+  66                    assert measure2 is not None
+  67                textExp = m21.expressions.TextExpression("inserted measure")
+  68                textExp.style.color = Visualization.INSERTED_COLOR
+  69                measure2.insert(0, textExp)
+  70                measure2.style.color = (
+  71                    Visualization.INSERTED_COLOR
+  72                )  # this apparently does nothing
+  73                for el in measure2.recurse().notesAndRests:
+  74                    el.style.color = Visualization.INSERTED_COLOR
+  75
+  76            elif op[0] == "delbar":
+  77                assert isinstance(op[1], AnnMeasure)
+  78                # color all the notes in the deleted score1 measure
+  79                # using Visualization.DELETED_COLOR
+  80                measure1 = score1.recurse().getElementById(op[1].measure)  # type: ignore
+  81                if t.TYPE_CHECKING:
+  82                    assert measure1 is not None
+  83                textExp = m21.expressions.TextExpression("deleted measure")
+  84                textExp.style.color = Visualization.DELETED_COLOR
+  85                measure1.insert(0, textExp)
+  86                measure1.style.color = (
+  87                    Visualization.DELETED_COLOR
+  88                )  # this apparently does nothing
+  89                for el in measure1.recurse().notesAndRests:
+  90                    el.style.color = Visualization.DELETED_COLOR
+  91
+  92            # voices
+  93            elif op[0] == "voiceins":
+  94                assert isinstance(op[2], AnnVoice)
+  95                # color all the notes in the inserted score2 voice
+  96                # using Visualization.INSERTED_COLOR
+  97                voice2 = score2.recurse().getElementById(op[2].voice)  # type: ignore
+  98                if t.TYPE_CHECKING:
+  99                    assert voice2 is not None
+ 100                textExp = m21.expressions.TextExpression("inserted voice")
+ 101                textExp.style.color = Visualization.INSERTED_COLOR
+ 102                voice2.insert(0, textExp)
+ 103
+ 104                voice2.style.color = (
+ 105                    Visualization.INSERTED_COLOR
+ 106                )  # this apparently does nothing
+ 107                for el in voice2.recurse().notesAndRests:
+ 108                    el.style.color = Visualization.INSERTED_COLOR
+ 109
+ 110            elif op[0] == "voicedel":
+ 111                assert isinstance(op[1], AnnVoice)
+ 112                # color all the notes in the deleted score1 voice
+ 113                # using Visualization.DELETED_COLOR
+ 114                voice1 = score1.recurse().getElementById(op[1].voice)  # type: ignore
+ 115                if t.TYPE_CHECKING:
+ 116                    assert voice1 is not None
+ 117                textExp = m21.expressions.TextExpression("deleted voice")
+ 118                textExp.style.color = Visualization.DELETED_COLOR
+ 119                voice1.insert(0, textExp)
+ 120
+ 121                voice1.style.color = (
+ 122                    Visualization.DELETED_COLOR
+ 123                )  # this apparently does nothing
+ 124                for el in voice1.recurse().notesAndRests:
+ 125                    el.style.color = Visualization.DELETED_COLOR
+ 126
+ 127            # extra
+ 128            elif op[0] == "extrains":
+ 129                assert isinstance(op[2], AnnExtra)
+ 130                # color the extra using Visualization.INSERTED_COLOR,
+ 131                # and add a textExpression describing the insertion.
+ 132                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 133                if t.TYPE_CHECKING:
+ 134                    assert extra2 is not None
+ 135                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
+ 136                textExp.style.color = Visualization.INSERTED_COLOR
+ 137                if isinstance(extra2, m21.spanner.Spanner):
+ 138                    insertionPoint = extra2.getFirst()
+ 139                    if isinstance(insertionPoint, m21.stream.Measure):
+ 140                        # insertionPoint is a measure, put the textExp at offset 0
+ 141                        # inside the measure
+ 142                        insertionPoint.insert(0, textExp)
+ 143                    else:
+ 144                        # insertionPoint is something else, put the textExp right next to it.
+ 145                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 146                else:
+ 147                    # extra2 is not a spanner, put the textExp right next to it
+ 148                    extra2.activeSite.insert(extra2.offset, textExp)
+ 149
+ 150            elif op[0] == "extradel":
+ 151                assert isinstance(op[1], AnnExtra)
+ 152                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
+ 153                # describing the deletion.
+ 154                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 155                if t.TYPE_CHECKING:
+ 156                    assert extra1 is not None
+ 157                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
+ 158                textExp.style.color = Visualization.DELETED_COLOR
+ 159                if isinstance(extra1, m21.spanner.Spanner):
+ 160                    insertionPoint = extra1.getFirst()
+ 161                    if isinstance(insertionPoint, m21.stream.Measure):
+ 162                        # insertionPoint is a measure, put the textExp at offset 0
+ 163                        # inside the measure
+ 164                        insertionPoint.insert(0, textExp)
+ 165                    else:
+ 166                        # insertionPoint is something else, put the textExp right next to it.
+ 167                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 168                else:
+ 169                    # extra1 is not a spanner, put the textExp right next to it
+ 170                    extra1.activeSite.insert(extra1.offset, textExp)
+ 171
+ 172            elif op[0] == "extrasub":
+ 173                assert isinstance(op[1], AnnExtra)
+ 174                assert isinstance(op[2], AnnExtra)
+ 175                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 176                # describing the change.
+ 177                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 178                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 179                if t.TYPE_CHECKING:
+ 180                    assert extra1 is not None
+ 181                    assert extra2 is not None
+ 182                if extra1.classes[0] != extra2.classes[0]:
+ 183                    textExp1 = m21.expressions.TextExpression(
+ 184                        f"changed to {extra2.classes[0]}"
+ 185                    )
+ 186                    textExp2 = m21.expressions.TextExpression(
+ 187                        f"changed from {extra1.classes[0]}"
+ 188                    )
+ 189                else:
+ 190                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 191                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 192                textExp1.style.color = Visualization.CHANGED_COLOR
+ 193                textExp2.style.color = Visualization.CHANGED_COLOR
+ 194                if isinstance(extra1, m21.spanner.Spanner):
+ 195                    insertionPoint1 = extra1.getFirst()
+ 196                    insertionPoint2 = extra2.getFirst()
+ 197                    if isinstance(insertionPoint1, m21.stream.Measure):
+ 198                        # insertionPoint1 is a measure, put the textExp at offset 0
+ 199                        # inside the measure
+ 200                        insertionPoint1.insert(0, textExp)
+ 201                    else:
+ 202                        # insertionPoint1 is something else, put the textExp right next to it.
+ 203                        insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp)
+ 204                    if isinstance(insertionPoint2, m21.stream.Measure):
+ 205                        # insertionPoint2 is a measure, put the textExp at offset 0
+ 206                        # inside the measure
+ 207                        insertionPoint2.insert(0, textExp)
+ 208                    else:
+ 209                        # insertionPoint2 is something else, put the textExp right next to it.
+ 210                        insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp)
+ 211                else:
+ 212                    # extra is not a spanner, put the textExp right next to it
+ 213                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 214                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 215
+ 216            elif op[0] == "extracontentedit":
+ 217                assert isinstance(op[1], AnnExtra)
+ 218                assert isinstance(op[2], AnnExtra)
+ 219                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 220                # describing the change.
+ 221                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 222                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 223                if t.TYPE_CHECKING:
+ 224                    assert extra1 is not None
+ 225                    assert extra2 is not None
+ 226                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 227                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 228                textExp1.style.color = Visualization.CHANGED_COLOR
+ 229                textExp2.style.color = Visualization.CHANGED_COLOR
+ 230                if isinstance(extra1, m21.spanner.Spanner):
+ 231                    insertionPoint1 = extra1.getFirst()
+ 232                    insertionPoint2 = extra2.getFirst()
+ 233                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 234                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 235                else:
+ 236                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 237                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 238
+ 239            elif op[0] == "extraoffsetedit":
+ 240                assert isinstance(op[1], AnnExtra)
+ 241                assert isinstance(op[2], AnnExtra)
+ 242                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 243                # describing the change.
+ 244                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 245                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 246                if t.TYPE_CHECKING:
+ 247                    assert extra1 is not None
+ 248                    assert extra2 is not None
+ 249                textExp1 = m21.expressions.TextExpression(
+ 250                    f"changed {extra1.classes[0]} offset")
+ 251                textExp2 = m21.expressions.TextExpression(
+ 252                    f"changed {extra1.classes[0]} offset")
+ 253                textExp1.style.color = Visualization.CHANGED_COLOR
+ 254                textExp2.style.color = Visualization.CHANGED_COLOR
+ 255                if isinstance(extra1, m21.spanner.Spanner):
+ 256                    insertionPoint1 = extra1.getFirst()
+ 257                    insertionPoint2 = extra2.getFirst()
+ 258                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 259                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 260                else:
+ 261                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 262                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 263
+ 264            elif op[0] == "extradurationedit":
+ 265                assert isinstance(op[1], AnnExtra)
+ 266                assert isinstance(op[2], AnnExtra)
+ 267                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 268                # describing the change.
+ 269                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 270                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 271                if t.TYPE_CHECKING:
+ 272                    assert extra1 is not None
+ 273                    assert extra2 is not None
+ 274                textExp1 = m21.expressions.TextExpression(
+ 275                    f"changed {extra1.classes[0]} duration")
+ 276                textExp2 = m21.expressions.TextExpression(
+ 277                    f"changed {extra1.classes[0]} duration")
+ 278                textExp1.style.color = Visualization.CHANGED_COLOR
+ 279                textExp2.style.color = Visualization.CHANGED_COLOR
+ 280                if isinstance(extra1, m21.spanner.Spanner):
+ 281                    insertionPoint1 = extra1.getFirst()
+ 282                    insertionPoint2 = extra2.getFirst()
+ 283                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 284                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 285                else:
+ 286                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 287                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 288
+ 289            elif op[0] == "extrastyleedit":
+ 290                assert isinstance(op[1], AnnExtra)
+ 291                assert isinstance(op[2], AnnExtra)
+ 292                sd1 = op[1].styledict
+ 293                sd2 = op[2].styledict
+ 294                changedStr = ""
+ 295                for k1, v1 in sd1.items():
+ 296                    if k1 not in sd2 or sd2[k1] != v1:
+ 297                        if changedStr:
+ 298                            changedStr += ","
+ 299                        changedStr += k1
+ 300
+ 301                # one last thing: check for keys in sd2 that aren't in sd1
+ 302                for k2 in sd2:
+ 303                    if k2 not in sd1:
+ 304                        if changedStr:
+ 305                            changedStr += ","
+ 306                        changedStr += k2
+ 307
+ 308                # color the extra using Visualization.CHANGED_COLOR,
+ 309                # and add a textExpression describing the change.
+ 310                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 311                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 312                if t.TYPE_CHECKING:
+ 313                    assert extra1 is not None
+ 314                    assert extra2 is not None
+ 315
+ 316                textExp1 = m21.expressions.TextExpression(
+ 317                    f"changed {extra1.classes[0]} {changedStr}")
+ 318                textExp2 = m21.expressions.TextExpression(
+ 319                    f"changed {extra1.classes[0]} {changedStr}")
+ 320                textExp1.style.color = Visualization.CHANGED_COLOR
+ 321                textExp2.style.color = Visualization.CHANGED_COLOR
+ 322                if isinstance(extra1, m21.spanner.Spanner):
+ 323                    insertionPoint1 = extra1.getFirst()
+ 324                    insertionPoint2 = extra2.getFirst()
+ 325                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 326                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 327                else:
+ 328                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 329                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 330
+ 331            # staff groups
+ 332            elif op[0] == "staffgrpins":
+ 333                assert isinstance(op[2], AnnStaffGroup)
+ 334                # add a textExpression describing the insertion.
+ 335                staffGroup2 = score2.recurse().getElementById(
+ 336                    op[2].staff_group  # type: ignore
+ 337                )
+ 338                if t.TYPE_CHECKING:
+ 339                    assert staffGroup2 is not None
+ 340                textExp = m21.expressions.TextExpression("inserted StaffGroup")
+ 341                textExp.style.color = Visualization.INSERTED_COLOR
+ 342                # insert text at offset 0 in first measure of first part in group
+ 343                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 344                insertionSite.insert(0, textExp)
+ 345
+ 346            elif op[0] == "staffgrpdel":
+ 347                assert isinstance(op[1], AnnStaffGroup)
+ 348                # add a textExpression describing the deletion.
+ 349                staffGroup1 = score1.recurse().getElementById(
+ 350                    op[1].staff_group  # type: ignore
+ 351                )
+ 352                if t.TYPE_CHECKING:
+ 353                    assert staffGroup1 is not None
+ 354                textExp = m21.expressions.TextExpression("deleted StaffGroup")
+ 355                textExp.style.color = Visualization.DELETED_COLOR
+ 356                # insert text at offset 0 in first measure of first part in group
+ 357                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 358                insertionSite.insert(0, textExp)
+ 359
+ 360            elif op[0] == "staffgrpsub":
+ 361                assert isinstance(op[1], AnnStaffGroup)
+ 362                assert isinstance(op[2], AnnStaffGroup)
+ 363                # add a textExpression describing the change.
+ 364                staffGroup1 = score1.recurse().getElementById(
+ 365                    op[1].staff_group  # type: ignore
+ 366                )
+ 367                staffGroup2 = score2.recurse().getElementById(
+ 368                    op[2].staff_group  # type: ignore
+ 369                )
+ 370                if t.TYPE_CHECKING:
+ 371                    assert staffGroup1 is not None
+ 372                    assert staffGroup2 is not None
+ 373                textExp1 = m21.expressions.TextExpression("changed StaffGroup")
+ 374                textExp2 = m21.expressions.TextExpression("changed StaffGroup")
+ 375                textExp1.style.color = Visualization.CHANGED_COLOR
+ 376                textExp2.style.color = Visualization.CHANGED_COLOR
+ 377                # insert text at offset 0 in first measure of first part in group
+ 378                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 379                insertionSite.insert(0, textExp1)
+ 380                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 381                insertionSite.insert(0, textExp2)
+ 382
+ 383            elif op[0] == "staffgrpnameedit":
+ 384                assert isinstance(op[1], AnnStaffGroup)
+ 385                assert isinstance(op[2], AnnStaffGroup)
+ 386                # add a textExpression describing the change.
+ 387                staffGroup1 = score1.recurse().getElementById(
+ 388                    op[1].staff_group  # type: ignore
+ 389                )
+ 390                staffGroup2 = score2.recurse().getElementById(
+ 391                    op[2].staff_group  # type: ignore
+ 392                )
+ 393                if t.TYPE_CHECKING:
+ 394                    assert staffGroup1 is not None
+ 395                    assert staffGroup2 is not None
+ 396                textExp1 = m21.expressions.TextExpression("changed StaffGroup name")
+ 397                textExp2 = m21.expressions.TextExpression("changed StaffGroup name")
+ 398                textExp1.style.color = Visualization.CHANGED_COLOR
+ 399                textExp2.style.color = Visualization.CHANGED_COLOR
+ 400                # insert text at offset 0 in first measure of first part in group
+ 401                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 402                insertionSite.insert(0, textExp1)
+ 403                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 404                insertionSite.insert(0, textExp2)
+ 405
+ 406            elif op[0] == "staffgrpabbreviationedit":
+ 407                assert isinstance(op[1], AnnStaffGroup)
+ 408                assert isinstance(op[2], AnnStaffGroup)
+ 409                # add a textExpression describing the change.
+ 410                staffGroup1 = score1.recurse().getElementById(
+ 411                    op[1].staff_group  # type: ignore
+ 412                )
+ 413                staffGroup2 = score2.recurse().getElementById(
+ 414                    op[2].staff_group  # type: ignore
+ 415                )
+ 416                if t.TYPE_CHECKING:
+ 417                    assert staffGroup1 is not None
+ 418                    assert staffGroup2 is not None
+ 419                textExp1 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 420                textExp2 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 421                textExp1.style.color = Visualization.CHANGED_COLOR
+ 422                textExp2.style.color = Visualization.CHANGED_COLOR
+ 423                # insert text at offset 0 in first measure of first part in group
+ 424                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 425                insertionSite.insert(0, textExp1)
+ 426                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 427                insertionSite.insert(0, textExp2)
+ 428
+ 429            elif op[0] == "staffgrpsymboledit":
+ 430                assert isinstance(op[1], AnnStaffGroup)
+ 431                assert isinstance(op[2], AnnStaffGroup)
+ 432                # add a textExpression describing the change.
+ 433                staffGroup1 = score1.recurse().getElementById(
+ 434                    op[1].staff_group  # type: ignore
+ 435                )
+ 436                staffGroup2 = score2.recurse().getElementById(
+ 437                    op[2].staff_group  # type: ignore
+ 438                )
+ 439                if t.TYPE_CHECKING:
+ 440                    assert staffGroup1 is not None
+ 441                    assert staffGroup2 is not None
+ 442                textExp1 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 443                textExp2 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 444                textExp1.style.color = Visualization.CHANGED_COLOR
+ 445                textExp2.style.color = Visualization.CHANGED_COLOR
+ 446                # insert text at offset 0 in first measure of first part in group
+ 447                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 448                insertionSite.insert(0, textExp1)
+ 449                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 450                insertionSite.insert(0, textExp2)
+ 451
+ 452            elif op[0] == "staffgrpbartogetheredit":
+ 453                assert isinstance(op[1], AnnStaffGroup)
+ 454                assert isinstance(op[2], AnnStaffGroup)
+ 455                # add a textExpression describing the change.
+ 456                staffGroup1 = score1.recurse().getElementById(
+ 457                    op[1].staff_group  # type: ignore
+ 458                )
+ 459                staffGroup2 = score2.recurse().getElementById(
+ 460                    op[2].staff_group  # type: ignore
+ 461                )
+ 462                if t.TYPE_CHECKING:
+ 463                    assert staffGroup1 is not None
+ 464                    assert staffGroup2 is not None
+ 465                textExp1 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 466                textExp2 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 467                textExp1.style.color = Visualization.CHANGED_COLOR
+ 468                textExp2.style.color = Visualization.CHANGED_COLOR
+ 469                # insert text at offset 0 in first measure of first part in group
+ 470                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 471                insertionSite.insert(0, textExp1)
+ 472                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 473                insertionSite.insert(0, textExp2)
+ 474
+ 475            elif op[0] == "staffgrppartindicesedit":
+ 476                assert isinstance(op[1], AnnStaffGroup)
+ 477                assert isinstance(op[2], AnnStaffGroup)
+ 478                # add a textExpression describing the change.
+ 479                staffGroup1 = score1.recurse().getElementById(
+ 480                    op[1].staff_group  # type: ignore
+ 481                )
+ 482                staffGroup2 = score2.recurse().getElementById(
+ 483                    op[2].staff_group  # type: ignore
+ 484                )
+ 485                if t.TYPE_CHECKING:
+ 486                    assert staffGroup1 is not None
+ 487                    assert staffGroup2 is not None
+ 488                textExp1 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 489                textExp2 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 490                textExp1.style.color = Visualization.CHANGED_COLOR
+ 491                textExp2.style.color = Visualization.CHANGED_COLOR
+ 492                # insert text at offset 0 in first measure of first part in group
+ 493                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 494                insertionSite.insert(0, textExp1)
+ 495                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 496                insertionSite.insert(0, textExp2)
+ 497
+ 498            # note
+ 499            elif op[0] == "noteins":
+ 500                assert isinstance(op[2], AnnNote)
+ 501                # color the inserted score2 general note (note, chord, or rest)
+ 502                # using Visualization.INSERTED_COLOR
+ 503                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 504                if t.TYPE_CHECKING:
+ 505                    assert note2 is not None
+ 506                note2.style.color = Visualization.INSERTED_COLOR
+ 507                textExp = m21.expressions.TextExpression(
+ 508                    f"inserted {note2.classes[0]}")
+ 509                textExp.style.color = Visualization.INSERTED_COLOR
+ 510                note2.activeSite.insert(note2.offset, textExp)
+ 511
+ 512            elif op[0] == "notedel":
+ 513                assert isinstance(op[1], AnnNote)
+ 514                # color the deleted score1 general note (note, chord, or rest)
+ 515                # using Visualization.DELETED_COLOR
+ 516                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 517                if t.TYPE_CHECKING:
+ 518                    assert note1 is not None
+ 519                note1.style.color = Visualization.DELETED_COLOR
+ 520                textExp = m21.expressions.TextExpression(f"deleted {note1.classes[0]}")
+ 521                textExp.style.color = Visualization.DELETED_COLOR
+ 522                note1.activeSite.insert(note1.offset, textExp)
+ 523
+ 524            # pitch
+ 525            elif op[0] == "pitchnameedit":
+ 526                assert isinstance(op[1], AnnNote)
+ 527                assert isinstance(op[2], AnnNote)
+ 528                assert len(op) == 5  # the indices must be there
+ 529                # color the changed note (in both scores) using Visualization.CHANGED_COLOR
+ 530                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 531                if t.TYPE_CHECKING:
+ 532                    assert chord1 is not None
+ 533                note1 = chord1
+ 534                if "Chord" in chord1.classes:
+ 535                    # color just the indexed note in the chord
+ 536                    idx = op[4][0]
+ 537                    note1 = chord1.notes[idx]
+ 538                if t.TYPE_CHECKING:
+ 539                    assert note1 is not None
+ 540                note1.style.color = Visualization.CHANGED_COLOR
+ 541                textExp = m21.expressions.TextExpression("changed pitch")
+ 542                textExp.style.color = Visualization.CHANGED_COLOR
+ 543                if note1.activeSite is not None:
+ 544                    note1.activeSite.insert(note1.offset, textExp)
+ 545                else:
+ 546                    chord1.activeSite.insert(chord1.offset, textExp)
+ 547
+ 548                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 549                if t.TYPE_CHECKING:
+ 550                    assert chord2 is not None
+ 551                note2 = chord2
+ 552                if "Chord" in chord2.classes:
+ 553                    # color just the indexed note in the chord
+ 554                    idx = op[4][1]
+ 555                    note2 = chord2.notes[idx]
+ 556                if t.TYPE_CHECKING:
+ 557                    assert note2 is not None
+ 558                note2.style.color = Visualization.CHANGED_COLOR
+ 559                textExp = m21.expressions.TextExpression("changed pitch")
+ 560                textExp.style.color = Visualization.CHANGED_COLOR
+ 561                if note2.activeSite is not None:
+ 562                    note2.activeSite.insert(note2.offset, textExp)
+ 563                else:
+ 564                    chord2.activeSite.insert(chord2.offset, textExp)
+ 565
+ 566            elif op[0] == "inspitch":
+ 567                assert isinstance(op[2], AnnNote)
+ 568                assert len(op) == 5  # the indices must be there
+ 569                # color the inserted note in score2 using Visualization.INSERTED_COLOR
+ 570                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 571                if t.TYPE_CHECKING:
+ 572                    assert chord2 is not None
+ 573                note2 = chord2
+ 574                if "Chord" in chord2.classes:
+ 575                    # color just the indexed note in the chord
+ 576                    idx = op[4][1]
+ 577                    note2 = chord2.notes[idx]
+ 578                if t.TYPE_CHECKING:
+ 579                    assert note2 is not None
+ 580                note2.style.color = Visualization.INSERTED_COLOR
+ 581                if "Rest" in note2.classes:
+ 582                    textExp = m21.expressions.TextExpression("inserted rest")
+ 583                else:
+ 584                    textExp = m21.expressions.TextExpression("inserted note")
+ 585                textExp.style.color = Visualization.INSERTED_COLOR
+ 586                if note2.activeSite is not None:
+ 587                    note2.activeSite.insert(note2.offset, textExp)
+ 588                else:
+ 589                    chord2.activeSite.insert(chord2.offset, textExp)
+ 590
+ 591            elif op[0] == "delpitch":
+ 592                assert isinstance(op[1], AnnNote)
+ 593                assert len(op) == 5  # the indices must be there
+ 594                # color the deleted note in score1 using Visualization.DELETED_COLOR
+ 595                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 596                if t.TYPE_CHECKING:
+ 597                    assert chord1 is not None
+ 598                note1 = chord1
+ 599                if "Chord" in chord1.classes:
+ 600                    # color just the indexed note in the chord
+ 601                    idx = op[4][0]
+ 602                    note1 = chord1.notes[idx]
+ 603                if t.TYPE_CHECKING:
+ 604                    assert note1 is not None
+ 605                note1.style.color = Visualization.DELETED_COLOR
+ 606                if "Rest" in note1.classes:
+ 607                    textExp = m21.expressions.TextExpression("deleted rest")
+ 608                else:
+ 609                    textExp = m21.expressions.TextExpression("deleted note")
+ 610                textExp.style.color = Visualization.DELETED_COLOR
+ 611                if note1.activeSite is not None:
+ 612                    note1.activeSite.insert(note1.offset, textExp)
+ 613                else:
+ 614                    chord1.activeSite.insert(chord1.offset, textExp)
+ 615
+ 616            elif op[0] == "headedit":
+ 617                assert isinstance(op[1], AnnNote)
+ 618                assert isinstance(op[2], AnnNote)
+ 619                # color the changed note/rest/chord (in both scores)
+ 620                # using Visualization.CHANGED_COLOR
+ 621                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 622                if t.TYPE_CHECKING:
+ 623                    assert note1 is not None
+ 624                note1.style.color = Visualization.CHANGED_COLOR
+ 625                textExp = m21.expressions.TextExpression("changed note head")
+ 626                textExp.style.color = Visualization.CHANGED_COLOR
+ 627                note1.activeSite.insert(note1.offset, textExp)
+ 628
+ 629                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 630                if t.TYPE_CHECKING:
+ 631                    assert note2 is not None
+ 632                note2.style.color = Visualization.CHANGED_COLOR
+ 633                textExp = m21.expressions.TextExpression("changed note head")
+ 634                textExp.style.color = Visualization.CHANGED_COLOR
+ 635                note2.activeSite.insert(note2.offset, textExp)
+ 636
+ 637            elif op[0] == "graceedit":
+ 638                assert isinstance(op[1], AnnNote)
+ 639                assert isinstance(op[2], AnnNote)
+ 640                # color the changed note/rest/chord (in both scores)
+ 641                # using Visualization.CHANGED_COLOR
+ 642                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 643                if t.TYPE_CHECKING:
+ 644                    assert note1 is not None
+ 645                note1.style.color = Visualization.CHANGED_COLOR
+ 646                textExp = m21.expressions.TextExpression("changed grace note")
+ 647                textExp.style.color = Visualization.CHANGED_COLOR
+ 648                note1.activeSite.insert(note1.offset, textExp)
+ 649
+ 650                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 651                if t.TYPE_CHECKING:
+ 652                    assert note2 is not None
+ 653                note2.style.color = Visualization.CHANGED_COLOR
+ 654                textExp = m21.expressions.TextExpression("changed grace note")
+ 655                textExp.style.color = Visualization.CHANGED_COLOR
+ 656                note2.activeSite.insert(note2.offset, textExp)
+ 657
+ 658            elif op[0] == "graceslashedit":
+ 659                assert isinstance(op[1], AnnNote)
+ 660                assert isinstance(op[2], AnnNote)
+ 661                # color the changed note/rest/chord (in both scores)
+ 662                # using Visualization.CHANGED_COLOR
+ 663                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 664                if t.TYPE_CHECKING:
+ 665                    assert note1 is not None
+ 666                note1.style.color = Visualization.CHANGED_COLOR
+ 667                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 668                textExp.style.color = Visualization.CHANGED_COLOR
+ 669                note1.activeSite.insert(note1.offset, textExp)
+ 670
+ 671                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 672                if t.TYPE_CHECKING:
+ 673                    assert note2 is not None
+ 674                note2.style.color = Visualization.CHANGED_COLOR
+ 675                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 676                textExp.style.color = Visualization.CHANGED_COLOR
+ 677                note2.activeSite.insert(note2.offset, textExp)
+ 678
+ 679            # beam
+ 680            elif op[0] == "insbeam":
+ 681                assert isinstance(op[1], AnnNote)
+ 682                assert isinstance(op[2], AnnNote)
+ 683                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 684                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 685                if t.TYPE_CHECKING:
+ 686                    assert note1 is not None
+ 687                note1.style.color = Visualization.INSERTED_COLOR
+ 688                if hasattr(note1, "beams"):
+ 689                    for beam in note1.beams:
+ 690                        beam.style.color = (
+ 691                            Visualization.INSERTED_COLOR
+ 692                        )  # this apparently does nothing
+ 693                textExp = m21.expressions.TextExpression("increased flags")
+ 694                textExp.style.color = Visualization.INSERTED_COLOR
+ 695                note1.activeSite.insert(note1.offset, textExp)
+ 696
+ 697                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 698                if t.TYPE_CHECKING:
+ 699                    assert note2 is not None
+ 700                note2.style.color = Visualization.INSERTED_COLOR
+ 701                if hasattr(note2, "beams"):
+ 702                    for beam in note2.beams:
+ 703                        beam.style.color = (
+ 704                            Visualization.INSERTED_COLOR
+ 705                        )  # this apparently does nothing
+ 706                textExp = m21.expressions.TextExpression("increased flags")
+ 707                textExp.style.color = Visualization.INSERTED_COLOR
+ 708                note2.activeSite.insert(note2.offset, textExp)
+ 709
+ 710            elif op[0] == "delbeam":
+ 711                assert isinstance(op[1], AnnNote)
+ 712                assert isinstance(op[2], AnnNote)
+ 713                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 714                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 715                if t.TYPE_CHECKING:
+ 716                    assert note1 is not None
+ 717                note1.style.color = Visualization.DELETED_COLOR
+ 718                if hasattr(note1, "beams"):
+ 719                    for beam in note1.beams:
+ 720                        beam.style.color = (
+ 721                            Visualization.DELETED_COLOR
+ 722                        )  # this apparently does nothing
+ 723                textExp = m21.expressions.TextExpression("decreased flags")
+ 724                textExp.style.color = Visualization.CHANGED_COLOR
+ 725                note1.activeSite.insert(note1.offset, textExp)
+ 726
+ 727                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 728                if t.TYPE_CHECKING:
+ 729                    assert note2 is not None
+ 730                note2.style.color = Visualization.DELETED_COLOR
+ 731                if hasattr(note2, "beams"):
+ 732                    for beam in note2.beams:
+ 733                        beam.style.color = (
+ 734                            Visualization.DELETED_COLOR
+ 735                        )  # this apparently does nothing
+ 736                textExp = m21.expressions.TextExpression("decreased flags")
+ 737                textExp.style.color = Visualization.DELETED_COLOR
+ 738                note2.activeSite.insert(note2.offset, textExp)
+ 739
+ 740            elif op[0] == "editbeam":
+ 741                assert isinstance(op[1], AnnNote)
+ 742                assert isinstance(op[2], AnnNote)
+ 743                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+ 744                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 745                if t.TYPE_CHECKING:
+ 746                    assert note1 is not None
+ 747                note1.style.color = Visualization.CHANGED_COLOR
+ 748                if hasattr(note1, "beams"):
+ 749                    for beam in note1.beams:
+ 750                        beam.style.color = (
+ 751                            Visualization.CHANGED_COLOR
+ 752                        )  # this apparently does nothing
+ 753                textExp = m21.expressions.TextExpression("changed flags")
+ 754                textExp.style.color = Visualization.CHANGED_COLOR
+ 755                note1.activeSite.insert(note1.offset, textExp)
+ 756
+ 757                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 758                if t.TYPE_CHECKING:
+ 759                    assert note2 is not None
+ 760                note2.style.color = Visualization.CHANGED_COLOR
+ 761                if hasattr(note2, "beams"):
+ 762                    for beam in note2.beams:
+ 763                        beam.style.color = (
+ 764                            Visualization.CHANGED_COLOR
+ 765                        )  # this apparently does nothing
+ 766                textExp = m21.expressions.TextExpression("changed flags")
+ 767                textExp.style.color = Visualization.CHANGED_COLOR
+ 768                note2.activeSite.insert(note2.offset, textExp)
+ 769
+ 770            elif op[0] == "editnoteshape":
+ 771                assert isinstance(op[1], AnnNote)
+ 772                assert isinstance(op[2], AnnNote)
+ 773                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 774                if t.TYPE_CHECKING:
+ 775                    assert note1 is not None
+ 776                note1.style.color = Visualization.CHANGED_COLOR
+ 777                textExp = m21.expressions.TextExpression("changed note shape")
+ 778                textExp.style.color = Visualization.CHANGED_COLOR
+ 779                note1.activeSite.insert(note1.offset, textExp)
+ 780
+ 781                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 782                if t.TYPE_CHECKING:
+ 783                    assert note2 is not None
+ 784                note2.style.color = Visualization.CHANGED_COLOR
+ 785                textExp = m21.expressions.TextExpression("changed note shape")
+ 786                textExp.style.color = Visualization.CHANGED_COLOR
+ 787                note2.activeSite.insert(note2.offset, textExp)
+ 788
+ 789            elif op[0] == "editnoteheadfill":
+ 790                assert isinstance(op[1], AnnNote)
+ 791                assert isinstance(op[2], AnnNote)
+ 792                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 793                if t.TYPE_CHECKING:
+ 794                    assert note1 is not None
+ 795                note1.style.color = Visualization.CHANGED_COLOR
+ 796                textExp = m21.expressions.TextExpression("changed note head fill")
+ 797                textExp.style.color = Visualization.CHANGED_COLOR
+ 798                note1.activeSite.insert(note1.offset, textExp)
+ 799
+ 800                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 801                if t.TYPE_CHECKING:
+ 802                    assert note2 is not None
+ 803                note2.style.color = Visualization.CHANGED_COLOR
+ 804                textExp = m21.expressions.TextExpression("changed note head fill")
+ 805                textExp.style.color = Visualization.CHANGED_COLOR
+ 806                note2.activeSite.insert(note2.offset, textExp)
+ 807
+ 808            elif op[0] == "editnoteheadparenthesis":
+ 809                assert isinstance(op[1], AnnNote)
+ 810                assert isinstance(op[2], AnnNote)
+ 811                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 812                if t.TYPE_CHECKING:
+ 813                    assert note1 is not None
+ 814                note1.style.color = Visualization.CHANGED_COLOR
+ 815                textExp = m21.expressions.TextExpression("changed note head paren")
+ 816                textExp.style.color = Visualization.CHANGED_COLOR
+ 817                note1.activeSite.insert(note1.offset, textExp)
+ 818
+ 819                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 820                if t.TYPE_CHECKING:
+ 821                    assert note2 is not None
+ 822                note2.style.color = Visualization.CHANGED_COLOR
+ 823                textExp = m21.expressions.TextExpression("changed note head paren")
+ 824                textExp.style.color = Visualization.CHANGED_COLOR
+ 825                note2.activeSite.insert(note2.offset, textExp)
+ 826
+ 827            elif op[0] == "editstemdirection":
+ 828                assert isinstance(op[1], AnnNote)
+ 829                assert isinstance(op[2], AnnNote)
+ 830                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 831                if t.TYPE_CHECKING:
+ 832                    assert note1 is not None
+ 833                note1.style.color = Visualization.CHANGED_COLOR
+ 834                textExp = m21.expressions.TextExpression("changed stem direction")
+ 835                textExp.style.color = Visualization.CHANGED_COLOR
+ 836                note1.activeSite.insert(note1.offset, textExp)
+ 837
+ 838                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 839                if t.TYPE_CHECKING:
+ 840                    assert note2 is not None
+ 841                note2.style.color = Visualization.CHANGED_COLOR
+ 842                textExp = m21.expressions.TextExpression("changed stem direction")
+ 843                textExp.style.color = Visualization.CHANGED_COLOR
+ 844                note2.activeSite.insert(note2.offset, textExp)
+ 845
+ 846            elif op[0] == "editstyle":
+ 847                assert isinstance(op[1], AnnNote)
+ 848                assert isinstance(op[2], AnnNote)
+ 849                sd1 = op[1].styledict
+ 850                sd2 = op[2].styledict
+ 851                changedStr = ""
+ 852                for k1, v1 in sd1.items():
+ 853                    if k1 not in sd2 or sd2[k1] != v1:
+ 854                        if changedStr:
+ 855                            changedStr += ","
+ 856                        changedStr += k1
+ 857
+ 858                # one last thing: check for keys in sd2 that aren't in sd1
+ 859                for k2 in sd2:
+ 860                    if k2 not in sd1:
+ 861                        if changedStr:
+ 862                            changedStr += ","
+ 863                        changedStr += k2
+ 864
+ 865                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 866                if t.TYPE_CHECKING:
+ 867                    assert note1 is not None
+ 868                note1.style.color = Visualization.CHANGED_COLOR
+ 869                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 870                textExp.style.color = Visualization.CHANGED_COLOR
+ 871                note1.activeSite.insert(note1.offset, textExp)
+ 872
+ 873                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 874                if t.TYPE_CHECKING:
+ 875                    assert note2 is not None
+ 876                note2.style.color = Visualization.CHANGED_COLOR
+ 877                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 878                textExp.style.color = Visualization.CHANGED_COLOR
+ 879                note2.activeSite.insert(note2.offset, textExp)
+ 880
+ 881            # accident
+ 882            elif op[0] == "accidentins":
+ 883                assert isinstance(op[1], AnnNote)
+ 884                assert isinstance(op[2], AnnNote)
+ 885                assert len(op) == 5  # the indices must be there
+ 886                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 887                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 888                if t.TYPE_CHECKING:
+ 889                    assert chord1 is not None
+ 890                note1 = chord1
+ 891                if "Chord" in chord1.classes:
+ 892                    # color only the indexed note's accidental in the chord
+ 893                    idx = op[4][0]
+ 894                    note1 = chord1.notes[idx]
+ 895                if t.TYPE_CHECKING:
+ 896                    assert note1 is not None
+ 897                if note1.pitch.accidental:
+ 898                    note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 899                note1.style.color = Visualization.INSERTED_COLOR
+ 900                textExp = m21.expressions.TextExpression("inserted accidental")
+ 901                textExp.style.color = Visualization.INSERTED_COLOR
+ 902                if note1.activeSite is not None:
+ 903                    note1.activeSite.insert(note1.offset, textExp)
+ 904                else:
+ 905                    chord1.activeSite.insert(chord1.offset, textExp)
+ 906
+ 907                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 908                if t.TYPE_CHECKING:
+ 909                    assert chord2 is not None
+ 910                note2 = chord2
+ 911                if "Chord" in chord2.classes:
+ 912                    # color only the indexed note's accidental in the chord
+ 913                    idx = op[4][1]
+ 914                    note2 = chord2.notes[idx]
+ 915                if t.TYPE_CHECKING:
+ 916                    assert note2 is not None
+ 917                if note2.pitch.accidental:
+ 918                    note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 919                note2.style.color = Visualization.INSERTED_COLOR
+ 920                textExp = m21.expressions.TextExpression("inserted accidental")
+ 921                textExp.style.color = Visualization.INSERTED_COLOR
+ 922                if note2.activeSite is not None:
+ 923                    note2.activeSite.insert(note2.offset, textExp)
+ 924                else:
+ 925                    chord2.activeSite.insert(chord2.offset, textExp)
+ 926
+ 927            elif op[0] == "accidentdel":
+ 928                assert isinstance(op[1], AnnNote)
+ 929                assert isinstance(op[2], AnnNote)
+ 930                assert len(op) == 5  # the indices must be there
+ 931                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 932                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 933                if t.TYPE_CHECKING:
+ 934                    assert chord1 is not None
+ 935                note1 = chord1
+ 936                if "Chord" in chord1.classes:
+ 937                    # color only the indexed note's accidental in the chord
+ 938                    idx = op[4][0]
+ 939                    note1 = chord1.notes[idx]
+ 940                if t.TYPE_CHECKING:
+ 941                    assert note1 is not None
+ 942                if note1.pitch.accidental:
+ 943                    note1.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 944                note1.style.color = Visualization.DELETED_COLOR
+ 945                textExp = m21.expressions.TextExpression("deleted accidental")
+ 946                textExp.style.color = Visualization.DELETED_COLOR
+ 947                if note1.activeSite is not None:
+ 948                    note1.activeSite.insert(note1.offset, textExp)
+ 949                else:
+ 950                    chord1.activeSite.insert(chord1.offset, textExp)
+ 951
+ 952                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 953                if t.TYPE_CHECKING:
+ 954                    assert chord2 is not None
+ 955                note2 = chord2
+ 956                if "Chord" in chord2.classes:
+ 957                    # color only the indexed note's accidental in the chord
+ 958                    idx = op[4][1]
+ 959                    note2 = chord2.notes[idx]
+ 960                if t.TYPE_CHECKING:
+ 961                    assert note2 is not None
+ 962                if note2.pitch.accidental:
+ 963                    note2.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 964                note2.style.color = Visualization.DELETED_COLOR
+ 965                textExp = m21.expressions.TextExpression("deleted accidental")
+ 966                textExp.style.color = Visualization.DELETED_COLOR
+ 967                if note2.activeSite is not None:
+ 968                    note2.activeSite.insert(note2.offset, textExp)
+ 969                else:
+ 970                    chord2.activeSite.insert(chord2.offset, textExp)
+ 971
+ 972            elif op[0] == "accidentedit":
+ 973                assert isinstance(op[1], AnnNote)
+ 974                assert isinstance(op[2], AnnNote)
+ 975                assert len(op) == 5  # the indices must be there
+ 976                # color the changed accidental (in both scores)
+ 977                # using Visualization.CHANGED_COLOR
+ 978                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 979                if t.TYPE_CHECKING:
+ 980                    assert chord1 is not None
+ 981                note1 = chord1
+ 982                if "Chord" in chord1.classes:
+ 983                    # color just the indexed note in the chord
+ 984                    idx = op[4][0]
+ 985                    note1 = chord1.notes[idx]
+ 986                if t.TYPE_CHECKING:
+ 987                    assert note1 is not None
+ 988                if note1.pitch.accidental:
+ 989                    note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+ 990                note1.style.color = Visualization.CHANGED_COLOR
+ 991                textExp = m21.expressions.TextExpression("changed accidental")
+ 992                textExp.style.color = Visualization.CHANGED_COLOR
+ 993                if note1.activeSite is not None:
+ 994                    note1.activeSite.insert(note1.offset, textExp)
+ 995                else:
+ 996                    chord1.activeSite.insert(chord1.offset, textExp)
+ 997
+ 998                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 999                if t.TYPE_CHECKING:
+1000                    assert chord2 is not None
+1001                note2 = chord2
+1002                if "Chord" in chord2.classes:
+1003                    # color just the indexed note in the chord
+1004                    idx = op[4][1]
+1005                    note2 = chord2.notes[idx]
+1006                if t.TYPE_CHECKING:
+1007                    assert note2 is not None
+1008                if note2.pitch.accidental:
+1009                    note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+1010                note2.style.color = Visualization.CHANGED_COLOR
+1011                textExp = m21.expressions.TextExpression("changed accidental")
+1012                textExp.style.color = Visualization.CHANGED_COLOR
+1013                if note2.activeSite is not None:
+1014                    note2.activeSite.insert(note2.offset, textExp)
+1015                else:
+1016                    chord2.activeSite.insert(chord2.offset, textExp)
+1017
+1018            elif op[0] == "dotins":
+1019                assert isinstance(op[1], AnnNote)
+1020                assert isinstance(op[2], AnnNote)
+1021                # In music21, the dots are not separately colorable from the note,
+1022                # so we will just color the modified note here in both scores,
+1023                # using Visualization.CHANGED_COLOR
+1024                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1025                if t.TYPE_CHECKING:
+1026                    assert note1 is not None
+1027                note1.style.color = Visualization.CHANGED_COLOR
+1028                textExp = m21.expressions.TextExpression("inserted dot")
+1029                textExp.style.color = Visualization.CHANGED_COLOR
+1030                note1.activeSite.insert(note1.offset, textExp)
+1031
+1032                if t.TYPE_CHECKING:
+1033                    assert note2 is not None
+1034                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1035                note2.style.color = Visualization.CHANGED_COLOR
+1036                textExp = m21.expressions.TextExpression("inserted dot")
+1037                textExp.style.color = Visualization.CHANGED_COLOR
+1038                note2.activeSite.insert(note2.offset, textExp)
+1039
+1040            elif op[0] == "dotdel":
+1041                assert isinstance(op[1], AnnNote)
+1042                assert isinstance(op[2], AnnNote)
+1043                # In music21, the dots are not separately colorable from the note,
+1044                # so we will just color the modified note here in both scores,
+1045                # using Visualization.CHANGED_COLOR
+1046                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1047                if t.TYPE_CHECKING:
+1048                    assert note1 is not None
+1049                note1.style.color = Visualization.CHANGED_COLOR
+1050                textExp = m21.expressions.TextExpression("deleted dot")
+1051                textExp.style.color = Visualization.CHANGED_COLOR
+1052                note1.activeSite.insert(note1.offset, textExp)
+1053
+1054                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1055                if t.TYPE_CHECKING:
+1056                    assert note2 is not None
+1057                note2.style.color = Visualization.CHANGED_COLOR
+1058                textExp = m21.expressions.TextExpression("deleted dot")
+1059                textExp.style.color = Visualization.CHANGED_COLOR
+1060                note2.activeSite.insert(note2.offset, textExp)
+1061
+1062            # tuplets
+1063            elif op[0] == "instuplet":
+1064                assert isinstance(op[1], AnnNote)
+1065                assert isinstance(op[2], AnnNote)
+1066                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1067                if t.TYPE_CHECKING:
+1068                    assert note1 is not None
+1069                note1.style.color = Visualization.CHANGED_COLOR
+1070                textExp = m21.expressions.TextExpression("inserted tuplet")
+1071                textExp.style.color = Visualization.CHANGED_COLOR
+1072                note1.activeSite.insert(note1.offset, textExp)
+1073
+1074                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1075                if t.TYPE_CHECKING:
+1076                    assert note2 is not None
+1077                note2.style.color = Visualization.CHANGED_COLOR
+1078                textExp = m21.expressions.TextExpression("inserted tuplet")
+1079                textExp.style.color = Visualization.CHANGED_COLOR
+1080                note2.activeSite.insert(note2.offset, textExp)
+1081
+1082            elif op[0] == "deltuplet":
+1083                assert isinstance(op[1], AnnNote)
+1084                assert isinstance(op[2], AnnNote)
+1085                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1086                if t.TYPE_CHECKING:
+1087                    assert note1 is not None
+1088                note1.style.color = Visualization.CHANGED_COLOR
+1089                textExp = m21.expressions.TextExpression("deleted tuplet")
+1090                textExp.style.color = Visualization.CHANGED_COLOR
+1091                note1.activeSite.insert(note1.offset, textExp)
+1092
+1093                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1094                if t.TYPE_CHECKING:
+1095                    assert note2 is not None
+1096                note2.style.color = Visualization.CHANGED_COLOR
+1097                textExp = m21.expressions.TextExpression("deleted tuplet")
+1098                textExp.style.color = Visualization.CHANGED_COLOR
+1099                note2.activeSite.insert(note2.offset, textExp)
+1100
+1101            elif op[0] == "edittuplet":
+1102                assert isinstance(op[1], AnnNote)
+1103                assert isinstance(op[2], AnnNote)
+1104                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1105                if t.TYPE_CHECKING:
+1106                    assert note1 is not None
+1107                note1.style.color = Visualization.CHANGED_COLOR
+1108                textExp = m21.expressions.TextExpression("changed tuplet")
+1109                textExp.style.color = Visualization.CHANGED_COLOR
+1110                note1.activeSite.insert(note1.offset, textExp)
+1111
+1112                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1113                if t.TYPE_CHECKING:
+1114                    assert note2 is not None
+1115                note2.style.color = Visualization.CHANGED_COLOR
+1116                textExp = m21.expressions.TextExpression("changed tuplet")
+1117                textExp.style.color = Visualization.CHANGED_COLOR
+1118                note2.activeSite.insert(note2.offset, textExp)
+1119
+1120            # ties
+1121            elif op[0] == "tieins":
+1122                assert isinstance(op[1], AnnNote)
+1123                assert isinstance(op[2], AnnNote)
+1124                assert len(op) == 5  # the indices must be there
+1125                # Color the modified note here in both scores,
+1126                # using Visualization.INSERTED_COLOR
+1127                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1128                if t.TYPE_CHECKING:
+1129                    assert chord1 is not None
+1130                note1 = chord1
+1131                if "Chord" in chord1.classes:
+1132                    # color just the indexed note in the chord
+1133                    idx = op[4][0]
+1134                    note1 = chord1.notes[idx]
+1135                if t.TYPE_CHECKING:
+1136                    assert note1 is not None
+1137                note1.style.color = Visualization.INSERTED_COLOR
+1138                textExp = m21.expressions.TextExpression("inserted tie")
+1139                textExp.style.color = Visualization.INSERTED_COLOR
+1140                if note1.activeSite is not None:
+1141                    note1.activeSite.insert(note1.offset, textExp)
+1142                else:
+1143                    chord1.activeSite.insert(chord1.offset, textExp)
+1144
+1145                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1146                if t.TYPE_CHECKING:
+1147                    assert chord2 is not None
+1148                note2 = chord2
+1149                if "Chord" in chord2.classes:
+1150                    # color just the indexed note in the chord
+1151                    idx = op[4][1]
+1152                    note2 = chord2.notes[idx]
+1153                if t.TYPE_CHECKING:
+1154                    assert note2 is not None
+1155                note2.style.color = Visualization.INSERTED_COLOR
+1156                textExp = m21.expressions.TextExpression("inserted tie")
+1157                textExp.style.color = Visualization.INSERTED_COLOR
+1158                if note2.activeSite is not None:
+1159                    note2.activeSite.insert(note2.offset, textExp)
+1160                else:
+1161                    chord2.activeSite.insert(chord2.offset, textExp)
+1162
+1163            elif op[0] == "tiedel":
+1164                assert isinstance(op[1], AnnNote)
+1165                assert isinstance(op[2], AnnNote)
+1166                assert len(op) == 5  # the indices must be there
+1167                # Color the modified note in both scores, using Visualization.DELETED_COLOR
+1168                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1169                if t.TYPE_CHECKING:
+1170                    assert chord1 is not None
+1171                note1 = chord1
+1172                if "Chord" in chord1.classes:
+1173                    # color just the indexed note in the chord
+1174                    idx = op[4][0]
+1175                    note1 = chord1.notes[idx]
+1176                if t.TYPE_CHECKING:
+1177                    assert note1 is not None
+1178                note1.style.color = Visualization.DELETED_COLOR
+1179                textExp = m21.expressions.TextExpression("deleted tie")
+1180                textExp.style.color = Visualization.DELETED_COLOR
+1181                if note1.activeSite is not None:
+1182                    note1.activeSite.insert(note1.offset, textExp)
+1183                else:
+1184                    chord1.activeSite.insert(chord1.offset, textExp)
+1185
+1186                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1187                if t.TYPE_CHECKING:
+1188                    assert chord2 is not None
+1189                note2 = chord2
+1190                if "Chord" in chord2.classes:
+1191                    # color just the indexed note in the chord
+1192                    idx = op[4][1]
+1193                    note2 = chord2.notes[idx]
+1194                if t.TYPE_CHECKING:
+1195                    assert note2 is not None
+1196                note2.style.color = Visualization.DELETED_COLOR
+1197                textExp = m21.expressions.TextExpression("deleted tie")
+1198                textExp.style.color = Visualization.DELETED_COLOR
+1199                if note2.activeSite is not None:
+1200                    note2.activeSite.insert(note2.offset, textExp)
+1201                else:
+1202                    chord2.activeSite.insert(chord2.offset, textExp)
+1203
+1204            # expressions
+1205            elif op[0] == "insexpression":
+1206                assert isinstance(op[1], AnnNote)
+1207                assert isinstance(op[2], AnnNote)
+1208                # color the note in both scores using Visualization.INSERTED_COLOR
+1209                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1210                if t.TYPE_CHECKING:
+1211                    assert note1 is not None
+1212                note1.style.color = Visualization.INSERTED_COLOR
+1213                textExp = m21.expressions.TextExpression("inserted expression")
+1214                textExp.style.color = Visualization.INSERTED_COLOR
+1215                note1.activeSite.insert(note1.offset, textExp)
+1216
+1217                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1218                if t.TYPE_CHECKING:
+1219                    assert note2 is not None
+1220                note2.style.color = Visualization.INSERTED_COLOR
+1221                textExp = m21.expressions.TextExpression("inserted expression")
+1222                textExp.style.color = Visualization.INSERTED_COLOR
+1223                note2.activeSite.insert(note2.offset, textExp)
+1224
+1225            elif op[0] == "delexpression":
+1226                assert isinstance(op[1], AnnNote)
+1227                assert isinstance(op[2], AnnNote)
+1228                # color the deleted expression in score1 using Visualization.DELETED_COLOR
+1229                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1230                if t.TYPE_CHECKING:
+1231                    assert note1 is not None
+1232                note1.style.color = Visualization.DELETED_COLOR
+1233                textExp = m21.expressions.TextExpression("deleted expression")
+1234                textExp.style.color = Visualization.DELETED_COLOR
+1235                note1.activeSite.insert(note1.offset, textExp)
+1236
+1237                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1238                if t.TYPE_CHECKING:
+1239                    assert note2 is not None
+1240                note2.style.color = Visualization.DELETED_COLOR
+1241                textExp = m21.expressions.TextExpression("deleted expression")
+1242                textExp.style.color = Visualization.DELETED_COLOR
+1243                note2.activeSite.insert(note2.offset, textExp)
+1244
+1245            elif op[0] == "editexpression":
+1246                assert isinstance(op[1], AnnNote)
+1247                assert isinstance(op[2], AnnNote)
+1248                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+1249                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1250                if t.TYPE_CHECKING:
+1251                    assert note1 is not None
+1252                note1.style.color = Visualization.CHANGED_COLOR
+1253                textExp = m21.expressions.TextExpression("changed expression")
+1254                textExp.style.color = Visualization.CHANGED_COLOR
+1255                note1.activeSite.insert(note1.offset, textExp)
+1256
+1257                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1258                if t.TYPE_CHECKING:
+1259                    assert note2 is not None
+1260                note2.style.color = Visualization.CHANGED_COLOR
+1261                textExp = m21.expressions.TextExpression("changed expression")
+1262                textExp.style.color = Visualization.CHANGED_COLOR
+1263                note2.activeSite.insert(note2.offset, textExp)
+1264
+1265            # articulations
+1266            elif op[0] == "insarticulation":
+1267                assert isinstance(op[1], AnnNote)
+1268                assert isinstance(op[2], AnnNote)
+1269                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1270                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1271                if t.TYPE_CHECKING:
+1272                    assert note1 is not None
+1273                note1.style.color = Visualization.INSERTED_COLOR
+1274                textExp = m21.expressions.TextExpression("inserted articulation")
+1275                textExp.style.color = Visualization.INSERTED_COLOR
+1276                note1.activeSite.insert(note1.offset, textExp)
+1277
+1278                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1279                if t.TYPE_CHECKING:
+1280                    assert note2 is not None
+1281                note2.style.color = Visualization.INSERTED_COLOR
+1282                textExp = m21.expressions.TextExpression("inserted articulation")
+1283                textExp.style.color = Visualization.INSERTED_COLOR
+1284                note2.activeSite.insert(note2.offset, textExp)
+1285
+1286            elif op[0] == "delarticulation":
+1287                assert isinstance(op[1], AnnNote)
+1288                assert isinstance(op[2], AnnNote)
+1289                # color the modified note in both scores using Visualization.DELETED_COLOR
+1290                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1291                if t.TYPE_CHECKING:
+1292                    assert note1 is not None
+1293                note1.style.color = Visualization.DELETED_COLOR
+1294                textExp = m21.expressions.TextExpression("deleted articulation")
+1295                textExp.style.color = Visualization.DELETED_COLOR
+1296                note1.activeSite.insert(note1.offset, textExp)
+1297
+1298                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1299                if t.TYPE_CHECKING:
+1300                    assert note2 is not None
+1301                note2.style.color = Visualization.DELETED_COLOR
+1302                textExp = m21.expressions.TextExpression("deleted articulation")
+1303                textExp.style.color = Visualization.DELETED_COLOR
+1304                note2.activeSite.insert(note2.offset, textExp)
+1305
+1306            elif op[0] == "editarticulation":
+1307                assert isinstance(op[1], AnnNote)
+1308                assert isinstance(op[2], AnnNote)
+1309                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1310                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1311                if t.TYPE_CHECKING:
+1312                    assert note1 is not None
+1313                note1.style.color = Visualization.CHANGED_COLOR
+1314                textExp = m21.expressions.TextExpression("changed articulation")
+1315                textExp.style.color = Visualization.CHANGED_COLOR
+1316                note1.activeSite.insert(note1.offset, textExp)
+1317
+1318                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1319                if t.TYPE_CHECKING:
+1320                    assert note2 is not None
+1321                note2.style.color = Visualization.CHANGED_COLOR
+1322                textExp = m21.expressions.TextExpression("changed articulation")
+1323                textExp.style.color = Visualization.CHANGED_COLOR
+1324                note2.activeSite.insert(note2.offset, textExp)
+1325
+1326            # lyrics
+1327            elif op[0] == "inslyric":
+1328                assert isinstance(op[1], AnnNote)
+1329                assert isinstance(op[2], AnnNote)
+1330                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1331                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1332                if t.TYPE_CHECKING:
+1333                    assert note1 is not None
+1334                note1.style.color = Visualization.INSERTED_COLOR
+1335                textExp = m21.expressions.TextExpression("inserted lyric")
+1336                textExp.style.color = Visualization.INSERTED_COLOR
+1337                note1.activeSite.insert(note1.offset, textExp)
+1338
+1339                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1340                if t.TYPE_CHECKING:
+1341                    assert note2 is not None
+1342                note2.style.color = Visualization.INSERTED_COLOR
+1343                textExp = m21.expressions.TextExpression("inserted lyric")
+1344                textExp.style.color = Visualization.INSERTED_COLOR
+1345                note2.activeSite.insert(note2.offset, textExp)
+1346
+1347            elif op[0] == "dellyric":
+1348                assert isinstance(op[1], AnnNote)
+1349                assert isinstance(op[2], AnnNote)
+1350                # color the modified note in both scores using Visualization.DELETED_COLOR
+1351                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1352                if t.TYPE_CHECKING:
+1353                    assert note1 is not None
+1354                note1.style.color = Visualization.DELETED_COLOR
+1355                textExp = m21.expressions.TextExpression("deleted lyric")
+1356                textExp.style.color = Visualization.DELETED_COLOR
+1357                note1.activeSite.insert(note1.offset, textExp)
+1358
+1359                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1360                if t.TYPE_CHECKING:
+1361                    assert note2 is not None
+1362                note2.style.color = Visualization.DELETED_COLOR
+1363                textExp = m21.expressions.TextExpression("deleted lyric")
+1364                textExp.style.color = Visualization.DELETED_COLOR
+1365                note2.activeSite.insert(note2.offset, textExp)
+1366
+1367            elif op[0] == "editlyric":
+1368                assert isinstance(op[1], AnnNote)
+1369                assert isinstance(op[2], AnnNote)
+1370                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1371                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1372                if t.TYPE_CHECKING:
+1373                    assert note1 is not None
+1374                note1.style.color = Visualization.CHANGED_COLOR
+1375                textExp = m21.expressions.TextExpression("changed lyric")
+1376                textExp.style.color = Visualization.CHANGED_COLOR
+1377                note1.activeSite.insert(note1.offset, textExp)
+1378
+1379                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1380                if t.TYPE_CHECKING:
+1381                    assert note2 is not None
+1382                note2.style.color = Visualization.CHANGED_COLOR
+1383                textExp = m21.expressions.TextExpression("changed lyric")
+1384                textExp.style.color = Visualization.CHANGED_COLOR
+1385                note2.activeSite.insert(note2.offset, textExp)
+1386
+1387            else:
+1388                print(
+1389                    f"Annotation type {op[0]} not yet supported for visualization",
+1390                    file=sys.stderr
+1391                )
+1392
+1393    @staticmethod
+1394    def show_diffs(
+1395        score1: m21.stream.Score,
+1396        score2: m21.stream.Score,
+1397        out_path1: str | Path | None = None,
+1398        out_path2: str | Path | None = None
+1399    ) -> None:
+1400        """
+1401        Render two (presumably marked-up) music21 scores.  If both out_path1 and
+1402        out_path2 are not None, save the rendered PDFs at those two locations,
+1403        otherwise just display them using the default PDF viewer on the system.
+1404
+1405        Args:
+1406            score1 (music21.stream.Score): The first score to render
+1407            score2 (music21.stream.Score): The second score to render
+1408            out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
+1409                If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
+1410                (default is None)
+1411            out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
+1412                If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
+1413                (default is None)
+1414        """
+1415        # display the two (presumably annotated) scores
+1416        originalComposer1: str | None = None
+1417        originalComposer2: str | None = None
+1418
+1419        if score1.metadata is None:
+1420            score1.metadata = m21.metadata.Metadata()
+1421        if score2.metadata is None:
+1422            score2.metadata = m21.metadata.Metadata()
+1423
+1424        originalComposer1 = score1.metadata.composer
+1425        if originalComposer1 is None:
+1426            score1.metadata.composer = "score1"
+1427        else:
+1428            score1.metadata.composer = "score1          " + originalComposer1
+1429
+1430        originalComposer2 = score2.metadata.composer
+1431        if originalComposer2 is None:
+1432            score2.metadata.composer = "score2"
+1433        else:
+1434            score2.metadata.composer = "score2          " + originalComposer2
+1435
+1436        # save files if requested
+1437        if (out_path1 is not None) and (out_path2 is not None):
+1438            score1.write("musicxml.pdf", makeNotation=False, fp=out_path1)
+1439            score2.write("musicxml.pdf", makeNotation=False, fp=out_path2)
+1440            print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr)
+1441        else:
+1442            # just display the scores
+1443            score1.show("musicxml.pdf", makeNotation=False)
+1444            score2.show("musicxml.pdf", makeNotation=False)
+
- -
-
#   +
+
+ INSERTED_COLOR = +'red' - Visualization()
- - + - -
-
-
#   - - INSERTED_COLOR = 'red' -
-

INSERTED_COLOR can be set to customize the rendered score markup that mark_diffs does.

-
#   +
+ DELETED_COLOR = +'red' - DELETED_COLOR = 'red' +
- + +

DELETED_COLOR can be set to customize the rendered score markup that mark_diffs does.

-
#   +
+ CHANGED_COLOR = +'red' - CHANGED_COLOR = 'red' +
- + +

CHANGED_COLOR can be set to customize the rendered score markup that mark_diffs does.

-
#   - -
@staticmethod
- - def - mark_diffs( - score1: music21.stream.base.Score, - score2: music21.stream.base.Score, - operations: List[Tuple] -): -
- -
- View Source -
    @staticmethod
-    def mark_diffs(
-        score1: m21.stream.Score, score2: m21.stream.Score, operations: List[Tuple]
-    ):
-        """
-        Mark up two music21 scores with the differences described by an operations
-        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
-
-        Args:
-            score1 (music21.stream.Score): The first score to mark up
-            score2 (music21.stream.Score): The second score to mark up
-            operations (List[Tuple]): The operations list that describes the difference
-                between the two scores
-        """
-        for op in operations:
-            # bar
-            if op[0] == "insbar":
-                assert isinstance(op[2], AnnMeasure)
-                # color all the notes in the inserted score2 measure using Visualization.INSERTED_COLOR
-                measure2 = score2.recurse().getElementById(op[2].measure)
-                textExp = m21.expressions.TextExpression("inserted measure")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                measure2.insert(0, textExp)
-                measure2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in measure2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "delbar":
-                assert isinstance(op[1], AnnMeasure)
-                # color all the notes in the deleted score1 measure using Visualization.DELETED_COLOR
-                measure1 = score1.recurse().getElementById(op[1].measure)
-                textExp = m21.expressions.TextExpression("deleted measure")
-                textExp.style.color = Visualization.DELETED_COLOR
-                measure1.insert(0, textExp)
-                measure1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in measure1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # voices
-            elif op[0] == "voiceins":
-                assert isinstance(op[2], AnnVoice)
-                # color all the notes in the inserted score2 voice using Visualization.INSERTED_COLOR
-                voice2 = score2.recurse().getElementById(op[2].voice)
-                textExp = m21.expressions.TextExpression("inserted voice")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                voice2.insert(0, textExp)
-
-                voice2.style.color = (
-                    Visualization.INSERTED_COLOR
-                )  # this apparently does nothing
-                for el in voice2.recurse().notesAndRests:
-                    el.style.color = Visualization.INSERTED_COLOR
-
-            elif op[0] == "voicedel":
-                assert isinstance(op[1], AnnVoice)
-                # color all the notes in the deleted score1 voice using Visualization.DELETED_COLOR
-                voice1 = score1.recurse().getElementById(op[1].voice)
-                textExp = m21.expressions.TextExpression("deleted voice")
-                textExp.style.color = Visualization.DELETED_COLOR
-                voice1.insert(0, textExp)
-
-                voice1.style.color = (
-                    Visualization.DELETED_COLOR
-                )  # this apparently does nothing
-                for el in voice1.recurse().notesAndRests:
-                    el.style.color = Visualization.DELETED_COLOR
-
-            # extra
-            elif op[0] == "extrains":
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.INSERTED_COLOR, and add a textExpression
-                # describing the insertion.
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
-                textExp.style.color = Visualization.INSERTED_COLOR
-                if isinstance(extra2, m21.spanner.Spanner):
-                    insertionPoint = extra2.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra2.activeSite.insert(extra2.offset, textExp)
-
-            elif op[0] == "extradel":
-                assert isinstance(op[1], AnnExtra)
-                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
-                # describing the deletion.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
-                textExp.style.color = Visualization.DELETED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint = extra1.getFirst()
-                    insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp)
-
-            elif op[0] == "extrasub":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                if extra1.classes[0] != extra2.classes[0]:
-                    textExp1 = m21.expressions.TextExpression(
-                                    f"changed to {extra2.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(
-                                    f"changed from {extra1.classes[0]}")
-                else:
-                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extracontentedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extraoffsetedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extradurationedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
-                # describing the change.
-                extra1 = score1.recurse().getElementById(op[1].extra)
-                extra2 = score2.recurse().getElementById(op[2].extra)
-                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration")
-                textExp1.style.color = Visualization.CHANGED_COLOR
-                textExp2.style.color = Visualization.CHANGED_COLOR
-                if isinstance(extra1, m21.spanner.Spanner):
-                    insertionPoint1 = extra1.getFirst()
-                    insertionPoint2 = extra2.getFirst()
-                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
-                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
-                else:
-                    extra1.activeSite.insert(extra1.offset, textExp1)
-                    extra2.activeSite.insert(extra2.offset, textExp2)
-
-            elif op[0] == "extrastyleedit":
-                assert isinstance(op[1], AnnExtra)
-                assert isinstance(op[2], AnnExtra)
-                sd1 = op[1].styledict
-                sd2 = op[2].styledict
-                changedStr: str = ""
-                for k1, v1 in sd1.items():
-                    if k1 not in sd2 or sd2[k1] != v1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k1
-
-                # one last thing: check for keys in sd2 that aren't in sd1
-                for k2 in sd2:
-                    if k2 not in sd1:
-                        if changedStr:
-                            changedStr += ","
-                        changedStr += k2
+                                        
+
+
@staticmethod
- # color the extra using Visualization.CHANGED_COLOR, and add a textExpression - # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) + def + mark_diffs( score1: music21.stream.base.Score, score2: music21.stream.base.Score, operations: list[tuple]) -> None: - textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}") - textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}") - textExp1.style.color = Visualization.CHANGED_COLOR - textExp2.style.color = Visualization.CHANGED_COLOR - if isinstance(extra1, m21.spanner.Spanner): - insertionPoint1 = extra1.getFirst() - insertionPoint2 = extra2.getFirst() - insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1) - insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2) - else: - extra1.activeSite.insert(extra1.offset, textExp1) - extra2.activeSite.insert(extra2.offset, textExp2) + - # note - elif op[0] == "noteins": - assert isinstance(op[2], AnnNote) - # color the inserted score2 general note (note, chord, or rest) using Visualization.INSERTED_COLOR - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression(f"inserted {note2.classes[0]}") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "notedel": - assert isinstance(op[1], AnnNote) - # color the deleted score1 general note (note, chord, or rest) using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression(f"deleted {note2.classes[0]}") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - # pitch - elif op[0] == "pitchnameedit": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the changed note (in both scores) using Visualization.CHANGED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed pitch") - textExp.style.color = Visualization.CHANGED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed pitch") - textExp.style.color = Visualization.CHANGED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "inspitch": - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the inserted note in score2 using Visualization.INSERTED_COLOR - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.INSERTED_COLOR - if "Rest" in note2.classes: - textExp = m21.expressions.TextExpression("inserted rest") - else: - textExp = m21.expressions.TextExpression("inserted note") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "delpitch": - assert isinstance(op[1], AnnNote) - assert len(op) == 5 # the indices must be there - # color the deleted note in score1 using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.DELETED_COLOR - if "Rest" in note1.classes: - textExp = m21.expressions.TextExpression("deleted rest") - else: - textExp = m21.expressions.TextExpression("deleted note") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - elif op[0] == "headedit": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # beam - elif op[0] == "insbeam": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - if hasattr(note1, "beams"): - for beam in note1.beams: - beam.style.color = ( - Visualization.INSERTED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("increased flags") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - if hasattr(note2, "beams"): - for beam in note2.beams: - beam.style.color = ( - Visualization.INSERTED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("increased flags") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delbeam": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - if hasattr(note1, "beams"): - for beam in note1.beams: - beam.style.color = ( - Visualization.DELETED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("decreased flags") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - if hasattr(note2, "beams"): - for beam in note2.beams: - beam.style.color = ( - Visualization.DELETED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("decreased flags") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editbeam": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - if hasattr(note1, "beams"): - for beam in note1.beams: - beam.style.color = ( - Visualization.CHANGED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("changed flags") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - if hasattr(note2, "beams"): - for beam in note2.beams: - beam.style.color = ( - Visualization.CHANGED_COLOR - ) # this apparently does nothing - textExp = m21.expressions.TextExpression("changed flags") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editnoteshape": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note shape") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note shape") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editnoteheadfill": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head fill") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head fill") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editnoteheadparenthesis": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head paren") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed note head paren") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editstemdirection": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed stem direction") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed stem direction") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editstyle": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - sd1 = op[1].styledict - sd2 = op[2].styledict - changedStr: str = "" - for k1, v1 in sd1.items(): - if k1 not in sd2 or sd2[k1] != v1: - if changedStr: - changedStr += "," - changedStr += k1 - - # one last thing: check for keys in sd2 that aren't in sd1 - for k2 in sd2: - if k2 not in sd1: - if changedStr: - changedStr += "," - changedStr += k2 - - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression(f"changed note {changedStr}") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression(f"changed note {changedStr}") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # accident - elif op[0] == "accidentins": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the modified note in both scores using Visualization.INSERTED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color only the indexed note's accidental in the chord - idx = op[4][0] - note1 = note1.notes[idx] - if note1.pitch.accidental: - note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted accidental") - textExp.style.color = Visualization.INSERTED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color only the indexed note's accidental in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted accidental") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "accidentdel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the modified note in both scores using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color only the indexed note's accidental in the chord - idx = op[4][0] - note1 = note1.notes[idx] - if note1.pitch.accidental: - note1.pitch.accidental.style.color = Visualization.DELETED_COLOR - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted accidental") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color only the indexed note's accidental in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.DELETED_COLOR - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted accidental") - textExp.style.color = Visualization.DELETED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "accidentedit": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # color the changed accidental (in both scores) using Visualization.CHANGED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - if note1.pitch.accidental: - note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed accidental") - textExp.style.color = Visualization.CHANGED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - if note2.pitch.accidental: - note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed accidental") - textExp.style.color = Visualization.CHANGED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "dotins": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "dotdel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted dot") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # tuplets - elif op[0] == "instuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("inserted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "deltuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("deleted tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "edittuplet": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed tuplet") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # ties - elif op[0] == "tieins": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # Color the modified note here in both scores, using Visualization.INSERTED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted tie") - textExp.style.color = Visualization.INSERTED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted tie") - textExp.style.color = Visualization.INSERTED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - elif op[0] == "tiedel": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - assert len(op) == 5 # the indices must be there - # Color the modified note in both scores, using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) - note1 = chord1 - if "Chord" in note1.classes: - # color just the indexed note in the chord - idx = op[4][0] - note1 = note1.notes[idx] - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note1.activeSite is not None: - note1.activeSite.insert(note1.offset, textExp) - else: - chord1.activeSite.insert(chord1.offset, textExp) - - chord2 = score2.recurse().getElementById(op[2].general_note) - note2 = chord2 - if "Chord" in note2.classes: - # color just the indexed note in the chord - idx = op[4][1] - note2 = note2.notes[idx] - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted tie") - textExp.style.color = Visualization.DELETED_COLOR - if note2.activeSite is not None: - note2.activeSite.insert(note2.offset, textExp) - else: - chord2.activeSite.insert(chord2.offset, textExp) - - # expressions - elif op[0] == "insexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted expression") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the deleted expression in score1 using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted expression") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editexpression": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed expression") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - # articulations - elif op[0] == "insarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression("inserted articulation") - textExp.style.color = Visualization.INSERTED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "delarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression("deleted articulation") - textExp.style.color = Visualization.DELETED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - elif op[0] == "editarticulation": - assert isinstance(op[1], AnnNote) - assert isinstance(op[2], AnnNote) - # color the modified note (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) - note1.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note1.activeSite.insert(note1.offset, textExp) - - note2 = score2.recurse().getElementById(op[2].general_note) - note2.style.color = Visualization.CHANGED_COLOR - textExp = m21.expressions.TextExpression("changed articulation") - textExp.style.color = Visualization.CHANGED_COLOR - note2.activeSite.insert(note2.offset, textExp) - - else: - print(f"Annotation type {op[0]} not yet supported for visualization", file=sys.stderr) -
+
+ +
  41    @staticmethod
+  42    def mark_diffs(
+  43        score1: m21.stream.Score,
+  44        score2: m21.stream.Score,
+  45        operations: list[tuple]
+  46    ) -> None:
+  47        """
+  48        Mark up two music21 scores with the differences described by an operations
+  49        list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`).
+  50
+  51        Args:
+  52            score1 (music21.stream.Score): The first score to mark up
+  53            score2 (music21.stream.Score): The second score to mark up
+  54            operations (list[tuple]): The operations list that describes the difference
+  55                between the two scores
+  56        """
+  57        changedStr: str
+  58        for op in operations:
+  59            # bar
+  60            if op[0] == "insbar":
+  61                assert isinstance(op[2], AnnMeasure)
+  62                # color all the notes in the inserted score2 measure
+  63                # using Visualization.INSERTED_COLOR
+  64                measure2 = score2.recurse().getElementById(op[2].measure)  # type: ignore
+  65                if t.TYPE_CHECKING:
+  66                    assert measure2 is not None
+  67                textExp = m21.expressions.TextExpression("inserted measure")
+  68                textExp.style.color = Visualization.INSERTED_COLOR
+  69                measure2.insert(0, textExp)
+  70                measure2.style.color = (
+  71                    Visualization.INSERTED_COLOR
+  72                )  # this apparently does nothing
+  73                for el in measure2.recurse().notesAndRests:
+  74                    el.style.color = Visualization.INSERTED_COLOR
+  75
+  76            elif op[0] == "delbar":
+  77                assert isinstance(op[1], AnnMeasure)
+  78                # color all the notes in the deleted score1 measure
+  79                # using Visualization.DELETED_COLOR
+  80                measure1 = score1.recurse().getElementById(op[1].measure)  # type: ignore
+  81                if t.TYPE_CHECKING:
+  82                    assert measure1 is not None
+  83                textExp = m21.expressions.TextExpression("deleted measure")
+  84                textExp.style.color = Visualization.DELETED_COLOR
+  85                measure1.insert(0, textExp)
+  86                measure1.style.color = (
+  87                    Visualization.DELETED_COLOR
+  88                )  # this apparently does nothing
+  89                for el in measure1.recurse().notesAndRests:
+  90                    el.style.color = Visualization.DELETED_COLOR
+  91
+  92            # voices
+  93            elif op[0] == "voiceins":
+  94                assert isinstance(op[2], AnnVoice)
+  95                # color all the notes in the inserted score2 voice
+  96                # using Visualization.INSERTED_COLOR
+  97                voice2 = score2.recurse().getElementById(op[2].voice)  # type: ignore
+  98                if t.TYPE_CHECKING:
+  99                    assert voice2 is not None
+ 100                textExp = m21.expressions.TextExpression("inserted voice")
+ 101                textExp.style.color = Visualization.INSERTED_COLOR
+ 102                voice2.insert(0, textExp)
+ 103
+ 104                voice2.style.color = (
+ 105                    Visualization.INSERTED_COLOR
+ 106                )  # this apparently does nothing
+ 107                for el in voice2.recurse().notesAndRests:
+ 108                    el.style.color = Visualization.INSERTED_COLOR
+ 109
+ 110            elif op[0] == "voicedel":
+ 111                assert isinstance(op[1], AnnVoice)
+ 112                # color all the notes in the deleted score1 voice
+ 113                # using Visualization.DELETED_COLOR
+ 114                voice1 = score1.recurse().getElementById(op[1].voice)  # type: ignore
+ 115                if t.TYPE_CHECKING:
+ 116                    assert voice1 is not None
+ 117                textExp = m21.expressions.TextExpression("deleted voice")
+ 118                textExp.style.color = Visualization.DELETED_COLOR
+ 119                voice1.insert(0, textExp)
+ 120
+ 121                voice1.style.color = (
+ 122                    Visualization.DELETED_COLOR
+ 123                )  # this apparently does nothing
+ 124                for el in voice1.recurse().notesAndRests:
+ 125                    el.style.color = Visualization.DELETED_COLOR
+ 126
+ 127            # extra
+ 128            elif op[0] == "extrains":
+ 129                assert isinstance(op[2], AnnExtra)
+ 130                # color the extra using Visualization.INSERTED_COLOR,
+ 131                # and add a textExpression describing the insertion.
+ 132                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 133                if t.TYPE_CHECKING:
+ 134                    assert extra2 is not None
+ 135                textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}")
+ 136                textExp.style.color = Visualization.INSERTED_COLOR
+ 137                if isinstance(extra2, m21.spanner.Spanner):
+ 138                    insertionPoint = extra2.getFirst()
+ 139                    if isinstance(insertionPoint, m21.stream.Measure):
+ 140                        # insertionPoint is a measure, put the textExp at offset 0
+ 141                        # inside the measure
+ 142                        insertionPoint.insert(0, textExp)
+ 143                    else:
+ 144                        # insertionPoint is something else, put the textExp right next to it.
+ 145                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 146                else:
+ 147                    # extra2 is not a spanner, put the textExp right next to it
+ 148                    extra2.activeSite.insert(extra2.offset, textExp)
+ 149
+ 150            elif op[0] == "extradel":
+ 151                assert isinstance(op[1], AnnExtra)
+ 152                # color the extra using Visualization.DELETED_COLOR, and add a textExpression
+ 153                # describing the deletion.
+ 154                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 155                if t.TYPE_CHECKING:
+ 156                    assert extra1 is not None
+ 157                textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}")
+ 158                textExp.style.color = Visualization.DELETED_COLOR
+ 159                if isinstance(extra1, m21.spanner.Spanner):
+ 160                    insertionPoint = extra1.getFirst()
+ 161                    if isinstance(insertionPoint, m21.stream.Measure):
+ 162                        # insertionPoint is a measure, put the textExp at offset 0
+ 163                        # inside the measure
+ 164                        insertionPoint.insert(0, textExp)
+ 165                    else:
+ 166                        # insertionPoint is something else, put the textExp right next to it.
+ 167                        insertionPoint.activeSite.insert(insertionPoint.offset, textExp)
+ 168                else:
+ 169                    # extra1 is not a spanner, put the textExp right next to it
+ 170                    extra1.activeSite.insert(extra1.offset, textExp)
+ 171
+ 172            elif op[0] == "extrasub":
+ 173                assert isinstance(op[1], AnnExtra)
+ 174                assert isinstance(op[2], AnnExtra)
+ 175                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 176                # describing the change.
+ 177                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 178                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 179                if t.TYPE_CHECKING:
+ 180                    assert extra1 is not None
+ 181                    assert extra2 is not None
+ 182                if extra1.classes[0] != extra2.classes[0]:
+ 183                    textExp1 = m21.expressions.TextExpression(
+ 184                        f"changed to {extra2.classes[0]}"
+ 185                    )
+ 186                    textExp2 = m21.expressions.TextExpression(
+ 187                        f"changed from {extra1.classes[0]}"
+ 188                    )
+ 189                else:
+ 190                    textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 191                    textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}")
+ 192                textExp1.style.color = Visualization.CHANGED_COLOR
+ 193                textExp2.style.color = Visualization.CHANGED_COLOR
+ 194                if isinstance(extra1, m21.spanner.Spanner):
+ 195                    insertionPoint1 = extra1.getFirst()
+ 196                    insertionPoint2 = extra2.getFirst()
+ 197                    if isinstance(insertionPoint1, m21.stream.Measure):
+ 198                        # insertionPoint1 is a measure, put the textExp at offset 0
+ 199                        # inside the measure
+ 200                        insertionPoint1.insert(0, textExp)
+ 201                    else:
+ 202                        # insertionPoint1 is something else, put the textExp right next to it.
+ 203                        insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp)
+ 204                    if isinstance(insertionPoint2, m21.stream.Measure):
+ 205                        # insertionPoint2 is a measure, put the textExp at offset 0
+ 206                        # inside the measure
+ 207                        insertionPoint2.insert(0, textExp)
+ 208                    else:
+ 209                        # insertionPoint2 is something else, put the textExp right next to it.
+ 210                        insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp)
+ 211                else:
+ 212                    # extra is not a spanner, put the textExp right next to it
+ 213                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 214                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 215
+ 216            elif op[0] == "extracontentedit":
+ 217                assert isinstance(op[1], AnnExtra)
+ 218                assert isinstance(op[2], AnnExtra)
+ 219                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 220                # describing the change.
+ 221                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 222                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 223                if t.TYPE_CHECKING:
+ 224                    assert extra1 is not None
+ 225                    assert extra2 is not None
+ 226                textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 227                textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text")
+ 228                textExp1.style.color = Visualization.CHANGED_COLOR
+ 229                textExp2.style.color = Visualization.CHANGED_COLOR
+ 230                if isinstance(extra1, m21.spanner.Spanner):
+ 231                    insertionPoint1 = extra1.getFirst()
+ 232                    insertionPoint2 = extra2.getFirst()
+ 233                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 234                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 235                else:
+ 236                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 237                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 238
+ 239            elif op[0] == "extraoffsetedit":
+ 240                assert isinstance(op[1], AnnExtra)
+ 241                assert isinstance(op[2], AnnExtra)
+ 242                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 243                # describing the change.
+ 244                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 245                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 246                if t.TYPE_CHECKING:
+ 247                    assert extra1 is not None
+ 248                    assert extra2 is not None
+ 249                textExp1 = m21.expressions.TextExpression(
+ 250                    f"changed {extra1.classes[0]} offset")
+ 251                textExp2 = m21.expressions.TextExpression(
+ 252                    f"changed {extra1.classes[0]} offset")
+ 253                textExp1.style.color = Visualization.CHANGED_COLOR
+ 254                textExp2.style.color = Visualization.CHANGED_COLOR
+ 255                if isinstance(extra1, m21.spanner.Spanner):
+ 256                    insertionPoint1 = extra1.getFirst()
+ 257                    insertionPoint2 = extra2.getFirst()
+ 258                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 259                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 260                else:
+ 261                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 262                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 263
+ 264            elif op[0] == "extradurationedit":
+ 265                assert isinstance(op[1], AnnExtra)
+ 266                assert isinstance(op[2], AnnExtra)
+ 267                # color the extra using Visualization.CHANGED_COLOR, and add a textExpression
+ 268                # describing the change.
+ 269                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 270                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 271                if t.TYPE_CHECKING:
+ 272                    assert extra1 is not None
+ 273                    assert extra2 is not None
+ 274                textExp1 = m21.expressions.TextExpression(
+ 275                    f"changed {extra1.classes[0]} duration")
+ 276                textExp2 = m21.expressions.TextExpression(
+ 277                    f"changed {extra1.classes[0]} duration")
+ 278                textExp1.style.color = Visualization.CHANGED_COLOR
+ 279                textExp2.style.color = Visualization.CHANGED_COLOR
+ 280                if isinstance(extra1, m21.spanner.Spanner):
+ 281                    insertionPoint1 = extra1.getFirst()
+ 282                    insertionPoint2 = extra2.getFirst()
+ 283                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 284                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 285                else:
+ 286                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 287                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 288
+ 289            elif op[0] == "extrastyleedit":
+ 290                assert isinstance(op[1], AnnExtra)
+ 291                assert isinstance(op[2], AnnExtra)
+ 292                sd1 = op[1].styledict
+ 293                sd2 = op[2].styledict
+ 294                changedStr = ""
+ 295                for k1, v1 in sd1.items():
+ 296                    if k1 not in sd2 or sd2[k1] != v1:
+ 297                        if changedStr:
+ 298                            changedStr += ","
+ 299                        changedStr += k1
+ 300
+ 301                # one last thing: check for keys in sd2 that aren't in sd1
+ 302                for k2 in sd2:
+ 303                    if k2 not in sd1:
+ 304                        if changedStr:
+ 305                            changedStr += ","
+ 306                        changedStr += k2
+ 307
+ 308                # color the extra using Visualization.CHANGED_COLOR,
+ 309                # and add a textExpression describing the change.
+ 310                extra1 = score1.recurse().getElementById(op[1].extra)  # type: ignore
+ 311                extra2 = score2.recurse().getElementById(op[2].extra)  # type: ignore
+ 312                if t.TYPE_CHECKING:
+ 313                    assert extra1 is not None
+ 314                    assert extra2 is not None
+ 315
+ 316                textExp1 = m21.expressions.TextExpression(
+ 317                    f"changed {extra1.classes[0]} {changedStr}")
+ 318                textExp2 = m21.expressions.TextExpression(
+ 319                    f"changed {extra1.classes[0]} {changedStr}")
+ 320                textExp1.style.color = Visualization.CHANGED_COLOR
+ 321                textExp2.style.color = Visualization.CHANGED_COLOR
+ 322                if isinstance(extra1, m21.spanner.Spanner):
+ 323                    insertionPoint1 = extra1.getFirst()
+ 324                    insertionPoint2 = extra2.getFirst()
+ 325                    insertionPoint1.activeSite.insert(insertionPoint1.offset, textExp1)
+ 326                    insertionPoint2.activeSite.insert(insertionPoint2.offset, textExp2)
+ 327                else:
+ 328                    extra1.activeSite.insert(extra1.offset, textExp1)
+ 329                    extra2.activeSite.insert(extra2.offset, textExp2)
+ 330
+ 331            # staff groups
+ 332            elif op[0] == "staffgrpins":
+ 333                assert isinstance(op[2], AnnStaffGroup)
+ 334                # add a textExpression describing the insertion.
+ 335                staffGroup2 = score2.recurse().getElementById(
+ 336                    op[2].staff_group  # type: ignore
+ 337                )
+ 338                if t.TYPE_CHECKING:
+ 339                    assert staffGroup2 is not None
+ 340                textExp = m21.expressions.TextExpression("inserted StaffGroup")
+ 341                textExp.style.color = Visualization.INSERTED_COLOR
+ 342                # insert text at offset 0 in first measure of first part in group
+ 343                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 344                insertionSite.insert(0, textExp)
+ 345
+ 346            elif op[0] == "staffgrpdel":
+ 347                assert isinstance(op[1], AnnStaffGroup)
+ 348                # add a textExpression describing the deletion.
+ 349                staffGroup1 = score1.recurse().getElementById(
+ 350                    op[1].staff_group  # type: ignore
+ 351                )
+ 352                if t.TYPE_CHECKING:
+ 353                    assert staffGroup1 is not None
+ 354                textExp = m21.expressions.TextExpression("deleted StaffGroup")
+ 355                textExp.style.color = Visualization.DELETED_COLOR
+ 356                # insert text at offset 0 in first measure of first part in group
+ 357                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 358                insertionSite.insert(0, textExp)
+ 359
+ 360            elif op[0] == "staffgrpsub":
+ 361                assert isinstance(op[1], AnnStaffGroup)
+ 362                assert isinstance(op[2], AnnStaffGroup)
+ 363                # add a textExpression describing the change.
+ 364                staffGroup1 = score1.recurse().getElementById(
+ 365                    op[1].staff_group  # type: ignore
+ 366                )
+ 367                staffGroup2 = score2.recurse().getElementById(
+ 368                    op[2].staff_group  # type: ignore
+ 369                )
+ 370                if t.TYPE_CHECKING:
+ 371                    assert staffGroup1 is not None
+ 372                    assert staffGroup2 is not None
+ 373                textExp1 = m21.expressions.TextExpression("changed StaffGroup")
+ 374                textExp2 = m21.expressions.TextExpression("changed StaffGroup")
+ 375                textExp1.style.color = Visualization.CHANGED_COLOR
+ 376                textExp2.style.color = Visualization.CHANGED_COLOR
+ 377                # insert text at offset 0 in first measure of first part in group
+ 378                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 379                insertionSite.insert(0, textExp1)
+ 380                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 381                insertionSite.insert(0, textExp2)
+ 382
+ 383            elif op[0] == "staffgrpnameedit":
+ 384                assert isinstance(op[1], AnnStaffGroup)
+ 385                assert isinstance(op[2], AnnStaffGroup)
+ 386                # add a textExpression describing the change.
+ 387                staffGroup1 = score1.recurse().getElementById(
+ 388                    op[1].staff_group  # type: ignore
+ 389                )
+ 390                staffGroup2 = score2.recurse().getElementById(
+ 391                    op[2].staff_group  # type: ignore
+ 392                )
+ 393                if t.TYPE_CHECKING:
+ 394                    assert staffGroup1 is not None
+ 395                    assert staffGroup2 is not None
+ 396                textExp1 = m21.expressions.TextExpression("changed StaffGroup name")
+ 397                textExp2 = m21.expressions.TextExpression("changed StaffGroup name")
+ 398                textExp1.style.color = Visualization.CHANGED_COLOR
+ 399                textExp2.style.color = Visualization.CHANGED_COLOR
+ 400                # insert text at offset 0 in first measure of first part in group
+ 401                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 402                insertionSite.insert(0, textExp1)
+ 403                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 404                insertionSite.insert(0, textExp2)
+ 405
+ 406            elif op[0] == "staffgrpabbreviationedit":
+ 407                assert isinstance(op[1], AnnStaffGroup)
+ 408                assert isinstance(op[2], AnnStaffGroup)
+ 409                # add a textExpression describing the change.
+ 410                staffGroup1 = score1.recurse().getElementById(
+ 411                    op[1].staff_group  # type: ignore
+ 412                )
+ 413                staffGroup2 = score2.recurse().getElementById(
+ 414                    op[2].staff_group  # type: ignore
+ 415                )
+ 416                if t.TYPE_CHECKING:
+ 417                    assert staffGroup1 is not None
+ 418                    assert staffGroup2 is not None
+ 419                textExp1 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 420                textExp2 = m21.expressions.TextExpression("changed StaffGroup abbreviation")
+ 421                textExp1.style.color = Visualization.CHANGED_COLOR
+ 422                textExp2.style.color = Visualization.CHANGED_COLOR
+ 423                # insert text at offset 0 in first measure of first part in group
+ 424                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 425                insertionSite.insert(0, textExp1)
+ 426                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 427                insertionSite.insert(0, textExp2)
+ 428
+ 429            elif op[0] == "staffgrpsymboledit":
+ 430                assert isinstance(op[1], AnnStaffGroup)
+ 431                assert isinstance(op[2], AnnStaffGroup)
+ 432                # add a textExpression describing the change.
+ 433                staffGroup1 = score1.recurse().getElementById(
+ 434                    op[1].staff_group  # type: ignore
+ 435                )
+ 436                staffGroup2 = score2.recurse().getElementById(
+ 437                    op[2].staff_group  # type: ignore
+ 438                )
+ 439                if t.TYPE_CHECKING:
+ 440                    assert staffGroup1 is not None
+ 441                    assert staffGroup2 is not None
+ 442                textExp1 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 443                textExp2 = m21.expressions.TextExpression("changed StaffGroup symbol shape")
+ 444                textExp1.style.color = Visualization.CHANGED_COLOR
+ 445                textExp2.style.color = Visualization.CHANGED_COLOR
+ 446                # insert text at offset 0 in first measure of first part in group
+ 447                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 448                insertionSite.insert(0, textExp1)
+ 449                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 450                insertionSite.insert(0, textExp2)
+ 451
+ 452            elif op[0] == "staffgrpbartogetheredit":
+ 453                assert isinstance(op[1], AnnStaffGroup)
+ 454                assert isinstance(op[2], AnnStaffGroup)
+ 455                # add a textExpression describing the change.
+ 456                staffGroup1 = score1.recurse().getElementById(
+ 457                    op[1].staff_group  # type: ignore
+ 458                )
+ 459                staffGroup2 = score2.recurse().getElementById(
+ 460                    op[2].staff_group  # type: ignore
+ 461                )
+ 462                if t.TYPE_CHECKING:
+ 463                    assert staffGroup1 is not None
+ 464                    assert staffGroup2 is not None
+ 465                textExp1 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 466                textExp2 = m21.expressions.TextExpression("changed StaffGroup barline type")
+ 467                textExp1.style.color = Visualization.CHANGED_COLOR
+ 468                textExp2.style.color = Visualization.CHANGED_COLOR
+ 469                # insert text at offset 0 in first measure of first part in group
+ 470                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 471                insertionSite.insert(0, textExp1)
+ 472                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 473                insertionSite.insert(0, textExp2)
+ 474
+ 475            elif op[0] == "staffgrppartindicesedit":
+ 476                assert isinstance(op[1], AnnStaffGroup)
+ 477                assert isinstance(op[2], AnnStaffGroup)
+ 478                # add a textExpression describing the change.
+ 479                staffGroup1 = score1.recurse().getElementById(
+ 480                    op[1].staff_group  # type: ignore
+ 481                )
+ 482                staffGroup2 = score2.recurse().getElementById(
+ 483                    op[2].staff_group  # type: ignore
+ 484                )
+ 485                if t.TYPE_CHECKING:
+ 486                    assert staffGroup1 is not None
+ 487                    assert staffGroup2 is not None
+ 488                textExp1 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 489                textExp2 = m21.expressions.TextExpression("changed StaffGroup parts")
+ 490                textExp1.style.color = Visualization.CHANGED_COLOR
+ 491                textExp2.style.color = Visualization.CHANGED_COLOR
+ 492                # insert text at offset 0 in first measure of first part in group
+ 493                insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first()
+ 494                insertionSite.insert(0, textExp1)
+ 495                insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first()
+ 496                insertionSite.insert(0, textExp2)
+ 497
+ 498            # note
+ 499            elif op[0] == "noteins":
+ 500                assert isinstance(op[2], AnnNote)
+ 501                # color the inserted score2 general note (note, chord, or rest)
+ 502                # using Visualization.INSERTED_COLOR
+ 503                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 504                if t.TYPE_CHECKING:
+ 505                    assert note2 is not None
+ 506                note2.style.color = Visualization.INSERTED_COLOR
+ 507                textExp = m21.expressions.TextExpression(
+ 508                    f"inserted {note2.classes[0]}")
+ 509                textExp.style.color = Visualization.INSERTED_COLOR
+ 510                note2.activeSite.insert(note2.offset, textExp)
+ 511
+ 512            elif op[0] == "notedel":
+ 513                assert isinstance(op[1], AnnNote)
+ 514                # color the deleted score1 general note (note, chord, or rest)
+ 515                # using Visualization.DELETED_COLOR
+ 516                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 517                if t.TYPE_CHECKING:
+ 518                    assert note1 is not None
+ 519                note1.style.color = Visualization.DELETED_COLOR
+ 520                textExp = m21.expressions.TextExpression(f"deleted {note1.classes[0]}")
+ 521                textExp.style.color = Visualization.DELETED_COLOR
+ 522                note1.activeSite.insert(note1.offset, textExp)
+ 523
+ 524            # pitch
+ 525            elif op[0] == "pitchnameedit":
+ 526                assert isinstance(op[1], AnnNote)
+ 527                assert isinstance(op[2], AnnNote)
+ 528                assert len(op) == 5  # the indices must be there
+ 529                # color the changed note (in both scores) using Visualization.CHANGED_COLOR
+ 530                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 531                if t.TYPE_CHECKING:
+ 532                    assert chord1 is not None
+ 533                note1 = chord1
+ 534                if "Chord" in chord1.classes:
+ 535                    # color just the indexed note in the chord
+ 536                    idx = op[4][0]
+ 537                    note1 = chord1.notes[idx]
+ 538                if t.TYPE_CHECKING:
+ 539                    assert note1 is not None
+ 540                note1.style.color = Visualization.CHANGED_COLOR
+ 541                textExp = m21.expressions.TextExpression("changed pitch")
+ 542                textExp.style.color = Visualization.CHANGED_COLOR
+ 543                if note1.activeSite is not None:
+ 544                    note1.activeSite.insert(note1.offset, textExp)
+ 545                else:
+ 546                    chord1.activeSite.insert(chord1.offset, textExp)
+ 547
+ 548                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 549                if t.TYPE_CHECKING:
+ 550                    assert chord2 is not None
+ 551                note2 = chord2
+ 552                if "Chord" in chord2.classes:
+ 553                    # color just the indexed note in the chord
+ 554                    idx = op[4][1]
+ 555                    note2 = chord2.notes[idx]
+ 556                if t.TYPE_CHECKING:
+ 557                    assert note2 is not None
+ 558                note2.style.color = Visualization.CHANGED_COLOR
+ 559                textExp = m21.expressions.TextExpression("changed pitch")
+ 560                textExp.style.color = Visualization.CHANGED_COLOR
+ 561                if note2.activeSite is not None:
+ 562                    note2.activeSite.insert(note2.offset, textExp)
+ 563                else:
+ 564                    chord2.activeSite.insert(chord2.offset, textExp)
+ 565
+ 566            elif op[0] == "inspitch":
+ 567                assert isinstance(op[2], AnnNote)
+ 568                assert len(op) == 5  # the indices must be there
+ 569                # color the inserted note in score2 using Visualization.INSERTED_COLOR
+ 570                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 571                if t.TYPE_CHECKING:
+ 572                    assert chord2 is not None
+ 573                note2 = chord2
+ 574                if "Chord" in chord2.classes:
+ 575                    # color just the indexed note in the chord
+ 576                    idx = op[4][1]
+ 577                    note2 = chord2.notes[idx]
+ 578                if t.TYPE_CHECKING:
+ 579                    assert note2 is not None
+ 580                note2.style.color = Visualization.INSERTED_COLOR
+ 581                if "Rest" in note2.classes:
+ 582                    textExp = m21.expressions.TextExpression("inserted rest")
+ 583                else:
+ 584                    textExp = m21.expressions.TextExpression("inserted note")
+ 585                textExp.style.color = Visualization.INSERTED_COLOR
+ 586                if note2.activeSite is not None:
+ 587                    note2.activeSite.insert(note2.offset, textExp)
+ 588                else:
+ 589                    chord2.activeSite.insert(chord2.offset, textExp)
+ 590
+ 591            elif op[0] == "delpitch":
+ 592                assert isinstance(op[1], AnnNote)
+ 593                assert len(op) == 5  # the indices must be there
+ 594                # color the deleted note in score1 using Visualization.DELETED_COLOR
+ 595                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 596                if t.TYPE_CHECKING:
+ 597                    assert chord1 is not None
+ 598                note1 = chord1
+ 599                if "Chord" in chord1.classes:
+ 600                    # color just the indexed note in the chord
+ 601                    idx = op[4][0]
+ 602                    note1 = chord1.notes[idx]
+ 603                if t.TYPE_CHECKING:
+ 604                    assert note1 is not None
+ 605                note1.style.color = Visualization.DELETED_COLOR
+ 606                if "Rest" in note1.classes:
+ 607                    textExp = m21.expressions.TextExpression("deleted rest")
+ 608                else:
+ 609                    textExp = m21.expressions.TextExpression("deleted note")
+ 610                textExp.style.color = Visualization.DELETED_COLOR
+ 611                if note1.activeSite is not None:
+ 612                    note1.activeSite.insert(note1.offset, textExp)
+ 613                else:
+ 614                    chord1.activeSite.insert(chord1.offset, textExp)
+ 615
+ 616            elif op[0] == "headedit":
+ 617                assert isinstance(op[1], AnnNote)
+ 618                assert isinstance(op[2], AnnNote)
+ 619                # color the changed note/rest/chord (in both scores)
+ 620                # using Visualization.CHANGED_COLOR
+ 621                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 622                if t.TYPE_CHECKING:
+ 623                    assert note1 is not None
+ 624                note1.style.color = Visualization.CHANGED_COLOR
+ 625                textExp = m21.expressions.TextExpression("changed note head")
+ 626                textExp.style.color = Visualization.CHANGED_COLOR
+ 627                note1.activeSite.insert(note1.offset, textExp)
+ 628
+ 629                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 630                if t.TYPE_CHECKING:
+ 631                    assert note2 is not None
+ 632                note2.style.color = Visualization.CHANGED_COLOR
+ 633                textExp = m21.expressions.TextExpression("changed note head")
+ 634                textExp.style.color = Visualization.CHANGED_COLOR
+ 635                note2.activeSite.insert(note2.offset, textExp)
+ 636
+ 637            elif op[0] == "graceedit":
+ 638                assert isinstance(op[1], AnnNote)
+ 639                assert isinstance(op[2], AnnNote)
+ 640                # color the changed note/rest/chord (in both scores)
+ 641                # using Visualization.CHANGED_COLOR
+ 642                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 643                if t.TYPE_CHECKING:
+ 644                    assert note1 is not None
+ 645                note1.style.color = Visualization.CHANGED_COLOR
+ 646                textExp = m21.expressions.TextExpression("changed grace note")
+ 647                textExp.style.color = Visualization.CHANGED_COLOR
+ 648                note1.activeSite.insert(note1.offset, textExp)
+ 649
+ 650                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 651                if t.TYPE_CHECKING:
+ 652                    assert note2 is not None
+ 653                note2.style.color = Visualization.CHANGED_COLOR
+ 654                textExp = m21.expressions.TextExpression("changed grace note")
+ 655                textExp.style.color = Visualization.CHANGED_COLOR
+ 656                note2.activeSite.insert(note2.offset, textExp)
+ 657
+ 658            elif op[0] == "graceslashedit":
+ 659                assert isinstance(op[1], AnnNote)
+ 660                assert isinstance(op[2], AnnNote)
+ 661                # color the changed note/rest/chord (in both scores)
+ 662                # using Visualization.CHANGED_COLOR
+ 663                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 664                if t.TYPE_CHECKING:
+ 665                    assert note1 is not None
+ 666                note1.style.color = Visualization.CHANGED_COLOR
+ 667                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 668                textExp.style.color = Visualization.CHANGED_COLOR
+ 669                note1.activeSite.insert(note1.offset, textExp)
+ 670
+ 671                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 672                if t.TYPE_CHECKING:
+ 673                    assert note2 is not None
+ 674                note2.style.color = Visualization.CHANGED_COLOR
+ 675                textExp = m21.expressions.TextExpression("changed grace note slash")
+ 676                textExp.style.color = Visualization.CHANGED_COLOR
+ 677                note2.activeSite.insert(note2.offset, textExp)
+ 678
+ 679            # beam
+ 680            elif op[0] == "insbeam":
+ 681                assert isinstance(op[1], AnnNote)
+ 682                assert isinstance(op[2], AnnNote)
+ 683                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 684                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 685                if t.TYPE_CHECKING:
+ 686                    assert note1 is not None
+ 687                note1.style.color = Visualization.INSERTED_COLOR
+ 688                if hasattr(note1, "beams"):
+ 689                    for beam in note1.beams:
+ 690                        beam.style.color = (
+ 691                            Visualization.INSERTED_COLOR
+ 692                        )  # this apparently does nothing
+ 693                textExp = m21.expressions.TextExpression("increased flags")
+ 694                textExp.style.color = Visualization.INSERTED_COLOR
+ 695                note1.activeSite.insert(note1.offset, textExp)
+ 696
+ 697                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 698                if t.TYPE_CHECKING:
+ 699                    assert note2 is not None
+ 700                note2.style.color = Visualization.INSERTED_COLOR
+ 701                if hasattr(note2, "beams"):
+ 702                    for beam in note2.beams:
+ 703                        beam.style.color = (
+ 704                            Visualization.INSERTED_COLOR
+ 705                        )  # this apparently does nothing
+ 706                textExp = m21.expressions.TextExpression("increased flags")
+ 707                textExp.style.color = Visualization.INSERTED_COLOR
+ 708                note2.activeSite.insert(note2.offset, textExp)
+ 709
+ 710            elif op[0] == "delbeam":
+ 711                assert isinstance(op[1], AnnNote)
+ 712                assert isinstance(op[2], AnnNote)
+ 713                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 714                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 715                if t.TYPE_CHECKING:
+ 716                    assert note1 is not None
+ 717                note1.style.color = Visualization.DELETED_COLOR
+ 718                if hasattr(note1, "beams"):
+ 719                    for beam in note1.beams:
+ 720                        beam.style.color = (
+ 721                            Visualization.DELETED_COLOR
+ 722                        )  # this apparently does nothing
+ 723                textExp = m21.expressions.TextExpression("decreased flags")
+ 724                textExp.style.color = Visualization.CHANGED_COLOR
+ 725                note1.activeSite.insert(note1.offset, textExp)
+ 726
+ 727                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 728                if t.TYPE_CHECKING:
+ 729                    assert note2 is not None
+ 730                note2.style.color = Visualization.DELETED_COLOR
+ 731                if hasattr(note2, "beams"):
+ 732                    for beam in note2.beams:
+ 733                        beam.style.color = (
+ 734                            Visualization.DELETED_COLOR
+ 735                        )  # this apparently does nothing
+ 736                textExp = m21.expressions.TextExpression("decreased flags")
+ 737                textExp.style.color = Visualization.DELETED_COLOR
+ 738                note2.activeSite.insert(note2.offset, textExp)
+ 739
+ 740            elif op[0] == "editbeam":
+ 741                assert isinstance(op[1], AnnNote)
+ 742                assert isinstance(op[2], AnnNote)
+ 743                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+ 744                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 745                if t.TYPE_CHECKING:
+ 746                    assert note1 is not None
+ 747                note1.style.color = Visualization.CHANGED_COLOR
+ 748                if hasattr(note1, "beams"):
+ 749                    for beam in note1.beams:
+ 750                        beam.style.color = (
+ 751                            Visualization.CHANGED_COLOR
+ 752                        )  # this apparently does nothing
+ 753                textExp = m21.expressions.TextExpression("changed flags")
+ 754                textExp.style.color = Visualization.CHANGED_COLOR
+ 755                note1.activeSite.insert(note1.offset, textExp)
+ 756
+ 757                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 758                if t.TYPE_CHECKING:
+ 759                    assert note2 is not None
+ 760                note2.style.color = Visualization.CHANGED_COLOR
+ 761                if hasattr(note2, "beams"):
+ 762                    for beam in note2.beams:
+ 763                        beam.style.color = (
+ 764                            Visualization.CHANGED_COLOR
+ 765                        )  # this apparently does nothing
+ 766                textExp = m21.expressions.TextExpression("changed flags")
+ 767                textExp.style.color = Visualization.CHANGED_COLOR
+ 768                note2.activeSite.insert(note2.offset, textExp)
+ 769
+ 770            elif op[0] == "editnoteshape":
+ 771                assert isinstance(op[1], AnnNote)
+ 772                assert isinstance(op[2], AnnNote)
+ 773                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 774                if t.TYPE_CHECKING:
+ 775                    assert note1 is not None
+ 776                note1.style.color = Visualization.CHANGED_COLOR
+ 777                textExp = m21.expressions.TextExpression("changed note shape")
+ 778                textExp.style.color = Visualization.CHANGED_COLOR
+ 779                note1.activeSite.insert(note1.offset, textExp)
+ 780
+ 781                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 782                if t.TYPE_CHECKING:
+ 783                    assert note2 is not None
+ 784                note2.style.color = Visualization.CHANGED_COLOR
+ 785                textExp = m21.expressions.TextExpression("changed note shape")
+ 786                textExp.style.color = Visualization.CHANGED_COLOR
+ 787                note2.activeSite.insert(note2.offset, textExp)
+ 788
+ 789            elif op[0] == "editnoteheadfill":
+ 790                assert isinstance(op[1], AnnNote)
+ 791                assert isinstance(op[2], AnnNote)
+ 792                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 793                if t.TYPE_CHECKING:
+ 794                    assert note1 is not None
+ 795                note1.style.color = Visualization.CHANGED_COLOR
+ 796                textExp = m21.expressions.TextExpression("changed note head fill")
+ 797                textExp.style.color = Visualization.CHANGED_COLOR
+ 798                note1.activeSite.insert(note1.offset, textExp)
+ 799
+ 800                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 801                if t.TYPE_CHECKING:
+ 802                    assert note2 is not None
+ 803                note2.style.color = Visualization.CHANGED_COLOR
+ 804                textExp = m21.expressions.TextExpression("changed note head fill")
+ 805                textExp.style.color = Visualization.CHANGED_COLOR
+ 806                note2.activeSite.insert(note2.offset, textExp)
+ 807
+ 808            elif op[0] == "editnoteheadparenthesis":
+ 809                assert isinstance(op[1], AnnNote)
+ 810                assert isinstance(op[2], AnnNote)
+ 811                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 812                if t.TYPE_CHECKING:
+ 813                    assert note1 is not None
+ 814                note1.style.color = Visualization.CHANGED_COLOR
+ 815                textExp = m21.expressions.TextExpression("changed note head paren")
+ 816                textExp.style.color = Visualization.CHANGED_COLOR
+ 817                note1.activeSite.insert(note1.offset, textExp)
+ 818
+ 819                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 820                if t.TYPE_CHECKING:
+ 821                    assert note2 is not None
+ 822                note2.style.color = Visualization.CHANGED_COLOR
+ 823                textExp = m21.expressions.TextExpression("changed note head paren")
+ 824                textExp.style.color = Visualization.CHANGED_COLOR
+ 825                note2.activeSite.insert(note2.offset, textExp)
+ 826
+ 827            elif op[0] == "editstemdirection":
+ 828                assert isinstance(op[1], AnnNote)
+ 829                assert isinstance(op[2], AnnNote)
+ 830                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 831                if t.TYPE_CHECKING:
+ 832                    assert note1 is not None
+ 833                note1.style.color = Visualization.CHANGED_COLOR
+ 834                textExp = m21.expressions.TextExpression("changed stem direction")
+ 835                textExp.style.color = Visualization.CHANGED_COLOR
+ 836                note1.activeSite.insert(note1.offset, textExp)
+ 837
+ 838                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 839                if t.TYPE_CHECKING:
+ 840                    assert note2 is not None
+ 841                note2.style.color = Visualization.CHANGED_COLOR
+ 842                textExp = m21.expressions.TextExpression("changed stem direction")
+ 843                textExp.style.color = Visualization.CHANGED_COLOR
+ 844                note2.activeSite.insert(note2.offset, textExp)
+ 845
+ 846            elif op[0] == "editstyle":
+ 847                assert isinstance(op[1], AnnNote)
+ 848                assert isinstance(op[2], AnnNote)
+ 849                sd1 = op[1].styledict
+ 850                sd2 = op[2].styledict
+ 851                changedStr = ""
+ 852                for k1, v1 in sd1.items():
+ 853                    if k1 not in sd2 or sd2[k1] != v1:
+ 854                        if changedStr:
+ 855                            changedStr += ","
+ 856                        changedStr += k1
+ 857
+ 858                # one last thing: check for keys in sd2 that aren't in sd1
+ 859                for k2 in sd2:
+ 860                    if k2 not in sd1:
+ 861                        if changedStr:
+ 862                            changedStr += ","
+ 863                        changedStr += k2
+ 864
+ 865                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 866                if t.TYPE_CHECKING:
+ 867                    assert note1 is not None
+ 868                note1.style.color = Visualization.CHANGED_COLOR
+ 869                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 870                textExp.style.color = Visualization.CHANGED_COLOR
+ 871                note1.activeSite.insert(note1.offset, textExp)
+ 872
+ 873                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 874                if t.TYPE_CHECKING:
+ 875                    assert note2 is not None
+ 876                note2.style.color = Visualization.CHANGED_COLOR
+ 877                textExp = m21.expressions.TextExpression(f"changed note {changedStr}")
+ 878                textExp.style.color = Visualization.CHANGED_COLOR
+ 879                note2.activeSite.insert(note2.offset, textExp)
+ 880
+ 881            # accident
+ 882            elif op[0] == "accidentins":
+ 883                assert isinstance(op[1], AnnNote)
+ 884                assert isinstance(op[2], AnnNote)
+ 885                assert len(op) == 5  # the indices must be there
+ 886                # color the modified note in both scores using Visualization.INSERTED_COLOR
+ 887                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 888                if t.TYPE_CHECKING:
+ 889                    assert chord1 is not None
+ 890                note1 = chord1
+ 891                if "Chord" in chord1.classes:
+ 892                    # color only the indexed note's accidental in the chord
+ 893                    idx = op[4][0]
+ 894                    note1 = chord1.notes[idx]
+ 895                if t.TYPE_CHECKING:
+ 896                    assert note1 is not None
+ 897                if note1.pitch.accidental:
+ 898                    note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 899                note1.style.color = Visualization.INSERTED_COLOR
+ 900                textExp = m21.expressions.TextExpression("inserted accidental")
+ 901                textExp.style.color = Visualization.INSERTED_COLOR
+ 902                if note1.activeSite is not None:
+ 903                    note1.activeSite.insert(note1.offset, textExp)
+ 904                else:
+ 905                    chord1.activeSite.insert(chord1.offset, textExp)
+ 906
+ 907                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 908                if t.TYPE_CHECKING:
+ 909                    assert chord2 is not None
+ 910                note2 = chord2
+ 911                if "Chord" in chord2.classes:
+ 912                    # color only the indexed note's accidental in the chord
+ 913                    idx = op[4][1]
+ 914                    note2 = chord2.notes[idx]
+ 915                if t.TYPE_CHECKING:
+ 916                    assert note2 is not None
+ 917                if note2.pitch.accidental:
+ 918                    note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR
+ 919                note2.style.color = Visualization.INSERTED_COLOR
+ 920                textExp = m21.expressions.TextExpression("inserted accidental")
+ 921                textExp.style.color = Visualization.INSERTED_COLOR
+ 922                if note2.activeSite is not None:
+ 923                    note2.activeSite.insert(note2.offset, textExp)
+ 924                else:
+ 925                    chord2.activeSite.insert(chord2.offset, textExp)
+ 926
+ 927            elif op[0] == "accidentdel":
+ 928                assert isinstance(op[1], AnnNote)
+ 929                assert isinstance(op[2], AnnNote)
+ 930                assert len(op) == 5  # the indices must be there
+ 931                # color the modified note in both scores using Visualization.DELETED_COLOR
+ 932                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 933                if t.TYPE_CHECKING:
+ 934                    assert chord1 is not None
+ 935                note1 = chord1
+ 936                if "Chord" in chord1.classes:
+ 937                    # color only the indexed note's accidental in the chord
+ 938                    idx = op[4][0]
+ 939                    note1 = chord1.notes[idx]
+ 940                if t.TYPE_CHECKING:
+ 941                    assert note1 is not None
+ 942                if note1.pitch.accidental:
+ 943                    note1.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 944                note1.style.color = Visualization.DELETED_COLOR
+ 945                textExp = m21.expressions.TextExpression("deleted accidental")
+ 946                textExp.style.color = Visualization.DELETED_COLOR
+ 947                if note1.activeSite is not None:
+ 948                    note1.activeSite.insert(note1.offset, textExp)
+ 949                else:
+ 950                    chord1.activeSite.insert(chord1.offset, textExp)
+ 951
+ 952                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 953                if t.TYPE_CHECKING:
+ 954                    assert chord2 is not None
+ 955                note2 = chord2
+ 956                if "Chord" in chord2.classes:
+ 957                    # color only the indexed note's accidental in the chord
+ 958                    idx = op[4][1]
+ 959                    note2 = chord2.notes[idx]
+ 960                if t.TYPE_CHECKING:
+ 961                    assert note2 is not None
+ 962                if note2.pitch.accidental:
+ 963                    note2.pitch.accidental.style.color = Visualization.DELETED_COLOR
+ 964                note2.style.color = Visualization.DELETED_COLOR
+ 965                textExp = m21.expressions.TextExpression("deleted accidental")
+ 966                textExp.style.color = Visualization.DELETED_COLOR
+ 967                if note2.activeSite is not None:
+ 968                    note2.activeSite.insert(note2.offset, textExp)
+ 969                else:
+ 970                    chord2.activeSite.insert(chord2.offset, textExp)
+ 971
+ 972            elif op[0] == "accidentedit":
+ 973                assert isinstance(op[1], AnnNote)
+ 974                assert isinstance(op[2], AnnNote)
+ 975                assert len(op) == 5  # the indices must be there
+ 976                # color the changed accidental (in both scores)
+ 977                # using Visualization.CHANGED_COLOR
+ 978                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+ 979                if t.TYPE_CHECKING:
+ 980                    assert chord1 is not None
+ 981                note1 = chord1
+ 982                if "Chord" in chord1.classes:
+ 983                    # color just the indexed note in the chord
+ 984                    idx = op[4][0]
+ 985                    note1 = chord1.notes[idx]
+ 986                if t.TYPE_CHECKING:
+ 987                    assert note1 is not None
+ 988                if note1.pitch.accidental:
+ 989                    note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+ 990                note1.style.color = Visualization.CHANGED_COLOR
+ 991                textExp = m21.expressions.TextExpression("changed accidental")
+ 992                textExp.style.color = Visualization.CHANGED_COLOR
+ 993                if note1.activeSite is not None:
+ 994                    note1.activeSite.insert(note1.offset, textExp)
+ 995                else:
+ 996                    chord1.activeSite.insert(chord1.offset, textExp)
+ 997
+ 998                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+ 999                if t.TYPE_CHECKING:
+1000                    assert chord2 is not None
+1001                note2 = chord2
+1002                if "Chord" in chord2.classes:
+1003                    # color just the indexed note in the chord
+1004                    idx = op[4][1]
+1005                    note2 = chord2.notes[idx]
+1006                if t.TYPE_CHECKING:
+1007                    assert note2 is not None
+1008                if note2.pitch.accidental:
+1009                    note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR
+1010                note2.style.color = Visualization.CHANGED_COLOR
+1011                textExp = m21.expressions.TextExpression("changed accidental")
+1012                textExp.style.color = Visualization.CHANGED_COLOR
+1013                if note2.activeSite is not None:
+1014                    note2.activeSite.insert(note2.offset, textExp)
+1015                else:
+1016                    chord2.activeSite.insert(chord2.offset, textExp)
+1017
+1018            elif op[0] == "dotins":
+1019                assert isinstance(op[1], AnnNote)
+1020                assert isinstance(op[2], AnnNote)
+1021                # In music21, the dots are not separately colorable from the note,
+1022                # so we will just color the modified note here in both scores,
+1023                # using Visualization.CHANGED_COLOR
+1024                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1025                if t.TYPE_CHECKING:
+1026                    assert note1 is not None
+1027                note1.style.color = Visualization.CHANGED_COLOR
+1028                textExp = m21.expressions.TextExpression("inserted dot")
+1029                textExp.style.color = Visualization.CHANGED_COLOR
+1030                note1.activeSite.insert(note1.offset, textExp)
+1031
+1032                if t.TYPE_CHECKING:
+1033                    assert note2 is not None
+1034                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1035                note2.style.color = Visualization.CHANGED_COLOR
+1036                textExp = m21.expressions.TextExpression("inserted dot")
+1037                textExp.style.color = Visualization.CHANGED_COLOR
+1038                note2.activeSite.insert(note2.offset, textExp)
+1039
+1040            elif op[0] == "dotdel":
+1041                assert isinstance(op[1], AnnNote)
+1042                assert isinstance(op[2], AnnNote)
+1043                # In music21, the dots are not separately colorable from the note,
+1044                # so we will just color the modified note here in both scores,
+1045                # using Visualization.CHANGED_COLOR
+1046                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1047                if t.TYPE_CHECKING:
+1048                    assert note1 is not None
+1049                note1.style.color = Visualization.CHANGED_COLOR
+1050                textExp = m21.expressions.TextExpression("deleted dot")
+1051                textExp.style.color = Visualization.CHANGED_COLOR
+1052                note1.activeSite.insert(note1.offset, textExp)
+1053
+1054                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1055                if t.TYPE_CHECKING:
+1056                    assert note2 is not None
+1057                note2.style.color = Visualization.CHANGED_COLOR
+1058                textExp = m21.expressions.TextExpression("deleted dot")
+1059                textExp.style.color = Visualization.CHANGED_COLOR
+1060                note2.activeSite.insert(note2.offset, textExp)
+1061
+1062            # tuplets
+1063            elif op[0] == "instuplet":
+1064                assert isinstance(op[1], AnnNote)
+1065                assert isinstance(op[2], AnnNote)
+1066                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1067                if t.TYPE_CHECKING:
+1068                    assert note1 is not None
+1069                note1.style.color = Visualization.CHANGED_COLOR
+1070                textExp = m21.expressions.TextExpression("inserted tuplet")
+1071                textExp.style.color = Visualization.CHANGED_COLOR
+1072                note1.activeSite.insert(note1.offset, textExp)
+1073
+1074                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1075                if t.TYPE_CHECKING:
+1076                    assert note2 is not None
+1077                note2.style.color = Visualization.CHANGED_COLOR
+1078                textExp = m21.expressions.TextExpression("inserted tuplet")
+1079                textExp.style.color = Visualization.CHANGED_COLOR
+1080                note2.activeSite.insert(note2.offset, textExp)
+1081
+1082            elif op[0] == "deltuplet":
+1083                assert isinstance(op[1], AnnNote)
+1084                assert isinstance(op[2], AnnNote)
+1085                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1086                if t.TYPE_CHECKING:
+1087                    assert note1 is not None
+1088                note1.style.color = Visualization.CHANGED_COLOR
+1089                textExp = m21.expressions.TextExpression("deleted tuplet")
+1090                textExp.style.color = Visualization.CHANGED_COLOR
+1091                note1.activeSite.insert(note1.offset, textExp)
+1092
+1093                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1094                if t.TYPE_CHECKING:
+1095                    assert note2 is not None
+1096                note2.style.color = Visualization.CHANGED_COLOR
+1097                textExp = m21.expressions.TextExpression("deleted tuplet")
+1098                textExp.style.color = Visualization.CHANGED_COLOR
+1099                note2.activeSite.insert(note2.offset, textExp)
+1100
+1101            elif op[0] == "edittuplet":
+1102                assert isinstance(op[1], AnnNote)
+1103                assert isinstance(op[2], AnnNote)
+1104                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1105                if t.TYPE_CHECKING:
+1106                    assert note1 is not None
+1107                note1.style.color = Visualization.CHANGED_COLOR
+1108                textExp = m21.expressions.TextExpression("changed tuplet")
+1109                textExp.style.color = Visualization.CHANGED_COLOR
+1110                note1.activeSite.insert(note1.offset, textExp)
+1111
+1112                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1113                if t.TYPE_CHECKING:
+1114                    assert note2 is not None
+1115                note2.style.color = Visualization.CHANGED_COLOR
+1116                textExp = m21.expressions.TextExpression("changed tuplet")
+1117                textExp.style.color = Visualization.CHANGED_COLOR
+1118                note2.activeSite.insert(note2.offset, textExp)
+1119
+1120            # ties
+1121            elif op[0] == "tieins":
+1122                assert isinstance(op[1], AnnNote)
+1123                assert isinstance(op[2], AnnNote)
+1124                assert len(op) == 5  # the indices must be there
+1125                # Color the modified note here in both scores,
+1126                # using Visualization.INSERTED_COLOR
+1127                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1128                if t.TYPE_CHECKING:
+1129                    assert chord1 is not None
+1130                note1 = chord1
+1131                if "Chord" in chord1.classes:
+1132                    # color just the indexed note in the chord
+1133                    idx = op[4][0]
+1134                    note1 = chord1.notes[idx]
+1135                if t.TYPE_CHECKING:
+1136                    assert note1 is not None
+1137                note1.style.color = Visualization.INSERTED_COLOR
+1138                textExp = m21.expressions.TextExpression("inserted tie")
+1139                textExp.style.color = Visualization.INSERTED_COLOR
+1140                if note1.activeSite is not None:
+1141                    note1.activeSite.insert(note1.offset, textExp)
+1142                else:
+1143                    chord1.activeSite.insert(chord1.offset, textExp)
+1144
+1145                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1146                if t.TYPE_CHECKING:
+1147                    assert chord2 is not None
+1148                note2 = chord2
+1149                if "Chord" in chord2.classes:
+1150                    # color just the indexed note in the chord
+1151                    idx = op[4][1]
+1152                    note2 = chord2.notes[idx]
+1153                if t.TYPE_CHECKING:
+1154                    assert note2 is not None
+1155                note2.style.color = Visualization.INSERTED_COLOR
+1156                textExp = m21.expressions.TextExpression("inserted tie")
+1157                textExp.style.color = Visualization.INSERTED_COLOR
+1158                if note2.activeSite is not None:
+1159                    note2.activeSite.insert(note2.offset, textExp)
+1160                else:
+1161                    chord2.activeSite.insert(chord2.offset, textExp)
+1162
+1163            elif op[0] == "tiedel":
+1164                assert isinstance(op[1], AnnNote)
+1165                assert isinstance(op[2], AnnNote)
+1166                assert len(op) == 5  # the indices must be there
+1167                # Color the modified note in both scores, using Visualization.DELETED_COLOR
+1168                chord1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1169                if t.TYPE_CHECKING:
+1170                    assert chord1 is not None
+1171                note1 = chord1
+1172                if "Chord" in chord1.classes:
+1173                    # color just the indexed note in the chord
+1174                    idx = op[4][0]
+1175                    note1 = chord1.notes[idx]
+1176                if t.TYPE_CHECKING:
+1177                    assert note1 is not None
+1178                note1.style.color = Visualization.DELETED_COLOR
+1179                textExp = m21.expressions.TextExpression("deleted tie")
+1180                textExp.style.color = Visualization.DELETED_COLOR
+1181                if note1.activeSite is not None:
+1182                    note1.activeSite.insert(note1.offset, textExp)
+1183                else:
+1184                    chord1.activeSite.insert(chord1.offset, textExp)
+1185
+1186                chord2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1187                if t.TYPE_CHECKING:
+1188                    assert chord2 is not None
+1189                note2 = chord2
+1190                if "Chord" in chord2.classes:
+1191                    # color just the indexed note in the chord
+1192                    idx = op[4][1]
+1193                    note2 = chord2.notes[idx]
+1194                if t.TYPE_CHECKING:
+1195                    assert note2 is not None
+1196                note2.style.color = Visualization.DELETED_COLOR
+1197                textExp = m21.expressions.TextExpression("deleted tie")
+1198                textExp.style.color = Visualization.DELETED_COLOR
+1199                if note2.activeSite is not None:
+1200                    note2.activeSite.insert(note2.offset, textExp)
+1201                else:
+1202                    chord2.activeSite.insert(chord2.offset, textExp)
+1203
+1204            # expressions
+1205            elif op[0] == "insexpression":
+1206                assert isinstance(op[1], AnnNote)
+1207                assert isinstance(op[2], AnnNote)
+1208                # color the note in both scores using Visualization.INSERTED_COLOR
+1209                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1210                if t.TYPE_CHECKING:
+1211                    assert note1 is not None
+1212                note1.style.color = Visualization.INSERTED_COLOR
+1213                textExp = m21.expressions.TextExpression("inserted expression")
+1214                textExp.style.color = Visualization.INSERTED_COLOR
+1215                note1.activeSite.insert(note1.offset, textExp)
+1216
+1217                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1218                if t.TYPE_CHECKING:
+1219                    assert note2 is not None
+1220                note2.style.color = Visualization.INSERTED_COLOR
+1221                textExp = m21.expressions.TextExpression("inserted expression")
+1222                textExp.style.color = Visualization.INSERTED_COLOR
+1223                note2.activeSite.insert(note2.offset, textExp)
+1224
+1225            elif op[0] == "delexpression":
+1226                assert isinstance(op[1], AnnNote)
+1227                assert isinstance(op[2], AnnNote)
+1228                # color the deleted expression in score1 using Visualization.DELETED_COLOR
+1229                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1230                if t.TYPE_CHECKING:
+1231                    assert note1 is not None
+1232                note1.style.color = Visualization.DELETED_COLOR
+1233                textExp = m21.expressions.TextExpression("deleted expression")
+1234                textExp.style.color = Visualization.DELETED_COLOR
+1235                note1.activeSite.insert(note1.offset, textExp)
+1236
+1237                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1238                if t.TYPE_CHECKING:
+1239                    assert note2 is not None
+1240                note2.style.color = Visualization.DELETED_COLOR
+1241                textExp = m21.expressions.TextExpression("deleted expression")
+1242                textExp.style.color = Visualization.DELETED_COLOR
+1243                note2.activeSite.insert(note2.offset, textExp)
+1244
+1245            elif op[0] == "editexpression":
+1246                assert isinstance(op[1], AnnNote)
+1247                assert isinstance(op[2], AnnNote)
+1248                # color the changed beam (in both scores) using Visualization.CHANGED_COLOR
+1249                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1250                if t.TYPE_CHECKING:
+1251                    assert note1 is not None
+1252                note1.style.color = Visualization.CHANGED_COLOR
+1253                textExp = m21.expressions.TextExpression("changed expression")
+1254                textExp.style.color = Visualization.CHANGED_COLOR
+1255                note1.activeSite.insert(note1.offset, textExp)
+1256
+1257                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1258                if t.TYPE_CHECKING:
+1259                    assert note2 is not None
+1260                note2.style.color = Visualization.CHANGED_COLOR
+1261                textExp = m21.expressions.TextExpression("changed expression")
+1262                textExp.style.color = Visualization.CHANGED_COLOR
+1263                note2.activeSite.insert(note2.offset, textExp)
+1264
+1265            # articulations
+1266            elif op[0] == "insarticulation":
+1267                assert isinstance(op[1], AnnNote)
+1268                assert isinstance(op[2], AnnNote)
+1269                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1270                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1271                if t.TYPE_CHECKING:
+1272                    assert note1 is not None
+1273                note1.style.color = Visualization.INSERTED_COLOR
+1274                textExp = m21.expressions.TextExpression("inserted articulation")
+1275                textExp.style.color = Visualization.INSERTED_COLOR
+1276                note1.activeSite.insert(note1.offset, textExp)
+1277
+1278                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1279                if t.TYPE_CHECKING:
+1280                    assert note2 is not None
+1281                note2.style.color = Visualization.INSERTED_COLOR
+1282                textExp = m21.expressions.TextExpression("inserted articulation")
+1283                textExp.style.color = Visualization.INSERTED_COLOR
+1284                note2.activeSite.insert(note2.offset, textExp)
+1285
+1286            elif op[0] == "delarticulation":
+1287                assert isinstance(op[1], AnnNote)
+1288                assert isinstance(op[2], AnnNote)
+1289                # color the modified note in both scores using Visualization.DELETED_COLOR
+1290                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1291                if t.TYPE_CHECKING:
+1292                    assert note1 is not None
+1293                note1.style.color = Visualization.DELETED_COLOR
+1294                textExp = m21.expressions.TextExpression("deleted articulation")
+1295                textExp.style.color = Visualization.DELETED_COLOR
+1296                note1.activeSite.insert(note1.offset, textExp)
+1297
+1298                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1299                if t.TYPE_CHECKING:
+1300                    assert note2 is not None
+1301                note2.style.color = Visualization.DELETED_COLOR
+1302                textExp = m21.expressions.TextExpression("deleted articulation")
+1303                textExp.style.color = Visualization.DELETED_COLOR
+1304                note2.activeSite.insert(note2.offset, textExp)
+1305
+1306            elif op[0] == "editarticulation":
+1307                assert isinstance(op[1], AnnNote)
+1308                assert isinstance(op[2], AnnNote)
+1309                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1310                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1311                if t.TYPE_CHECKING:
+1312                    assert note1 is not None
+1313                note1.style.color = Visualization.CHANGED_COLOR
+1314                textExp = m21.expressions.TextExpression("changed articulation")
+1315                textExp.style.color = Visualization.CHANGED_COLOR
+1316                note1.activeSite.insert(note1.offset, textExp)
+1317
+1318                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1319                if t.TYPE_CHECKING:
+1320                    assert note2 is not None
+1321                note2.style.color = Visualization.CHANGED_COLOR
+1322                textExp = m21.expressions.TextExpression("changed articulation")
+1323                textExp.style.color = Visualization.CHANGED_COLOR
+1324                note2.activeSite.insert(note2.offset, textExp)
+1325
+1326            # lyrics
+1327            elif op[0] == "inslyric":
+1328                assert isinstance(op[1], AnnNote)
+1329                assert isinstance(op[2], AnnNote)
+1330                # color the modified note in both scores using Visualization.INSERTED_COLOR
+1331                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1332                if t.TYPE_CHECKING:
+1333                    assert note1 is not None
+1334                note1.style.color = Visualization.INSERTED_COLOR
+1335                textExp = m21.expressions.TextExpression("inserted lyric")
+1336                textExp.style.color = Visualization.INSERTED_COLOR
+1337                note1.activeSite.insert(note1.offset, textExp)
+1338
+1339                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1340                if t.TYPE_CHECKING:
+1341                    assert note2 is not None
+1342                note2.style.color = Visualization.INSERTED_COLOR
+1343                textExp = m21.expressions.TextExpression("inserted lyric")
+1344                textExp.style.color = Visualization.INSERTED_COLOR
+1345                note2.activeSite.insert(note2.offset, textExp)
+1346
+1347            elif op[0] == "dellyric":
+1348                assert isinstance(op[1], AnnNote)
+1349                assert isinstance(op[2], AnnNote)
+1350                # color the modified note in both scores using Visualization.DELETED_COLOR
+1351                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1352                if t.TYPE_CHECKING:
+1353                    assert note1 is not None
+1354                note1.style.color = Visualization.DELETED_COLOR
+1355                textExp = m21.expressions.TextExpression("deleted lyric")
+1356                textExp.style.color = Visualization.DELETED_COLOR
+1357                note1.activeSite.insert(note1.offset, textExp)
+1358
+1359                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1360                if t.TYPE_CHECKING:
+1361                    assert note2 is not None
+1362                note2.style.color = Visualization.DELETED_COLOR
+1363                textExp = m21.expressions.TextExpression("deleted lyric")
+1364                textExp.style.color = Visualization.DELETED_COLOR
+1365                note2.activeSite.insert(note2.offset, textExp)
+1366
+1367            elif op[0] == "editlyric":
+1368                assert isinstance(op[1], AnnNote)
+1369                assert isinstance(op[2], AnnNote)
+1370                # color the modified note (in both scores) using Visualization.CHANGED_COLOR
+1371                note1 = score1.recurse().getElementById(op[1].general_note)  # type: ignore
+1372                if t.TYPE_CHECKING:
+1373                    assert note1 is not None
+1374                note1.style.color = Visualization.CHANGED_COLOR
+1375                textExp = m21.expressions.TextExpression("changed lyric")
+1376                textExp.style.color = Visualization.CHANGED_COLOR
+1377                note1.activeSite.insert(note1.offset, textExp)
+1378
+1379                note2 = score2.recurse().getElementById(op[2].general_note)  # type: ignore
+1380                if t.TYPE_CHECKING:
+1381                    assert note2 is not None
+1382                note2.style.color = Visualization.CHANGED_COLOR
+1383                textExp = m21.expressions.TextExpression("changed lyric")
+1384                textExp.style.color = Visualization.CHANGED_COLOR
+1385                note2.activeSite.insert(note2.offset, textExp)
+1386
+1387            else:
+1388                print(
+1389                    f"Annotation type {op[0]} not yet supported for visualization",
+1390                    file=sys.stderr
+1391                )
+
-

Mark up two music21 scores with the differences described by an operations -list (e.g. a list returned from musicdiff.Comparison.annotated_scores_diff).

+list (e.g. a list returned from musicdiff.Comparison.annotated_scores_diff).

-
Args
+
Arguments:
  • score1 (music21.stream.Score): The first score to mark up
  • score2 (music21.stream.Score): The second score to mark up
  • -
  • operations (List[Tuple]): The operations list that describes the difference +
  • operations (list[tuple]): The operations list that describes the difference between the two scores
@@ -2911,79 +4378,77 @@
Args
-
#   + +
+
@staticmethod
-
@staticmethod
- - def - show_diffs( - score1: music21.stream.base.Score, - score2: music21.stream.base.Score, - out_path1: Union[str, pathlib.Path] = None, - out_path2: Union[str, pathlib.Path] = None -): -
+ def + show_diffs( score1: music21.stream.base.Score, score2: music21.stream.base.Score, out_path1: str | pathlib.Path | None = None, out_path2: str | pathlib.Path | None = None) -> None: -
- View Source -
    @staticmethod
-    def show_diffs(score1: m21.stream.Score,
-                   score2: m21.stream.Score,
-                   out_path1: Union[str, Path] = None,
-                   out_path2: Union[str, Path] = None):
-        """
-        Render two (presumably marked-up) music21 scores.  If both out_path1 and out_path2 are not None,
-        save the rendered PDFs at those two locations, otherwise just display them using the default
-        PDF viewer on the system.
+                
 
-        Args:
-            score1 (music21.stream.Score): The first score to render
-            score2 (music21.stream.Score): The second score to render
-            out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
-                If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
-                (default is None)
-            out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
-                If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
-                (default is None)
-        """
-        # display the two (presumably annotated) scores
-        originalComposer1: str = None
-        originalComposer2: str = None
-
-        if score1.metadata is None:
-            score1.metadata = m21.metadata.Metadata()
-        if score2.metadata is None:
-            score2.metadata = m21.metadata.Metadata()
-
-        originalComposer1 = score1.metadata.composer
-        if originalComposer1 is None:
-            score1.metadata.composer = "score1"
-        else:
-            score1.metadata.composer = "score1          " + originalComposer1
-
-        originalComposer2 = score2.metadata.composer
-        if originalComposer2 is None:
-            score2.metadata.composer = "score2"
-        else:
-            score2.metadata.composer = "score2          " + originalComposer2
-
-        #save files if requested
-        if (out_path1 is not None) and (out_path2 is not None):
-            score1.write("musicxml.pdf", makeNotation=False, fp=out_path1)
-            score2.write("musicxml.pdf", makeNotation=False, fp=out_path2)
-            print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr)
-        else: # just display the scores
-            score1.show("musicxml.pdf", makeNotation=False)
-            score2.show("musicxml.pdf", makeNotation=False)
-
- -
- -

Render two (presumably marked-up) music21 scores. If both out_path1 and out_path2 are not None, -save the rendered PDFs at those two locations, otherwise just display them using the default -PDF viewer on the system.

- -
Args
+
+ +
1393    @staticmethod
+1394    def show_diffs(
+1395        score1: m21.stream.Score,
+1396        score2: m21.stream.Score,
+1397        out_path1: str | Path | None = None,
+1398        out_path2: str | Path | None = None
+1399    ) -> None:
+1400        """
+1401        Render two (presumably marked-up) music21 scores.  If both out_path1 and
+1402        out_path2 are not None, save the rendered PDFs at those two locations,
+1403        otherwise just display them using the default PDF viewer on the system.
+1404
+1405        Args:
+1406            score1 (music21.stream.Score): The first score to render
+1407            score2 (music21.stream.Score): The second score to render
+1408            out_path1 (str, Path): Where to save the first marked-up rendered score PDF.
+1409                If out_path1 is None, both PDFs will be displayed in the default PDF viewer.
+1410                (default is None)
+1411            out_path2 (str, Path): Where to save the second marked-up rendered score PDF.
+1412                If out_path2 is None, both PDFs will be displayed in the default PDF viewer.
+1413                (default is None)
+1414        """
+1415        # display the two (presumably annotated) scores
+1416        originalComposer1: str | None = None
+1417        originalComposer2: str | None = None
+1418
+1419        if score1.metadata is None:
+1420            score1.metadata = m21.metadata.Metadata()
+1421        if score2.metadata is None:
+1422            score2.metadata = m21.metadata.Metadata()
+1423
+1424        originalComposer1 = score1.metadata.composer
+1425        if originalComposer1 is None:
+1426            score1.metadata.composer = "score1"
+1427        else:
+1428            score1.metadata.composer = "score1          " + originalComposer1
+1429
+1430        originalComposer2 = score2.metadata.composer
+1431        if originalComposer2 is None:
+1432            score2.metadata.composer = "score2"
+1433        else:
+1434            score2.metadata.composer = "score2          " + originalComposer2
+1435
+1436        # save files if requested
+1437        if (out_path1 is not None) and (out_path2 is not None):
+1438            score1.write("musicxml.pdf", makeNotation=False, fp=out_path1)
+1439            score2.write("musicxml.pdf", makeNotation=False, fp=out_path2)
+1440            print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr)
+1441        else:
+1442            # just display the scores
+1443            score1.show("musicxml.pdf", makeNotation=False)
+1444            score2.show("musicxml.pdf", makeNotation=False)
+
+ + +

Render two (presumably marked-up) music21 scores. If both out_path1 and +out_path2 are not None, save the rendered PDFs at those two locations, +otherwise just display them using the default PDF viewer on the system.

+ +
Arguments:
  • score1 (music21.stream.Score): The first score to render
  • @@ -3101,9 +4566,13 @@
    Args
    } let heading; - switch (result.doc.type) { + switch (result.doc.kind) { case "function": - heading = `${doc.funcdef} ${doc.fullname}${doc.signature}:`; + if (doc.fullname.endsWith(".__init__")) { + heading = `${doc.fullname.replace(/\.__init__$/, "")}${doc.signature}`; + } else { + heading = `${doc.funcdef} ${doc.fullname}${doc.signature}`; + } break; case "class": heading = `class ${doc.fullname}`; @@ -3116,7 +4585,7 @@
    Args
    if (doc.annotation) heading += `${doc.annotation}`; if (doc.default_value) - heading += `${doc.default_value}`; + heading += ` = ${doc.default_value}`; break; default: heading = `${doc.fullname}`; @@ -3124,7 +4593,7 @@
    Args
    } html += `
    - ${heading} + ${heading}
    ${doc.doc}
    `; diff --git a/docs/search.js b/docs/search.js index da6bffd..7c21ca0 100644 --- a/docs/search.js +++ b/docs/search.js @@ -1,6 +1,6 @@ window.pdocSearch = (function(){ /** elasticlunr - http://weixsong.github.io * Copyright (C) 2017 Oliver Nightingale * Copyright (C) 2017 Wei Song * MIT Licensed */!function(){function e(e){if(null===e||"object"!=typeof e)return e;var t=e.constructor();for(var n in e)e.hasOwnProperty(n)&&(t[n]=e[n]);return t}var t=function(e){var n=new t.Index;return n.pipeline.add(t.trimmer,t.stopWordFilter,t.stemmer),e&&e.call(n,n),n};t.version="0.9.5",lunr=t,t.utils={},t.utils.warn=function(e){return function(t){e.console&&console.warn&&console.warn(t)}}(this),t.utils.toString=function(e){return void 0===e||null===e?"":e.toString()},t.EventEmitter=function(){this.events={}},t.EventEmitter.prototype.addListener=function(){var e=Array.prototype.slice.call(arguments),t=e.pop(),n=e;if("function"!=typeof t)throw new TypeError("last argument must be a function");n.forEach(function(e){this.hasHandler(e)||(this.events[e]=[]),this.events[e].push(t)},this)},t.EventEmitter.prototype.removeListener=function(e,t){if(this.hasHandler(e)){var n=this.events[e].indexOf(t);-1!==n&&(this.events[e].splice(n,1),0==this.events[e].length&&delete this.events[e])}},t.EventEmitter.prototype.emit=function(e){if(this.hasHandler(e)){var t=Array.prototype.slice.call(arguments,1);this.events[e].forEach(function(e){e.apply(void 0,t)},this)}},t.EventEmitter.prototype.hasHandler=function(e){return e in this.events},t.tokenizer=function(e){if(!arguments.length||null===e||void 0===e)return[];if(Array.isArray(e)){var n=e.filter(function(e){return null===e||void 0===e?!1:!0});n=n.map(function(e){return t.utils.toString(e).toLowerCase()});var i=[];return n.forEach(function(e){var n=e.split(t.tokenizer.seperator);i=i.concat(n)},this),i}return e.toString().trim().toLowerCase().split(t.tokenizer.seperator)},t.tokenizer.defaultSeperator=/[\s\-]+/,t.tokenizer.seperator=t.tokenizer.defaultSeperator,t.tokenizer.setSeperator=function(e){null!==e&&void 0!==e&&"object"==typeof e&&(t.tokenizer.seperator=e)},t.tokenizer.resetSeperator=function(){t.tokenizer.seperator=t.tokenizer.defaultSeperator},t.tokenizer.getSeperator=function(){return t.tokenizer.seperator},t.Pipeline=function(){this._queue=[]},t.Pipeline.registeredFunctions={},t.Pipeline.registerFunction=function(e,n){n in t.Pipeline.registeredFunctions&&t.utils.warn("Overwriting existing registered function: "+n),e.label=n,t.Pipeline.registeredFunctions[n]=e},t.Pipeline.getRegisteredFunction=function(e){return e in t.Pipeline.registeredFunctions!=!0?null:t.Pipeline.registeredFunctions[e]},t.Pipeline.warnIfFunctionNotRegistered=function(e){var n=e.label&&e.label in this.registeredFunctions;n||t.utils.warn("Function is not registered with pipeline. This may cause problems when serialising the index.\n",e)},t.Pipeline.load=function(e){var n=new t.Pipeline;return e.forEach(function(e){var i=t.Pipeline.getRegisteredFunction(e);if(!i)throw new Error("Cannot load un-registered function: "+e);n.add(i)}),n},t.Pipeline.prototype.add=function(){var e=Array.prototype.slice.call(arguments);e.forEach(function(e){t.Pipeline.warnIfFunctionNotRegistered(e),this._queue.push(e)},this)},t.Pipeline.prototype.after=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i+1,0,n)},t.Pipeline.prototype.before=function(e,n){t.Pipeline.warnIfFunctionNotRegistered(n);var i=this._queue.indexOf(e);if(-1===i)throw new Error("Cannot find existingFn");this._queue.splice(i,0,n)},t.Pipeline.prototype.remove=function(e){var t=this._queue.indexOf(e);-1!==t&&this._queue.splice(t,1)},t.Pipeline.prototype.run=function(e){for(var t=[],n=e.length,i=this._queue.length,o=0;n>o;o++){for(var r=e[o],s=0;i>s&&(r=this._queue[s](r,o,e),void 0!==r&&null!==r);s++);void 0!==r&&null!==r&&t.push(r)}return t},t.Pipeline.prototype.reset=function(){this._queue=[]},t.Pipeline.prototype.get=function(){return this._queue},t.Pipeline.prototype.toJSON=function(){return this._queue.map(function(e){return t.Pipeline.warnIfFunctionNotRegistered(e),e.label})},t.Index=function(){this._fields=[],this._ref="id",this.pipeline=new t.Pipeline,this.documentStore=new t.DocumentStore,this.index={},this.eventEmitter=new t.EventEmitter,this._idfCache={},this.on("add","remove","update",function(){this._idfCache={}}.bind(this))},t.Index.prototype.on=function(){var e=Array.prototype.slice.call(arguments);return this.eventEmitter.addListener.apply(this.eventEmitter,e)},t.Index.prototype.off=function(e,t){return this.eventEmitter.removeListener(e,t)},t.Index.load=function(e){e.version!==t.version&&t.utils.warn("version mismatch: current "+t.version+" importing "+e.version);var n=new this;n._fields=e.fields,n._ref=e.ref,n.documentStore=t.DocumentStore.load(e.documentStore),n.pipeline=t.Pipeline.load(e.pipeline),n.index={};for(var i in e.index)n.index[i]=t.InvertedIndex.load(e.index[i]);return n},t.Index.prototype.addField=function(e){return this._fields.push(e),this.index[e]=new t.InvertedIndex,this},t.Index.prototype.setRef=function(e){return this._ref=e,this},t.Index.prototype.saveDocument=function(e){return this.documentStore=new t.DocumentStore(e),this},t.Index.prototype.addDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.addDoc(i,e),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));this.documentStore.addFieldLength(i,n,o.length);var r={};o.forEach(function(e){e in r?r[e]+=1:r[e]=1},this);for(var s in r){var u=r[s];u=Math.sqrt(u),this.index[n].addToken(s,{ref:i,tf:u})}},this),n&&this.eventEmitter.emit("add",e,this)}},t.Index.prototype.removeDocByRef=function(e){if(e&&this.documentStore.isDocStored()!==!1&&this.documentStore.hasDoc(e)){var t=this.documentStore.getDoc(e);this.removeDoc(t,!1)}},t.Index.prototype.removeDoc=function(e,n){if(e){var n=void 0===n?!0:n,i=e[this._ref];this.documentStore.hasDoc(i)&&(this.documentStore.removeDoc(i),this._fields.forEach(function(n){var o=this.pipeline.run(t.tokenizer(e[n]));o.forEach(function(e){this.index[n].removeToken(e,i)},this)},this),n&&this.eventEmitter.emit("remove",e,this))}},t.Index.prototype.updateDoc=function(e,t){var t=void 0===t?!0:t;this.removeDocByRef(e[this._ref],!1),this.addDoc(e,!1),t&&this.eventEmitter.emit("update",e,this)},t.Index.prototype.idf=function(e,t){var n="@"+t+"/"+e;if(Object.prototype.hasOwnProperty.call(this._idfCache,n))return this._idfCache[n];var i=this.index[t].getDocFreq(e),o=1+Math.log(this.documentStore.length/(i+1));return this._idfCache[n]=o,o},t.Index.prototype.getFields=function(){return this._fields.slice()},t.Index.prototype.search=function(e,n){if(!e)return[];e="string"==typeof e?{any:e}:JSON.parse(JSON.stringify(e));var i=null;null!=n&&(i=JSON.stringify(n));for(var o=new t.Configuration(i,this.getFields()).get(),r={},s=Object.keys(e),u=0;u0&&t.push(e);for(var i in n)"docs"!==i&&"df"!==i&&this.expandToken(e+i,t,n[i]);return t},t.InvertedIndex.prototype.toJSON=function(){return{root:this.root}},t.Configuration=function(e,n){var e=e||"";if(void 0==n||null==n)throw new Error("fields should not be null");this.config={};var i;try{i=JSON.parse(e),this.buildUserConfig(i,n)}catch(o){t.utils.warn("user configuration parse failed, will use default configuration"),this.buildDefaultConfig(n)}},t.Configuration.prototype.buildDefaultConfig=function(e){this.reset(),e.forEach(function(e){this.config[e]={boost:1,bool:"OR",expand:!1}},this)},t.Configuration.prototype.buildUserConfig=function(e,n){var i="OR",o=!1;if(this.reset(),"bool"in e&&(i=e.bool||i),"expand"in e&&(o=e.expand||o),"fields"in e)for(var r in e.fields)if(n.indexOf(r)>-1){var s=e.fields[r],u=o;void 0!=s.expand&&(u=s.expand),this.config[r]={boost:s.boost||0===s.boost?s.boost:1,bool:s.bool||i,expand:u}}else t.utils.warn("field name in user configuration not found in index instance fields");else this.addAllFields2UserConfig(i,o,n)},t.Configuration.prototype.addAllFields2UserConfig=function(e,t,n){n.forEach(function(n){this.config[n]={boost:1,bool:e,expand:t}},this)},t.Configuration.prototype.get=function(){return this.config},t.Configuration.prototype.reset=function(){this.config={}},lunr.SortedSet=function(){this.length=0,this.elements=[]},lunr.SortedSet.load=function(e){var t=new this;return t.elements=e,t.length=e.length,t},lunr.SortedSet.prototype.add=function(){var e,t;for(e=0;e1;){if(r===e)return o;e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o]}return r===e?o:-1},lunr.SortedSet.prototype.locationFor=function(e){for(var t=0,n=this.elements.length,i=n-t,o=t+Math.floor(i/2),r=this.elements[o];i>1;)e>r&&(t=o),r>e&&(n=o),i=n-t,o=t+Math.floor(i/2),r=this.elements[o];return r>e?o:e>r?o+1:void 0},lunr.SortedSet.prototype.intersect=function(e){for(var t=new lunr.SortedSet,n=0,i=0,o=this.length,r=e.length,s=this.elements,u=e.elements;;){if(n>o-1||i>r-1)break;s[n]!==u[i]?s[n]u[i]&&i++:(t.add(s[n]),n++,i++)}return t},lunr.SortedSet.prototype.clone=function(){var e=new lunr.SortedSet;return e.elements=this.toArray(),e.length=e.elements.length,e},lunr.SortedSet.prototype.union=function(e){var t,n,i;this.length>=e.length?(t=this,n=e):(t=e,n=this),i=t.clone();for(var o=0,r=n.toArray();o

    \n"}, {"fullname": "musicdiff.diff", "modulename": "musicdiff", "qualname": "diff", "type": "function", "doc": "

    Compare two musical scores and optionally save/display the differences as two marked-up\nrendered PDFs.

    \n\n
    Args
    \n\n
      \n
    • score1 (str, Path, music21.stream.Score): The first music score to compare. The score\ncan be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,\netc), or a music21 Score object.
    • \n
    • score2 (str, Path, music21.stream.Score): The second musical score to compare. The score\ncan be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,\netc), or a music21 Score object.
    • \n
    • out_path1 (str, Path): Where to save the first marked-up rendered score PDF.\nIf out_path1 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • out_path2 (str, Path): Where to save the second marked-up rendered score PDF.\nIf out_path2 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed\npreviously.\n(default is True)
    • \n
    • visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,\nthe only result of the call will be the return value (the number of differences).\n(default is True)
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n\n
    Returns
    \n\n
    \n

    int: The number of differences found (0 means the scores were identical, None means the diff failed)

    \n
    \n", "signature": "(\n score1: Union[str, pathlib.Path, music21.stream.base.Score],\n score2: Union[str, pathlib.Path, music21.stream.base.Score],\n out_path1: Union[str, pathlib.Path] = None,\n out_path2: Union[str, pathlib.Path] = None,\n force_parse: bool = True,\n visualize_diffs: bool = True,\n detail: musicdiff.m21utils.DetailLevel = \n) -> int", "funcdef": "def"}, {"fullname": "musicdiff.annotation", "modulename": "musicdiff.annotation", "type": "module", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnNote", "modulename": "musicdiff.annotation", "qualname": "AnnNote", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnNote.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnNote.__init__", "type": "function", "doc": "

    Extend music21 GeneralNote with some precomputed, easily compared information about it.

    \n\n
    Args
    \n\n
      \n
    • general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
    • \n
    • enhanced_beam_list (list): A list of beaming information about this GeneralNote.
    • \n
    • tuplet_list (list): A list of tuplet info about this GeneralNote.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n general_note: music21.note.GeneralNote,\n enhanced_beam_list,\n tuplet_list,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnNote.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnNote.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnNote.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated note

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnNote.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnNote.get_note_ids", "type": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnNote. Since there\nis only one GeneralNote here, this will always be a single-element list.

    \n\n
    Returns
    \n\n
    \n

    [int]: A list containing the single GeneralNote id for this note.

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnExtra", "modulename": "musicdiff.annotation", "qualname": "AnnExtra", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnExtra.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.__init__", "type": "function", "doc": "

    Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it.\nExamples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.

    \n\n
    Args
    \n\n
      \n
    • extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
    • \n
    • measure (music21.stream.Measure): The music21 Measure the extra was found in. If the extra\nwas found in a Voice, this is the Measure that the Voice was found in.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n extra: music21.base.Music21Object,\n measure: music21.stream.base.Measure,\n score: music21.stream.base.Score,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnExtra.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnExtra.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated extra

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnVoice", "modulename": "musicdiff.annotation", "qualname": "AnnVoice", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnVoice.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.__init__", "type": "function", "doc": "

    Extend music21 Voice with some precomputed, easily compared information about it.

    \n\n
    Args
    \n\n
      \n
    • voice (music21.stream.Voice): The music21 voice to extend.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n voice: music21.stream.base.Voice,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnVoice.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnVoice.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated voice

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnVoice.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.get_note_ids", "type": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnVoice.

    \n\n
    Returns
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this voice

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMeasure", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnMeasure.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.__init__", "type": "function", "doc": "

    Extend music21 Measure with some precomputed, easily compared information about it.

    \n\n
    Args
    \n\n
      \n
    • measure (music21.stream.Measure): The music21 measure to extend.
    • \n
    • score (music21.stream.Score): the enclosing music21 Score.
    • \n
    • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n measure: music21.stream.base.Measure,\n score: music21.stream.base.Score,\n spannerBundle: music21.spanner.SpannerBundle,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMeasure.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnMeasure.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated measure

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMeasure.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.get_note_ids", "type": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnMeasure.

    \n\n
    Returns
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this measure

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnPart", "modulename": "musicdiff.annotation", "qualname": "AnnPart", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnPart.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnPart.__init__", "type": "function", "doc": "

    Extend music21 Part/PartStaff with some precomputed, easily compared information about it.

    \n\n
    Args
    \n\n
      \n
    • part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
    • \n
    • score (music21.stream.Score): the enclosing music21 Score.
    • \n
    • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n part: music21.stream.base.Part,\n score: music21.stream.base.Score,\n spannerBundle: music21.spanner.SpannerBundle,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnPart.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnPart.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnPart.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated part

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnPart.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnPart.get_note_ids", "type": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnPart.

    \n\n
    Returns
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this part

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnScore", "modulename": "musicdiff.annotation", "qualname": "AnnScore", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnScore.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnScore.__init__", "type": "function", "doc": "

    Take a music21 score and store it as a sequence of Full Trees.\nThe hierarchy is \"score -> parts -> measures -> voices -> notes\"

    \n\n
    Args
    \n\n
      \n
    • score (music21.stream.Score): The music21 score
    • \n
    • detail (DetailLevel): What level of detail to use during the diff. Can be\nGeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is\ncurrently equivalent to AllObjects).
    • \n
    \n", "signature": "(\n self,\n score: music21.stream.base.Score,\n detail: musicdiff.m21utils.DetailLevel = \n)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnScore.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnScore.notation_size", "type": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnScore.

    \n\n
    Returns
    \n\n
    \n

    int: The notation size of the annotated score

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnScore.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnScore.get_note_ids", "type": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnScore.

    \n\n
    Returns
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this score

    \n
    \n", "signature": "(self)", "funcdef": "def"}, {"fullname": "musicdiff.comparison", "modulename": "musicdiff.comparison", "type": "module", "doc": "

    \n"}, {"fullname": "musicdiff.comparison.Comparison", "modulename": "musicdiff.comparison", "qualname": "Comparison", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.comparison.Comparison.__init__", "modulename": "musicdiff.comparison", "qualname": "Comparison.__init__", "type": "function", "doc": "

    \n", "signature": "()", "funcdef": "def"}, {"fullname": "musicdiff.comparison.Comparison.annotated_scores_diff", "modulename": "musicdiff.comparison", "qualname": "Comparison.annotated_scores_diff", "type": "function", "doc": "

    Compare two annotated scores, computing an operations list and the cost of applying those\noperations to the first score to generate the second score.

    \n\n
    Args
    \n\n
      \n
    • score1 (musicdiff.annotation.AnnScore): The first annotated score to compare.
    • \n
    • score2 (musicdiff.annotation.AnnScore): The second annotated score to compare.
    • \n
    \n\n
    Returns
    \n\n
    \n

    List[Tuple], int: The operations list and the cost

    \n
    \n", "signature": "(\n score1: musicdiff.annotation.AnnScore,\n score2: musicdiff.annotation.AnnScore\n) -> Tuple[List[Tuple], int]", "funcdef": "def"}, {"fullname": "musicdiff.visualization", "modulename": "musicdiff.visualization", "type": "module", "doc": "

    \n"}, {"fullname": "musicdiff.visualization.Visualization", "modulename": "musicdiff.visualization", "qualname": "Visualization", "type": "class", "doc": "

    \n"}, {"fullname": "musicdiff.visualization.Visualization.__init__", "modulename": "musicdiff.visualization", "qualname": "Visualization.__init__", "type": "function", "doc": "

    \n", "signature": "()", "funcdef": "def"}, {"fullname": "musicdiff.visualization.Visualization.INSERTED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.INSERTED_COLOR", "type": "variable", "doc": "

    INSERTED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": " = 'red'"}, {"fullname": "musicdiff.visualization.Visualization.DELETED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.DELETED_COLOR", "type": "variable", "doc": "

    DELETED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": " = 'red'"}, {"fullname": "musicdiff.visualization.Visualization.CHANGED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.CHANGED_COLOR", "type": "variable", "doc": "

    CHANGED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": " = 'red'"}, {"fullname": "musicdiff.visualization.Visualization.mark_diffs", "modulename": "musicdiff.visualization", "qualname": "Visualization.mark_diffs", "type": "function", "doc": "

    Mark up two music21 scores with the differences described by an operations\nlist (e.g. a list returned from musicdiff.Comparison.annotated_scores_diff).

    \n\n
    Args
    \n\n
      \n
    • score1 (music21.stream.Score): The first score to mark up
    • \n
    • score2 (music21.stream.Score): The second score to mark up
    • \n
    • operations (List[Tuple]): The operations list that describes the difference\nbetween the two scores
    • \n
    \n", "signature": "(\n score1: music21.stream.base.Score,\n score2: music21.stream.base.Score,\n operations: List[Tuple]\n)", "funcdef": "def"}, {"fullname": "musicdiff.visualization.Visualization.show_diffs", "modulename": "musicdiff.visualization", "qualname": "Visualization.show_diffs", "type": "function", "doc": "

    Render two (presumably marked-up) music21 scores. If both out_path1 and out_path2 are not None,\nsave the rendered PDFs at those two locations, otherwise just display them using the default\nPDF viewer on the system.

    \n\n
    Args
    \n\n
      \n
    • score1 (music21.stream.Score): The first score to render
    • \n
    • score2 (music21.stream.Score): The second score to render
    • \n
    • out_path1 (str, Path): Where to save the first marked-up rendered score PDF.\nIf out_path1 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • out_path2 (str, Path): Where to save the second marked-up rendered score PDF.\nIf out_path2 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    \n", "signature": "(\n score1: music21.stream.base.Score,\n score2: music21.stream.base.Score,\n out_path1: Union[str, pathlib.Path] = None,\n out_path2: Union[str, pathlib.Path] = None\n)", "funcdef": "def"}]; + /** pdoc search index */const docs = [{"fullname": "musicdiff", "modulename": "musicdiff", "kind": "module", "doc": "

    \n"}, {"fullname": "musicdiff.diff", "modulename": "musicdiff", "qualname": "diff", "kind": "function", "doc": "

    Compare two musical scores and optionally save/display the differences as two marked-up\nrendered PDFs.

    \n\n
    Arguments:
    \n\n
      \n
    • score1 (str, Path, music21.stream.Score): The first music score to compare. The score\ncan be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,\netc), or a music21 Score object.
    • \n
    • score2 (str, Path, music21.stream.Score): The second musical score to compare. The score\ncan be a file of any format readable by music21 (e.g. MusicXML, MEI, Humdrum, MIDI,\netc), or a music21 Score object.
    • \n
    • out_path1 (str, Path): Where to save the first marked-up rendered score PDF.\nIf out_path1 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • out_path2 (str, Path): Where to save the second marked-up rendered score PDF.\nIf out_path2 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • force_parse (bool): Whether or not to force music21 to re-parse a file it has parsed\npreviously.\n(default is True)
    • \n
    • visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,\nthe only result of the call will be the return value (the number of differences).\n(default is True)
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n\n
    Returns:
    \n\n
    \n

    int | None: The number of differences found (0 means the scores were identical,\n None means the diff failed)

    \n
    \n", "signature": "(\tscore1: str | pathlib.Path | music21.stream.base.Score,\tscore2: str | pathlib.Path | music21.stream.base.Score,\tout_path1: str | pathlib.Path | None = None,\tout_path2: str | pathlib.Path | None = None,\tforce_parse: bool = True,\tvisualize_diffs: bool = True,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>) -> int | None:", "funcdef": "def"}, {"fullname": "musicdiff.annotation", "modulename": "musicdiff.annotation", "kind": "module", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnNote", "modulename": "musicdiff.annotation", "qualname": "AnnNote", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnNote.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnNote.__init__", "kind": "function", "doc": "

    Extend music21 GeneralNote with some precomputed, easily compared information about it.

    \n\n
    Arguments:
    \n\n
      \n
    • general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
    • \n
    • enhanced_beam_list (list): A list of beaming information about this GeneralNote.
    • \n
    • tuplet_list (list): A list of tuplet info about this GeneralNote.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\tgeneral_note: music21.note.GeneralNote,\tenhanced_beam_list: list[str],\ttuplet_list: list[str],\ttuplet_info: list[str],\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnNote.general_note", "modulename": "musicdiff.annotation", "qualname": "AnnNote.general_note", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnNote.beamings", "modulename": "musicdiff.annotation", "qualname": "AnnNote.beamings", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.tuplets", "modulename": "musicdiff.annotation", "qualname": "AnnNote.tuplets", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.tuplet_info", "modulename": "musicdiff.annotation", "qualname": "AnnNote.tuplet_info", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.stylestr", "modulename": "musicdiff.annotation", "qualname": "AnnNote.stylestr", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnNote.styledict", "modulename": "musicdiff.annotation", "qualname": "AnnNote.styledict", "kind": "variable", "doc": "

    \n", "annotation": ": dict"}, {"fullname": "musicdiff.annotation.AnnNote.noteshape", "modulename": "musicdiff.annotation", "qualname": "AnnNote.noteshape", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnNote.noteheadFill", "modulename": "musicdiff.annotation", "qualname": "AnnNote.noteheadFill", "kind": "variable", "doc": "

    \n", "annotation": ": bool | None"}, {"fullname": "musicdiff.annotation.AnnNote.noteheadParenthesis", "modulename": "musicdiff.annotation", "qualname": "AnnNote.noteheadParenthesis", "kind": "variable", "doc": "

    \n", "annotation": ": bool"}, {"fullname": "musicdiff.annotation.AnnNote.stemDirection", "modulename": "musicdiff.annotation", "qualname": "AnnNote.stemDirection", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnNote.pitches", "modulename": "musicdiff.annotation", "qualname": "AnnNote.pitches", "kind": "variable", "doc": "

    \n", "annotation": ": list[tuple[str, str, bool]]"}, {"fullname": "musicdiff.annotation.AnnNote.note_head", "modulename": "musicdiff.annotation", "qualname": "AnnNote.note_head", "kind": "variable", "doc": "

    \n", "annotation": ": int | fractions.Fraction"}, {"fullname": "musicdiff.annotation.AnnNote.dots", "modulename": "musicdiff.annotation", "qualname": "AnnNote.dots", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnNote.articulations", "modulename": "musicdiff.annotation", "qualname": "AnnNote.articulations", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.expressions", "modulename": "musicdiff.annotation", "qualname": "AnnNote.expressions", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.lyrics", "modulename": "musicdiff.annotation", "qualname": "AnnNote.lyrics", "kind": "variable", "doc": "

    \n", "annotation": ": list[str]"}, {"fullname": "musicdiff.annotation.AnnNote.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnNote.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnNote.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnNote.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnNote.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated note

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnNote.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnNote.get_note_ids", "kind": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnNote. Since there\nis only one GeneralNote here, this will always be a single-element list.

    \n\n
    Returns:
    \n\n
    \n

    [int]: A list containing the single GeneralNote id for this note.

    \n
    \n", "signature": "(self) -> list[str | int]:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnExtra", "modulename": "musicdiff.annotation", "qualname": "AnnExtra", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnExtra.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.__init__", "kind": "function", "doc": "

    Extend music21 non-GeneralNote and non-Stream objects with some precomputed,\neasily compared information about it.

    \n\n

    Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.

    \n\n
    Arguments:
    \n\n
      \n
    • extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream\nobject to extend.
    • \n
    • measure (music21.stream.Measure): The music21 Measure the extra was found in.\nIf the extra was found in a Voice, this is the Measure that the Voice was\nfound in.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\textra: music21.base.Music21Object,\tmeasure: music21.stream.base.Measure,\tscore: music21.stream.base.Score,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnExtra.extra", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.extra", "kind": "variable", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnExtra.offset", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.offset", "kind": "variable", "doc": "

    \n", "annotation": ": float"}, {"fullname": "musicdiff.annotation.AnnExtra.duration", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.duration", "kind": "variable", "doc": "

    \n", "annotation": ": float"}, {"fullname": "musicdiff.annotation.AnnExtra.numNotes", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.numNotes", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnExtra.content", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.content", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnExtra.styledict", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.styledict", "kind": "variable", "doc": "

    \n", "annotation": ": dict"}, {"fullname": "musicdiff.annotation.AnnExtra.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnExtra.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnExtra.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnExtra.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated extra

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnVoice", "modulename": "musicdiff.annotation", "qualname": "AnnVoice", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnVoice.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.__init__", "kind": "function", "doc": "

    Extend music21 Voice with some precomputed, easily compared information about it.

    \n\n
    Arguments:
    \n\n
      \n
    • voice (music21.stream.Voice or Measure): The music21 voice to extend. This\ncan be a Measure, but only if it contains no Voices.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\tvoice: music21.stream.base.Voice | music21.stream.base.Measure,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnVoice.voice", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.voice", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnVoice.n_of_notes", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.n_of_notes", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnVoice.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnVoice.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnVoice.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated voice

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnVoice.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnVoice.get_note_ids", "kind": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnVoice.

    \n\n
    Returns:
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this voice

    \n
    \n", "signature": "(self) -> list[str | int]:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMeasure", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnMeasure.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.__init__", "kind": "function", "doc": "

    Extend music21 Measure with some precomputed, easily compared information about it.

    \n\n
    Arguments:
    \n\n
      \n
    • measure (music21.stream.Measure): The music21 Measure to extend.
    • \n
    • part (music21.stream.Part): the enclosing music21 Part
    • \n
    • score (music21.stream.Score): the enclosing music21 Score.
    • \n
    • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners\nin the score.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\tmeasure: music21.stream.base.Measure,\tpart: music21.stream.base.Part,\tscore: music21.stream.base.Score,\tspannerBundle: music21.spanner.SpannerBundle,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnMeasure.measure", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.measure", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnMeasure.voices_list", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.voices_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnVoice]"}, {"fullname": "musicdiff.annotation.AnnMeasure.n_of_voices", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.n_of_voices", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnMeasure.extras_list", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.extras_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnExtra]"}, {"fullname": "musicdiff.annotation.AnnMeasure.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnMeasure.precomputed_repr", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.precomputed_repr", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnMeasure.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnMeasure.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated measure

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMeasure.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnMeasure.get_note_ids", "kind": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnMeasure.

    \n\n
    Returns:
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this measure

    \n
    \n", "signature": "(self) -> list[str | int]:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnPart", "modulename": "musicdiff.annotation", "qualname": "AnnPart", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnPart.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnPart.__init__", "kind": "function", "doc": "

    Extend music21 Part/PartStaff with some precomputed, easily compared information about it.

    \n\n
    Arguments:
    \n\n
      \n
    • part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff\nto extend.
    • \n
    • score (music21.stream.Score): the enclosing music21 Score.
    • \n
    • spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in\nthe score.
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\tpart: music21.stream.base.Part,\tscore: music21.stream.base.Score,\tspannerBundle: music21.spanner.SpannerBundle,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnPart.part", "modulename": "musicdiff.annotation", "qualname": "AnnPart.part", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnPart.bar_list", "modulename": "musicdiff.annotation", "qualname": "AnnPart.bar_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnMeasure]"}, {"fullname": "musicdiff.annotation.AnnPart.n_of_bars", "modulename": "musicdiff.annotation", "qualname": "AnnPart.n_of_bars", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnPart.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnPart.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnPart.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnPart.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnPart.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated part

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnPart.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnPart.get_note_ids", "kind": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnPart.

    \n\n
    Returns:
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this part

    \n
    \n", "signature": "(self) -> list[str | int]:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnStaffGroup", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.__init__", "kind": "function", "doc": "

    Take a StaffGroup and store it as an annotated object.

    \n", "signature": "(\tstaff_group: music21.layout.StaffGroup,\tpart_to_index: dict[music21.stream.base.Part, int],\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.staff_group", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.staff_group", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.name", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.name", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.abbreviation", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.abbreviation", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.symbol", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.symbol", "kind": "variable", "doc": "

    \n", "annotation": ": str | None"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.barTogether", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.barTogether", "kind": "variable", "doc": "

    \n", "annotation": ": bool | str | None"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.part_indices", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.part_indices", "kind": "variable", "doc": "

    \n", "annotation": ": list[int]"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.n_of_parts", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.n_of_parts", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.precomputed_str", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.precomputed_str", "kind": "variable", "doc": "

    \n", "annotation": ": str"}, {"fullname": "musicdiff.annotation.AnnStaffGroup.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnStaffGroup.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnStaffGroup.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated staff group

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnMetadataItem", "modulename": "musicdiff.annotation", "qualname": "AnnMetadataItem", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnMetadataItem.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnMetadataItem.__init__", "kind": "function", "doc": "

    \n", "signature": "(key: str, value: Any)"}, {"fullname": "musicdiff.annotation.AnnMetadataItem.key", "modulename": "musicdiff.annotation", "qualname": "AnnMetadataItem.key", "kind": "variable", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnMetadataItem.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnMetadataItem.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnMetadataItem.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated metadata item

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnScore", "modulename": "musicdiff.annotation", "qualname": "AnnScore", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.annotation.AnnScore.__init__", "modulename": "musicdiff.annotation", "qualname": "AnnScore.__init__", "kind": "function", "doc": "

    Take a music21 score and store it as a sequence of Full Trees.\nThe hierarchy is \"score -> parts -> measures -> voices -> notes\"

    \n\n
    Arguments:
    \n\n
      \n
    • score (music21.stream.Score): The music21 score
    • \n
    • detail (DetailLevel): What level of detail to use during the diff.\nCan be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly,\nGeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata,\nor Default (Default is currently equivalent to AllObjects).
    • \n
    \n", "signature": "(\tscore: music21.stream.base.Score,\tdetail: musicdiff.m21utils.DetailLevel = <DetailLevel.AllObjects: 3>)"}, {"fullname": "musicdiff.annotation.AnnScore.score", "modulename": "musicdiff.annotation", "qualname": "AnnScore.score", "kind": "variable", "doc": "

    \n", "annotation": ": int | str"}, {"fullname": "musicdiff.annotation.AnnScore.part_list", "modulename": "musicdiff.annotation", "qualname": "AnnScore.part_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnPart]"}, {"fullname": "musicdiff.annotation.AnnScore.staff_group_list", "modulename": "musicdiff.annotation", "qualname": "AnnScore.staff_group_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnStaffGroup]"}, {"fullname": "musicdiff.annotation.AnnScore.metadata_items_list", "modulename": "musicdiff.annotation", "qualname": "AnnScore.metadata_items_list", "kind": "variable", "doc": "

    \n", "annotation": ": list[musicdiff.annotation.AnnMetadataItem]"}, {"fullname": "musicdiff.annotation.AnnScore.n_of_parts", "modulename": "musicdiff.annotation", "qualname": "AnnScore.n_of_parts", "kind": "variable", "doc": "

    \n", "annotation": ": int"}, {"fullname": "musicdiff.annotation.AnnScore.notation_size", "modulename": "musicdiff.annotation", "qualname": "AnnScore.notation_size", "kind": "function", "doc": "

    Compute a measure of how many symbols are displayed in the score for this AnnScore.

    \n\n
    Returns:
    \n\n
    \n

    int: The notation size of the annotated score

    \n
    \n", "signature": "(self) -> int:", "funcdef": "def"}, {"fullname": "musicdiff.annotation.AnnScore.get_note_ids", "modulename": "musicdiff.annotation", "qualname": "AnnScore.get_note_ids", "kind": "function", "doc": "

    Computes a list of the GeneralNote ids for this AnnScore.

    \n\n
    Returns:
    \n\n
    \n

    [int]: A list containing the GeneralNote ids contained in this score

    \n
    \n", "signature": "(self) -> list[str | int]:", "funcdef": "def"}, {"fullname": "musicdiff.comparison", "modulename": "musicdiff.comparison", "kind": "module", "doc": "

    \n"}, {"fullname": "musicdiff.comparison.Comparison", "modulename": "musicdiff.comparison", "qualname": "Comparison", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.comparison.Comparison.annotated_scores_diff", "modulename": "musicdiff.comparison", "qualname": "Comparison.annotated_scores_diff", "kind": "function", "doc": "

    Compare two annotated scores, computing an operations list and the cost of applying those\noperations to the first score to generate the second score.

    \n\n
    Arguments:
    \n\n
      \n
    • score1 (musicdiff.annotation.AnnScore): The first annotated score to compare.
    • \n
    • score2 (musicdiff.annotation.AnnScore): The second annotated score to compare.
    • \n
    \n\n
    Returns:
    \n\n
    \n

    list[tuple], int: The operations list and the cost

    \n
    \n", "signature": "(\tscore1: musicdiff.annotation.AnnScore,\tscore2: musicdiff.annotation.AnnScore) -> tuple[list[tuple], int]:", "funcdef": "def"}, {"fullname": "musicdiff.visualization", "modulename": "musicdiff.visualization", "kind": "module", "doc": "

    \n"}, {"fullname": "musicdiff.visualization.Visualization", "modulename": "musicdiff.visualization", "qualname": "Visualization", "kind": "class", "doc": "

    \n"}, {"fullname": "musicdiff.visualization.Visualization.INSERTED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.INSERTED_COLOR", "kind": "variable", "doc": "

    INSERTED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": "'red'"}, {"fullname": "musicdiff.visualization.Visualization.DELETED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.DELETED_COLOR", "kind": "variable", "doc": "

    DELETED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": "'red'"}, {"fullname": "musicdiff.visualization.Visualization.CHANGED_COLOR", "modulename": "musicdiff.visualization", "qualname": "Visualization.CHANGED_COLOR", "kind": "variable", "doc": "

    CHANGED_COLOR can be set to customize the rendered score markup that mark_diffs does.

    \n", "default_value": "'red'"}, {"fullname": "musicdiff.visualization.Visualization.mark_diffs", "modulename": "musicdiff.visualization", "qualname": "Visualization.mark_diffs", "kind": "function", "doc": "

    Mark up two music21 scores with the differences described by an operations\nlist (e.g. a list returned from musicdiff.Comparison.annotated_scores_diff).

    \n\n
    Arguments:
    \n\n
      \n
    • score1 (music21.stream.Score): The first score to mark up
    • \n
    • score2 (music21.stream.Score): The second score to mark up
    • \n
    • operations (list[tuple]): The operations list that describes the difference\nbetween the two scores
    • \n
    \n", "signature": "(\tscore1: music21.stream.base.Score,\tscore2: music21.stream.base.Score,\toperations: list[tuple]) -> None:", "funcdef": "def"}, {"fullname": "musicdiff.visualization.Visualization.show_diffs", "modulename": "musicdiff.visualization", "qualname": "Visualization.show_diffs", "kind": "function", "doc": "

    Render two (presumably marked-up) music21 scores. If both out_path1 and\nout_path2 are not None, save the rendered PDFs at those two locations,\notherwise just display them using the default PDF viewer on the system.

    \n\n
    Arguments:
    \n\n
      \n
    • score1 (music21.stream.Score): The first score to render
    • \n
    • score2 (music21.stream.Score): The second score to render
    • \n
    • out_path1 (str, Path): Where to save the first marked-up rendered score PDF.\nIf out_path1 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    • out_path2 (str, Path): Where to save the second marked-up rendered score PDF.\nIf out_path2 is None, both PDFs will be displayed in the default PDF viewer.\n(default is None)
    • \n
    \n", "signature": "(\tscore1: music21.stream.base.Score,\tscore2: music21.stream.base.Score,\tout_path1: str | pathlib.Path | None = None,\tout_path2: str | pathlib.Path | None = None) -> None:", "funcdef": "def"}]; // mirrored in build-search-index.js (part 1) // Also split on html tags. this is a cheap heuristic, but good enough. diff --git a/musicdiff/__init__.py b/musicdiff/__init__.py index 09930a5..5ee881f 100644 --- a/musicdiff/__init__.py +++ b/musicdiff/__init__.py @@ -6,7 +6,7 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ @@ -14,7 +14,7 @@ import sys import os -from typing import Union, List, Tuple +import typing as t from pathlib import Path import music21 as m21 @@ -26,7 +26,7 @@ from musicdiff.comparison import Comparison from musicdiff.visualization import Visualization -def _getInputExtensionsList() -> [str]: +def _getInputExtensionsList() -> list[str]: c = m21.converter.Converter() inList = c.subconvertersList('input') result = [] @@ -35,7 +35,7 @@ def _getInputExtensionsList() -> [str]: result.append('.' + inputExt) return result -def _printSupportedInputFormats(): +def _printSupportedInputFormats() -> None: c = m21.converter.Converter() inList = c.subconvertersList('input') print("Supported input formats are:", file=sys.stderr) @@ -44,14 +44,15 @@ def _printSupportedInputFormats(): print('\tformats : ' + ', '.join(subc.registerFormats) + '\textensions: ' + ', '.join(subc.registerInputExtensions), file=sys.stderr) -def diff(score1: Union[str, Path, m21.stream.Score], - score2: Union[str, Path, m21.stream.Score], - out_path1: Union[str, Path] = None, - out_path2: Union[str, Path] = None, - force_parse: bool = True, - visualize_diffs: bool = True, - detail: DetailLevel = DetailLevel.Default - ) -> int: +def diff( + score1: str | Path | m21.stream.Score, + score2: str | Path | m21.stream.Score, + out_path1: str | Path | None = None, + out_path2: str | Path | None = None, + force_parse: bool = True, + visualize_diffs: bool = True, + detail: DetailLevel = DetailLevel.Default +) -> int | None: ''' Compare two musical scores and optionally save/display the differences as two marked-up rendered PDFs. @@ -75,12 +76,14 @@ def diff(score1: Union[str, Path, m21.stream.Score], visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False, the only result of the call will be the return value (the number of differences). (default is True) - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). Returns: - int: The number of differences found (0 means the scores were identical, None means the diff failed) + int | None: The number of differences found (0 means the scores were identical, + None means the diff failed) ''' # Use the new Humdrum/MEI importers from converter21 in place of the ones in music21... # Comment out this line to go back to music21's built-in Humdrum/MEI importers. @@ -93,14 +96,14 @@ def diff(score1: Union[str, Path, m21.stream.Score], if isinstance(score1, str): try: score1 = Path(score1) - except: + except Exception: # pylint: disable=broad-exception-caught print(f'score1 ({score1}) is not a valid path.', file=sys.stderr) badArg1 = True if isinstance(score2, str): try: score2 = Path(score2) - except: + except Exception: # pylint: disable=broad-exception-caught print(f'score2 ({score2}) is not a valid path.', file=sys.stderr) badArg2 = True @@ -118,7 +121,11 @@ def diff(score1: Union[str, Path, m21.stream.Score], if not badArg1: # pylint: disable=broad-except try: - score1 = m21.converter.parse(score1, forceSource = force_parse) + sc = m21.converter.parse(score1, forceSource=force_parse) + if t.TYPE_CHECKING: + assert isinstance(sc, m21.stream.Score) + score1 = sc + except Exception as e: print(f'score1 ({fileName1}) could not be parsed by music21', file=sys.stderr) print(e, file=sys.stderr) @@ -136,7 +143,10 @@ def diff(score1: Union[str, Path, m21.stream.Score], if not badArg2: # pylint: disable=broad-except try: - score2 = m21.converter.parse(score2, forceSource = force_parse) + sc = m21.converter.parse(score2, forceSource=force_parse) + if t.TYPE_CHECKING: + assert isinstance(sc, m21.stream.Score) + score2 = sc except Exception as e: print(f'score2 ({fileName2}) could not be parsed by music21', file=sys.stderr) print(e, file=sys.stderr) @@ -146,20 +156,24 @@ def diff(score1: Union[str, Path, m21.stream.Score], if badArg1 or badArg2: return None + if t.TYPE_CHECKING: + assert isinstance(score1, m21.stream.Score) + assert isinstance(score2, m21.stream.Score) + # scan each score, producing an annotated wrapper annotated_score1: AnnScore = AnnScore(score1, detail) annotated_score2: AnnScore = AnnScore(score2, detail) - diff_list: List = None - _cost: int = None + diff_list: list + _cost: int diff_list, _cost = Comparison.annotated_scores_diff(annotated_score1, annotated_score2) numDiffs: int = len(diff_list) if visualize_diffs and numDiffs != 0: # you can change these three colors as you like... - #Visualization.INSERTED_COLOR = 'red' - #Visualization.DELETED_COLOR = 'red' - #Visualization.CHANGED_COLOR = 'red' + # Visualization.INSERTED_COLOR = 'red' + # Visualization.DELETED_COLOR = 'red' + # Visualization.CHANGED_COLOR = 'red' # color changed/deleted/inserted notes, add descriptive text for each change, etc Visualization.mark_diffs(score1, score2, diff_list) diff --git a/musicdiff/__main__.py b/musicdiff/__main__.py index f5ff0af..b44a43b 100644 --- a/musicdiff/__main__.py +++ b/musicdiff/__main__.py @@ -9,7 +9,7 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ import sys @@ -26,15 +26,32 @@ if __name__ == "__main__": parser = argparse.ArgumentParser( - prog='python3 -m musicdiff', - description='Music score notation diff (MusicXML, MEI, Humdrum, etc)') - parser.add_argument("file1", - help="first music score file to compare (any format music21 can parse)") - parser.add_argument("file2", - help="second music score file to compare (any format music21 can parse)") - parser.add_argument("-d", "--detail", default="Default", - choices=["GeneralNotesOnly", "AllObjects", "AllObjectsWithStyle", "Default"], - help="set detail level") + prog='python3 -m musicdiff', + description='Music score notation diff (MusicXML, MEI, Humdrum, etc)' + ) + parser.add_argument( + "file1", + help="first music score file to compare (any format music21 can parse)" + ) + parser.add_argument( + "file2", + help="second music score file to compare (any format music21 can parse)" + ) + parser.add_argument( + "-d", + "--detail", + default="Default", + choices=[ + "GeneralNotesOnly", + "AllObjects", + "AllObjectsWithStyle", + "MetadataOnly", + "GeneralNotesAndMetadata", + "AllObjectsAndMetadata", + "AllObjectsWithStyleAndMetadata", + "Default"], + help="set detail level" + ) args = parser.parse_args() detail: DetailLevel = DetailLevel.Default @@ -44,12 +61,22 @@ detail = DetailLevel.AllObjects elif args.detail == "AllObjectsWithStyle": detail = DetailLevel.AllObjectsWithStyle + elif args.detail == "MetadataOnly": + detail = DetailLevel.MetadataOnly + elif args.detail == "GeneralNotesAndMetadata": + detail = DetailLevel.GeneralNotesAndMetadata + elif args.detail == "AllObjectsAndMetadata": + detail = DetailLevel.AllObjectsAndMetadata + elif args.detail == "AllObjectsWithStyleAndMetadata": + detail = DetailLevel.AllObjectsWithStyleAndMetadata elif args.detail == "Default": detail = DetailLevel.Default # Note that diff() can take a music21 Score instead of a file, for either # or both arguments. # Note also that diff() can take str or pathlib.Path for files. - numDiffs: int = diff(args.file1, args.file2, detail=detail) - if numDiffs is not None and numDiffs == 0: + numDiffs: int | None = diff(args.file1, args.file2, detail=detail) + if numDiffs is None: + print('musicdiff failed.', file=sys.stderr) + elif numDiffs == 0: print(f'Scores in {args.file1} and {args.file2} are identical.', file=sys.stderr) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 1410e88..a726fbb 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -8,14 +8,15 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ __docformat__ = "google" from fractions import Fraction -from typing import Optional, List + +import typing as t import music21 as m21 @@ -23,7 +24,14 @@ from musicdiff import DetailLevel class AnnNote: - def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuplet_list, detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + general_note: m21.note.GeneralNote, + enhanced_beam_list: list[str], + tuplet_list: list[str], + tuplet_info: list[str], + detail: DetailLevel = DetailLevel.Default + ) -> None: """ Extend music21 GeneralNote with some precomputed, easily compared information about it. @@ -31,62 +39,65 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend. enhanced_beam_list (list): A list of beaming information about this GeneralNote. tuplet_list (list): A list of tuplet info about this GeneralNote. - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). """ - self.general_note = general_note.id - self.beamings = enhanced_beam_list - self.tuplets = tuplet_list + self.general_note: int | str = general_note.id + self.beamings: list[str] = enhanced_beam_list + self.tuplets: list[str] = tuplet_list + self.tuplet_info: list[str] = tuplet_info self.stylestr: str = '' self.styledict: dict = {} if M21Utils.has_style(general_note): self.styledict = M21Utils.obj_to_styledict(general_note, detail) self.noteshape: str = 'normal' - self.noteheadFill: Optional[bool] = None + self.noteheadFill: bool | None = None self.noteheadParenthesis: bool = False self.stemDirection: str = 'unspecified' - if detail >= DetailLevel.AllObjectsWithStyle and isinstance(general_note, m21.note.NotRest): + if DetailLevel.includesStyle(detail) and isinstance(general_note, m21.note.NotRest): self.noteshape = general_note.notehead self.noteheadFill = general_note.noteheadFill self.noteheadParenthesis = general_note.noteheadParenthesis self.stemDirection = general_note.stemDirection # compute the representation of NoteNode as in the paper - # pitches is a list of elements, each one is (pitchposition, accidental, tie) - if general_note.isRest: - self.pitches = [ - ("R", "None", False) - ] # accidental and tie are automaticaly set for rests - elif general_note.isChord or "ChordBase" in general_note.classSet: - # ChordBase/PercussionChord is new in v7, so I am being careful to use - # it only as a string so v6 will still work. - noteList: [m21.note.GeneralNote] = general_note.notes - if hasattr(general_note, "sortDiatonicAscending"): # PercussionChords don't have this - noteList = general_note.sortDiatonicAscending().notes - self.pitches = [ - M21Utils.note2tuple(p) for p in noteList - ] - elif general_note.isNote or isinstance(general_note, m21.note.Unpitched): - self.pitches = [M21Utils.note2tuple(general_note)] + # pitches is a list of elements, each one is (pitchposition, accidental, tied) + self.pitches: list[tuple[str, str, bool]] + if isinstance(general_note, m21.chord.ChordBase): + notes: tuple[m21.note.NotRest, ...] = general_note.notes + if hasattr(general_note, "sortDiatonicAscending"): + # PercussionChords don't have this + notes = general_note.sortDiatonicAscending().notes + self.pitches = [] + for p in notes: + if not isinstance(p, (m21.note.Note, m21.note.Unpitched)): + raise TypeError("The chord must contain only Note or Unpitched") + self.pitches.append(M21Utils.note2tuple(p, detail)) + + elif isinstance(general_note, (m21.note.Note, m21.note.Unpitched, m21.note.Rest)): + self.pitches = [M21Utils.note2tuple(general_note, detail)] else: - raise TypeError("The generalNote must be a Chord, a Rest or a Note") + raise TypeError("The generalNote must be a Chord, a Rest, a Note, or an Unpitched") + # note head type_number = Fraction( M21Utils.get_type_num(general_note.duration) ) + self.note_head: int | Fraction if type_number >= 4: self.note_head = 4 else: self.note_head = type_number # dots - self.dots = general_note.duration.dots + self.dots: int = general_note.duration.dots # graceness if isinstance(general_note.duration, m21.duration.AppoggiaturaDuration): - self.graceType = 'acc' - self.graceSlash = general_note.duration.slash + self.graceType: str = 'acc' + self.graceSlash: bool | None = general_note.duration.slash elif isinstance(general_note.duration, m21.duration.GraceDuration): self.graceType = 'nonacc' self.graceSlash = general_note.duration.slash @@ -94,16 +105,20 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple self.graceType = '' self.graceSlash = False # articulations - self.articulations = [a.name for a in general_note.articulations] + self.articulations: list[str] = [ + M21Utils.articulation_to_string(a, detail) for a in general_note.articulations + ] if self.articulations: self.articulations.sort() # expressions - self.expressions = [a.name for a in general_note.expressions] + self.expressions: list[str] = [ + M21Utils.expression_to_string(a, detail) for a in general_note.expressions + ] if self.expressions: self.expressions.sort() # lyrics - self.lyrics: List[str] = [] + self.lyrics: list[str] = [] for lyric in general_note.lyrics: lyricStr: str = "" if lyric.number is not None: @@ -120,16 +135,16 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple self.lyrics.append(lyricStr) # precomputed representations for faster comparison - self.precomputed_str = self.__str__() + self.precomputed_str: str = self.__str__() - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnNote`. Returns: int: The notation size of the annotated note """ - size = 0 + size: int = 0 # add for the pitches for pitch in self.pitches: size += M21Utils.pitch_size(pitch) @@ -147,18 +162,20 @@ def notation_size(self): size += len(self.lyrics) return size - def __repr__(self): + def __repr__(self) -> str: # does consider the MEI id! - return (f"{self.pitches},{self.note_head},{self.dots},{self.beamings}," + - f"{self.tuplets},{self.general_note},{self.articulations},{self.expressions}," + - f"{self.lyrics},{self.styledict}") + return ( + f"{self.pitches},{self.note_head},{self.dots},B:{self.beamings}," + + f"T:{self.tuplets},TI:{self.tuplet_info},{self.general_note}," + + f"{self.articulations},{self.expressions},{self.lyrics},{self.styledict}" + ) - def __str__(self): + def __str__(self) -> str: """ Returns: str: the representation of the Annotated note. Does not consider MEI id """ - string = "[" + string: str = "[" for p in self.pitches: # add for pitches string += p[0] if p[1] != "None": @@ -187,18 +204,22 @@ def __str__(self): elif b == "partial": string += "pa" else: - raise Exception(f"Incorrect beaming type: {b}") + raise ValueError(f"Incorrect beaming type: {b}") + if len(self.tuplets) > 0: # add for tuplets string += "T" - for t in self.tuplets: - if t == "start": - string += "sr" - elif t == "continue": - string += "co" - elif t == "stop": - string += "sp" + for tup, ti in zip(self.tuplets, self.tuplet_info): + if ti != "": + ti = "(" + ti + ")" + if tup == "start": + string += "sr" + ti + elif tup == "continue": + string += "co" + ti + elif tup == "stop": + string += "sp" + ti else: - raise Exception(f"Incorrect tuplets type: {t}") + raise ValueError(f"Incorrect tuplet type: {tup}") + if len(self.articulations) > 0: # add for articulations for a in self.articulations: string += a @@ -226,7 +247,7 @@ def __str__(self): return string - def get_note_ids(self): + def get_note_ids(self) -> list[str | int]: """ Computes a list of the GeneralNote ids for this `AnnNote`. Since there is only one GeneralNote here, this will always be a single-element list. @@ -236,7 +257,7 @@ def get_note_ids(self): """ return [self.general_note] - def __eq__(self, other): + def __eq__(self, other) -> bool: # equality does not consider the MEI id! return self.precomputed_str == other.precomputed_str @@ -261,45 +282,77 @@ def __eq__(self, other): class AnnExtra: - def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + extra: m21.base.Music21Object, + measure: m21.stream.Measure, + score: m21.stream.Score, + detail: DetailLevel = DetailLevel.Default + ) -> None: """ - Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it. + Extend music21 non-GeneralNote and non-Stream objects with some precomputed, + easily compared information about it. + Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc. Args: - extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend. - measure (music21.stream.Measure): The music21 Measure the extra was found in. If the extra - was found in a Voice, this is the Measure that the Voice was found in. - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). + extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream + object to extend. + measure (music21.stream.Measure): The music21 Measure the extra was found in. + If the extra was found in a Voice, this is the Measure that the Voice was + found in. + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). """ self.extra = extra.id self.offset: float self.duration: float self.numNotes: int = 1 + if isinstance(extra, m21.spanner.Spanner): self.numNotes = len(extra) - firstNote: m21.note.GeneralNote = extra.getFirst() - lastNote: m21.note.GeneralNote = extra.getLast() + firstNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = ( + M21Utils.getPrimarySpannerElement(extra) + ) + lastNote: m21.note.GeneralNote | m21.spanner.SpannerAnchor = ( + extra.getLast() + ) self.offset = float(firstNote.getOffsetInHierarchy(measure)) - # to compute duration we need to use offset-in-score, since the end note might be in another Measure - startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score)) - endOffsetInScore: float = float(lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength) - self.duration = endOffsetInScore - startOffsetInScore + # to compute duration we need to use offset-in-score, since the end note might + # be in another Measure. Except for ArpeggioMarkSpanners, where the duration + # doesn't matter, so we just set it to 0, rather than figuring out the longest + # duration in all the notes/chords in the arpeggio. + if isinstance(extra, m21.expressions.ArpeggioMarkSpanner): + self.duration = 0. + else: + startOffsetInScore: float = float(firstNote.getOffsetInHierarchy(score)) + try: + endOffsetInScore: float = float( + lastNote.getOffsetInHierarchy(score) + lastNote.duration.quarterLength + ) + except m21.sites.SitesException: + endOffsetInScore = startOffsetInScore + self.duration = endOffsetInScore - startOffsetInScore else: self.offset = float(extra.getOffsetInHierarchy(measure)) self.duration = float(extra.duration.quarterLength) - self.content: str = M21Utils.extra_to_string(extra) - self.styledict: str = {} + + self.content: str = M21Utils.extra_to_string(extra, detail) + self.styledict: dict = {} + if M21Utils.has_style(extra): - self.styledict = M21Utils.obj_to_styledict(extra, detail) # includes extra.placement if present - self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday + # includes extra.placement if present + self.styledict = M21Utils.obj_to_styledict(extra, detail) + + # so far, always 1, but maybe some extra will be bigger someday + self._notation_size: int = 1 # precomputed representations for faster comparison - self.precomputed_str = self.__str__() + self.precomputed_str: str = self.__str__() - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnExtra`. @@ -308,10 +361,10 @@ def notation_size(self): """ return self._notation_size - def __repr__(self): + def __repr__(self) -> str: return str(self) - def __str__(self): + def __str__(self) -> str: """ Returns: str: the compared representation of the AnnExtra. Does not consider music21 id. @@ -324,29 +377,39 @@ def __str__(self): string += f",{k}={v}" return string - def __eq__(self, other): + def __eq__(self, other) -> bool: # equality does not consider the MEI id! return self.precomputed_str == other.precomputed_str class AnnVoice: - def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + voice: m21.stream.Voice | m21.stream.Measure, + detail: DetailLevel = DetailLevel.Default + ) -> None: """ Extend music21 Voice with some precomputed, easily compared information about it. Args: - voice (music21.stream.Voice): The music21 voice to extend. - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). + voice (music21.stream.Voice or Measure): The music21 voice to extend. This + can be a Measure, but only if it contains no Voices. + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). """ - self.voice = voice.id - note_list = M21Utils.get_notes_and_gracenotes(voice) + self.voice: int | str = voice.id + note_list: list[m21.note.GeneralNote] = [] + + if DetailLevel.includesGeneralNotes(detail): + note_list = M21Utils.get_notes_and_gracenotes(voice) + if not note_list: - self.en_beam_list = [] - self.tuplet_list = [] - self.tuple_info = [] - self.annot_notes = [] + self.en_beam_list: list[list[str]] = [] + self.tuplet_list: list[list[str]] = [] + self.tuplet_info: list[list[str]] = [] + self.annot_notes: list[AnnNote] = [] else: self.en_beam_list = M21Utils.get_enhance_beamings( note_list @@ -354,18 +417,24 @@ def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.De self.tuplet_list = M21Utils.get_tuplets_type( note_list ) # corrected tuplets (with "start" and "continue") - self.tuple_info = M21Utils.get_tuplets_info(note_list) + self.tuplet_info = M21Utils.get_tuplets_info(note_list) # create a list of notes with beaming and tuplets information attached self.annot_notes = [] for i, n in enumerate(note_list): self.annot_notes.append( - AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail) + AnnNote( + n, + self.en_beam_list[i], + self.tuplet_list[i], + self.tuplet_info[i], + detail + ) ) - self.n_of_notes = len(self.annot_notes) - self.precomputed_str = self.__str__() + self.n_of_notes: int = len(self.annot_notes) + self.precomputed_str: str = self.__str__() - def __eq__(self, other): + def __eq__(self, other) -> bool: # equality does not consider MEI id! if not isinstance(other, AnnVoice): return False @@ -378,7 +447,7 @@ def __eq__(self, other): # [an[0] == an[1] for an in zip(self.annot_notes, other.annot_notes)] # ) - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnVoice`. @@ -387,22 +456,23 @@ def notation_size(self): """ return sum([an.notation_size() for an in self.annot_notes]) - def __repr__(self): + def __repr__(self) -> str: return self.annot_notes.__repr__() - def __str__(self): + def __str__(self) -> str: string = "[" for an in self.annot_notes: string += str(an) string += "," if string[-1] == ",": - string = string[:-1] # delete the last comma + # delete the last comma + string = string[:-1] string += "]" return string - def get_note_ids(self): + def get_note_ids(self) -> list[str | int]: """ Computes a list of the GeneralNote ids for this `AnnVoice`. @@ -413,26 +483,33 @@ def get_note_ids(self): class AnnMeasure: - def __init__(self, measure: m21.stream.Measure, - score: m21.stream.Score, - spannerBundle: m21.spanner.SpannerBundle, - detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + measure: m21.stream.Measure, + part: m21.stream.Part, + score: m21.stream.Score, + spannerBundle: m21.spanner.SpannerBundle, + detail: DetailLevel = DetailLevel.Default + ) -> None: """ Extend music21 Measure with some precomputed, easily compared information about it. Args: - measure (music21.stream.Measure): The music21 measure to extend. + measure (music21.stream.Measure): The music21 Measure to extend. + part (music21.stream.Part): the enclosing music21 Part score (music21.stream.Score): the enclosing music21 Score. - spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score. - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). - """ - self.measure = measure.id - self.voices_list = [] - if ( - len(measure.voices) == 0 - ): # there is a single AnnVoice ( == for the library there are no voices) + spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners + in the score. + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). + """ + self.measure: int | str = measure.id + self.voices_list: list[AnnVoice] = [] + + if len(measure.voices) == 0: + # there is a single AnnVoice (i.e. in the music21 Measure there are no voices) ann_voice = AnnVoice(measure, detail) if ann_voice.n_of_notes > 0: self.voices_list.append(ann_voice) @@ -441,28 +518,32 @@ def __init__(self, measure: m21.stream.Measure, ann_voice = AnnVoice(voice, detail) if ann_voice.n_of_notes > 0: self.voices_list.append(ann_voice) - self.n_of_voices = len(self.voices_list) + self.n_of_voices: int = len(self.voices_list) - self.extras_list = [] - if detail >= DetailLevel.AllObjects: - for extra in M21Utils.get_extras(measure, spannerBundle): + self.extras_list: list[AnnExtra] = [] + if DetailLevel.includesOtherMusicObjects(detail): + for extra in M21Utils.get_extras(measure, part, spannerBundle, detail): self.extras_list.append(AnnExtra(extra, measure, score, detail)) # For correct comparison, sort the extras_list, so that any list slices # that all have the same offset are sorted alphabetically. - self.extras_list.sort(key=lambda e: ( e.offset, str(e) )) + self.extras_list.sort(key=lambda e: (e.offset, str(e))) # precomputed values to speed up the computation. As they start to be long, they are hashed - self.precomputed_str = hash(self.__str__()) - self.precomputed_repr = hash(self.__repr__()) - - def __str__(self): - return str([str(v) for v in self.voices_list]) + ' Extras:' + str([str(e) for e in self.extras_list]) + self.precomputed_str: int = hash(self.__str__()) + self.precomputed_repr: int = hash(self.__repr__()) + + def __str__(self) -> str: + return ( + str([str(v) for v in self.voices_list]) + + ' Extras:' + + str([str(e) for e in self.extras_list]) + ) - def __repr__(self): + def __repr__(self) -> str: return self.voices_list.__repr__() + ' Extras:' + self.extras_list.__repr__() - def __eq__(self, other): + def __eq__(self, other) -> bool: # equality does not consider MEI id! if not isinstance(other, AnnMeasure): return False @@ -476,16 +557,19 @@ def __eq__(self, other): return self.precomputed_str == other.precomputed_str # return all([v[0] == v[1] for v in zip(self.voices_list, other.voices_list)]) - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnMeasure`. Returns: int: The notation size of the annotated measure """ - return sum([v.notation_size() for v in self.voices_list]) + sum([e.notation_size() for e in self.extras_list]) + return ( + sum([v.notation_size() for v in self.voices_list]) + + sum([e.notation_size() for e in self.extras_list]) + ) - def get_note_ids(self): + def get_note_ids(self) -> list[str | int]: """ Computes a list of the GeneralNote ids for this `AnnMeasure`. @@ -499,35 +583,45 @@ def get_note_ids(self): class AnnPart: - def __init__(self, part: m21.stream.Part, - score: m21.stream.Score, - spannerBundle: m21.spanner.SpannerBundle, - detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + part: m21.stream.Part, + score: m21.stream.Score, + spannerBundle: m21.spanner.SpannerBundle, + detail: DetailLevel = DetailLevel.Default + ): """ Extend music21 Part/PartStaff with some precomputed, easily compared information about it. Args: - part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend. + part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff + to extend. score (music21.stream.Score): the enclosing music21 Score. - spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score. - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). - """ - self.part = part.id - self.bar_list = [] + spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in + the score. + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). + """ + self.part: int | str = part.id + self.bar_list: list[AnnMeasure] = [] for measure in part.getElementsByClass("Measure"): - ann_bar = AnnMeasure(measure, score, spannerBundle, detail) # create the bar objects + # create the bar objects + ann_bar = AnnMeasure(measure, part, score, spannerBundle, detail) if ann_bar.n_of_voices > 0: self.bar_list.append(ann_bar) - self.n_of_bars = len(self.bar_list) - # precomputed str to speed up the computation. String itself start to be long, so it is hashed - self.precomputed_str = hash(self.__str__()) + self.n_of_bars: int = len(self.bar_list) + # Precomputed str to speed up the computation. + # String itself is pretty long, so it is hashed + self.precomputed_str: int = hash(self.__str__()) - def __str__(self): - return str([str(b) for b in self.bar_list]) + def __str__(self) -> str: + output: str = 'Part: ' + output += str([str(b) for b in self.bar_list]) + return output - def __eq__(self, other): + def __eq__(self, other) -> bool: # equality does not consider MEI id! if not isinstance(other, AnnPart): return False @@ -537,7 +631,7 @@ def __eq__(self, other): return all(b[0] == b[1] for b in zip(self.bar_list, other.bar_list)) - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnPart`. @@ -546,10 +640,10 @@ def notation_size(self): """ return sum([b.notation_size() for b in self.bar_list]) - def __repr__(self): + def __repr__(self) -> str: return self.bar_list.__repr__() - def get_note_ids(self): + def get_note_ids(self) -> list[str | int]: """ Computes a list of the GeneralNote ids for this `AnnPart`. @@ -562,28 +656,208 @@ def get_note_ids(self): return notes_id +class AnnStaffGroup: + def __init__( + self, + staff_group: m21.layout.StaffGroup, + part_to_index: dict[m21.stream.Part, int], + detail: DetailLevel = DetailLevel.Default + ) -> None: + """ + Take a StaffGroup and store it as an annotated object. + """ + self.staff_group: int | str = staff_group.id + self.name: str = staff_group.name or '' + self.abbreviation: str = staff_group.abbreviation or '' + self.symbol: str | None = None + self.barTogether: bool | str | None = staff_group.barTogether + + if DetailLevel.includesStyle(detail): + # symbol (brace, bracket, line, etc) is considered to be style + self.symbol = staff_group.symbol + + self.part_indices: list[int] = [] + for part in staff_group: + self.part_indices.append(part_to_index.get(part, -1)) + + # sort so simple list comparison can work + self.part_indices.sort() + + self.n_of_parts: int = len(self.part_indices) + + # precomputed representations for faster comparison + self.precomputed_str: str = self.__str__() + + def __str__(self) -> str: + output: str = "StaffGroup" + if self.name and self.abbreviation: + output += f"({self.name},{self.abbreviation})" + elif self.name: + output += f"({self.name})" + elif self.abbreviation: + output += f"(,{self.abbreviation})" + else: + output += "(,)" + + output += f", symbol={self.symbol}" + output += f", barTogether={self.barTogether}" + output += f", partIndices={self.part_indices}" + return output + + def __eq__(self, other) -> bool: + # equality does not consider MEI id (or MEI ids of parts included in the group) + if not isinstance(other, AnnStaffGroup): + return False + + if self.name != other.name: + return False + + if self.abbreviation != other.abbreviation: + return False + + if self.symbol != other.symbol: + return False + + if self.barTogether != other.barTogether: + return False + + if self.part_indices != other.part_indices: + return False + + return True + + def notation_size(self) -> int: + """ + Compute a measure of how many symbols are displayed in the score for this `AnnStaffGroup`. + + Returns: + int: The notation size of the annotated staff group + """ + # notation_size = 5 because there are 5 main visible things about a StaffGroup: + # name, abbreviation, symbol shape, barline type, and which parts it encloses + return 5 + + def __repr__(self) -> str: + # does consider the MEI id! + output: str = f"StaffGroup({self.staff_group}):" + output += f" name={self.name}, abbrev={self.abbreviation}," + output += f" symbol={self.symbol}, barTogether={self.barTogether}" + output += f", partIndices={self.part_indices}" + return output + + +class AnnMetadataItem: + def __init__( + self, + key: str, + value: t.Any + ) -> None: + self.key = key + if isinstance(value, m21.metadata.Text): + # Create a string representing both the text and the language, but not isTranslated, + # since isTranslated cannot be represented in many file formats. + self.value = str(value) + f'(language={value.language})' + elif isinstance(value, m21.metadata.Contributor): + # Create a string (same thing: value.name.isTranslated will differ randomly) + # Currently I am also ignoring more than one name, and birth/death. + self.value = str(value) + f'(role={value.role}, language={value._names[0].language})' + else: + self.value = value + + def __eq__(self, other) -> bool: + if not isinstance(other, AnnMetadataItem): + return False + + if self.key != other.key: + return False + + if self.value != other.value: + return False + + return True + + def __str__(self) -> str: + return self.__repr__() + + def __repr__(self) -> str: + return self.key + ':' + str(self.value) + + def notation_size(self) -> int: + """ + Compute a measure of how many symbols are displayed in the score for this `AnnMetadataItem`. + + Returns: + int: The notation size of the annotated metadata item + """ + return 1 + + class AnnScore: - def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default): + def __init__( + self, + score: m21.stream.Score, + detail: DetailLevel = DetailLevel.Default + ) -> None: """ Take a music21 score and store it as a sequence of Full Trees. The hierarchy is "score -> parts -> measures -> voices -> notes" Args: score (music21.stream.Score): The music21 score - detail (DetailLevel): What level of detail to use during the diff. Can be - GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently equivalent to AllObjects). + detail (DetailLevel): What level of detail to use during the diff. + Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle, MetadataOnly, + GeneralNotesAndMetadata, AllObjectsAndMetadata, AllObjectsWithStyleAndMetadata, + or Default (Default is currently equivalent to AllObjects). """ - self.score = score.id - self.part_list = [] + self.score: int | str = score.id + self.part_list: list[AnnPart] = [] + self.staff_group_list: list[AnnStaffGroup] = [] + self.metadata_items_list: list[AnnMetadataItem] = [] + spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle - for part in score.parts.stream(): + part_to_index: dict[m21.stream.Part, int] = {} + + # Before we start, transpose all notes to written pitch, both for transposing + # instruments and Ottavas. Be careful to preserve accidental.displayStatus + # during transposition, since we use that visibility indicator when comparing + # accidentals. + score.toWrittenPitch(inPlace=True, preserveAccidentalDisplay=True) + + for idx, part in enumerate(score.parts): # create and add the AnnPart object to part_list + # and to part_to_index dict + part_to_index[part] = idx ann_part = AnnPart(part, score, spannerBundle, detail) - if ann_part.n_of_bars > 0: - self.part_list.append(ann_part) - self.n_of_parts = len(self.part_list) + self.part_list.append(ann_part) + + self.n_of_parts: int = len(self.part_list) - def __eq__(self, other): + if DetailLevel.includesOtherMusicObjects(detail): + # staffgroups are extras (a.k.a. OtherMusicObjects) + for staffGroup in score[m21.layout.StaffGroup]: + ann_staff_group = AnnStaffGroup(staffGroup, part_to_index, detail) + if ann_staff_group.n_of_parts > 0: + self.staff_group_list.append(ann_staff_group) + + if DetailLevel.includesMetadata(detail) and score.metadata is not None: + # m21 metadata.all() can't sort primitives, so we'll have to sort by hand. + allItems: list[tuple[str, t.Any]] = list( + score.metadata.all(returnPrimitives=True, returnSorted=False) + ) + allItems.sort(key=lambda each: (each[0], str(each[1]))) + for key, value in allItems: + if key in ('fileFormat', 'filePath', 'software'): + # Don't compare metadata items that are uninterestingly different. + continue + if (key.startswith('raw:') + or key.startswith('meiraw:') + or key.startswith('humdrumraw:')): + # Don't compare verbatim/raw metadata ('meiraw:meihead', + # 'raw:freeform', 'humdrumraw:XXX'), it's often deleted + # when made obsolete by conversions/edits. + continue + self.metadata_items_list.append(AnnMetadataItem(key, value)) + + def __eq__(self, other) -> bool: # equality does not consider MEI id! if not isinstance(other, AnnScore): return False @@ -593,7 +867,7 @@ def __eq__(self, other): return all(p[0] == p[1] for p in zip(self.part_list, other.part_list)) - def notation_size(self): + def notation_size(self) -> int: """ Compute a measure of how many symbols are displayed in the score for this `AnnScore`. @@ -602,10 +876,10 @@ def notation_size(self): """ return sum([p.notation_size() for p in self.part_list]) - def __repr__(self): + def __repr__(self) -> str: return self.part_list.__repr__() - def get_note_ids(self): + def get_note_ids(self) -> list[str | int]: """ Computes a list of the GeneralNote ids for this `AnnScore`. @@ -618,10 +892,10 @@ def get_note_ids(self): return notes_id # return the sequences of measures for a specified part - def _measures_from_part(self, part_number): + def _measures_from_part(self, part_number) -> list[AnnMeasure]: # only used by tests/test_scl.py if part_number not in range(0, len(self.part_list)): - raise Exception( + raise ValueError( f"parameter 'part_number' should be between 0 and {len(self.part_list) - 1}" ) return self.part_list[part_number].bar_list diff --git a/musicdiff/comparison.py b/musicdiff/comparison.py index 6850e35..738a349 100644 --- a/musicdiff/comparison.py +++ b/musicdiff/comparison.py @@ -7,20 +7,21 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ __docformat__ = "google" import copy -from typing import List, Tuple from collections import namedtuple from difflib import ndiff +# import typing as t import numpy as np -from musicdiff.annotation import AnnScore, AnnNote, AnnVoice, AnnExtra +from musicdiff.annotation import AnnScore, AnnNote, AnnVoice, AnnExtra, AnnStaffGroup +from musicdiff.annotation import AnnMetadataItem from musicdiff import M21Utils # memoizers to speed up the recursive computation @@ -46,6 +47,28 @@ def memoizer(original, compare_to): return memoizer +def _memoize_staff_groups_diff_lin(func): + mem = {} + + def memoizer(original, compare_to): + key = repr(original) + repr(compare_to) + if key not in mem: + mem[key] = func(original, compare_to) + return copy.deepcopy(mem[key]) + + return memoizer + +def _memoize_metadata_items_diff_lin(func): + mem = {} + + def memoizer(original, compare_to): + key = repr(original) + repr(compare_to) + if key not in mem: + mem[key] = func(original, compare_to) + return copy.deepcopy(mem[key]) + + return memoizer + def _memoize_block_diff_lin(func): mem = {} @@ -167,9 +190,11 @@ def _myers_diff(a_lines, b_lines): @staticmethod def _non_common_subsequences_myers(original, compare_to): - ### Both original and compare_to are list of lists, or numpy arrays with 2 columns. - ### This is necessary because bars need two representation at the same time. - ### One without the id (for comparison), and one with the id (to retrieve the bar at the end) + # Both original and compare_to are list of lists, or numpy arrays with 2 columns. + # This is necessary because bars need two representation at the same time. + # One without the id (for comparison), and one with the id (to retrieve the bar + # at the end). + # get the list of operations op_list = Comparison._myers_diff( np.array(original, dtype=np.int64), np.array(compare_to, dtype=np.int64) @@ -196,7 +221,8 @@ def _non_common_subsequences_myers(original, compare_to): def _non_common_subsequences_of_measures(original_m, compare_to_m): # Take the hash for each measure to run faster comparison # We need two hashes: one that is independent of the IDs (precomputed_str, for comparison), - # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after computation) + # and one that contains the IDs (precomputed_repr, to retrieve the correct measure after + # computation) original_int = [[o.precomputed_str, o.precomputed_repr] for o in original_m] compare_to_int = [[c.precomputed_str, c.precomputed_repr] for c in compare_to_m] ncs = Comparison._non_common_subsequences_myers(original_int, compare_to_int) @@ -220,8 +246,15 @@ def _non_common_subsequences_of_measures(original_m, compare_to_m): @staticmethod @_memoize_pitches_lev_diff - def _pitches_leveinsthein_diff(original, compare_to, noteNode1, noteNode2, ids): - """Compute the leveinsthein distance between two sequences of pitches + def _pitches_leveinsthein_diff( + original: list[tuple[str, str, bool]], + compare_to: list[tuple[str, str, bool]], + noteNode1: AnnNote, + noteNode2: AnnNote, + ids: tuple[int, int] + ): + """ + Compute the leveinsthein distance between two sequences of pitches. Arguments: original {list} -- list of pitches compare_to {list} -- list of pitches @@ -293,7 +326,8 @@ def _pitches_leveinsthein_diff(original, compare_to, noteNode1, noteNode2, ids): @staticmethod def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids): - """compute the differences between two pitch (definition from the paper). + """ + Compute the differences between two pitch (definition from the paper). a pitch consist of a tuple: pitch name (letter+number), accidental, tie. param : pitch1. The music_notation_repr tuple of note1 param : pitch2. The music_notation_repr tuple of note2 @@ -327,8 +361,9 @@ def _pitches_diff(pitch1, pitch2, noteNode1, noteNode2, ids): else: # a different tipe of alteration is present op_list.append(("accidentedit", noteNode1, noteNode2, 1, ids)) # add for the ties - if pitch1[2] != pitch2[2]: # exclusive or. Add if one is tied and not the other - ################probably to revise for chords + if pitch1[2] != pitch2[2]: + # exclusive or. Add if one is tied and not the other. + # probably to revise for chords cost += 1 if pitch1[2]: assert not pitch2[2] @@ -453,7 +488,9 @@ def _extras_diff_lin(original, compare_to): ): # avoid call another function if they are equal extrasub_op, extrasub_cost = [], 0 else: - extrasub_op, extrasub_cost = Comparison._annotated_extra_diff(original[0], compare_to[0]) + extrasub_op, extrasub_cost = ( + Comparison._annotated_extra_diff(original[0], compare_to[0]) + ) cost["extrasub"] += extrasub_cost op_list["extrasub"].extend(extrasub_op) # compute the minimum of the possibilities @@ -461,16 +498,134 @@ def _extras_diff_lin(original, compare_to): out = op_list[min_key], cost[min_key] return out + @staticmethod + @_memoize_metadata_items_diff_lin + def _metadata_items_diff_lin(original, compare_to): + # original and compare to are two lists of tuple[str, t.Any] + if len(original) == 0 and len(compare_to) == 0: + return [], 0 + + if len(original) == 0: + cost = 0 + op_list, cost = Comparison._metadata_items_diff_lin(original, compare_to[1:]) + op_list.append(("mditemins", None, compare_to[0], compare_to[0].notation_size())) + cost += compare_to[0].notation_size() + return op_list, cost + + if len(compare_to) == 0: + cost = 0 + op_list, cost = Comparison._metadata_items_diff_lin(original[1:], compare_to) + op_list.append(("mditemdel", original[0], None, original[0].notation_size())) + cost += original[0].notation_size() + return op_list, cost + + # compute the cost and the op_list for the many possibilities of recursion + cost = {} + op_list = {} + # mditemdel + op_list["mditemdel"], cost["mditemdel"] = Comparison._metadata_items_diff_lin( + original[1:], compare_to + ) + cost["mditemdel"] += original[0].notation_size() + op_list["mditemdel"].append( + ("mditemdel", original[0], None, original[0].notation_size()) + ) + # mditemins + op_list["mditemins"], cost["mditemins"] = Comparison._metadata_items_diff_lin( + original, compare_to[1:] + ) + cost["mditemins"] += compare_to[0].notation_size() + op_list["mditemins"].append( + ("mditemins", None, compare_to[0], compare_to[0].notation_size()) + ) + # mditemsub + op_list["mditemsub"], cost["mditemsub"] = Comparison._metadata_items_diff_lin( + original[1:], compare_to[1:] + ) + if ( + original[0] == compare_to[0] + ): # avoid call another function if they are equal + mditemsub_op, mditemsub_cost = [], 0 + else: + mditemsub_op, mditemsub_cost = ( + Comparison._annotated_metadata_item_diff(original[0], compare_to[0]) + ) + cost["mditemsub"] += mditemsub_cost + op_list["mditemsub"].extend(mditemsub_op) + # compute the minimum of the possibilities + min_key = min(cost, key=cost.get) + out = op_list[min_key], cost[min_key] + return out + + @staticmethod + @_memoize_staff_groups_diff_lin + def _staff_groups_diff_lin(original, compare_to): + # original and compare to are two lists of AnnStaffGroup + if len(original) == 0 and len(compare_to) == 0: + return [], 0 + + if len(original) == 0: + cost = 0 + op_list, cost = Comparison._staff_groups_diff_lin(original, compare_to[1:]) + op_list.append(("staffgrpins", None, compare_to[0], compare_to[0].notation_size())) + cost += compare_to[0].notation_size() + return op_list, cost + + if len(compare_to) == 0: + cost = 0 + op_list, cost = Comparison._staff_groups_diff_lin(original[1:], compare_to) + op_list.append(("staffgrpdel", original[0], None, original[0].notation_size())) + cost += original[0].notation_size() + return op_list, cost + + # compute the cost and the op_list for the many possibilities of recursion + cost = {} + op_list = {} + # staffgrpdel + op_list["staffgrpdel"], cost["staffgrpdel"] = Comparison._staff_groups_diff_lin( + original[1:], compare_to + ) + cost["staffgrpdel"] += original[0].notation_size() + op_list["staffgrpdel"].append( + ("staffgrpdel", original[0], None, original[0].notation_size()) + ) + # staffgrpins + op_list["staffgrpins"], cost["staffgrpins"] = Comparison._staff_groups_diff_lin( + original, compare_to[1:] + ) + cost["staffgrpins"] += compare_to[0].notation_size() + op_list["staffgrpins"].append( + ("staffgrpins", None, compare_to[0], compare_to[0].notation_size()) + ) + # staffgrpsub + op_list["staffgrpsub"], cost["staffgrpsub"] = Comparison._staff_groups_diff_lin( + original[1:], compare_to[1:] + ) + if ( + original[0] == compare_to[0] + ): # avoid call another function if they are equal + staffgrpsub_op, staffgrpsub_cost = [], 0 + else: + staffgrpsub_op, staffgrpsub_cost = ( + Comparison._annotated_staff_group_diff(original[0], compare_to[0]) + ) + cost["staffgrpsub"] += staffgrpsub_cost + op_list["staffgrpsub"].extend(staffgrpsub_op) + # compute the minimum of the possibilities + min_key = min(cost, key=cost.get) + out = op_list[min_key], cost[min_key] + return out + @staticmethod def _strings_leveinshtein_distance(str1: str, str2: str): counter: dict = {"+": 0, "-": 0} distance: int = 0 - for edit_code, *_ in ndiff(str1, str2): - if edit_code == " ": + for edit_code in ndiff(str1, str2): + if edit_code[0] == " ": distance += max(counter.values()) counter = {"+": 0, "-": 0} else: - counter[edit_code] += 1 + counter[edit_code[0]] += 1 distance += max(counter.values()) return distance @@ -486,7 +641,8 @@ def _areDifferentEnough(flt1: float, flt2: float) -> bool: @staticmethod def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra): - """compute the differences between two annotated extras + """ + Compute the differences between two annotated extras. Each annotated extra consists of three values: content, offset, and duration """ cost = 0 @@ -494,8 +650,9 @@ def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra): # add for the content if annExtra1.content != annExtra2.content: - content_cost: int = Comparison._strings_leveinshtein_distance( - annExtra1.content, annExtra2.content) + content_cost: int = ( + Comparison._strings_leveinshtein_distance(annExtra1.content, annExtra2.content) + ) cost += content_cost op_list.append(("extracontentedit", annExtra1, annExtra2, content_cost)) @@ -525,6 +682,108 @@ def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra): return op_list, cost + @staticmethod + def _annotated_metadata_item_diff( + annMetadataItem1: AnnMetadataItem, + annMetadataItem2: AnnMetadataItem + ): + """ + Compute the differences between two annotated metadata items. + Each annotated metadata item has two values: key: str, value: t.Any, + """ + cost = 0 + op_list = [] + + # add for the key + if annMetadataItem1.key != annMetadataItem2.key: + key_cost: int = ( + Comparison._strings_leveinshtein_distance( + annMetadataItem1.key, + annMetadataItem2.key + ) + ) + cost += key_cost + op_list.append(("mditemkeyedit", annMetadataItem1, annMetadataItem2, key_cost)) + + # add for the value + if annMetadataItem1.value != annMetadataItem2.value: + value_cost: int = ( + Comparison._strings_leveinshtein_distance( + str(annMetadataItem1.value), + str(annMetadataItem2.value) + ) + ) + cost += value_cost + op_list.append( + ("mditemvalueedit", annMetadataItem1, annMetadataItem2, value_cost) + ) + + return op_list, cost + + @staticmethod + def _annotated_staff_group_diff(annStaffGroup1: AnnStaffGroup, annStaffGroup2: AnnStaffGroup): + """ + Compute the differences between two annotated staff groups. + Each annotated staff group consists of five values: name, abbreviation, + symbol, barTogether, part_indices. + """ + cost = 0 + op_list = [] + + # add for the name + if annStaffGroup1.name != annStaffGroup2.name: + name_cost: int = ( + Comparison._strings_leveinshtein_distance(annStaffGroup1.name, annStaffGroup2.name) + ) + cost += name_cost + op_list.append(("staffgrpnameedit", annStaffGroup1, annStaffGroup2, name_cost)) + + # add for the abbreviation + if annStaffGroup1.abbreviation != annStaffGroup2.abbreviation: + abbreviation_cost: int = ( + Comparison._strings_leveinshtein_distance( + annStaffGroup1.abbreviation, + annStaffGroup2.abbreviation + ) + ) + cost += abbreviation_cost + op_list.append( + ("staffgrpabbreviationedit", annStaffGroup1, annStaffGroup2, abbreviation_cost) + ) + + # add for the symbol + if annStaffGroup1.symbol != annStaffGroup2.symbol: + symbol_cost: int = 1 + cost += symbol_cost + op_list.append( + ("staffgrpsymboledit", annStaffGroup1, annStaffGroup2, symbol_cost) + ) + + # add for barTogether + if annStaffGroup1.barTogether != annStaffGroup2.barTogether: + barTogether_cost: int = 1 + cost += barTogether_cost + op_list.append( + ("staffgrpbartogetheredit", annStaffGroup1, annStaffGroup2, barTogether_cost) + ) + + # add for partIndices (sorted list of int) + if annStaffGroup1.part_indices != annStaffGroup2.part_indices: + parts1: str = str(annStaffGroup1.part_indices) + parts2: str = str(annStaffGroup2.part_indices) + partIndices_cost: int = ( + Comparison._strings_leveinshtein_distance( + parts1, + parts2 + ) + ) + cost += partIndices_cost + op_list.append( + ("staffgrppartindicesedit", annStaffGroup1, annStaffGroup2, partIndices_cost) + ) + + return op_list, cost + @staticmethod @_memoize_inside_bars_diff_lin def _inside_bars_diff_lin(original, compare_to): @@ -584,8 +843,10 @@ def _inside_bars_diff_lin(original, compare_to): @staticmethod def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote): - """compute the differences between two annotated notes - Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets) where pitches is a list + """ + Compute the differences between two annotated notes. + Each annotated note consist in a 5tuple (pitches, notehead, dots, beamings, tuplets) + where pitches is a list. Arguments: noteNode1 {[AnnNote]} -- original AnnNote noteNode2 {[AnnNote]} -- compare_to AnnNote @@ -695,7 +956,8 @@ def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote): @staticmethod @_memoize_beamtuplet_lev_diff def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which): - """Compute the leveinsthein distance between two sequences of beaming or tuples + """ + Compute the leveinsthein distance between two sequences of beaming or tuples. Arguments: original {list} -- list of strings (start, stop, continue or partial) compare_to {list} -- list of strings (start, stop, continue or partial) @@ -703,8 +965,8 @@ def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which): note2 {AnnNote} -- the note for referencing in the score which -- a string: "beam" or "tuplet" depending what we are comparing """ - if not which in ("beam", "tuplet"): - raise Exception("Argument 'which' must be either 'beam' or 'tuplet'") + if which not in ("beam", "tuplet"): + raise ValueError("Argument 'which' must be either 'beam' or 'tuplet'") if len(original) == 0 and len(compare_to) == 0: return [], 0 @@ -744,7 +1006,7 @@ def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which): op_list["edit" + which], cost["edit" + which] = Comparison._beamtuplet_leveinsthein_diff( original[1:], compare_to[1:], note1, note2, which ) - if original[0] == compare_to[0]: # to avoid perform the pitch_diff + if original[0] == compare_to[0]: beam_diff_op_list = [] beam_diff_cost = 0 else: @@ -759,7 +1021,9 @@ def _beamtuplet_leveinsthein_diff(original, compare_to, note1, note2, which): @staticmethod @_memoize_generic_lev_diff def _generic_leveinsthein_diff(original, compare_to, note1, note2, which): - """Compute the leveinsthein distance between two generic sequences of symbols (e.g., articulations) + """ + Compute the leveinsthein distance between two generic sequences of symbols + (e.g., articulations). Arguments: original {list} -- list of strings compare_to {list} -- list of strings @@ -821,8 +1085,10 @@ def _generic_leveinsthein_diff(original, compare_to, note1, note2, which): return out @staticmethod - def _voices_coupling_recursive(original: List[AnnVoice], compare_to: List[AnnVoice]): - """compare all the possible voices permutations, considering also deletion and insertion (equation on office lens) + def _voices_coupling_recursive(original: list[AnnVoice], compare_to: list[AnnVoice]): + """ + Compare all the possible voices permutations, considering also deletion and + insertion (equation on office lens). original [list] -- a list of Voice compare_to [list] -- a list of Voice """ @@ -861,7 +1127,7 @@ def _voices_coupling_recursive(original: List[AnnVoice], compare_to: List[AnnVoi op_list["voicesub" + str(i)], cost["voicesub" + str(i)], ) = Comparison._voices_coupling_recursive( - original[1:], compare_to[:i] + compare_to[i + 1 :] + original[1:], compare_to[:i] + compare_to[i + 1:] ) if ( compare_to[0] != original[0] @@ -877,7 +1143,7 @@ def _voices_coupling_recursive(original: List[AnnVoice], compare_to: List[AnnVoi return out @staticmethod - def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tuple], int]: + def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> tuple[list[tuple], int]: ''' Compare two annotated scores, computing an operations list and the cost of applying those operations to the first score to generate the second score. @@ -887,7 +1153,7 @@ def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tupl score2 (`musicdiff.annotation.AnnScore`): The second annotated score to compare. Returns: - List[Tuple], int: The operations list and the cost + list[tuple], int: The operations list and the cost ''' # for now just working with equal number of parts that are already pairs # TODO : extend to different number of parts @@ -909,4 +1175,18 @@ def annotated_scores_diff(score1: AnnScore, score2: AnnScore) -> Tuple[List[Tupl op_list_total.extend(op_list_block) cost_total += cost_block + # compare the staff groups + groups_op_list, groups_cost = Comparison._staff_groups_diff_lin( + score1.staff_group_list, score2.staff_group_list + ) + op_list_total.extend(groups_op_list) + cost_total += groups_cost + + # compare the metadata items + mditems_op_list, mditems_cost = Comparison._metadata_items_diff_lin( + score1.metadata_items_list, score2.metadata_items_list + ) + op_list_total.extend(mditems_op_list) + cost_total += mditems_cost + return op_list_total, cost_total diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index 4767820..f2da36e 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -7,47 +7,82 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ from fractions import Fraction import math import sys -from typing import List, Union -from enum import IntEnum, auto +import copy +import typing as t +from enum import IntEnum # import sys import music21 as m21 +from music21.common.types import OffsetQL class DetailLevel(IntEnum): + # Bit definitions are private: + _GeneralNotes = 1 + _Extras = 2 + _Style = 4 + _Metadata = 8 + + # Combinations are public (and supported on command line): + # Chords, Notes, Rests, Unpitched, etc (and their beams/expressions/articulations) - GeneralNotesOnly = auto() + GeneralNotesOnly = _GeneralNotes - # Add in Clefs, TextExpressions, Key/KeySignatures, Barlines/Repeats, TimeSignatures, TempoIndications, etc - AllObjects = auto() + # Add in the "extras": Clefs, TextExpressions, Key/KeySignatures, Barlines/Repeats, + # TimeSignatures, TempoIndications, etc + AllObjects = GeneralNotesOnly | _Extras # All of the above, plus typographical stuff: placement, stem direction, # color, italic/bold, Style, etc - AllObjectsWithStyle = auto() + AllObjectsWithStyle = AllObjects | _Style + + # Various options that include Metadata: + MetadataOnly = _Metadata + GeneralNotesAndMetadata = GeneralNotesOnly | _Metadata + AllObjectsAndMetadata = AllObjects | _Metadata + AllObjectsWithStyleAndMetadata = AllObjectsWithStyle | _Metadata Default = AllObjects + @classmethod + def includesGeneralNotes(cls, val: int) -> bool: + return val & cls._GeneralNotes != 0 + + @classmethod + def includesOtherMusicObjects(cls, val: int) -> bool: + return val & cls._Extras != 0 + + @classmethod + def includesStyle(cls, val: int) -> bool: + return val & cls._Style != 0 + + @classmethod + def includesMetadata(cls, val: int) -> bool: + return val & cls._Metadata != 0 + class M21Utils: @staticmethod - def get_beamings(note_list): - _beam_list = [] + def get_beamings(note_list: list[m21.note.GeneralNote]) -> list[list[str]]: + _beam_list: list[list[str]] = [] for n in note_list: if n.isRest: _beam_list.append([]) else: + if t.TYPE_CHECKING: + assert isinstance(n, m21.note.NotRest) _beam_list.append(n.beams.getTypes()) return _beam_list @staticmethod - def generalNote_to_string(gn): + def generalNote_to_string(gn: m21.note.GeneralNote) -> str: """ Return the NoteString with R or N, notehead number and dots. Does not consider the ties (because of music21 ties encoding). @@ -62,7 +97,8 @@ def generalNote_to_string(gn): out_string += "R" else: out_string += "N" - # add notehead information (4,2,1,1/2, etc...). 4 means a black note, 2 white, 1 whole etc... + # add notehead information (4,2,1,1/2, etc...). + # 4 means a black note, 2 white, 1 whole etc... type_number = Fraction(m21.duration.convertTypeToNumber(gn.duration.type)) if type_number >= 4: out_string += "4" @@ -74,15 +110,173 @@ def generalNote_to_string(gn): out_string += "*" return out_string + @staticmethod + def expression_to_string( + expr: m21.expressions.Expression, + detail: DetailLevel = DetailLevel.Default + ) -> str: + theName: str = '' + placement: str | None = None + + # we customize name a bit for Turn/GeneralMordent/Trill, because we only want to + # know about visible accidentals (i.e. with displayStatus == True). + if isinstance(expr, m21.expressions.Turn): + theName = expr.__class__.__name__ + theName = m21.common.camelCaseToHyphen(theName, replacement=' ') + + if expr.delay == m21.common.enums.OrnamentDelay.DEFAULT_DELAY: + theName = 'delayed ' + theName + elif isinstance(expr.delay, (float, Fraction)): + theName = f'delayed(delayQL={expr.delay}) ' + theName + + upperAccidentalIsVisible: bool = ( + expr.upperAccidental is not None + and expr.upperAccidental.displayStatus is True + ) + if not upperAccidentalIsVisible: + # check if someone (e.g. makeAccidentals) decided it should be visible anyway + upperAccidentalIsVisible = ( + expr.upperOrnamentalPitch is not None + and expr.upperOrnamentalPitch.accidental is not None + and expr.upperOrnamentalPitch.accidental.displayStatus is True + ) + + lowerAccidentalIsVisible: bool = ( + expr.lowerAccidental is not None + and expr.lowerAccidental.displayStatus is True + ) + if not lowerAccidentalIsVisible: + # check if someone (e.g. makeAccidentals) decided it should be visible anyway + lowerAccidentalIsVisible = ( + expr.lowerOrnamentalPitch is not None + and expr.lowerOrnamentalPitch.accidental is not None + and expr.lowerOrnamentalPitch.accidental.displayStatus is True + ) + + if upperAccidentalIsVisible or lowerAccidentalIsVisible: + theName += ' (' + if upperAccidentalIsVisible: + if t.TYPE_CHECKING: + assert expr.upperAccidental is not None + theName += 'upper=' + expr.upperAccidental.name + if lowerAccidentalIsVisible: + theName += ', ' + if lowerAccidentalIsVisible: + if t.TYPE_CHECKING: + assert expr.lowerAccidental is not None + theName += 'lower=' + expr.lowerAccidental.name + theName += ')' + + # if diffing style, include placement (None, "above", "below") + if DetailLevel.includesStyle(detail): + placement = None + if hasattr(expr, 'placement'): + placement = getattr(expr, 'placement') + elif expr.hasStyleInformation and hasattr(expr.style, 'placement'): + placement = getattr(expr.style, 'placement') + if placement is not None: + theName = theName + '(' + placement + ')' + + return theName + + if isinstance(expr, (m21.expressions.GeneralMordent, m21.expressions.Trill)): + theName = expr.__class__.__name__ + theName = m21.common.camelCaseToHyphen(theName, replacement=' ') + + accidentalIsVisible: bool = ( + expr.accidental is not None and expr.accidental.displayStatus is True + ) + if not accidentalIsVisible: + # check if someone (e.g. makeAccidentals) decided it should be visible anyway + accidentalIsVisible = ( + expr.ornamentalPitch is not None + and expr.ornamentalPitch.accidental is not None + and expr.ornamentalPitch.accidental.displayStatus is True + ) + + if accidentalIsVisible: + if t.TYPE_CHECKING: + assert expr.accidental is not None + theName += f' ({expr.accidental.name})' + + # if diffing style, include placement (None, "above", "below") + if DetailLevel.includesStyle(detail): + placement = None + if hasattr(expr, 'placement'): + placement = getattr(expr, 'placement') + elif expr.hasStyleInformation and hasattr(expr.style, 'placement'): + placement = getattr(expr.style, 'placement') + if placement is not None: + theName = theName + '(' + placement + ')' + + return theName + + if isinstance(expr, m21.expressions.Tremolo): + return M21Utils.tremolo_to_string(expr, detail) + + # all others just get expr.name + theName = expr.name + return theName @staticmethod - def note2tuple(note): - # pitch name (including octave, but not accidental) - if isinstance(note, m21.note.Unpitched): + def tremolo_to_string( + expr: m21.expressions.Tremolo | m21.expressions.TremoloSpanner, + detail: DetailLevel = DetailLevel.Default + ) -> str: + if isinstance(expr, m21.expressions.Tremolo): + return 'bTrem' + if isinstance(expr, m21.expressions.TremoloSpanner): + return 'fTrem' + return '' + + @staticmethod + def articulation_to_string( + artic: m21.articulations.Articulation, + detail: DetailLevel = DetailLevel.Default + ) -> str: + theName: str = artic.name + + # if diffing style, include placement (None, "above", "below") + if DetailLevel.includesStyle(detail): + placement: str | None = None + if hasattr(artic, 'placement'): + placement = getattr(artic, 'placement') + elif artic.hasStyleInformation and hasattr(artic.style, 'placement'): + placement = getattr(artic.style, 'placement') + if placement is not None: + theName = theName + '(' + placement + ')' + + return theName + + @staticmethod + def note2tuple( + note: m21.note.Note | m21.note.Unpitched | m21.note.Rest, + detail: DetailLevel = DetailLevel.Default + ) -> tuple[str, str, bool]: + note_pitch: str + note_accidental: str + note_tie: bool = False + if isinstance(note, m21.note.Rest): + note_pitch = "R" + note_accidental = "None" + if DetailLevel.includesStyle(detail): + # Rest position is style, not substance, but check + # that rest-position comparison hasn't been turned off + TURN_OFF_REST_POSITION_COMPARISON: int = 0x10000000 + if detail & TURN_OFF_REST_POSITION_COMPARISON == 0: + # rest.stepShift is the number of lines/spaces above/below middle of staff. + # We can use it directly in our annotation. + if note.stepShift > 0: + note_pitch = f"R+{note.stepShift}" + elif note.stepShift < 0: + note_pitch = f"R{note.stepShift}" + + elif isinstance(note, m21.note.Unpitched): # use the displayName (e.g. 'G4') with no accidental note_pitch = note.displayName note_accidental = "None" else: + # pitch name (including octave, but not accidental) note_pitch = note.pitch.step + str(note.pitch.octave) # note_accidental is only set to non-'None' if the accidental will @@ -91,15 +285,15 @@ def note2tuple(note): if note.pitch.accidental is None: pass elif note.pitch.accidental.displayStatus is not None: - if note.pitch.accidental.displayStatus: + if note.pitch.accidental.displayStatus is True: note_accidental = note.pitch.accidental.name else: # note.pitch.accidental.displayStatus was not set. # This can happen when there are no measures in the test data. # We will guess, based on displayType. - # displayType can be 'normal', 'always', 'never', 'unless-repeated', 'even-tied' - # print("accidental.displayStatus unknown, so we will guess based on displayType", file=sys.stderr) - displayType = note.pitch.accidental.displayType + # displayType can be 'normal', 'always', 'never', 'unless-repeated', + # 'if-absolutely-necessary', 'even-tied' + displayType: str | None = note.pitch.accidental.displayType if displayType is None: displayType = "normal" @@ -107,7 +301,7 @@ def note2tuple(note): note_accidental = note.pitch.accidental.name elif displayType == "never": note_accidental = "None" - elif displayType == "normal": + elif displayType in ("normal", "if-absolutely-necessary"): # Complete guess: the accidental will be displayed # This will be wrong if this is not the first such note in the measure. note_accidental = note.pitch.accidental.name @@ -117,13 +311,17 @@ def note2tuple(note): # TODO: we should append editorial style info to note_accidental here ('paren', etc) - # add tie information (Unpitched has this, too) - note_tie = note.tie is not None and note.tie.type in ("stop", "continue") + # add tie information (Unpitched has this, too, but not Rest, and not meaningfully in + # Chord either) + if isinstance(note, (m21.note.Rest, m21.chord.ChordBase)): + note_tie = False + else: + note_tie = note.tie is not None and note.tie.type in ("start", "continue") return (note_pitch, note_accidental, note_tie) @staticmethod - def pitch_size(pitch): + def pitch_size(pitch: tuple[str, str, bool]) -> int: """Compute the size of a pitch. Arguments: pitch {[triple]} -- a triple (pitchname,accidental,tie) @@ -141,28 +339,37 @@ def pitch_size(pitch): @staticmethod - def generalNote_info(gn): + def generalNote_info(gn: m21.note.GeneralNote) -> dict[str, int | str | list]: """ Get a json of informations about a general note. - The fields of the json are "type"-string (chord, rest,note), "pitches" (a list of pitches)-list of strings,"noteHead" (also for rests)-string,"dots"-integer. - For rests the pitch is set to [\"A0\"]. + The fields of the json are + type: ("chord", "rest", or "note"), + pitches: list of pitch strings + noteHead (also for rests): string + dots: integer + For rests the pitch is set to ['A0']. Does not consider the ties (because of music21 ties encoding). Arguments: gn {music21 general note} -- the general note to have the information """ # pitches and type info - if gn.isChord: + pitches: list[tuple[str, m21.pitch.Accidental | None]] + gn_type: str + if isinstance(gn, m21.chord.ChordBase): + gnPitches: tuple[m21.pitch.Pitch, ...] = gn.pitches + if hasattr(gn, "sortDiatonicAscending"): + gnPitches = gn.sortDiatonicAscending().pitches pitches = [ (p.step + str(p.octave), p.accidental) - for p in gn.sortDiatonicAscending().pitches + for p in gnPitches ] gn_type = "chord" elif gn.isRest: - pitches = ["A0", None] # pitch is set to ["A0"] for rests + pitches = [("A0", None)] # pitch is set to ["A0"] for rests gn_type = "rest" - elif gn.isNote: + elif isinstance(gn, m21.note.Note): pitches = [ - (gn.step + str(gn.octave), gn.pitch.accidental) + (gn.pitch.step + str(gn.pitch.octave), gn.pitch.accidental) ] # a list with one pitch inside gn_type = "note" else: @@ -175,7 +382,7 @@ def generalNote_info(gn): else: note_head = str(type_number) - gn_info = { + gn_info: dict[str, int | str | list] = { "type": gn_type, "pitches": pitches, "noteHead": note_head, @@ -192,20 +399,21 @@ def generalNote_info(gn): # else: # _general_ties_list.append(n.tie.type) # # keep only the information of when a note is tied to the previous - # # (also we solve the bad notation of having a start and a not specified stop, that can happen in music21) + # # (also we solve the bad notation of having a start and a not specified + # # stop, that can happen in music21) # _ties_list = [False] * len(_general_ties_list) # for i, t in enumerate(_general_ties_list): # if t == 'start' and i < len(_ties_list) - 1: # _ties_list[i + 1] = True # elif t == 'continue' and i < len(_ties_list) - 1: # _ties_list[i + 1] = True - # if i == 0: # we can have a continue in the first note if the tie is from the previous bar + # if i == 0: # we can have a continue in first note if tie is from previous bar # _ties_list[i] = True # elif t == 'stop': - # if i == 0: # we can have a stop in the first note if the tie is from the previous bar + # if i == 0: # we can have a stop in first note if tie is from previous bar # _ties_list[i] = True # else: - # # assert (_ties_list[i] == True) #removed to import wrong scores even if it vould be correct + # # assert (_ties_list[i] == True) # don't reject wrong scores # _ties_list[i] = True # return _ties_list @@ -219,16 +427,16 @@ def get_type_num(duration: m21.duration.Duration) -> float: return typeNum @staticmethod - def get_type_nums(note_list): - _type_list = [] + def get_type_nums(note_list: list[m21.note.GeneralNote]) -> list[float]: + _type_list: list[float] = [] for n in note_list: _type_list.append(M21Utils.get_type_num(n.duration)) return _type_list @staticmethod - def get_rest_or_note(note_list): - _rest_or_note = [] + def get_rest_or_note(note_list: list[m21.note.GeneralNote]) -> list[str]: + _rest_or_note: list[str] = [] for n in note_list: if n.isRest: _rest_or_note.append("R") @@ -238,15 +446,17 @@ def get_rest_or_note(note_list): @staticmethod - def get_enhance_beamings(note_list): - """create a mod_beam_list that take into account also the single notes with a type > 4""" - _beam_list = M21Utils.get_beamings(note_list) - _type_list = M21Utils.get_type_nums(note_list) - _mod_beam_list = M21Utils.get_beamings(note_list) + def get_enhance_beamings(note_list: list[m21.note.GeneralNote]) -> list[list[str]]: + """ + Create a mod_beam_list that take into account also the single notes with a type > 4 + """ + _beam_list: list[list[str]] = M21Utils.get_beamings(note_list) + _type_list: list[float] = M21Utils.get_type_nums(note_list) + _mod_beam_list: list[list[str]] = M21Utils.get_beamings(note_list) # add informations for rests and notes not grouped for i, n in enumerate(_beam_list): if len(n) == 0: - rangeEnd: int = None + rangeEnd: int | None = None if _type_list[i] != 0: rangeEnd = int(math.log(_type_list[i] / 4, 2)) if rangeEnd is None: @@ -265,37 +475,52 @@ def get_enhance_beamings(note_list): _mod_beam_list[i].append("continue") else: _mod_beam_list[i].append("partial") - # change the single "start" and "stop" with partial (since MEI parser is not working properly) - new_mod_beam_list = _mod_beam_list.copy() - max_beam_len = max([len(t) for t in _mod_beam_list]) + # change the single "start" and "stop" with partial (since MEI parser is + # not working properly) + new_mod_beam_list: list[list[str]] = _mod_beam_list.copy() + max_beam_len: int = max([len(t) for t in _mod_beam_list]) for beam_depth in range(max_beam_len): for note_index in range(len(_mod_beam_list)): if ( - M21Utils.safe_get(_mod_beam_list[note_index], beam_depth) == "start" - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth) - is None + M21Utils.safe_get( + _mod_beam_list[note_index], beam_depth + ) == "start" + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth + ) is None ): new_mod_beam_list[note_index][beam_depth] = "partial" elif ( - M21Utils.safe_get(_mod_beam_list[note_index], beam_depth) == "stop" - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth) - is None + M21Utils.safe_get( + _mod_beam_list[note_index], beam_depth + ) == "stop" + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth + ) is None ): new_mod_beam_list[note_index][beam_depth] = "partial" elif ( - M21Utils.safe_get(_mod_beam_list[note_index], beam_depth) == "continue" - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth) - is None - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth) - is None + M21Utils.safe_get( + _mod_beam_list[note_index], beam_depth + ) == "continue" + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth + ) is None + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth + ) is None ): new_mod_beam_list[note_index][beam_depth] = "partial" elif ( - M21Utils.safe_get(_mod_beam_list[note_index], beam_depth) == "continue" - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth) - is None - and M21Utils.safe_get(M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth) - is not None + M21Utils.safe_get( + _mod_beam_list[note_index], beam_depth + ) == "continue" + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index - 1), beam_depth + ) is None + and M21Utils.safe_get( + M21Utils.safe_get(_mod_beam_list, note_index + 1), beam_depth + ) is not None ): new_mod_beam_list[note_index][beam_depth] = "start" @@ -303,170 +528,286 @@ def get_enhance_beamings(note_list): @staticmethod - def get_dots(note_list): + def get_dots(note_list: list[m21.note.GeneralNote]) -> list[int]: return [n.duration.dots for n in note_list] @staticmethod - def get_durations(note_list): + def get_durations(note_list: list[m21.note.GeneralNote]) -> list[Fraction]: return [Fraction(n.duration.quarterLength) for n in note_list] @staticmethod - def get_norm_durations(note_list): + def get_norm_durations(note_list: list[m21.note.GeneralNote]) -> list[Fraction]: dur_list = M21Utils.get_durations(note_list) - if sum(dur_list) == 0: + sum_dur_list = sum(dur_list) + if sum_dur_list == 0: raise ValueError("It's not possible to normalize the durations if the sum is 0") - return [d / sum(dur_list) for d in dur_list] # normalize the duration + return [d / sum_dur_list for d in dur_list] # normalize the duration @staticmethod - def get_tuplets(note_list): + def get_tuplets( + note_list: list[m21.note.GeneralNote] + ) -> list[tuple[m21.duration.Tuplet, ...]]: return [n.duration.tuplets for n in note_list] @staticmethod - def get_tuplets_info(note_list): - """create a list with the string that is on the tuplet bracket""" - str_list = [] + def get_tuplets_info( + note_list: list[m21.note.GeneralNote], + detail: DetailLevel = DetailLevel.Default + ) -> list[list[str]]: + """ + for each note return a list of tuple(str, str) with the tuplet type string and a string + representation of what is visible. + """ + str_list: list[list[str]] = [] for n in note_list: - tuple_info_list_for_note = [] - for t in n.duration.tuplets: - if t.tupletNormalShow in ("number", "both"): # if there is a notation like "2:3" - new_info = str(t.numberNotesActual) + ":" + str(t.numberNotesNormal) - else: # just a number for the tuplets - new_info = str(t.numberNotesActual) - # if the brackets are drown explicitly, add B - if t.bracket: - new_info = new_info + "B" - tuple_info_list_for_note.append(new_info) - str_list.append(tuple_info_list_for_note) + tuplet_info_list_for_note: list[str] = [] + for tup in n.duration.tuplets: + if tup.type == "start": + # music21 only pays attention to number and bracket visibility/placement + # on the start note of a tuplet. + if tup.tupletActualShow in ("number", "both"): + if tup.tupletNormalShow in ("number", "both"): + new_info = str(tup.numberNotesActual) + ":" + str(tup.numberNotesNormal) + else: # just a number for the tuplets + new_info = str(tup.numberNotesActual) + else: + if tup.tupletNormalShow in ("number", "both"): + new_info = ":" + str(tup.numberNotesNormal) + else: # no number shown + new_info = "" + # if the brackets are drawn explicitly, add B + if tup.bracket: + new_info = new_info + "B" + # if diffing style, include placement (None, "above", "below") + if DetailLevel.includesStyle(detail): + if tup.placement is not None: + new_info = new_info + tup.placement + tuplet_info_list_for_note.append(new_info) + str_list.append(tuplet_info_list_for_note) return str_list @staticmethod - def get_tuplets_type(note_list): - tuplets_list = [[t.type for t in n.duration.tuplets] for n in note_list] + def get_tuplets_type( + note_list: list[m21.note.GeneralNote] + ) -> list[list[str]]: + """ + for each note return a list of tuple(str, str), with the first string filled in with + the type of the tuplets for the note + """ + tuplets_list: list[list[str | None]] = [ + [tup.type for tup in n.duration.tuplets] for n in note_list # type: ignore + ] new_tuplets_list = tuplets_list.copy() + # now correct the missing of "start" and add "continue" for clarity max_tupl_len = max([len(t) for t in tuplets_list]) for ii in range(max_tupl_len): start_index = None # stop_index = None - for i, note_tuple in enumerate(tuplets_list): - if len(note_tuple) > ii: - if note_tuple[ii] == "start": + for i, note_tuplets in enumerate(tuplets_list): + if len(note_tuplets) > ii: + if note_tuplets[ii] == "start": # Some medieval music has weirdly nested triplets that - # end up in music21 with two starts in a row. This is - # OK, no need to assert here. -# assert start_index is None + # end up in music21 with two starts in a row. start_index = ii - elif note_tuple[ii] is None: + elif note_tuplets[ii] is None: + # replace any None with "start" or "continue" if start_index is None: start_index = ii new_tuplets_list[i][ii] = "start" else: new_tuplets_list[i][ii] = "continue" - elif note_tuple[ii] == "stop": + elif note_tuplets[ii] in ("stop", "startStop"): start_index = None else: raise TypeError("Invalid tuplet type") - return new_tuplets_list + # we have replaced any None with "start" or "continue" + return t.cast(list[list[str]], new_tuplets_list) @staticmethod - def get_notes(measure, allowGraceNotes=False): + def get_notes( + measureOrVoice: m21.stream.Measure | m21.stream.Voice, + allowGraceNotes=False + ) -> list[m21.note.GeneralNote]: """ - :param measure: a music21 measure + :param measureOrVoice: a music21 measure or voice :return: a list of (visible) notes, eventually excluding grace notes, inside the measure """ out = [] if allowGraceNotes: - for n in measure.getElementsByClass('GeneralNote'): + for n in measureOrVoice.getElementsByClass('GeneralNote'): if not n.style.hideObjectOnPrint: out.append(n) else: - for n in measure.getElementsByClass('GeneralNote'): + for n in measureOrVoice.getElementsByClass('GeneralNote'): if not n.style.hideObjectOnPrint and n.duration.quarterLength != 0: out.append(n) return out @staticmethod - def get_notes_and_gracenotes(measure): + def get_notes_and_gracenotes( + measureOrVoice: m21.stream.Measure | m21.stream.Voice + ) -> list[m21.note.GeneralNote]: """ - :param measure: a music21 measure + :param measureOrVoice: a music21 measure or voice :return: a list of visible notes, including grace notes, inside the measure """ - out = [] - for n in measure.getElementsByClass('GeneralNote'): + out: list[m21.note.GeneralNote] = [] + for n in measureOrVoice.getElementsByClass('GeneralNote'): if not n.style.hideObjectOnPrint: out.append(n) return out @staticmethod - def get_extras(measure: m21.stream.Measure, spannerBundle: m21.spanner.SpannerBundle) -> List[m21.base.Music21Object]: + def getHighestDiatonicNoteOrChord( + arpeggio: m21.expressions.ArpeggioMarkSpanner + ) -> m21.note.NotRest: + if hasattr(arpeggio, 'musicdiff_cached_highest_diatonic_element'): + return arpeggio.musicdiff_cached_highest_diatonic_element # type: ignore + + origSpannedList: list[m21.note.NotRest] = arpeggio.getSpannedElements() + nrList: list[m21.note.NotRest] = copy.deepcopy(origSpannedList) + highestNoteOrChord: m21.note.NotRest + highestNote: m21.note.Note + for i, (nr, origSpanned) in enumerate(zip(nrList, origSpannedList)): + currentNote: m21.note.Note + if isinstance(nr, m21.chord.Chord): + # set currentNote to the highest diatonic note in the chord + nr.sortDiatonicAscending() + currentNote = nr.notes[-1] + else: + if t.TYPE_CHECKING: + # because you don't see arpeggios on Unpitched + assert isinstance(nr, m21.note.Note) + currentNote = nr + if i == 0: + highestNote = currentNote + highestNoteOrChord = origSpanned + elif currentNote.pitch.diatonicNoteNum > highestNote.pitch.diatonicNoteNum: + highestNote = currentNote + highestNoteOrChord = origSpanned + + arpeggio.musicdiff_cached_highest_diatonic_element = highestNoteOrChord # type: ignore + return highestNoteOrChord + + @staticmethod + def getPrimarySpannerElement( + sp: m21.spanner.Spanner + ) -> m21.note.GeneralNote | m21.spanner.SpannerAnchor: + # returns sp.getFirst() except if the spanner is ArpeggioMarkSpanner, in + # which case it returns the element that contains the highest diatonic + # pitch. + if not isinstance(sp, m21.expressions.ArpeggioMarkSpanner): + return sp.getFirst() + return M21Utils.getHighestDiatonicNoteOrChord(sp) + + @staticmethod + def clefs_are_equivalent( + clef1: m21.clef.Clef | None, + clef2: m21.clef.Clef | None + ) -> bool: + if not isinstance(clef1, m21.clef.Clef): + return False + if not isinstance(clef2, m21.clef.Clef): + return False + + if clef1.sign != clef2.sign: + return False + if clef1.line != clef2.line: + return False + if clef1.octaveChange != clef2.octaveChange: + return False + + return True + + @staticmethod + def get_extras( + measure: m21.stream.Measure, + part: m21.stream.Part, + spannerBundle: m21.spanner.SpannerBundle, + detail: DetailLevel = DetailLevel.Default + ) -> list[m21.base.Music21Object]: # returns a list of every object contained in the measure (and in the measure's - # substreams/Voices), skipping any Streams, layout stuff, and GeneralNotes (which - # are returned from get_notes/get_notes_and_gracenotes). We're looking for things - # like Clefs, TextExpressions, and Dynamics... - output: List[m21.base.Music21Object] = [] - initialList: List[m21.base.Music21Object] - if hasattr(m21.spanner, 'SpannerAnchor'): - initialList = list( - measure.recurse().getElementsNotOfClass( - (m21.note.GeneralNote, - m21.spanner.SpannerAnchor, - m21.stream.Stream, - m21.layout.LayoutBase) ) ) - else: - initialList = list( - measure.recurse().getElementsNotOfClass( - (m21.note.GeneralNote, - m21.stream.Stream, - m21.layout.LayoutBase) ) ) + # substreams/Voices), skipping any Streams, and GeneralNotes (which are returned + # from get_notes/get_notes_and_gracenotes). We're looking for things like Clefs, + # TextExpressions, and Dynamics... + output: list[m21.base.Music21Object] = [] + initialList: list[m21.base.Music21Object] + initialList = list( + measure.recurse().getElementsNotOfClass( + (m21.note.GeneralNote, + m21.spanner.SpannerAnchor, + m21.stream.Stream, + m21.spanner.Spanner) + ) + ) + + # Sort the initialList by offset in measure, so we can see which clefs are + # duplicates from different voices. + if len(initialList) > 1: + for el in initialList: + el.musicdiff_offset_in_measure = el.getOffsetInHierarchy(measure) # type: ignore + initialList.sort(key=lambda el: el.musicdiff_offset_in_measure) # type: ignore # loop over the initialList, filtering out (and complaining about) things we # don't recognize. Also, we filter out hidden (non-printed) extras. And # barlines of type 'none' (also not printed). + # We also try to de-duplicate redundant clefs. + mostRecentClef: m21.clef.Clef | None = None for el in initialList: # we ignore hidden extras if el.hasStyleInformation and el.style.hideObjectOnPrint: continue if isinstance(el, m21.bar.Barline) and el.type == 'none': continue - if M21Utils.extra_to_string(el) == '': + if M21Utils.extra_to_string(el, detail) == '': continue + if isinstance(el, m21.clef.Clef): + # If this clef is the same as the most recent clef seen in this + # measure (i.e. with no different clef between them), ignore + # this one. It not, use this one, and make a note of it as the + # most recent clef. + + # Clef __eq__ compares class, sign, line, and octaveShift. + # I don't want to include class in this, since I would like + # clef.TrebleClef() == clef.GClef(line=2) to evaluate to True. + if M21Utils.clefs_are_equivalent(el, mostRecentClef): + # ignore this clef + continue + + mostRecentClef = el + output.append(el) - # Add any ArpeggioMarkSpanners/Crescendos/Diminuendos that start - # on GeneralNotes/SpannerAnchors in this measure - if hasattr(m21.expressions, 'ArpeggioMarkSpanner'): - spanner_types = (m21.expressions.ArpeggioMarkSpanner, m21.dynamics.DynamicWedge) - else: - spanner_types = (m21.dynamics.DynamicWedge,) - - if hasattr(m21.spanner, 'SpannerAnchor'): - for gn in measure.recurse().getElementsByClass( - (m21.note.GeneralNote, m21.spanner.SpannerAnchor) - ): - spannerList: List[m21.spanner.Spanner] = gn.getSpannerSites(spanner_types) - for sp in spannerList: - if sp not in spannerBundle: - continue - if sp.isFirst(gn): - output.append(sp) - else: - for gn in measure.recurse().getElementsByClass(m21.note.GeneralNote): - spannerList: List[m21.spanner.Spanner] = gn.getSpannerSites(spanner_types) - for sp in spannerList: - if sp not in spannerBundle: + # Add any interesting spanners that start on GeneralNotes/SpannerAnchors in this measure + spanner_types = ( + m21.expressions.ArpeggioMarkSpanner, + m21.dynamics.DynamicWedge, + m21.spanner.Ottava, + m21.expressions.TremoloSpanner + ) + + spannerElementClasses = (m21.note.GeneralNote, m21.spanner.SpannerAnchor) + + for gn in measure.recurse().getElementsByClass(spannerElementClasses): + spannerList: list[m21.spanner.Spanner] = gn.getSpannerSites(spanner_types) + for sp in spannerList: + if sp not in spannerBundle: + continue + if M21Utils.getPrimarySpannerElement(sp) is gn: + output.append(sp) + if not isinstance(sp, m21.spanner.Ottava): continue - if sp.isFirst(gn): - output.append(sp) # Add any RepeatBracket spanners that start on this measure - rbList: List[m21.spanner.Spanner] = measure.getSpannerSites(m21.spanner.RepeatBracket) + rbList: list[m21.spanner.Spanner] = measure.getSpannerSites([m21.spanner.RepeatBracket]) for rb in rbList: if rb not in spannerBundle: continue @@ -476,14 +817,87 @@ def get_extras(measure: m21.stream.Measure, spannerBundle: m21.spanner.SpannerBu return output @staticmethod - def note_to_string(note): + def fillOttava( + ottava: m21.spanner.Ottava, + searchStream: m21.stream.Stream, + *, + includeEndBoundary: bool = False, + mustFinishInSpan: bool = False, + mustBeginInSpan: bool = True, + includeElementsThatEndAtStart: bool = False + ) -> None: + if ottava.filledStatus is True: + # Don't fill twice. + return + + if ottava.getFirst() is None: + # no spanned elements? Nothing to fill. + return + + endElement: m21.base.Music21Object | None = None + if len(ottava) > 1: + # Start and end elements are different, we can't just append everything, we need + # to save off the end element, remove it, add everything, then add the end element + # again. Note that if there are actually more than 2 elements before we start + # filling, the new intermediate elements will come after the existing ones, + # regardless of offset. But first and last will still be the same two elements + # as before, which is the most important thing. + endElement = ottava.getLast() + if t.TYPE_CHECKING: + assert endElement is not None + ottava.spannerStorage.remove(endElement) + + try: + startOffsetInHierarchy: OffsetQL = ottava.getFirst().getOffsetInHierarchy(searchStream) + except m21.sites.SitesException: + # print('start element not in searchStream') + if endElement is not None: + ottava.addSpannedElements(endElement) + return + + endOffsetInHierarchy: OffsetQL + if endElement is not None: + try: + endOffsetInHierarchy = ( + endElement.getOffsetInHierarchy(searchStream) + endElement.quarterLength + ) + except m21.sites.SitesException: + # print('end element not in searchStream') + ottava.addSpannedElements(endElement) + return + else: + endOffsetInHierarchy = ( + ottava.getLast().getOffsetInHierarchy(searchStream) + + ottava.getLast().quarterLength + ) + + for foundElement in (searchStream + .recurse() + .getElementsByOffsetInHierarchy( + startOffsetInHierarchy, + endOffsetInHierarchy, + includeEndBoundary=includeEndBoundary, + mustFinishInSpan=mustFinishInSpan, + mustBeginInSpan=mustBeginInSpan, + includeElementsThatEndAtStart=includeElementsThatEndAtStart) + .getElementsByClass(m21.note.NotRest)): + if endElement is None or foundElement is not endElement: + ottava.addSpannedElements(foundElement) + + if endElement is not None: + # add it back in as the end element + ottava.addSpannedElements(endElement) + + ottava.filledStatus = True # type: ignore + + @staticmethod + def note_to_string(note: m21.note.GeneralNote) -> str: if note.isRest: _str = "R" else: _str = "N" return _str - @staticmethod def safe_get(indexable, idx): if indexable is None: @@ -535,7 +949,9 @@ def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: # convert to MetronomeMark mm = mm.newMetronome - # Assume mm is now a MetronomeMark + # mm must be a MetronomeMark if we get here. + if t.TYPE_CHECKING: + assert isinstance(mm, m21.tempo.MetronomeMark) if mm.textImplicit is True or mm._tempoText is None: if mm.referent is None or mm.number is None: output = 'MM:' @@ -552,7 +968,10 @@ def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: return output # no 'MM:' prefix, TempoText adds their own - output = f'{M21Utils.tempo_to_string(mm._tempoText)} {mm.referent.fullName}={float(mm.number)}' + output = ( + f'{M21Utils.tempo_to_string(mm._tempoText)}' + + f' {mm.referent.fullName}={float(mm.number)}' + ) return output # pylint: enable=protected-access @@ -579,7 +998,12 @@ def barline_to_string(barline: m21.bar.Barline) -> str: return f'RPT:{output}' @staticmethod - def keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature]) -> str: + def ottava_to_string(ottava: m21.spanner.Ottava) -> str: + output: str = f'OTT:{ottava.type}' + return output + + @staticmethod + def keysig_to_string(keysig: m21.key.Key | m21.key.KeySignature) -> str: output: str = f'KS:{keysig.sharps}' return output @@ -594,9 +1018,11 @@ def dynamic_to_string(dynamic: m21.dynamics.Dynamic) -> str: return output @staticmethod - def notestyle_to_dict(style: m21.style.NoteStyle, - detail: DetailLevel = DetailLevel.Default) -> dict: - if detail < DetailLevel.AllObjectsWithStyle: + def notestyle_to_dict( + style: m21.style.NoteStyle, + detail: DetailLevel = DetailLevel.Default + ) -> dict: + if not DetailLevel.includesStyle(detail): return {} output: dict = {} @@ -613,9 +1039,11 @@ def notestyle_to_dict(style: m21.style.NoteStyle, return output @staticmethod - def textstyle_to_dict(style: m21.style.TextStyle, - detail: DetailLevel = DetailLevel.Default) -> dict: - if detail < DetailLevel.AllObjectsWithStyle: + def textstyle_to_dict( + style: m21.style.TextStyle, + detail: DetailLevel = DetailLevel.Default + ) -> dict: + if not DetailLevel.includesStyle(detail): return {} output: dict = {} @@ -652,9 +1080,11 @@ def textstyle_to_dict(style: m21.style.TextStyle, return output @staticmethod - def genericstyle_to_dict(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> dict: - if detail < DetailLevel.AllObjectsWithStyle: + def genericstyle_to_dict( + style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default + ) -> dict: + if not DetailLevel.includesStyle(detail): return {} output: dict = {} @@ -681,34 +1111,39 @@ def genericstyle_to_dict(style: m21.style.Style, return output @staticmethod - def specificstyle_to_dict(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> dict: - if detail < DetailLevel.AllObjectsWithStyle: + def specificstyle_to_dict( + style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default + ) -> dict: + if not DetailLevel.includesStyle(detail): return {} if isinstance(style, m21.style.NoteStyle): return M21Utils.notestyle_to_dict(style, detail) - if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement + if isinstance(style, m21.style.TextStyle): + # includes TextStylePlacement return M21Utils.textstyle_to_dict(style, detail) if isinstance(style, m21.style.BezierStyle): - return {} # M21Utils.bezierstyle_to_dict(style, detail) + return {} # M21Utils.bezierstyle_to_dict(style, detail) if isinstance(style, m21.style.LineStyle): - return {} # M21Utils.linestyle_to_dict(style, detail) + return {} # M21Utils.linestyle_to_dict(style, detail) if isinstance(style, m21.style.BeamStyle): - return {} # M21Utils.beamstyle_to_dict(style, detail) + return {} # M21Utils.beamstyle_to_dict(style, detail) return {} @staticmethod - def obj_to_styledict(obj: m21.base.Music21Object, - detail: DetailLevel = DetailLevel.Default) -> dict: - if detail < DetailLevel.AllObjectsWithStyle: + def obj_to_styledict( + obj: m21.base.Music21Object | m21.style.StyleMixin, + detail: DetailLevel = DetailLevel.Default + ) -> dict: + if not DetailLevel.includesStyle(detail): return {} output: dict = {} if obj.hasStyleInformation: output = M21Utils.genericstyle_to_dict(obj.style, detail) specific = M21Utils.specificstyle_to_dict(obj.style, detail) - for k,v in specific.items(): + for k, v in specific.items(): output[k] = v if hasattr(obj, 'placement') and obj.placement is not None: @@ -733,21 +1168,59 @@ def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: @staticmethod def arpeggiomark_to_string( - arp: m21.expressions.Expression) -> str: - if hasattr(m21.expressions, 'ArpeggioMark'): - if isinstance(arp, m21.expressions.ArpeggioMark): - return f'ARP:{arp.type}' - if hasattr(m21.expressions, 'ArpeggioMarkSpanner'): - if isinstance(arp, m21.expressions.ArpeggioMarkSpanner): - return f'ARPS:{arp.type}:len={len(arp)}' + arp: m21.expressions.ArpeggioMark | m21.expressions.ArpeggioMarkSpanner + ) -> str: + if isinstance(arp, m21.expressions.ArpeggioMark): + return f'ARP:{arp.type}' + if isinstance(arp, m21.expressions.ArpeggioMarkSpanner): + return f'ARPS:{arp.type}:len={len(arp)}' return '' @staticmethod def repeatbracket_to_string(rb: m21.spanner.RepeatBracket) -> str: - return f'END:{rb.number}:len={len(rb)}' + if rb.overrideDisplay: + return f'END:{rb.number,rb.overrideDisplay}:len={len(rb)}' + else: + return f'END:{rb.number}:len={len(rb)}' + + @staticmethod + def stafflayout_to_string( + sl: m21.layout.StaffLayout, + detail: DetailLevel = DetailLevel.Default + ) -> str: + output: str = '' + if sl.staffLines is not None: + if not output: + output = 'STAFF:' + output += f'lines={sl.staffLines}' + if DetailLevel.includesStyle(detail): + if sl.staffSize is not None: + if not output: + output = 'STAFF:' + else: + output += ',' + output += f'size={sl.staffSize:.2g}%' + return output + + @staticmethod + def systemlayout_to_string(sb: m21.layout.SystemLayout) -> str: + if sb.isNew: + return 'SB' + return '' + + @staticmethod + def pagelayout_to_string(pb: m21.layout.PageLayout) -> str: + if pb.isNew: + if pb.pageNumber is not None: + return f'PB:num={pb.pageNumber}' + return 'PB' + return '' @staticmethod - def extra_to_string(extra: m21.base.Music21Object) -> str: + def extra_to_string( + extra: m21.base.Music21Object, + detail: DetailLevel = DetailLevel.Default + ) -> str: if isinstance(extra, (m21.key.Key, m21.key.KeySignature)): return M21Utils.keysig_to_string(extra) if isinstance(extra, m21.expressions.TextExpression): @@ -764,18 +1237,33 @@ def extra_to_string(extra: m21.base.Music21Object) -> str: return M21Utils.tempo_to_string(extra) if isinstance(extra, m21.bar.Barline): return M21Utils.barline_to_string(extra) + if isinstance(extra, m21.spanner.Ottava): + return M21Utils.ottava_to_string(extra) if isinstance(extra, m21.spanner.RepeatBracket): return M21Utils.repeatbracket_to_string(extra) - if (hasattr(m21.expressions, 'ArpeggioMark') - and hasattr(m21.expressions, 'ArpeggioMarkSpanner')): - if isinstance(extra, (m21.expressions.ArpeggioMark, m21.expressions.ArpeggioMarkSpanner)): - return M21Utils.arpeggiomark_to_string(extra) + if isinstance(extra, m21.expressions.TremoloSpanner): + return M21Utils.tremolo_to_string(extra) + if isinstance(extra, m21.layout.StaffLayout): + return M21Utils.stafflayout_to_string(extra, detail) + if isinstance( + extra, + (m21.expressions.ArpeggioMark, m21.expressions.ArpeggioMarkSpanner)): + return M21Utils.arpeggiomark_to_string(extra) + + # Page breaks and system breaks are only paid attention to at + # DetailLevel.AllObjectsWithStyle, because they are entirely + # style, no substance. + if DetailLevel.includesStyle(detail): + if isinstance(extra, m21.layout.SystemLayout): + return M21Utils.systemlayout_to_string(extra) + if isinstance(extra, m21.layout.PageLayout): + return M21Utils.pagelayout_to_string(extra) # print(f'Unexpected extra: {extra.classes[0]}', file=sys.stderr) return '' @staticmethod - def has_style(obj: m21.base.Music21Object) -> bool: + def has_style(obj: m21.base.Music21Object | m21.style.StyleMixin) -> bool: output: bool = hasattr(obj, 'placement') and obj.placement is not None output = output or obj.hasStyleInformation return output diff --git a/musicdiff/visualization.py b/musicdiff/visualization.py index 56cb00f..4ce0fc5 100644 --- a/musicdiff/visualization.py +++ b/musicdiff/visualization.py @@ -7,19 +7,19 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ __docformat__ = "google" -from typing import List, Tuple, Union from pathlib import Path import sys +import typing as t import music21 as m21 -from musicdiff.annotation import AnnMeasure, AnnVoice, AnnNote, AnnExtra +from musicdiff.annotation import AnnMeasure, AnnVoice, AnnNote, AnnExtra, AnnStaffGroup class Visualization: @@ -39,8 +39,10 @@ class Visualization: @staticmethod def mark_diffs( - score1: m21.stream.Score, score2: m21.stream.Score, operations: List[Tuple] - ): + score1: m21.stream.Score, + score2: m21.stream.Score, + operations: list[tuple] + ) -> None: """ Mark up two music21 scores with the differences described by an operations list (e.g. a list returned from `musicdiff.Comparison.annotated_scores_diff`). @@ -48,15 +50,19 @@ def mark_diffs( Args: score1 (music21.stream.Score): The first score to mark up score2 (music21.stream.Score): The second score to mark up - operations (List[Tuple]): The operations list that describes the difference + operations (list[tuple]): The operations list that describes the difference between the two scores """ + changedStr: str for op in operations: # bar if op[0] == "insbar": assert isinstance(op[2], AnnMeasure) - # color all the notes in the inserted score2 measure using Visualization.INSERTED_COLOR - measure2 = score2.recurse().getElementById(op[2].measure) + # color all the notes in the inserted score2 measure + # using Visualization.INSERTED_COLOR + measure2 = score2.recurse().getElementById(op[2].measure) # type: ignore + if t.TYPE_CHECKING: + assert measure2 is not None textExp = m21.expressions.TextExpression("inserted measure") textExp.style.color = Visualization.INSERTED_COLOR measure2.insert(0, textExp) @@ -68,8 +74,11 @@ def mark_diffs( elif op[0] == "delbar": assert isinstance(op[1], AnnMeasure) - # color all the notes in the deleted score1 measure using Visualization.DELETED_COLOR - measure1 = score1.recurse().getElementById(op[1].measure) + # color all the notes in the deleted score1 measure + # using Visualization.DELETED_COLOR + measure1 = score1.recurse().getElementById(op[1].measure) # type: ignore + if t.TYPE_CHECKING: + assert measure1 is not None textExp = m21.expressions.TextExpression("deleted measure") textExp.style.color = Visualization.DELETED_COLOR measure1.insert(0, textExp) @@ -82,8 +91,11 @@ def mark_diffs( # voices elif op[0] == "voiceins": assert isinstance(op[2], AnnVoice) - # color all the notes in the inserted score2 voice using Visualization.INSERTED_COLOR - voice2 = score2.recurse().getElementById(op[2].voice) + # color all the notes in the inserted score2 voice + # using Visualization.INSERTED_COLOR + voice2 = score2.recurse().getElementById(op[2].voice) # type: ignore + if t.TYPE_CHECKING: + assert voice2 is not None textExp = m21.expressions.TextExpression("inserted voice") textExp.style.color = Visualization.INSERTED_COLOR voice2.insert(0, textExp) @@ -96,8 +108,11 @@ def mark_diffs( elif op[0] == "voicedel": assert isinstance(op[1], AnnVoice) - # color all the notes in the deleted score1 voice using Visualization.DELETED_COLOR - voice1 = score1.recurse().getElementById(op[1].voice) + # color all the notes in the deleted score1 voice + # using Visualization.DELETED_COLOR + voice1 = score1.recurse().getElementById(op[1].voice) # type: ignore + if t.TYPE_CHECKING: + assert voice1 is not None textExp = m21.expressions.TextExpression("deleted voice") textExp.style.color = Visualization.DELETED_COLOR voice1.insert(0, textExp) @@ -111,9 +126,11 @@ def mark_diffs( # extra elif op[0] == "extrains": assert isinstance(op[2], AnnExtra) - # color the extra using Visualization.INSERTED_COLOR, and add a textExpression - # describing the insertion. - extra2 = score2.recurse().getElementById(op[2].extra) + # color the extra using Visualization.INSERTED_COLOR, + # and add a textExpression describing the insertion. + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra2 is not None textExp = m21.expressions.TextExpression(f"inserted {extra2.classes[0]}") textExp.style.color = Visualization.INSERTED_COLOR if isinstance(extra2, m21.spanner.Spanner): @@ -133,7 +150,9 @@ def mark_diffs( assert isinstance(op[1], AnnExtra) # color the extra using Visualization.DELETED_COLOR, and add a textExpression # describing the deletion. - extra1 = score1.recurse().getElementById(op[1].extra) + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None textExp = m21.expressions.TextExpression(f"deleted {extra1.classes[0]}") textExp.style.color = Visualization.DELETED_COLOR if isinstance(extra1, m21.spanner.Spanner): @@ -154,13 +173,18 @@ def mark_diffs( assert isinstance(op[2], AnnExtra) # color the extra using Visualization.CHANGED_COLOR, and add a textExpression # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None + assert extra2 is not None if extra1.classes[0] != extra2.classes[0]: textExp1 = m21.expressions.TextExpression( - f"changed to {extra2.classes[0]}") + f"changed to {extra2.classes[0]}" + ) textExp2 = m21.expressions.TextExpression( - f"changed from {extra1.classes[0]}") + f"changed from {extra1.classes[0]}" + ) else: textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}") textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]}") @@ -193,8 +217,11 @@ def mark_diffs( assert isinstance(op[2], AnnExtra) # color the extra using Visualization.CHANGED_COLOR, and add a textExpression # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None + assert extra2 is not None textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text") textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} text") textExp1.style.color = Visualization.CHANGED_COLOR @@ -213,10 +240,15 @@ def mark_diffs( assert isinstance(op[2], AnnExtra) # color the extra using Visualization.CHANGED_COLOR, and add a textExpression # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) - textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset") - textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} offset") + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None + assert extra2 is not None + textExp1 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} offset") + textExp2 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} offset") textExp1.style.color = Visualization.CHANGED_COLOR textExp2.style.color = Visualization.CHANGED_COLOR if isinstance(extra1, m21.spanner.Spanner): @@ -233,10 +265,15 @@ def mark_diffs( assert isinstance(op[2], AnnExtra) # color the extra using Visualization.CHANGED_COLOR, and add a textExpression # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) - textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration") - textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} duration") + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None + assert extra2 is not None + textExp1 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} duration") + textExp2 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} duration") textExp1.style.color = Visualization.CHANGED_COLOR textExp2.style.color = Visualization.CHANGED_COLOR if isinstance(extra1, m21.spanner.Spanner): @@ -253,7 +290,7 @@ def mark_diffs( assert isinstance(op[2], AnnExtra) sd1 = op[1].styledict sd2 = op[2].styledict - changedStr: str = "" + changedStr = "" for k1, v1 in sd1.items(): if k1 not in sd2 or sd2[k1] != v1: if changedStr: @@ -267,13 +304,18 @@ def mark_diffs( changedStr += "," changedStr += k2 - # color the extra using Visualization.CHANGED_COLOR, and add a textExpression - # describing the change. - extra1 = score1.recurse().getElementById(op[1].extra) - extra2 = score2.recurse().getElementById(op[2].extra) - - textExp1 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}") - textExp2 = m21.expressions.TextExpression(f"changed {extra1.classes[0]} {changedStr}") + # color the extra using Visualization.CHANGED_COLOR, + # and add a textExpression describing the change. + extra1 = score1.recurse().getElementById(op[1].extra) # type: ignore + extra2 = score2.recurse().getElementById(op[2].extra) # type: ignore + if t.TYPE_CHECKING: + assert extra1 is not None + assert extra2 is not None + + textExp1 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} {changedStr}") + textExp2 = m21.expressions.TextExpression( + f"changed {extra1.classes[0]} {changedStr}") textExp1.style.color = Visualization.CHANGED_COLOR textExp2.style.color = Visualization.CHANGED_COLOR if isinstance(extra1, m21.spanner.Spanner): @@ -285,22 +327,196 @@ def mark_diffs( extra1.activeSite.insert(extra1.offset, textExp1) extra2.activeSite.insert(extra2.offset, textExp2) + # staff groups + elif op[0] == "staffgrpins": + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the insertion. + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup2 is not None + textExp = m21.expressions.TextExpression("inserted StaffGroup") + textExp.style.color = Visualization.INSERTED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp) + + elif op[0] == "staffgrpdel": + assert isinstance(op[1], AnnStaffGroup) + # add a textExpression describing the deletion. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + textExp = m21.expressions.TextExpression("deleted StaffGroup") + textExp.style.color = Visualization.DELETED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp) + + elif op[0] == "staffgrpsub": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup") + textExp2 = m21.expressions.TextExpression("changed StaffGroup") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + + elif op[0] == "staffgrpnameedit": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup name") + textExp2 = m21.expressions.TextExpression("changed StaffGroup name") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + + elif op[0] == "staffgrpabbreviationedit": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup abbreviation") + textExp2 = m21.expressions.TextExpression("changed StaffGroup abbreviation") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + + elif op[0] == "staffgrpsymboledit": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup symbol shape") + textExp2 = m21.expressions.TextExpression("changed StaffGroup symbol shape") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + + elif op[0] == "staffgrpbartogetheredit": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup barline type") + textExp2 = m21.expressions.TextExpression("changed StaffGroup barline type") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + + elif op[0] == "staffgrppartindicesedit": + assert isinstance(op[1], AnnStaffGroup) + assert isinstance(op[2], AnnStaffGroup) + # add a textExpression describing the change. + staffGroup1 = score1.recurse().getElementById( + op[1].staff_group # type: ignore + ) + staffGroup2 = score2.recurse().getElementById( + op[2].staff_group # type: ignore + ) + if t.TYPE_CHECKING: + assert staffGroup1 is not None + assert staffGroup2 is not None + textExp1 = m21.expressions.TextExpression("changed StaffGroup parts") + textExp2 = m21.expressions.TextExpression("changed StaffGroup parts") + textExp1.style.color = Visualization.CHANGED_COLOR + textExp2.style.color = Visualization.CHANGED_COLOR + # insert text at offset 0 in first measure of first part in group + insertionSite = staffGroup1.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp1) + insertionSite = staffGroup2.getFirst()[m21.stream.Measure].first() + insertionSite.insert(0, textExp2) + # note elif op[0] == "noteins": assert isinstance(op[2], AnnNote) - # color the inserted score2 general note (note, chord, or rest) using Visualization.INSERTED_COLOR - note2 = score2.recurse().getElementById(op[2].general_note) + # color the inserted score2 general note (note, chord, or rest) + # using Visualization.INSERTED_COLOR + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR - textExp = m21.expressions.TextExpression(f"inserted {note2.classes[0]}") + textExp = m21.expressions.TextExpression( + f"inserted {note2.classes[0]}") textExp.style.color = Visualization.INSERTED_COLOR note2.activeSite.insert(note2.offset, textExp) elif op[0] == "notedel": assert isinstance(op[1], AnnNote) - # color the deleted score1 general note (note, chord, or rest) using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # color the deleted score1 general note (note, chord, or rest) + # using Visualization.DELETED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR - textExp = m21.expressions.TextExpression(f"deleted {note2.classes[0]}") + textExp = m21.expressions.TextExpression(f"deleted {note1.classes[0]}") textExp.style.color = Visualization.DELETED_COLOR note1.activeSite.insert(note1.offset, textExp) @@ -310,12 +526,16 @@ def mark_diffs( assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there # color the changed note (in both scores) using Visualization.CHANGED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color just the indexed note in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed pitch") textExp.style.color = Visualization.CHANGED_COLOR @@ -324,12 +544,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color just the indexed note in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed pitch") textExp.style.color = Visualization.CHANGED_COLOR @@ -342,12 +566,16 @@ def mark_diffs( assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there # color the inserted note in score2 using Visualization.INSERTED_COLOR - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color just the indexed note in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR if "Rest" in note2.classes: textExp = m21.expressions.TextExpression("inserted rest") @@ -363,12 +591,16 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert len(op) == 5 # the indices must be there # color the deleted note in score1 using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color just the indexed note in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR if "Rest" in note1.classes: textExp = m21.expressions.TextExpression("deleted rest") @@ -383,14 +615,19 @@ def mark_diffs( elif op[0] == "headedit": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # color the changed note/rest/chord (in both scores) + # using Visualization.CHANGED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head") textExp.style.color = Visualization.CHANGED_COLOR @@ -399,14 +636,19 @@ def mark_diffs( elif op[0] == "graceedit": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # color the changed note/rest/chord (in both scores) + # using Visualization.CHANGED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed grace note") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed grace note") textExp.style.color = Visualization.CHANGED_COLOR @@ -415,14 +657,19 @@ def mark_diffs( elif op[0] == "graceslashedit": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - # color the changed note/rest/chord (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # color the changed note/rest/chord (in both scores) + # using Visualization.CHANGED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed grace note slash") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed grace note slash") textExp.style.color = Visualization.CHANGED_COLOR @@ -433,7 +680,9 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.INSERTED_COLOR if hasattr(note1, "beams"): for beam in note1.beams: @@ -444,7 +693,9 @@ def mark_diffs( textExp.style.color = Visualization.INSERTED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR if hasattr(note2, "beams"): for beam in note2.beams: @@ -459,7 +710,9 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR if hasattr(note1, "beams"): for beam in note1.beams: @@ -470,7 +723,9 @@ def mark_diffs( textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.DELETED_COLOR if hasattr(note2, "beams"): for beam in note2.beams: @@ -485,7 +740,9 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR if hasattr(note1, "beams"): for beam in note1.beams: @@ -496,7 +753,9 @@ def mark_diffs( textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR if hasattr(note2, "beams"): for beam in note2.beams: @@ -510,13 +769,17 @@ def mark_diffs( elif op[0] == "editnoteshape": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note shape") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note shape") textExp.style.color = Visualization.CHANGED_COLOR @@ -525,13 +788,17 @@ def mark_diffs( elif op[0] == "editnoteheadfill": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head fill") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head fill") textExp.style.color = Visualization.CHANGED_COLOR @@ -540,13 +807,17 @@ def mark_diffs( elif op[0] == "editnoteheadparenthesis": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head paren") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed note head paren") textExp.style.color = Visualization.CHANGED_COLOR @@ -555,13 +826,17 @@ def mark_diffs( elif op[0] == "editstemdirection": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed stem direction") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed stem direction") textExp.style.color = Visualization.CHANGED_COLOR @@ -572,7 +847,7 @@ def mark_diffs( assert isinstance(op[2], AnnNote) sd1 = op[1].styledict sd2 = op[2].styledict - changedStr: str = "" + changedStr = "" for k1, v1 in sd1.items(): if k1 not in sd2 or sd2[k1] != v1: if changedStr: @@ -586,13 +861,17 @@ def mark_diffs( changedStr += "," changedStr += k2 - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression(f"changed note {changedStr}") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression(f"changed note {changedStr}") textExp.style.color = Visualization.CHANGED_COLOR @@ -604,12 +883,16 @@ def mark_diffs( assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there # color the modified note in both scores using Visualization.INSERTED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color only the indexed note's accidental in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None if note1.pitch.accidental: note1.pitch.accidental.style.color = Visualization.INSERTED_COLOR note1.style.color = Visualization.INSERTED_COLOR @@ -620,12 +903,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color only the indexed note's accidental in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None if note2.pitch.accidental: note2.pitch.accidental.style.color = Visualization.INSERTED_COLOR note2.style.color = Visualization.INSERTED_COLOR @@ -641,12 +928,16 @@ def mark_diffs( assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there # color the modified note in both scores using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color only the indexed note's accidental in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None if note1.pitch.accidental: note1.pitch.accidental.style.color = Visualization.DELETED_COLOR note1.style.color = Visualization.DELETED_COLOR @@ -657,12 +948,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color only the indexed note's accidental in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None if note2.pitch.accidental: note2.pitch.accidental.style.color = Visualization.DELETED_COLOR note2.style.color = Visualization.DELETED_COLOR @@ -677,13 +972,18 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there - # color the changed accidental (in both scores) using Visualization.CHANGED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + # color the changed accidental (in both scores) + # using Visualization.CHANGED_COLOR + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color just the indexed note in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None if note1.pitch.accidental: note1.pitch.accidental.style.color = Visualization.CHANGED_COLOR note1.style.color = Visualization.CHANGED_COLOR @@ -694,12 +994,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color just the indexed note in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None if note2.pitch.accidental: note2.pitch.accidental.style.color = Visualization.CHANGED_COLOR note2.style.color = Visualization.CHANGED_COLOR @@ -714,14 +1018,19 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # so we will just color the modified note here in both scores, + # using Visualization.CHANGED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("inserted dot") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + if t.TYPE_CHECKING: + assert note2 is not None + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("inserted dot") textExp.style.color = Visualization.CHANGED_COLOR @@ -731,14 +1040,19 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # In music21, the dots are not separately colorable from the note, - # so we will just color the modified note here in both scores, using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + # so we will just color the modified note here in both scores, + # using Visualization.CHANGED_COLOR + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("deleted dot") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("deleted dot") textExp.style.color = Visualization.CHANGED_COLOR @@ -748,13 +1062,17 @@ def mark_diffs( elif op[0] == "instuplet": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("inserted tuplet") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("inserted tuplet") textExp.style.color = Visualization.CHANGED_COLOR @@ -763,13 +1081,17 @@ def mark_diffs( elif op[0] == "deltuplet": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("deleted tuplet") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("deleted tuplet") textExp.style.color = Visualization.CHANGED_COLOR @@ -778,13 +1100,17 @@ def mark_diffs( elif op[0] == "edittuplet": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed tuplet") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed tuplet") textExp.style.color = Visualization.CHANGED_COLOR @@ -795,13 +1121,18 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there - # Color the modified note here in both scores, using Visualization.INSERTED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + # Color the modified note here in both scores, + # using Visualization.INSERTED_COLOR + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color just the indexed note in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted tie") textExp.style.color = Visualization.INSERTED_COLOR @@ -810,12 +1141,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color just the indexed note in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted tie") textExp.style.color = Visualization.INSERTED_COLOR @@ -829,12 +1164,16 @@ def mark_diffs( assert isinstance(op[2], AnnNote) assert len(op) == 5 # the indices must be there # Color the modified note in both scores, using Visualization.DELETED_COLOR - chord1 = score1.recurse().getElementById(op[1].general_note) + chord1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord1 is not None note1 = chord1 - if "Chord" in note1.classes: + if "Chord" in chord1.classes: # color just the indexed note in the chord idx = op[4][0] - note1 = note1.notes[idx] + note1 = chord1.notes[idx] + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted tie") textExp.style.color = Visualization.DELETED_COLOR @@ -843,12 +1182,16 @@ def mark_diffs( else: chord1.activeSite.insert(chord1.offset, textExp) - chord2 = score2.recurse().getElementById(op[2].general_note) + chord2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert chord2 is not None note2 = chord2 - if "Chord" in note2.classes: + if "Chord" in chord2.classes: # color just the indexed note in the chord idx = op[4][1] - note2 = note2.notes[idx] + note2 = chord2.notes[idx] + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted tie") textExp.style.color = Visualization.DELETED_COLOR @@ -862,13 +1205,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted expression") textExp.style.color = Visualization.INSERTED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted expression") textExp.style.color = Visualization.INSERTED_COLOR @@ -878,13 +1225,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the deleted expression in score1 using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted expression") textExp.style.color = Visualization.DELETED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted expression") textExp.style.color = Visualization.DELETED_COLOR @@ -894,13 +1245,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the changed beam (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed expression") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed expression") textExp.style.color = Visualization.CHANGED_COLOR @@ -911,13 +1266,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted articulation") textExp.style.color = Visualization.INSERTED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted articulation") textExp.style.color = Visualization.INSERTED_COLOR @@ -927,13 +1286,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted articulation") textExp.style.color = Visualization.DELETED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted articulation") textExp.style.color = Visualization.DELETED_COLOR @@ -943,13 +1306,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed articulation") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed articulation") textExp.style.color = Visualization.CHANGED_COLOR @@ -960,13 +1327,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.INSERTED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted lyric") textExp.style.color = Visualization.INSERTED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.INSERTED_COLOR textExp = m21.expressions.TextExpression("inserted lyric") textExp.style.color = Visualization.INSERTED_COLOR @@ -976,13 +1347,17 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note in both scores using Visualization.DELETED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted lyric") textExp.style.color = Visualization.DELETED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.DELETED_COLOR textExp = m21.expressions.TextExpression("deleted lyric") textExp.style.color = Visualization.DELETED_COLOR @@ -992,30 +1367,39 @@ def mark_diffs( assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) # color the modified note (in both scores) using Visualization.CHANGED_COLOR - note1 = score1.recurse().getElementById(op[1].general_note) + note1 = score1.recurse().getElementById(op[1].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note1 is not None note1.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed lyric") textExp.style.color = Visualization.CHANGED_COLOR note1.activeSite.insert(note1.offset, textExp) - note2 = score2.recurse().getElementById(op[2].general_note) + note2 = score2.recurse().getElementById(op[2].general_note) # type: ignore + if t.TYPE_CHECKING: + assert note2 is not None note2.style.color = Visualization.CHANGED_COLOR textExp = m21.expressions.TextExpression("changed lyric") textExp.style.color = Visualization.CHANGED_COLOR note2.activeSite.insert(note2.offset, textExp) else: - print(f"Annotation type {op[0]} not yet supported for visualization", file=sys.stderr) + print( + f"Annotation type {op[0]} not yet supported for visualization", + file=sys.stderr + ) @staticmethod - def show_diffs(score1: m21.stream.Score, - score2: m21.stream.Score, - out_path1: Union[str, Path] = None, - out_path2: Union[str, Path] = None): + def show_diffs( + score1: m21.stream.Score, + score2: m21.stream.Score, + out_path1: str | Path | None = None, + out_path2: str | Path | None = None + ) -> None: """ - Render two (presumably marked-up) music21 scores. If both out_path1 and out_path2 are not None, - save the rendered PDFs at those two locations, otherwise just display them using the default - PDF viewer on the system. + Render two (presumably marked-up) music21 scores. If both out_path1 and + out_path2 are not None, save the rendered PDFs at those two locations, + otherwise just display them using the default PDF viewer on the system. Args: score1 (music21.stream.Score): The first score to render @@ -1028,8 +1412,8 @@ def show_diffs(score1: m21.stream.Score, (default is None) """ # display the two (presumably annotated) scores - originalComposer1: str = None - originalComposer2: str = None + originalComposer1: str | None = None + originalComposer2: str | None = None if score1.metadata is None: score1.metadata = m21.metadata.Metadata() @@ -1048,11 +1432,12 @@ def show_diffs(score1: m21.stream.Score, else: score2.metadata.composer = "score2 " + originalComposer2 - #save files if requested + # save files if requested if (out_path1 is not None) and (out_path2 is not None): score1.write("musicxml.pdf", makeNotation=False, fp=out_path1) score2.write("musicxml.pdf", makeNotation=False, fp=out_path2) print(f"Annotated scores saved in {out_path1} and {out_path2}.", file=sys.stderr) - else: # just display the scores + else: + # just display the scores score1.show("musicxml.pdf", makeNotation=False) score2.show("musicxml.pdf", makeNotation=False) diff --git a/pypi_README.md b/pypi_README.md index ddeb9c9..85edb9b 100644 --- a/pypi_README.md +++ b/pypi_README.md @@ -7,7 +7,7 @@ musicdiff is derived from: [music-score-diff](https://github.com/fosfrancesco/mu by [Francesco Foscarin](https://github.com/fosfrancesco). ## Setup -Depends on [music21](https://pypi.org/project/music21) (version 8.1+), [numpy](https://pypi.org/project/numpy), and [converter21](https://pypi.org/project/converter21) (version 2.0+). You also will need to configure music21 (instructions [here](https://web.mit.edu/music21/doc/usersGuide/usersGuide_01_installing.html)) to display a musical score (e.g. with MuseScore). +Depends on [music21](https://pypi.org/project/music21) (version 9.1+), [numpy](https://pypi.org/project/numpy), and [converter21](https://pypi.org/project/converter21) (version 3.0+). You also will need to configure music21 (instructions [here](https://web.mit.edu/music21/doc/usersGuide/usersGuide_01_installing.html)) to display a musical score (e.g. with MuseScore). Requires Python 3.10+. ## Usage On the command line: @@ -45,7 +45,7 @@ Many thanks to [Francesco Foscarin](https://github.com/fosfrancesco) for allowin ## License The MIT License (MIT) -Copyright (c) 2023, Francesco Foscarin, Greg Chapman +Copyright (c) 2022, 2023 Francesco Foscarin, Greg Chapman Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/setup.py b/setup.py index 0cc9048..6b6f2ae 100644 --- a/setup.py +++ b/setup.py @@ -6,14 +6,14 @@ # https://github.com/fosfrancesco/music-score-diff.git # by Francesco Foscarin # -# Copyright: (c) 2022 Francesco Foscarin, Greg Chapman +# Copyright: (c) 2022, 2023 Francesco Foscarin, Greg Chapman # License: MIT, see LICENSE # ------------------------------------------------------------------------------ from setuptools import setup, find_packages import pathlib -musicdiffversion = '2.0.1' +musicdiffversion = '3.0.0' here = pathlib.Path(__file__).parent.resolve() @@ -49,6 +49,7 @@ 'compare', 'OMR', 'Optical Music Recognition', + 'assess', 'assessment', 'comparison', 'music21', @@ -56,12 +57,12 @@ packages=find_packages(), - python_requires='>=3.9', + python_requires='>=3.10', install_requires=[ - 'music21>=8.1', + 'music21>=9.1', 'numpy', - 'converter21>=2.0' + 'converter21>=3.0' ], project_urls={ diff --git a/tests/test_nl.py b/tests/test_nl.py index 9f4830c..dd37b68 100644 --- a/tests/test_nl.py +++ b/tests/test_nl.py @@ -1,246 +1,225 @@ from pathlib import Path import music21 as m21 +import converter21 from musicdiff.annotation import AnnScore, AnnNote -def test_annotNote1(): - n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1) - n1.id = 344 - # create annotated note - anote = AnnNote(n1, [], []) - assert anote.__repr__() == "[('D5', 'sharp', False)],4,0,[],[],344,[],[],[],{}" - assert str(anote) == "[D5sharp]4" - - -def test_annotNote2(): - n1 = m21.note.Note(nameWithOctave="E#5", quarterLength=0.5) - n1.id = 344 - # create annotated note - anote = AnnNote(n1, ["start"], ["start"]) - assert ( - anote.__repr__() == "[('E5', 'sharp', False)],4,0,['start'],['start'],344,[],[],[],{}" - ) - assert str(anote) == "[E5sharp]4BsrTsr" - - -def test_annotNote3(): - n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n1.id = 344 - n1.tie = m21.tie.Tie("stop") - # create annotated note - anote = AnnNote(n1, [], []) - assert anote.__repr__() == "[('D5', 'None', True)],2,0,[],[],344,[],[],[],{}" - assert str(anote) == "[D5T]2" - - -def test_annotNote_size1(): - n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n1.tie = m21.tie.Tie("stop") - # create annotated note - anote = AnnNote(n1, [], []) - assert anote.notation_size() == 2 - - -def test_annotNote_size2(): - n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1.5) - n1.tie = m21.tie.Tie("stop") - # create annotated note - anote = AnnNote(n1, [], []) - assert anote.notation_size() == 4 - - -def test_noteNode_size3(): - d = m21.duration.Duration(1.5) - n1 = m21.chord.Chord(["D", "F#", "A"], duration=d) - # create annotated note - anote = AnnNote(n1, [], []) - assert anote.notation_size() == 7 - - -def test_noteNode_size4(): - n1 = m21.note.Note(nameWithOctave="D5") - n2 = m21.note.Note(nameWithOctave="F#5") - n2.tie = m21.tie.Tie("stop") - n3 = m21.note.Note(nameWithOctave="G#5") - d = m21.duration.Duration(1.75) - chord = m21.chord.Chord([n1, n2, n3], duration=d) - # create annotated note - anote = AnnNote(chord, [], []) - assert anote.notation_size() == 12 - - -def test_noteNode_size5(): - score2_path = Path("tests/test_scores/monophonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - score_lin2 = AnnScore(score2) - assert ( - score_lin2.part_list[0] - .bar_list[6] - .voices_list[0] - .annot_notes[2] - .notation_size() - == 2 - ) - - -def test_scorelin1(): - # import score - score1_path = Path("tests/test_scores/polyphonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # produce a ScoreTree - score_lin1 = AnnScore(score1) - # number of parts - assert len(score_lin1.part_list) == 2 - # number of measures for each part - assert len(score_lin1.part_list[0].bar_list) == 5 - assert len(score_lin1.part_list[1].bar_list) == 5 - # number of voices for each measure in part 0 - for m in score_lin1.part_list[0].bar_list: - assert len(m.voices_list) == 1 - - -def test_scorelin2(): - # import score - score1_path = Path("tests/test_scores/monophonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # produce a ScoreTree - score_lin1 = AnnScore(score1) - # number of parts - assert len(score_lin1.part_list) == 1 - # number of measures for each part - assert len(score_lin1.part_list[0].bar_list) == 11 - # number of voices for each measure in part 0 - for m in score_lin1.part_list[0].bar_list: - assert len(m.voices_list) == 1 - - -def test_generalnotes1(): - # import score - score1_path = Path("tests/test_scores/chord_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # produce a ScoreTree - score_lin1 = AnnScore(score1) - # number of parts - assert len(score_lin1.part_list) == 1 - # number of measures for each part - assert len(score_lin1.part_list[0].bar_list) == 1 - # number of voices for each measure in part 0 - for m in score_lin1.part_list[0].bar_list: - assert len(m.voices_list) == 1 - assert score_lin1.part_list[0].bar_list[0].voices_list[0].notation_size() == 14 - - -def test_ties1(): - # import score - score1_path = Path("tests/test_scores/tie_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # produce a ScoreTree - score_lin1 = AnnScore(score1) - # number of parts - assert len(score_lin1.part_list) == 1 - # number of measures for each part - assert len(score_lin1.part_list[0].bar_list) == 1 - # number of voices for each measure in part 0 - for m in score_lin1.part_list[0].bar_list: - assert len(m.voices_list) == 1 - expected_tree_repr = "[[E4]4Bsr,[E4T]4Bcosr,[D4]4Bspsp,[C4,E4]4Bsr,[C4T]4Bcosr,[D4]4Bspsp,[E4,G4,C5]4,[E4]4Bsr,[F4]4Bsp]" - assert str(score_lin1.part_list[0].bar_list[0].voices_list[0]) == expected_tree_repr - assert score_lin1.part_list[0].bar_list[0].voices_list[0].notation_size() == 26 - - -def test_equality_an1(): - n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n1.id = 344 - n1.tie = m21.tie.Tie("stop") - n2 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n2.id = 345 - n2.tie = m21.tie.Tie("stop") - # create annotated note - anote1 = AnnNote(n1, [], []) - anote2 = AnnNote(n2, [], []) - assert anote1 == anote2 - assert repr(anote1) != repr(anote2) - - -def test_equality_an2(): - n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n1.id = 344 - n1.tie = m21.tie.Tie("stop") - n2 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n2.id = 344 - n2.tie = m21.tie.Tie("stop") - # create annotated note - anote1 = AnnNote(n1, [], []) - anote2 = AnnNote(n2, [], []) - assert anote1 == anote2 - assert repr(anote1) == repr(anote2) - - -def test_equality_all1(): - # import score1 - score1_path = Path("tests/test_scores/multivoice_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # import score2 - score2_path = Path("tests/test_scores/multivoice_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # create scores - s1 = AnnScore(score1) - s2 = AnnScore(score2) - # select voices1 - v1 = s1.part_list[0].bar_list[0].voices_list[0] - v2 = s2.part_list[0].bar_list[0].voices_list[0] - # change the ids - for an in v1.annot_notes: - an.general_note = 344 - for an in v2.annot_notes: - an.general_note = 345 - assert v1 == v2 - assert repr(v1) == repr(v1) - assert repr(v2) == repr(v2) - assert repr(v1) != repr(v2) - - -def test_equality_all2(): - # import score1 - score1_path = Path("tests/test_scores/polyphonic_score_2b.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - # create score - s = AnnScore(score1) - # select bars - b1 = s.part_list[0].bar_list[11] - b2 = s.part_list[0].bar_list[12] - assert b1 == b2 - assert repr(b1) == repr(b1) - assert repr(b2) == repr(b2) - assert repr(b1) != repr(b2) - # select voices - v1 = s.part_list[0].bar_list[11].voices_list[0] - v2 = s.part_list[0].bar_list[12].voices_list[0] - assert v1 == v2 - assert repr(v1) == repr(v1) - assert repr(v2) == repr(v2) - assert repr(v1) != repr(v2) - +class TestNl: + converter21.register() + + def test_annotNote1(self): + n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1) + n1.id = 344 + # create annotated note + anote = AnnNote(n1, [], [], []) + assert anote.__repr__() == "[('D5', 'sharp', False)],4,0,B:[],T:[],TI:[],344,[],[],[],{}" + assert str(anote) == "[D5sharp]4" + + + def test_annotNote2(self): + n1 = m21.note.Note(nameWithOctave="E#5", quarterLength=0.5) + n1.id = 344 + # create annotated note + anote = AnnNote(n1, ["start"], ["start"], ['3']) + assert ( + anote.__repr__() == "[('E5', 'sharp', False)],4,0,B:['start'],T:['start'],TI:['3'],344,[],[],[],{}" + ) + assert str(anote) == "[E5sharp]4BsrTsr(3)" + + + def test_annotNote3(self): + n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n1.id = 344 + n1.tie = m21.tie.Tie("start") + # create annotated note + anote = AnnNote(n1, [], [], []) + assert anote.__repr__() == "[('D5', 'None', True)],2,0,B:[],T:[],TI:[],344,[],[],[],{}" + assert str(anote) == "[D5T]2" + + + def test_annotNote_size1(self): + n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n1.tie = m21.tie.Tie("start") + # create annotated note + anote = AnnNote(n1, [], [], []) + assert anote.notation_size() == 2 + + + def test_annotNote_size2(self): + n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1.5) + n1.tie = m21.tie.Tie("start") + # create annotated note + anote = AnnNote(n1, [], [], []) + assert anote.notation_size() == 4 + + + def test_noteNode_size3(self): + d = m21.duration.Duration(1.5) + n1 = m21.chord.Chord(["D", "F#", "A"], duration=d) + # create annotated note + anote = AnnNote(n1, [], [], []) + assert anote.notation_size() == 7 + + + def test_noteNode_size4(self): + n1 = m21.note.Note(nameWithOctave="D5") + n2 = m21.note.Note(nameWithOctave="F#5") + n2.tie = m21.tie.Tie("start") + n3 = m21.note.Note(nameWithOctave="G#5") + d = m21.duration.Duration(1.75) + chord = m21.chord.Chord([n1, n2, n3], duration=d) + # create annotated note + anote = AnnNote(chord, [], [], []) + assert anote.notation_size() == 12 + + + def test_noteNode_size5(self): + score2_path = Path("tests/test_scores/monophonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + score_lin2 = AnnScore(score2) + assert ( + score_lin2.part_list[0] + .bar_list[6] + .voices_list[0] + .annot_notes[2] + .notation_size() + == 2 + ) + + + def test_scorelin1(self): + # import score + score1_path = Path("tests/test_scores/polyphonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + # produce a ScoreTree + score_lin1 = AnnScore(score1) + # number of parts + assert len(score_lin1.part_list) == 2 + # number of measures for each part + assert len(score_lin1.part_list[0].bar_list) == 5 + assert len(score_lin1.part_list[1].bar_list) == 5 + # number of voices for each measure in part 0 + for m in score_lin1.part_list[0].bar_list: + assert len(m.voices_list) == 1 + + + def test_scorelin2(self): + # import score + score1_path = Path("tests/test_scores/monophonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + # produce a ScoreTree + score_lin1 = AnnScore(score1) + # number of parts + assert len(score_lin1.part_list) == 1 + # number of measures for each part + assert len(score_lin1.part_list[0].bar_list) == 11 + # number of voices for each measure in part 0 + for m in score_lin1.part_list[0].bar_list: + assert len(m.voices_list) == 1 + + + def test_generalnotes1(self): + # import score + score1_path = Path("tests/test_scores/chord_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + # produce a ScoreTree + score_lin1 = AnnScore(score1) + # number of parts + assert len(score_lin1.part_list) == 1 + # number of measures for each part + assert len(score_lin1.part_list[0].bar_list) == 1 + # number of voices for each measure in part 0 + for m in score_lin1.part_list[0].bar_list: + assert len(m.voices_list) == 1 + assert score_lin1.part_list[0].bar_list[0].voices_list[0].notation_size() == 14 + + + def test_ties1(self): + # import score + score1_path = Path("tests/test_scores/tie_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + # produce a ScoreTree + score_lin1 = AnnScore(score1) + # number of parts + assert len(score_lin1.part_list) == 1 + # number of measures for each part + assert len(score_lin1.part_list[0].bar_list) == 1 + # number of voices for each measure in part 0 + for m in score_lin1.part_list[0].bar_list: + assert len(m.voices_list) == 1 + expected_tree_repr = "[[E4T]4Bsr,[E4]4Bcosr,[D4]4Bspsp,[C4T,E4]4Bsr,[C4]4Bcosr,[D4]4Bspsp,[E4,G4,C5]4,[E4]4Bsr,[F4T]4Bsp]" + assert str(score_lin1.part_list[0].bar_list[0].voices_list[0]) == expected_tree_repr + assert score_lin1.part_list[0].bar_list[0].voices_list[0].notation_size() == 27 + + + def test_equality_an1(self): + n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n1.id = 344 + n1.tie = m21.tie.Tie("start") + n2 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n2.id = 345 + n2.tie = m21.tie.Tie("start") + # create annotated note + anote1 = AnnNote(n1, [], [], []) + anote2 = AnnNote(n2, [], [], []) + assert anote1 == anote2 + assert repr(anote1) != repr(anote2) + + + def test_equality_an2(self): + n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n1.id = 344 + n1.tie = m21.tie.Tie("start") + n2 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n2.id = 344 + n2.tie = m21.tie.Tie("start") + # create annotated note + anote1 = AnnNote(n1, [], [], []) + anote2 = AnnNote(n2, [], [], []) + assert anote1 == anote2 + assert repr(anote1) == repr(anote2) + + + def test_equality_all1(self): + # import score1 + score1_path = Path("tests/test_scores/multivoice_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + # import score2 + score2_path = Path("tests/test_scores/multivoice_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # create scores + s1 = AnnScore(score1) + s2 = AnnScore(score2) + # select voices1 + v1 = s1.part_list[0].bar_list[0].voices_list[0] + v2 = s2.part_list[0].bar_list[0].voices_list[0] + # change the ids + for an in v1.annot_notes: + an.general_note = 344 + for an in v2.annot_notes: + an.general_note = 345 + assert v1 == v2 + assert repr(v1) == repr(v1) + assert repr(v2) == repr(v2) + assert repr(v1) != repr(v2) + + + def test_equality_all2(self): + # import score1 + score1_path = Path("tests/test_scores/polyphonic_score_2b.mei") + score1 = m21.converter.parse(str(score1_path)) + # create score + s = AnnScore(score1) + # select bars + b1 = s.part_list[0].bar_list[11] + b2 = s.part_list[0].bar_list[12] + assert b1 == b2 + assert repr(b1) == repr(b1) + assert repr(b2) == repr(b2) + assert repr(b1) != repr(b2) + # select voices + v1 = s.part_list[0].bar_list[11].voices_list[0] + v2 = s.part_list[0].bar_list[12].voices_list[0] + assert v1 == v2 + assert repr(v1) == repr(v1) + assert repr(v2) == repr(v2) + assert repr(v1) != repr(v2) diff --git a/tests/test_scl.py b/tests/test_scl.py index 1fecadc..7ccb301 100644 --- a/tests/test_scl.py +++ b/tests/test_scl.py @@ -2,301 +2,269 @@ from pathlib import Path import music21 as m21 +import converter21 from musicdiff import Comparison from musicdiff.annotation import AnnScore, AnnNote -def test_non_common_subsequences_myers1(): - original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] - compare_to = [0, 0, 2, 3, 4, 5, 6, 4, 5, 9, 10] - # since repr and str of integers are the same thing, we just duplicate the values in a new column - original = [[e, e] for e in original] - compare_to = [[e, e] for e in compare_to] - non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) - expected_result = [ - {"original": [1], "compare_to": [0, 0]}, - {"original": [7, 8], "compare_to": [4, 5]}, - ] - assert non_common_subsequences == expected_result - - -def test_non_common_subsequences_myers2(): - original = [0, 1, 2, 3] - compare_to = [5, 7, 8, 6, 3] - # since repr and str of integers are the same thing, we just duplicate the values in a new column - original = [[e, e] for e in original] - compare_to = [[e, e] for e in compare_to] - non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) - expected_result = [{"original": [0, 1, 2], "compare_to": [5, 7, 8, 6]}] - assert non_common_subsequences == expected_result - - -def test_non_common_subsequences_myers3(): - original = [0, 1, 2, 3, 4] - compare_to = [0, 1, 2] - # since repr and str of integers are the same thing, we just duplicate the values in a new column - original = [[e, e] for e in original] - compare_to = [[e, e] for e in compare_to] - non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) - expected_result = [{"original": [3, 4], "compare_to": []}] - assert non_common_subsequences == expected_result - - -def test_non_common_subsequences_myers4(): - original = [0, 1, 2] - compare_to = [0, 1, 2] - # since repr and str of integers are the same thing, we just duplicate the values in a new column - original = [[e, e] for e in original] - compare_to = [[e, e] for e in compare_to] - non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) - # keep just one integer for easy comparison - for s in non_common_subsequences: - for k in s.keys(): - s[k] = [e[0] for e in s[k]] - expected_result = [] - assert non_common_subsequences == expected_result - - -def test_non_common_subsequences_bars1(): - # import scores - score1_path = Path("tests/test_scores/polyphonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/polyphonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_tree1 = AnnScore(score1) - score_tree2 = AnnScore(score2) - # compute the non common_subsequences for part 0 - part = 0 - ncs = Comparison._non_common_subsequences_of_measures( - score_tree1.part_list[part].bar_list, score_tree2.part_list[part].bar_list - ) - assert len(ncs) == 2 - - -def test_non_common_subsequences_bars2(): - # import scores - score1_path = Path("tests/test_scores/monophonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/monophonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_tree1 = AnnScore(score1) - score_tree2 = AnnScore(score2) - # compute the non common_subsequences for part 0 - part = 0 - non_common_subsequences = Comparison._non_common_subsequences_of_measures( - score_tree1.part_list[part].bar_list, score_tree2.part_list[part].bar_list - ) - expected_non_common1 = { - "original": [score_tree1.part_list[0].bar_list[1]], - "compare_to": [score_tree2.part_list[0].bar_list[1]], - } - expected_non_common2 = { - "original": [ - score_tree1.part_list[0].bar_list[5], - score_tree1.part_list[0].bar_list[6], - score_tree1.part_list[0].bar_list[7], - score_tree1.part_list[0].bar_list[8], - ], - "compare_to": [ - score_tree2.part_list[0].bar_list[5], - score_tree2.part_list[0].bar_list[6], - score_tree2.part_list[0].bar_list[7], - ], - } - assert len(non_common_subsequences) == 2 - assert non_common_subsequences[0] == expected_non_common1 - assert non_common_subsequences[1] == expected_non_common2 - - -def test_non_common_subsequences_bars3(): - # import scores - score1_path = Path("tests/test_scores/monophonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/monophonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build Score - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the non common_subsequences for part 0 - part = 0 - non_common_subsequences = Comparison._non_common_subsequences_of_measures( - score_lin1.part_list[part].bar_list, score_lin2.part_list[part].bar_list - ) - expected_non_common1 = { - "original": [score_lin1.part_list[0].bar_list[1]], - "compare_to": [score_lin2.part_list[0].bar_list[1]], - } - expected_non_common2 = { - "original": [ - score_lin1.part_list[0].bar_list[5], - score_lin1.part_list[0].bar_list[6], - score_lin1.part_list[0].bar_list[7], - score_lin1.part_list[0].bar_list[8], - ], - "compare_to": [ - score_lin2.part_list[0].bar_list[5], - score_lin2.part_list[0].bar_list[6], - score_lin2.part_list[0].bar_list[7], - ], - } - assert len(non_common_subsequences) == 2 - assert non_common_subsequences[0] == expected_non_common1 - assert non_common_subsequences[1] == expected_non_common2 - - -def test_pitches_diff1(): - n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1) - n2 = m21.note.Note(nameWithOctave="D--5", quarterLength=1) - # create AnnotatedNotes - note1 = AnnNote(n1, [], []) - note2 = AnnNote(n2, [], []) - # pitches to compare - pitch1 = note1.pitches[0] - pitch2 = note2.pitches[0] - # compare - op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) - assert cost == 1 - assert op_list == [("accidentedit", note1, note2, 1, (0, 0))] - - -def test_pitches_diff2(): - n1 = m21.note.Note(nameWithOctave="E5", quarterLength=2) - n2 = m21.note.Note(nameWithOctave="D--5", quarterLength=1) - note1 = AnnNote(n1, [], []) - note2 = AnnNote(n2, [], []) - # pitches to compare - pitch1 = note1.pitches[0] - pitch2 = note2.pitches[0] - # compare - op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) - assert cost == 2 - assert len(op_list) == 2 - assert ("accidentins", note1, note2, 1, (0, 0)) in op_list - assert ("pitchnameedit", note1, note2, 1, (0, 0)) in op_list - - -def test_pitches_diff3(): - n1 = m21.note.Note(nameWithOctave="D--5", quarterLength=2) - n1.tie = m21.tie.Tie("stop") - n2 = m21.note.Rest(quarterLength=0.5) - note1 = AnnNote(n1, [], []) - note2 = AnnNote(n2, [], []) - # pitches to compare - pitch1 = note1.pitches[0] - pitch2 = note2.pitches[0] - # compare - op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) - assert cost == 3 - assert len(op_list) == 3 - assert ("accidentdel", note1, note2, 1, (0, 0)) in op_list - assert ("pitchtypeedit", note1, note2, 1, (0, 0)) in op_list - assert ("tiedel", note1, note2, 1, (0, 0)) in op_list - - -def test_pitches_diff4(): - n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) - n1.tie = m21.tie.Tie("stop") - n2 = m21.note.Note(nameWithOctave="D#5", quarterLength=3) - n2.tie = m21.tie.Tie("stop") - note1 = AnnNote(n1, [], []) - note2 = AnnNote(n2, [], []) - # pitches to compare - pitch1 = note1.pitches[0] - pitch2 = note2.pitches[0] - # compare - op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) - assert cost == 1 - assert len(op_list) == 1 - assert ("accidentins", note1, note2, 1, (0, 0)) in op_list - - -def test_block_diff1(): - score1_path = Path("tests/test_scores/monophonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/monophonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the blockdiff between all the bars (just for test, in practise we will run on non common subseq) - op_list, cost = Comparison._block_diff_lin( - score_lin1._measures_from_part(0), score_lin2._measures_from_part(0) - ) - assert cost == 8 - - -def test_multivoice_annotated_scores_diff1(): - score1_path = Path("tests/test_scores/multivoice_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/multivoice_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - assert cost == 8 - - -def test_annotated_scores_diff1(): - score1_path = Path("tests/test_scores/monophonic_score_1a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/monophonic_score_1b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - assert cost == 8 - - -def test_musicxml_articulation_diff1(): - score1_path = Path("tests/test_scores/musicxml/articulation_score_1a.xml") - score1 = m21.converter.parse(str(score1_path)) - score2_path = Path("tests/test_scores/musicxml/articulation_score_1b.xml") - score2 = m21.converter.parse(str(score2_path)) - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - assert cost == 10 +class TestScl: + converter21.register() + + def test_non_common_subsequences_myers1(self): + original = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + compare_to = [0, 0, 2, 3, 4, 5, 6, 4, 5, 9, 10] + # since repr and str of integers are the same thing, we just duplicate the values in a new column + original = [[e, e] for e in original] + compare_to = [[e, e] for e in compare_to] + non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) + expected_result = [ + {"original": [1], "compare_to": [0, 0]}, + {"original": [7, 8], "compare_to": [4, 5]}, + ] + assert non_common_subsequences == expected_result + + + def test_non_common_subsequences_myers2(self): + original = [0, 1, 2, 3] + compare_to = [5, 7, 8, 6, 3] + # since repr and str of integers are the same thing, we just duplicate the values in a new column + original = [[e, e] for e in original] + compare_to = [[e, e] for e in compare_to] + non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) + expected_result = [{"original": [0, 1, 2], "compare_to": [5, 7, 8, 6]}] + assert non_common_subsequences == expected_result + + + def test_non_common_subsequences_myers3(self): + original = [0, 1, 2, 3, 4] + compare_to = [0, 1, 2] + # since repr and str of integers are the same thing, we just duplicate the values in a new column + original = [[e, e] for e in original] + compare_to = [[e, e] for e in compare_to] + non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) + expected_result = [{"original": [3, 4], "compare_to": []}] + assert non_common_subsequences == expected_result + + + def test_non_common_subsequences_myers4(self): + original = [0, 1, 2] + compare_to = [0, 1, 2] + # since repr and str of integers are the same thing, we just duplicate the values in a new column + original = [[e, e] for e in original] + compare_to = [[e, e] for e in compare_to] + non_common_subsequences = Comparison._non_common_subsequences_myers(original, compare_to) + # keep just one integer for easy comparison + for s in non_common_subsequences: + for k in s.keys(): + s[k] = [e[0] for e in s[k]] + expected_result = [] + assert non_common_subsequences == expected_result + + + def test_non_common_subsequences_bars1(self): + # import scores + score1_path = Path("tests/test_scores/polyphonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/polyphonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_tree1 = AnnScore(score1) + score_tree2 = AnnScore(score2) + # compute the non common_subsequences for part 0 + part = 0 + ncs = Comparison._non_common_subsequences_of_measures( + score_tree1.part_list[part].bar_list, score_tree2.part_list[part].bar_list + ) + assert len(ncs) == 1 + + + def test_non_common_subsequences_bars2(self): + # import scores + score1_path = Path("tests/test_scores/monophonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/monophonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_tree1 = AnnScore(score1) + score_tree2 = AnnScore(score2) + # compute the non common_subsequences for part 0 + part = 0 + non_common_subsequences = Comparison._non_common_subsequences_of_measures( + score_tree1.part_list[part].bar_list, score_tree2.part_list[part].bar_list + ) + expected_non_common1 = { + "original": [score_tree1.part_list[0].bar_list[1]], + "compare_to": [score_tree2.part_list[0].bar_list[1]], + } + expected_non_common2 = { + "original": [ + score_tree1.part_list[0].bar_list[5], + score_tree1.part_list[0].bar_list[6], + score_tree1.part_list[0].bar_list[7], + score_tree1.part_list[0].bar_list[8], + ], + "compare_to": [ + score_tree2.part_list[0].bar_list[5], + score_tree2.part_list[0].bar_list[6], + score_tree2.part_list[0].bar_list[7], + ], + } + assert len(non_common_subsequences) == 2 + assert non_common_subsequences[0] == expected_non_common1 + assert non_common_subsequences[1] == expected_non_common2 + + + def test_non_common_subsequences_bars3(self): + # import scores + score1_path = Path("tests/test_scores/monophonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/monophonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build Score + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the non common_subsequences for part 0 + part = 0 + non_common_subsequences = Comparison._non_common_subsequences_of_measures( + score_lin1.part_list[part].bar_list, score_lin2.part_list[part].bar_list + ) + expected_non_common1 = { + "original": [score_lin1.part_list[0].bar_list[1]], + "compare_to": [score_lin2.part_list[0].bar_list[1]], + } + expected_non_common2 = { + "original": [ + score_lin1.part_list[0].bar_list[5], + score_lin1.part_list[0].bar_list[6], + score_lin1.part_list[0].bar_list[7], + score_lin1.part_list[0].bar_list[8], + ], + "compare_to": [ + score_lin2.part_list[0].bar_list[5], + score_lin2.part_list[0].bar_list[6], + score_lin2.part_list[0].bar_list[7], + ], + } + assert len(non_common_subsequences) == 2 + assert non_common_subsequences[0] == expected_non_common1 + assert non_common_subsequences[1] == expected_non_common2 + + + def test_pitches_diff1(self): + n1 = m21.note.Note(nameWithOctave="D#5", quarterLength=1) + n2 = m21.note.Note(nameWithOctave="D--5", quarterLength=1) + # create AnnotatedNotes + note1 = AnnNote(n1, [], [], []) + note2 = AnnNote(n2, [], [], []) + # pitches to compare + pitch1 = note1.pitches[0] + pitch2 = note2.pitches[0] + # compare + op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) + assert cost == 1 + assert op_list == [("accidentedit", note1, note2, 1, (0, 0))] + + + def test_pitches_diff2(self): + n1 = m21.note.Note(nameWithOctave="E5", quarterLength=2) + n2 = m21.note.Note(nameWithOctave="D--5", quarterLength=1) + note1 = AnnNote(n1, [], [], []) + note2 = AnnNote(n2, [], [], []) + # pitches to compare + pitch1 = note1.pitches[0] + pitch2 = note2.pitches[0] + # compare + op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) + assert cost == 2 + assert len(op_list) == 2 + assert ("accidentins", note1, note2, 1, (0, 0)) in op_list + assert ("pitchnameedit", note1, note2, 1, (0, 0)) in op_list + + + def test_pitches_diff3(self): + n1 = m21.note.Note(nameWithOctave="D--5", quarterLength=2) + n1.tie = m21.tie.Tie("start") + n2 = m21.note.Rest(quarterLength=0.5) + note1 = AnnNote(n1, [], [], []) + note2 = AnnNote(n2, [], [], []) + # pitches to compare + pitch1 = note1.pitches[0] + pitch2 = note2.pitches[0] + # compare + op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) + assert cost == 3 + assert len(op_list) == 3 + assert ("accidentdel", note1, note2, 1, (0, 0)) in op_list + assert ("pitchtypeedit", note1, note2, 1, (0, 0)) in op_list + assert ("tiedel", note1, note2, 1, (0, 0)) in op_list + + + def test_pitches_diff4(self): + n1 = m21.note.Note(nameWithOctave="D5", quarterLength=2) + n1.tie = m21.tie.Tie("start") + n2 = m21.note.Note(nameWithOctave="D#5", quarterLength=3) + n2.tie = m21.tie.Tie("start") + note1 = AnnNote(n1, [], [], []) + note2 = AnnNote(n2, [], [], []) + # pitches to compare + pitch1 = note1.pitches[0] + pitch2 = note2.pitches[0] + # compare + op_list, cost = Comparison._pitches_diff(pitch1, pitch2, note1, note2, (0, 0)) + assert cost == 1 + assert len(op_list) == 1 + assert ("accidentins", note1, note2, 1, (0, 0)) in op_list + + + def test_block_diff1(self): + score1_path = Path("tests/test_scores/monophonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/monophonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the blockdiff between all the bars (just for test, in practise we will run on non common subseq) + op_list, cost = Comparison._block_diff_lin( + score_lin1._measures_from_part(0), score_lin2._measures_from_part(0) + ) + assert cost == 9 + + + def test_multivoice_annotated_scores_diff1(self): + score1_path = Path("tests/test_scores/multivoice_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/multivoice_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + assert cost == 8 + + + def test_annotated_scores_diff1(self): + score1_path = Path("tests/test_scores/monophonic_score_1a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/monophonic_score_1b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + assert cost == 9 + + + def test_musicxml_articulation_diff1(self): + score1_path = Path("tests/test_scores/musicxml/articulation_score_1a.xml") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/musicxml/articulation_score_1b.xml") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + assert cost == 10 diff --git a/tests/test_score_visualization.py b/tests/test_score_visualization.py index 5456706..0d386f1 100644 --- a/tests/test_score_visualization.py +++ b/tests/test_score_visualization.py @@ -4,58 +4,49 @@ from musicdiff.annotation import AnnScore from musicdiff import Comparison from musicdiff import Visualization +import converter21 +class TestScoreVisualization: + converter21.register() -def test_scorevis1(): - score1_path = Path("tests/test_scores/tie_score_2a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/tie_score_2b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - Visualization.mark_diffs(score1, score2, op_list) - # Visualization.show_diffs(score1, score2) + def test_scorevis1(self): + score1_path = Path("tests/test_scores/tie_score_2a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/tie_score_2b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + Visualization.mark_diffs(score1, score2, op_list) + # Visualization.show_diffs(score1, score2) -def test_scorevis2(): - score1_path = Path("tests/test_scores/polyphonic_score_2a.mei") - with open(score1_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score1 = conv.run() - score2_path = Path("tests/test_scores/polyphonic_score_2b.mei") - with open(score2_path, "r") as f: - mei_string = f.read() - conv = m21.mei.MeiToM21Converter(mei_string) - score2 = conv.run() - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - Visualization.mark_diffs(score1, score2, op_list) - # Visualization.show_diffs(score1, score2) + def test_scorevis2(self): + score1_path = Path("tests/test_scores/polyphonic_score_2a.mei") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/polyphonic_score_2b.mei") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + Visualization.mark_diffs(score1, score2, op_list) + # Visualization.show_diffs(score1, score2) -def test_scorevis3(): - score1_path = Path("tests/test_scores/musicxml/articulation_score_1a.xml") - score1 = m21.converter.parse(str(score1_path)) - score2_path = Path("tests/test_scores/musicxml/articulation_score_1b.xml") - score2 = m21.converter.parse(str(score2_path)) - # build ScoreTrees - score_lin1 = AnnScore(score1) - score_lin2 = AnnScore(score2) - # compute the complete score diff - op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) - Visualization.mark_diffs(score1, score2, op_list) - # Visualization.show_diffs(score1, score2) + def test_scorevis3(self): + score1_path = Path("tests/test_scores/musicxml/articulation_score_1a.xml") + score1 = m21.converter.parse(str(score1_path)) + score2_path = Path("tests/test_scores/musicxml/articulation_score_1b.xml") + score2 = m21.converter.parse(str(score2_path)) + # build ScoreTrees + score_lin1 = AnnScore(score1) + score_lin2 = AnnScore(score2) + # compute the complete score diff + op_list, cost = Comparison.annotated_scores_diff(score_lin1, score_lin2) + Visualization.mark_diffs(score1, score2, op_list) + # Visualization.show_diffs(score1, score2) diff --git a/tests/test_utils.py b/tests/test_utils.py index 24800d2..a3ce532 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -14,11 +14,11 @@ def test_note2tuple2(): def test_note2tuple3(): n = m21.note.Note(nameWithOctave='D--5') n.tie = m21.tie.Tie('start') - expected_tuple = ("D5","double-flat",False) + expected_tuple = ("D5","double-flat",True) assert(M21Utils.note2tuple(n) == expected_tuple ) def test_note2tuple4(): n = m21.note.Note(nameWithOctave='D--5') n.tie = m21.tie.Tie('stop') - expected_tuple = ("D5","double-flat",True) + expected_tuple = ("D5","double-flat",False) assert(M21Utils.note2tuple(n) == expected_tuple )