From 2cacfca64eb257947c3f69470b66b31fa70335ec Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 30 Jun 2026 11:41:20 +0200 Subject: [PATCH 1/3] Preserve microtones through Interval transposition Pitch.transpose() corrupted, or crashed on, pitches carrying a microtone. _diatonicTransposePitch measured interval2 from the microtonal source pitch, so the source cents leaked into halfStepsToFix and were dumped onto the accidental: e.g. C4(+30c) transposed by a perfect unison became C~4(-20c), and G#5(+50c) by an augmented unison raised AccidentalException ('2.5 is not a supported accidental type'). The dead 'centsOrigin' comment in the method showed this was an unfinished intent. Peel the source microtone off halfStepsToFix before it sets the accidental, then restore it as a microtone, guarded by 'if centsOrigin' so the common non-microtonal (performance-critical) path is unchanged. --- music21/interval.py | 14 +++++++++-- music21/test/test_interval.py | 45 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/music21/interval.py b/music21/interval.py index e90ad75a7e..d145c23c45 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -3460,7 +3460,7 @@ def _diatonicTransposePitch(self, pitch1 = p pitch2 = copy.deepcopy(pitch1) oldDiatonicNum = pitch1.diatonicNoteNum - # centsOrigin = pitch1.microtone.cents # unused!! + centsOrigin = pitch1.microtone.cents distanceToMove = self.diatonic.generic.staffDistance newDiatonicNumber = oldDiatonicNum + distanceToMove @@ -3475,8 +3475,14 @@ def _diatonicTransposePitch(self, # We have the right note name but not the right accidental interval2 = Interval(pitch1, pitch2) - # halfStepsToFix already has any microtones + # interval2 is measured from the microtonal pitch1, so halfStepsToFix + # carries the source microtone. Peel it off so it adjusts only the + # accidental; it is restored as a microtone once the accidental is set. + # Without this, the leaked cents corrupt the spelling (e.g. a +30c pitch + # transposed by P1 becomes a half-sharp) or raise AccidentalException. halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones + if centsOrigin: + halfStepsToFix = round(halfStepsToFix - centsOrigin / 100.0, 9) # environLocal.printDebug(['self', self, 'halfStepsToFix', halfStepsToFix, # 'centsOrigin', centsOrigin, 'interval2', interval2]) @@ -3527,6 +3533,10 @@ def _diatonicTransposePitch(self, and oldPitch2Accidental.name == 'natural'): pitch2.accidental = oldPitch2Accidental + # restore the source microtone that was peeled off halfStepsToFix above + if centsOrigin: + pitch2.microtone = pitch2.microtone.cents + centsOrigin + if useImplicitOctave: pitch2.octave = None diff --git a/music21/test/test_interval.py b/music21/test/test_interval.py index da17bb5b35..a747bb41a4 100644 --- a/music21/test/test_interval.py +++ b/music21/test/test_interval.py @@ -350,6 +350,51 @@ def testIntervalMicrotonesB(self): i = interval.Interval(note.Note('c4'), note.Note('c~4')) self.assertEqual(str(i), '') + def testTransposeMicrotonePreserved(self): + # A microtone (cents offset) must survive transposition unchanged and + # must not be turned into a spurious quarter-tone accidental. Formerly + # the source microtone leaked into the accidental computation, so e.g. + # C4(+30c) transposed by a unison became C~4(-20c), or larger microtones + # raised AccidentalException. + + # Transposing by a perfect unison is the identity. + p = pitch.Pitch('C4') + p.microtone = 30 + out = p.transpose(interval.Interval('P1')) + self.assertEqual(out.nameWithOctave, 'C4') + self.assertEqual(out.microtone.cents, 30) + + # A formerly-crashing case: G#5(+50c) transposed by an augmented unison. + p = pitch.Pitch('G#5') + p.microtone = 50 + out = p.transpose(interval.Interval('A1')) + self.assertEqual(out.nameWithOctave, 'G##5') + self.assertEqual(out.microtone.cents, 50) + + # A quarter-tone accidental and a microtone both survive a unison. + p = pitch.Pitch('C~4') + p.microtone = 30 + out = p.transpose(interval.Interval('P1')) + self.assertEqual(out.name, 'C~') + self.assertEqual(out.microtone.cents, 30) + + # Across intervals and microtone magnitudes: the cents are preserved, + # the sounding pitch moves by exactly the interval, and no quarter-tone + # accidental is introduced on an integer-semitone interval. + for iName in ['P1', 'm2', 'M3', 'P5', 'm7', 'P8', 'A4', 'd5']: + iv = interval.Interval(iName) + for cents in (-49, -25, 26, 49): + with self.subTest(interval=iName, cents=cents): + src = pitch.Pitch('C4') + src.microtone = cents + psBefore = src.ps + out = src.transpose(iv) + self.assertAlmostEqual(out.microtone.cents, cents) + self.assertAlmostEqual(out.ps - psBefore, + iv.chromatic.semitones) + if out.accidental is not None: + self.assertEqual(out.accidental.alter % 1, 0.0) + def testDescendingAugmentedUnison(self): ns = note.Note('C4') ne = note.Note('C-4') From a50d7692cd01cac567e52ea20c251c6d9494e60c Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 5 Jul 2026 10:09:44 +0200 Subject: [PATCH 2/3] Address review: gate centsOrigin on isTwelveTone and trim comments --- music21/interval.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/music21/interval.py b/music21/interval.py index d145c23c45..8f47d1bd1f 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -3460,7 +3460,10 @@ def _diatonicTransposePitch(self, pitch1 = p pitch2 = copy.deepcopy(pitch1) oldDiatonicNum = pitch1.diatonicNoteNum - centsOrigin = pitch1.microtone.cents + if not pitch1.isTwelveTone(): + centsOrigin = pitch1.microtone.cents + else: + centsOrigin = 0.0 distanceToMove = self.diatonic.generic.staffDistance newDiatonicNumber = oldDiatonicNum + distanceToMove @@ -3475,13 +3478,9 @@ def _diatonicTransposePitch(self, # We have the right note name but not the right accidental interval2 = Interval(pitch1, pitch2) - # interval2 is measured from the microtonal pitch1, so halfStepsToFix - # carries the source microtone. Peel it off so it adjusts only the - # accidental; it is restored as a microtone once the accidental is set. - # Without this, the leaked cents corrupt the spelling (e.g. a +30c pitch - # transposed by P1 becomes a half-sharp) or raise AccidentalException. halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones if centsOrigin: + # peel the source microtone off so it adjusts only the accidental halfStepsToFix = round(halfStepsToFix - centsOrigin / 100.0, 9) # environLocal.printDebug(['self', self, 'halfStepsToFix', halfStepsToFix, @@ -3533,8 +3532,8 @@ def _diatonicTransposePitch(self, and oldPitch2Accidental.name == 'natural'): pitch2.accidental = oldPitch2Accidental - # restore the source microtone that was peeled off halfStepsToFix above if centsOrigin: + # restore the cents peeled off halfStepsToFix above pitch2.microtone = pitch2.microtone.cents + centsOrigin if useImplicitOctave: From 8c8242478909ecedbd0141a38fbd656bf8c4b148 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sun, 5 Jul 2026 10:23:15 +0200 Subject: [PATCH 3/3] Fix microtone preservation breaking scale realization Only carry the source microtone through when the applied interval is not itself microtonal; a microtonal interval already supplies the result's cents (scale realization chains these), so preserving would double-count. Also measure the accidental from a microtone-free copy of pitch1 so the interval construction does not hit a fractional chromatic with no specifier. --- music21/interval.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/music21/interval.py b/music21/interval.py index 8f47d1bd1f..df3357e4f9 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -3460,7 +3460,11 @@ def _diatonicTransposePitch(self, pitch1 = p pitch2 = copy.deepcopy(pitch1) oldDiatonicNum = pitch1.diatonicNoteNum - if not pitch1.isTwelveTone(): + # Only carry pitch1's microtone through when the interval is not itself + # microtonal: a microtonal interval already supplies the result's cents + # (e.g. scale realization), so preserving here would double-count. + intervalIsMicrotonal = self.chromatic.semitones != int(self.chromatic.semitones) + if not pitch1.isTwelveTone() and not intervalIsMicrotonal: centsOrigin = pitch1.microtone.cents else: centsOrigin = 0.0 @@ -3476,12 +3480,16 @@ def _diatonicTransposePitch(self, # if this is not set to None then terrible things happen pitch2.microtone = None # type: ignore - # We have the right note name but not the right accidental - interval2 = Interval(pitch1, pitch2) - halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones + # We have the right note name but not the right accidental. Measure it + # from pitch1 without its microtone (reapplied to the result below); a + # microtonal endpoint here yields a fractional chromatic with no + # interval specifier. + pitch1ForAccidental = pitch1 if centsOrigin: - # peel the source microtone off so it adjusts only the accidental - halfStepsToFix = round(halfStepsToFix - centsOrigin / 100.0, 9) + pitch1ForAccidental = copy.deepcopy(pitch1) + pitch1ForAccidental.microtone = None + interval2 = Interval(pitch1ForAccidental, pitch2) + halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones # environLocal.printDebug(['self', self, 'halfStepsToFix', halfStepsToFix, # 'centsOrigin', centsOrigin, 'interval2', interval2])