Compare commits

..

2 Commits

Author SHA1 Message Date
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
8 changed files with 228 additions and 12 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.1"
extensions = [
"sphinx.ext.autodoc",
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.3.0"
version = "0.3.1"
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.1"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+61 -3
View File
@@ -73,6 +73,31 @@ class Chord:
return any(item == t.name for t in self.tones)
return item in self.tones
def __add__(self, other):
"""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):
"""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=1):
"""Return the nth inversion of this chord.
@@ -599,6 +624,34 @@ class Fretboard:
l = tuple([tone.full_name for tone in self.tones])
return f"<Fretboard tones={l!r}>"
def capo(self, fret):
"""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):
return iter(self.tones)
@@ -627,18 +680,23 @@ class Fretboard:
}
@classmethod
def guitar(cls, tuning="standard"):
"""Guitar with the given tuning.
def guitar(cls, tuning="standard", capo=0):
"""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):
View File
+68 -4
View File
@@ -255,14 +255,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.
@@ -396,6 +408,58 @@ class Key:
"""
return self._scale.nashville(*numbers)
def secondary_dominant(self, degree):
"""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):
"""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):
"""The relative major or minor key.
+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.1"
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.1"
source = { editable = "." }
dependencies = [
{ name = "numeral" },