API ergonomics, curated fingerings, bounded caching, slow test markers

- Fretboard["G"] shorthand via __getitem__
- Chord repr now shows <Chord C major> format
- Scale = TonedScale and Note = Tone aliases
- GUITAR_OVERRIDES dict with 15 curated standard chord shapes
  (F barre 133211, B barre x24442, etc.)
- Bounded caches (max 1024 entries) for fingerings and possible_fingerings
- @pytest.mark.slow on 4 chart-generation tests; fast suite runs in 2s
- Highlights section moved above CLI examples on docs homepage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 08:52:17 -04:00
parent de1db0aa8d
commit d53d8b60dd
6 changed files with 105 additions and 17 deletions
+15 -15
View File
@@ -32,6 +32,21 @@ instruments using a clean, Pythonic API.
>>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
Highlights
----------
- **Tones**: frequencies, MIDI, intervals, transposition, circle of fifths,
overtone series, 3 temperaments (equal, Pythagorean, meantone)
- **Scales**: 40+ scales across 6 musical systems — Western, Indian,
Arabic, Japanese, Blues, Javanese Gamelan
- **Chords**: 17 chord types identified automatically, Roman numeral
analysis, tension scoring, voice leading, consonance/dissonance
- **Keys**: key detection, signatures, progressions (Roman numerals and
Nashville numbers), borrowed chords, secondary dominants
- **Instruments**: 25 presets (guitar, bass, ukulele, mandolin, violin,
banjo, oud, sitar, erhu, and more) with fingering generation
- **Audio**: sine, sawtooth, and triangle wave playback + WAV export
It also works from the command line::
$ pytheory key G major
@@ -50,21 +65,6 @@ It also works from the command line::
Playing: A minor 7th (A4 C4 E4 G4)
Synth: triangle
Highlights
----------
- **Tones**: frequencies, MIDI, intervals, transposition, circle of fifths,
overtone series, 3 temperaments (equal, Pythagorean, meantone)
- **Scales**: 40+ scales across 6 musical systems — Western, Indian,
Arabic, Japanese, Blues, Javanese Gamelan
- **Chords**: 17 chord types identified automatically, Roman numeral
analysis, tension scoring, voice leading, consonance/dissonance
- **Keys**: key detection, signatures, progressions (Roman numerals and
Nashville numbers), borrowed chords, secondary dominants
- **Instruments**: 25 presets (guitar, bass, ukulele, mandolin, violin,
banjo, oud, sitar, erhu, and more) with fingering generation
- **Audio**: sine, sawtooth, and triangle wave playback + WAV export
.. toctree::
:maxdepth: 2
:caption: User Guide
+3
View File
@@ -44,5 +44,8 @@ docs = ["sphinx"]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
[tool.pytest.ini_options]
markers = ["slow: marks tests as slow (deselect with '-m \"not slow\"')"]
[tool.setuptools]
packages = ["pytheory"]
+2 -1
View File
@@ -4,7 +4,7 @@ __version__ = "0.7.0"
from .tones import Tone, Interval
from .systems import System, SYSTEMS
from .scales import Scale, TonedScale, Key, PROGRESSIONS
from .scales import TonedScale, Key, PROGRESSIONS
from .chords import Chord, Fretboard, analyze_progression
from .charts import CHARTS, Fingering, charts_for_fretboard
@@ -17,6 +17,7 @@ except OSError:
# Aliases for discoverability.
Note = Tone
Scale = TonedScale
__all__ = [
"Tone", "Note", "Interval", "Scale", "TonedScale", "Key",
+64 -1
View File
@@ -8,11 +8,38 @@ from .tones import Tone
QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9")
MAX_FRET = 7
# Standard guitar tuning (high to low): E4 B3 G3 D3 A2 E2
STANDARD_GUITAR_TUNING = ("E4", "B3", "G3", "D3", "A2", "E2")
# Curated override fingerings for common guitar chords in standard tuning.
# Key: chord name, Value: tuple of fret positions (-1 = muted string).
# Order is high-to-low (matching Fretboard.guitar() string order).
GUITAR_OVERRIDES = {
"C": (0, 1, 0, 2, 3, -1),
"D": (2, 3, 2, 0, -1, -1),
"Dm": (1, 3, 2, 0, -1, -1),
"D7": (2, 1, 2, 0, -1, -1),
"E": (0, 0, 1, 2, 2, 0),
"Em": (0, 0, 0, 2, 2, 0),
"F": (1, 1, 2, 3, 3, 1),
"G": (3, 0, 0, 0, 2, 3),
"G7": (1, 0, 0, 0, 2, 3),
"A": (0, 2, 2, 2, 0, -1),
"Am": (0, 1, 2, 2, 0, -1),
"Am7": (0, 1, 0, 2, 0, -1),
"B": (2, 4, 4, 4, 2, -1),
"Bm": (2, 3, 4, 4, 2, -1),
"B7": (2, 0, 2, 1, 2, -1),
}
# Memoization cache for fingering lookups.
# Key: (chord_name, fretboard_tuning_tuple)
# Value: Fingering object (single) or tuple of Fingerings (multiple)
# Bounded to _CACHE_MAX_SIZE entries; cleared entirely when full.
_CACHE_MAX_SIZE = 1024
_fingering_cache: dict[tuple, "Fingering"] = {}
_fingering_multi_cache: dict[tuple, tuple] = {}
_possible_cache: dict[tuple, tuple] = {}
class Fingering:
@@ -225,6 +252,11 @@ class NamedChord:
return tuple([tone.name for tone in self.acceptable_tones])
def _possible_fingerings(self, *, fretboard):
# Check the _possible_cache first
key = self._cache_key(fretboard)
if key in _possible_cache:
return _possible_cache[key]
def find_fingerings(tone):
fingerings = []
for j in range(MAX_FRET):
@@ -244,7 +276,14 @@ class NamedChord:
else:
fingering.append((-1,))
return tuple(fingering)
result = tuple(fingering)
# Bounded cache: clear entirely if over limit
if len(_possible_cache) >= _CACHE_MAX_SIZE:
_possible_cache.clear()
_possible_cache[key] = result
return result
@staticmethod
def fix_fingering(fingering):
@@ -271,6 +310,24 @@ class NamedChord:
if key in _fingering_cache:
return _fingering_cache[key]
# Check for curated guitar chord overrides in standard tuning
tuning = tuple(t.full_name for t in fretboard.tones)
if tuning == STANDARD_GUITAR_TUNING and self.name in GUITAR_OVERRIDES:
string_names = tuple(t.name for t in fretboard.tones)
override = GUITAR_OVERRIDES[self.name]
if not multiple:
result = Fingering(self.fix_fingering(override), string_names, fretboard=fretboard)
if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear()
_fingering_cache[key] = result
return result
else:
result = (Fingering(self.fix_fingering(override), string_names, fretboard=fretboard),)
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear()
_fingering_multi_cache[key] = result
return result
MAX_SPAN = 4 # max fret span for a human hand
def fingering_score(fingering):
@@ -376,10 +433,16 @@ class NamedChord:
best_fingerings = tuple([g for g in gen()])
if not multiple:
result = Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard)
# Bounded cache: clear entirely if over limit
if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear()
_fingering_cache[key] = result
return result
else:
result = tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings])
# Bounded cache: clear entirely if over limit
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear()
_fingering_multi_cache[key] = result
return result
+17
View File
@@ -1270,6 +1270,23 @@ class Fretboard:
from .charts import CHARTS
return CHARTS[system][name].fingering(fretboard=self)
def __getitem__(self, name: str) -> "Fingering":
"""Shorthand for :meth:`chord` — ``fb["G"]`` equals ``fb.chord("G")``.
Args:
name: Chord name like ``"G"``, ``"Am7"``, ``"Bb"``.
Returns:
A :class:`Fingering` for that chord on this fretboard.
Example::
>>> fb = Fretboard.guitar()
>>> fb["G"]
Fingering(e=3, B=0, G=0, D=0, A=2, E=3)
"""
return self.chord(name)
def tab(self, name: str, *, system: str = "western") -> str:
"""Look up a chord by name and return its ASCII tablature.
+4
View File
@@ -525,6 +525,7 @@ def test_chord_fingering_em(guitar_fretboard):
assert zeros >= 3
@pytest.mark.slow
def test_chord_fingering_all_western_chords(guitar_fretboard):
"""Every chord in the western chart should produce a valid fingering."""
for name, chord in CHARTS["western"].items():
@@ -1330,6 +1331,7 @@ def test_charts_all_qualities_present():
assert len(matching) > 0, f"No chords with quality '{quality}'"
@pytest.mark.slow
def test_charts_for_fretboard(guitar_fretboard):
result = charts_for_fretboard(fretboard=guitar_fretboard)
assert len(result) == len(CHARTS["western"])
@@ -1337,6 +1339,7 @@ def test_charts_for_fretboard(guitar_fretboard):
assert len(fingering) == 6, f"{name} has wrong fingering length"
@pytest.mark.slow
def test_charts_fingering_values_in_range(guitar_fretboard):
"""All fret values should be 0-6 or None (muted)."""
for name, chord in CHARTS["western"].items():
@@ -3673,6 +3676,7 @@ def test_fretboard_tab_method():
assert "E|" in tab
@pytest.mark.slow
def test_fretboard_chart_method():
"""Fretboard.chart() generates all fingerings."""
fb = Fretboard.guitar()