import itertools
from typing import Optional
from .systems import SYSTEMS
from .tones import Tone
QUALITIES = ("", "maj", "m", "5", "7", "9", "dim", "m6", "m7", "m9", "maj7", "maj9")
MAX_FRET = 7
[docs]
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
"""
[docs]
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))
[docs]
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
[docs]
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)
[docs]
def identify(self) -> Optional[str]:
"""Identify the chord name from this fingering."""
return self.to_chord().identify()
CHARTS = {}
CHARTS["western"] = []
[docs]
class NamedChord:
[docs]
def __init__(self, *, tone_name, quality):
self.tone_name = tone_name
self.quality = quality
self._tone = None
@property
def name(self):
return f"{self.tone_name}{self.quality}"
@property
def tone(self):
if self._tone is None:
flat_to_sharp = {"Ab": "G#", "Bb": "A#", "Db": "C#", "Eb": "D#", "Gb": "F#"}
tone_name = flat_to_sharp.get(self.tone_name, self.tone_name)
self._tone = Tone(name=tone_name)
return self._tone
[docs]
def __repr__(self):
return f"<NamedChord name={self.name!r}>"
@property
def acceptable_tones(self):
acceptable = [self.tone]
if self.quality == "maj":
# Major triad: root, major 3rd, perfect 5th
acceptable += [self.tone.add(4), self.tone.add(7)]
elif self.quality == "m":
# Minor triad: root, minor 3rd, perfect 5th
acceptable += [self.tone.add(3), self.tone.add(7)]
elif self.quality == "5":
# Power chord: root, perfect 5th
acceptable += [self.tone.add(7)]
elif self.quality == "7":
# Dominant 7th: root, major 3rd, perfect 5th, minor 7th
acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(10)]
elif self.quality == "9":
# Dominant 9th: root, major 3rd, perfect 5th, minor 7th, major 9th
acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(10), self.tone.add(2)]
elif self.quality == "dim":
# Diminished: root, minor 3rd, diminished 5th
acceptable += [self.tone.add(3), self.tone.add(6)]
elif self.quality == "m6":
# Minor 6th: root, minor 3rd, perfect 5th, major 6th
acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(9)]
elif self.quality == "m7":
# Minor 7th: root, minor 3rd, perfect 5th, minor 7th
acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(10)]
elif self.quality == "m9":
# Minor 9th: root, minor 3rd, perfect 5th, minor 7th, major 9th
acceptable += [self.tone.add(3), self.tone.add(7), self.tone.add(10), self.tone.add(2)]
elif self.quality == "maj7":
# Major 7th: root, major 3rd, perfect 5th, major 7th
acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(11)]
elif self.quality == "maj9":
# Major 9th: root, major 3rd, perfect 5th, major 7th, major 9th
acceptable += [self.tone.add(4), self.tone.add(7), self.tone.add(11), self.tone.add(2)]
else:
# Default (no quality): major triad
acceptable += [self.tone.add(4), self.tone.add(7)]
return tuple(acceptable)
@property
def acceptable_tone_names(self):
return tuple([tone.name for tone in self.acceptable_tones])
def _possible_fingerings(self, *, fretboard):
def find_fingerings(tone):
fingerings = []
for j in range(MAX_FRET):
fingered_tone = tone.add(j)
for acceptable_tone in self.acceptable_tones:
if fingered_tone.name == acceptable_tone:
fingerings.append(j)
return tuple(fingerings)
fingering = []
for i, tone in enumerate(fretboard.tones):
fingering.append(find_fingerings(tone))
for i, finger in enumerate(fingering):
if finger == ():
fingering[i] = (-1,)
return tuple(fingering)
[docs]
@staticmethod
def fix_fingering(fingering):
fingering = list(fingering)
for i, finger in enumerate(fingering):
if finger == -1:
fingering[i] = None
return tuple(fingering)
[docs]
def fingerings(self, *, fretboard):
return tuple(itertools.product(*self._possible_fingerings(fretboard=fretboard)))
[docs]
def fingering(self, *, fretboard, multiple=False):
def fingering_score(fingering):
def number_of_fingers(fingering):
zeros = 0
for finger in fingering:
if finger == 0:
zeros += 1
return len(fingering) - zeros
def ascending(fingering):
fingering = [f for f in fingering if f != 0]
return sorted(fingering) == fingering
ascending = int(ascending(fingering))
finger_count = number_of_fingers(fingering)
return ascending + (1 / finger_count)
def gen():
fingerings = self.fingerings(fretboard=fretboard)
score_map = tuple(map(fingering_score, fingerings))
max_score = max(score_map)
for possible_fingering in fingerings:
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 Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard)
else:
return tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings])
[docs]
def tab(self, *, fretboard):
"""Render this chord as ASCII guitar tablature.
Example::
>>> print(CHARTS["western"]["C"].tab(fretboard=Fretboard.guitar()))
C
e|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--0--
"""
fingering = self.fingering(fretboard=fretboard)
string_names = [t.name for t in fretboard.tones]
lines = [self.name]
max_name = max(len(n) for n in string_names)
for i, (name, fret) in enumerate(zip(string_names, fingering)):
fret_str = "x" if fret is None else str(fret)
lines.append(f"{name:>{max_name}}|--{fret_str}--")
return "\n".join(lines)
western_chart = {}
for tone_titles in SYSTEMS["western"].tone_names:
# Take the second tone name, if it's available.
if len(tone_titles) == 2:
tone_name = tone_titles[1]
else:
tone_name = tone_titles[0]
for quality in QUALITIES:
named_chord = NamedChord(tone_name=tone_name, quality=quality)
western_chart.update({f"{tone_name}{quality}": named_chord})
CHARTS["western"] = western_chart
[docs]
def charts_for_fretboard(*, chart=CHARTS["western"], fretboard):
super_chart = {}
for chord in chart:
super_chart[chord] = chart[chord].fingering(fretboard=fretboard)
return super_chart