From d53d8b60dd1708687afebbd193a1715abb474b49 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Mon, 23 Mar 2026 08:52:17 -0400 Subject: [PATCH] API ergonomics, curated fingerings, bounded caching, slow test markers - Fretboard["G"] shorthand via __getitem__ - Chord repr now shows 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) --- docs/index.rst | 30 ++++++++++---------- pyproject.toml | 3 ++ pytheory/__init__.py | 3 +- pytheory/charts.py | 65 +++++++++++++++++++++++++++++++++++++++++++- pytheory/chords.py | 17 ++++++++++++ test_pytheory.py | 4 +++ 6 files changed, 105 insertions(+), 17 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index ba5b717..217b54f 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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 diff --git a/pyproject.toml b/pyproject.toml index c9c7b69..819ddb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/pytheory/__init__.py b/pytheory/__init__.py index fac1fba..8e1da75 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -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", diff --git a/pytheory/charts.py b/pytheory/charts.py index c49cf99..9d5c325 100644 --- a/pytheory/charts.py +++ b/pytheory/charts.py @@ -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 diff --git a/pytheory/chords.py b/pytheory/chords.py index 2791dff..563403f 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -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. diff --git a/test_pytheory.py b/test_pytheory.py index c054413..8d813f3 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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()