From aa454ea7e9ca479cfd5e7c10712c0f6ea3dccc26 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Wed, 25 Mar 2026 04:19:29 -0400 Subject: [PATCH] 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) --- pyproject.toml | 2 +- pytheory/__init__.py | 2 +- pytheory/chords.py | 47 +++++++++++++++ pytheory/cli.py | 55 ++++++++++++++++++ pytheory/scales.py | 24 ++++++++ test_pytheory.py | 135 +++++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 264 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7323312..15bae2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index b6f7c16..fbb5356 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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 diff --git a/pytheory/chords.py b/pytheory/chords.py index 4aee380..9f317f3 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -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. diff --git a/pytheory/cli.py b/pytheory/cli.py index aae5708..382c4fe 100644 --- a/pytheory/cli.py +++ b/pytheory/cli.py @@ -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) diff --git a/pytheory/scales.py b/pytheory/scales.py index 1d776b9..2250ad8 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -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. diff --git a/test_pytheory.py b/test_pytheory.py index a472042..5bd4916 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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 diff --git a/uv.lock b/uv.lock index 3d2416a..17a6ce6 100644 --- a/uv.lock +++ b/uv.lock @@ -612,7 +612,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.4.1" +version = "0.8.3" source = { editable = "." } dependencies = [ { name = "numeral" },