Source code for klotho.tonos.pitch.pitch

from ..utils.frequency_conversion import pitchclass_to_freq, freq_to_pitchclass, freq_to_midicents, midicents_to_freq, A4_Hz, A4_MIDI
from ..utils.harmonics import partial_to_fundamental
from fractions import Fraction
from typing import Union

import numpy as np

[docs] class Pitch: """ A musical pitch with frequency, pitch class, octave, and partial information. Pitch represents a specific musical frequency with associated metadata including pitch class name, octave number, cents offset from equal temperament, and partial number for harmonic series calculations. Parameters ---------- pitch_input : str or None, optional Pitch class name (e.g., ``"C"``, ``"F#"``) or pitch with octave (e.g., ``"C4"``, ``"Bb-1"``). octave : int, optional Octave number. Default is 4 (middle octave). cents_offset : float, optional Deviation from equal temperament in cents. Default is 0.0. partial : int, float, or Fraction, optional Partial number or frequency ratio. Default is 1 (fundamental). Examples -------- >>> p = Pitch("C4") >>> p.freq 261.6255653005986 >>> p = Pitch("A", 4, 0.0) >>> p.freq 440.0 >>> p = Pitch("C", 4, 14.0) >>> p.cents_offset 14.0 >>> p = Pitch.from_freq(880.0) >>> str(p) 'A5' """ __slots__ = ('_pitchclass', '_octave', '_cents_offset', '_partial', '_freq')
[docs] def __init__(self, pitch_input=None, octave=4, cents_offset=0.0, partial=1): if isinstance(pitch_input, str) and len(pitch_input) >= 1: pitchclass = "" octave_from_str = None for i, char in enumerate(pitch_input): if char.isdigit() or (char == '-' and i > 0): octave_from_str = int(pitch_input[i:]) pitchclass = pitch_input[:i] break if octave_from_str is None: pitchclass = pitch_input else: octave = octave_from_str self._pitchclass = pitchclass self._octave = octave self._cents_offset = cents_offset self._partial = partial self._freq = pitchclass_to_freq(pitchclass, octave, cents_offset) else: self._pitchclass = pitch_input or 'A' self._octave = octave self._cents_offset = cents_offset self._partial = partial self._freq = pitchclass_to_freq(pitch_input or 'A', octave, cents_offset)
[docs] @classmethod def from_freq(cls, freq: float, partial: Union[int, float, Fraction] = 1): """ Create a Pitch from a frequency in Hertz. Parameters ---------- freq : float Frequency in Hertz. partial : int, float, or Fraction, optional Partial number or frequency ratio. Default is 1. Returns ------- Pitch A new Pitch instance corresponding to the given frequency. """ return cls(*freq_to_pitchclass(freq), partial=partial)
[docs] @classmethod def from_midi(cls, midi_note: float, partial: Union[int, float, Fraction] = 1): """ Create a Pitch from a MIDI note number. Parameters ---------- midi_note : float MIDI note number (e.g., 60 for middle C, 69 for A4). partial : int, float, or Fraction, optional Partial number or frequency ratio. Default is 1. Returns ------- Pitch A new Pitch instance corresponding to the MIDI note. """ midicents = midi_note * 100 return cls.from_midicent(midicents, partial)
[docs] @classmethod def from_midicent(cls, midicent_value: float, partial: Union[int, float, Fraction] = 1): """ Create a Pitch from a MIDI cent value. Parameters ---------- midicent_value : float MIDI cent value (e.g., 6900 for A4). partial : int, float, or Fraction, optional Partial number or frequency ratio. Default is 1. Returns ------- Pitch A new Pitch instance corresponding to the MIDI cent value. """ freq = midicents_to_freq(midicent_value) return cls.from_freq(freq, partial)
@property def pitchclass(self): """str : The pitch class name (e.g., ``'C'``, ``'F#'``).""" return self._pitchclass @property def octave(self): """int : The octave number.""" return self._octave @property def cents_offset(self): """float : Deviation from equal temperament in cents.""" return self._cents_offset @property def partial(self): """int, float, or Fraction : The partial number or frequency ratio.""" return self._partial @property def freq(self): """float : The frequency in Hertz.""" return self._freq @property def midicent(self): """float : The MIDI cent representation of this pitch.""" return freq_to_midicents(self.freq) @property def midi(self): """float : The MIDI note number, rounded when cents offset is negligible.""" midi_value = float(self.midicent / 100) if abs(self.cents_offset) < 0.01: return float(round(midi_value)) return midi_value @property def virtual_fundamental(self): """Pitch : The implied fundamental derived from this pitch's partial number.""" return Pitch(*partial_to_fundamental(self.pitchclass, self.octave, self.partial, self.cents_offset)) def __eq__(self, other): if not isinstance(other, Pitch): return NotImplemented return abs(self.freq - other.freq) < 1e-6 def __lt__(self, other): if not isinstance(other, Pitch): return NotImplemented return self.freq < other.freq def __le__(self, other): if not isinstance(other, Pitch): return NotImplemented return self.freq <= other.freq or abs(self.freq - other.freq) < 1e-6 def __gt__(self, other): if not isinstance(other, Pitch): return NotImplemented return self.freq > other.freq def __ge__(self, other): if not isinstance(other, Pitch): return NotImplemented return self.freq >= other.freq or abs(self.freq - other.freq) < 1e-6 def __hash__(self): return hash((self.pitchclass, self.octave, round(self.cents_offset, 1), self.partial))
[docs] def is_same_note(self, other): """ Check whether two pitches share the same pitch class and octave. Parameters ---------- other : Pitch The pitch to compare against. Returns ------- bool True if both pitch class and octave match. """ if not isinstance(other, Pitch): return False return self.pitchclass == other.pitchclass and self.octave == other.octave
[docs] def is_same_pitchclass(self, other): """ Check whether two pitches share the same pitch class regardless of octave. Parameters ---------- other : Pitch The pitch to compare against. Returns ------- bool True if pitch classes match. """ if not isinstance(other, Pitch): return False return self.pitchclass == other.pitchclass
[docs] def cents_difference(self, other): """ Calculate the interval between this pitch and another in cents. Parameters ---------- other : Pitch The pitch to measure the interval to. Returns ------- float Signed interval in cents (positive if self is higher). Raises ------ TypeError If *other* is not a Pitch instance. """ if not isinstance(other, Pitch): raise TypeError("Can only calculate cents difference with another Pitch") return 1200 * np.log2(self.freq / other.freq)
[docs] def with_partial(self, partial: Union[int, float, Fraction]) -> 'Pitch': """ Return a copy of this pitch with a different partial number. Parameters ---------- partial : int, float, or Fraction The new partial number to assign. Returns ------- Pitch A new Pitch sharing all attributes except the partial. """ new_pitch = Pitch.__new__(Pitch) new_pitch._pitchclass = self._pitchclass new_pitch._octave = self._octave new_pitch._cents_offset = self._cents_offset new_pitch._partial = partial new_pitch._freq = self._freq return new_pitch
def __repr__(self): return self.__str__() def __str__(self): pitch_name = f"{self.pitchclass}{self.octave}" if abs(self.cents_offset) > 0.01: cents_str = f" ({self.cents_offset:+.2f}ยข)" else: cents_str = "" freq_str = f"{self.freq:.2f} Hz" return f"Pitch({pitch_name}{cents_str}, {freq_str})"