diff --git a/music21/pitch.py b/music21/pitch.py index fc13f1026..ea41aa794 100644 --- a/music21/pitch.py +++ b/music21/pitch.py @@ -63,6 +63,20 @@ 9: 'A', 11: 'B', } +pitchClassToDefaultStepAlter: tuple[tuple[StepName, int], ...] = ( + ('C', 0), # 0 + ('C', 1), # 1 + ('D', 0), # 2 + ('E', -1), # 3 + ('E', 0), # 4 + ('F', 0), # 5 + ('F', 1), # 6 + ('G', 0), # 7 + ('G', 1), # 8 + ('A', 0), # 9 + ('B', -1), # 10 + ('B', 0), # 11 +) STEPNAMES: set[StepName] = {'C', 'D', 'E', 'F', 'G', 'A', 'B'} # set STEP_TO_DNN_OFFSET: dict[StepName, int] = { 'C': 0, @@ -346,7 +360,7 @@ def _convertPsToStep( ps: int|float ) -> tuple[StepName, Accidental, - Microtone, + Microtone|None, int]: ''' Utility conversion; does not process internal representations. @@ -356,29 +370,31 @@ def _convertPsToStep( Returns a tuple of Step, an Accidental object, a Microtone object or None, and an int representing octave shift (which is nearly always zero, but can be 1 - for a B with very high microtones). + for a B with very high microtones). Whole numbers get None for the Microtone. >>> pitch._convertPsToStep(60) - ('C', , , 0) + ('C', , None, 0) + >>> pitch._convertPsToStep(60.0) + ('C', , None, 0) >>> pitch._convertPsToStep(66) - ('F', , , 0) + ('F', , None, 0) >>> pitch._convertPsToStep(67) - ('G', , , 0) + ('G', , None, 0) >>> pitch._convertPsToStep(68) - ('G', , , 0) + ('G', , None, 0) >>> pitch._convertPsToStep(-2) - ('B', , , 0) + ('B', , None, 0) >>> pitch._convertPsToStep(60.5) ('C', , , 0) >>> pitch._convertPsToStep(62) - ('D', , , 0) + ('D', , None, 0) >>> pitch._convertPsToStep(62.5) ('D', , , 0) >>> pitch._convertPsToStep(135) - ('E', , , 0) + ('E', , None, 0) >>> pitch._convertPsToStep(70) - ('B', , , 0) + ('B', , None, 0) >>> pitch._convertPsToStep(70.2) ('B', , , 0) >>> pitch._convertPsToStep(70.5) @@ -407,15 +423,17 @@ def _convertPsToStep( >>> pitch._convertPsToStep(59.9999999) ('C', , , 0) + + * Changed in v11: whole numbers return None instead of a Microtone of 0 cents. ''' - if isinstance(ps, int): - pc = ps % 12 - alter = 0.0 - micro = 0.0 - elif ps == int(ps): - pc = int(ps) % 12 - alter = 0.0 - micro = 0.0 + # TODO: make _convertPsToStep return an alter not an accidental, and then change + # the alter of the Pitch's existing accidental (setting displayStatus to None) + # to preserve style and displayType information + wholePs = int(ps) + if wholePs == ps: + # if this changes, update Pitch.__init__ too + step, wholeAlter = pitchClassToDefaultStepAlter[wholePs % 12] + return step, Accidental(wholeAlter), None, 0 else: # rounding here is essential ps = round(ps, PITCH_SPACE_SIG_DIGITS) @@ -1958,7 +1976,13 @@ def __init__(self, else: # is a number # is a midiNumber or a ps -- a float midiNumber # get step and accidental w/o octave - self.step, self._accidental = _convertPsToStep(name)[0:2] + wholePs = int(name) + if wholePs == name: + # if this changes, update _convertPsToStep too + self._step, alter = pitchClassToDefaultStepAlter[wholePs % 12] + self._accidental = Accidental(alter) + else: + self._step, self._accidental = _convertPsToStep(name)[0:2] self.spellingIsInferred = True if name >= 12: # is not a pitchClass self._octave = int(name / 12) - 1 @@ -2640,19 +2664,23 @@ def ps(self) -> float: @ps.setter def ps(self, value: int|float) -> None: + # TODO: make _convertPsToStep return an alter not an accidental, and then change + # the alter of the Pitch's existing accidental (setting displayStatus to None) + # to preserve style and displayType information # can assign microtone here; will be either None or a Microtone object - self.step, acc, self._microtone, octShift = _convertPsToStep(value) + self._step, acc, self._microtone, octShift = _convertPsToStep(value) # replace a natural with a None if acc.name == 'natural': - self.accidental = None + self._accidental = None else: - self.accidental = acc - self.octave = _convertPsToOct(value) + octShift + self._accidental = acc + self._octave = _convertPsToOct(value) + octShift # all ps settings must set implicit to True, as we do not know # what accidental this is self.spellingIsInferred = True + self.informClient() @property def midi(self) -> int: diff --git a/music21/test/test_pitch.py b/music21/test/test_pitch.py index 35875404a..e10b169f0 100644 --- a/music21/test/test_pitch.py +++ b/music21/test/test_pitch.py @@ -133,6 +133,45 @@ def testInitShortcutsMatchParsing(self): with self.assertRaises(PitchException): Pitch(step='CD') + def testPsAndMidiSettersInformNoteOnce(self): + for attribute in ('ps', 'midi'): + with self.subTest(attribute=attribute): + n = note.Note('D4') + with mock.patch.object(n, 'pitchChanged') as pitchChanged: + setattr(n.pitch, attribute, 61) + pitchChanged.assert_called_once() + + def testWholeNumberPsAndMidi(self): + ''' + Setting a whole-number .ps or .midi replaces a quarter-tone accidental + and clears a microtone. + ''' + p = Pitch('D~5') + p.microtone = 20 + p.ps = 61 + self.assertEqual(p.nameWithOctave, 'C#4') + self.assertEqual(p.microtone.cents, 0) + + # a natural is None, and a negative ps floors to the octave below + p.ps = 60.0 + self.assertEqual(p.nameWithOctave, 'C4') + self.assertIsNone(p.accidental) + p.ps = -1 + self.assertEqual((p.step, p.octave), ('B', -2)) + + # above 127, the midi setter wraps into the top octave; ps does not + p = Pitch() + p.midi = 130 + self.assertEqual(p.nameWithOctave, 'B-8') + p.ps = 130 + self.assertEqual(p.nameWithOctave, 'B-9') + + # Pitch(number) keeps the natural + for value in (60, 60.0): + p = Pitch(value) + self.assertEqual(p.nameWithOctave, 'C4') + self.assertEqual(p.accidental.name, 'natural') + def testAccidentalImport(self):