From 7153dc908ff498f7fb3783acf3f7d9ccbcd45f5a Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 07:09:16 -0400 Subject: [PATCH] Add Chord.from_name, Interval constants, PROGRESSIONS, enharmonic, fix API docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .github/workflows/docs.yml | 5 ++- examples/explore.py | 74 ++++++++++++++++++++++++++++++++++ pytheory/__init__.py | 7 ++-- pytheory/chords.py | 39 ++++++++++++++++++ pytheory/scales.py | 21 ++++++++++ pytheory/tones.py | 40 +++++++++++++++++++ test_pytheory.py | 81 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 examples/explore.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5e29b30..a1c6e63 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -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 diff --git a/examples/explore.py b/examples/explore.py new file mode 100644 index 0000000..dcd9133 --- /dev/null +++ b/examples/explore.py @@ -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}") diff --git a/pytheory/__init__.py b/pytheory/__init__.py index a5d5396..ad2c2ec 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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", ] diff --git a/pytheory/chords.py b/pytheory/chords.py index 5889301..5d2283c 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -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.from_name("Am7") + + >>> Chord.from_name("G7", octave=3) + + """ + 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"" l = tuple([tone.full_name for tone in self.tones]) return f"" + 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) diff --git a/pytheory/scales.py b/pytheory/scales.py index 9f92228..223be1f 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -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"" + def __str__(self): + return f"{self.tonic_name} {self.mode}" + @property def scale(self): """The scale for this key.""" diff --git a/pytheory/tones.py b/pytheory/tones.py index 53b1777..8696b38 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -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"" diff --git a/test_pytheory.py b/test_pytheory.py index bfd5c92..c72b15f 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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"