From c9e91df2b343d0f6859c36b9ba5968bf1011a38f Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 16 Mar 2022 17:57:26 -0700 Subject: [PATCH 1/9] Lots of detail/style about notes, and style about everything else. --- musicdiff/annotation.py | 17 ++- musicdiff/comparison.py | 20 +++ musicdiff/m21utils.py | 285 +++++++++++++++++++++++++++++++++---- musicdiff/visualization.py | 80 +++++++++++ 4 files changed, 371 insertions(+), 31 deletions(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 35b5422..49199e3 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -15,6 +15,7 @@ __docformat__ = "google" from fractions import Fraction +from typing import Optional import music21 as m21 @@ -34,6 +35,17 @@ def __init__(self, general_note, enhanced_beam_list, tuplet_list): self.general_note = general_note.id self.beamings = enhanced_beam_list self.tuplets = tuplet_list + self.stylestr: str = M21Utils.style_to_string(general_note.style) + self.noteshape: str = 'normal' + self.noteheadFill: Optional[bool] = None + self.noteheadParenthesis: bool = False + self.stemDirection: str = 'unspecified' + if 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: @@ -101,7 +113,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.stylestr}") def __str__(self): """ @@ -151,6 +164,8 @@ def __str__(self): if len(self.expressions) > 0: # add for articulations for e in self.expressions: string += e + if self.stylestr: + string += self.stylestr return string def get_note_ids(self): diff --git a/musicdiff/comparison.py b/musicdiff/comparison.py index db5df18..9755c8f 100644 --- a/musicdiff/comparison.py +++ b/musicdiff/comparison.py @@ -632,6 +632,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 stylestr + if annNote1.stylestr != annNote2.stylestr: + cost += 1 + op_list.append(("editstyle", annNote1, annNote2, 1)) return op_list, cost diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index 3b128fa..838df61 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -14,7 +14,7 @@ from fractions import Fraction import math import sys -from typing import List +from typing import List, Union # import sys import music21 as m21 @@ -441,30 +441,50 @@ def safe_get(indexable, idx): @staticmethod def clef_to_string(clef: m21.clef.Clef) -> str: # sign(str), line(int), octaveChange(int == # octaves to shift up(+) or down(-)) + stylestr: str = M21Utils.style_to_string(clef.style) sign: str = '' if clef.sign is None else clef.sign line: str = '0' if clef.line is None else f'{clef.line}' octave: str = '' if clef.octaveChange == 0 else f'{8 * clef.octaveChange:+}' - return f'CL:{sign}{line}{octave}' + output: str = f'CL:{sign}{line}{octave}' + if stylestr: + return f'{output} ({stylestr})' + return output @staticmethod def timesig_to_string(timesig: m21.meter.TimeSignature) -> str: + output: str = '' + stylestr: str = M21Utils.style_to_string(timesig.style) + if not timesig.symbol: - return f'TS:{timesig.numerator}/{timesig.denominator}' - if timesig.symbol in ('common', 'cut'): - return f'TS:{timesig.symbol}' - if timesig.symbol == 'single-number': - return f'TS:{timesig.numerator}' - return f'TS:{timesig.numerator}/{timesig.denominator}' + output = f'TS:{timesig.numerator}/{timesig.denominator}' + elif timesig.symbol in ('common', 'cut'): + output = f'TS:{timesig.symbol}' + elif timesig.symbol == 'single-number': + output = f'TS:{timesig.numerator}' + else: + output = f'TS:{timesig.numerator}/{timesig.denominator}' + + if stylestr: + return f'{output} ({stylestr})' + + return output @staticmethod def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: # pylint: disable=protected-access # We need direct access to mm._textExpression and mm._tempoText, to avoid - # the extra formatting that referencing via the .text propert will perform. + # the extra formatting that referencing via the .text property will perform. + stylestr: str = M21Utils.style_to_string(mm.style) + output: str = '' if isinstance(mm, m21.tempo.TempoText): if mm._textExpression is None: - return 'MM:' - return f'MM:{M21Utils.extra_to_string(mm._textExpression)}' + output = 'MM:' + else: + output = f'MM:{M21Utils.extra_to_string(mm._textExpression)}' + + if stylestr: + return f'{output} ({stylestr})' + return output if isinstance(mm, m21.tempo.MetricModulation): # convert to MetronomeMark @@ -473,31 +493,48 @@ def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: # Assume mm is now a MetronomeMark if mm.textImplicit is True or mm._tempoText is None: if mm.referent is None or mm.number is None: - return 'MM:' - return f'MM:{mm.referent.fullName}={float(mm.number)}' + output = 'MM:' + else: + output = f'MM:{mm.referent.fullName}={float(mm.number)}' + + if stylestr: + return f'{output} ({stylestr})' + return output + if mm.numberImplicit is True or mm.number is None: if mm._tempoText is None: - return 'MM:' - # no 'MM:' prefix, TempoText adds their own - return M21Utils.tempo_to_string(mm._tempoText) + output = 'MM:' + else: + # no 'MM:' prefix, TempoText adds their own + output = f'{M21Utils.tempo_to_string(mm._tempoText)}' + + if stylestr: + return f'{output} ({stylestr})' + return output # no 'MM:' prefix, TempoText adds their own - return f'{M21Utils.tempo_to_string(mm._tempoText)} {mm.referent.fullName}={float(mm.number)}' + output = f'{M21Utils.tempo_to_string(mm._tempoText)} {mm.referent.fullName}={float(mm.number)}' + if stylestr: + return f'{output} ({stylestr})' + return output # pylint: enable=protected-access @staticmethod def barline_to_string(barline: m21.bar.Barline) -> str: # for all Barlines: type, pause # for Repeat Barlines: direction, times + stylestr: str = M21Utils.style_to_string(barline.style) pauseStr: str = '' if barline.pause is not None: if isinstance(barline.pause, m21.expressions.Fermata): - pauseStr = ' with fermata' + pauseStr = f' with fermata({barline.pause.type},{barline.pause.shape})' else: pauseStr = ' with pause (non-fermata)' output: str = f'{barline.type}{pauseStr}' if not isinstance(barline, m21.bar.Repeat): + if stylestr: + return f'BL:{output} ({stylestr})' return f'BL:{output}' # add the Repeat fields (direction, times) @@ -505,25 +542,213 @@ def barline_to_string(barline: m21.bar.Barline) -> str: output += f' direction={barline.direction}' if barline.times is not None: output += f' times={barline.times}' + + if stylestr: + return f'RPT:{output} ({stylestr})' return f'RPT:{output}' + @staticmethod + def keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature]) -> str: + output: str = f'KS:{keysig.sharps}' + stylestr: str = M21Utils.style_to_string(keysig.style) + if stylestr: + output += f' ({stylestr})' + return output + + @staticmethod + def textexp_to_string(textexp: m21.expressions.TextExpression) -> str: + output: str = f'TX:{textexp.content}' + if textexp.placement is not None: + output += f',placement={textexp.placement}' + stylestr: str = M21Utils.style_to_string(textexp.style) + if stylestr: + output += f' ({stylestr})' + return output + + @staticmethod + def dynamic_to_string(dynamic: m21.dynamics.Dynamic) -> str: + output: str = f'DY:{dynamic.value}' + if dynamic.placement is not None: + output += f',placement={dynamic.placement}' + stylestr: str = M21Utils.style_to_string(dynamic.style) + if stylestr: + output += f' ({stylestr})' + return output + + @staticmethod + def style_to_string(style: m21.style.Style) -> str: + generic: str = M21Utils.genericstyle_to_string(style) + specific: str = M21Utils.specificstyle_to_string(style) + if specific and generic: + return f'{specific},{generic}' + if specific: + return specific + if generic: + return generic + return '' + + @staticmethod + def notestyle_to_string(style: m21.style.NoteStyle) -> str: + output: str = '' + stem: str = '' + accidental: str = '' + size: str = '' + + if style.stemStyle is not None: + stem = f'stemstyle=({M21Utils.genericstyle_to_string(style.stemStyle)})' + if output: + output += ',' + output += stem + + if style.accidentalStyle is not None: + accidental = f'accidstyle=({M21Utils.genericstyle_to_string(style.accidentalStyle)})' + if output: + output += ',' + output += accidental + + if style.noteSize: + size = f'size={style.noteSize}' + if output: + output += ',' + output += size + + return output + + @staticmethod + def textstyle_to_string(style: m21.style.TextStyle) -> str: + output: str = '' + placement: str = '' # None/string ('above', etc) + # (only exists in TextStylePlacement) + fontFamily: str = '' # None or list of strings (font names) + fontSize: str = '' # None/int/float/str(CSS font size) + fontStyle: str = '' # None/'normal'/'italic'/'bold'/'bolditalic' + fontWeight: str = '' # None/'normal'/'bold' + letterSpacing: str = '' # None/'normal'/float + + if isinstance(style, m21.style.TextStylePlacement) and style.placement: + placement = f'placement={style.placement}' + if output: + output += ',' + output += placement + if style.fontFamily: + fontFamily = f'fontFamily={style.fontFamily}' + if output: + output += ',' + output += fontFamily + if style.fontSize is not None: + fontSize = f'fontSize={style.fontSize}' + if output: + output += ',' + output += fontSize + if style.fontStyle is not None and style.fontStyle != 'normal': + fontStyle = f'fontStyle={style.fontStyle}' + if output: + output += ',' + output += fontStyle + if style.fontWeight is not None and style.fontWeight != 'normal': + fontWeight = f'fontWeight={style.fontWeight}' + if output: + output += ',' + output += fontWeight + if style.letterSpacing is not None and style.letterSpacing != 'normal': + letterSpacing = f'letterSpacing={style.letterSpacing}' + if output: + output += ',' + output += letterSpacing + + return output + + @staticmethod + def specificstyle_to_string(style: m21.style.Style) -> str: + if isinstance(style, m21.style.NoteStyle): + return M21Utils.notestyle_to_string(style) + if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement + return M21Utils.textstyle_to_string(style) + if isinstance(style, m21.style.BezierStyle): + return '' # M21Utils.bezierstyle_to_string(style) + if isinstance(style, m21.style.LineStyle): + return '' # M21Utils.linestyle_to_string(style) + if isinstance(style, m21.style.BeamStyle): + return '' # M21Utils.beamstyle_to_string(style) + return '' + + @staticmethod + def genericstyle_to_string(style: m21.style.Style) -> str: + generic: str = '' + if style.size is not None: + if generic: + generic += ',' + generic += f'size={style.size}' + if style.relativeX: + if generic: + generic += ',' + generic += f'relX={style.relativeX}' + if style.relativeY: + if generic: + generic += ',' + generic += f'relY={style.relativeY}' + # Most objects default to style.absX & absY of None, but + # Dynamic objects default to absX = -36 and absY = -80 + if style.absoluteX and style.absoluteX != -36: + if generic: + generic += ',' + generic += f'absX={style.absoluteX}' + if style.absoluteY and style.absoluteY != -80: + if generic: + generic += ',' + if style.absoluteY == 10: + generic += 'absY=above' + elif style.absoluteY == -70: + generic += 'absY=below' + else: + generic += f'absY={style.absoluteY}' + if style.enclosure: + if generic: + generic += ',' + generic += f'encl={style.enclosure}' + if style.fontRepresentation: + if generic: + generic += ',' + generic += f'fontrep={style.fontRepresentation}' + if style.color: + if generic: + generic += ',' + generic += f'color={style.color}' + if style.units != 'tenths': + if generic: + generic += ',' + generic += f'units={style.units}' + if style.hideObjectOnPrint: + if generic: + generic += ',' + generic += 'hidden' + return generic + + @staticmethod + def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: + stylestr: str = M21Utils.style_to_string(dynwedge.style) + output: str = '' + if isinstance(dynwedge, m21.dynamics.Crescendo): + output = '<' + elif isinstance(dynwedge, m21.dynamics.Diminuendo): + output = '>' + else: + output = 'wedge' + + if stylestr: + return f'DY:{output} ({stylestr})' + return f'DY:{output}' + @staticmethod def extra_to_string(extra: m21.base.Music21Object) -> str: - # object classes that have text content in a single field if isinstance(extra, (m21.key.Key, m21.key.KeySignature)): - return f'KS:{extra.sharps}' + return M21Utils.keysig_to_string(extra) if isinstance(extra, m21.expressions.TextExpression): - return f'TX:{extra.content}' + return M21Utils.textexp_to_string(extra) if isinstance(extra, m21.dynamics.Dynamic): - return f'DY:{extra.value}' - - # object classes whose text is derived from class name - if isinstance(extra, m21.dynamics.Diminuendo): - return 'DY:>' - if isinstance(extra, m21.dynamics.Crescendo): - return 'DY:<' - - # object classes that have several fields to be combined into string + return M21Utils.dynamic_to_string(extra) + if isinstance(extra, m21.dynamics.DynamicWedge): + return M21Utils.dynwedge_to_string(extra) if isinstance(extra, m21.clef.Clef): return M21Utils.clef_to_string(extra) if isinstance(extra, m21.meter.TimeSignature): diff --git a/musicdiff/visualization.py b/musicdiff/visualization.py index 28eb35b..e96162e 100644 --- a/musicdiff/visualization.py +++ b/musicdiff/visualization.py @@ -411,6 +411,86 @@ def mark_diffs( 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) + # 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 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) + # 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 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) + # 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 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) + # 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 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) + # 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 note style") + 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 style") + textExp.style.color = Visualization.CHANGED_COLOR + note2.activeSite.insert(note2.offset, textExp) + # accident elif op[0] == "accidentins": assert isinstance(op[1], AnnNote) From 570cbd3d74bf97f1011bd62b19071275377a5e38 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 23 Mar 2022 12:13:02 -0700 Subject: [PATCH 2/9] Don't create style info by looking at it. --- musicdiff/annotation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 49199e3..1ffb651 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -35,7 +35,9 @@ def __init__(self, general_note, enhanced_beam_list, tuplet_list): self.general_note = general_note.id self.beamings = enhanced_beam_list self.tuplets = tuplet_list - self.stylestr: str = M21Utils.style_to_string(general_note.style) + self.stylestr: str = '' + if general_note.hasStyleInformation: + self.stylestr = M21Utils.style_to_string(general_note.style) self.noteshape: str = 'normal' self.noteheadFill: Optional[bool] = None self.noteheadParenthesis: bool = False From 2218ddca655cf8bdf5aeb3edeaf453a162cbe5f3 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 23 Mar 2022 13:22:34 -0700 Subject: [PATCH 3/9] Better visualization of style differences (e.g. "changed note color"). --- musicdiff/annotation.py | 2 + musicdiff/m21utils.py | 104 +++++++++++++++++++++++++++++++------ musicdiff/visualization.py | 26 +++++++--- 3 files changed, 110 insertions(+), 22 deletions(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 1ffb651..544b179 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -38,6 +38,8 @@ def __init__(self, general_note, enhanced_beam_list, tuplet_list): self.stylestr: str = '' if general_note.hasStyleInformation: self.stylestr = M21Utils.style_to_string(general_note.style) + # styledict is for visualization details only. Comparison is done with stylestr. + self.styledict = M21Utils.style_to_dict(general_note.style) self.noteshape: str = 'normal' self.noteheadFill: Optional[bool] = None self.noteheadParenthesis: bool = False diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index 838df61..eb5415a 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -679,38 +679,31 @@ def genericstyle_to_string(style: m21.style.Style) -> str: if generic: generic += ',' generic += f'size={style.size}' - if style.relativeX: + if style.relativeX is not None: if generic: generic += ',' generic += f'relX={style.relativeX}' - if style.relativeY: + if style.relativeY is not None: if generic: generic += ',' generic += f'relY={style.relativeY}' - # Most objects default to style.absX & absY of None, but - # Dynamic objects default to absX = -36 and absY = -80 - if style.absoluteX and style.absoluteX != -36: + if style.absoluteX is not None: if generic: generic += ',' generic += f'absX={style.absoluteX}' - if style.absoluteY and style.absoluteY != -80: + if style.absoluteY is not None: if generic: generic += ',' - if style.absoluteY == 10: - generic += 'absY=above' - elif style.absoluteY == -70: - generic += 'absY=below' - else: - generic += f'absY={style.absoluteY}' - if style.enclosure: + generic += f'absY={style.absoluteY}' + if style.enclosure is not None: if generic: generic += ',' generic += f'encl={style.enclosure}' - if style.fontRepresentation: + if style.fontRepresentation is not None: if generic: generic += ',' generic += f'fontrep={style.fontRepresentation}' - if style.color: + if style.color is not None: if generic: generic += ',' generic += f'color={style.color}' @@ -724,6 +717,87 @@ def genericstyle_to_string(style: m21.style.Style) -> str: generic += 'hidden' return generic + @staticmethod + def notestyle_to_dict(style: m21.style.NoteStyle) -> dict: + output: dict = {} + + if style.stemStyle is not None: + output['stemstyle'] = M21Utils.genericstyle_to_dict(style.stemStyle) + + if style.accidentalStyle is not None: + output['accidstyle'] = M21Utils.genericstyle_to_dict(style.accidentalStyle) + + if style.noteSize: + output['size'] = style.noteSize + + return output + + @staticmethod + def textstyle_to_dict(style: m21.style.TextStyle) -> dict: + output: dict = {} + + if isinstance(style, m21.style.TextStylePlacement) and style.placement: + output['placement'] = style.placement + if style.fontFamily: + output['fontFamily'] = style.fontFamily + if style.fontSize is not None: + output['fontSize'] = style.fontSize + if style.fontStyle is not None and style.fontStyle != 'normal': + output['fontStyle'] = style.fontStyle + if style.fontWeight is not None and style.fontWeight != 'normal': + output['fontWeight'] = style.fontWeight + if style.letterSpacing is not None and style.letterSpacing != 'normal': + output['letterSpacing'] = style.letterSpacing + + return output + + @staticmethod + def genericstyle_to_dict(style: m21.style.Style) -> dict: + output: dict = {} + if style.size is not None: + output['size'] = style.size + if style.relativeX is not None: + output['relX'] = style.relativeX + if style.relativeY is not None: + output['relY'] = style.relativeY + if style.absoluteX is not None: + output['absX'] = style.absoluteX + if style.absoluteY is not None: + output['absY'] = style.absoluteY + if style.enclosure is not None: + output['encl'] = style.enclosure + if style.fontRepresentation is not None: + output['fontrep'] = style.fontRepresentation + if style.color is not None: + output['color'] = style.color + if style.units != 'tenths': + output['units'] = style.units + if style.hideObjectOnPrint: + output['hidden'] = True + return output + + @staticmethod + def specificstyle_to_dict(style: m21.style.Style) -> dict: + if isinstance(style, m21.style.NoteStyle): + return M21Utils.notestyle_to_dict(style) + if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement + return M21Utils.textstyle_to_dict(style) + if isinstance(style, m21.style.BezierStyle): + return {} # M21Utils.bezierstyle_to_dict(style) + if isinstance(style, m21.style.LineStyle): + return {} # M21Utils.linestyle_to_dict(style) + if isinstance(style, m21.style.BeamStyle): + return {} # M21Utils.beamstyle_to_dict(style) + return {} + + @staticmethod + def style_to_dict(style: m21.style.Style) -> dict: + output: dict = M21Utils.genericstyle_to_dict(style) + specific = M21Utils.specificstyle_to_dict(style) + for k,v in specific.items(): + output[k] = v + return output + @staticmethod def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: stylestr: str = M21Utils.style_to_string(dynwedge.style) diff --git a/musicdiff/visualization.py b/musicdiff/visualization.py index e96162e..6747ace 100644 --- a/musicdiff/visualization.py +++ b/musicdiff/visualization.py @@ -414,7 +414,6 @@ def mark_diffs( elif op[0] == "editnoteshape": 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 note shape") @@ -430,7 +429,6 @@ def mark_diffs( elif op[0] == "editnoteheadfill": 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 note head fill") @@ -446,7 +444,6 @@ def mark_diffs( elif op[0] == "editnoteheadparenthesis": 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 note head paren") @@ -462,7 +459,6 @@ def mark_diffs( elif op[0] == "editstemdirection": 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 stem direction") @@ -478,16 +474,32 @@ def mark_diffs( elif op[0] == "editstyle": assert isinstance(op[1], AnnNote) assert isinstance(op[2], AnnNote) - # color the changed beam (in both scores) using Visualization.CHANGED_COLOR + 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("changed note style") + 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("changed note style") + textExp = m21.expressions.TextExpression(f"changed note {changedStr}") textExp.style.color = Visualization.CHANGED_COLOR note2.activeSite.insert(note2.offset, textExp) From fc93793b156730091528661854d3374f783157e9 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Sat, 26 Mar 2022 13:46:01 -0700 Subject: [PATCH 4/9] Add DetailLevel specification. Flesh out missing TextStyle stuff (e.g. alignVertical). --- musicdiff/__init__.py | 9 +- musicdiff/annotation.py | 51 +++++---- musicdiff/m21utils.py | 222 ++++++++++++++++++++++++++++++---------- 3 files changed, 206 insertions(+), 76 deletions(-) diff --git a/musicdiff/__init__.py b/musicdiff/__init__.py index 701490b..830da89 100644 --- a/musicdiff/__init__.py +++ b/musicdiff/__init__.py @@ -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 @@ -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 @@ -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 AllObjects). Returns: 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], 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 diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 544b179..41e9eb2 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -20,10 +20,10 @@ 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. @@ -35,8 +35,10 @@ def __init__(self, general_note, enhanced_beam_list, tuplet_list): self.general_note = general_note.id self.beamings = enhanced_beam_list self.tuplets = tuplet_list + self.stylestr: str = '' - if general_note.hasStyleInformation: + self.styledict: dict = {} + if detail >= DetailLevel.AllObjectsWithStyle and general_note.hasStyleInformation: self.stylestr = M21Utils.style_to_string(general_note.style) # styledict is for visualization details only. Comparison is done with stylestr. self.styledict = M21Utils.style_to_dict(general_note.style) @@ -44,7 +46,7 @@ def __init__(self, general_note, enhanced_beam_list, tuplet_list): self.noteheadFill: Optional[bool] = None self.noteheadParenthesis: bool = False self.stemDirection: str = 'unspecified' - if isinstance(general_note, m21.note.NotRest): + 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 @@ -207,7 +209,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. @@ -231,7 +233,7 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s else: self.offset = float(extra.getOffsetInHierarchy(measure)) self.duration = float(extra.duration.quarterLength) - self.content: str = M21Utils.extra_to_string(extra) + self.content: str = M21Utils.extra_to_string(extra, detail) self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday # precomputed representations for faster comparison @@ -262,7 +264,7 @@ def __eq__(self, other): 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. @@ -288,7 +290,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) @@ -342,7 +344,10 @@ 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. @@ -354,23 +359,24 @@ def __init__(self, measure, score, spannerBundle): 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__()) @@ -419,7 +425,10 @@ 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. @@ -429,7 +438,7 @@ def __init__(self, part, score, spannerBundle): 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) @@ -475,7 +484,7 @@ 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" @@ -484,10 +493,10 @@ def __init__(self, score): """ 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) diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index eb5415a..1be758d 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -15,9 +15,25 @@ import math import sys from typing import List, Union +from enum import IntEnum, auto + # import sys import music21 as m21 +class DetailLevel(IntEnum): + # Chords, Notes, Rests, Unpitched, etc (and their beams/expressions/articulations) + GeneralNotesOnly = auto() + + # Add in Clefs, TextExpressions, Key/KeySignatures, Barlines/Repeats, TimeSignatures, TempoIndications, etc + AllObjects = auto() + + # All of the above, plus typographical stuff: placement, stem direction, + # color, italic/bold, Style, etc + AllObjectsWithStyle = auto() + + Default = AllObjects + + class M21Utils: @staticmethod def get_beamings(note_list): @@ -405,7 +421,7 @@ def get_extras(measure: m21.stream.Measure, spannerBundle: m21.spanner.SpannerBu # loop over the initialList, filtering out (and complaining about) things we # don't recognize. for el in initialList: - if M21Utils.extra_to_string(el) != '': + if M21Utils.extra_to_string(el, DetailLevel.AllObjects) != '': output.append(el) # we must add any Crescendo/Diminuendo spanners that start on GeneralNotes in this measure @@ -439,9 +455,10 @@ def safe_get(indexable, idx): return out @staticmethod - def clef_to_string(clef: m21.clef.Clef) -> str: + def clef_to_string(clef: m21.clef.Clef, + detail: DetailLevel = DetailLevel.Default) -> str: # sign(str), line(int), octaveChange(int == # octaves to shift up(+) or down(-)) - stylestr: str = M21Utils.style_to_string(clef.style) + stylestr: str = M21Utils.style_to_string(clef.style, detail) sign: str = '' if clef.sign is None else clef.sign line: str = '0' if clef.line is None else f'{clef.line}' octave: str = '' if clef.octaveChange == 0 else f'{8 * clef.octaveChange:+}' @@ -451,9 +468,10 @@ def clef_to_string(clef: m21.clef.Clef) -> str: return output @staticmethod - def timesig_to_string(timesig: m21.meter.TimeSignature) -> str: + def timesig_to_string(timesig: m21.meter.TimeSignature, + detail: DetailLevel = DetailLevel.Default) -> str: output: str = '' - stylestr: str = M21Utils.style_to_string(timesig.style) + stylestr: str = M21Utils.style_to_string(timesig.style, detail) if not timesig.symbol: output = f'TS:{timesig.numerator}/{timesig.denominator}' @@ -470,17 +488,18 @@ def timesig_to_string(timesig: m21.meter.TimeSignature) -> str: return output @staticmethod - def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: + def tempo_to_string(mm: m21.tempo.TempoIndication, + detail: DetailLevel) -> str: # pylint: disable=protected-access # We need direct access to mm._textExpression and mm._tempoText, to avoid # the extra formatting that referencing via the .text property will perform. - stylestr: str = M21Utils.style_to_string(mm.style) + stylestr: str = M21Utils.style_to_string(mm.style, detail) output: str = '' if isinstance(mm, m21.tempo.TempoText): if mm._textExpression is None: output = 'MM:' else: - output = f'MM:{M21Utils.extra_to_string(mm._textExpression)}' + output = f'MM:{M21Utils.extra_to_string(mm._textExpression, detail)}' if stylestr: return f'{output} ({stylestr})' @@ -506,24 +525,25 @@ def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: output = 'MM:' else: # no 'MM:' prefix, TempoText adds their own - output = f'{M21Utils.tempo_to_string(mm._tempoText)}' + output = f'{M21Utils.tempo_to_string(mm._tempoText, detail)}' if stylestr: return f'{output} ({stylestr})' 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, detail)} {mm.referent.fullName}={float(mm.number)}' if stylestr: return f'{output} ({stylestr})' return output # pylint: enable=protected-access @staticmethod - def barline_to_string(barline: m21.bar.Barline) -> str: + def barline_to_string(barline: m21.bar.Barline, + detail: DetailLevel = DetailLevel.Default) -> str: # for all Barlines: type, pause # for Repeat Barlines: direction, times - stylestr: str = M21Utils.style_to_string(barline.style) + stylestr: str = M21Utils.style_to_string(barline.style, detail) pauseStr: str = '' if barline.pause is not None: if isinstance(barline.pause, m21.expressions.Fermata): @@ -548,37 +568,41 @@ 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 keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature], + detail: DetailLevel = DetailLevel.Default) -> str: output: str = f'KS:{keysig.sharps}' - stylestr: str = M21Utils.style_to_string(keysig.style) + stylestr: str = M21Utils.style_to_string(keysig.style, detail) if stylestr: output += f' ({stylestr})' return output @staticmethod - def textexp_to_string(textexp: m21.expressions.TextExpression) -> str: + def textexp_to_string(textexp: m21.expressions.TextExpression, + detail: DetailLevel = DetailLevel.Default) -> str: output: str = f'TX:{textexp.content}' if textexp.placement is not None: output += f',placement={textexp.placement}' - stylestr: str = M21Utils.style_to_string(textexp.style) + stylestr: str = M21Utils.style_to_string(textexp.style, detail) if stylestr: output += f' ({stylestr})' return output @staticmethod - def dynamic_to_string(dynamic: m21.dynamics.Dynamic) -> str: + def dynamic_to_string(dynamic: m21.dynamics.Dynamic, + detail: DetailLevel = DetailLevel.Default) -> str: output: str = f'DY:{dynamic.value}' if dynamic.placement is not None: output += f',placement={dynamic.placement}' - stylestr: str = M21Utils.style_to_string(dynamic.style) + stylestr: str = M21Utils.style_to_string(dynamic.style, detail) if stylestr: output += f' ({stylestr})' return output @staticmethod - def style_to_string(style: m21.style.Style) -> str: - generic: str = M21Utils.genericstyle_to_string(style) - specific: str = M21Utils.specificstyle_to_string(style) + def style_to_string(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> str: + generic: str = M21Utils.genericstyle_to_string(style, detail) + specific: str = M21Utils.specificstyle_to_string(style, detail) if specific and generic: return f'{specific},{generic}' if specific: @@ -588,7 +612,11 @@ def style_to_string(style: m21.style.Style) -> str: return '' @staticmethod - def notestyle_to_string(style: m21.style.NoteStyle) -> str: + def notestyle_to_string(style: m21.style.NoteStyle, + detail: DetailLevel = DetailLevel.Default) -> str: + if detail < DetailLevel.AllObjectsWithStyle: + return '' + output: str = '' stem: str = '' accidental: str = '' @@ -615,7 +643,11 @@ def notestyle_to_string(style: m21.style.NoteStyle) -> str: return output @staticmethod - def textstyle_to_string(style: m21.style.TextStyle) -> str: + def textstyle_to_string(style: m21.style.TextStyle, + detail: DetailLevel = DetailLevel.Default) -> str: + if detail < DetailLevel.AllObjectsWithStyle: + return '' + output: str = '' placement: str = '' # None/string ('above', etc) # (only exists in TextStylePlacement) @@ -624,6 +656,14 @@ def textstyle_to_string(style: m21.style.TextStyle) -> str: fontStyle: str = '' # None/'normal'/'italic'/'bold'/'bolditalic' fontWeight: str = '' # None/'normal'/'bold' letterSpacing: str = '' # None/'normal'/float + lineHeight: str = '' + textDirection: str = '' + textRotation: str = '' + language: str = '' + textDecoration: str = '' + justify: str = '' + alignHorizontal: str = '' + alignVertical: str = '' if isinstance(style, m21.style.TextStylePlacement) and style.placement: placement = f'placement={style.placement}' @@ -655,25 +695,70 @@ def textstyle_to_string(style: m21.style.TextStyle) -> str: if output: output += ',' output += letterSpacing + if style.lineHeight: + lineHeight = f'lineHeight={style.lineHeight}' + if output: + output += ',' + output += lineHeight + if style.textDirection: + textDirection = f'textDirection={style.textDirection}' + if output: + output += ',' + output += textDirection + if style.textRotation: + textRotation = f'textRotation={style.textRotation}' + if output: + output += ',' + output += textRotation + if style.language: + language = f'language={style.language}' + if output: + output += ',' + output += language + if style.textDecoration: + textDecoration = f'textDecoration={style.textDecoration}' + if output: + output += ',' + output += textDecoration + if style.justify: + justify = f'justify={style.justify}' + if output: + output += ',' + output += justify + if style.alignHorizontal: + alignHorizontal = f'alignHorizontal={style.alignHorizontal}' + if output: + output += ',' + output += alignHorizontal + if style.alignVertical: + alignVertical = f'alignVertical={style.alignVertical}' + if output: + output += ',' + output += alignVertical return output @staticmethod - def specificstyle_to_string(style: m21.style.Style) -> str: + def specificstyle_to_string(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> str: if isinstance(style, m21.style.NoteStyle): - return M21Utils.notestyle_to_string(style) + return M21Utils.notestyle_to_string(style, detail) if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement - return M21Utils.textstyle_to_string(style) + return M21Utils.textstyle_to_string(style, detail) if isinstance(style, m21.style.BezierStyle): - return '' # M21Utils.bezierstyle_to_string(style) + return '' # M21Utils.bezierstyle_to_string(style, detail) if isinstance(style, m21.style.LineStyle): - return '' # M21Utils.linestyle_to_string(style) + return '' # M21Utils.linestyle_to_string(style, detail) if isinstance(style, m21.style.BeamStyle): - return '' # M21Utils.beamstyle_to_string(style) + return '' # M21Utils.beamstyle_to_string(style, detail) return '' @staticmethod - def genericstyle_to_string(style: m21.style.Style) -> str: + def genericstyle_to_string(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> str: + if detail < DetailLevel.AllObjectsWithStyle: + return '' + generic: str = '' if style.size is not None: if generic: @@ -718,7 +803,11 @@ def genericstyle_to_string(style: m21.style.Style) -> str: return generic @staticmethod - def notestyle_to_dict(style: m21.style.NoteStyle) -> dict: + def notestyle_to_dict(style: m21.style.NoteStyle, + detail: DetailLevel = DetailLevel.Default) -> dict: + if detail < DetailLevel.AllObjectsWithStyle: + return {} + output: dict = {} if style.stemStyle is not None: @@ -733,7 +822,11 @@ def notestyle_to_dict(style: m21.style.NoteStyle) -> dict: return output @staticmethod - def textstyle_to_dict(style: m21.style.TextStyle) -> dict: + def textstyle_to_dict(style: m21.style.TextStyle, + detail: DetailLevel = DetailLevel.Default) -> dict: + if detail < DetailLevel.AllObjectsWithStyle: + return {} + output: dict = {} if isinstance(style, m21.style.TextStylePlacement) and style.placement: @@ -748,11 +841,31 @@ def textstyle_to_dict(style: m21.style.TextStyle) -> dict: output['fontWeight'] = style.fontWeight if style.letterSpacing is not None and style.letterSpacing != 'normal': output['letterSpacing'] = style.letterSpacing + if style.lineHeight: + output['lineHeight'] = style.lineHeight + if style.textDirection: + output['textDirection'] = style.textDirection + if style.textRotation: + output['textRotation'] = style.textRotation + if style.language: + output['language'] = style.language + if style.textDecoration: + output['textDecoration'] = style.textDecoration + if style.justify: + output['justify'] = style.justify + if style.alignHorizontal: + output['alignHorizontal'] = style.alignHorizontal + if style.alignVertical: + output['alignVertical'] = style.alignVertical return output @staticmethod - def genericstyle_to_dict(style: m21.style.Style) -> dict: + def genericstyle_to_dict(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> dict: + if detail < DetailLevel.AllObjectsWithStyle: + return {} + output: dict = {} if style.size is not None: output['size'] = style.size @@ -777,30 +890,33 @@ def genericstyle_to_dict(style: m21.style.Style) -> dict: return output @staticmethod - def specificstyle_to_dict(style: m21.style.Style) -> dict: + def specificstyle_to_dict(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> dict: if isinstance(style, m21.style.NoteStyle): - return M21Utils.notestyle_to_dict(style) + return M21Utils.notestyle_to_dict(style, detail) if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement - return M21Utils.textstyle_to_dict(style) + return M21Utils.textstyle_to_dict(style, detail) if isinstance(style, m21.style.BezierStyle): - return {} # M21Utils.bezierstyle_to_dict(style) + return {} # M21Utils.bezierstyle_to_dict(style, detail) if isinstance(style, m21.style.LineStyle): - return {} # M21Utils.linestyle_to_dict(style) + return {} # M21Utils.linestyle_to_dict(style, detail) if isinstance(style, m21.style.BeamStyle): - return {} # M21Utils.beamstyle_to_dict(style) + return {} # M21Utils.beamstyle_to_dict(style, detail) return {} @staticmethod - def style_to_dict(style: m21.style.Style) -> dict: - output: dict = M21Utils.genericstyle_to_dict(style) - specific = M21Utils.specificstyle_to_dict(style) + def style_to_dict(style: m21.style.Style, + detail: DetailLevel = DetailLevel.Default) -> dict: + output: dict = M21Utils.genericstyle_to_dict(style, detail) + specific = M21Utils.specificstyle_to_dict(style, detail) for k,v in specific.items(): output[k] = v return output @staticmethod - def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: - stylestr: str = M21Utils.style_to_string(dynwedge.style) + def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge, + detail: DetailLevel = DetailLevel.Default) -> str: + stylestr: str = M21Utils.style_to_string(dynwedge.style, detail) output: str = '' if isinstance(dynwedge, m21.dynamics.Crescendo): output = '<' @@ -814,23 +930,23 @@ def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: return f'DY:{output}' @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) + return M21Utils.keysig_to_string(extra, detail) if isinstance(extra, m21.expressions.TextExpression): - return M21Utils.textexp_to_string(extra) + return M21Utils.textexp_to_string(extra, detail) if isinstance(extra, m21.dynamics.Dynamic): - return M21Utils.dynamic_to_string(extra) + return M21Utils.dynamic_to_string(extra, detail) if isinstance(extra, m21.dynamics.DynamicWedge): - return M21Utils.dynwedge_to_string(extra) + return M21Utils.dynwedge_to_string(extra, detail) if isinstance(extra, m21.clef.Clef): - return M21Utils.clef_to_string(extra) + return M21Utils.clef_to_string(extra, detail) if isinstance(extra, m21.meter.TimeSignature): - return M21Utils.timesig_to_string(extra) + return M21Utils.timesig_to_string(extra, detail) if isinstance(extra, m21.tempo.TempoIndication): - return M21Utils.tempo_to_string(extra) + return M21Utils.tempo_to_string(extra, detail) if isinstance(extra, m21.bar.Barline): - return M21Utils.barline_to_string(extra) + return M21Utils.barline_to_string(extra, detail) print(f'Unexpected extra: {extra.classes[0]}', file=sys.stderr) return '' From 9cbfaf2bda5ce2dc053121e0bc82563814c20b0c Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 29 Mar 2022 14:16:33 -0700 Subject: [PATCH 5/9] Placement should only be diffed if DetailLevel >= AllObjectsWithStyle. --- musicdiff/m21utils.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index 1be758d..d103a5e 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -580,8 +580,10 @@ def keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature], def textexp_to_string(textexp: m21.expressions.TextExpression, detail: DetailLevel = DetailLevel.Default) -> str: output: str = f'TX:{textexp.content}' - if textexp.placement is not None: - output += f',placement={textexp.placement}' + if detail >= DetailLevel.AllObjectsWithStyle: + # we treat placement as if it were part of style + if textexp.placement is not None: + output += f',placement={textexp.placement}' stylestr: str = M21Utils.style_to_string(textexp.style, detail) if stylestr: output += f' ({stylestr})' @@ -591,8 +593,10 @@ def textexp_to_string(textexp: m21.expressions.TextExpression, def dynamic_to_string(dynamic: m21.dynamics.Dynamic, detail: DetailLevel = DetailLevel.Default) -> str: output: str = f'DY:{dynamic.value}' - if dynamic.placement is not None: - output += f',placement={dynamic.placement}' + if detail >= DetailLevel.AllObjectsWithStyle: + # we treat placement as if it were part of style + if dynamic.placement is not None: + output += f',placement={dynamic.placement}' stylestr: str = M21Utils.style_to_string(dynamic.style, detail) if stylestr: output += f' ({stylestr})' From 3dc90cf4a91cafc4941bd0ad290fb1f147801fb1 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 29 Mar 2022 14:48:12 -0700 Subject: [PATCH 6/9] Add new command line argument -d/--detail to specify DetailLevel of diff requested. --- musicdiff/__init__.py | 2 +- musicdiff/__main__.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/musicdiff/__init__.py b/musicdiff/__init__.py index 830da89..4559b6b 100644 --- a/musicdiff/__init__.py +++ b/musicdiff/__init__.py @@ -76,7 +76,7 @@ def diff(score1: Union[str, Path, m21.stream.Score], (default is True) detail (DetailLevel): What level of detail to use during the diff. Can be GeneralNotesOnly, AllObjects, AllObjectsWithStyle or Default (Default is - currently AllObjects). + currently equivalent to AllObjects). Returns: int: The number of differences found (0 means the scores were identical, None means the diff failed) diff --git a/musicdiff/__main__.py b/musicdiff/__main__.py index ef4dc4c..33a784d 100644 --- a/musicdiff/__main__.py +++ b/musicdiff/__main__.py @@ -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 @@ -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) From 656f9256f832fea576885cabdb14a1cba62a88f0 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Tue, 29 Mar 2022 15:19:56 -0700 Subject: [PATCH 7/9] More complete note style diffing. Update API docs. --- musicdiff/annotation.py | 43 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 41e9eb2..186aa0b 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -31,6 +31,10 @@ 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). + """ self.general_note = general_note.id self.beamings = enhanced_beam_list @@ -52,6 +56,24 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple self.noteheadParenthesis = general_note.noteheadParenthesis self.stemDirection = general_note.stemDirection + # set these up as if they were part of style (in stylestr) + if self.noteshape is not None: + if self.stylestr: + self.stylestr += "," + self.stylestr += f"noteshape={self.noteshape}" + if self.noteheadFill is not None: + if self.stylestr: + self.stylestr += "," + self.stylestr += f"noteheadFill={self.noteheadFill}" + if self.noteheadParenthesis is not None: + if self.stylestr: + self.stylestr += "," + self.stylestr += f"noteheadParenthesis={self.noteheadParenthesis}" + if self.stemDirection is not None: + if self.stylestr: + self.stylestr += "," + self.stylestr += f"stemDirection={self.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: @@ -218,6 +240,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 @@ -233,7 +258,7 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s else: self.offset = float(extra.getOffsetInHierarchy(measure)) self.duration = float(extra.duration.quarterLength) - self.content: str = M21Utils.extra_to_string(extra, detail) + self.content: str = M21Utils.extra_to_string(extra, detail) # includes any style self._notation_size: int = 1 # so far, always 1, but maybe some extra will be bigger someday # precomputed representations for faster comparison @@ -270,6 +295,9 @@ def __init__(self, voice: m21.stream.Voice, detail: DetailLevel = DetailLevel.De 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) @@ -353,6 +381,11 @@ def __init__(self, measure: m21.stream.Measure, 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 = [] @@ -434,6 +467,11 @@ def __init__(self, part: m21.stream.Part, 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 = [] @@ -490,6 +528,9 @@ def __init__(self, score: m21.stream.Score, detail: DetailLevel = DetailLevel.De 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 = [] From 9a935e5a7b596eac95570d532701e861dfc8c203 Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 30 Mar 2022 11:43:31 -0700 Subject: [PATCH 8/9] Fix up detailed annotation of notes. --- musicdiff/annotation.py | 28 ++++++++++------------------ 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index 186aa0b..a435746 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -56,24 +56,6 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple self.noteheadParenthesis = general_note.noteheadParenthesis self.stemDirection = general_note.stemDirection - # set these up as if they were part of style (in stylestr) - if self.noteshape is not None: - if self.stylestr: - self.stylestr += "," - self.stylestr += f"noteshape={self.noteshape}" - if self.noteheadFill is not None: - if self.stylestr: - self.stylestr += "," - self.stylestr += f"noteheadFill={self.noteheadFill}" - if self.noteheadParenthesis is not None: - if self.stylestr: - self.stylestr += "," - self.stylestr += f"noteheadParenthesis={self.noteheadParenthesis}" - if self.stemDirection is not None: - if self.stylestr: - self.stylestr += "," - self.stylestr += f"stemDirection={self.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: @@ -192,6 +174,16 @@ 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}" + if self.stylestr: string += self.stylestr return string From fc43e5e23383c9a6755c19e9803e868dfbdbad6d Mon Sep 17 00:00:00 2001 From: Greg Chapman <75333244+gregchapman-dev@users.noreply.github.com> Date: Wed, 30 Mar 2022 20:30:09 -0700 Subject: [PATCH 9/9] Major cleanup of style diffing, fixing a few bugs along the way. --- musicdiff/annotation.py | 27 ++- musicdiff/comparison.py | 9 +- musicdiff/m21utils.py | 334 ++++++------------------------------- musicdiff/visualization.py | 38 ++++- 4 files changed, 111 insertions(+), 297 deletions(-) diff --git a/musicdiff/annotation.py b/musicdiff/annotation.py index a435746..270da18 100644 --- a/musicdiff/annotation.py +++ b/musicdiff/annotation.py @@ -42,10 +42,8 @@ def __init__(self, general_note: m21.note.GeneralNote, enhanced_beam_list, tuple self.stylestr: str = '' self.styledict: dict = {} - if detail >= DetailLevel.AllObjectsWithStyle and general_note.hasStyleInformation: - self.stylestr = M21Utils.style_to_string(general_note.style) - # styledict is for visualization details only. Comparison is done with stylestr. - self.styledict = M21Utils.style_to_dict(general_note.style) + 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 @@ -124,7 +122,7 @@ 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.stylestr}") + f"{self.styledict}") def __str__(self): """ @@ -184,8 +182,12 @@ def __str__(self): if self.stemDirection != 'unspecified': string += f"stemDirection={self.stemDirection}" - if self.stylestr: - string += self.stylestr + # 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): @@ -250,7 +252,10 @@ def __init__(self, extra: m21.base.Music21Object, measure: m21.stream.Measure, s else: self.offset = float(extra.getOffsetInHierarchy(measure)) self.duration = float(extra.duration.quarterLength) - self.content: str = M21Utils.extra_to_string(extra, detail) # includes any style + 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 @@ -273,7 +278,11 @@ 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! diff --git a/musicdiff/comparison.py b/musicdiff/comparison.py index 9755c8f..b954e91 100644 --- a/musicdiff/comparison.py +++ b/musicdiff/comparison.py @@ -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 @@ -648,8 +653,8 @@ def _annotated_note_diff(annNote1: AnnNote, annNote2: AnnNote): if annNote1.stemDirection != annNote2.stemDirection: cost += 1 op_list.append(("editstemdirection", annNote1, annNote2, 1)) - # add for the stylestr - if annNote1.stylestr != annNote2.stylestr: + # add for the styledict + if annNote1.styledict != annNote2.styledict: cost += 1 op_list.append(("editstyle", annNote1, annNote2, 1)) diff --git a/musicdiff/m21utils.py b/musicdiff/m21utils.py index d103a5e..1eaf5d2 100644 --- a/musicdiff/m21utils.py +++ b/musicdiff/m21utils.py @@ -421,7 +421,7 @@ def get_extras(measure: m21.stream.Measure, spannerBundle: m21.spanner.SpannerBu # loop over the initialList, filtering out (and complaining about) things we # don't recognize. for el in initialList: - if M21Utils.extra_to_string(el, DetailLevel.AllObjects) != '': + if M21Utils.extra_to_string(el) != '': output.append(el) # we must add any Crescendo/Diminuendo spanners that start on GeneralNotes in this measure @@ -455,23 +455,17 @@ def safe_get(indexable, idx): return out @staticmethod - def clef_to_string(clef: m21.clef.Clef, - detail: DetailLevel = DetailLevel.Default) -> str: + def clef_to_string(clef: m21.clef.Clef) -> str: # sign(str), line(int), octaveChange(int == # octaves to shift up(+) or down(-)) - stylestr: str = M21Utils.style_to_string(clef.style, detail) sign: str = '' if clef.sign is None else clef.sign line: str = '0' if clef.line is None else f'{clef.line}' octave: str = '' if clef.octaveChange == 0 else f'{8 * clef.octaveChange:+}' output: str = f'CL:{sign}{line}{octave}' - if stylestr: - return f'{output} ({stylestr})' return output @staticmethod - def timesig_to_string(timesig: m21.meter.TimeSignature, - detail: DetailLevel = DetailLevel.Default) -> str: + def timesig_to_string(timesig: m21.meter.TimeSignature) -> str: output: str = '' - stylestr: str = M21Utils.style_to_string(timesig.style, detail) if not timesig.symbol: output = f'TS:{timesig.numerator}/{timesig.denominator}' @@ -482,27 +476,19 @@ def timesig_to_string(timesig: m21.meter.TimeSignature, else: output = f'TS:{timesig.numerator}/{timesig.denominator}' - if stylestr: - return f'{output} ({stylestr})' - return output @staticmethod - def tempo_to_string(mm: m21.tempo.TempoIndication, - detail: DetailLevel) -> str: + def tempo_to_string(mm: m21.tempo.TempoIndication) -> str: # pylint: disable=protected-access # We need direct access to mm._textExpression and mm._tempoText, to avoid # the extra formatting that referencing via the .text property will perform. - stylestr: str = M21Utils.style_to_string(mm.style, detail) output: str = '' if isinstance(mm, m21.tempo.TempoText): if mm._textExpression is None: output = 'MM:' else: - output = f'MM:{M21Utils.extra_to_string(mm._textExpression, detail)}' - - if stylestr: - return f'{output} ({stylestr})' + output = f'MM:{M21Utils.extra_to_string(mm._textExpression)}' return output if isinstance(mm, m21.tempo.MetricModulation): @@ -515,9 +501,6 @@ def tempo_to_string(mm: m21.tempo.TempoIndication, output = 'MM:' else: output = f'MM:{mm.referent.fullName}={float(mm.number)}' - - if stylestr: - return f'{output} ({stylestr})' return output if mm.numberImplicit is True or mm.number is None: @@ -525,25 +508,18 @@ def tempo_to_string(mm: m21.tempo.TempoIndication, output = 'MM:' else: # no 'MM:' prefix, TempoText adds their own - output = f'{M21Utils.tempo_to_string(mm._tempoText, detail)}' - - if stylestr: - return f'{output} ({stylestr})' + output = f'{M21Utils.tempo_to_string(mm._tempoText)}' return output # no 'MM:' prefix, TempoText adds their own - output = f'{M21Utils.tempo_to_string(mm._tempoText, detail)} {mm.referent.fullName}={float(mm.number)}' - if stylestr: - return f'{output} ({stylestr})' + output = f'{M21Utils.tempo_to_string(mm._tempoText)} {mm.referent.fullName}={float(mm.number)}' return output # pylint: enable=protected-access @staticmethod - def barline_to_string(barline: m21.bar.Barline, - detail: DetailLevel = DetailLevel.Default) -> str: + def barline_to_string(barline: m21.bar.Barline) -> str: # for all Barlines: type, pause # for Repeat Barlines: direction, times - stylestr: str = M21Utils.style_to_string(barline.style, detail) pauseStr: str = '' if barline.pause is not None: if isinstance(barline.pause, m21.expressions.Fermata): @@ -553,8 +529,6 @@ def barline_to_string(barline: m21.bar.Barline, output: str = f'{barline.type}{pauseStr}' if not isinstance(barline, m21.bar.Repeat): - if stylestr: - return f'BL:{output} ({stylestr})' return f'BL:{output}' # add the Repeat fields (direction, times) @@ -562,250 +536,23 @@ def barline_to_string(barline: m21.bar.Barline, output += f' direction={barline.direction}' if barline.times is not None: output += f' times={barline.times}' - - if stylestr: - return f'RPT:{output} ({stylestr})' return f'RPT:{output}' @staticmethod - def keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature], - detail: DetailLevel = DetailLevel.Default) -> str: + def keysig_to_string(keysig: Union[m21.key.Key, m21.key.KeySignature]) -> str: output: str = f'KS:{keysig.sharps}' - stylestr: str = M21Utils.style_to_string(keysig.style, detail) - if stylestr: - output += f' ({stylestr})' return output @staticmethod - def textexp_to_string(textexp: m21.expressions.TextExpression, - detail: DetailLevel = DetailLevel.Default) -> str: + def textexp_to_string(textexp: m21.expressions.TextExpression) -> str: output: str = f'TX:{textexp.content}' - if detail >= DetailLevel.AllObjectsWithStyle: - # we treat placement as if it were part of style - if textexp.placement is not None: - output += f',placement={textexp.placement}' - stylestr: str = M21Utils.style_to_string(textexp.style, detail) - if stylestr: - output += f' ({stylestr})' return output @staticmethod - def dynamic_to_string(dynamic: m21.dynamics.Dynamic, - detail: DetailLevel = DetailLevel.Default) -> str: + def dynamic_to_string(dynamic: m21.dynamics.Dynamic) -> str: output: str = f'DY:{dynamic.value}' - if detail >= DetailLevel.AllObjectsWithStyle: - # we treat placement as if it were part of style - if dynamic.placement is not None: - output += f',placement={dynamic.placement}' - stylestr: str = M21Utils.style_to_string(dynamic.style, detail) - if stylestr: - output += f' ({stylestr})' - return output - - @staticmethod - def style_to_string(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> str: - generic: str = M21Utils.genericstyle_to_string(style, detail) - specific: str = M21Utils.specificstyle_to_string(style, detail) - if specific and generic: - return f'{specific},{generic}' - if specific: - return specific - if generic: - return generic - return '' - - @staticmethod - def notestyle_to_string(style: m21.style.NoteStyle, - detail: DetailLevel = DetailLevel.Default) -> str: - if detail < DetailLevel.AllObjectsWithStyle: - return '' - - output: str = '' - stem: str = '' - accidental: str = '' - size: str = '' - - if style.stemStyle is not None: - stem = f'stemstyle=({M21Utils.genericstyle_to_string(style.stemStyle)})' - if output: - output += ',' - output += stem - - if style.accidentalStyle is not None: - accidental = f'accidstyle=({M21Utils.genericstyle_to_string(style.accidentalStyle)})' - if output: - output += ',' - output += accidental - - if style.noteSize: - size = f'size={style.noteSize}' - if output: - output += ',' - output += size - - return output - - @staticmethod - def textstyle_to_string(style: m21.style.TextStyle, - detail: DetailLevel = DetailLevel.Default) -> str: - if detail < DetailLevel.AllObjectsWithStyle: - return '' - - output: str = '' - placement: str = '' # None/string ('above', etc) - # (only exists in TextStylePlacement) - fontFamily: str = '' # None or list of strings (font names) - fontSize: str = '' # None/int/float/str(CSS font size) - fontStyle: str = '' # None/'normal'/'italic'/'bold'/'bolditalic' - fontWeight: str = '' # None/'normal'/'bold' - letterSpacing: str = '' # None/'normal'/float - lineHeight: str = '' - textDirection: str = '' - textRotation: str = '' - language: str = '' - textDecoration: str = '' - justify: str = '' - alignHorizontal: str = '' - alignVertical: str = '' - - if isinstance(style, m21.style.TextStylePlacement) and style.placement: - placement = f'placement={style.placement}' - if output: - output += ',' - output += placement - if style.fontFamily: - fontFamily = f'fontFamily={style.fontFamily}' - if output: - output += ',' - output += fontFamily - if style.fontSize is not None: - fontSize = f'fontSize={style.fontSize}' - if output: - output += ',' - output += fontSize - if style.fontStyle is not None and style.fontStyle != 'normal': - fontStyle = f'fontStyle={style.fontStyle}' - if output: - output += ',' - output += fontStyle - if style.fontWeight is not None and style.fontWeight != 'normal': - fontWeight = f'fontWeight={style.fontWeight}' - if output: - output += ',' - output += fontWeight - if style.letterSpacing is not None and style.letterSpacing != 'normal': - letterSpacing = f'letterSpacing={style.letterSpacing}' - if output: - output += ',' - output += letterSpacing - if style.lineHeight: - lineHeight = f'lineHeight={style.lineHeight}' - if output: - output += ',' - output += lineHeight - if style.textDirection: - textDirection = f'textDirection={style.textDirection}' - if output: - output += ',' - output += textDirection - if style.textRotation: - textRotation = f'textRotation={style.textRotation}' - if output: - output += ',' - output += textRotation - if style.language: - language = f'language={style.language}' - if output: - output += ',' - output += language - if style.textDecoration: - textDecoration = f'textDecoration={style.textDecoration}' - if output: - output += ',' - output += textDecoration - if style.justify: - justify = f'justify={style.justify}' - if output: - output += ',' - output += justify - if style.alignHorizontal: - alignHorizontal = f'alignHorizontal={style.alignHorizontal}' - if output: - output += ',' - output += alignHorizontal - if style.alignVertical: - alignVertical = f'alignVertical={style.alignVertical}' - if output: - output += ',' - output += alignVertical - return output - @staticmethod - def specificstyle_to_string(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> str: - if isinstance(style, m21.style.NoteStyle): - return M21Utils.notestyle_to_string(style, detail) - if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement - return M21Utils.textstyle_to_string(style, detail) - if isinstance(style, m21.style.BezierStyle): - return '' # M21Utils.bezierstyle_to_string(style, detail) - if isinstance(style, m21.style.LineStyle): - return '' # M21Utils.linestyle_to_string(style, detail) - if isinstance(style, m21.style.BeamStyle): - return '' # M21Utils.beamstyle_to_string(style, detail) - return '' - - @staticmethod - def genericstyle_to_string(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> str: - if detail < DetailLevel.AllObjectsWithStyle: - return '' - - generic: str = '' - if style.size is not None: - if generic: - generic += ',' - generic += f'size={style.size}' - if style.relativeX is not None: - if generic: - generic += ',' - generic += f'relX={style.relativeX}' - if style.relativeY is not None: - if generic: - generic += ',' - generic += f'relY={style.relativeY}' - if style.absoluteX is not None: - if generic: - generic += ',' - generic += f'absX={style.absoluteX}' - if style.absoluteY is not None: - if generic: - generic += ',' - generic += f'absY={style.absoluteY}' - if style.enclosure is not None: - if generic: - generic += ',' - generic += f'encl={style.enclosure}' - if style.fontRepresentation is not None: - if generic: - generic += ',' - generic += f'fontrep={style.fontRepresentation}' - if style.color is not None: - if generic: - generic += ',' - generic += f'color={style.color}' - if style.units != 'tenths': - if generic: - generic += ',' - generic += f'units={style.units}' - if style.hideObjectOnPrint: - if generic: - generic += ',' - generic += 'hidden' - return generic - @staticmethod def notestyle_to_dict(style: m21.style.NoteStyle, detail: DetailLevel = DetailLevel.Default) -> dict: @@ -896,6 +643,9 @@ def genericstyle_to_dict(style: m21.style.Style, @staticmethod def specificstyle_to_dict(style: m21.style.Style, detail: DetailLevel = DetailLevel.Default) -> dict: + if detail < DetailLevel.AllObjectsWithStyle: + return {} + if isinstance(style, m21.style.NoteStyle): return M21Utils.notestyle_to_dict(style, detail) if isinstance(style, m21.style.TextStyle): # includes TextStylePlacement @@ -909,18 +659,29 @@ def specificstyle_to_dict(style: m21.style.Style, return {} @staticmethod - def style_to_dict(style: m21.style.Style, - detail: DetailLevel = DetailLevel.Default) -> dict: - output: dict = M21Utils.genericstyle_to_dict(style, detail) - specific = M21Utils.specificstyle_to_dict(style, detail) - for k,v in specific.items(): - output[k] = v + def obj_to_styledict(obj: m21.base.Music21Object, + detail: DetailLevel = DetailLevel.Default) -> dict: + if detail < DetailLevel.AllObjectsWithStyle: + 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(): + output[k] = v + + if hasattr(obj, 'placement') and obj.placement is not None: + if 'placement' in output: + # style was a TextStylePlacement, with placement specified + print('placement specified twice, taking the one in .style', file=sys.stderr) + else: + output['placement'] = obj.placement + return output @staticmethod - def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge, - detail: DetailLevel = DetailLevel.Default) -> str: - stylestr: str = M21Utils.style_to_string(dynwedge.style, detail) + def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge) -> str: output: str = '' if isinstance(dynwedge, m21.dynamics.Crescendo): output = '<' @@ -928,29 +689,32 @@ def dynwedge_to_string(dynwedge: m21.dynamics.DynamicWedge, output = '>' else: output = 'wedge' - - if stylestr: - return f'DY:{output} ({stylestr})' return f'DY:{output}' @staticmethod - def extra_to_string(extra: m21.base.Music21Object, detail: DetailLevel = DetailLevel.Default) -> str: + def extra_to_string(extra: m21.base.Music21Object) -> str: if isinstance(extra, (m21.key.Key, m21.key.KeySignature)): - return M21Utils.keysig_to_string(extra, detail) + return M21Utils.keysig_to_string(extra) if isinstance(extra, m21.expressions.TextExpression): - return M21Utils.textexp_to_string(extra, detail) + return M21Utils.textexp_to_string(extra) if isinstance(extra, m21.dynamics.Dynamic): - return M21Utils.dynamic_to_string(extra, detail) + return M21Utils.dynamic_to_string(extra) if isinstance(extra, m21.dynamics.DynamicWedge): - return M21Utils.dynwedge_to_string(extra, detail) + return M21Utils.dynwedge_to_string(extra) if isinstance(extra, m21.clef.Clef): - return M21Utils.clef_to_string(extra, detail) + return M21Utils.clef_to_string(extra) if isinstance(extra, m21.meter.TimeSignature): - return M21Utils.timesig_to_string(extra, detail) + return M21Utils.timesig_to_string(extra) if isinstance(extra, m21.tempo.TempoIndication): - return M21Utils.tempo_to_string(extra, detail) + return M21Utils.tempo_to_string(extra) if isinstance(extra, m21.bar.Barline): - return M21Utils.barline_to_string(extra, detail) + return M21Utils.barline_to_string(extra) print(f'Unexpected extra: {extra.classes[0]}', file=sys.stderr) return '' + + @staticmethod + def has_style(obj: m21.base.Music21Object) -> 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 6747ace..70740e7 100644 --- a/musicdiff/visualization.py +++ b/musicdiff/visualization.py @@ -221,6 +221,43 @@ def mark_diffs( 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) @@ -490,7 +527,6 @@ def mark_diffs( 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}")