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