diff --git a/music21/interval.py b/music21/interval.py index e90ad75a7..df3357e4f 100644 --- a/music21/interval.py +++ b/music21/interval.py @@ -3460,7 +3460,14 @@ def _diatonicTransposePitch(self, pitch1 = p pitch2 = copy.deepcopy(pitch1) oldDiatonicNum = pitch1.diatonicNoteNum - # centsOrigin = pitch1.microtone.cents # unused!! + # 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 distanceToMove = self.diatonic.generic.staffDistance newDiatonicNumber = oldDiatonicNum + distanceToMove @@ -3473,9 +3480,15 @@ 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 already has any microtones + # 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: + pitch1ForAccidental = copy.deepcopy(pitch1) + pitch1ForAccidental.microtone = None + interval2 = Interval(pitch1ForAccidental, pitch2) halfStepsToFix = self.chromatic.semitones - interval2.chromatic.semitones # environLocal.printDebug(['self', self, 'halfStepsToFix', halfStepsToFix, @@ -3527,6 +3540,10 @@ def _diatonicTransposePitch(self, and oldPitch2Accidental.name == 'natural'): pitch2.accidental = oldPitch2Accidental + if centsOrigin: + # restore the cents peeled off halfStepsToFix above + 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 da17bb5b3..a747bb41a 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')