Compare commits

..

2 Commits

Author SHA1 Message Date
kennethreitz 54b82440c4 v0.3.2
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:56:15 -04:00
kennethreitz ef003dbd1d Add type hints, docstrings, and property caching throughout
Type hints: all methods and properties across Tone, Scale, TonedScale,
Key, Chord, and Fretboard now have full type annotations using
from __future__ import annotations.

Docstrings: added to all methods that were missing them —
constructors, dunder methods, properties, classmethods.

Property caching:
- TonedScale._scales: computed once and cached (immutable after init)
- Chord.identify(): cached result, cleared on transpose/inversion
- Tone.frequency: cached after first computation

428 tests passing, no behavior changes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:53:06 -04:00
8 changed files with 335 additions and 146 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock()
project = "PyTheory"
copyright = "2026, Kenneth Reitz"
author = "Kenneth Reitz"
release = "0.3.1"
release = "0.3.2"
extensions = [
"sphinx.ext.autodoc",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.3.1"
version = "0.3.2"
description = "Music Theory for Humans"
readme = "README.md"
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans."""
__version__ = "0.3.1"
__version__ = "0.3.2"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+119 -58
View File
@@ -1,9 +1,20 @@
from __future__ import annotations
from typing import Iterator, Optional, Union
class Chord:
def __init__(self, tones):
def __init__(self, tones: list[Tone]) -> None:
"""Initialize a Chord from a list of Tone objects.
Args:
tones: A list of :class:`Tone` instances that make up the chord.
"""
self.tones = tones
self._identify_cache: Optional[str] = None
@classmethod
def from_tones(cls, *note_names, octave=4):
def from_tones(cls, *note_names: str, octave: int = 4) -> Chord:
"""Create a Chord from note name strings.
Example::
@@ -20,7 +31,7 @@ class Chord:
])
@classmethod
def from_name(cls, name, octave=4):
def from_name(cls, name: str, octave: int = 4) -> Chord:
"""Create a Chord from a chord name like ``"Cmaj7"`` or ``"Am"``.
Uses the built-in chord chart to find the correct tones,
@@ -49,31 +60,34 @@ class Chord:
f"{t.name}{octave}", system="western"))
return cls(tones=tones)
def __repr__(self):
def __repr__(self) -> str:
name = self.identify()
if name:
return f"<Chord {name}>"
l = tuple([tone.full_name for tone in self.tones])
return f"<Chord tones={l!r}>"
def __str__(self):
def __str__(self) -> str:
name = self.identify()
if name:
return name
return " ".join(t.full_name for t in self.tones)
def __iter__(self):
def __iter__(self) -> Iterator[Tone]:
"""Iterate over the tones in this chord."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of tones in this chord."""
return len(self.tones)
def __contains__(self, item):
def __contains__(self, item: Union[str, Tone]) -> bool:
"""Check if a tone (by name or Tone object) is in this chord."""
if isinstance(item, str):
return any(item == t.name for t in self.tones)
return item in self.tones
def __add__(self, other):
def __add__(self, other: Chord) -> Chord:
"""Merge two chords into one (layer their tones).
Example::
@@ -86,7 +100,7 @@ class Chord:
return Chord(tones=list(self.tones) + list(other.tones))
return NotImplemented
def tritone_sub(self):
def tritone_sub(self) -> Chord:
"""Return the tritone substitution of this chord.
In jazz harmony, any dominant chord can be replaced by the
@@ -98,7 +112,7 @@ class Chord:
"""
return self.transpose(6)
def inversion(self, n=1):
def inversion(self, n: int = 1) -> Chord:
"""Return the nth inversion of this chord.
An inversion moves the lowest tone(s) up by one octave:
@@ -121,9 +135,11 @@ class Chord:
break
tone = tones.pop(0)
tones.append(tone.add(12))
return Chord(tones=tones)
result = Chord(tones=tones)
result._identify_cache = None
return result
def transpose(self, semitones):
def transpose(self, semitones: int) -> Chord:
"""Return a new Chord transposed by the given number of semitones.
Every tone in the chord is shifted up (positive) or down
@@ -136,10 +152,12 @@ class Chord:
>>> c_major.transpose(7).identify()
'G major'
"""
return Chord(tones=[t.add(semitones) for t in self.tones])
result = Chord(tones=[t.add(semitones) for t in self.tones])
result._identify_cache = None
return result
@property
def root(self):
def root(self) -> Optional[Tone]:
"""The root of this chord (if identifiable).
Returns the Tone that serves as the root based on chord
@@ -155,7 +173,7 @@ class Chord:
return None
@property
def quality(self):
def quality(self) -> Optional[str]:
"""The quality of this chord (e.g. 'major', 'minor 7th').
Returns the quality string from chord identification, or
@@ -168,7 +186,7 @@ class Chord:
return parts[1] if len(parts) > 1 else None
@property
def intervals(self):
def intervals(self) -> list[int]:
"""Semitone distances between adjacent tones in the chord.
Returns a list of integers, where each value is the absolute
@@ -201,7 +219,7 @@ class Chord:
for i in range(1, len(self.tones))]
@property
def harmony(self):
def harmony(self) -> float:
"""Consonance score based on frequency ratio simplicity.
Computed by examining the frequency ratio between every pair of
@@ -243,7 +261,7 @@ class Chord:
return score
@property
def dissonance(self):
def dissonance(self) -> float:
"""Sensory dissonance score using the Plomp-Levelt roughness model.
When two tones are close in frequency, their waveforms interfere
@@ -296,7 +314,7 @@ class Chord:
return roughness
@property
def beat_frequencies(self):
def beat_frequencies(self) -> list[tuple[Tone, Tone, float]]:
"""Beat frequencies (Hz) between all pairs of tones in the chord.
When two tones with frequencies f1 and f2 are played together,
@@ -337,7 +355,7 @@ class Chord:
return sorted(beats, key=lambda b: b[2])
@property
def beat_pulse(self):
def beat_pulse(self) -> float:
"""The slowest (most perceptible) beat frequency in the chord, in Hz.
This is the beat frequency between the two tones closest in
@@ -379,7 +397,7 @@ class Chord:
"minor 9th": {0, 2, 3, 7, 10},
}
def identify(self):
def identify(self) -> Optional[str]:
"""Identify this chord by name (root + quality).
Tries each tone as a potential root and checks if the remaining
@@ -400,6 +418,9 @@ class Chord:
>>> Chord([A4, C5, E5]).identify()
'A minor'
"""
if self._identify_cache is not None:
return self._identify_cache
if len(self.tones) < 2:
return None
@@ -413,10 +434,11 @@ class Chord:
for name, pattern in self._CHORD_PATTERNS.items():
if pitch_classes == pattern:
return f"{root.name} {name}"
self._identify_cache = f"{root.name} {name}"
return self._identify_cache
return None
def voice_leading(self, other):
def voice_leading(self, other: Chord) -> list[tuple[Tone, Tone, int]]:
"""Find the smoothest voice leading to another chord.
Voice leading is the art of moving individual voices (tones)
@@ -471,7 +493,7 @@ class Chord:
result.append((src[i], dst[j], movement))
return sorted(result, key=lambda v: v[0].pitch(), reverse=True)
def analyze(self, key_tonic, mode="major"):
def analyze(self, key_tonic: Union[str, Tone], mode: str = "major") -> Optional[str]:
"""Roman numeral analysis of this chord relative to a key.
In tonal music, every chord has a **function** determined by
@@ -541,7 +563,7 @@ class Chord:
return numeral_str + suffix
@property
def tension(self):
def tension(self) -> dict:
"""Harmonic tension score and resolution suggestions.
Tension in tonal music arises from specific intervallic
@@ -603,7 +625,22 @@ class Chord:
"has_dominant_function": has_dominant,
}
def fingering(self, *positions):
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each tone, returning a new Chord.
Each position value is added (in semitones) to the corresponding
tone. The number of positions must match the number of tones.
Args:
*positions: One integer per tone indicating the fret offset.
Returns:
A new :class:`Chord` with each tone shifted by its position.
Raises:
ValueError: If the number of positions doesn't match the
number of tones.
"""
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
@@ -617,14 +654,20 @@ class Chord:
class Fretboard:
def __init__(self, *, tones):
def __init__(self, *, tones: list[Tone]) -> None:
"""Initialize a Fretboard from a list of open-string Tone objects.
Args:
tones: A list of :class:`Tone` instances representing the
open strings (high to low).
"""
self.tones = tones
def __repr__(self):
def __repr__(self) -> str:
l = tuple([tone.full_name for tone in self.tones])
return f"<Fretboard tones={l!r}>"
def capo(self, fret):
def capo(self, fret: int) -> Fretboard:
"""Return a new Fretboard with a capo at the given fret.
A `capo <https://en.wikipedia.org/wiki/Capo>`_ clamps across
@@ -652,10 +695,12 @@ class Fretboard:
"""
return Fretboard(tones=[t.add(fret) for t in self.tones])
def __iter__(self):
def __iter__(self) -> Iterator[Tone]:
"""Iterate over the open-string tones of this fretboard."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of strings on this fretboard."""
return len(self.tones)
INSTRUMENTS = [
@@ -680,7 +725,7 @@ class Fretboard:
}
@classmethod
def guitar(cls, tuning="standard", capo=0):
def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0) -> Fretboard:
"""Guitar with the given tuning and optional capo.
Args:
@@ -699,7 +744,7 @@ class Fretboard:
return fb
@classmethod
def bass(cls, five_string=False):
def bass(cls, five_string: bool = False) -> Fretboard:
"""Standard bass guitar tuning.
Args:
@@ -712,7 +757,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def ukulele(cls):
def ukulele(cls) -> Fretboard:
"""Standard ukulele tuning (A4 E4 C4 G4).
Re-entrant tuning: the G4 string is higher than C4.
@@ -726,7 +771,7 @@ class Fretboard:
])
@classmethod
def mandolin(cls):
def mandolin(cls) -> Fretboard:
"""Standard mandolin tuning (E5 A4 D4 G3).
Tuned in fifths, same as a violin but one octave relationship.
@@ -741,7 +786,7 @@ class Fretboard:
])
@classmethod
def mandola(cls):
def mandola(cls) -> Fretboard:
"""Standard mandola tuning (A4 D4 G3 C3).
The mandola (or tenor mandola) is to the mandolin what the
@@ -757,7 +802,7 @@ class Fretboard:
])
@classmethod
def octave_mandolin(cls):
def octave_mandolin(cls) -> Fretboard:
"""Octave mandolin tuning (E4 A3 D3 G2).
Also called the octave mandola in European terminology.
@@ -774,7 +819,7 @@ class Fretboard:
])
@classmethod
def mandocello(cls):
def mandocello(cls) -> Fretboard:
"""Mandocello tuning (A3 D3 G2 C2).
The bass of the mandolin family. Tuned like a cello — an
@@ -790,7 +835,7 @@ class Fretboard:
])
@classmethod
def violin(cls):
def violin(cls) -> Fretboard:
"""Standard violin tuning (E5 A4 D4 G3).
Tuned in perfect fifths. The violin has no frets — intonation
@@ -806,7 +851,7 @@ class Fretboard:
])
@classmethod
def viola(cls):
def viola(cls) -> Fretboard:
"""Standard viola tuning (A4 D4 G3 C3).
A perfect fifth below the violin. The viola's darker, warmer
@@ -821,7 +866,7 @@ class Fretboard:
])
@classmethod
def cello(cls):
def cello(cls) -> Fretboard:
"""Standard cello tuning (A3 D3 G2 C2).
An octave below the viola. Tuned in fifths. The cello spans
@@ -836,7 +881,7 @@ class Fretboard:
])
@classmethod
def banjo(cls, tuning="open g"):
def banjo(cls, tuning: Union[str, tuple[str, ...]] = "open g") -> Fretboard:
"""Banjo with the given tuning.
Args:
@@ -858,7 +903,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in tuning])
@classmethod
def double_bass(cls):
def double_bass(cls) -> Fretboard:
"""Standard double bass (upright bass) tuning (G2 D2 A1 E1).
The largest and lowest-pitched bowed string instrument in the
@@ -877,7 +922,7 @@ class Fretboard:
])
@classmethod
def harp(cls):
def harp(cls) -> Fretboard:
"""Concert harp strings — 47 strings spanning C1 to G7.
The pedal harp has 7 strings per octave (one per note name),
@@ -905,7 +950,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(s, system="western") for s in strings])
@classmethod
def pedal_steel(cls):
def pedal_steel(cls) -> Fretboard:
"""Pedal steel guitar — E9 Nashville tuning (10 strings).
The standard tuning for country music. The pedal steel has
@@ -919,7 +964,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(s, system="western") for s in strings])
@classmethod
def bouzouki(cls, variant="irish"):
def bouzouki(cls, variant: Union[str, tuple[str, ...]] = "irish") -> Fretboard:
"""Bouzouki tuning.
Args:
@@ -939,7 +984,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in variant])
@classmethod
def oud(cls):
def oud(cls) -> Fretboard:
"""Standard Arabic oud tuning (C4 G3 D3 A2 G2 C2).
The oud is the ancestor of the European lute and the defining
@@ -953,7 +998,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def sitar(cls):
def sitar(cls) -> Fretboard:
"""Sitar main playing strings (approximation).
The sitar typically has 6-7 main strings and 11-13 sympathetic
@@ -970,7 +1015,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def shamisen(cls):
def shamisen(cls) -> Fretboard:
"""Standard shamisen tuning — honchoshi (C4 G3 C3).
The shamisen is a 3-stringed Japanese instrument played with
@@ -988,7 +1033,7 @@ class Fretboard:
])
@classmethod
def erhu(cls):
def erhu(cls) -> Fretboard:
"""Standard erhu tuning (A4 D4).
The erhu is a 2-stringed Chinese bowed instrument with a
@@ -1003,7 +1048,7 @@ class Fretboard:
])
@classmethod
def charango(cls):
def charango(cls) -> Fretboard:
"""Standard charango tuning (E5 A4 E5 C5 G4).
A small Andean stringed instrument, traditionally made from
@@ -1021,7 +1066,7 @@ class Fretboard:
])
@classmethod
def pipa(cls):
def pipa(cls) -> Fretboard:
"""Standard pipa tuning (D4 A3 E3 A2).
The pipa is a 4-stringed Chinese lute with a pear-shaped
@@ -1037,7 +1082,7 @@ class Fretboard:
])
@classmethod
def balalaika(cls):
def balalaika(cls) -> Fretboard:
"""Standard balalaika prima tuning (A4 E4 E4).
The Russian balalaika has a distinctive triangular body and
@@ -1052,7 +1097,7 @@ class Fretboard:
])
@classmethod
def keyboard(cls, keys=88, start="A0"):
def keyboard(cls, keys: int = 88, start: str = "A0") -> Fretboard:
"""Piano or keyboard with the given number of keys.
Args:
@@ -1078,7 +1123,7 @@ class Fretboard:
return cls(tones=tones)
@classmethod
def lute(cls):
def lute(cls) -> Fretboard:
"""Renaissance lute in G tuning (6 courses).
The European lute was the dominant instrument of the
@@ -1091,7 +1136,7 @@ class Fretboard:
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
@classmethod
def twelve_string(cls):
def twelve_string(cls) -> Fretboard:
"""12-string guitar in standard tuning.
The lower 4 courses are doubled at the octave; the upper 2
@@ -1111,7 +1156,23 @@ class Fretboard:
]
return cls(tones=[Tone.from_string(t, system="western") for t in strings])
def fingering(self, *positions):
def fingering(self, *positions: int) -> Chord:
"""Apply fret positions to each string, returning a Chord.
Each position value is added (in semitones) to the corresponding
open-string tone. The number of positions must match the number
of strings.
Args:
*positions: One integer per string indicating the fret number.
Returns:
A :class:`Chord` with each tone shifted by its fret position.
Raises:
ValueError: If the number of positions doesn't match the
number of strings.
"""
if not len(positions) == len(self.tones):
raise ValueError(
"The number of positions must match the number of tones (strings)."
+84 -39
View File
@@ -1,11 +1,25 @@
from __future__ import annotations
from typing import Optional, Union
import numeral
from .systems import SYSTEMS
from .systems import SYSTEMS, System
from .tones import Tone
class Scale:
def __init__(self, *, tones, degrees=None, system='western'):
def __init__(self, *, tones: tuple[Tone, ...], degrees: Optional[tuple[str, ...]] = None, system: Union[str, System] = 'western') -> None:
"""Initialize a Scale from a sequence of Tones.
Args:
tones: The tones that make up the scale.
degrees: Optional names for each scale degree (must match length of *tones*).
system: A tone system name or :class:`System` instance.
Raises:
ValueError: If *degrees* is provided but its length differs from *tones*.
"""
self.tones = tones
self.degrees = degrees
@@ -21,14 +35,18 @@ class Scale:
raise ValueError("The number of tones and degrees must be equal!")
@property
def system(self):
def system(self) -> Optional[System]:
"""Return the tone system for this scale.
Resolves a system name to a :class:`System` object on first access.
"""
if self._system:
return self._system
if self.system_name:
return SYSTEMS[self.system_name]
def __repr__(self):
def __repr__(self) -> str:
r = []
for (i, tone) in enumerate(self.tones):
degree = numeral.int2roman(i + 1, only_ascii=True)
@@ -38,22 +56,25 @@ class Scale:
return f"<Scale {r}>"
def __iter__(self):
"""Iterate over the tones in this scale."""
return iter(self.tones)
def __len__(self):
def __len__(self) -> int:
"""Return the number of tones in this scale (including the octave)."""
return len(self.tones)
def __contains__(self, item):
def __contains__(self, item: Union[str, Tone]) -> bool:
"""Check whether a tone or note name belongs to this scale."""
if isinstance(item, str):
return any(item == t.name for t in self.tones)
return item in self.tones
@property
def note_names(self):
def note_names(self) -> list[str]:
"""List of note names in this scale."""
return [t.name for t in self.tones]
def chord(self, *degrees):
def chord(self, *degrees: int) -> Chord:
"""Build a Chord from scale degrees (0-indexed).
Wraps around if degrees exceed the scale length, transposing
@@ -75,7 +96,7 @@ class Scale:
result.append(tone)
return Chord(tones=result)
def transpose(self, semitones):
def transpose(self, semitones: int) -> Scale:
"""Return a new Scale transposed by the given number of semitones.
Every tone is shifted by the same interval, preserving the
@@ -92,21 +113,21 @@ class Scale:
new_tones = tuple(t.add(semitones) for t in self.tones)
return Scale(tones=new_tones)
def triad(self, root=0):
def triad(self, root: int = 0) -> Chord:
"""Build a triad starting from the given scale degree (0-indexed).
Returns a chord with the root, 3rd, and 5th above it.
"""
return self.chord(root, root + 2, root + 4)
def seventh(self, root=0):
def seventh(self, root: int = 0) -> Chord:
"""Build a seventh chord from the given scale degree (0-indexed).
Returns a chord with the root, 3rd, 5th, and 7th.
"""
return self.chord(root, root + 2, root + 4, root + 6)
def progression(self, *numerals):
def progression(self, *numerals: str) -> list[Chord]:
"""Build a chord progression from Roman numeral strings.
Accepts Roman numerals like ``"I"``, ``"IV"``, ``"V"``,
@@ -130,7 +151,7 @@ class Scale:
chords.append(self.triad(degree))
return chords
def nashville(self, *numbers):
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
"""Build a chord progression using Nashville number system.
The `Nashville number system <https://en.wikipedia.org/wiki/Nashville_Number_System>`_
@@ -159,7 +180,7 @@ class Scale:
return chords
@staticmethod
def detect(*note_names):
def detect(*note_names: str) -> Optional[tuple[str, str, int]]:
"""Detect the most likely scale from a set of note names.
Tries all scales in the Western system and returns the best
@@ -200,7 +221,7 @@ class Scale:
return (best[1], best[2], best[3])
return None
def harmonize(self):
def harmonize(self) -> list[Chord]:
"""Build diatonic triads on every scale degree.
Returns a list of Chords — one triad for each degree of the
@@ -214,7 +235,7 @@ class Scale:
unique = len(self.tones) - 1
return [self.triad(i) for i in range(unique)]
def degree(self, item, major=None, minor=False):
def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
# TODO: cleanup degrees.
# Ensure that both major and minor aren't passed.
@@ -247,7 +268,12 @@ class Scale:
if isinstance(item, int) or isinstance(item, slice):
return self.tones[item]
def __getitem__(self, item):
def __getitem__(self, item: Union[str, int, slice]) -> Union[Tone, tuple[Tone, ...]]:
"""Retrieve a tone by scale degree (integer, Roman numeral, or degree name).
Raises:
KeyError: If the given degree is not found in this scale.
"""
result = self.degree(item)
if result is None:
raise KeyError(item)
@@ -301,7 +327,7 @@ class Key:
[<Chord (C,E,G)>, <Chord (G,B,D)>, ...]
"""
def __init__(self, tonic, mode="major", system=None):
def __init__(self, tonic: str, mode: str = "major", system: Optional[Union[str, System]] = None) -> None:
if system is None:
system = SYSTEMS["western"]
elif isinstance(system, str):
@@ -313,7 +339,7 @@ class Key:
self._scale = self._toned_scale[mode]
@classmethod
def detect(cls, *note_names):
def detect(cls, *note_names: str) -> Optional[Key]:
"""Detect the most likely key from a set of note names.
Tries every possible major and minor key and returns the one
@@ -355,42 +381,42 @@ class Key:
return best_key
def __repr__(self):
def __repr__(self) -> str:
return f"<Key {self.tonic_name} {self.mode}>"
def __str__(self):
def __str__(self) -> str:
return f"{self.tonic_name} {self.mode}"
@property
def scale(self):
def scale(self) -> Scale:
"""The scale for this key."""
return self._scale
@property
def note_names(self):
def note_names(self) -> list[str]:
"""Note names in this key's scale."""
return self._scale.note_names
@property
def chords(self):
def chords(self) -> list[str]:
"""Names of all diatonic triads in this key."""
return [c.identify() for c in self._scale.harmonize()]
@property
def seventh_chords(self):
def seventh_chords(self) -> list[str]:
"""Names of all diatonic seventh chords in this key."""
unique = len(self._scale.tones) - 1
return [self._scale.seventh(i).identify() for i in range(unique)]
def triad(self, degree):
def triad(self, degree: int) -> Chord:
"""Build a diatonic triad on the given degree (0-indexed)."""
return self._scale.triad(degree)
def seventh(self, degree):
def seventh(self, degree: int) -> Chord:
"""Build a diatonic seventh chord on the given degree (0-indexed)."""
return self._scale.seventh(degree)
def progression(self, *numerals):
def progression(self, *numerals: str) -> list[Chord]:
"""Build a chord progression from Roman numerals.
Example::
@@ -399,7 +425,7 @@ class Key:
"""
return self._scale.progression(*numerals)
def nashville(self, *numbers):
def nashville(self, *numbers: Union[int, str]) -> list[Chord]:
"""Build a chord progression using Nashville numbers.
Example::
@@ -408,7 +434,7 @@ class Key:
"""
return self._scale.nashville(*numbers)
def secondary_dominant(self, degree):
def secondary_dominant(self, degree: int) -> Chord:
"""Build a secondary dominant (V/x) for the given scale degree.
A secondary dominant is the dominant chord of a non-tonic
@@ -441,7 +467,7 @@ class Key:
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
@classmethod
def all_keys(cls):
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
Returns:
@@ -461,7 +487,7 @@ class Key:
return keys
@property
def relative(self):
def relative(self) -> Optional[Key]:
"""The relative major or minor key.
If this is a major key, returns the relative minor (vi).
@@ -478,7 +504,7 @@ class Key:
return None
@property
def parallel(self):
def parallel(self) -> Optional[Key]:
"""The parallel major or minor key (same tonic, different mode)."""
if self.mode == "major":
return Key(self.tonic_name, "minor")
@@ -488,7 +514,13 @@ class Key:
class TonedScale:
def __init__(self, *, system=SYSTEMS["western"], tonic):
def __init__(self, *, system: Union[str, System] = SYSTEMS["western"], tonic: Union[str, Tone]) -> None:
"""Initialize a TonedScale with a tonic note and tone system.
Args:
system: A tone system name or :class:`System` instance.
tonic: The tonic note as a string (e.g. ``"C4"``) or :class:`Tone`.
"""
if isinstance(system, str):
system = SYSTEMS[system]
self.system = system
@@ -497,28 +529,40 @@ class TonedScale:
tonic = Tone.from_string(tonic, system=self.system)
self.tonic = tonic
self._cached_scales: Optional[dict[str, Scale]] = None
def __repr__(self):
def __repr__(self) -> str:
return f"<TonedScale system={self.system!r} tonic={self.tonic}>"
def __getitem__(self, scale):
def __getitem__(self, scale: str) -> Scale:
"""Retrieve a scale by name.
Raises:
KeyError: If the named scale is not found in this system.
"""
result = self.get(scale)
if result is None:
raise KeyError(scale)
return result
def get(self, scale):
def get(self, scale: str) -> Optional[Scale]:
"""Look up a scale by name, returning ``None`` if not found."""
try:
return self._scales[scale]
except KeyError:
pass
@property
def scales(self):
def scales(self) -> tuple[str, ...]:
"""Tuple of all available scale names in this system."""
return tuple(self._scales.keys())
@property
def _scales(self):
def _scales(self) -> dict[str, Scale]:
"""Lazily computed (and cached) mapping of scale names to Scale objects."""
if self._cached_scales is not None:
return self._cached_scales
scales = {}
for scale_type in self.system.scales:
@@ -536,4 +580,5 @@ class TonedScale:
scales[scale] = Scale(tones=tuple(working_scale))
self._cached_scales = scales
return scales
+127 -44
View File
@@ -1,3 +1,7 @@
from __future__ import annotations
from typing import Optional, Union
from ._statics import REFERENCE_A, TEMPERAMENTS
@@ -20,7 +24,24 @@ class Interval:
class Tone:
def __init__(self, name, *, alt_names=None, octave=None, system="western"):
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 = []
@@ -38,6 +59,7 @@ class Tone:
self.name = name
self.octave = octave
self.alt_names = alt_names
self._frequency: Optional[float] = None
if isinstance(system, str):
self.system_name = system
@@ -47,11 +69,16 @@ class Tone:
self._system = system
@property
def exists(self):
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):
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:
@@ -62,32 +89,34 @@ class Tone:
return self.system
@property
def full_name(self):
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
def names(self):
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):
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):
def is_sharp(self) -> bool:
"""True if this tone has a sharp (#)."""
return "#" in self.name
@property
def is_flat(self):
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 enharmonic(self):
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.
@@ -109,16 +138,16 @@ class Tone:
pass
return None
def __repr__(self):
def __repr__(self) -> str:
return f"<Tone {self.full_name}>"
def __str__(self):
def __str__(self) -> str:
return self.full_name
def __add__(self, interval):
def __add__(self, interval: int) -> Tone:
return self.add(interval)
def __sub__(self, other):
def __sub__(self, other: Union[int, Tone]) -> Union[Tone, int]:
# Tone - int: subtract semitones
if isinstance(other, int):
return self.subtract(other)
@@ -134,27 +163,27 @@ class Tone:
return self_from_c0 - other_from_c0
return NotImplemented
def __lt__(self, other):
def __lt__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() < other.pitch()
def __le__(self, other):
def __le__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() <= other.pitch()
def __gt__(self, other):
def __gt__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() > other.pitch()
def __ge__(self, other):
def __ge__(self, other: Tone) -> bool:
if not isinstance(other, Tone):
return NotImplemented
return self.pitch() >= other.pitch()
def __eq__(self, other):
def __eq__(self, other: object) -> bool:
# Comparing string literals.
if isinstance(other, str):
@@ -169,11 +198,20 @@ class Tone:
return False
def __hash__(self):
def __hash__(self) -> int:
return hash((self.name, self.octave))
@classmethod
def from_string(klass, s, system=None):
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:
@@ -187,7 +225,16 @@ class Tone:
return klass(name=tone, octave=octave)
@classmethod
def from_tuple(klass, t):
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:
@@ -196,7 +243,7 @@ class Tone:
return tone
@classmethod
def from_frequency(klass, hz, system="western"):
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).
@@ -228,7 +275,7 @@ class Tone:
return klass.from_index(index, octave=octave, system=system)
@classmethod
def from_midi(klass, note_number, system="western"):
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).
@@ -251,18 +298,33 @@ class Tone:
return klass.from_index(index, octave=octave, system=system)
@classmethod
def from_index(klass, i, *, octave, system):
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):
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):
def _math(self, interval: int) -> tuple[int, int]:
"""Returns (new index, new octave).
Octave boundaries follow scientific pitch notation, where the
@@ -292,11 +354,27 @@ class Tone:
return (new_index, new_octave)
def add(self, interval):
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)
def subtract(self, interval):
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 = {
@@ -306,7 +384,7 @@ class Tone:
12: "octave",
}
def interval_to(self, other):
def interval_to(self, other: Tone) -> str:
"""Name the interval between this tone and another.
Returns a string like ``"perfect 5th"``, ``"major 3rd"``, or
@@ -335,7 +413,7 @@ class Tone:
return f"{name} + {octaves} octaves"
@property
def midi(self):
def midi(self) -> Optional[int]:
"""MIDI note number (C4 = 60, A4 = 69).
The MIDI standard assigns integer note numbers from 0127.
@@ -350,7 +428,7 @@ class Tone:
semitones_from_c0 = ((self._index - c_index) % 12) + (self.octave * 12)
return semitones_from_c0 + 12 # MIDI C0 = 12 (C-1 = 0)
def transpose(self, semitones):
def transpose(self, semitones: int) -> Tone:
"""Return a new Tone transposed by the given number of semitones.
Alias for ``tone + semitones`` / ``tone - semitones``. Positive
@@ -358,7 +436,7 @@ class Tone:
"""
return self.add(semitones)
def circle_of_fifths(self):
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
@@ -372,14 +450,14 @@ class Tone:
Returns:
A list of 12 Tones.
"""
tones = []
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
t = t.add(7)
return tones
def circle_of_fourths(self):
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
@@ -390,7 +468,7 @@ class Tone:
Returns:
A list of 12 Tones.
"""
tones = []
tones: list[Tone] = []
t = self
for _ in range(12):
tones.append(t)
@@ -398,11 +476,16 @@ class Tone:
return tones
@property
def frequency(self):
"""The frequency of this tone in Hz (equal temperament, A4=440)."""
return self.pitch()
def frequency(self) -> float:
"""The frequency of this tone in Hz (equal temperament, A4=440).
def overtones(self, n=8):
The result is cached after the first computation.
"""
if self._frequency is None:
self._frequency = self.pitch()
return self._frequency
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.
@@ -439,11 +522,11 @@ class Tone:
def pitch(
self,
*,
reference_pitch=REFERENCE_A,
temperament="equal",
symbolic=False,
precision=None,
):
reference_pitch: float = REFERENCE_A,
temperament: str = "equal",
symbolic: bool = False,
precision: Optional[int] = None,
) -> float:
try:
tones = len(self.system.tones)
except AttributeError:
+1 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.3.1"
assert pytheory.__version__ == "0.3.2"
def test_all_exports():
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.3.1"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "numeral" },