Compare commits

..

4 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
kennethreitz 890c3cfbe2 v0.3.1
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:47:08 -04:00
kennethreitz 599a00f066 Add capo, chord merging, tritone sub, secondary dominants, more progressions
Fretboard:
- Fretboard.guitar(capo=2) — capo as constructor parameter
- fretboard.capo(fret) — apply capo to any instrument

Chord:
- chord1 + chord2 — merge/layer two chords
- chord.tritone_sub() — jazz tritone substitution (transpose by 6)

Key:
- key.secondary_dominant(5) → V/V (e.g. D7 in C major)
- Key.all_keys() → all 24 major and minor keys

Progressions (14 total, up from 8):
- Pachelbel (Canon in D)
- Andalusian cadence (flamenco)
- Rhythm changes A section
- Jazz turnaround (iii-vi-ii-V)
- Dorian vamp, Mixolydian vamp

Also: py.typed marker for type checkers. 428 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 07:44:41 -04:00
9 changed files with 552 additions and 147 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.0"
release = "0.3.2"
extensions = [
"sphinx.ext.autodoc",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.3.0"
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.0"
__version__ = "0.3.2"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+176 -57
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,59 @@ 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 inversion(self, n=1):
def __add__(self, other: Chord) -> Chord:
"""Merge two chords into one (layer their tones).
Example::
>>> c_major = Chord.from_tones("C", "E", "G")
>>> g_bass = Chord.from_tones("G", octave=2)
>>> slash = c_major + g_bass # C/G
"""
if isinstance(other, Chord):
return Chord(tones=list(self.tones) + list(other.tones))
return NotImplemented
def tritone_sub(self) -> Chord:
"""Return the tritone substitution of this chord.
In jazz harmony, any dominant chord can be replaced by the
dominant chord a tritone (6 semitones) away. G7 → Db7,
C7 → F#7. This works because the two chords share the same
tritone interval (the 3rd and 7th swap roles).
Returns a new Chord transposed by 6 semitones.
"""
return self.transpose(6)
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:
@@ -96,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
@@ -111,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
@@ -130,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
@@ -143,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
@@ -176,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
@@ -218,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
@@ -271,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,
@@ -312,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
@@ -354,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
@@ -375,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
@@ -388,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)
@@ -446,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
@@ -516,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
@@ -578,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)."
@@ -592,17 +654,53 @@ 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 __iter__(self):
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
all strings at a fret, raising every string's pitch by that
many semitones. This lets you play open chord shapes in
higher keys.
Common uses:
- Capo 2 + G shapes = A major voicings
- Capo 4 + C shapes = E major voicings
- Capo 7 + D shapes = A major voicings (bright, high register)
Example::
>>> fb = Fretboard.guitar(capo=2)
>>> # Open strings are now F#4 C#4 A3 E3 B2 F#2
>>> # Playing a "G shape" sounds as A major
Args:
fret: The fret number to place the capo (1-12).
Returns:
A new Fretboard with all strings raised by ``fret`` semitones.
"""
return Fretboard(tones=[t.add(fret) for t in self.tones])
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 = [
@@ -627,21 +725,26 @@ class Fretboard:
}
@classmethod
def guitar(cls, tuning="standard"):
"""Guitar with the given tuning.
def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0) -> Fretboard:
"""Guitar with the given tuning and optional capo.
Args:
tuning: Tuning name or tuple of tone strings (high to low).
Built-in tunings: standard, drop d, open g, open d,
open e, open a, dadgad, half step down.
capo: Fret number for the capo (0 = no capo). Raises all
strings by this many semitones.
"""
from .tones import Tone
if isinstance(tuning, str):
tuning = cls.TUNINGS[tuning]
return cls(tones=[Tone.from_string(t, system="western") for t in tuning])
fb = cls(tones=[Tone.from_string(t, system="western") for t in tuning])
if capo:
fb = fb.capo(capo)
return fb
@classmethod
def bass(cls, five_string=False):
def bass(cls, five_string: bool = False) -> Fretboard:
"""Standard bass guitar tuning.
Args:
@@ -654,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.
@@ -668,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.
@@ -683,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
@@ -699,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.
@@ -716,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
@@ -732,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
@@ -748,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
@@ -763,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
@@ -778,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:
@@ -800,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
@@ -819,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),
@@ -847,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
@@ -861,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:
@@ -881,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
@@ -895,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
@@ -912,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
@@ -930,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
@@ -945,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
@@ -963,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
@@ -979,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
@@ -994,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:
@@ -1020,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
@@ -1033,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
@@ -1053,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)."
View File
+150 -41
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)
@@ -255,14 +281,26 @@ class Scale:
PROGRESSIONS = {
# Rock / Pop / Folk
"I-IV-V-I": ("I", "IV", "V", "I"),
"I-V-vi-IV": ("I", "V", "vi", "IV"),
"ii-V-I": ("ii", "V7", "I"),
"I-vi-IV-V": ("I", "vi", "IV", "V"),
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
"I-IV-vi-V": ("I", "IV", "vi", "V"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
# Blues
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
# Jazz
"ii-V-I": ("ii", "V7", "I"),
"I-vi-ii-V": ("I", "vi", "ii", "V"), # rhythm changes A section
"iii-vi-ii-V": ("iii", "vi", "ii", "V"), # jazz turnaround
# Classical / Film
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"),
# Flamenco / Spanish
"Andalusian": ("i", "VII", "VI", "V"),
# Modal
"Dorian vamp": ("i", "IV"),
"Mixolydian vamp": ("I", "VII"),
}
"""Common chord progressions as Roman numeral tuples.
@@ -289,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):
@@ -301,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
@@ -343,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::
@@ -387,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::
@@ -396,8 +434,60 @@ class Key:
"""
return self._scale.nashville(*numbers)
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
degree. For example, in C major, V/V is D major (the V chord
of G). Secondary dominants create momentary tonicizations
that add color and forward motion.
Common secondary dominants:
- V/V (e.g. D7 in C major) — approaches the dominant
- V/ii (e.g. A7 in C major) — approaches the supertonic
- V/vi (e.g. E7 in C major) — approaches the relative minor
Args:
degree: Scale degree to target (1-indexed). ``5`` means
"build the V of the 5th degree."
Returns:
A dominant 7th Chord that resolves to the given degree.
Example::
>>> Key("C", "major").secondary_dominant(5) # V/V = D7
<Chord D dominant 7th>
"""
target = self._scale.tones[degree - 1]
# Build a dominant 7th a perfect 5th above the target
from .chords import Chord
root = target.add(7)
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
@classmethod
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
Returns:
A list of Key objects for all 12 major and 12 minor keys.
Example::
>>> for k in Key.all_keys():
... print(k)
"""
chromatic = ["C", "C#", "D", "D#", "E", "F",
"F#", "G", "G#", "A", "A#", "B"]
keys = []
for tonic in chromatic:
keys.append(cls(tonic, "major"))
keys.append(cls(tonic, "minor"))
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).
@@ -414,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")
@@ -424,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
@@ -433,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:
@@ -472,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:
+95 -1
View File
@@ -2622,7 +2622,7 @@ def test_tension_empty():
def test_version():
import pytheory
assert pytheory.__version__ == "0.3.0"
assert pytheory.__version__ == "0.3.2"
def test_all_exports():
@@ -3248,3 +3248,97 @@ def test_nashville_on_scale():
prog = scale.nashville(1, 5, 1)
assert prog[0].identify() == "C major"
assert prog[1].identify() == "G major"
# ── Capo ───────────────────────────────────────────────────────────────────
def test_guitar_capo():
fb = Fretboard.guitar(capo=2)
assert fb.tones[0].name == "F#"
assert len(fb) == 6
def test_capo_method():
fb = Fretboard.guitar()
fb3 = fb.capo(3)
assert fb3.tones[0].name == "G"
def test_capo_zero():
fb = Fretboard.guitar(capo=0)
assert fb.tones[0].name == "E"
# ── Chord.__add__ ─────────────────────────────────────────────────────────
def test_chord_add():
c = Chord.from_tones("C", "E", "G")
bass = Chord.from_tones("G", octave=2)
merged = c + bass
assert len(merged) == 4
def test_chord_add_preserves_tones():
a = Chord.from_tones("C", "E")
b = Chord.from_tones("G", "B")
merged = a + b
names = [t.name for t in merged]
assert "C" in names and "G" in names
# ── Tritone substitution ──────────────────────────────────────────────────
def test_tritone_sub():
g7 = Chord.from_name("G7")
sub = g7.tritone_sub()
assert sub.identify() == "C# dominant 7th"
def test_tritone_sub_is_6_semitones():
c = Chord.from_tones("C", "E", "G")
sub = c.tritone_sub()
assert sub.root.name == "F#"
# ── Secondary dominants ──────────────────────────────────────────────────
def test_secondary_dominant_V_of_V():
k = Key("C", "major")
vv = k.secondary_dominant(5)
assert vv.identify() == "D dominant 7th"
def test_secondary_dominant_V_of_ii():
k = Key("C", "major")
assert k.secondary_dominant(2).identify() == "A dominant 7th"
def test_secondary_dominant_V_of_vi():
k = Key("C", "major")
assert k.secondary_dominant(6).identify() == "E dominant 7th"
# ── Key.all_keys ─────────────────────────────────────────────────────────
def test_all_keys():
keys = Key.all_keys()
assert len(keys) == 24
majors = [k for k in keys if k.mode == "major"]
minors = [k for k in keys if k.mode == "minor"]
assert len(majors) == 12
assert len(minors) == 12
# ── More progressions ───────────────────────────────────────────────────
def test_progressions_count():
from pytheory.scales import PROGRESSIONS
assert len(PROGRESSIONS) >= 14
def test_pachelbel_progression():
from pytheory.scales import PROGRESSIONS
k = Key("C", "major")
prog = k.progression(*PROGRESSIONS["Pachelbel"])
assert len(prog) == 8
assert prog[0].identify() == "C major"
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.3.0"
version = "0.3.2"
source = { editable = "." }
dependencies = [
{ name = "numeral" },