Source code for mutwo.music_parameters.pitches.WesternPitch

from __future__ import annotations

import numbers
import operator
import typing

try:
    import quicktions as fractions  # type: ignore
except ImportError:
    import fractions  # type: ignore

from mutwo import core_constants
from mutwo import core_utilities
from mutwo import music_parameters

from .EqualDividedOctavePitch import EqualDividedOctavePitch


__all__ = ("WesternPitch",)

ConcertPitch = core_constants.Real | music_parameters.abc.Pitch
PitchClassOrPitchClassName = core_constants.Real | str


[docs]class WesternPitch(EqualDividedOctavePitch): """Pitch with a traditional Western nomenclature. :param pitch_class_or_pitch_class_name: Name or number of the pitch class of the new ``WesternPitch`` object. The nomenclature is English (c, d, e, f, g, a, b). It uses an equal divided octave system in 12 chromatic steps. Accidentals are indicated by (s = sharp) and (f = flat). Further microtonal accidentals are supported (see :const:`mutwo.music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT` for all supported accidentals). :type pitch_class_or_pitch_class_name: PitchClassOrPitchClassName :param octave: The octave of the new :class:`WesternPitch` object. Indications for the specific octave follow the MIDI Standard where 4 is defined as one line. :type octave: int **Example:** >>> from mutwo import music_parameters >>> music_parameters.WesternPitch('cs', 4) # c-sharp 4 WesternPitch('cs', 4) >>> music_parameters.WesternPitch('aqs', 2) # a-quarter-sharp 2 WesternPitch('aqs', 2) """ def __init__( self, pitch_class_or_pitch_class_name: PitchClassOrPitchClassName = 0, octave: int = 4, concert_pitch_pitch_class: core_constants.Real = None, concert_pitch_octave: int = None, concert_pitch: ConcertPitch = None, *args, **kwargs, ): self._logger = core_utilities.get_cls_logger(type(self)) ( pitch_class, pitch_class_name, ) = self._pitch_class_or_pitch_class_name_to_pitch_class_and_pitch_class_name( pitch_class_or_pitch_class_name ) super().__init__( music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT, pitch_class, octave, ( concert_pitch_pitch_class or music_parameters.configurations.DEFAULT_CONCERT_PITCH_PITCH_CLASS_FOR_WESTERN_PITCH ), ( concert_pitch_octave or music_parameters.configurations.DEFAULT_CONCERT_PITCH_OCTAVE_FOR_WESTERN_PITCH ), concert_pitch, *args, **kwargs, ) self._pitch_class_name = pitch_class_name # ###################################################################### # # static private methods # # ###################################################################### # @staticmethod def _base_interval_type_and_interval_quality_semitone_count_to_interval_quality( base_interval_type: str, interval_quality_semitone_count: int ) -> str: is_interval_type_perfect = ( music_parameters.WesternPitchInterval.is_interval_type_perfect( base_interval_type ) ) if is_interval_type_perfect: if interval_quality_semitone_count == 0: interval_quality = "p" elif interval_quality_semitone_count > 0: interval_quality = "A" * interval_quality_semitone_count else: interval_quality = "d" * abs(interval_quality_semitone_count) else: if interval_quality_semitone_count == 0: interval_quality = "M" elif interval_quality_semitone_count == -1: interval_quality = "m" elif interval_quality_semitone_count > 0: interval_quality = "A" * interval_quality_semitone_count else: interval_quality = "d" * abs(interval_quality_semitone_count + 1) return interval_quality @staticmethod def _pitch_class_or_pitch_class_name_to_pitch_class_and_pitch_class_name( pitch_class_or_pitch_class_name: PitchClassOrPitchClassName, ) -> tuple: """Helper function to initialise a WesternPitch from a number or a string. A number has to represent the pitch class while the name has to use the Western English nomenclature with the form DIATONICPITCHCLASSNAME-ACCIDENTAL (e.g. "cs" for c-sharp, "gqf" for g-quarter-flat, "b" for b) """ if isinstance(pitch_class_or_pitch_class_name, numbers.Real): pitch_class = float(pitch_class_or_pitch_class_name) pitch_class_name = WesternPitch._pitch_class_to_pitch_class_name( pitch_class_or_pitch_class_name ) elif isinstance(pitch_class_or_pitch_class_name, str): pitch_class = WesternPitch._pitch_class_name_to_pitch_class( pitch_class_or_pitch_class_name ) pitch_class_name = pitch_class_or_pitch_class_name else: raise TypeError( "Can't initalise pitch_class by " f"'{pitch_class_or_pitch_class_name}' of type" f" '{type(pitch_class_or_pitch_class_name)}'." ) return pitch_class, pitch_class_name @staticmethod def _accidental_to_pitch_class_modifications( accidental: str, ) -> core_constants.Real: """Helper function to translate an accidental to its pitch class modification. Raises an error if the accidental hasn't been defined yet in mutwo.music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT. """ try: return music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ accidental ] except KeyError: raise NotImplementedError( "Can't initialise WesternPitch with " f"unknown accidental {accidental}! Please see " "'music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT'" " for a list of allowed accidentals." ) @staticmethod def _pitch_class_name_to_pitch_class( pitch_class_name: str, ) -> float: """Helper function to translate a pitch class name to its respective number. +/-1 is defined as one chromatic step. Smaller floating point numbers represent microtonal inflections.. """ diatonic_pitch_class_name, accidental = ( pitch_class_name[0], pitch_class_name[1:], ) diatonic_pitch_class = ( music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER[ diatonic_pitch_class_name ].pitch_class ) pitch_class_modification = ( WesternPitch._accidental_to_pitch_class_modifications(accidental) ) return float(diatonic_pitch_class + pitch_class_modification) @staticmethod def _difference_to_closest_diatonic_pitch_to_accidental( difference_to_closest_diatonic_pitch: core_constants.Real, ) -> str: """Helper function to translate a number to the closest known accidental.""" closest_pitch_class_modification: fractions.Fraction = core_utilities.find_closest_item( difference_to_closest_diatonic_pitch, tuple( music_parameters.constants.PITCH_CLASS_MODIFICATION_TO_ACCIDENTAL_NAME_DICT.keys() ), ) closest_accidental = ( music_parameters.constants.PITCH_CLASS_MODIFICATION_TO_ACCIDENTAL_NAME_DICT[ closest_pitch_class_modification ] ) return closest_accidental @staticmethod def _pitch_class_to_pitch_class_name( pitch_class: core_constants.Real, ) -> str: """Helper function to translate a pitch class in number to a string. The returned pitch class name uses a Western nomenclature of English diatonic note names. Accidental names are defined in mutwo.music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT. For floating point numbers the closest accidental is chosen. """ closest_diatonic_pitch_class = music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER.get_closest_diatonic_pitch_class( pitch_class ) accidental_adjustments = pitch_class - closest_diatonic_pitch_class.pitch_class accidental = WesternPitch._difference_to_closest_diatonic_pitch_to_accidental( accidental_adjustments ) pitch_class_name = f"{closest_diatonic_pitch_class}{accidental}" return pitch_class_name # ###################################################################### # # public class methods # # ###################################################################### #
[docs] @classmethod def from_midi_pitch_number(cls, midi_pitch_number: float) -> WesternPitch: pitch_number = ( midi_pitch_number - music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT ) pitch_class_number = ( pitch_number % music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT ) octave_number = int( pitch_number // music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT ) return cls(pitch_class_number, octave=octave_number)
# ###################################################################### # # magic methods # # ###################################################################### # def __repr__(self) -> str: return f"{type(self).__name__}('{self.pitch_class_name}', {self.octave})" def __str__(self) -> str: return repr(self) # ###################################################################### # # private methods # # ###################################################################### # def _parse_pitch_interval( self, pitch_interval: str | music_parameters.abc.PitchInterval | core_constants.Real, ) -> music_parameters.abc.PitchInterval | core_constants.Real | music_parameters.abc.PitchInterval: if isinstance(pitch_interval, str): pitch_interval = music_parameters.WesternPitchInterval(pitch_interval) elif isinstance(pitch_interval, core_constants.Real.__args__ + (int,)): # Only convert to western pitch interval in case the interval isn't # microtonal (because WesternPitchInterval doesn't support # microtonality). 0.001 (= 0.1 cents) are set in case for # floating point errors. if abs(round(pitch_interval) - pitch_interval) < 0.001: pitch_interval = music_parameters.WesternPitchInterval(pitch_interval) return pitch_interval def _get_new_diatonic_pitch_class_name_and_octave_count( self, western_pitch_interval_to_add: music_parameters.WesternPitchInterval ) -> tuple[str, int]: diatonic_pitch_class = ( music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER[ self.diatonic_pitch_class_name ] ) return ( diatonic_pitch_class + western_pitch_interval_to_add.diatonic_pitch_class_count ) def _get_new_pitch_class_modification( self, western_pitch_interval_to_add: music_parameters.WesternPitchInterval, new_diatonic_pitch_class_name: str, ) -> fractions.Fraction: key = (self.diatonic_pitch_class_name, new_diatonic_pitch_class_name) if western_pitch_interval_to_add.is_interval_falling: key = tuple(reversed(key)) added_cent_deviation = ( western_pitch_interval_to_add.interval_quality_cent_deviation + music_parameters.constants.DIATONIC_PITCH_CLASS_NAME_PAIR_TO_COMPENSATION_IN_CENTS_DICT[ key ] ) added_pitch_class_modification = fractions.Fraction( int(added_cent_deviation), # 100 for cents -> to semitones 100, ) pitch_class_modification = (operator.add, operator.sub)[ western_pitch_interval_to_add.is_interval_falling ]( music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ self.accidental_name ], added_pitch_class_modification, ) return pitch_class_modification def _add_western_pitch_interval( self, western_pitch_interval_to_add: music_parameters.WesternPitchInterval ): ( new_diatonic_pitch_class_name, octave_count, ) = self._get_new_diatonic_pitch_class_name_and_octave_count( western_pitch_interval_to_add ) new_pitch_class_modification = self._get_new_pitch_class_modification( western_pitch_interval_to_add, new_diatonic_pitch_class_name ) try: new_accidental = music_parameters.constants.PITCH_CLASS_MODIFICATION_TO_ACCIDENTAL_NAME_DICT[ new_pitch_class_modification ] except KeyError: # Fall back to default calculation (because the needed accidental # doesn't exist. We would need something even more sharp than # double sharp or even more flat than double flat). self._logger.warning( "Couldn't get correct western pitch with " f"interval '{western_pitch_interval_to_add} to" f" '{self}'; pitch_modifiation: " f"{new_pitch_class_modification}.", RuntimeWarning, ) return super().add(western_pitch_interval_to_add) new_pitch_class_name = f"{new_diatonic_pitch_class_name}{new_accidental}" self.pitch_class_name = new_pitch_class_name self.octave += octave_count # ###################################################################### # # public properties # # ###################################################################### # @property def name(self) -> str: """The name of the pitch in Western nomenclature.""" return f"{self._pitch_class_name}{self.octave}" @property def pitch_class_name(self) -> str: """The name of the pitch class in Western nomenclature. Mutwo uses the English nomenclature for pitch class names: (c, d, e, f, g, a, b) """ return self._pitch_class_name @pitch_class_name.setter def pitch_class_name(self, pitch_class_name: str): self._pitch_class = self._pitch_class_name_to_pitch_class(pitch_class_name) self._pitch_class_name = pitch_class_name @EqualDividedOctavePitch.pitch_class.setter # type: ignore def pitch_class(self, pitch_class: core_constants.Real): self._pitch_class_name = self._pitch_class_to_pitch_class_name(pitch_class) self._pitch_class = pitch_class @property def diatonic_pitch_class_name(self) -> str: """Only get the diatonic part of the pitch name""" return self.pitch_class_name[0] @property def accidental_name(self) -> str: """Only get accidental part of pitch name""" return self.pitch_class_name[1:] @property def is_microtonal(self) -> bool: """Return `True` if accidental isn't on chromatic grid.""" pitch_modifiation = ( music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ self.accidental_name ] ) return pitch_modifiation % fractions.Fraction(1, 1) != 0 @property def enharmonic_pitch_tuple(self) -> tuple[WesternPitch, ...]: """Return pitches with equal frequency but different name. **Disclaimer:** This doesn't work in some corner cases yet (e.g. it won't find "css" for "eff") """ ( previous_neighbour, next_neighbour, ) = music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER[ self.diatonic_pitch_class_name ].neighbour_tuple enharmonic_pitch_list = [] for neighbour, accidental_sequence in ( ( previous_neighbour, music_parameters.constants.RISING_ACCIDENTAL_NAME_TUPLE, ), ( next_neighbour, music_parameters.constants.FALLING_ACCIDENTAL_NAME_TUPLE, ), ): diatonic_pitch_class, octave_count = neighbour for accidental_name in ("",) + accidental_sequence: if ( ( potential_enharmonic_pitch := WesternPitch( f"{diatonic_pitch_class}{accidental_name}", self.octave + octave_count, ) ).pitch_class % music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT == self.pitch_class % music_parameters.constants.CHROMATIC_PITCH_CLASS_COUNT ): enharmonic_pitch_list.append(potential_enharmonic_pitch) return tuple(enharmonic_pitch_list) # ###################################################################### # # public methods # # ###################################################################### #
[docs] @core_utilities.add_copy_option def add( # type: ignore self, pitch_interval: str | music_parameters.abc.PitchInterval | core_constants.Real, ) -> WesternPitch: # type: ignore pitch_interval = self._parse_pitch_interval(pitch_interval) if isinstance(pitch_interval, music_parameters.WesternPitchInterval): self._add_western_pitch_interval(pitch_interval) else: return super().add(pitch_interval) # type: ignore
[docs] @core_utilities.add_copy_option def subtract( # type: ignore self, pitch_interval: str | music_parameters.abc.PitchInterval | core_constants.Real, ) -> WesternPitch: # type: ignore pitch_interval = self._parse_pitch_interval(pitch_interval) if isinstance(pitch_interval, music_parameters.WesternPitchInterval): return self.add(pitch_interval.inverse(mutate=False)) else: return super().subtract(pitch_interval) # type: ignore
def _get_western_pitch_interval( self, pitch_to_compare: music_parameters.WesternPitch ) -> music_parameters.WesternPitchInterval: # First we need to fetch the basic interval type: # we can check the distance between the diatonic pitch class # names for this purpose. diatonic_pitch_class_self, diatonic_pitch_class_other = ( music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER[ self.diatonic_pitch_class_name ], music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER[ pitch_to_compare.diatonic_pitch_class_name ], ) diatonic_pitch_class_count, octave_count = ( diatonic_pitch_class_other - diatonic_pitch_class_self ) # Then we can add the octaves to our basic interval # type. octave_count += pitch_to_compare.octave - self.octave base_interval_type = diatonic_pitch_class_count + 1 interval_type = base_interval_type + ( abs(octave_count) * music_parameters.constants.DIATONIC_PITCH_CLASS_CONTAINER.diatonic_pitch_class_count ) # Next we have to figure out the interval quality. # To figure out the interval quality we need to compare # (1) the accidentals of both pitches (2) the # compensation between the diatonic pitch classes. interval_quality_semitone_count = -( music_parameters.constants.DIATONIC_PITCH_CLASS_NAME_PAIR_TO_COMPENSATION_IN_CENTS_DICT[ (diatonic_pitch_class_self, diatonic_pitch_class_other) ] / 100 ) pitch_modification_self, pitch_modification_other = ( music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ self.accidental_name ], music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ pitch_to_compare.accidental_name ], ) interval_quality_semitone_count += ( pitch_modification_other - pitch_modification_self ) interval_quality = WesternPitch._base_interval_type_and_interval_quality_semitone_count_to_interval_quality( str(base_interval_type), int(interval_quality_semitone_count) ) return music_parameters.WesternPitchInterval( f"{interval_quality}{interval_type}" )
[docs] def get_pitch_interval( self, pitch_to_compare: music_parameters.abc.Pitch ) -> music_parameters.abc.PitchInterval: # We can try to return WesternPitchInterval which is more precise # than the general DirectPitchInterval class. if ( isinstance(pitch_to_compare, WesternPitch) # WesternPitchInterval doesn't support microtonal intervals and not self.is_microtonal and not pitch_to_compare.is_microtonal ): if pitch_to_compare < self: pitch_interval = pitch_to_compare._get_western_pitch_interval(self) pitch_interval.inverse() else: pitch_interval = self._get_western_pitch_interval(pitch_to_compare) return pitch_interval else: return super().get_pitch_interval(pitch_to_compare)
[docs] @core_utilities.add_copy_option def round_to( self, allowed_division_sequence: typing.Sequence[fractions.Fraction] = ( fractions.Fraction(1, 1), ), ) -> WesternPitch: """Round to closest accidental (helpful to avoid microtones). param allowed_division_sequence: Only accidentals are allowed which pitch class modification are dividable by any of the provided numbers. So for instance if only chromatic pitches should be allowed this should be ``[fractions.Fraction(1, 1)]``. But if both chromatic and quartertone pitches are allowed this must be ``[fractions.Fraction(1, 1), fractions.Fraction(1, 2)]``. Default to ``(fractions.Fraction(1, 1),)`` (only chromatic pitches are allowed). type allowed_division_sequence: typing.Sequence[fractions.Fraction] """ allowed_accidental_to_pitch_class_modification = { a: pmod for a, pmod in music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT.items() if any([pmod % d == 0 for d in allowed_division_sequence]) } pitch_modifiation = ( music_parameters.constants.ACCIDENTAL_NAME_TO_PITCH_CLASS_MODIFICATION_DICT[ self.accidental_name ] ) if pitch_modifiation not in allowed_accidental_to_pitch_class_modification: pitch_class_modification_to_accidental = { v: k for k, v in allowed_accidental_to_pitch_class_modification.items() } allowed_pitch_class_modification_tuple = tuple( sorted(allowed_accidental_to_pitch_class_modification.values()) ) new_accidental = pitch_class_modification_to_accidental[ core_utilities.find_closest_item( pitch_modifiation, allowed_pitch_class_modification_tuple ) ] self.pitch_class_name = f"{self.pitch_class_name[0]}{new_accidental}" return self