v0.8.3: Chord symbols, common progressions, CLI modes/circle/progressions

- Add Chord.symbol property for standard shorthand notation (Cmaj7, Dm, G7)
- Add Key.common_progressions() to realize all named progressions in a key
- Add CLI commands: modes, circle, progressions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 04:19:29 -04:00
parent 5aed586187
commit aa454ea7e9
7 changed files with 264 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "pytheory"
version = "0.8.2"
version = "0.8.3"
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.8.2"
__version__ = "0.8.3"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
+47
View File
@@ -468,6 +468,53 @@ class Chord:
return self._identify_cache
return None
_SYMBOL_MAP = {
"major": "",
"minor": "m",
"diminished": "dim",
"augmented": "aug",
"sus2": "sus2",
"sus4": "sus4",
"power": "5",
"dominant 7th": "7",
"major 7th": "maj7",
"minor 7th": "m7",
"diminished 7th": "dim7",
"half-diminished 7th": "m7b5",
"minor-major 7th": "mMaj7",
"augmented 7th": "aug7",
"dominant 9th": "9",
"major 9th": "maj9",
"minor 9th": "m9",
}
@property
def symbol(self) -> Optional[str]:
"""Standard chord symbol (e.g. ``"Cmaj7"``, ``"Dm"``, ``"G7"``).
Returns the compact notation used in lead sheets and fake books,
or ``None`` if the chord can't be identified.
Example::
>>> Chord([C4, E4, G4]).symbol
'C'
>>> Chord([C4, E4, G4, B4]).symbol
'Cmaj7'
>>> Chord([A4, C5, E5]).symbol
'Am'
>>> Chord([G4, B4, D5, F5]).symbol
'G7'
"""
name = self.identify()
if not name:
return None
parts = name.split(" ", 1)
root = parts[0]
quality = parts[1] if len(parts) > 1 else "major"
suffix = self._SYMBOL_MAP.get(quality, quality)
return f"{root}{suffix}"
def voice_leading(self, other: Chord) -> list[tuple[Tone, Tone, int]]:
"""Find the smoothest voice leading to another chord.
+55
View File
@@ -127,6 +127,44 @@ def cmd_play(args):
play(target, temperament=args.temperament, synth=synth, t=duration)
def cmd_modes(args):
from .scales import TonedScale
ts = TonedScale(tonic=f"{args.tonic}4", system=args.system)
mode_names = ["ionian", "dorian", "phrygian", "lydian",
"mixolydian", "aeolian", "locrian"]
print(f" Modes of {args.tonic}:\n")
for mode in mode_names:
try:
scale = ts[mode]
notes = " ".join(scale.note_names)
print(f" {mode:<12s} {notes}")
except KeyError:
continue
def cmd_circle(args):
from .tones import Tone
tone = Tone.from_string(f"{args.tonic}4", system="western")
fifths = tone.circle_of_fifths()
fourths = tone.circle_of_fourths()
print(f" Circle of fifths from {args.tonic}:")
print(f"{''.join(t.name for t in fifths)}")
print()
print(f" Circle of fourths from {args.tonic}:")
print(f"{''.join(t.name for t in fourths)}")
def cmd_progressions(args):
from .scales import Key
key = Key(args.tonic, args.mode)
progs = key.common_progressions()
print(f" Common progressions in {key}:\n")
for name, chords in progs.items():
symbols = [c.symbol or str(c) for c in chords]
print(f" {name:<20s} {''.join(symbols)}")
def cmd_detect(args):
from .scales import Key
key = Key.detect(*args.notes)
@@ -193,6 +231,20 @@ def main():
p = sub.add_parser("detect", help="Detect key from notes (e.g. pytheory detect C E G)")
p.add_argument("notes", nargs="+", help="Note names")
# modes
p = sub.add_parser("modes", help="Show all modes of a note (e.g. pytheory modes C)")
p.add_argument("tonic", help="Tonic note (e.g. C, G)")
p.add_argument("--system", default="western", help="Musical system (default: western)")
# circle
p = sub.add_parser("circle", help="Circle of fifths/fourths (e.g. pytheory circle C)")
p.add_argument("tonic", help="Starting note (e.g. C, G)")
# progressions
p = sub.add_parser("progressions", help="Common progressions in a key (e.g. pytheory progressions C major)")
p.add_argument("tonic", help="Tonic note")
p.add_argument("mode", nargs="?", default="major", help="Mode (default: major)")
args = parser.parse_args()
if not args.command:
parser.print_help()
@@ -207,6 +259,9 @@ def main():
"progression": cmd_progression,
"play": cmd_play,
"detect": cmd_detect,
"modes": cmd_modes,
"circle": cmd_circle,
"progressions": cmd_progressions,
}
commands[args.command](args)
+24
View File
@@ -465,6 +465,30 @@ class Key:
root = target.add(7)
return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
def common_progressions(self) -> dict[str, list]:
"""Named chord progressions realized in this key.
Returns a dict mapping progression names (from ``PROGRESSIONS``)
to lists of Chord objects built in this key.
Example::
>>> key = Key("C", "major")
>>> for name, chords in key.common_progressions().items():
... symbols = [c.symbol or str(c) for c in chords]
... print(f"{name}: {''.join(symbols)}")
I-IV-V-I: C F G C
I-V-vi-IV: C G Am F
...
"""
result = {}
for name, numerals in PROGRESSIONS.items():
try:
result[name] = self.progression(*numerals)
except (KeyError, ValueError, IndexError):
continue
return result
@classmethod
def all_keys(cls) -> list[Key]:
"""Return all 24 major and minor keys.
+135
View File
@@ -3923,3 +3923,138 @@ def test_play_save_chord(tmp_path):
chord = Chord.from_tones("C", "E", "G")
save(chord, str(path), t=200)
assert path.exists()
# ── Chord.symbol ────────────────────────────────────────────────────────────
def test_chord_symbol_major():
c = Chord.from_tones("C", "E", "G")
assert c.symbol == "C"
def test_chord_symbol_minor():
c = Chord.from_tones("A", "C", "E")
assert c.symbol == "Am"
def test_chord_symbol_dominant_7th():
c = Chord.from_intervals("G", 4, 7, 10)
assert c.symbol == "G7"
def test_chord_symbol_major_7th():
c = Chord.from_intervals("C", 4, 7, 11)
assert c.symbol == "Cmaj7"
def test_chord_symbol_minor_7th():
c = Chord.from_intervals("D", 3, 7, 10)
assert c.symbol == "Dm7"
def test_chord_symbol_diminished():
c = Chord.from_intervals("B", 3, 6)
assert c.symbol == "Bdim"
def test_chord_symbol_augmented():
c = Chord.from_intervals("C", 4, 8)
assert c.symbol == "Caug"
def test_chord_symbol_sus2():
c = Chord.from_intervals("C", 2, 7)
assert c.symbol == "Csus2"
def test_chord_symbol_sus4():
c = Chord.from_intervals("C", 5, 7)
assert c.symbol == "Csus4"
def test_chord_symbol_power():
c = Chord.from_intervals("C", 7)
assert c.symbol == "C5"
def test_chord_symbol_half_diminished():
c = Chord.from_intervals("B", 3, 6, 10)
assert c.symbol == "Bm7b5"
def test_chord_symbol_dim7():
c = Chord.from_intervals("B", 3, 6, 9)
assert c.symbol == "Bdim7"
def test_chord_symbol_unidentifiable():
c = Chord.from_intervals("C", 1)
assert c.symbol is None
# ── Key.common_progressions ─────────────────────────────────────────────────
def test_common_progressions_returns_dict():
key = Key("C", "major")
progs = key.common_progressions()
assert isinstance(progs, dict)
assert len(progs) > 0
def test_common_progressions_contains_known():
key = Key("C", "major")
progs = key.common_progressions()
assert "I-V-vi-IV" in progs
assert "12-bar blues" in progs
assert "ii-V-I" in progs
def test_common_progressions_chords_are_correct():
key = Key("G", "major")
progs = key.common_progressions()
chords = progs["I-IV-V-I"]
symbols = [c.symbol for c in chords]
assert symbols == ["G", "C", "D", "G"]
def test_common_progressions_i_v_vi_iv():
key = Key("C", "major")
progs = key.common_progressions()
chords = progs["I-V-vi-IV"]
symbols = [c.symbol for c in chords]
assert symbols == ["C", "G", "Am", "F"]
# ── CLI: modes, circle, progressions ────────────────────────────────────────
def test_cli_modes(capsys):
from pytheory.cli import cmd_modes
import argparse
args = argparse.Namespace(tonic="C", system="western")
cmd_modes(args)
out = capsys.readouterr().out
assert "ionian" in out
assert "dorian" in out
assert "locrian" in out
def test_cli_circle(capsys):
from pytheory.cli import cmd_circle
import argparse
args = argparse.Namespace(tonic="C")
cmd_circle(args)
out = capsys.readouterr().out
assert "Circle of fifths" in out
assert "Circle of fourths" in out
assert "G" in out
assert "F" in out
def test_cli_progressions(capsys):
from pytheory.cli import cmd_progressions
import argparse
args = argparse.Namespace(tonic="C", mode="major")
cmd_progressions(args)
out = capsys.readouterr().out
assert "I-V-vi-IV" in out
assert "C" in out
Generated
+1 -1
View File
@@ -612,7 +612,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.4.1"
version = "0.8.3"
source = { editable = "." }
dependencies = [
{ name = "numeral" },