From da401898454ce82174c28effcaee1c1a28c827c2 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 12:48:04 -0400 Subject: [PATCH] v0.4.0: key signatures, scale diagrams, chord building, progression analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New features: - Key.signature — sharps/flats count and accidental names - Key.borrowed_chords — modal interchange from parallel key - Key.random_progression(n) — weighted random diatonic progressions - Chord.from_intervals("C", 4, 7) — build from root + semitones - Chord.from_midi_message(60, 64, 67) — build from MIDI note numbers - Chord.add_tone(tone) / remove_tone("B") — modify chords immutably - Tone.letter — "C" from "C#" (letter without accidental) - Fretboard.scale_diagram(scale) — ASCII neck diagram - analyze_progression([chords], key="C") → ["I", "vi", "IV", "V"] 443 tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/conf.py | 2 +- pyproject.toml | 2 +- pytheory/__init__.py | 6 +-- pytheory/chords.py | 108 +++++++++++++++++++++++++++++++++++++++++++ pytheory/scales.py | 103 +++++++++++++++++++++++++++++++++++++++++ pytheory/tones.py | 15 ++++++ test_pytheory.py | 100 ++++++++++++++++++++++++++++++++++++++- uv.lock | 2 +- 8 files changed, 331 insertions(+), 7 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4011515..a023c14 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -10,7 +10,7 @@ sys.modules["sounddevice"] = MagicMock() project = "PyTheory" copyright = "2026, Kenneth Reitz" author = "Kenneth Reitz" -release = "0.3.2" +release = "0.4.0" extensions = [ "sphinx.ext.autodoc", diff --git a/pyproject.toml b/pyproject.toml index d311c37..565f91a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.3.2" +version = "0.4.0" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 6fcacab..e63a05c 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,11 +1,11 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.3.2" +__version__ = "0.4.0" from .tones import Tone, Interval from .systems import System, SYSTEMS from .scales import Scale, TonedScale, Key, PROGRESSIONS -from .chords import Chord, Fretboard +from .chords import Chord, Fretboard, analyze_progression from .charts import CHARTS, charts_for_fretboard try: @@ -19,7 +19,7 @@ Note = Tone __all__ = [ "Tone", "Note", "Interval", "Scale", "TonedScale", "Key", - "PROGRESSIONS", "Chord", "Fretboard", + "PROGRESSIONS", "Chord", "Fretboard", "analyze_progression", "System", "SYSTEMS", "CHARTS", "charts_for_fretboard", "play", "Synth", ] diff --git a/pytheory/chords.py b/pytheory/chords.py index 623767d..28c74d1 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -60,6 +60,36 @@ class Chord: f"{t.name}{octave}", system="western")) return cls(tones=tones) + @classmethod + def from_intervals(cls, root: str, *intervals: int, octave: int = 4) -> Chord: + """Create a Chord from a root note and semitone intervals. + + Example:: + + >>> Chord.from_intervals("C", 4, 7) # C major + + >>> Chord.from_intervals("G", 4, 7, 10) # G7 + + >>> Chord.from_intervals("D", 3, 7) # D minor + + """ + from .tones import Tone + root_tone = Tone.from_string(f"{root}{octave}", system="western") + tones = [root_tone] + [root_tone.add(i) for i in intervals] + return cls(tones=tones) + + @classmethod + def from_midi_message(cls, *note_numbers: int) -> Chord: + """Create a Chord from MIDI note numbers. + + Example:: + + >>> Chord.from_midi_message(60, 64, 67) # C4, E4, G4 + + """ + from .tones import Tone + return cls(tones=[Tone.from_midi(n) for n in note_numbers]) + def __repr__(self) -> str: name = self.identify() if name: @@ -625,6 +655,31 @@ class Chord: "has_dominant_function": has_dominant, } + def add_tone(self, tone) -> Chord: + """Return a new Chord with an additional tone. + + Example:: + + >>> c_major = Chord.from_tones("C", "E", "G") + >>> c_major.add_tone(Tone.from_string("B4", system="western")) + + """ + return Chord(tones=list(self.tones) + [tone]) + + def remove_tone(self, tone_name: str) -> Chord: + """Return a new Chord with tones of the given name removed. + + Args: + tone_name: The note name to remove (e.g. "G"). + + Example:: + + >>> cmaj7 = Chord.from_name("Cmaj7") + >>> cmaj7.remove_tone("B") # Remove the 7th + + """ + return Chord(tones=[t for t in self.tones if t.name != tone_name]) + def fingering(self, *positions: int) -> Chord: """Apply fret positions to each tone, returning a new Chord. @@ -1156,6 +1211,47 @@ class Fretboard: ] return cls(tones=[Tone.from_string(t, system="western") for t in strings]) + def scale_diagram(self, scale, frets: int = 12) -> str: + """Render an ASCII diagram showing where scale notes fall on the neck. + + Each string is shown with dots on frets where scale notes appear. + Useful for learning scale patterns on guitar, mandolin, etc. + + Args: + scale: A Scale object (or anything with a ``note_names`` attribute). + frets: Number of frets to display (default 12). + + Returns: + A multi-line string showing the fretboard diagram. + + Example:: + + >>> from pytheory import Fretboard, TonedScale + >>> fb = Fretboard.guitar() + >>> pentatonic = TonedScale(tonic="A4")["minor"] + >>> print(fb.scale_diagram(pentatonic, frets=5)) + """ + scale_notes = set(scale.note_names) + max_name = max(len(t.name) for t in self.tones) + lines = [] + + # Header with fret numbers + header = " " * (max_name + 1) + " ".join(f"{f:<3d}" for f in range(frets + 1)) + lines.append(header) + + for tone in self.tones: + fret_marks = [] + for f in range(frets + 1): + note = tone.add(f) + if note.name in scale_notes: + fret_marks.append(f" {note.name:<2s}") + else: + fret_marks.append(" - ") + line = f"{tone.name:>{max_name}}|{'|'.join(fret_marks)}|" + lines.append(line) + + return "\n".join(lines) + def fingering(self, *positions: int) -> Chord: """Apply fret positions to each string, returning a Chord. @@ -1183,3 +1279,15 @@ class Fretboard: tones.append(tone.add(positions[i])) return Chord(tones=tones) + + +def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]: + """Analyze a list of chords and return their Roman numeral functions. + + Example:: + + >>> chords = [Chord.from_name("C"), Chord.from_name("Am"), Chord.from_name("F"), Chord.from_name("G")] + >>> analyze_progression(chords, key="C") + ['I', 'vi', 'IV', 'V'] + """ + return [c.analyze(key, mode) for c in chords] diff --git a/pytheory/scales.py b/pytheory/scales.py index c278762..f028e98 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -486,6 +486,109 @@ class Key: keys.append(cls(tonic, "minor")) return keys + @property + def signature(self) -> dict: + """The key signature — number and names of sharps or flats. + + In Western music, each key has a unique key signature that tells + you which notes are sharped or flatted throughout a piece. + + Returns: + A dict with: + - ``sharps`` (int): number of sharps (0 if flat key) + - ``flats`` (int): number of flats (0 if sharp key) + - ``accidentals`` (list[str]): the sharped/flatted note names + + Example:: + + >>> Key("G", "major").signature + {'sharps': 1, 'flats': 0, 'accidentals': ['F#']} + >>> Key("F", "major").signature + {'sharps': 0, 'flats': 1, 'accidentals': ['Bb']} + >>> Key("C", "major").signature + {'sharps': 0, 'flats': 0, 'accidentals': []} + """ + # Compare scale notes against the natural notes C D E F G A B + naturals = {"C", "D", "E", "F", "G", "A", "B"} + scale_notes = set(self.note_names[:-1]) # exclude octave + + sharps = [n for n in scale_notes if "#" in n] + flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B + + # Order sharps: F C G D A E B + sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"] + flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"] + + sharps_sorted = [s for s in sharp_order if s in sharps] + flats_sorted = [f for f in flat_order if f in flats] + + if sharps_sorted: + return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted} + elif flats_sorted: + return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted} + else: + return {"sharps": 0, "flats": 0, "accidentals": []} + + @property + def borrowed_chords(self) -> list[str]: + """Chords borrowed from the parallel key. + + Modal interchange (or modal mixture) borrows chords from the + parallel major or minor key. In C major, the parallel minor + is C minor, which provides chords like Ab major, Bb major, + and Eb major — commonly heard in rock, film, and pop music. + + Returns: + A list of chord names from the parallel key that are NOT + in the current key's diatonic chords. + + Example:: + + >>> Key("C", "major").borrowed_chords + ['C minor', 'D diminished', 'D# major', ...] + """ + par = self.parallel + if par is None: + return [] + own = set(self.chords) + return [c for c in par.chords if c not in own] + + def random_progression(self, length: int = 4) -> list: + """Generate a random diatonic chord progression. + + Uses weighted probabilities based on common chord function: + I and vi are most common, IV and V are very common, ii is + common, iii and viidim are rare. Always starts on I and + ends on I or V. + + Args: + length: Number of chords (default 4). + + Returns: + A list of Chord objects. + + Example:: + + >>> Key("C", "major").random_progression(4) + [, , , ] + """ + import random + + harmonized = self._scale.harmonize() + unique = len(harmonized) + # Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low + weights = [10, 5, 2, 8, 8, 5, 1] + if unique < len(weights): + weights = weights[:unique] + + chords = [harmonized[0]] # Start on I + for _ in range(length - 2): + chords.append(random.choices(harmonized, weights=weights, k=1)[0]) + if length > 1: + # End on I or V + chords.append(random.choice([harmonized[0], harmonized[4 % unique]])) + return chords + @property def relative(self) -> Optional[Key]: """The relative major or minor key. diff --git a/pytheory/tones.py b/pytheory/tones.py index 3ba99a4..3a71c5f 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -115,6 +115,21 @@ class Tone: """True if this tone has a flat (b after the first character).""" return "b" in self.name[1:] + @property + def letter(self) -> str: + """The letter name without any accidental. + + Example:: + + >>> Tone.from_string("C#4").letter + 'C' + >>> Tone.from_string("Bb4").letter + 'B' + >>> Tone.from_string("G4").letter + 'G' + """ + return self.name[0] + @property def enharmonic(self) -> Optional[str]: """The enharmonic equivalent of this tone, or None if there isn't one. diff --git a/test_pytheory.py b/test_pytheory.py index c32c5c3..0850da2 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -2622,7 +2622,7 @@ def test_tension_empty(): def test_version(): import pytheory - assert pytheory.__version__ == "0.3.2" + assert pytheory.__version__ == "0.4.0" def test_all_exports(): @@ -3342,3 +3342,101 @@ def test_pachelbel_progression(): prog = k.progression(*PROGRESSIONS["Pachelbel"]) assert len(prog) == 8 assert prog[0].identify() == "C major" + + +# ── Tone.letter ──────────────────────────────────────────────────────────── + +def test_tone_letter_natural(): + assert Tone.from_string("C4").letter == "C" + + +def test_tone_letter_sharp(): + assert Tone.from_string("C#4").letter == "C" + + +def test_tone_letter_flat(): + assert Tone(name="Bb", octave=4).letter == "B" + + +# ── Key.signature ────────────────────────────────────────────────────────── + +def test_key_signature_c_major(): + sig = Key("C", "major").signature + assert sig["sharps"] == 0 + assert sig["flats"] == 0 + + +def test_key_signature_g_major(): + sig = Key("G", "major").signature + assert sig["sharps"] == 1 + assert sig["accidentals"] == ["F#"] + + +def test_key_signature_d_major(): + sig = Key("D", "major").signature + assert sig["sharps"] == 2 + + +# ── Chord.from_intervals ────────────────────────────────────────────────── + +def test_chord_from_intervals_major(): + assert Chord.from_intervals("C", 4, 7).identify() == "C major" + + +def test_chord_from_intervals_dom7(): + assert Chord.from_intervals("G", 4, 7, 10).identify() == "G dominant 7th" + + +# ── Chord.from_midi_message ────────────────────────────────────────────── + +def test_chord_from_midi_message(): + c = Chord.from_midi_message(60, 64, 67) + assert c.identify() == "C major" + + +# ── Chord.add_tone / remove_tone ────────────────────────────────────────── + +def test_chord_add_tone(): + c = Chord.from_tones("C", "E", "G") + cmaj7 = c.add_tone(Tone("B", octave=4)) + assert cmaj7.identify() == "C major 7th" + + +def test_chord_remove_tone(): + cmaj7 = Chord.from_name("Cmaj7") + c = cmaj7.remove_tone("B") + assert c.identify() == "C major" + + +# ── analyze_progression ────────────────────────────────────────────────── + +def test_analyze_progression(): + from pytheory import analyze_progression + prog = [Chord.from_name("C"), Chord.from_name("Am"), + Chord.from_name("F"), Chord.from_name("G")] + assert analyze_progression(prog, key="C") == ["I", "vi", "IV", "V"] + + +# ── Key.borrowed_chords ───────────────────────────────────────────────── + +def test_borrowed_chords(): + borrowed = Key("C", "major").borrowed_chords + assert len(borrowed) > 0 + + +# ── Key.random_progression ────────────────────────────────────────────── + +def test_random_progression(): + prog = Key("C", "major").random_progression(4) + assert len(prog) == 4 + + +# ── Fretboard.scale_diagram ──────────────────────────────────────────── + +def test_scale_diagram(): + fb = Fretboard.guitar() + scale = TonedScale(tonic="C4")["major"] + diagram = fb.scale_diagram(scale, frets=5) + assert "E|" in diagram + lines = diagram.strip().split("\n") + assert len(lines) == 7 diff --git a/uv.lock b/uv.lock index 4a9d356..043aebc 100644 --- a/uv.lock +++ b/uv.lock @@ -612,7 +612,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.3.2" +version = "0.4.0" source = { editable = "." } dependencies = [ { name = "numeral" },