Add Chord.from_name, Interval constants, PROGRESSIONS, enharmonic, fix API docs

New features:
- Chord.from_name("Am7") — build chords from chart names
- Chord.__str__() — prints "C major" instead of raw tones
- Interval constants: Interval.PERFECT_FIFTH, MAJOR_THIRD, OCTAVE, etc.
- PROGRESSIONS dict: 8 common progressions as Roman numeral tuples
  ("I-V-vi-IV", "ii-V-I", "12-bar blues", etc.)
- Tone.enharmonic: C# → "Db", natural notes → None
- Key.__str__(): "C major"

Fix: docs CI now installs all dependencies (uv sync --group docs)
so autodoc can import pytheory modules. API reference pages were
blank on GitHub Pages because pytuning/scipy weren't installed.

New example: examples/explore.py — comprehensive demo of the full API.

393 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-22 07:09:16 -04:00
parent 240d2564a4
commit 7153dc908f
7 changed files with 263 additions and 4 deletions
+4 -1
View File
@@ -25,8 +25,11 @@ jobs:
- name: Set up Python - name: Set up Python
run: uv python install 3.13 run: uv python install 3.13
- name: Install dependencies
run: uv sync --group docs
- name: Build docs - name: Build docs
run: uv run --group docs sphinx-build -b html docs docs/_build/html run: uv run sphinx-build -b html docs docs/_build/html
- name: Upload artifact - name: Upload artifact
uses: actions/upload-pages-artifact@v3 uses: actions/upload-pages-artifact@v3
+74
View File
@@ -0,0 +1,74 @@
"""Explore music theory with PyTheory."""
from pytheory import Key, Chord, Tone, Interval, PROGRESSIONS, Fretboard
# ── Keys and Scales ──────────────────────────────────────────────────────
key = Key("C", "major")
print(f"Key: {key}")
print(f"Notes: {key.note_names}")
print()
# ── Diatonic Harmony ─────────────────────────────────────────────────────
print("Diatonic triads:")
for i, chord in enumerate(key.scale.harmonize()):
analysis = chord.analyze("C")
print(f" {analysis:4s} {chord}")
print()
print("Diatonic seventh chords:")
for name in key.seventh_chords:
print(f" {name}")
# ── Progressions ─────────────────────────────────────────────────────────
print()
print("Common progressions in C major:")
for name, numerals in PROGRESSIONS.items():
chords = key.progression(*numerals)
chord_names = [str(c) for c in chords]
print(f" {name:20s} {''.join(chord_names)}")
# ── Intervals ────────────────────────────────────────────────────────────
print()
c4 = Tone.from_string("C4", system="western")
print("Intervals from C4:")
for semitones in range(13):
tone = c4 + semitones
name = c4.interval_to(tone)
print(f" {semitones:2d} semitones = {tone.name:3s} ({name})")
# ── Circle of Fifths ─────────────────────────────────────────────────────
print()
print("Circle of fifths:", "".join(t.name for t in c4.circle_of_fifths()))
# ── Chord Analysis ───────────────────────────────────────────────────────
print()
g7 = Chord.from_name("G7")
print(f"Chord: {g7}")
print(f" Intervals: {g7.intervals}")
print(f" Tension: {g7.tension}")
print(f" Analysis in C: {g7.analyze('C')}")
# ── Guitar Fingerings ────────────────────────────────────────────────────
print()
fb = Fretboard.guitar()
print("Guitar fingerings:")
for name in ["C", "G", "Am", "F", "Dm", "E7"]:
from pytheory import CHARTS
fingering = CHARTS["western"][name].fingering(fretboard=fb)
print(f" {name:4s} {fingering}")
# ── Overtone Series ──────────────────────────────────────────────────────
print()
a4 = Tone.from_string("A4", system="western")
print(f"Overtone series of {a4}:")
for i, hz in enumerate(a4.overtones(8), 1):
nearest = Tone.from_frequency(hz)
print(f" Harmonic {i}: {hz:8.1f} Hz ≈ {nearest.full_name}")
+4 -3
View File
@@ -2,9 +2,9 @@
__version__ = "0.3.0" __version__ = "0.3.0"
from .tones import Tone from .tones import Tone, Interval
from .systems import System, SYSTEMS from .systems import System, SYSTEMS
from .scales import Scale, TonedScale, Key from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard from .chords import Chord, Fretboard
from .charts import CHARTS, charts_for_fretboard from .charts import CHARTS, charts_for_fretboard
@@ -18,7 +18,8 @@ except OSError:
Note = Tone Note = Tone
__all__ = [ __all__ = [
"Tone", "Note", "Scale", "TonedScale", "Key", "Chord", "Fretboard", "Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
"PROGRESSIONS", "Chord", "Fretboard",
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard", "System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
"play", "Synth", "play", "Synth",
] ]
+39
View File
@@ -2,10 +2,49 @@ class Chord:
def __init__(self, tones): def __init__(self, tones):
self.tones = tones self.tones = tones
@classmethod
def from_name(cls, name, octave=4):
"""Create a Chord from a chord name like ``"Cmaj7"`` or ``"Am"``.
Uses the built-in chord chart to find the correct tones,
then builds the chord at the given octave.
Example::
>>> Chord.from_name("C")
<Chord C major>
>>> Chord.from_name("Am7")
<Chord A minor 7th>
>>> Chord.from_name("G7", octave=3)
<Chord G dominant 7th>
"""
from .charts import CHARTS
from .tones import Tone
chart = CHARTS.get("western", {})
if name not in chart:
raise ValueError(f"Unknown chord: {name!r}")
named = chart[name]
tones = []
for t in named.acceptable_tones:
tones.append(Tone.from_string(
f"{t.name}{octave}", system="western"))
return cls(tones=tones)
def __repr__(self): def __repr__(self):
name = self.identify()
if name:
return f"<Chord {name}>"
l = tuple([tone.full_name for tone in self.tones]) l = tuple([tone.full_name for tone in self.tones])
return f"<Chord tones={l!r}>" return f"<Chord tones={l!r}>"
def __str__(self):
name = self.identify()
if name:
return name
return " ".join(t.full_name for t in self.tones)
def __iter__(self): def __iter__(self):
return iter(self.tones) return iter(self.tones)
+21
View File
@@ -184,6 +184,24 @@ class Scale:
return result return result
PROGRESSIONS = {
"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"),
}
"""Common chord progressions as Roman numeral tuples.
Use with :meth:`Scale.progression` or :meth:`Key.progression`::
Key("C", "major").progression(*PROGRESSIONS["I-V-vi-IV"])
"""
class Key: class Key:
"""A musical key — a convenient entry point for scales and harmony. """A musical key — a convenient entry point for scales and harmony.
@@ -212,6 +230,9 @@ class Key:
def __repr__(self): def __repr__(self):
return f"<Key {self.tonic_name} {self.mode}>" return f"<Key {self.tonic_name} {self.mode}>"
def __str__(self):
return f"{self.tonic_name} {self.mode}"
@property @property
def scale(self): def scale(self):
"""The scale for this key.""" """The scale for this key."""
+40
View File
@@ -1,6 +1,23 @@
from ._statics import REFERENCE_A, TEMPERAMENTS from ._statics import REFERENCE_A, TEMPERAMENTS
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
class Tone: class Tone:
def __init__(self, name, *, alt_names=None, octave=None, system="western"): def __init__(self, name, *, alt_names=None, octave=None, system="western"):
@@ -54,6 +71,29 @@ class Tone:
def names(self): def names(self):
return [self.name] + self.alt_names return [self.name] + self.alt_names
@property
def enharmonic(self):
"""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
def __repr__(self): def __repr__(self):
return f"<Tone {self.full_name}>" return f"<Tone {self.full_name}>"
+81
View File
@@ -3016,3 +3016,84 @@ def test_note_from_string():
n = Note.from_string("C4", system="western") n = Note.from_string("C4", system="western")
assert n.name == "C" assert n.name == "C"
assert n.frequency == Tone.from_string("C4", system="western").frequency assert n.frequency == Tone.from_string("C4", system="western").frequency
# ── Chord.from_name ────────────────────────────────────────────────────────
def test_chord_from_name_c():
c = Chord.from_name("C")
assert c.identify() == "C major"
def test_chord_from_name_am7():
am7 = Chord.from_name("Am7")
assert am7.identify() == "A minor 7th"
def test_chord_from_name_g7():
g7 = Chord.from_name("G7")
assert g7.identify() == "G dominant 7th"
def test_chord_from_name_unknown_raises():
with pytest.raises(ValueError):
Chord.from_name("Xmaj13")
def test_chord_str():
c = Chord.from_name("C")
assert str(c) == "C major"
# ── Interval constants ─────────────────────────────────────────────────────
def test_interval_constants():
from pytheory import Interval
assert Interval.PERFECT_FIFTH == 7
assert Interval.MAJOR_THIRD == 4
assert Interval.OCTAVE == 12
assert Interval.TRITONE == 6
def test_interval_with_tone():
from pytheory import Interval
c4 = Tone.from_string("C4", system="western")
assert (c4 + Interval.PERFECT_FIFTH).name == "G"
assert (c4 + Interval.MAJOR_THIRD).name == "E"
assert (c4 + Interval.MINOR_THIRD).name == "D#"
# ── Enharmonic ─────────────────────────────────────────────────────────────
def test_enharmonic_sharp():
cs = Tone.from_string("C#4", system="western")
assert cs.enharmonic == "Db"
def test_enharmonic_natural():
c = Tone.from_string("C4", system="western")
assert c.enharmonic is None
# ── PROGRESSIONS ───────────────────────────────────────────────────────────
def test_progressions_dict():
from pytheory import PROGRESSIONS
assert "I-V-vi-IV" in PROGRESSIONS
assert "12-bar blues" in PROGRESSIONS
assert len(PROGRESSIONS["12-bar blues"]) == 12
def test_progressions_with_key():
from pytheory import PROGRESSIONS
k = Key("C", "major")
pop = k.progression(*PROGRESSIONS["I-V-vi-IV"])
assert len(pop) == 4
assert pop[0].identify() == "C major"
# ── Key.__str__ ────────────────────────────────────────────────────────────
def test_key_str():
assert str(Key("C", "major")) == "C major"
assert str(Key("A", "minor")) == "A minor"