From 51d5c739100f4f5ea538d3791fc42d3f0cc6d071 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 5 Jul 2026 18:55:22 -1000 Subject: [PATCH 1/2] Duration.__eq__: only quarterLength matters when expression is inferred Fixes #1970 When both Durations have expressionIsInferred=True, their type, dots, tuplets, and components can be freely re-expressed according to context, so only quarterLength needs to match for equality. Also corrects the expressionIsInferred docstring ("according to complex" -> "context"). AI-assisted (Claude) --- music21/duration.py | 87 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/music21/duration.py b/music21/duration.py index 02af148b0..9cd927380 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, 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 quarter note is equal to a half note in a 2:1 + tuplet, since both span one quarter length and both are free to be + re-expressed: + + >>> plainDur = duration.Duration(1.0) + >>> plainDur.type + 'quarter' + >>> altDur = duration.Duration('half') + >>> altDur.appendTuplet(duration.Tuplet(2, 1)) + >>> altDur.quarterLength + 1.0 + >>> 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 From e919a55990e9872c26a7f577d3f5f044dbcd08e1 Mon Sep 17 00:00:00 2001 From: Michael Scott Asato Cuthbert Date: Sun, 5 Jul 2026 19:02:06 -1000 Subject: [PATCH 2/2] Duration.__eq__ doctest: use eighth vs dotted-eighth-triplet example Use a more standard case (a plain eighth vs a dotted eighth under a 3:2 triplet, both 0.5 QL) and note that inferred expressions arise e.g. when parsing from MIDI. AI-assisted (Claude) --- music21/duration.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/music21/duration.py b/music21/duration.py index 9cd927380..de957f688 100644 --- a/music21/duration.py +++ b/music21/duration.py @@ -1737,20 +1737,20 @@ def __eq__(self, other): >>> tupDur1 == tupDur2 False - If both durations have :attr:`expressionIsInferred` set to True, 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 quarter note is equal to a half note in a 2:1 - tuplet, since both span one quarter length and both are free to be - re-expressed: - - >>> plainDur = duration.Duration(1.0) + 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 - 'quarter' - >>> altDur = duration.Duration('half') - >>> altDur.appendTuplet(duration.Tuplet(2, 1)) + 'eighth' + >>> altDur = duration.Duration(type='eighth', dots=1) + >>> altDur.appendTuplet(duration.Tuplet(3, 2)) >>> altDur.quarterLength - 1.0 + 0.5 >>> altDur.expressionIsInferred = True >>> plainDur == altDur True