mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 06:46:14 +00:00
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:
@@ -25,8 +25,11 @@ jobs:
|
||||
- name: Set up Python
|
||||
run: uv python install 3.13
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --group 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
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
|
||||
@@ -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}")
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
__version__ = "0.3.0"
|
||||
|
||||
from .tones import Tone
|
||||
from .tones import Tone, Interval
|
||||
from .systems import System, SYSTEMS
|
||||
from .scales import Scale, TonedScale, Key
|
||||
from .scales import Scale, TonedScale, Key, PROGRESSIONS
|
||||
from .chords import Chord, Fretboard
|
||||
from .charts import CHARTS, charts_for_fretboard
|
||||
|
||||
@@ -18,7 +18,8 @@ except OSError:
|
||||
Note = Tone
|
||||
|
||||
__all__ = [
|
||||
"Tone", "Note", "Scale", "TonedScale", "Key", "Chord", "Fretboard",
|
||||
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
|
||||
"PROGRESSIONS", "Chord", "Fretboard",
|
||||
"System", "SYSTEMS", "CHARTS", "charts_for_fretboard",
|
||||
"play", "Synth",
|
||||
]
|
||||
|
||||
@@ -2,10 +2,49 @@ class Chord:
|
||||
def __init__(self, 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):
|
||||
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):
|
||||
name = self.identify()
|
||||
if name:
|
||||
return name
|
||||
return " ".join(t.full_name for t in self.tones)
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self.tones)
|
||||
|
||||
|
||||
@@ -184,6 +184,24 @@ class Scale:
|
||||
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:
|
||||
"""A musical key — a convenient entry point for scales and harmony.
|
||||
|
||||
@@ -212,6 +230,9 @@ class Key:
|
||||
def __repr__(self):
|
||||
return f"<Key {self.tonic_name} {self.mode}>"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.tonic_name} {self.mode}"
|
||||
|
||||
@property
|
||||
def scale(self):
|
||||
"""The scale for this key."""
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
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:
|
||||
|
||||
def __init__(self, name, *, alt_names=None, octave=None, system="western"):
|
||||
@@ -54,6 +71,29 @@ class Tone:
|
||||
def names(self):
|
||||
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):
|
||||
return f"<Tone {self.full_name}>"
|
||||
|
||||
|
||||
@@ -3016,3 +3016,84 @@ def test_note_from_string():
|
||||
n = Note.from_string("C4", system="western")
|
||||
assert n.name == "C"
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user