Skip to content

Commit adb44c4

Browse files
Tons of new detail in the diff. So much so that I added a DetailLevel 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.
1 parent 1ee9d0b commit adb44c4

File tree

6 files changed

+479
-55
lines changed

6 files changed

+479
-55
lines changed

musicdiff/__init__.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import music21 as m21
2121

2222
from musicdiff.m21utils import M21Utils
23+
from musicdiff.m21utils import DetailLevel
2324
from musicdiff.annotation import AnnScore
2425
from musicdiff.comparison import Comparison
2526
from musicdiff.visualization import Visualization
@@ -48,6 +49,7 @@ def diff(score1: Union[str, Path, m21.stream.Score],
4849
out_path2: Union[str, Path] = None,
4950
force_parse: bool = True,
5051
visualize_diffs: bool = True,
52+
detail: DetailLevel = DetailLevel.Default
5153
) -> int:
5254
'''
5355
Compare two musical scores and optionally save/display the differences as two marked-up
@@ -72,6 +74,9 @@ def diff(score1: Union[str, Path, m21.stream.Score],
7274
visualize_diffs (bool): Whether or not to render diffs as marked up PDFs. If False,
7375
the only result of the call will be the return value (the number of differences).
7476
(default is True)
77+
detail (DetailLevel): What level of detail to use during the diff. Can be
78+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
79+
currently equivalent to AllObjects).
7580
7681
Returns:
7782
int: The number of differences found (0 means the scores were identical, None means the diff failed)
@@ -137,8 +142,8 @@ def diff(score1: Union[str, Path, m21.stream.Score],
137142
return None
138143

139144
# scan each score, producing an annotated wrapper
140-
annotated_score1: AnnScore = AnnScore(score1)
141-
annotated_score2: AnnScore = AnnScore(score2)
145+
annotated_score1: AnnScore = AnnScore(score1, detail)
146+
annotated_score2: AnnScore = AnnScore(score2, detail)
142147

143148
diff_list: List = None
144149
_cost: int = None

musicdiff/__main__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616
import argparse
1717

1818
from musicdiff import diff
19+
from musicdiff import DetailLevel
1920

2021
# To use the new Humdrum importer from converter21 in place of the one in music21:
21-
# git clone https://github.com/gregchapman-dev/converter21.git
22-
# pip install converter21 # or pip install -e converter21 if you want it "editable"
22+
# pip install converter21
2323
# Then uncomment all lines in this file marked "# c21"
2424
# import music21 as m21 # c21
2525
# from converter21 import HumdrumConverter # c21
@@ -43,11 +43,24 @@
4343
help="first music score file to compare (any format music21 can parse)")
4444
parser.add_argument("file2",
4545
help="second music score file to compare (any format music21 can parse)")
46+
parser.add_argument("-d", "--detail", default="Default",
47+
choices=["GeneralNotesOnly", "AllObjects", "AllObjectsWithStyle", "Default"],
48+
help="set detail level")
4649
args = parser.parse_args()
4750

51+
detail: DetailLevel = DetailLevel.Default
52+
if args.detail == "GeneralNotesOnly":
53+
detail = DetailLevel.GeneralNotesOnly
54+
elif args.detail == "AllObjects":
55+
detail = DetailLevel.AllObjects
56+
elif args.detail == "AllObjectsWithStyle":
57+
detail = DetailLevel.AllObjectsWithStyle
58+
elif args.detail == "Default":
59+
detail = DetailLevel.Default
60+
4861
# Note that diff() can take a music21 Score instead of a file, for either
4962
# or both arguments.
5063
# Note also that diff() can take str or pathlib.Path for files.
51-
numDiffs: int = diff(args.file1, args.file2)
64+
numDiffs: int = diff(args.file1, args.file2, detail=detail)
5265
if numDiffs is not None and numDiffs == 0:
5366
print(f'Scores in {args.file1} and {args.file2} are identical.', file=sys.stderr)

musicdiff/annotation.py

Lines changed: 90 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,25 +15,45 @@
1515
__docformat__ = "google"
1616

1717
from fractions import Fraction
18+
from typing import Optional
1819

1920
import music21 as m21
2021

2122
from musicdiff import M21Utils
22-
23+
from musicdiff import DetailLevel
2324

2425
class AnnNote:
25-
def __init__(self, general_note, enhanced_beam_list, tuplet_list):
26+
def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuplet_list, detail: DetailLevel = DetailLevel.Default):
2627
"""
2728
Extend music21 GeneralNote with some precomputed, easily compared information about it.
2829
2930
Args:
3031
general_note (music21.note.GeneralNote): The music21 note/chord/rest to extend.
3132
enhanced_beam_list (list): A list of beaming information about this GeneralNote.
3233
tuplet_list (list): A list of tuplet info about this GeneralNote.
34+
detail (DetailLevel): What level of detail to use during the diff. Can be
35+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
36+
currently equivalent to AllObjects).
37+
3338
"""
3439
self.general_note = general_note.id
3540
self.beamings = enhanced_beam_list
3641
self.tuplets = tuplet_list
42+
43+
self.stylestr: str = ''
44+
self.styledict: dict = {}
45+
if M21Utils.has_style(general_note):
46+
self.styledict = M21Utils.obj_to_styledict(general_note, detail)
47+
self.noteshape: str = 'normal'
48+
self.noteheadFill: Optional[bool] = None
49+
self.noteheadParenthesis: bool = False
50+
self.stemDirection: str = 'unspecified'
51+
if detail >= DetailLevel.AllObjectsWithStyle and isinstance(general_note, m21.note.NotRest):
52+
self.noteshape = general_note.notehead
53+
self.noteheadFill = general_note.noteheadFill
54+
self.noteheadParenthesis = general_note.noteheadParenthesis
55+
self.stemDirection = general_note.stemDirection
56+
3757
# compute the representation of NoteNode as in the paper
3858
# pitches is a list of elements, each one is (pitchposition, accidental, tie)
3959
if general_note.isRest:
@@ -101,7 +121,8 @@ def notation_size(self):
101121
def __repr__(self):
102122
# does consider the MEI id!
103123
return (f"{self.pitches},{self.note_head},{self.dots},{self.beamings}," +
104-
f"{self.tuplets},{self.general_note},{self.articulations},{self.expressions}")
124+
f"{self.tuplets},{self.general_note},{self.articulations},{self.expressions}" +
125+
f"{self.styledict}")
105126

106127
def __str__(self):
107128
"""
@@ -151,6 +172,22 @@ def __str__(self):
151172
if len(self.expressions) > 0: # add for articulations
152173
for e in self.expressions:
153174
string += e
175+
176+
if self.noteshape != 'normal':
177+
string += f"noteshape={self.noteshape}"
178+
if self.noteheadFill is not None:
179+
string += f"noteheadFill={self.noteheadFill}"
180+
if self.noteheadParenthesis:
181+
string += f"noteheadParenthesis={self.noteheadParenthesis}"
182+
if self.stemDirection != 'unspecified':
183+
string += f"stemDirection={self.stemDirection}"
184+
185+
# and then the style fields
186+
for i, (k, v) in enumerate(self.styledict.items()):
187+
if i > 0:
188+
string += ","
189+
string += f"{k}={v}"
190+
154191
return string
155192

156193
def get_note_ids(self):
@@ -188,7 +225,7 @@ def __eq__(self, other):
188225

189226

190227
class AnnExtra:
191-
def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score):
228+
def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
192229
"""
193230
Extend music21 non-GeneralNote and non-Stream objects with some precomputed, easily compared information about it.
194231
Examples: TextExpression, Dynamic, Clef, Key, TimeSignature, MetronomeMark, etc.
@@ -197,6 +234,9 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s
197234
extra (music21.base.Music21Object): The music21 non-GeneralNote/non-Stream object to extend.
198235
measure (music21.stream.Measure): The music21 Measure the extra was found in. If the extra
199236
was found in a Voice, this is the Measure that the Voice was found in.
237+
detail (DetailLevel): What level of detail to use during the diff. Can be
238+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
239+
currently equivalent to AllObjects).
200240
"""
201241
self.extra = extra.id
202242
self.offset: float
@@ -213,6 +253,9 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s
213253
self.offset = float(extra.getOffsetInHierarchy(measure))
214254
self.duration = float(extra.duration.quarterLength)
215255
self.content: str = M21Utils.extra_to_string(extra)
256+
self.styledict: str = {}
257+
if M21Utils.has_style(extra):
258+
self.styledict = M21Utils.obj_to_styledict(extra, detail) # includes extra.placement if present
216259
self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday
217260

218261
# precomputed representations for faster comparison
@@ -235,20 +278,27 @@ def __str__(self):
235278
Returns:
236279
str: the compared representation of the AnnExtra. Does not consider music21 id.
237280
"""
238-
return f'[{self.content},off={self.offset},dur={self.duration}]'
281+
string = f'{self.content},off={self.offset},dur={self.duration}'
282+
# and then any style fields
283+
for k, v in self.styledict.items():
284+
string += f",{k}={v}"
285+
return string
239286

240287
def __eq__(self, other):
241288
# equality does not consider the MEI id!
242289
return self.precomputed_str == other.precomputed_str
243290

244291

245292
class AnnVoice:
246-
def __init__(self, voice):
293+
def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.Default):
247294
"""
248295
Extend music21 Voice with some precomputed, easily compared information about it.
249296
250297
Args:
251298
voice (music21.stream.Voice): The music21 voice to extend.
299+
detail (DetailLevel): What level of detail to use during the diff. Can be
300+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
301+
currently equivalent to AllObjects).
252302
"""
253303
self.voice = voice.id
254304
note_list = M21Utils.get_notes(voice)
@@ -269,7 +319,7 @@ def __init__(self, voice):
269319
self.annot_notes = []
270320
for i, n in enumerate(note_list):
271321
self.annot_notes.append(
272-
AnnNote(n, self.en_beam_list[i], self.tuplet_list[i])
322+
AnnNote(n, self.en_beam_list[i], self.tuplet_list[i], detail)
273323
)
274324

275325
self.n_of_notes = len(self.annot_notes)
@@ -323,35 +373,44 @@ def get_note_ids(self):
323373

324374

325375
class AnnMeasure:
326-
def __init__(self, measure, score, spannerBundle):
376+
def __init__(self, measure: m21.stream.Measure,
377+
score: m21.stream.Score,
378+
spannerBundle: m21.spanner.SpannerBundle,
379+
detail: DetailLevel = DetailLevel.Default):
327380
"""
328381
Extend music21 Measure with some precomputed, easily compared information about it.
329382
330383
Args:
331384
measure (music21.stream.Measure): The music21 measure to extend.
385+
score (music21.stream.Score): the enclosing music21 Score.
386+
spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
387+
detail (DetailLevel): What level of detail to use during the diff. Can be
388+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
389+
currently equivalent to AllObjects).
332390
"""
333391
self.measure = measure.id
334392
self.voices_list = []
335393
if (
336394
len(measure.voices) == 0
337395
): # there is a single AnnVoice ( == for the library there are no voices)
338-
ann_voice = AnnVoice(measure)
396+
ann_voice = AnnVoice(measure, detail)
339397
if ann_voice.n_of_notes > 0:
340398
self.voices_list.append(ann_voice)
341399
else: # there are multiple voices (or an array with just one voice)
342400
for voice in measure.voices:
343-
ann_voice = AnnVoice(voice)
401+
ann_voice = AnnVoice(voice, detail)
344402
if ann_voice.n_of_notes > 0:
345403
self.voices_list.append(ann_voice)
346404
self.n_of_voices = len(self.voices_list)
347405

348406
self.extras_list = []
349-
for extra in M21Utils.get_extras(measure, spannerBundle):
350-
self.extras_list.append(AnnExtra(extra, measure, score))
407+
if detail >= DetailLevel.AllObjects:
408+
for extra in M21Utils.get_extras(measure, spannerBundle):
409+
self.extras_list.append(AnnExtra(extra, measure, score, detail))
351410

352-
# For correct comparison, sort the extras_list, so that any list slices
353-
# that all have the same offset are sorted alphabetically.
354-
self.extras_list.sort(key=lambda e: ( e.offset, str(e) ))
411+
# For correct comparison, sort the extras_list, so that any list slices
412+
# that all have the same offset are sorted alphabetically.
413+
self.extras_list.sort(key=lambda e: ( e.offset, str(e) ))
355414

356415
# precomputed values to speed up the computation. As they start to be long, they are hashed
357416
self.precomputed_str = hash(self.__str__())
@@ -400,17 +459,25 @@ def get_note_ids(self):
400459

401460

402461
class AnnPart:
403-
def __init__(self, part, score, spannerBundle):
462+
def __init__(self, part: m21.stream.Part,
463+
score: m21.stream.Score,
464+
spannerBundle: m21.spanner.SpannerBundle,
465+
detail: DetailLevel = DetailLevel.Default):
404466
"""
405467
Extend music21 Part/PartStaff with some precomputed, easily compared information about it.
406468
407469
Args:
408470
part (music21.stream.Part, music21.stream.PartStaff): The music21 Part/PartStaff to extend.
471+
score (music21.stream.Score): the enclosing music21 Score.
472+
spannerBundle (music21.spanner.SpannerBundle): a bundle of all the spanners in the score.
473+
detail (DetailLevel): What level of detail to use during the diff. Can be
474+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
475+
currently equivalent to AllObjects).
409476
"""
410477
self.part = part.id
411478
self.bar_list = []
412479
for measure in part.getElementsByClass("Measure"):
413-
ann_bar = AnnMeasure(measure, score, spannerBundle) # create the bar objects
480+
ann_bar = AnnMeasure(measure, score, spannerBundle, detail) # create the bar objects
414481
if ann_bar.n_of_voices > 0:
415482
self.bar_list.append(ann_bar)
416483
self.n_of_bars = len(self.bar_list)
@@ -456,19 +523,22 @@ def get_note_ids(self):
456523

457524

458525
class AnnScore:
459-
def __init__(self, score):
526+
def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.Default):
460527
"""
461528
Take a music21 score and store it as a sequence of Full Trees.
462529
The hierarchy is "score -> parts -> measures -> voices -> notes"
463530
Args:
464531
score (music21.stream.Score): The music21 score
532+
detail (DetailLevel): What level of detail to use during the diff. Can be
533+
GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is
534+
currently equivalent to AllObjects).
465535
"""
466536
self.score = score.id
467537
self.part_list = []
468-
spannerBundle = score.spannerBundle
538+
spannerBundle: m21.spanner.SpannerBundle = score.spannerBundle
469539
for part in score.parts.stream():
470540
# create and add the AnnPart object to part_list
471-
ann_part = AnnPart(part, score, spannerBundle)
541+
ann_part = AnnPart(part, score, spannerBundle, detail)
472542
if ann_part.n_of_bars > 0:
473543
self.part_list.append(ann_part)
474544
self.n_of_parts = len(self.part_list)

musicdiff/comparison.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,11 @@ def _annotated_extra_diff(annExtra1: AnnExtra, annExtra2: AnnExtra):
504504
cost += duration_cost
505505
op_list.append(("extradurationedit", annExtra1, annExtra2, duration_cost))
506506

507+
# add for the style
508+
if annExtra1.styledict != annExtra2.styledict:
509+
cost += 1
510+
op_list.append(("extrastyleedit", annExtra1, annExtra2, 1))
511+
507512
return op_list, cost
508513

509514
@staticmethod
@@ -632,6 +637,26 @@ def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote):
632637
)
633638
op_list.extend(expr_op_list)
634639
cost += expr_cost
640+
# add for noteshape
641+
if annNote1.noteshape != annNote2.noteshape:
642+
cost += 1
643+
op_list.append(("editnoteshape", annNote1, annNote2, 1))
644+
# add for noteheadFill
645+
if annNote1.noteheadFill != annNote2.noteheadFill:
646+
cost += 1
647+
op_list.append(("editnoteheadfill", annNote1, annNote2, 1))
648+
# add for noteheadParenthesis
649+
if annNote1.noteheadParenthesis != annNote2.noteheadParenthesis:
650+
cost += 1
651+
op_list.append(("editnoteheadparenthesis", annNote1, annNote2, 1))
652+
# add for stemDirection
653+
if annNote1.stemDirection != annNote2.stemDirection:
654+
cost += 1
655+
op_list.append(("editstemdirection", annNote1, annNote2, 1))
656+
# add for the styledict
657+
if annNote1.styledict != annNote2.styledict:
658+
cost += 1
659+
op_list.append(("editstyle", annNote1, annNote2, 1))
635660

636661
return op_list, cost
637662

0 commit comments

Comments
 (0)