mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
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:
+1
-1
@@ -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,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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user