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) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 07:13:26 -04:00
parent 0843c21884
commit a766737707
9 changed files with 573 additions and 3 deletions
+7
View File
@@ -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
+70
View File
@@ -528,3 +528,73 @@ labeling them with flat-degree prefixes:
'bVI'
>>> Chord.from_symbol("Bb").analyze("C", "major")
'bVII'
Figured Bass
------------
`Figured bass <https://en.wikipedia.org/wiki/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 <https://en.wikipedia.org/wiki/Set_theory_(music)>`_
is the framework for analyzing atonal and post-tonal music. It reduces
any collection of notes to abstract pitch classes (011, 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'
+19
View File
@@ -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
~~~~~~~~~~~~~~
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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
+273
View File
@@ -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.
+54
View File
@@ -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.01.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.
+147
View File
@@ -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]
Generated
+1 -1
View File
@@ -707,7 +707,7 @@ wheels = [
[[package]]
name = "pytheory"
version = "0.27.2"
version = "0.28.0"
source = { editable = "." }
dependencies = [
{ name = "numeral" },