from __future__ import annotations
from typing import Optional, Union
from ._statics import REFERENCE_A, TEMPERAMENTS
[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: str,
*,
alt_names: Optional[list[str]] = None,
octave: Optional[int] = None,
system: Union[str, object] = "western",
) -> None:
"""Initialize a Tone with a name, optional octave, and musical system.
Args:
name: The note name (e.g. ``"C"``, ``"C#4"``). If the name
contains a digit, it is parsed as the octave.
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 = []
if isinstance(name, str):
try:
parsed_octave = int("".join([c for c in filter(str.isdigit, name)]))
except ValueError:
parsed_octave = None
if parsed_octave is not None:
name = name.replace(str(parsed_octave), "")
if octave is None:
octave = parsed_octave
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
@property
def exists(self) -> bool:
"""True if this tone's name is found in the associated system."""
return self.name in self.system.tones
@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 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
[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):
c_index = 3
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.
"""
try:
octave = int("".join([c for c in filter(str.isdigit, s)]))
except ValueError:
octave = None
tone = s.replace(str(octave), "") if octave else s
if system:
return klass(name=tone, octave=octave, system=system)
else:
return klass(name=tone, octave=octave)
[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")
# Semitones from A4
semitones_from_a4 = 12 * math.log2(hz / REFERENCE_A)
semitones = round(semitones_from_a4)
# A4 is index 0 in the Western system, octave 4
# Convert to absolute position from C0
c_index = 3
a4_from_c0 = ((0 - c_index) % 12) + (4 * 12) # = 57
abs_pos = a4_from_c0 + semitones
octave = abs_pos // 12
relative = abs_pos % 12
index = (relative + c_index) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
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>
"""
c_index = 3
adjusted = note_number - 12 # MIDI C0=12
octave = adjusted // 12
relative = adjusted % 12
index = (relative + c_index) % 12
if isinstance(system, str):
from .systems import SYSTEMS
system = SYSTEMS[system]
return klass.from_index(index, octave=octave, system=system)
[docs]
@classmethod
def from_index(klass, i: int, *, octave: int, system: object) -> 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.
Returns:
A new ``Tone`` instance.
"""
tone = system.tones[i].name
return klass(name=tone, octave=octave, system=system)
@property
def _index(self) -> int:
"""The index of this tone within its associated system's tone list.
Raises:
ValueError: If no system is associated with this tone.
"""
try:
return self.system.tones.index(self.name)
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.tones)
except AttributeError:
raise ValueError(
"Tone math can only be computed with an associated system!"
)
# C is at index 3 in the Western tone list (A=0, A#=1, B=2, C=3, ...)
# Scientific pitch notation changes octave at C, not A.
c_index = 3
# Convert to absolute semitones from C0
note_from_c0 = ((self._index - c_index) % mod) + (octave * mod)
note_from_c0 += interval
new_octave = note_from_c0 // mod
relative = note_from_c0 % mod
new_index = (relative + c_index) % mod
return (new_index, new_octave)
[docs]
def add(self, interval: int) -> Tone:
"""Return a new Tone that is *interval* semitones above this one.
Args:
interval: Number of semitones to add (positive = up).
Returns:
A new ``Tone`` instance.
"""
index, octave = self._math(interval)
return self.from_index(index, octave=octave, system=self.system)
[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)
octaves = semitones // 12
remainder = semitones % 12
name = self._INTERVAL_NAMES.get(remainder, f"{remainder} semitones")
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
c_index = 3
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 circle_of_fifths(self) -> list[Tone]:
"""The 12 tones of the circle of fifths starting from this tone.
Each step ascends by a perfect fifth (7 semitones). After 12
steps 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.
Clockwise = add sharps: C → G → D → A → E → B → F# → ...
Counter-clockwise = add flats (see ``circle_of_fourths``).
Returns:
A list of 12 Tones.
"""
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(7)
return tones
[docs]
def circle_of_fourths(self) -> list[Tone]:
"""The 12 tones of the circle of fourths starting from this tone.
Each step ascends by a perfect fourth (5 semitones) — the
reverse direction of the circle of fifths.
Clockwise = add flats: C → F → Bb → Eb → Ab → ...
Returns:
A list of 12 Tones.
"""
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(5)
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.tones)
except AttributeError:
raise ValueError("Pitches can only be computed with an associated system!")
pitch_scale = TEMPERAMENTS[temperament](tones)
octave = self.octave if self.octave is not None else 4
# C is at index 3; convert to semitones from C0 for both
# this note and the reference A4.
c_index = 3
note_from_c0 = ((self._index - c_index) % tones) + (octave * tones)
a4_from_c0 = ((0 - c_index) % tones) + (4 * tones) # A4
diff = note_from_c0 - a4_from_c0
octave_shift = diff // tones
within_octave = diff % tones
ratio = pitch_scale[within_octave] * (2 ** octave_shift)
if symbolic:
return reference_pitch * ratio
else:
result = reference_pitch * ratio
if precision:
return float(result.evalf(precision))
return float(result)