Source code for pytheory.tones

from __future__ import annotations

from typing import Optional, Union

from ._statics import REFERENCE_A, TEMPERAMENTS, C_INDEX


[docs] class Interval: """Named constants for common musical intervals (in semitones).""" UNISON = 0 MINOR_SECOND = 1 MAJOR_SECOND = 2 MINOR_THIRD = 3 MAJOR_THIRD = 4 PERFECT_FOURTH = 5 TRITONE = 6 PERFECT_FIFTH = 7 MINOR_SIXTH = 8 MAJOR_SIXTH = 9 MINOR_SEVENTH = 10 MAJOR_SEVENTH = 11 OCTAVE = 12
[docs] class Tone:
[docs] def __init__( self, name, *, alt_names: Optional[list[str]] = None, octave: Optional[int] = None, system: Union[str, object] = "western", _validate: bool = True, ) -> None: """Initialize a Tone with a name, optional octave, and musical system. Args: name: The note name as a string (``"C"``, ``"C#4"``) or an int for numbered systems (``0``, ``11``). Ints are converted to strings and wrapped to the system's range (e.g. 22 in a 22-tone system becomes 0 at octave+1). alt_names: Alternate spellings for this tone (e.g. enharmonics). octave: The octave number. Overrides any octave parsed from *name*. system: The tuning system, either as a string key (``"western"``) or a ``ToneSystem`` instance. """ if alt_names is None: alt_names = [] # Int tone names: wrap to system range, adjust octave if isinstance(name, int): if isinstance(system, str): from .systems import SYSTEMS _sys = SYSTEMS[system] else: _sys = system n_tones = len(_sys.tone_names) if name < 0 or name >= n_tones: extra_octaves = name // n_tones name = name % n_tones if octave is None: octave = 4 + extra_octaves else: octave += extra_octaves name = str(name) if isinstance(name, str): # Normalize unicode music symbols to ASCII equivalents name = (name .replace('\u266f', '#') # ♯ → # .replace('\u266d', 'b') # ♭ → b .replace('\U0001d12a', '##') # 𝄪 → ## .replace('\U0001d12b', 'bb') # 𝄫 → bb ) # Normalize 'x' / 'X' as double sharp (only after letter name) if len(name) >= 2 and name[1] in ('x', 'X') and name[0].isalpha(): name = name[0] + '##' + name[2:] # Only parse trailing digits as octave (e.g. "C4" → "C", octave=4). # Digits embedded in the name (e.g. "Mib+1") are NOT octaves. # Numeric pitch class names ("0", "11") are also left alone. if name and name[0].isalpha(): import re as _re m = _re.search(r'(\d+)$', name) if m: parsed_octave = int(m.group(1)) name = name[:m.start()] if octave is None: octave = parsed_octave # Octave boundary fix: B#→C should increment octave, # Cb→B should decrement octave (scientific pitch changes at C). # Only applies to Western-style systems with letter names. if octave is not None and name and name[0].isalpha(): if isinstance(system, str): from .systems import SYSTEMS _sys_check = SYSTEMS.get(system) else: _sys_check = system if _sys_check is not None: resolved = _sys_check.resolve_name(name) if resolved is not None and resolved != name: orig_letter = name[0].upper() res_letter = resolved[0].upper() # Sharp crossing B→C: B# resolves to C, octave up if orig_letter == 'B' and res_letter == 'C' and '#' in name: octave += 1 # Double sharp: A## resolves to B — no boundary cross # But B## resolves to C# — boundary cross if orig_letter == 'B' and res_letter not in ('B', 'A') and '##' in name: octave += 1 # Flat crossing C→B: Cb resolves to B, octave down if orig_letter == 'C' and res_letter == 'B' and 'b' in name and name != 'C': octave -= 1 # Double flat: D♭♭ resolves to C — no boundary cross # But C♭♭ resolves to Bb — boundary cross if orig_letter == 'C' and res_letter not in ('C', 'D') and 'bb' in name: octave -= 1 self.name = name self.octave = octave self.alt_names = alt_names self._frequency: Optional[float] = None if isinstance(system, str): self.system_name = system self._system = None else: self.system_name = None self._system = system # Validate tone name against the system early (fixes #39). if _validate and self.system.resolve_name(name) is None: raise ValueError( f"Unknown tone name: {name!r}. " f"Not found in the {system!r} system." )
@property def exists(self) -> bool: """True if this tone's name is found in the associated system.""" return self.system.resolve_name(self.name) is not None @property def system(self) -> object: """The ``ToneSystem`` associated with this tone. Lazily resolved from ``system_name`` on first access and cached. """ from .systems import SYSTEMS if self._system: return self._system if self.system_name: self._system = SYSTEMS[self.system_name] return self.system @property def full_name(self) -> str: """The tone name with octave appended, e.g. ``'C4'`` or ``'C'``.""" if self.octave is not None: return f"{self.name}{self.octave}" else: return self.name
[docs] def names(self) -> list[str]: """Return a list containing the primary name and all alternate names.""" return [self.name] + self.alt_names
@property def scientific(self) -> str: """Scientific pitch notation (e.g. ``'C4'``, ``'A#3'``). This is the default notation used throughout PyTheory — note name followed by octave number. Middle C is C4. Same as ``full_name``. """ return self.full_name @property def helmholtz(self) -> str: """Helmholtz pitch notation. The older European convention still used in some contexts: - C2 → ``CC`` (sub-contra) - C3 → ``C`` (great octave) - C4 → ``c`` (small octave / middle C) - C5 → ``c'`` (one-line) - C6 → ``c''`` (two-line) - C7 → ``c'''`` Accidentals are preserved as-is (e.g. ``c#'``). Example:: >>> Tone.from_string("C4").helmholtz 'c' >>> Tone.from_string("C3").helmholtz 'C' >>> Tone.from_string("C5").helmholtz "c'" >>> Tone.from_string("A2").helmholtz 'AA' """ if self.octave is None: return self.name letter = self.name[0] accidental = self.name[1:] if self.octave <= 2: # Contra and sub-contra: uppercase repeated # Octave 2 = contra (CC), 1 = sub-contra (CCC), 0 = (CCCC) repeats = 4 - self.octave return (letter.upper() * repeats) + accidental elif self.octave == 3: # Great octave: single uppercase return letter.upper() + accidental else: # Octave 4+: lowercase with tick marks ticks = self.octave - 4 tick_str = "'" * ticks if ticks > 0 else "" return letter.lower() + accidental + tick_str @property def is_natural(self) -> bool: """True if this is a natural note (no sharp or flat).""" return not self.is_sharp and not self.is_flat @property def is_sharp(self) -> bool: """True if this tone has a sharp (#).""" return "#" in self.name @property def is_flat(self) -> bool: """True if this tone has a flat (b after the first character).""" return "b" in self.name[1:] @property def letter(self) -> str: """The letter name without any accidental. Example:: >>> Tone.from_string("C#4").letter 'C' >>> Tone.from_string("Bb4").letter 'B' >>> Tone.from_string("G4").letter 'G' """ return self.name[0] @property def enharmonic(self) -> Optional[str]: """The enharmonic equivalent of this tone, or None if there isn't one. Returns the alternate spelling: C# → Db, Db → C#, etc. Natural notes (C, D, E, F, G, A, B) have no enharmonic. Example:: >>> Tone.from_string("C#4").enharmonic 'Db' """ if self.alt_names: return self.alt_names[0] if isinstance(self.alt_names, (list, tuple)) else self.alt_names # Check the system for alt names try: for tone in self.system.tones: if tone.name == self.name and tone.alt_names: return tone.alt_names[0] except (AttributeError, TypeError): pass return None _SOLFEGE_MAP = { "C": "Do", "D": "Re", "E": "Mi", "F": "Fa", "G": "Sol", "A": "La", "B": "Ti", } _SOLFEGE_SHARP_MAP = { "C#": "Di", "D#": "Ri", "F#": "Fi", "G#": "Si", "A#": "Li", "E#": "Mi", "B#": "Do", } _SOLFEGE_FLAT_MAP = { "Db": "Ra", "Eb": "Me", "Gb": "Se", "Ab": "Le", "Bb": "Te", "Fb": "Mi", "Cb": "Ti", } @property def solfege(self) -> str: """Map Western note names to fixed-Do solfege syllables. Uses fixed Do system where C is always Do regardless of key. - C->Do, D->Re, E->Mi, F->Fa, G->Sol, A->La, B->Ti - Sharps: C#->Di, D#->Ri, F#->Fi, G#->Si, A#->Li - Flats: Db->Ra, Eb->Me, Gb->Se, Ab->Le, Bb->Te Returns the note name unchanged if the system isn't western or the name isn't recognized. Example:: >>> Tone.from_string("C4").solfege 'Do' >>> Tone.from_string("F#4").solfege 'Fi' """ # Check system sys_name = self.system_name if sys_name is not None and sys_name != "western": return self.name if self._system is not None: try: if hasattr(self._system, 'name') and self._system.name != "western": return self.name except (AttributeError, TypeError): pass name = self.name if name in self._SOLFEGE_MAP: return self._SOLFEGE_MAP[name] if name in self._SOLFEGE_SHARP_MAP: return self._SOLFEGE_SHARP_MAP[name] if name in self._SOLFEGE_FLAT_MAP: return self._SOLFEGE_FLAT_MAP[name] return name
[docs] def __repr__(self) -> str: return f"<Tone {self.full_name}>"
[docs] def __str__(self) -> str: return self.full_name
[docs] def __add__(self, interval: int) -> Tone: return self.add(interval)
[docs] def __sub__(self, other: Union[int, Tone]) -> Union[Tone, int]: # Tone - int: subtract semitones if isinstance(other, int): return self.subtract(other) # Tone - Tone: semitone distance if isinstance(other, Tone): try: mod = len(self.system.tones) except AttributeError: raise ValueError("Tone math can only be computed with an associated system!") self_from_c0 = ((self._index - C_INDEX) % mod) + ((self.octave or 0) * mod) other_from_c0 = ((other._index - C_INDEX) % mod) + ((other.octave or 0) * mod) return self_from_c0 - other_from_c0 return NotImplemented
[docs] def __lt__(self, other: Tone) -> bool: if not isinstance(other, Tone): return NotImplemented return self.pitch() < other.pitch()
[docs] def __le__(self, other: Tone) -> bool: if not isinstance(other, Tone): return NotImplemented return self.pitch() <= other.pitch()
[docs] def __gt__(self, other: Tone) -> bool: if not isinstance(other, Tone): return NotImplemented return self.pitch() > other.pitch()
[docs] def __ge__(self, other: Tone) -> bool: if not isinstance(other, Tone): return NotImplemented return self.pitch() >= other.pitch()
[docs] def __eq__(self, other: object) -> bool: # Comparing string literals. if isinstance(other, str): return self.name == other # Comparing against other Tones. try: if (self.name in other.names()) and (self.octave == other.octave): return True except AttributeError: pass return False
[docs] def __hash__(self) -> int: return hash((self.name, self.octave))
[docs] @classmethod def from_string(klass, s: str, system: Optional[Union[str, object]] = None) -> Tone: """Create a Tone by parsing a string like ``'C#4'`` or ``'Bb'``. Args: s: A note string, optionally including an octave number. system: The tuning system to associate with the tone. Returns: A new ``Tone`` instance. """ import re as _re octave = None tone = s # Only parse trailing digits as octave if s and s[0].isalpha(): m = _re.search(r'(\d+)$', s) if m: octave = int(m.group(1)) tone = s[:m.start()] if system: return klass(name=tone, octave=octave, system=system) else: return klass(name=tone, octave=octave, _validate=False)
[docs] @classmethod def from_tuple(klass, t: tuple[str, ...]) -> Tone: """Create a Tone from a tuple of ``(name, *alt_names)``. Args: t: A tuple where the first element is the primary name and any remaining elements are alternate names (enharmonics). Returns: A new ``Tone`` instance. """ if len(t) == 1: return klass.from_string(s=t[0]) else: tone = klass.from_string(s=t[0]) tone.alt_names = t[1:] return tone
[docs] @classmethod def from_frequency(klass, hz: float, system: Union[str, object] = "western") -> Tone: """Create a Tone from a frequency in Hz. Finds the nearest note in 12-TET tuning (A4=440Hz). Example:: >>> Tone.from_frequency(440) <Tone A4> >>> Tone.from_frequency(261.63) <Tone C4> """ import math if hz <= 0: raise ValueError("Frequency must be positive") if isinstance(system, str): from .systems import SYSTEMS system = SYSTEMS[system] n = len(system.tone_names) c_idx = getattr(system, 'c_index', C_INDEX) # Steps from A4 in this EDO steps_from_a4 = n * math.log2(hz / REFERENCE_A) steps = round(steps_from_a4) # A4 is index 0, octave 4. Convert to absolute position from C0. a4_from_c0 = ((0 - c_idx) % n) + (4 * n) abs_pos = a4_from_c0 + steps octave = abs_pos // n relative = abs_pos % n index = (relative + c_idx) % n return klass.from_index(index, octave=octave, system=system)
[docs] @classmethod def from_midi(klass, note_number: int, system: Union[str, object] = "western") -> Tone: """Create a Tone from a MIDI note number. MIDI note 60 = C4 (middle C), 69 = A4 (440 Hz). Example:: >>> Tone.from_midi(60) <Tone C4> >>> Tone.from_midi(69) <Tone A4> """ if isinstance(system, str): from .systems import SYSTEMS system = SYSTEMS[system] # MIDI is a 12-TET standard. Convert to Hz and use from_frequency # for non-12 systems. n = len(system.tone_names) if n != 12: hz = REFERENCE_A * (2 ** ((note_number - 69) / 12)) return klass.from_frequency(hz, system=system) adjusted = note_number - 12 # MIDI C0=12 octave = adjusted // 12 relative = adjusted % 12 index = (relative + C_INDEX) % 12 return klass.from_index(index, octave=octave, system=system)
[docs] @classmethod def from_index(klass, i: int, *, octave: int, system: object, prefer_flats: bool = False) -> Tone: """Create a Tone from its index within a tuning system. Args: i: The index of the tone in the system's tone list. octave: The octave number. system: The ``ToneSystem`` instance. prefer_flats: If True and the tone has a flat spelling, use it instead of the default sharp spelling. Returns: A new ``Tone`` instance. """ tone_names = system.tone_names[i] if prefer_flats and len(tone_names) > 1: # Find the first flat spelling (contains 'b' but isn't just 'B') tone = tone_names[0] # fallback to primary for tn in tone_names[1:]: if 'b' in tn and tn != 'B': tone = tn break else: tone = tone_names[0] # primary spelling # Bypass parsing and validation — name comes from a known system index obj = klass.__new__(klass) obj.name = tone obj.octave = octave obj.alt_names = list(tone_names[1:]) if len(tone_names) > 1 else [] obj._frequency = None if isinstance(system, str): obj.system_name = system obj._system = None else: obj.system_name = None obj._system = system return obj
@property def _index(self) -> int: """The index of this tone within its associated system's tone list. Resolves enharmonic names (e.g. 'Db' → 'C#') before lookup. Raises: ValueError: If no system is associated with this tone or the name is not found. """ try: canonical = self.system.resolve_name(self.name) if canonical is None: raise ValueError(f"Tone {self.name!r} not found in system") # Use _name_to_index for direct lookup (avoids creating Tone objects) idx = self.system._name_to_index(canonical) if idx is not None: return idx # Fallback: linear search through tone_names for i, names in enumerate(self.system.tone_names): if canonical in names: return i raise ValueError(f"Tone {self.name!r} not found in system") except AttributeError: raise ValueError("Tone index cannot be referenced without a system!") def _math(self, interval: int) -> tuple[int, int]: """Returns (new index, new octave). Octave boundaries follow scientific pitch notation, where the octave number increments at C (index 3 in the Western system). """ octave = self.octave or 0 try: mod = len(self.system.tone_names) except AttributeError: raise ValueError( "Tone math can only be computed with an associated system!" ) c_idx = getattr(self.system, 'c_index', C_INDEX) # Convert to absolute steps from C0 note_from_c0 = ((self._index - c_idx) % mod) + (octave * mod) note_from_c0 += interval new_octave = note_from_c0 // mod relative = note_from_c0 % mod new_index = (relative + c_idx) % mod return (new_index, new_octave)
[docs] def add(self, interval: int, *, prefer_flats: bool = False) -> Tone: """Return a new Tone that is *interval* semitones above this one. Args: interval: Number of semitones to add (positive = up). prefer_flats: If True, use flat spellings (Bb, Eb) instead of sharp spellings (A#, D#) for accidentals. Returns: A new ``Tone`` instance. """ index, octave = self._math(interval) return self.from_index(index, octave=octave, system=self.system, prefer_flats=prefer_flats)
[docs] def subtract(self, interval: int) -> Tone: """Return a new Tone that is *interval* semitones below this one. Args: interval: Number of semitones to subtract (positive = down). Returns: A new ``Tone`` instance. """ return self.add((-1 * interval))
_INTERVAL_NAMES = { 0: "unison", 1: "minor 2nd", 2: "major 2nd", 3: "minor 3rd", 4: "major 3rd", 5: "perfect 4th", 6: "tritone", 7: "perfect 5th", 8: "minor 6th", 9: "major 6th", 10: "minor 7th", 11: "major 7th", 12: "octave", }
[docs] def interval_to(self, other: Tone) -> str: """Name the interval between this tone and another. Returns a string like ``"perfect 5th"``, ``"major 3rd"``, or ``"octave"``. For intervals larger than an octave, returns the compound form (e.g. ``"minor 2nd + 1 octave"``). Example:: >>> C4.interval_to(G4) 'perfect 5th' >>> C4.interval_to(C5) 'octave' """ semitones = abs(self - other) n = len(self.system.tones) octaves = semitones // n remainder = semitones % n name = self._INTERVAL_NAMES.get(remainder, f"{remainder} steps") if octaves == 0: return name if remainder == 0: if octaves == 1: return "octave" return f"{octaves} octaves" if octaves == 1: return f"{name} + 1 octave" return f"{name} + {octaves} octaves"
@property def midi(self) -> Optional[int]: """MIDI note number (C4 = 60, A4 = 69). The MIDI standard assigns integer note numbers from 0–127. Middle C (C4) is 60, and each semitone increments by 1. Returns: int: the MIDI note number, or None if no octave is set. """ if self.octave is None: return None n = len(self.system.tones) if n != 12: # Non-12-TET: approximate MIDI via frequency import math hz = self.pitch() return round(69 + 12 * math.log2(hz / REFERENCE_A)) semitones_from_c0 = ((self._index - C_INDEX) % 12) + (self.octave * 12) return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0)
[docs] def transpose(self, semitones: int) -> Tone: """Return a new Tone transposed by the given number of semitones. Alias for ``tone + semitones`` / ``tone - semitones``. Positive values transpose up, negative values transpose down. """ return self.add(semitones)
[docs] def cents_difference(self, other: Tone, *, temperament: str = "equal") -> float: """Difference in cents between this tone and another. One semitone = 100 cents. Musicians use cents to measure fine pitch differences — e.g. comparing equal temperament to Pythagorean tuning, or checking how far out of tune a note is. Args: other: The tone to compare against. temperament: Tuning temperament for both tones. Returns: Signed float — positive means *other* is higher. Example:: >>> a4 = Tone.from_string("A4", system="western") >>> a4.cents_difference(a4 + 1) # one semitone 100.0 >>> a4_pyth = a4.pitch(temperament="pythagorean") >>> a4_equal = a4.pitch(temperament="equal") """ import math f1 = self.pitch(temperament=temperament) f2 = other.pitch(temperament=temperament) if f1 <= 0 or f2 <= 0: raise ValueError("Both tones must have positive frequencies") return 1200 * math.log2(f2 / f1)
[docs] def circle_of_fifths(self) -> list[Tone]: """The circle of fifths starting from this tone. Each step ascends by a perfect fifth (7 semitones in 12-TET). After N steps (where N = number of tones in the system) you return to the starting tone. The circle of fifths is the backbone of Western harmony — it determines key signatures, chord relationships, and modulation paths. Returns: A list of Tones (12 for Western, N for other systems). """ n = len(self.system.tones) # Perfect fifth: the closest approximation to 3:2 ratio fifth = round(n * 7 / 12) # 7 in 12-TET, 11 in 19-TET, 18 in 31-TET tones: list[Tone] = [] t = self for _ in range(n): tones.append(t) t = t.add(fifth) return tones
[docs] def circle_of_fourths(self) -> list[Tone]: """The circle of fourths starting from this tone. Each step ascends by a perfect fourth — the reverse direction of the circle of fifths. Returns: A list of Tones (12 for Western, N for other systems). """ n = len(self.system.tones) fourth = round(n * 5 / 12) # 5 in 12-TET, 8 in 19-TET, 13 in 31-TET tones: list[Tone] = [] t = self for _ in range(n): tones.append(t) t = t.add(fourth) return tones
@property def frequency(self) -> float: """The frequency of this tone in Hz (equal temperament, A4=440). The result is cached after the first computation. """ if self._frequency is None: self._frequency = self.pitch() return self._frequency
[docs] def overtones(self, n: int = 8) -> list[float]: """The first *n* overtones (harmonic series) of this tone. The harmonic series is the foundation of timbre and consonance. When a string or air column vibrates, it produces not just the fundamental frequency but also integer multiples: 2f, 3f, 4f... The intervals between consecutive harmonics form the basis of Western harmony:: Harmonic Ratio Interval from fundamental 1 1:1 Unison (the fundamental) 2 2:1 Octave 3 3:1 Octave + perfect 5th 4 4:1 Two octaves 5 5:1 Two octaves + major 3rd 6 6:1 Two octaves + perfect 5th 7 7:1 Two octaves + minor 7th (slightly flat) 8 8:1 Three octaves The reason a perfect fifth sounds consonant is that the 3rd harmonic of the lower note aligns with the 2nd harmonic of the upper note (when the upper note is a fifth above). More shared harmonics = more consonance. Args: n: Number of harmonics to return (default 8). Returns: List of frequencies in Hz. """ f = self.pitch() return [f * i for i in range(1, n + 1)]
[docs] def pitch( self, *, reference_pitch: float = REFERENCE_A, temperament: str = "equal", symbolic: bool = False, precision: Optional[int] = None, ) -> float: try: tones = len(self.system.tone_names) except AttributeError: raise ValueError("Pitches can only be computed with an associated system!") # Period ratio: 2.0 for standard octave-based systems, # 3.0 for Bohlen-Pierce (tritave), configurable per system. period = getattr(self.system, 'period', 2.0) c_idx = getattr(self.system, 'c_index', C_INDEX) # Custom ratios override temperament (e.g. shruti just ratios) custom_ratios = getattr(self.system, 'ratios', None) if custom_ratios is not None: pitch_scale = list(custom_ratios) + [period] elif period != 2.0 and temperament == "equal": # Non-octave period (e.g. Bohlen-Pierce tritave=3.0) pitch_scale = [period ** (i / tones) for i in range(tones + 1)] else: pitch_scale = TEMPERAMENTS[temperament](tones) octave = self.octave if self.octave is not None else 4 note_from_c0 = ((self._index - c_idx) % tones) + (octave * tones) a4_from_c0 = ((0 - c_idx) % tones) + (4 * tones) # A4 diff = note_from_c0 - a4_from_c0 octave_shift = diff // tones within_octave = diff % tones ratio = pitch_scale[within_octave] * (period ** octave_shift) if symbolic: return reference_pitch * ratio else: result = float(reference_pitch * ratio) if precision: return round(result, precision) return result