From a76673770720e450f77a44bc39cfe5966ff2f25d Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Thu, 26 Mar 2026 07:13:26 -0400 Subject: [PATCH] v0.28.0: Figured bass, pitch class sets, scale recommendation - Chord.figured_bass: classical inversion notation (6, 6/4, 7, 6/5, 4/3, 2) - Chord.analyze_figured(): Roman numerals with figured bass (V6/5, ii6) - Chord.pitch_classes, normal_form, prime_form, forte_number: set theory - Scale.recommend(): ranked scale suggestions from note sets - Forte catalog: all trichords and tetrachords - Documented in chords and scales guides Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 7 ++ docs/guide/chords.rst | 70 +++++++++++ docs/guide/scales.rst | 19 +++ pyproject.toml | 2 +- pytheory/__init__.py | 2 +- pytheory/chords.py | 273 ++++++++++++++++++++++++++++++++++++++++++ pytheory/scales.py | 54 +++++++++ test_pytheory.py | 147 +++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 573 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a7d978..1c66176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to PyTheory are documented here. +## 0.28.0 + +- Add figured bass notation: `Chord.figured_bass` and `Chord.analyze_figured()` for classical inversion symbols +- Add pitch class set theory: `pitch_classes`, `normal_form`, `prime_form`, `forte_number` on Chord +- Add `Scale.recommend()` — ranked scale suggestions for a set of notes +- Forte number catalog covers all trichords and tetrachords + ## 0.27.1 - Tab completion in REPL — context-aware for commands, drum presets, synths, envelopes, chords, notes, systems diff --git a/docs/guide/chords.rst b/docs/guide/chords.rst index 1e0f023..86c9232 100644 --- a/docs/guide/chords.rst +++ b/docs/guide/chords.rst @@ -528,3 +528,73 @@ labeling them with flat-degree prefixes: 'bVI' >>> Chord.from_symbol("Bb").analyze("C", "major") 'bVII' + +Figured Bass +------------ + +`Figured bass `_ is the +classical notation for chord inversions — numbers below the bass note +describing the intervals above it. It's how Bach, Handel, and every +Baroque composer communicated harmony. + +.. code-block:: pycon + + >>> from pytheory import Chord, Tone + + >>> root = Chord([Tone.from_string("C4"), Tone.from_string("E4"), Tone.from_string("G4")]) + >>> root.figured_bass + '' + + >>> first_inv = Chord([Tone.from_string("E3"), Tone.from_string("G3"), Tone.from_string("C4")]) + >>> first_inv.figured_bass + '6' + + >>> second_inv = Chord([Tone.from_string("G3"), Tone.from_string("C4"), Tone.from_string("E4")]) + >>> second_inv.figured_bass + '6/4' + +For seventh chords: root position → ``"7"``, first inversion → ``"6/5"``, +second inversion → ``"4/3"``, third inversion → ``"2"``. + +Combine with Roman numeral analysis using ``analyze_figured()``: + +.. code-block:: pycon + + >>> first_inv.analyze_figured("C") + 'I6' + +Pitch Class Sets +---------------- + +`Pitch class set theory `_ +is the framework for analyzing atonal and post-tonal music. It reduces +any collection of notes to abstract pitch classes (0–11, where C=0), +finds the most compact form, and catalogs it with a Forte number. + +If you're studying Schoenberg, Webern, Bartók, or any 20th-century +music that doesn't follow traditional harmony, this is the tool. + +.. code-block:: pycon + + >>> Chord.from_tones("C", "E", "G").pitch_classes + {0, 4, 7} + + >>> Chord.from_tones("C", "E", "G").prime_form + (0, 3, 7) + + >>> Chord.from_tones("A", "C", "E").prime_form + (0, 3, 7) + +Major and minor triads share the same prime form — they're inversions +of each other in pitch class space. + +.. code-block:: pycon + + >>> Chord.from_tones("C", "E", "G").forte_number + '3-11' + + >>> Chord.from_tones("C", "E", "G", "B").forte_number + '4-20' + + >>> Chord.from_tones("C", "E", "G#").forte_number + '3-12' diff --git a/docs/guide/scales.rst b/docs/guide/scales.rst index 4d2a08e..a417503 100644 --- a/docs/guide/scales.rst +++ b/docs/guide/scales.rst @@ -423,6 +423,25 @@ analysis or detecting which scale a phrase belongs to: >>> major.fitness("C", "D", "F#", "G") 0.75 +Scale Recommendation +~~~~~~~~~~~~~~~~~~~~ + +Given a melody or set of notes, find the best-matching scales ranked +by fitness. Useful for figuring out what key you're in or finding +alternative scales to improvise over: + +.. code-block:: pycon + + >>> from pytheory.scales import Scale + + >>> Scale.recommend("C", "D", "E", "G", "A", top=3) + [('C', 'major', 1.0), ('A', 'aeolian', 1.0), ...] + + >>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb", top=3) + [('C', 'blues', 1.0), ...] + +Chromatic scales are deprioritized since they match everything. + Parallel Modes ~~~~~~~~~~~~~~ diff --git a/pyproject.toml b/pyproject.toml index b62d56c..a7a8eed 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pytheory" -version = "0.27.2" +version = "0.28.0" description = "Music Theory for Humans" readme = "README.md" license = "MIT" diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 639a8e8..263c3f4 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -1,6 +1,6 @@ """PyTheory: Music Theory for Humans.""" -__version__ = "0.27.2" +__version__ = "0.28.0" from .tones import Tone, Interval from .systems import System, SYSTEMS diff --git a/pytheory/chords.py b/pytheory/chords.py index 7468acf..11564a8 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -1051,6 +1051,279 @@ class Chord: """ return Chord(tones=[t for t in self.tones if t.name != tone_name]) + # ── Figured Bass ───────────────────────────────────────────────── + + @property + def figured_bass(self) -> Optional[str]: + """Return figured bass notation for this chord. + + Figured bass describes the intervals above the lowest note. + Used in classical music theory and continuo playing. + + Returns: + A string like ``"6"``, ``"6/4"``, ``"7"``, ``"6/5"``, + ``"4/3"``, ``"2"``, or ``""`` for root position triads. + None if the chord can't be identified. + + Example:: + + >>> Chord([C4, E4, G4]).figured_bass # root position + '' + >>> Chord([E4, G4, C5]).figured_bass # first inversion + '6' + >>> Chord([G4, C5, E5]).figured_bass # second inversion + '6/4' + """ + chord_id = self.identify() + if not chord_id: + return None + + # Find root name from identification + root_name = chord_id.split(" ", 1)[0] + quality = chord_id.split(" ", 1)[1] if " " in chord_id else "" + is_seventh = "7th" in quality or "9th" in quality + + # Find the bass note (lowest by pitch) + bass = min(self.tones, key=lambda t: t.pitch()) + bass_name = bass.name + + # Check if bass is the root (handle enharmonics) + if bass_name == root_name: + # Root position + if is_seventh: + return "7" + return "" + + # Find root tone object + root_tone = None + for t in self.tones: + if t.name == root_name: + root_tone = t + break + + if root_tone is None: + return None + + # Determine which chord degree the bass is + bass_interval = (bass - root_tone) % 12 + + # Get the pattern for this quality + pattern = self._CHORD_PATTERNS.get(quality) + if pattern is None: + return None + + sorted_pattern = sorted(pattern) + if bass_interval not in sorted_pattern: + return None + + inversion = sorted_pattern.index(bass_interval) + + if is_seventh: + fb_map = {0: "7", 1: "6/5", 2: "4/3", 3: "2"} + return fb_map.get(inversion, None) + else: + fb_map = {0: "", 1: "6", 2: "6/4"} + return fb_map.get(inversion, None) + + def analyze_figured(self, key_tonic, mode="major") -> Optional[str]: + """Roman numeral analysis with figured bass inversion symbols. + + Combines the Roman numeral from :meth:`analyze` with the + figured bass symbol from :attr:`figured_bass`. + + Args: + key_tonic: The tonic note name (e.g. ``"C"``) or a Tone. + mode: ``"major"`` or ``"minor"`` (default ``"major"``). + + Returns: + A string like ``"V7"``, ``"ii6"``, or ``None``. + + Example:: + + >>> Chord([G4, B4, D5, F5]).analyze_figured("C") + 'V7' + """ + roman = self.analyze(key_tonic, mode) + if roman is None: + return None + fb = self.figured_bass + if fb is None: + return roman + # Don't duplicate "7" — if the Roman numeral already ends with "7" + # and figured bass is just "7" (root position seventh), skip it. + if fb == "7" and roman.endswith("7"): + return roman + if fb: + return f"{roman}{fb}" + return roman + + # ── Pitch Class Set Theory ───────────────────────────────────── + + # Forte number catalog for trichords and tetrachords. + _FORTE_NUMBERS = { + # Trichords (3 notes) + (0, 1, 2): "3-1", + (0, 1, 3): "3-2", + (0, 1, 4): "3-3", + (0, 1, 5): "3-4", + (0, 1, 6): "3-5", + (0, 2, 4): "3-6", + (0, 2, 5): "3-7", + (0, 2, 6): "3-8", + (0, 2, 7): "3-9", + (0, 3, 6): "3-10", + (0, 3, 7): "3-11", # major/minor triad + (0, 4, 8): "3-12", # augmented triad + # Tetrachords (4 notes) + (0, 1, 2, 3): "4-1", + (0, 1, 2, 4): "4-2", + (0, 1, 3, 4): "4-3", + (0, 1, 2, 5): "4-4", + (0, 1, 2, 6): "4-5", + (0, 1, 2, 7): "4-6", + (0, 1, 4, 5): "4-7", + (0, 1, 5, 6): "4-8", + (0, 1, 6, 7): "4-9", + (0, 2, 3, 5): "4-10", + (0, 1, 3, 5): "4-11", + (0, 2, 3, 6): "4-12", + (0, 1, 3, 6): "4-13", + (0, 2, 3, 7): "4-14", + (0, 1, 4, 6): "4-z15", + (0, 1, 5, 7): "4-16", + (0, 3, 4, 7): "4-17", + (0, 1, 4, 7): "4-18", + (0, 1, 4, 8): "4-19", + (0, 1, 5, 8): "4-20", + (0, 2, 4, 6): "4-21", + (0, 2, 4, 7): "4-22", + (0, 2, 5, 7): "4-23", + (0, 2, 4, 8): "4-24", + (0, 2, 6, 8): "4-25", + (0, 3, 5, 8): "4-26", + (0, 2, 5, 8): "4-27", + (0, 3, 6, 9): "4-28", # diminished 7th + (0, 1, 3, 7): "4-z29", + } + + @property + def pitch_classes(self) -> set: + """Return the set of pitch classes (0-11) in this chord. + + Pitch class 0 = C, 1 = C#/Db, 2 = D, ..., 11 = B. + Octave information is removed. + + Example:: + + >>> Chord([C4, E4, G4]).pitch_classes + {0, 4, 7} + """ + from ._statics import C_INDEX + result = set() + for tone in self.tones: + pc = (tone._index - C_INDEX) % 12 + result.add(pc) + return result + + @staticmethod + def _find_normal_form(pcs_sorted): + """Find the normal form of a sorted list of pitch classes.""" + n = len(pcs_sorted) + if n <= 1: + return tuple(pcs_sorted) + + best = None + best_span = 13 + + for start in range(n): + rotation = [pcs_sorted[(start + i) % n] for i in range(n)] + span = (rotation[-1] - rotation[0]) % 12 + + if span < best_span: + best_span = span + best = rotation + elif span == best_span: + # Tiebreak: compare intervals from bottom + for k in range(1, n): + a = (rotation[k] - rotation[0]) % 12 + b = (best[k] - best[0]) % 12 + if a < b: + best = rotation + break + elif a > b: + break + + return tuple(best) + + @property + def normal_form(self) -> tuple: + """Return the normal form -- most compact ascending arrangement. + + The normal form is the rotation of pitch classes that spans + the smallest interval. This is used in set theory analysis. + + Example:: + + >>> Chord([C4, E4, G4]).normal_form + (0, 4, 7) + """ + pcs = sorted(self.pitch_classes) + return self._find_normal_form(pcs) + + @property + def prime_form(self) -> tuple: + """Return the prime form -- transposed to start on 0, most compact. + + Prime form is the canonical representation used for Forte number + lookup. It compares the normal form of the set and its inversion, + picks whichever is more compact, and transposes to start on 0. + + Example:: + + >>> Chord([C4, E4, G4]).prime_form + (0, 4, 7) + >>> Chord([A4, C5, E5]).prime_form # minor triad + (0, 3, 7) + """ + nf = self.normal_form + if len(nf) <= 1: + return (0,) * len(nf) if nf else () + + # Transpose normal form to start on 0 + t0 = nf[0] + nf_transposed = tuple((pc - t0) % 12 for pc in nf) + + # Compute inversion: 12 - each pc + inv_pcs = sorted(set((12 - pc) % 12 for pc in self.pitch_classes)) + inv_nf = self._find_normal_form(inv_pcs) + inv_t0 = inv_nf[0] + inv_transposed = tuple((pc - inv_t0) % 12 for pc in inv_nf) + + # Pick whichever is more compact (smaller intervals from bottom) + for a, b in zip(nf_transposed, inv_transposed): + if a < b: + return nf_transposed + elif a > b: + return inv_transposed + return nf_transposed + + @property + def forte_number(self) -> Optional[str]: + """Return the Forte number for this pitch class set. + + Forte numbers catalog all possible pitch class sets by cardinality + and ordering. They are the standard reference in post-tonal theory. + + Example:: + + >>> Chord([C4, E4, G4]).forte_number + '3-11' + >>> Chord([C4, E4, G4, Bb4]).forte_number + '4-27' + """ + pf = self.prime_form + return self._FORTE_NUMBERS.get(pf, None) + def fingering(self, *positions: int) -> "Fingering": """Apply fret positions to each tone, returning a Fingering. diff --git a/pytheory/scales.py b/pytheory/scales.py index 318781d..c47de49 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -293,6 +293,60 @@ class Scale: return (best[1], best[2], best[3]) return None + @staticmethod + def recommend(*note_names: str, top: int = 5) -> list[tuple[str, str, float]]: + """Recommend the best-matching scales for a set of notes, ranked by fitness. + + Tests the given notes against every scale in the Western system + and returns the top matches. Useful for figuring out what scale + a melody or chord progression belongs to, or finding alternative + scales to play over a set of changes. + + Args: + *note_names: Note name strings (e.g. ``"C"``, ``"E"``, ``"G"``). + top: Number of results to return (default 5). + + Returns: + A list of ``(tonic, scale_name, fitness)`` tuples sorted + by fitness descending. Fitness is 0.0–1.0. + + Example:: + + >>> Scale.recommend("C", "D", "E", "G", "A") + [('C', 'major', 1.0), ('G', 'major', 1.0), ...] + >>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb") + [('C', 'blues', 1.0), ...] + """ + if not note_names: + return [] + + results = [] + chromatic = ["C", "C#", "D", "D#", "E", "F", + "F#", "G", "G#", "A", "A#", "B"] + + for tonic in chromatic: + ts = TonedScale(tonic=f"{tonic}4") + for scale_name in ts.scales: + try: + scale = ts[scale_name] + fit = scale.fitness(*note_names) + if fit > 0: + results.append((tonic, scale_name, fit)) + except (KeyError, ValueError): + continue + + # Penalize chromatic scale — it matches everything but tells you nothing + # Also prefer scales whose length is closer to the input length + input_len = len(note_names) + + def _score(r): + tonic, name, fit = r + penalty = 0.5 if "chromatic" in name else 0 + return (-fit + penalty, abs(input_len - 7), name, tonic) + + results.sort(key=_score) + return results[:top] + def harmonize(self) -> list[Chord]: """Build diatonic triads on every scale degree. diff --git a/test_pytheory.py b/test_pytheory.py index 35fed46..797f483 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -6177,3 +6177,150 @@ def test_repl_chords(capsys): out = capsys.readouterr().out assert "C major" in out assert "D minor" in out + + +# ── Figured Bass ────────────────────────────────────────────────────────────── + +def test_figured_bass_root_position(): + chord = Chord.from_tones("C", "E", "G") + assert chord.figured_bass == "" + + +def test_figured_bass_first_inversion(): + # E in bass: first inversion of C major + chord = Chord.from_symbol("C").inversion(1) + assert chord.figured_bass == "6" + + +def test_figured_bass_second_inversion(): + # G in bass: second inversion of C major + chord = Chord.from_symbol("C").inversion(2) + assert chord.figured_bass == "6/4" + + +def test_figured_bass_seventh_root(): + # G7 in root position: G is the lowest note + chord = Chord.from_symbol("G7", octave=4) + assert chord.figured_bass == "7" + + +def test_figured_bass_seventh_first_inv(): + # First inversion of G7: B in bass + chord = Chord.from_symbol("G7").inversion(1) + assert chord.figured_bass == "6/5" + + +def test_figured_bass_seventh_second_inv(): + chord = Chord.from_symbol("G7").inversion(2) + assert chord.figured_bass == "4/3" + + +def test_figured_bass_seventh_third_inv(): + chord = Chord.from_symbol("G7").inversion(3) + assert chord.figured_bass == "2" + + +def test_analyze_figured(): + # V7 in root position + chord = Chord.from_symbol("G7") + result = chord.analyze_figured("C") + assert result == "V7" + + +def test_analyze_figured_with_inversion(): + # V in first inversion + chord = Chord.from_symbol("G").inversion(1) + result = chord.analyze_figured("C") + assert result == "V6" + + +# ── Pitch Class Set Theory ─────────────────────────────────────────────────── + +def test_pitch_classes(): + chord = Chord.from_tones("C", "E", "G") + assert chord.pitch_classes == {0, 4, 7} + + +def test_pitch_classes_with_sharps(): + chord = Chord.from_tones("C", "E", "G#") + assert chord.pitch_classes == {0, 4, 8} + + +def test_normal_form(): + chord = Chord.from_tones("C", "E", "G") + assert chord.normal_form == (0, 4, 7) + + +def test_prime_form_major(): + # Major and minor triads share the same prime form (0, 3, 7) + # because C major (0,4,7) inverts to (0,5,8) -> normal form (0,3,7) + chord = Chord.from_tones("C", "E", "G") + assert chord.prime_form == (0, 3, 7) + + +def test_prime_form_minor(): + # Minor triad: A C E has intervals 0,3,7 which inverts to 0,5,9 + # Normal form of inversion: best compact = (0,3,7) via inversion check + chord = Chord.from_tones("A", "C", "E") + assert chord.prime_form == (0, 3, 7) + + +def test_forte_number_triad(): + chord = Chord.from_tones("C", "E", "G") + assert chord.forte_number == "3-11" + + +def test_forte_number_minor_triad(): + chord = Chord.from_tones("A", "C", "E") + assert chord.forte_number == "3-11" + + +def test_forte_number_dom7(): + chord = Chord.from_symbol("G7") + assert chord.forte_number == "4-27" + + +def test_forte_number_augmented(): + chord = Chord.from_tones("C", "E", "G#") + assert chord.forte_number == "3-12" + + +def test_forte_number_diminished7(): + chord = Chord.from_tones("B", "D", "F", "Ab") + assert chord.forte_number == "4-28" + + +# ── Scale.recommend ─────────────────────────────────────────────────────── + +def test_recommend_c_major_notes(): + from pytheory.scales import Scale + results = Scale.recommend("C", "D", "E", "F", "G", "A", "B") + assert len(results) > 0 + assert results[0][2] == 1.0 # perfect match + # Chromatic should NOT be the top result + assert "chromatic" not in results[0][1] + + +def test_recommend_pentatonic(): + from pytheory.scales import Scale + results = Scale.recommend("C", "D", "E", "G", "A") + assert len(results) > 0 + assert results[0][2] == 1.0 + + +def test_recommend_returns_top(): + from pytheory.scales import Scale + results = Scale.recommend("C", "E", "G", top=3) + assert len(results) <= 3 + + +def test_recommend_empty(): + from pytheory.scales import Scale + assert Scale.recommend() == [] + + +def test_recommend_fitness_descending(): + from pytheory.scales import Scale + results = Scale.recommend("C", "D", "E", "F#", "G") + for i in range(len(results) - 1): + assert results[i][2] >= results[i + 1][2] diff --git a/uv.lock b/uv.lock index 82526ec..a4c68eb 100644 --- a/uv.lock +++ b/uv.lock @@ -707,7 +707,7 @@ wheels = [ [[package]] name = "pytheory" -version = "0.27.2" +version = "0.28.0" source = { editable = "." } dependencies = [ { name = "numeral" },