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