Skip to content

Commit

Permalink
Tons of new detail in the diff. So much so that I added a DetailLevel…
Browse files Browse the repository at this point in the history
… parameter throughout. (#6)

* Lots of detail/style about notes, and style about everything else.

* Don't create style info by looking at it.

* Better visualization of style differences (e.g. "changed note color").

* Add DetailLevel specification. Flesh out missing TextStyle stuff (e.g. alignVertical).

* Placement should only be diffed if DetailLevel >= AllObjectsWithStyle.

* Add new command line argument -d/--detail to specify DetailLevel of diff requested.

* More complete note style diffing. Update API docs.

* Fix up detailed annotation of notes.

* Major cleanup of style diffing, fixing a few bugs along the way.
  • Loading branch information
gregchapman-dev authored Apr 3, 2022
1 parent 1ee9d0b commit adb44c4
Show file tree
Hide file tree
Showing 6 changed files with 479 additions and 55 deletions.
9 changes: 7 additions & 2 deletions musicdiff/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
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
Expand Down Expand Up @@ -48,6 +49,7 @@ def diff(score1: Union[str, Path, m21.stream.Score],
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
Expand All @@ -72,6 +74,9 @@ 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).
Returns:
int: The number of differences found (0 means the scores were identical, None means the diff failed)
Expand Down Expand Up @@ -137,8 +142,8 @@ def diff(score1: Union[str, Path, m21.stream.Score],
return None

# scan each score, producing an annotated wrapper
annotated_score1: AnnScore = AnnScore(score1)
annotated_score2: AnnScore = AnnScore(score2)
annotated_score1: AnnScore = AnnScore(score1, detail)
annotated_score2: AnnScore = AnnScore(score2, detail)

diff_list: List = None
_cost: int = None
Expand Down
19 changes: 16 additions & 3 deletions musicdiff/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
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:
# git clone https://github.com/gregchapman-dev/converter21.git
# pip install converter21 # or pip install -e converter21 if you want it "editable"
# pip install converter21
# Then uncomment all lines in this file marked "# c21"
# import music21 as m21 # c21
# from converter21 import HumdrumConverter # c21
Expand All @@ -43,11 +43,24 @@
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)
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)
110 changes: 90 additions & 20 deletions musicdiff/annotation.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,45 @@
__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, enhanced_beam_list, tuplet_list):
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:
Expand Down Expand Up @@ -101,7 +121,8 @@ def notation_size(self):
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.tuplets},{self.general_note},{self.articulations},{self.expressions}" +
f"{self.styledict}")

def __str__(self):
"""
Expand Down Expand Up @@ -151,6 +172,22 @@ def __str__(self):
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):
Expand Down Expand Up @@ -188,7 +225,7 @@ def __eq__(self, other):


class AnnExtra:
def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score):
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.
Expand All @@ -197,6 +234,9 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s
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
Expand All @@ -213,6 +253,9 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s
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
Expand All @@ -235,20 +278,27 @@ def __str__(self):
Returns:
str: the compared representation of the AnnExtra. Does not consider music21 id.
"""
return f'[{self.content},off={self.offset},dur={self.duration}]'
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):
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)
Expand All @@ -269,7 +319,7 @@ def __init__(self, voice):
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])
AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail)
)

self.n_of_notes = len(self.annot_notes)
Expand Down Expand Up @@ -323,35 +373,44 @@ def get_note_ids(self):


class AnnMeasure:
def __init__(self, measure, score, spannerBundle):
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)
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)
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 = []
for extra in M21Utils.get_extras(measure, spannerBundle):
self.extras_list.append(AnnExtra(extra, measure, score))
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) ))
# 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__())
Expand Down Expand Up @@ -400,17 +459,25 @@ def get_note_ids(self):


class AnnPart:
def __init__(self, part, score, spannerBundle):
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) # create the bar objects
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)
Expand Down Expand Up @@ -456,19 +523,22 @@ def get_note_ids(self):


class AnnScore:
def __init__(self, score):
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 = score.spannerBundle
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)
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)
Expand Down
25 changes: 25 additions & 0 deletions musicdiff/comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@ def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
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
Expand Down Expand Up @@ -632,6 +637,26 @@ def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote):
)
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

Expand Down
Loading

0 comments on commit adb44c4

Please sign in to comment.