diff --git a/music21/duration.py b/music21/duration.py index 02af148b0..de957f688 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -1604,7 +1604,7 @@ class Duration(prebase.ProtoM21Object, SlottedObjectMixin): expression. For instance the duration of 0.5 is generally an eighth note, but in the middle of a triplet group might be better written as a dotted-eighth triplet. If expressionIsInferred - is True then `music21` can change it according to complex. If + is True then `music21` can change it according to context. If False, then the type, dots, and tuplets are considered immutable. >>> d = duration.Duration(0.5) @@ -1736,10 +1736,47 @@ def __eq__(self, other): >>> tupDur1.linked = False >>> tupDur1 == tupDur2 False + + If both durations have :attr:`expressionIsInferred` set to True (such as + if they were parsed from MIDI), then their expression (type, dots, + tuplets, and components) can be freely changed according to context, so + only the `quarterLength` needs to match. Here a plain eighth note is + equal to a dotted eighth under a 3:2 triplet, since both span half a + quarter length and both are free to be re-expressed: + + >>> plainDur = duration.Duration(0.5) + >>> plainDur.type + 'eighth' + >>> altDur = duration.Duration(type='eighth', dots=1) + >>> altDur.appendTuplet(duration.Tuplet(3, 2)) + >>> altDur.quarterLength + 0.5 + >>> altDur.expressionIsInferred = True + >>> plainDur == altDur + True + + But if either duration's expression is *not* inferred, then type, dots, + tuplets, and components must all match as before: + + >>> altDur.expressionIsInferred = False + >>> plainDur == altDur + False + + * Changed in v11: if both durations have `expressionIsInferred` True, + only `quarterLength` must match. ''' if type(other) is not type(self): return False + if self.linked != other.linked: + return False + + # When both expressions are inferred from quarterLength, callers are + # free to re-express the type, dots, tuplets, and components, so only + # the quarterLength is meaningful for equality. + if self.expressionIsInferred and other.expressionIsInferred: + return self.quarterLength == other.quarterLength + if self.isComplex != other.isComplex: return False if len(self.components) != len(other.components): @@ -1756,8 +1793,6 @@ def __eq__(self, other): return False if self.quarterLength != other.quarterLength: return False - if self.linked != other.linked: - return False return True @@ -4027,6 +4062,52 @@ def testExpressionIsInferred(self): # attribute assignments that could occur in any order self.assertEqual(d.expressionIsInferred, False) + def testEqualityExpressionIsInferred(self): + ''' + When both durations infer their expression from quarterLength, only + quarterLength must match for equality (GH #1970). + ''' + # A plain quarter note and a half note in a 2:1 tuplet both span one + # quarter length but differ in type and tuplets. + plainDur = Duration(1.0) + altDur = Duration('half') + altDur.appendTuplet(Tuplet(2, 1)) + self.assertEqual(altDur.quarterLength, 1.0) + self.assertNotEqual(plainDur.type, altDur.type) + self.assertNotEqual(plainDur.tuplets, altDur.tuplets) + + # altDur's expression is not inferred (built from a type), so strict + # comparison applies and they are not equal. + self.assertFalse(altDur.expressionIsInferred) + self.assertNotEqual(plainDur, altDur) + + # Marking altDur's expression as inferred relaxes the comparison to + # quarterLength only. + altDur.expressionIsInferred = True + self.assertEqual(plainDur, altDur) + self.assertEqual(altDur, plainDur) # symmetric + + # Differing quarterLength is still unequal even when both are inferred. + altDur2 = Duration(2.0) + self.assertTrue(altDur2.expressionIsInferred) + self.assertNotEqual(plainDur, altDur2) + + # Link status is still checked before the inference shortcut: an + # unlinked duration with the same quarterLength is never equal. + unlinkedDur = Duration(1.0) + unlinkedDur.linked = False + self.assertEqual(unlinkedDur.quarterLength, 1.0) + self.assertNotEqual(plainDur, unlinkedDur) + + # Inference must be mutual: one inferred, one not -> strict. + eighthDur = Duration(0.5) + typedQuarter = Duration(type='quarter') + self.assertFalse(typedQuarter.expressionIsInferred) + # Same type/dots/QL, so equal even without inference relaxation. + self.assertEqual(plainDur, typedQuarter) + # But an inferred eighth is not equal to a typed quarter. + self.assertNotEqual(eighthDur, typedQuarter) + def testExceptions(self): ''' These errors are user errors, so they raise generic exceptions