Low-to-high fingerings by default (high_to_low opt-out) — v0.43.0

Fretboard string lists and Fingering positions/string-names now read
low-to-high (lowest-pitched string first), matching how chord diagrams
and tablature are conventionally written. Pass high_to_low=True to any
fretboard constructor to restore the pre-0.43 high-to-low behavior.

Design: each board keeps a private canonical (high-to-low) tone store
so the fingering scorer and GUITAR_OVERRIDES table stay untouched; a
single _orient() helper re-orients at the user-facing boundary (and,
being self-inverse, also canonicalizes custom tuning/position input).
Fingering carries its own orientation flag and presents oriented
positions/names via properties. The fingering cache key now includes
orientation so the two orderings don't collide.

to_tab() and Part.strum() now sort by pitch internally, so their output
is identical regardless of board orientation.

- All 25 instrument presets gain a high_to_low param, routed through a
  canonical build path.
- Tests updated for the new default; added orientation-specific tests.
- Docs/examples flipped to low-to-high; chord_charts.py example now uses
  the built-in Fingering.tab() instead of a hand-rolled renderer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 10:42:11 -04:00
parent b3f3e985b4
commit 6c83dbe5aa
11 changed files with 414 additions and 325 deletions
+23
View File
@@ -2,6 +2,29 @@
All notable changes to PyTheory are documented here. All notable changes to PyTheory are documented here.
## 0.43.0
- **BREAKING — fingerings now read low-to-high by default.** `Fretboard`
string lists and `Fingering` positions/string-names now run from the
**lowest-pitched string first** (e.g. standard guitar reads `E A D G B E`),
matching how chord diagrams and tablature are conventionally written.
Previously they ran high-to-low (`E B G D A E`). This affects
`Fretboard.tones`, iteration over a fretboard, `repr`, `chord()`, `tab()`,
`chart()`, and `fingering()` output.
To restore the pre-0.43 high-to-low behavior, pass **`high_to_low=True`**
to any fretboard constructor — `Fretboard.guitar(high_to_low=True)`,
`Fretboard(tones=..., high_to_low=True)`, and likewise on every instrument
preset (`bass`, `ukulele`, `mandolin`, … `keyboard`).
The flip also applies to **input**: a custom tuning tuple passed to
`Fretboard.guitar(...)` and manual fret positions passed to
`fingering(*positions)` are now read in the board's orientation
(low-to-high by default).
`to_tab()` and `Part.strum()` are unaffected — they sort by pitch
internally and produce identical output regardless of orientation.
## 0.42.1 ## 0.42.1
- **Fretboard tuning support** — `to_tab()` now accepts `Fretboard` objects as - **Fretboard tuning support** — `to_tab()` now accepts `Fretboard` objects as
+3 -3
View File
@@ -94,11 +94,11 @@ PyTheory includes 144 pre-built chords (12 roots x 12 qualities):
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> fb.chord("C") >>> fb.chord("C")
Fingering(e=0, B=1, G=0, D=2, A=3, E=x) Fingering(E=x, A=3, D=2, G=0, B=1, e=0)
>>> fb.chord("Am") >>> fb.chord("Am")
Fingering(e=0, B=1, G=2, D=2, A=0, E=x) Fingering(E=x, A=0, D=2, G=2, B=1, e=0)
>>> fb.chord("G7") >>> fb.chord("G7")
Fingering(e=1, B=0, G=0, D=0, A=2, E=3) Fingering(E=3, A=2, D=0, G=0, B=0, e=1)
You can also build chords directly with ``Chord.from_name()``: You can also build chords directly with ``Chord.from_name()``:
+40 -31
View File
@@ -18,19 +18,28 @@ positions are just semitone steps along the fingerboard.
Guitars Guitars
------- -------
`Standard guitar tuning <https://en.wikipedia.org/wiki/Guitar_tunings>`_ `Standard guitar tuning <https://en.wikipedia.org/wiki/Guitar_tunings>`_::
(high to low)::
String 1: E4 (highest)
String 2: B3
String 3: G3
String 4: D3
String 5: A2
String 6: E2 (lowest) String 6: E2 (lowest)
String 5: A2
String 4: D3
String 3: G3
String 2: B3
String 1: E4 (highest)
This tuning uses intervals of a perfect 4th (5 semitones) between most This tuning uses intervals of a perfect 4th (5 semitones) between most
strings, except between G and B which is a major 3rd (4 semitones). strings, except between G and B which is a major 3rd (4 semitones).
.. note::
Since **v0.43.0**, fingerings and string lists read **low to high**
(lowest-pitched string first) by default — matching how chord
diagrams and tab are conventionally written. To get the pre-0.43
high-to-low order, pass ``high_to_low=True`` to any fretboard
constructor, e.g. ``Fretboard.guitar(high_to_low=True)``. A custom
tuning tuple and manual ``fingering()`` positions are likewise read
in the board's orientation.
.. code-block:: pycon .. code-block:: pycon
>>> from pytheory import Fretboard >>> from pytheory import Fretboard
@@ -192,12 +201,12 @@ on any instrument. It scores each possibility by:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> f = fb.chord("C") >>> f = fb.chord("C")
>>> f >>> f
Fingering(e=0, B=1, G=0, D=2, A=3, E=x) Fingering(E=x, A=3, D=2, G=0, B=1, e=0)
>>> f['A'] >>> f['A']
3 3
>>> f[1] >>> f[1]
1 3
>>> f.identify() >>> f.identify()
'C major' 'C major'
@@ -210,11 +219,11 @@ You can also go from fret positions to chord identification:
.. code-block:: pycon .. code-block:: pycon
>>> # "What chord am I playing?" >>> # "What chord am I playing?" (positions read low to high)
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> f = fb.fingering(0, 0, 0, 2, 2, 0) >>> f = fb.fingering(0, 2, 2, 0, 0, 0)
>>> f >>> f
Fingering(e=0, B=0, G=0, D=2, A=2, E=0) Fingering(E=0, A=2, D=2, G=0, B=0, e=0)
>>> f.identify() >>> f.identify()
'E minor' 'E minor'
@@ -223,14 +232,14 @@ Reading Fingerings
Each position is labeled with its string name. Duplicate string names Each position is labeled with its string name. Duplicate string names
are disambiguated — on a standard guitar, high E appears as ``e`` and are disambiguated — on a standard guitar, high E appears as ``e`` and
low E as ``E``:: low E as ``E``. Strings read low to high (lowest first)::
e|--0-- (open — E) E|--x-- (muted — low E)
B|--1-- (fret 1 — C)
G|--0-- (open — G)
D|--2-- (fret 2 — E)
A|--3-- (fret 3 — C) A|--3-- (fret 3 — C)
E|--x-- (muted) D|--2-- (fret 2 — E)
G|--0-- (open — G)
B|--1-- (fret 1 — C)
e|--0-- (open — high E)
A value of ``x`` (``None``) means the string is muted (not played). A value of ``x`` (``None``) means the string is muted (not played).
@@ -243,12 +252,12 @@ For a more visual representation, use ``tab()``:
>>> print(fb.tab("C")) >>> print(fb.tab("C"))
C major C major
e|--0--
B|--1--
G|--0--
D|--2--
A|--3--
E|--x-- E|--x--
A|--3--
D|--2--
G|--0--
B|--1--
e|--0--
Generating Full Charts Generating Full Charts
---------------------- ----------------------
@@ -261,7 +270,7 @@ Generate fingerings for every chord at once:
>>> chart = fb.chart() >>> chart = fb.chart()
>>> chart["C"] >>> chart["C"]
Fingering(e=0, B=1, G=0, D=2, A=3, E=x) Fingering(E=x, A=3, D=2, G=0, B=1, e=0)
>>> # Works with any instrument >>> # Works with any instrument
>>> uke_chart = Fretboard.ukulele().chart() >>> uke_chart = Fretboard.ukulele().chart()
@@ -303,19 +312,19 @@ Any instrument can be modeled with custom string tunings:
>>> from pytheory import Tone, Fretboard >>> from pytheory import Tone, Fretboard
>>> # Baritone ukulele (DGBE — top 4 guitar strings) >>> # Baritone ukulele (DGBE — top 4 guitar strings, low to high)
>>> bari_uke = Fretboard(tones=[ >>> bari_uke = Fretboard(tones=[
... Tone.from_string("E4"),
... Tone.from_string("B3"),
... Tone.from_string("G3"),
... Tone.from_string("D3"), ... Tone.from_string("D3"),
... Tone.from_string("G3"),
... Tone.from_string("B3"),
... Tone.from_string("E4"),
... ]) ... ])
>>> # Tres cubano (Cuban guitar, 3 doubled courses) >>> # Tres cubano (Cuban guitar, 3 doubled courses, low to high)
>>> tres = Fretboard(tones=[ >>> tres = Fretboard(tones=[
... Tone.from_string("E4"),
... Tone.from_string("B3"),
... Tone.from_string("G3"), ... Tone.from_string("G3"),
... Tone.from_string("B3"),
... Tone.from_string("E4"),
... ]) ... ])
If it has strings, you can model it. Define the tuning, and PyTheory handles the rest -- fingerings, charts, scale diagrams, all of it. Got a weird instrument or a custom tuning? That's what the ``Fretboard`` constructor is for. If it has strings, you can model it. Define the tuning, and PyTheory handles the rest -- fingerings, charts, scale diagrams, all of it. Got a weird instrument or a custom tuning? That's what the ``Fretboard`` constructor is for.
+5 -42
View File
@@ -1,17 +1,8 @@
from pytheory import Tone, Fretboard, CHARTS from pytheory import Fretboard, CHARTS
# Create standard tuning (from high E to low E) # Standard guitar fretboard. Since v0.43.0 fingerings read low to high
standard_tuning = [ # (low E first) by default — exactly how tab is conventionally written.
Tone.from_string("E4"), # High E fretboard = Fretboard.guitar()
Tone.from_string("B3"), # B
Tone.from_string("G3"), # G
Tone.from_string("D3"), # D
Tone.from_string("A2"), # A
Tone.from_string("E2"), # Low E
]
# Create fretboard with standard tuning
fretboard = Fretboard(tones=standard_tuning)
# Define flat to sharp note mappings (updated to include all possible flats) # Define flat to sharp note mappings (updated to include all possible flats)
flat_to_sharp = {"Ab": "G#", "Bb": "A#", "Db": "C#", "Eb": "D#", "Gb": "F#"} flat_to_sharp = {"Ab": "G#", "Bb": "A#", "Db": "C#", "Eb": "D#", "Gb": "F#"}
@@ -26,34 +17,6 @@ print("Standard Guitar Chord Charts:")
print("-" * 30) print("-" * 30)
def fingering_to_tab(fingering):
if not fingering:
return ""
# Create 6 strings of dashes, representing the guitar strings
strings = ["-" * 15 for _ in range(6)]
# For each string (starting from high E)
for string_num, fret in enumerate(fingering):
if fret is not None:
# Place the fret number at the correct position
if fret == 0:
strings[string_num] = "0" + strings[string_num][1:]
else:
strings[string_num] = (
"-" * (fret - 1) + str(fret) + strings[string_num][fret:]
)
# Combine strings with newlines, and add string names
tab = "e|" + strings[0] + "\n"
tab += "B|" + strings[1] + "\n"
tab += "G|" + strings[2] + "\n"
tab += "D|" + strings[3] + "\n"
tab += "A|" + strings[4] + "\n"
tab += "E|" + strings[5] + "\n"
return tab
for chord_name in all_chords: for chord_name in all_chords:
# Store original chord name for lookup # Store original chord name for lookup
lookup_name = chord_name lookup_name = chord_name
@@ -74,7 +37,7 @@ for chord_name in all_chords:
try: try:
fingering = chord.fingering(fretboard=fretboard) fingering = chord.fingering(fretboard=fretboard)
print(f"\n{display_name}:") print(f"\n{display_name}:")
print(fingering_to_tab(fingering)) print(fingering.tab())
except Exception as e: except Exception as e:
print(f"{display_name}: Unable to calculate fingering - {str(e)}") print(f"{display_name}: Unable to calculate fingering - {str(e)}")
# Add more detailed debug information # Add more detailed debug information
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "pytheory" name = "pytheory"
version = "0.42.1" version = "0.43.0"
description = "Music Theory for Humans" description = "Music Theory for Humans"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
"""PyTheory: Music Theory for Humans.""" """PyTheory: Music Theory for Humans."""
__version__ = "0.42.1" __version__ = "0.43.0"
from .tones import Tone, Interval from .tones import Tone, Interval
from .systems import System, SYSTEMS, TET from .systems import System, SYSTEMS, TET
+77 -42
View File
@@ -13,7 +13,8 @@ STANDARD_GUITAR_TUNING = ("E4", "B3", "G3", "D3", "A2", "E2")
# Curated override fingerings for common guitar chords in standard tuning. # Curated override fingerings for common guitar chords in standard tuning.
# Key: chord name, Value: tuple of fret positions (-1 = muted string). # Key: chord name, Value: tuple of fret positions (-1 = muted string).
# Order is high-to-low (matching Fretboard.guitar() string order). # Order is canonical high-to-low (high-E first); Fingering re-orients these
# to the board's display orientation (low-to-high by default since v0.43.0).
GUITAR_OVERRIDES = { GUITAR_OVERRIDES = {
"C": (0, 1, 0, 2, 3, -1), "C": (0, 1, 0, 2, 3, -1),
"D": (2, 3, 2, 0, -1, -1), "D": (2, 3, 2, 0, -1, -1),
@@ -60,11 +61,16 @@ class Fingering:
3 3
""" """
def __init__(self, positions: tuple, string_names: tuple[str, ...], *, fretboard=None) -> None: def __init__(self, positions: tuple, string_names: tuple[str, ...], *,
self.positions = tuple(positions) fretboard=None, high_to_low: bool = True) -> None:
# `positions` / `string_names` arrive in canonical (high-to-low)
# order; `high_to_low` controls only how they're presented. The
# default (True) keeps standalone construction high-to-low.
self.high_to_low = high_to_low
self._positions = tuple(positions)
self._fretboard = fretboard self._fretboard = fretboard
# Disambiguate duplicate names: for standard guitar tuning # Disambiguate duplicate names in canonical (high-to-low) order:
# (high-to-low), the first occurrence of a duplicate becomes # the first (higher-pitched) occurrence of a duplicate becomes
# lowercase (e.g. high E → 'e') while the last keeps uppercase. # lowercase (e.g. high E → 'e') while the last keeps uppercase.
from collections import Counter from collections import Counter
name_counts = Counter(string_names) name_counts = Counter(string_names)
@@ -77,8 +83,22 @@ class Fingering:
else: else:
unique_names.append(name) unique_names.append(name)
self.string_names = tuple(unique_names) self._string_names = tuple(unique_names)
self._map = dict(zip(self.string_names, self.positions)) self._map = dict(zip(self._string_names, self._positions))
def _orient(self, seq):
"""Re-orient a canonical (high-to-low) sequence for display."""
return tuple(seq) if self.high_to_low else tuple(reversed(seq))
@property
def positions(self) -> tuple:
"""Fret positions in this fingering's orientation (low-to-high by default)."""
return self._orient(self._positions)
@property
def string_names(self) -> tuple:
"""String names in this fingering's orientation (low-to-high by default)."""
return self._orient(self._string_names)
def __repr__(self) -> str: def __repr__(self) -> str:
pairs = ", ".join( pairs = ", ".join(
@@ -114,11 +134,12 @@ class Fingering:
""" """
if self._fretboard is None: if self._fretboard is None:
raise ValueError("Cannot resolve tones without a fretboard reference.") raise ValueError("Cannot resolve tones without a fretboard reference.")
tones = [] # Zip canonical positions with canonical open tones so they always
for pos, tone in zip(self.positions, self._fretboard.tones): # align regardless of orientation, then present in display order.
if pos is not None: tones = [tone.add(pos)
tones.append(tone.add(pos)) for pos, tone in zip(self._positions, self._fretboard._tones)
return tones if pos is not None]
return tones if self.high_to_low else list(reversed(tones))
def to_chord(self, fretboard=None) -> "Chord": def to_chord(self, fretboard=None) -> "Chord":
"""Apply this fingering to a fretboard, returning a Chord. """Apply this fingering to a fretboard, returning a Chord.
@@ -131,10 +152,9 @@ class Fingering:
fb = fretboard or self._fretboard fb = fretboard or self._fretboard
if fb is None: if fb is None:
raise ValueError("No fretboard provided.") raise ValueError("No fretboard provided.")
tones = [] tones = [tone.add(pos)
for pos, tone in zip(self.positions, fb.tones): for pos, tone in zip(self._positions, fb._tones)
if pos is not None: if pos is not None]
tones.append(tone.add(pos))
return Chord(tones=tones) return Chord(tones=tones)
def identify(self) -> Optional[str]: def identify(self) -> Optional[str]:
@@ -151,12 +171,12 @@ class Fingering:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> print(fb.chord("C").tab()) >>> print(fb.chord("C").tab())
C C
e|--0-- E|--x--
B|--1--
G|--0--
D|--2--
A|--3-- A|--3--
E|--0-- D|--2--
G|--0--
B|--1--
e|--0--
""" """
if self._fretboard is None: if self._fretboard is None:
raise ValueError("Cannot render tab without a fretboard reference.") raise ValueError("Cannot render tab without a fretboard reference.")
@@ -306,7 +326,7 @@ class NamedChord:
return tuple(fingerings) return tuple(fingerings)
fingering = [] fingering = []
for i, tone in enumerate(fretboard.tones): for i, tone in enumerate(fretboard._tones):
frets = find_fingerings(tone) frets = find_fingerings(tone)
# Always allow muting as an option # Always allow muting as an option
if frets: if frets:
@@ -335,8 +355,13 @@ class NamedChord:
return tuple(itertools.product(*self._possible_fingerings(fretboard=fretboard))) return tuple(itertools.product(*self._possible_fingerings(fretboard=fretboard)))
def _cache_key(self, fretboard): def _cache_key(self, fretboard):
"""Return a hashable key for memoization.""" """Return a hashable key for memoization.
return (self.name, tuple(t.full_name for t in fretboard.tones))
Keyed on canonical tones plus orientation so the two display
orderings of the same board don't collide in the result caches.
"""
return (self.name, tuple(t.full_name for t in fretboard._tones),
fretboard.high_to_low)
def fingering(self, *, fretboard, multiple=False): def fingering(self, *, fretboard, multiple=False):
# Check cache first # Check cache first
@@ -348,19 +373,22 @@ class NamedChord:
if key in _fingering_cache: if key in _fingering_cache:
return _fingering_cache[key] return _fingering_cache[key]
# Check for curated guitar chord overrides in standard tuning # Check for curated guitar chord overrides in standard tuning.
tuning = tuple(t.full_name for t in fretboard.tones) # Overrides are written in canonical (high-to-low) order.
tuning = tuple(t.full_name for t in fretboard._tones)
if tuning == STANDARD_GUITAR_TUNING and self.name in GUITAR_OVERRIDES: if tuning == STANDARD_GUITAR_TUNING and self.name in GUITAR_OVERRIDES:
string_names = tuple(t.name for t in fretboard.tones) string_names = tuple(t.name for t in fretboard._tones)
override = GUITAR_OVERRIDES[self.name] override = GUITAR_OVERRIDES[self.name]
if not multiple: if not multiple:
result = Fingering(self.fix_fingering(override), string_names, fretboard=fretboard) result = Fingering(self.fix_fingering(override), string_names,
fretboard=fretboard, high_to_low=fretboard.high_to_low)
if len(_fingering_cache) >= _CACHE_MAX_SIZE: if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear() _fingering_cache.clear()
_fingering_cache[key] = result _fingering_cache[key] = result
return result return result
else: else:
result = (Fingering(self.fix_fingering(override), string_names, fretboard=fretboard),) result = (Fingering(self.fix_fingering(override), string_names,
fretboard=fretboard, high_to_low=fretboard.high_to_low),)
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE: if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear() _fingering_multi_cache.clear()
_fingering_multi_cache[key] = result _fingering_multi_cache[key] = result
@@ -390,7 +418,7 @@ class NamedChord:
sounding_names = set() sounding_names = set()
for i, f in enumerate(fingering): for i, f in enumerate(fingering):
if f != -1: if f != -1:
sounding_names.add(fretboard.tones[i].add(f).name) sounding_names.add(fretboard._tones[i].add(f).name)
required = set(t.name for t in self.acceptable_tones) required = set(t.name for t in self.acceptable_tones)
missing = required - sounding_names missing = required - sounding_names
score -= len(missing) * 5.0 score -= len(missing) * 5.0
@@ -433,12 +461,13 @@ class NamedChord:
if fingers_needed > 4: if fingers_needed > 4:
score -= (fingers_needed - 4) * 5.0 score -= (fingers_needed - 4) * 5.0
# Reward root in bass — the lowest sounding string # Reward root in bass — the lowest sounding string. `fingering`
# is canonical (high-to-low), so the last index is the bass.
for i in range(len(fingering) - 1, -1, -1): for i in range(len(fingering) - 1, -1, -1):
f = fingering[i] f = fingering[i]
if f == -1: if f == -1:
continue continue
bass_tone = fretboard.tones[i].add(f) bass_tone = fretboard._tones[i].add(f)
if bass_tone.name == self.tone.name: if bass_tone.name == self.tone.name:
score += 4.0 score += 4.0
else: else:
@@ -467,17 +496,20 @@ class NamedChord:
if s == max_score: if s == max_score:
yield possible_fingering yield possible_fingering
string_names = tuple(t.name for t in fretboard.tones) string_names = tuple(t.name for t in fretboard._tones)
best_fingerings = tuple([g for g in gen()]) best_fingerings = tuple([g for g in gen()])
if not multiple: if not multiple:
result = Fingering(self.fix_fingering(best_fingerings[0]), string_names, fretboard=fretboard) result = Fingering(self.fix_fingering(best_fingerings[0]), string_names,
fretboard=fretboard, high_to_low=fretboard.high_to_low)
# Bounded cache: clear entirely if over limit # Bounded cache: clear entirely if over limit
if len(_fingering_cache) >= _CACHE_MAX_SIZE: if len(_fingering_cache) >= _CACHE_MAX_SIZE:
_fingering_cache.clear() _fingering_cache.clear()
_fingering_cache[key] = result _fingering_cache[key] = result
return result return result
else: else:
result = tuple([Fingering(self.fix_fingering(f), string_names, fretboard=fretboard) for f in best_fingerings]) result = tuple([Fingering(self.fix_fingering(f), string_names,
fretboard=fretboard, high_to_low=fretboard.high_to_low)
for f in best_fingerings])
# Bounded cache: clear entirely if over limit # Bounded cache: clear entirely if over limit
if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE: if len(_fingering_multi_cache) >= _CACHE_MAX_SIZE:
_fingering_multi_cache.clear() _fingering_multi_cache.clear()
@@ -491,18 +523,21 @@ class NamedChord:
>>> print(CHARTS["western"]["C"].tab(fretboard=Fretboard.guitar())) >>> print(CHARTS["western"]["C"].tab(fretboard=Fretboard.guitar()))
C C
e|--0-- E|--x--
B|--1--
G|--0--
D|--2--
A|--3-- A|--3--
E|--0-- D|--2--
G|--0--
B|--1--
e|--0--
""" """
fingering = self.fingering(fretboard=fretboard) fingering = self.fingering(fretboard=fretboard)
string_names = [t.name for t in fretboard.tones] # Use the fingering's oriented, disambiguated string names/positions
# so the tab honors the fretboard's orientation.
string_names = fingering.string_names
positions = fingering.positions
lines = [self.name] lines = [self.name]
max_name = max(len(n) for n in string_names) max_name = max(len(n) for n in string_names)
for i, (name, fret) in enumerate(zip(string_names, fingering)): for name, fret in zip(string_names, positions):
fret_str = "x" if fret is None else str(fret) fret_str = "x" if fret is None else str(fret)
lines.append(f"{name:>{max_name}}|--{fret_str}--") lines.append(f"{name:>{max_name}}|--{fret_str}--")
return "\n".join(lines) return "\n".join(lines)
+154 -165
View File
@@ -1352,14 +1352,63 @@ class Chord:
class Fretboard: class Fretboard:
def __init__(self, *, tones: list[Tone]) -> None: def __init__(self, *, tones: list[Tone], high_to_low: bool = False,
_canonical: bool = False) -> None:
"""Initialize a Fretboard from a list of open-string Tone objects. """Initialize a Fretboard from a list of open-string Tone objects.
Args: Args:
tones: A list of :class:`Tone` instances representing the tones: A list of :class:`Tone` instances representing the
open strings (high to low). open strings. By default these are read **low to high**
(low string first) — pass ``high_to_low=True`` if your
list runs high to low instead.
high_to_low: Orientation of this fretboard. When ``False``
(the default since v0.43.0), strings and fingerings read
low to high; when ``True``, they read high to low (the
pre-0.43 behavior).
_canonical: Internal flag — when ``True``, *tones* are already
in canonical (high-to-low) order and are stored as-is.
Used by the instrument presets.
""" """
self.tones = tones self.high_to_low = high_to_low
# Internally we always store strings high-to-low; this keeps the
# fingering scorer and chord-override tables (which assume that
# order) untouched. User-facing access is re-oriented on the way out.
if _canonical or high_to_low:
self._tones = list(tones)
else:
self._tones = list(reversed(tones))
def _orient(self, seq):
"""Re-orient a canonical (high-to-low) sequence for display.
Returns *seq* unchanged when this board reads high-to-low, or
reversed when it reads low-to-high. Self-inverse, so it also maps
user-supplied (oriented) input back to canonical order.
"""
return list(seq) if self.high_to_low else list(reversed(seq))
@property
def tones(self) -> list[Tone]:
"""The open-string tones in this board's orientation.
Low-to-high by default; high-to-low when ``high_to_low=True``.
"""
return self._orient(self._tones)
@classmethod
def _from_canonical(cls, tone_strings, high_to_low: bool = False) -> Fretboard:
"""Build a board from canonical (high-to-low) tone-name strings.
Used by the instrument presets, whose tunings are written in the
conventional high-to-low order. *high_to_low* sets only the
board's display orientation.
"""
from .tones import Tone
return cls(
tones=[Tone.from_string(t, system="western") for t in tone_strings],
high_to_low=high_to_low,
_canonical=True,
)
def __repr__(self) -> str: def __repr__(self) -> str:
l = tuple([tone.full_name for tone in self.tones]) l = tuple([tone.full_name for tone in self.tones])
@@ -1391,7 +1440,11 @@ class Fretboard:
Returns: Returns:
A new Fretboard with all strings raised by ``fret`` semitones. A new Fretboard with all strings raised by ``fret`` semitones.
""" """
return Fretboard(tones=[t.add(fret) for t in self.tones]) return Fretboard(
tones=[t.add(fret) for t in self._tones],
high_to_low=self.high_to_low,
_canonical=True,
)
def __iter__(self) -> Iterator[Tone]: def __iter__(self) -> Iterator[Tone]:
"""Iterate over the open-string tones of this fretboard.""" """Iterate over the open-string tones of this fretboard."""
@@ -1399,7 +1452,7 @@ class Fretboard:
def __len__(self) -> int: def __len__(self) -> int:
"""Return the number of strings on this fretboard.""" """Return the number of strings on this fretboard."""
return len(self.tones) return len(self._tones)
INSTRUMENTS = [ INSTRUMENTS = [
"guitar", "twelve_string", "bass", "ukulele", "guitar", "twelve_string", "bass", "ukulele",
@@ -1423,84 +1476,76 @@ class Fretboard:
} }
@classmethod @classmethod
def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0) -> Fretboard: def guitar(cls, tuning: Union[str, tuple[str, ...]] = "standard", capo: int = 0,
high_to_low: bool = False) -> Fretboard:
"""Guitar with the given tuning and optional capo. """Guitar with the given tuning and optional capo.
Args: Args:
tuning: Tuning name or tuple of tone strings (high to low). tuning: Tuning name, or a tuple of tone strings. A custom
tuple is read **low to high** by default (pass
``high_to_low=True`` to give it high to low instead).
Built-in tunings: standard, drop d, open g, open d, Built-in tunings: standard, drop d, open g, open d,
open e, open a, dadgad, half step down. open e, open a, dadgad, half step down.
capo: Fret number for the capo (0 = no capo). Raises all capo: Fret number for the capo (0 = no capo). Raises all
strings by this many semitones. strings by this many semitones.
high_to_low: When ``True``, the resulting board reads high to
low (pre-0.43 behavior); otherwise low to high.
""" """
from .tones import Tone from .tones import Tone
if isinstance(tuning, str): if isinstance(tuning, str):
tuning = cls.TUNINGS[tuning] # Built-in tunings are defined canonically (high to low).
fb = cls(tones=[Tone.from_string(t, system="western") for t in tuning]) canonical = [Tone.from_string(t, system="western") for t in cls.TUNINGS[tuning]]
fb = cls(tones=canonical, high_to_low=high_to_low, _canonical=True)
else:
# A user-supplied tuple is in the board's orientation.
fb = cls(tones=[Tone.from_string(t, system="western") for t in tuning],
high_to_low=high_to_low)
if capo: if capo:
fb = fb.capo(capo) fb = fb.capo(capo)
return fb return fb
@classmethod @classmethod
def bass(cls, five_string: bool = False) -> Fretboard: def bass(cls, five_string: bool = False, high_to_low: bool = False) -> Fretboard:
"""Standard bass guitar tuning. """Standard bass guitar tuning.
Args: Args:
five_string: If True, adds a low B string (B0). five_string: If True, adds a low B string (B0).
high_to_low: When ``True``, the board reads high to low.
""" """
from .tones import Tone
strings = ["G2", "D2", "A1", "E1"] strings = ["G2", "D2", "A1", "E1"]
if five_string: if five_string:
strings.append("B0") strings.append("B0")
return cls(tones=[Tone.from_string(t, system="western") for t in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def ukulele(cls) -> Fretboard: def ukulele(cls, high_to_low: bool = False) -> Fretboard:
"""Standard ukulele tuning (A4 E4 C4 G4). """Standard ukulele tuning (A4 E4 C4 G4).
Re-entrant tuning: the G4 string is higher than C4. Re-entrant tuning: the G4 string is higher than C4.
""" """
from .tones import Tone return cls._from_canonical(["A4", "E4", "C4", "G4"], high_to_low)
return cls(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("C4", system="western"),
Tone.from_string("G4", system="western"),
])
@classmethod @classmethod
def mandolin(cls) -> Fretboard: def mandolin(cls, high_to_low: bool = False) -> Fretboard:
"""Standard mandolin tuning (E5 A4 D4 G3). """Standard mandolin tuning (E5 A4 D4 G3).
Tuned in fifths, same as a violin but one octave relationship. Tuned in fifths, same as a violin but one octave relationship.
Strings are typically doubled (paired courses). Strings are typically doubled (paired courses).
""" """
from .tones import Tone return cls._from_canonical(["E5", "A4", "D4", "G3"], high_to_low)
return cls(tones=[
Tone.from_string("E5", system="western"),
Tone.from_string("A4", system="western"),
Tone.from_string("D4", system="western"),
Tone.from_string("G3", system="western"),
])
@classmethod @classmethod
def mandola(cls) -> Fretboard: def mandola(cls, high_to_low: bool = False) -> Fretboard:
"""Standard mandola tuning (A4 D4 G3 C3). """Standard mandola tuning (A4 D4 G3 C3).
The mandola (or tenor mandola) is to the mandolin what the The mandola (or tenor mandola) is to the mandolin what the
viola is to the violin — a fifth lower, with a warmer, viola is to the violin — a fifth lower, with a warmer,
darker tone. Tuned in fifths like all the mandolin family. darker tone. Tuned in fifths like all the mandolin family.
""" """
from .tones import Tone return cls._from_canonical(["A4", "D4", "G3", "C3"], high_to_low)
return cls(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("D4", system="western"),
Tone.from_string("G3", system="western"),
Tone.from_string("C3", system="western"),
])
@classmethod @classmethod
def octave_mandolin(cls) -> Fretboard: def octave_mandolin(cls, high_to_low: bool = False) -> Fretboard:
"""Octave mandolin tuning (E4 A3 D3 G2). """Octave mandolin tuning (E4 A3 D3 G2).
Also called the octave mandola in European terminology. Also called the octave mandola in European terminology.
@@ -1508,84 +1553,57 @@ class Fretboard:
family's cello-to-violin relationship. Popular in Irish family's cello-to-violin relationship. Popular in Irish
and Celtic folk music. and Celtic folk music.
""" """
from .tones import Tone return cls._from_canonical(["E4", "A3", "D3", "G2"], high_to_low)
return cls(tones=[
Tone.from_string("E4", system="western"),
Tone.from_string("A3", system="western"),
Tone.from_string("D3", system="western"),
Tone.from_string("G2", system="western"),
])
@classmethod @classmethod
def mandocello(cls) -> Fretboard: def mandocello(cls, high_to_low: bool = False) -> Fretboard:
"""Mandocello tuning (A3 D3 G2 C2). """Mandocello tuning (A3 D3 G2 C2).
The bass of the mandolin family. Tuned like a cello — an The bass of the mandolin family. Tuned like a cello — an
octave below the mandola. Rare but beautiful; used in octave below the mandola. Rare but beautiful; used in
mandolin orchestras. mandolin orchestras.
""" """
from .tones import Tone return cls._from_canonical(["A3", "D3", "G2", "C2"], high_to_low)
return cls(tones=[
Tone.from_string("A3", system="western"),
Tone.from_string("D3", system="western"),
Tone.from_string("G2", system="western"),
Tone.from_string("C2", system="western"),
])
@classmethod @classmethod
def violin(cls) -> Fretboard: def violin(cls, high_to_low: bool = False) -> Fretboard:
"""Standard violin tuning (E5 A4 D4 G3). """Standard violin tuning (E5 A4 D4 G3).
Tuned in perfect fifths. The violin has no frets — intonation Tuned in perfect fifths. The violin has no frets — intonation
is continuous, allowing vibrato and microtonal inflections is continuous, allowing vibrato and microtonal inflections
not possible on fretted instruments. not possible on fretted instruments.
""" """
from .tones import Tone return cls._from_canonical(["E5", "A4", "D4", "G3"], high_to_low)
return cls(tones=[
Tone.from_string("E5", system="western"),
Tone.from_string("A4", system="western"),
Tone.from_string("D4", system="western"),
Tone.from_string("G3", system="western"),
])
@classmethod @classmethod
def viola(cls) -> Fretboard: def viola(cls, high_to_low: bool = False) -> Fretboard:
"""Standard viola tuning (A4 D4 G3 C3). """Standard viola tuning (A4 D4 G3 C3).
A perfect fifth below the violin. The viola's darker, warmer A perfect fifth below the violin. The viola's darker, warmer
tone comes from its larger body and lower register. tone comes from its larger body and lower register.
""" """
from .tones import Tone return cls._from_canonical(["A4", "D4", "G3", "C3"], high_to_low)
return cls(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("D4", system="western"),
Tone.from_string("G3", system="western"),
Tone.from_string("C3", system="western"),
])
@classmethod @classmethod
def cello(cls) -> Fretboard: def cello(cls, high_to_low: bool = False) -> Fretboard:
"""Standard cello tuning (A3 D3 G2 C2). """Standard cello tuning (A3 D3 G2 C2).
An octave below the viola. Tuned in fifths. The cello spans An octave below the viola. Tuned in fifths. The cello spans
the range of the human voice — tenor through bass. the range of the human voice — tenor through bass.
""" """
from .tones import Tone return cls._from_canonical(["A3", "D3", "G2", "C2"], high_to_low)
return cls(tones=[
Tone.from_string("A3", system="western"),
Tone.from_string("D3", system="western"),
Tone.from_string("G2", system="western"),
Tone.from_string("C2", system="western"),
])
@classmethod @classmethod
def banjo(cls, tuning: Union[str, tuple[str, ...]] = "open g") -> Fretboard: def banjo(cls, tuning: Union[str, tuple[str, ...]] = "open g",
high_to_low: bool = False) -> Fretboard:
"""Banjo with the given tuning. """Banjo with the given tuning.
Args: Args:
tuning: ``"open g"`` (default, bluegrass) or ``"open d"`` tuning: ``"open g"`` (default, bluegrass) or ``"open d"``
(old-time, clawhammer). The 5th string is a high (old-time, clawhammer). The 5th string is a high
drone — a defining feature of the banjo sound. drone — a defining feature of the banjo sound. A custom
tuple is read low to high unless ``high_to_low=True``.
high_to_low: When ``True``, the board reads high to low.
Standard open G: G4 D3 G3 B3 D4 (5th string is the short Standard open G: G4 D3 G3 B3 D4 (5th string is the short
high G4 drone). high G4 drone).
@@ -1597,11 +1615,12 @@ class Fretboard:
"double c": ("D4", "C4", "G3", "C3", "G4"), "double c": ("D4", "C4", "G3", "C3", "G4"),
} }
if isinstance(tuning, str): if isinstance(tuning, str):
tuning = tunings[tuning] return cls._from_canonical(tunings[tuning], high_to_low)
return cls(tones=[Tone.from_string(t, system="western") for t in tuning]) return cls(tones=[Tone.from_string(t, system="western") for t in tuning],
high_to_low=high_to_low)
@classmethod @classmethod
def double_bass(cls) -> Fretboard: def double_bass(cls, high_to_low: bool = False) -> Fretboard:
"""Standard double bass (upright bass) tuning (G2 D2 A1 E1). """Standard double bass (upright bass) tuning (G2 D2 A1 E1).
The largest and lowest-pitched bowed string instrument in the The largest and lowest-pitched bowed string instrument in the
@@ -1611,16 +1630,10 @@ class Fretboard:
The 5-string double bass adds a low B0 or C1. The 5-string double bass adds a low B0 or C1.
""" """
from .tones import Tone return cls._from_canonical(["G2", "D2", "A1", "E1"], high_to_low)
return cls(tones=[
Tone.from_string("G2", system="western"),
Tone.from_string("D2", system="western"),
Tone.from_string("A1", system="western"),
Tone.from_string("E1", system="western"),
])
@classmethod @classmethod
def harp(cls) -> Fretboard: def harp(cls, high_to_low: bool = False) -> Fretboard:
"""Concert harp strings — 47 strings spanning C1 to G7. """Concert harp strings — 47 strings spanning C1 to G7.
The pedal harp has 7 strings per octave (one per note name), The pedal harp has 7 strings per octave (one per note name),
@@ -1630,7 +1643,6 @@ class Fretboard:
This returns the full set of 47 strings in the default This returns the full set of 47 strings in the default
Cb (enharmonic B) tuning. Cb (enharmonic B) tuning.
""" """
from .tones import Tone
# 47 strings: C1 to G7, one per diatonic note # 47 strings: C1 to G7, one per diatonic note
notes = ["C", "D", "E", "F", "G", "A", "B"] notes = ["C", "D", "E", "F", "G", "A", "B"]
strings = [] strings = []
@@ -1643,30 +1655,33 @@ class Fretboard:
else: else:
continue continue
break break
# Harp strings are high to low # Canonical (high to low)
strings.reverse() strings.reverse()
return cls(tones=[Tone.from_string(s, system="western") for s in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def pedal_steel(cls) -> Fretboard: def pedal_steel(cls, high_to_low: bool = False) -> Fretboard:
"""Pedal steel guitar — E9 Nashville tuning (10 strings). """Pedal steel guitar — E9 Nashville tuning (10 strings).
The standard tuning for country music. The pedal steel has The standard tuning for country music. The pedal steel has
foot pedals and knee levers that change string pitches during foot pedals and knee levers that change string pitches during
play, enabling its signature swooping, crying sound. play, enabling its signature swooping, crying sound.
""" """
from .tones import Tone # E9 Nashville tuning (canonical: high to low)
# E9 Nashville tuning (high to low)
strings = ["F#4", "D#4", "G#3", "E3", "B3", "G#3", strings = ["F#4", "D#4", "G#3", "E3", "B3", "G#3",
"F#3", "E3", "D3", "B2"] "F#3", "E3", "D3", "B2"]
return cls(tones=[Tone.from_string(s, system="western") for s in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def bouzouki(cls, variant: Union[str, tuple[str, ...]] = "irish") -> Fretboard: def bouzouki(cls, variant: Union[str, tuple[str, ...]] = "irish",
high_to_low: bool = False) -> Fretboard:
"""Bouzouki tuning. """Bouzouki tuning.
Args: Args:
variant: ``"irish"`` (default, GDAD) or ``"greek"`` (CFAD). variant: ``"irish"`` (default, GDAD) or ``"greek"`` (CFAD).
A custom tuple is read low to high unless
``high_to_low=True``.
high_to_low: When ``True``, the board reads high to low.
The Irish bouzouki is a staple of Celtic music, usually tuned The Irish bouzouki is a staple of Celtic music, usually tuned
in unison or octave pairs. The Greek bouzouki traditionally in unison or octave pairs. The Greek bouzouki traditionally
@@ -1678,11 +1693,12 @@ class Fretboard:
"greek": ("D4", "A3", "F3", "C3"), "greek": ("D4", "A3", "F3", "C3"),
} }
if isinstance(variant, str): if isinstance(variant, str):
variant = tunings[variant] return cls._from_canonical(tunings[variant], high_to_low)
return cls(tones=[Tone.from_string(t, system="western") for t in variant]) return cls(tones=[Tone.from_string(t, system="western") for t in variant],
high_to_low=high_to_low)
@classmethod @classmethod
def oud(cls) -> Fretboard: def oud(cls, high_to_low: bool = False) -> Fretboard:
"""Standard Arabic oud tuning (C4 G3 D3 A2 G2 C2). """Standard Arabic oud tuning (C4 G3 D3 A2 G2 C2).
The oud is the ancestor of the European lute and the defining The oud is the ancestor of the European lute and the defining
@@ -1691,12 +1707,11 @@ class Fretboard:
essential to maqam performance. 6 courses (11 strings), essential to maqam performance. 6 courses (11 strings),
typically tuned in fourths. typically tuned in fourths.
""" """
from .tones import Tone
strings = ["C4", "G3", "D3", "A2", "G2", "C2"] strings = ["C4", "G3", "D3", "A2", "G2", "C2"]
return cls(tones=[Tone.from_string(t, system="western") for t in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def sitar(cls) -> Fretboard: def sitar(cls, high_to_low: bool = False) -> Fretboard:
"""Sitar main playing strings (approximation). """Sitar main playing strings (approximation).
The sitar typically has 6-7 main strings and 11-13 sympathetic The sitar typically has 6-7 main strings and 11-13 sympathetic
@@ -1706,14 +1721,13 @@ class Fretboard:
Main strings: Sa Sa Pa Sa Re Sa Ma (approximated in 12-TET). Main strings: Sa Sa Pa Sa Re Sa Ma (approximated in 12-TET).
Represented here as the most common Ravi Shankar school tuning. Represented here as the most common Ravi Shankar school tuning.
""" """
from .tones import Tone
# Common Ravi Shankar tuning mapped to Western notes # Common Ravi Shankar tuning mapped to Western notes
# (sitar is tuned relative to Sa, typically C# or D) # (sitar is tuned relative to Sa, typically C# or D)
strings = ["C4", "C3", "G3", "C3", "D3", "C2", "F2"] strings = ["C4", "C3", "G3", "C3", "D3", "C2", "F2"]
return cls(tones=[Tone.from_string(t, system="western") for t in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def shamisen(cls) -> Fretboard: def shamisen(cls, high_to_low: bool = False) -> Fretboard:
"""Standard shamisen tuning — honchoshi (C4 G3 C3). """Standard shamisen tuning — honchoshi (C4 G3 C3).
The shamisen is a 3-stringed Japanese instrument played with The shamisen is a 3-stringed Japanese instrument played with
@@ -1723,15 +1737,10 @@ class Fretboard:
- niagari (二上り): root-5th-2nd (raises 2nd string) - niagari (二上り): root-5th-2nd (raises 2nd string)
- sansagari (三下り): root-5th-b7th (lowers 3rd string) - sansagari (三下り): root-5th-b7th (lowers 3rd string)
""" """
from .tones import Tone return cls._from_canonical(["C4", "G3", "C3"], high_to_low)
return cls(tones=[
Tone.from_string("C4", system="western"),
Tone.from_string("G3", system="western"),
Tone.from_string("C3", system="western"),
])
@classmethod @classmethod
def erhu(cls) -> Fretboard: def erhu(cls, high_to_low: bool = False) -> Fretboard:
"""Standard erhu tuning (A4 D4). """Standard erhu tuning (A4 D4).
The erhu is a 2-stringed Chinese bowed instrument with a The erhu is a 2-stringed Chinese bowed instrument with a
@@ -1739,14 +1748,10 @@ class Fretboard:
— the player presses the strings without touching the neck, — the player presses the strings without touching the neck,
allowing continuous pitch bending. allowing continuous pitch bending.
""" """
from .tones import Tone return cls._from_canonical(["A4", "D4"], high_to_low)
return cls(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("D4", system="western"),
])
@classmethod @classmethod
def charango(cls) -> Fretboard: def charango(cls, high_to_low: bool = False) -> Fretboard:
"""Standard charango tuning (E5 A4 E5 C5 G4). """Standard charango tuning (E5 A4 E5 C5 G4).
A small Andean stringed instrument, traditionally made from A small Andean stringed instrument, traditionally made from
@@ -1754,54 +1759,38 @@ class Fretboard:
— the 3rd course (E5) is the highest pitched, creating the — the 3rd course (E5) is the highest pitched, creating the
charango's bright, sparkling sound. charango's bright, sparkling sound.
""" """
from .tones import Tone return cls._from_canonical(["E5", "A4", "E5", "C5", "G4"], high_to_low)
return cls(tones=[
Tone.from_string("E5", system="western"),
Tone.from_string("A4", system="western"),
Tone.from_string("E5", system="western"),
Tone.from_string("C5", system="western"),
Tone.from_string("G4", system="western"),
])
@classmethod @classmethod
def pipa(cls) -> Fretboard: def pipa(cls, high_to_low: bool = False) -> Fretboard:
"""Standard pipa tuning (D4 A3 E3 A2). """Standard pipa tuning (D4 A3 E3 A2).
The pipa is a 4-stringed Chinese lute with a pear-shaped The pipa is a 4-stringed Chinese lute with a pear-shaped
body, dating back over 2000 years. Known for its percussive body, dating back over 2000 years. Known for its percussive
attack and rapid tremolo technique. attack and rapid tremolo technique.
""" """
from .tones import Tone return cls._from_canonical(["D4", "A3", "E3", "A2"], high_to_low)
return cls(tones=[
Tone.from_string("D4", system="western"),
Tone.from_string("A3", system="western"),
Tone.from_string("E3", system="western"),
Tone.from_string("A2", system="western"),
])
@classmethod @classmethod
def balalaika(cls) -> Fretboard: def balalaika(cls, high_to_low: bool = False) -> Fretboard:
"""Standard balalaika prima tuning (A4 E4 E4). """Standard balalaika prima tuning (A4 E4 E4).
The Russian balalaika has a distinctive triangular body and The Russian balalaika has a distinctive triangular body and
3 strings. The two lower strings are tuned in unison — a 3 strings. The two lower strings are tuned in unison — a
unique feature that gives it a natural chorus effect. unique feature that gives it a natural chorus effect.
""" """
from .tones import Tone return cls._from_canonical(["A4", "E4", "E4"], high_to_low)
return cls(tones=[
Tone.from_string("A4", system="western"),
Tone.from_string("E4", system="western"),
Tone.from_string("E4", system="western"),
])
@classmethod @classmethod
def keyboard(cls, keys: int = 88, start: str = "A0") -> Fretboard: def keyboard(cls, keys: int = 88, start: str = "A0",
high_to_low: bool = False) -> Fretboard:
"""Piano or keyboard with the given number of keys. """Piano or keyboard with the given number of keys.
Args: Args:
keys: Number of keys (default 88 for a full piano). keys: Number of keys (default 88 for a full piano).
Common sizes: 25, 37, 49, 61, 76, 88. Common sizes: 25, 37, 49, 61, 76, 88.
start: The lowest note (default ``"A0"`` for standard piano). start: The lowest note (default ``"A0"`` for standard piano).
high_to_low: When ``True``, the board reads high to low.
A full 88-key piano spans A0 (27.5 Hz) to C8 (4186 Hz) — A full 88-key piano spans A0 (27.5 Hz) to C8 (4186 Hz) —
the widest range of any standard acoustic instrument. the widest range of any standard acoustic instrument.
@@ -1815,13 +1804,12 @@ class Fretboard:
""" """
from .tones import Tone from .tones import Tone
start_tone = Tone.from_string(start, system="western") start_tone = Tone.from_string(start, system="western")
tones = [] # Built high-to-low (canonical): highest key first, down to `start`.
for i in range(keys - 1, -1, -1): tones = [start_tone.add(i) for i in range(keys - 1, -1, -1)]
tones.append(start_tone.add(i)) return cls(tones=tones, high_to_low=high_to_low, _canonical=True)
return cls(tones=tones)
@classmethod @classmethod
def lute(cls) -> Fretboard: def lute(cls, high_to_low: bool = False) -> Fretboard:
"""Renaissance lute in G tuning (6 courses). """Renaissance lute in G tuning (6 courses).
The European lute was the dominant instrument of the The European lute was the dominant instrument of the
@@ -1829,21 +1817,19 @@ class Fretboard:
a major third between the 3rd and 4th courses — the a major third between the 3rd and 4th courses — the
same intervallic pattern as a modern guitar. same intervallic pattern as a modern guitar.
""" """
from .tones import Tone
strings = ["G4", "D4", "A3", "F3", "C3", "G2"] strings = ["G4", "D4", "A3", "F3", "C3", "G2"]
return cls(tones=[Tone.from_string(t, system="western") for t in strings]) return cls._from_canonical(strings, high_to_low)
@classmethod @classmethod
def twelve_string(cls) -> Fretboard: def twelve_string(cls, high_to_low: bool = False) -> Fretboard:
"""12-string guitar in standard tuning. """12-string guitar in standard tuning.
The lower 4 courses are doubled at the octave; the upper 2 The lower 4 courses are doubled at the octave; the upper 2
are doubled in unison. This creates the characteristic are doubled in unison. This creates the characteristic
shimmering, chorus-like sound. shimmering, chorus-like sound.
Represented as 12 strings (high to low, pairs together). Represented as 12 strings (canonical: high to low, pairs together).
""" """
from .tones import Tone
strings = [ strings = [
"E4", "E4", # 1st course (unison) "E4", "E4", # 1st course (unison)
"B3", "B3", # 2nd course (unison) "B3", "B3", # 2nd course (unison)
@@ -1852,7 +1838,7 @@ class Fretboard:
"A3", "A2", # 5th course (octave) "A3", "A2", # 5th course (octave)
"E3", "E2", # 6th course (octave) "E3", "E2", # 6th course (octave)
] ]
return cls(tones=[Tone.from_string(t, system="western") for t in strings]) return cls._from_canonical(strings, high_to_low)
def scale_diagram(self, scale, frets: int = 12, chord=None) -> str: def scale_diagram(self, scale, frets: int = 12, chord=None) -> str:
"""Render an ASCII diagram showing where scale notes fall on the neck. """Render an ASCII diagram showing where scale notes fall on the neck.
@@ -1927,7 +1913,7 @@ class Fretboard:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> fb.chord("G") >>> fb.chord("G")
Fingering(e=3, B=0, G=0, D=0, A=2, E=3) Fingering(E=3, A=2, D=0, G=0, B=0, e=3)
""" """
from .charts import CHARTS from .charts import CHARTS
return CHARTS[system][name].fingering(fretboard=self) return CHARTS[system][name].fingering(fretboard=self)
@@ -1945,7 +1931,7 @@ class Fretboard:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> fb["G"] >>> fb["G"]
Fingering(e=3, B=0, G=0, D=0, A=2, E=3) Fingering(E=3, A=2, D=0, G=0, B=0, e=3)
""" """
return self.chord(name) return self.chord(name)
@@ -1964,12 +1950,12 @@ class Fretboard:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> print(fb.tab("Am")) >>> print(fb.tab("Am"))
A minor A minor
e|--0-- E|--x--
B|--1--
G|--2--
D|--2--
A|--0-- A|--0--
E|--0-- D|--2--
G|--2--
B|--1--
e|--0--
""" """
return self.chord(name, system=system).tab() return self.chord(name, system=system).tab()
@@ -1984,7 +1970,7 @@ class Fretboard:
>>> fb = Fretboard.guitar() >>> fb = Fretboard.guitar()
>>> chart = fb.chart() >>> chart = fb.chart()
>>> chart["Am7"] >>> chart["Am7"]
Fingering(e=0, B=1, G=0, D=2, A=0, E=0) Fingering(E=0, A=0, D=2, G=0, B=1, e=0)
""" """
from .charts import charts_for_fretboard, CHARTS from .charts import charts_for_fretboard, CHARTS
return charts_for_fretboard(chart=CHARTS[system], fretboard=self) return charts_for_fretboard(chart=CHARTS[system], fretboard=self)
@@ -2009,13 +1995,16 @@ class Fretboard:
""" """
from .charts import Fingering from .charts import Fingering
if not len(positions) == len(self.tones): if not len(positions) == len(self._tones):
raise ValueError( raise ValueError(
"The number of positions must match the number of tones (strings)." "The number of positions must match the number of tones (strings)."
) )
string_names = tuple(t.name for t in self.tones) # Positions arrive in this board's orientation; canonicalise them
return Fingering(positions, string_names, fretboard=self) # (high-to-low) to match the internal tone order Fingering expects.
string_names = tuple(t.name for t in self._tones)
return Fingering(self._orient(positions), string_names, fretboard=self,
high_to_low=self.high_to_low)
def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]: def analyze_progression(chords: list[Chord], key: str = "C", mode: str = "major") -> list[str | None]:
+9 -6
View File
@@ -3706,17 +3706,19 @@ class Part:
fingering = self._fretboard.chord(chord_name) fingering = self._fretboard.chord(chord_name)
# Get the sounding tones (skips muted strings) # Get the sounding tones (skips muted strings)
tones = fingering.tones # list of Tone objects, high to low tones = fingering.tones
if not tones: if not tones:
self.rest(duration) self.rest(duration)
return self return self
# Order: down strum = low to high (reverse since tones are high-to-low) # Sort by pitch so strum direction is correct regardless of the
# fingering's display orientation: down = low to high, up = high to low.
low_to_high = sorted(tones, key=lambda t: t.midi)
if direction == "down": if direction == "down":
strum_tones = list(reversed(tones)) strum_tones = low_to_high
else: else:
strum_tones = list(tones) strum_tones = list(reversed(low_to_high))
if hasattr(duration, 'value'): if hasattr(duration, 'value'):
total_beats = duration.value total_beats = duration.value
@@ -3826,8 +3828,9 @@ class Part:
open_midis = list(self._TAB_TUNINGS[tuning]) open_midis = list(self._TAB_TUNINGS[tuning])
labels = list(self._TAB_LABELS[tuning]) labels = list(self._TAB_LABELS[tuning])
elif hasattr(tuning, "tones"): elif hasattr(tuning, "tones"):
# Fretboard object — tones are high-to-low, reverse for low-to-high # Fretboard object — sort by pitch so we get low-to-high
fb_tones = list(reversed(tuning.tones)) # regardless of the board's display orientation.
fb_tones = sorted(tuning.tones, key=lambda t: t.midi)
open_midis = [t.midi for t in fb_tones] open_midis = [t.midi for t in fb_tones]
labels = [t.name if len(t.name) <= 2 else t.name[0] for t in fb_tones] labels = [t.name if len(t.name) <= 2 else t.name[0] for t in fb_tones]
else: else:
+100 -33
View File
@@ -482,7 +482,8 @@ def test_fretboard_creation():
Tone(name="A", octave=2), Tone(name="A", octave=2),
Tone(name="E", octave=2), Tone(name="E", octave=2),
] ]
fretboard = Fretboard(tones=standard_tuning) # Literal is written high-to-low, so declare that orientation.
fretboard = Fretboard(tones=standard_tuning, high_to_low=True)
assert len(fretboard.tones) == 6 assert len(fretboard.tones) == 6
assert fretboard.tones[0].full_name == "E4" assert fretboard.tones[0].full_name == "E4"
assert fretboard.tones[-1].full_name == "E2" assert fretboard.tones[-1].full_name == "E2"
@@ -505,7 +506,8 @@ def guitar_fretboard():
Tone.from_string("A2"), Tone.from_string("A2"),
Tone.from_string("E2"), Tone.from_string("E2"),
] ]
return Fretboard(tones=tuning) # Literal is written high-to-low, so declare that orientation.
return Fretboard(tones=tuning, high_to_low=True)
def test_chord_fingering_c(guitar_fretboard): def test_chord_fingering_c(guitar_fretboard):
@@ -1767,28 +1769,32 @@ def test_chord_contains_tone():
def test_fretboard_guitar(): def test_fretboard_guitar():
fb = Fretboard.guitar() fb = Fretboard.guitar()
assert len(fb) == 6 assert len(fb) == 6
# Low-to-high by default (v0.43.0).
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["E", "B", "G", "D", "A", "E"] assert names == ["E", "A", "D", "G", "B", "E"]
# high_to_low=True restores the pre-0.43 order.
assert [t.name for t in Fretboard.guitar(high_to_low=True)] == \
["E", "B", "G", "D", "A", "E"]
def test_fretboard_guitar_octaves(): def test_fretboard_guitar_octaves():
fb = Fretboard.guitar() fb = Fretboard.guitar()
octaves = [t.octave for t in fb] octaves = [t.octave for t in fb]
assert octaves == [4, 3, 3, 3, 2, 2] assert octaves == [2, 2, 3, 3, 3, 4]
def test_fretboard_bass(): def test_fretboard_bass():
fb = Fretboard.bass() fb = Fretboard.bass()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["G", "D", "A", "E"] assert names == ["E", "A", "D", "G"]
def test_fretboard_ukulele(): def test_fretboard_ukulele():
fb = Fretboard.ukulele() fb = Fretboard.ukulele()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["A", "E", "C", "G"] assert names == ["G", "C", "E", "A"]
def test_fretboard_iter(): def test_fretboard_iter():
@@ -1821,8 +1827,9 @@ def test_fretboard_ukulele_fingerings():
def test_fretboard_guitar_drop_d(): def test_fretboard_guitar_drop_d():
fb = Fretboard.guitar("drop d") fb = Fretboard.guitar("drop d")
assert len(fb) == 6 assert len(fb) == 6
assert fb.tones[-1].name == "D" # Low-to-high: the dropped low D is now the first string.
assert fb.tones[-1].octave == 2 assert fb.tones[0].name == "D"
assert fb.tones[0].octave == 2
def test_fretboard_guitar_open_g(): def test_fretboard_guitar_open_g():
@@ -1840,7 +1847,8 @@ def test_fretboard_guitar_custom_tuple():
def test_fretboard_bass_five_string(): def test_fretboard_bass_five_string():
fb = Fretboard.bass(five_string=True) fb = Fretboard.bass(five_string=True)
assert len(fb) == 5 assert len(fb) == 5
assert fb.tones[-1].name == "B" # Low-to-high: the added low B is the first string.
assert fb.tones[0].name == "B"
def test_fretboard_tunings_dict(): def test_fretboard_tunings_dict():
@@ -1852,36 +1860,37 @@ def test_fretboard_tunings_dict():
def test_fretboard_mandolin(): def test_fretboard_mandolin():
fb = Fretboard.mandolin() fb = Fretboard.mandolin()
assert len(fb) == 4 assert len(fb) == 4
assert fb.tones[0].name == "E" # Low-to-high.
assert fb.tones[-1].name == "G" assert fb.tones[0].name == "G"
assert fb.tones[-1].name == "E"
def test_fretboard_violin(): def test_fretboard_violin():
fb = Fretboard.violin() fb = Fretboard.violin()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["E", "A", "D", "G"] assert names == ["G", "D", "A", "E"]
def test_fretboard_viola(): def test_fretboard_viola():
fb = Fretboard.viola() fb = Fretboard.viola()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"] assert names == ["C", "G", "D", "A"]
def test_fretboard_cello(): def test_fretboard_cello():
fb = Fretboard.cello() fb = Fretboard.cello()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"] assert names == ["C", "G", "D", "A"]
assert fb.tones[0].octave == 3 assert fb.tones[0].octave == 2
def test_fretboard_banjo(): def test_fretboard_banjo():
fb = Fretboard.banjo() fb = Fretboard.banjo()
assert len(fb) == 5 assert len(fb) == 5
assert fb.tones[-1].name == "G" # high drone string assert fb.tones[0].name == "G" # high drone string (now first, low-to-high)
def test_fretboard_banjo_open_d(): def test_fretboard_banjo_open_d():
@@ -1898,46 +1907,49 @@ def test_fretboard_violin_tuned_in_fifths():
"""Violin strings should be a perfect 5th apart.""" """Violin strings should be a perfect 5th apart."""
fb = Fretboard.violin() fb = Fretboard.violin()
for i in range(len(fb.tones) - 1): for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1] # Low-to-high: each next string is a 5th higher.
interval = fb.tones[i + 1] - fb.tones[i]
assert interval == 7, f"Strings {i} and {i+1} not a 5th apart" assert interval == 7, f"Strings {i} and {i+1} not a 5th apart"
def test_fretboard_octave_mandolin(): def test_fretboard_octave_mandolin():
fb = Fretboard.octave_mandolin() fb = Fretboard.octave_mandolin()
assert len(fb) == 4 assert len(fb) == 4
assert fb.tones[0].name == "E" assert fb.tones[-1].name == "E"
assert fb.tones[0].octave == 4 assert fb.tones[-1].octave == 4
def test_fretboard_mandocello(): def test_fretboard_mandocello():
fb = Fretboard.mandocello() fb = Fretboard.mandocello()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["A", "D", "G", "C"] assert names == ["C", "G", "D", "A"]
assert fb.tones[0].octave == 3 assert fb.tones[0].octave == 2
def test_fretboard_double_bass(): def test_fretboard_double_bass():
fb = Fretboard.double_bass() fb = Fretboard.double_bass()
assert len(fb) == 4 assert len(fb) == 4
names = [t.name for t in fb] names = [t.name for t in fb]
assert names == ["G", "D", "A", "E"] assert names == ["E", "A", "D", "G"]
def test_fretboard_double_bass_tuned_in_fourths(): def test_fretboard_double_bass_tuned_in_fourths():
fb = Fretboard.double_bass() fb = Fretboard.double_bass()
for i in range(len(fb.tones) - 1): for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1] # Low-to-high: each next string is a 4th higher.
interval = fb.tones[i + 1] - fb.tones[i]
assert interval == 5, f"Strings {i} and {i+1} not a 4th apart" assert interval == 5, f"Strings {i} and {i+1} not a 4th apart"
def test_fretboard_harp(): def test_fretboard_harp():
fb = Fretboard.harp() fb = Fretboard.harp()
assert len(fb) == 47 assert len(fb) == 47
assert fb.tones[0].name == "G" # Low-to-high.
assert fb.tones[0].octave == 7 assert fb.tones[0].name == "C"
assert fb.tones[-1].name == "C" assert fb.tones[0].octave == 1
assert fb.tones[-1].octave == 1 assert fb.tones[-1].name == "G"
assert fb.tones[-1].octave == 7
def test_fretboard_pedal_steel(): def test_fretboard_pedal_steel():
@@ -1950,7 +1962,7 @@ def test_mandolin_family_fifths():
for name in ["mandolin", "mandola", "octave_mandolin", "mandocello"]: for name in ["mandolin", "mandola", "octave_mandolin", "mandocello"]:
fb = getattr(Fretboard, name)() fb = getattr(Fretboard, name)()
for i in range(len(fb.tones) - 1): for i in range(len(fb.tones) - 1):
interval = fb.tones[i] - fb.tones[i + 1] interval = fb.tones[i + 1] - fb.tones[i]
assert interval == 7, f"{name} strings {i},{i+1} not a 5th apart" assert interval == 7, f"{name} strings {i},{i+1} not a 5th apart"
@@ -1982,7 +1994,7 @@ def test_fretboard_shamisen():
def test_fretboard_erhu(): def test_fretboard_erhu():
fb = Fretboard.erhu() fb = Fretboard.erhu()
assert len(fb) == 2 assert len(fb) == 2
assert fb.tones[0] - fb.tones[1] == 7 # tuned in 5ths assert fb.tones[1] - fb.tones[0] == 7 # tuned in 5ths (low-to-high)
def test_fretboard_bouzouki_irish(): def test_fretboard_bouzouki_irish():
@@ -2003,8 +2015,8 @@ def test_fretboard_charango():
def test_fretboard_balalaika(): def test_fretboard_balalaika():
fb = Fretboard.balalaika() fb = Fretboard.balalaika()
assert len(fb) == 3 assert len(fb) == 3
# Two unison strings # Two unison strings (now the lowest two, low-to-high)
assert fb.tones[1].name == fb.tones[2].name assert fb.tones[0].name == fb.tones[1].name
def test_fretboard_lute(): def test_fretboard_lute():
@@ -2030,8 +2042,9 @@ def test_keyboard_88():
def test_keyboard_25(): def test_keyboard_25():
kb = Fretboard.keyboard(25, "C3") kb = Fretboard.keyboard(25, "C3")
assert len(kb) == 25 assert len(kb) == 25
assert kb.tones[-1].name == "C" # Low-to-high: the start note is now the first key.
assert kb.tones[-1].octave == 3 assert kb.tones[0].name == "C"
assert kb.tones[0].octave == 3
def test_keyboard_custom(): def test_keyboard_custom():
@@ -2039,6 +2052,60 @@ def test_keyboard_custom():
assert len(kb) == 61 assert len(kb) == 61
# ── Fingering orientation (low-to-high default, v0.43.0) ─────────────────────
def test_chord_low_to_high_default():
"""By default, chord fingerings read low-to-high (low E first)."""
fb = Fretboard.guitar()
g = fb.chord("G")
assert g.string_names == ("E", "A", "D", "G", "B", "e")
assert g.positions == (3, 2, 0, 0, 0, 3)
def test_chord_high_to_low_opt_out():
"""high_to_low=True restores the pre-0.43 high-to-low order."""
fb = Fretboard.guitar(high_to_low=True)
g = fb.chord("G")
assert g.string_names == ("e", "B", "G", "D", "A", "E")
assert g.positions == (3, 0, 0, 0, 2, 3)
def test_orientation_is_a_reversal():
"""The two orientations are exact reverses of each other."""
lo = Fretboard.guitar().chord("Am7")
hi = Fretboard.guitar(high_to_low=True).chord("Am7")
assert lo.positions == tuple(reversed(hi.positions))
# ...and identify to the same chord.
assert lo.identify() == hi.identify()
def test_manual_fingering_input_orientation():
"""Manual fret positions are read in the board's orientation."""
lo = Fretboard.guitar()
hi = Fretboard.guitar(high_to_low=True)
# Same physical G voicing, expressed in each orientation.
assert lo.fingering(3, 2, 0, 0, 0, 3) == lo.chord("G")
assert hi.fingering(3, 0, 0, 0, 2, 3) == hi.chord("G")
def test_orientation_cache_no_collision():
"""The two orientations must not collide in the fingering cache."""
lo = Fretboard.guitar().chord("C")
hi = Fretboard.guitar(high_to_low=True).chord("C")
assert lo.positions != hi.positions
assert lo.positions == tuple(reversed(hi.positions))
def test_to_tab_orientation_agnostic():
"""to_tab output is identical regardless of board orientation."""
from pytheory import Part
notes = ["C4", "E4", "G4"]
p_lo = Part("test"); [p_lo.add(n) for n in notes]
p_hi = Part("test"); [p_hi.add(n) for n in notes]
assert p_lo.to_tab(tuning=Fretboard.guitar()) == \
p_hi.to_tab(tuning=Fretboard.guitar(high_to_low=True))
# ── Ergonomic integration tests ───────────────────────────────────────────── # ── Ergonomic integration tests ─────────────────────────────────────────────
def test_ergonomic_workflow(): def test_ergonomic_workflow():
Generated
+1 -1
View File
@@ -690,7 +690,7 @@ wheels = [
[[package]] [[package]]
name = "pytheory" name = "pytheory"
version = "0.42.1" version = "0.43.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "rich" }, { name = "rich" },