From 2cacfca64eb257947c3f69470b66b31fa70335ec Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Tue, 30 Jun 2026 11:41:20 +0200 Subject: [PATCH] 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')