From e8bfeb884add4659a8c50adf457c83726eee1cc4 Mon Sep 17 00:00:00 2001 From: Kenneth Reitz Date: Sun, 22 Mar 2026 16:11:37 -0400 Subject: [PATCH] Add Fingering class for labeled chord fingerings (#25) Replace plain tuples from fingering() methods with a Fingering object that labels each fret position with its string name, supporting both named (f['A']) and index (f[1]) access while remaining backward compatible with tuple equality. Co-Authored-By: Claude Opus 4.6 (1M context) --- pytheory/__init__.py | 4 +- pytheory/charts.py | 106 ++++++++++++++++++++++++++++++++++++++++++- pytheory/chords.py | 31 ++++++------- 3 files changed, 121 insertions(+), 20 deletions(-) diff --git a/pytheory/__init__.py b/pytheory/__init__.py index 5664781..219c61b 100644 --- a/pytheory/__init__.py +++ b/pytheory/__init__.py @@ -6,7 +6,7 @@ from .tones import Tone, Interval from .systems import System, SYSTEMS from .scales import Scale, TonedScale, Key, PROGRESSIONS from .chords import Chord, Fretboard, analyze_progression -from .charts import CHARTS, charts_for_fretboard +from .charts import CHARTS, Fingering, charts_for_fretboard try: from .play import play, Synth @@ -19,7 +19,7 @@ Note = Tone __all__ = [ "Tone", "Note", "Interval", "Scale", "TonedScale", "Key", - "PROGRESSIONS", "Chord", "Fretboard", "analyze_progression", + "PROGRESSIONS", "Chord", "Fretboard", "Fingering", "analyze_progression", "System", "SYSTEMS", "CHARTS", "charts_for_fretboard", "play", "Synth", ] diff --git a/pytheory/charts.py b/pytheory/charts.py index cab4642..8fb2759 100644 --- a/pytheory/charts.py +++ b/pytheory/charts.py @@ -1,4 +1,5 @@ import itertools +from typing import Optional from .systems import SYSTEMS from .tones import Tone @@ -6,6 +7,106 @@ from .tones import Tone QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9") MAX_FRET = 7 + +class Fingering: + """A chord fingering labeled with string names. + + Provides both index and named access to fret positions, making it + clear which string each position corresponds to. + + Example:: + + >>> f = Fingering(positions=(0, 3, 2, 0, 1, 0), + ... string_names=('E', 'A', 'D', 'G', 'B', 'e')) + >>> f + Fingering(E=0, A=3, D=2, G=0, B=1, e=0) + >>> f['A'] + 3 + >>> f[1] + 3 + """ + + def __init__(self, positions: tuple, string_names: tuple[str, ...], *, fretboard=None) -> None: + self.positions = tuple(positions) + self._fretboard = fretboard + # Disambiguate duplicate names: for standard guitar tuning + # (high-to-low), the first occurrence of a duplicate becomes + # lowercase (e.g. high E → 'e') while the last keeps uppercase. + from collections import Counter + name_counts = Counter(string_names) + seen: dict[str, int] = {} + unique_names: list[str] = [] + for name in string_names: + seen[name] = seen.get(name, 0) + 1 + if name_counts[name] > 1 and seen[name] < name_counts[name]: + unique_names.append(name.lower()) + else: + unique_names.append(name) + + self.string_names = tuple(unique_names) + self._map = dict(zip(self.string_names, self.positions)) + + def __repr__(self) -> str: + pairs = ", ".join( + f"{name}={'x' if pos is None else pos}" + for name, pos in zip(self.string_names, self.positions) + ) + return f"Fingering({pairs})" + + def __getitem__(self, key): + if isinstance(key, int): + return self.positions[key] + return self._map[key] + + def __iter__(self): + return iter(self.positions) + + def __len__(self): + return len(self.positions) + + def __eq__(self, other): + if isinstance(other, Fingering): + return self.positions == other.positions and self.string_names == other.string_names + if isinstance(other, tuple): + return self.positions == other + return NotImplemented + + @property + def tones(self): + """Return the sounding tones for this fingering. + + Requires that the Fingering was created with a fretboard reference. + Muted strings (``None``) are excluded. + """ + if self._fretboard is None: + raise ValueError("Cannot resolve tones without a fretboard reference.") + tones = [] + for pos, tone in zip(self.positions, self._fretboard.tones): + if pos is not None: + tones.append(tone.add(pos)) + return tones + + def to_chord(self, fretboard=None) -> "Chord": + """Apply this fingering to a fretboard, returning a Chord. + + Strings with ``None`` positions (muted) are excluded. + If no fretboard is given, uses the one stored at creation time. + """ + from .chords import Chord + + fb = fretboard or self._fretboard + if fb is None: + raise ValueError("No fretboard provided.") + tones = [] + for pos, tone in zip(self.positions, fb.tones): + if pos is not None: + tones.append(tone.add(pos)) + return Chord(tones=tones) + + def identify(self) -> Optional[str]: + """Identify the chord name from this fingering.""" + return self.to_chord().identify() + CHARTS = {} CHARTS["western"] = [] @@ -148,11 +249,12 @@ class NamedChord: if fingering_score(possible_fingering) == max_score: yield possible_fingering + string_names = tuple(t.name for t in fretboard.tones) best_fingerings = tuple([g for g in gen()]) if not multiple: - return self.fix_fingering(best_fingerings[0]) + return Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard) else: - return tuple([self.fix_fingering(f) for f in best_fingerings]) + return tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings]) def tab(self, *, fretboard): """Render this chord as ASCII guitar tablature. diff --git a/pytheory/chords.py b/pytheory/chords.py index 28c74d1..117bbc4 100644 --- a/pytheory/chords.py +++ b/pytheory/chords.py @@ -680,8 +680,8 @@ class Chord: """ return Chord(tones=[t for t in self.tones if t.name != tone_name]) - def fingering(self, *positions: int) -> Chord: - """Apply fret positions to each tone, returning a new Chord. + def fingering(self, *positions: int) -> "Fingering": + """Apply fret positions to each tone, returning a Fingering. Each position value is added (in semitones) to the corresponding tone. The number of positions must match the number of tones. @@ -690,22 +690,21 @@ class Chord: *positions: One integer per tone indicating the fret offset. Returns: - A new :class:`Chord` with each tone shifted by its position. + A :class:`Fingering` labeled with tone names. Raises: ValueError: If the number of positions doesn't match the number of tones. """ + from .charts import Fingering + if not len(positions) == len(self.tones): raise ValueError( "The number of positions must match the number of tones (strings)." ) - tones = [] - for i, tone in enumerate(self.tones): - tones.append(tone.add(positions[i])) - - return Chord(tones=tones) + string_names = tuple(t.name for t in self.tones) + return Fingering(positions, string_names) class Fretboard: @@ -1252,8 +1251,8 @@ class Fretboard: return "\n".join(lines) - def fingering(self, *positions: int) -> Chord: - """Apply fret positions to each string, returning a Chord. + def fingering(self, *positions: int) -> "Fingering": + """Apply fret positions to each string, returning a Fingering. Each position value is added (in semitones) to the corresponding open-string tone. The number of positions must match the number @@ -1263,22 +1262,22 @@ class Fretboard: *positions: One integer per string indicating the fret number. Returns: - A :class:`Chord` with each tone shifted by its fret position. + A :class:`Fingering` labeled with string names. Call + ``.to_chord(fretboard)`` or use the resulting chord directly. Raises: ValueError: If the number of positions doesn't match the number of strings. """ + from .charts import Fingering + if not len(positions) == len(self.tones): raise ValueError( "The number of positions must match the number of tones (strings)." ) - tones = [] - for i, tone in enumerate(self.tones): - tones.append(tone.add(positions[i])) - - return Chord(tones=tones) + string_names = tuple(t.name for t in self.tones) + return Fingering(positions, string_names, fretboard=self) def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]: