mirror of
https://github.com/kennethreitz/pytheory.git
synced 2026-06-05 23:00:20 +00:00
Use musically correct flat spellings in flat keys
Flat keys now display flats (Bb, Eb, Ab) instead of sharps (A#, D#, G#). Uses the "no duplicate letter names" rule: if building a scale with sharps produces two notes with the same letter (e.g. C and C# in C minor), the scale is rebuilt with flat spellings instead. - Tone.add() and Tone.from_index() accept prefer_flats parameter - TonedScale detects flat vs sharp per-scale automatically - F major: Bb (not A#), Eb major: Ab Bb (not G# A#), etc. - All tests and docs updated to match Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,15 +89,15 @@ on different degrees; the blues scale adds the "blue note" (flat 5th):
|
||||
>>> c["major"].note_names
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
|
||||
>>> c["minor"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
|
||||
>>> c["dorian"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
|
||||
>>> c["mixolydian"].note_names
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
>>> c_blues = TonedScale(tonic="C4", system="blues")
|
||||
>>> c_blues["blues"].note_names
|
||||
['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
|
||||
['C', 'Eb', 'F', 'Gb', 'G', 'Bb', 'C']
|
||||
|
||||
Guitar Chord Chart
|
||||
------------------
|
||||
@@ -279,7 +279,7 @@ dominants that approach other scale degrees:
|
||||
>>> c = Key("C", "major")
|
||||
|
||||
>>> c.borrowed_chords[:4]
|
||||
['C minor', 'D diminished', 'D# major', 'F minor']
|
||||
['C minor', 'D diminished', 'Eb major', 'F minor']
|
||||
|
||||
>>> c.secondary_dominant(5).identify()
|
||||
'D dominant 7th'
|
||||
@@ -342,7 +342,7 @@ Explore scales from Indian, Arabic, and Japanese traditions:
|
||||
|
||||
>>> japanese = TonedScale(tonic="C4", system="japanese")
|
||||
>>> japanese["hirajoshi"].note_names
|
||||
['C', 'D', 'D#', 'G', 'G#', 'C']
|
||||
['C', 'D', 'Eb', 'G', 'Ab', 'C']
|
||||
|
||||
Visualize a Scale on Guitar
|
||||
----------------------------
|
||||
|
||||
@@ -65,10 +65,10 @@ Build scales in any key and mode:
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
|
||||
|
||||
>>> c["minor"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
|
||||
|
||||
>>> c["dorian"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
>>> major = c["major"]
|
||||
>>> major["tonic"]
|
||||
|
||||
@@ -92,7 +92,7 @@ Scarborough Fair):
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> c["dorian"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
`Phrygian <https://en.wikipedia.org/wiki/Phrygian_mode>`_ (iii) — minor with a flat 2nd. Spanish, flamenco, dark
|
||||
(White Rabbit):
|
||||
@@ -100,7 +100,7 @@ Scarborough Fair):
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> c["phrygian"].note_names
|
||||
['C', 'C#', 'D#', 'F', 'G', 'G#', 'A#', 'C']
|
||||
['C', 'Db', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
|
||||
|
||||
`Lydian <https://en.wikipedia.org/wiki/Lydian_mode>`_ (IV) — major with a raised 4th. Dreamy, floating, ethereal
|
||||
(The Simpsons theme, Flying by ET):
|
||||
@@ -116,7 +116,7 @@ Scarborough Fair):
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> c["mixolydian"].note_names
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
`Aeolian <https://en.wikipedia.org/wiki/Aeolian_mode>`_ (vi) — the natural minor scale. Sad, dark, introspective
|
||||
(Stairway to Heaven, Losing My Religion):
|
||||
@@ -124,7 +124,7 @@ Scarborough Fair):
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> c["aeolian"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'G#', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb', 'C']
|
||||
|
||||
`Locrian <https://en.wikipedia.org/wiki/Locrian_mode>`_ (vii) — minor with flat 2nd and flat 5th. Unstable,
|
||||
rarely used as a home key (used in metal and jazz over diminished
|
||||
@@ -133,7 +133,7 @@ chords):
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> c["locrian"].note_names
|
||||
['C', 'C#', 'D#', 'F', 'F#', 'G#', 'A#', 'C']
|
||||
['C', 'Db', 'Eb', 'F', 'Gb', 'Ab', 'Bb', 'C']
|
||||
|
||||
Scale Degrees
|
||||
-------------
|
||||
@@ -309,7 +309,7 @@ The ``signature`` property tells you how many sharps or flats a key has:
|
||||
>>> Key("G", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['F#']}
|
||||
>>> Key("F", "major").signature
|
||||
{'sharps': 1, 'flats': 0, 'accidentals': ['A#']}
|
||||
{'sharps': 0, 'flats': 1, 'accidentals': ['Bb']}
|
||||
>>> Key("C", "major").signature
|
||||
{'sharps': 0, 'flats': 0, 'accidentals': []}
|
||||
|
||||
@@ -340,7 +340,7 @@ are borrowed from C minor and appear constantly in rock and film music:
|
||||
.. code-block:: pycon
|
||||
|
||||
>>> Key("C", "major").borrowed_chords
|
||||
['C minor', 'D diminished', 'D# major', 'F minor', 'G minor', 'G# major', 'A# major']
|
||||
['C minor', 'D diminished', 'Eb major', 'F minor', 'G minor', 'Ab major', 'Bb major']
|
||||
|
||||
Secondary Dominants
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -19,7 +19,7 @@ and all seven modes.
|
||||
['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C']
|
||||
|
||||
>>> c["dorian"].note_names
|
||||
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
**Scales:** major, minor, harmonic minor, ionian, dorian, phrygian,
|
||||
lydian, mixolydian, aeolian, locrian, chromatic
|
||||
@@ -98,16 +98,16 @@ and heptatonic scales from Japanese music.
|
||||
>>> c = TonedScale(tonic="C4", system="japanese")
|
||||
|
||||
>>> c["hirajoshi"].note_names # most iconic Japanese scale
|
||||
['C', 'D', 'D#', 'G', 'G#', 'C']
|
||||
['C', 'D', 'Eb', 'G', 'Ab', 'C']
|
||||
|
||||
>>> c["in"].note_names # Miyako-bushi, used in koto music
|
||||
['C', 'C#', 'F', 'G', 'G#', 'C']
|
||||
['C', 'Db', 'F', 'G', 'Ab', 'C']
|
||||
|
||||
>>> c["yo"].note_names # folk music scale
|
||||
['C', 'D', 'F', 'G', 'A#', 'C']
|
||||
|
||||
>>> c["ritsu"].note_names # gagaku court music (= Dorian)
|
||||
['C', 'D', 'D#', 'F', 'G', 'A', 'A#', 'C']
|
||||
['C', 'D', 'Eb', 'F', 'G', 'A', 'Bb', 'C']
|
||||
|
||||
**Pentatonic scales:** hirajoshi, in, yo, iwato, kumoi, insen
|
||||
|
||||
@@ -138,10 +138,10 @@ of the blues.
|
||||
['C', 'D#', 'F', 'G', 'A#', 'C']
|
||||
|
||||
>>> c["blues"].note_names # minor pentatonic + blue note
|
||||
['C', 'D#', 'F', 'F#', 'G', 'A#', 'C']
|
||||
['C', 'Eb', 'F', 'Gb', 'G', 'Bb', 'C']
|
||||
|
||||
>>> c["major blues"].note_names # major pentatonic + blue note
|
||||
['C', 'D', 'D#', 'E', 'G', 'A', 'C']
|
||||
['C', 'D', 'Eb', 'E', 'G', 'A', 'C']
|
||||
|
||||
**Pentatonic:** major pentatonic, minor pentatonic
|
||||
|
||||
|
||||
+27
-3
@@ -659,27 +659,51 @@ class TonedScale:
|
||||
"""Tuple of all available scale names in this system."""
|
||||
return tuple(self._scales.keys())
|
||||
|
||||
@staticmethod
|
||||
def _should_prefer_flats(tones: list) -> bool:
|
||||
"""Determine if a scale should use flat spellings.
|
||||
|
||||
Uses the "no duplicate letters" rule: build the scale with sharps
|
||||
first, and if any letter name appears twice (excluding the octave
|
||||
repeat at the end), try flats instead. This correctly handles all
|
||||
keys on the circle of fifths.
|
||||
"""
|
||||
# Exclude the last tone (octave repeat of the tonic)
|
||||
unique_tones = tones[:-1] if len(tones) > 1 else tones
|
||||
letters = [t.name[0] for t in unique_tones]
|
||||
return len(letters) != len(set(letters))
|
||||
|
||||
@property
|
||||
def _scales(self) -> dict[str, Scale]:
|
||||
"""Lazily computed (and cached) mapping of scale names to Scale objects."""
|
||||
if self._cached_scales is not None:
|
||||
return self._cached_scales
|
||||
|
||||
# Also check if tonic itself is a flat (always prefer flats then)
|
||||
tonic_is_flat = "b" in self.tonic.name and self.tonic.name != "B"
|
||||
|
||||
scales = {}
|
||||
|
||||
for scale_type in self.system.scales:
|
||||
for scale in self.system.scales[scale_type]:
|
||||
|
||||
working_scale = []
|
||||
reference_scale = self.system.scales[scale_type][scale]["intervals"]
|
||||
|
||||
# First pass: build with sharps (default)
|
||||
working_scale = [self.tonic]
|
||||
current_tone = self.tonic
|
||||
working_scale.append(current_tone)
|
||||
|
||||
for interval in reference_scale:
|
||||
current_tone = current_tone.add(interval)
|
||||
working_scale.append(current_tone)
|
||||
|
||||
# Check if we need flats (duplicate letter names)
|
||||
if tonic_is_flat or self._should_prefer_flats(working_scale):
|
||||
working_scale = [self.tonic]
|
||||
current_tone = self.tonic
|
||||
for interval in reference_scale:
|
||||
current_tone = current_tone.add(interval, prefer_flats=True)
|
||||
working_scale.append(current_tone)
|
||||
|
||||
scales[scale] = Scale(tones=tuple(working_scale))
|
||||
|
||||
self._cached_scales = scales
|
||||
|
||||
+12
-4
@@ -313,18 +313,24 @@ class Tone:
|
||||
return klass.from_index(index, octave=octave, system=system)
|
||||
|
||||
@classmethod
|
||||
def from_index(klass, i: int, *, octave: int, system: object) -> Tone:
|
||||
def from_index(klass, i: int, *, octave: int, system: object, prefer_flats: bool = False) -> Tone:
|
||||
"""Create a Tone from its index within a tuning system.
|
||||
|
||||
Args:
|
||||
i: The index of the tone in the system's tone list.
|
||||
octave: The octave number.
|
||||
system: The ``ToneSystem`` instance.
|
||||
prefer_flats: If True and the tone has a flat spelling,
|
||||
use it instead of the default sharp spelling.
|
||||
|
||||
Returns:
|
||||
A new ``Tone`` instance.
|
||||
"""
|
||||
tone = system.tones[i].name
|
||||
tone_names = system.tone_names[i]
|
||||
if prefer_flats and len(tone_names) > 1:
|
||||
tone = tone_names[1] # flat spelling (e.g. "Bb")
|
||||
else:
|
||||
tone = tone_names[0] # sharp spelling (e.g. "A#")
|
||||
return klass(name=tone, octave=octave, system=system)
|
||||
|
||||
@property
|
||||
@@ -375,17 +381,19 @@ class Tone:
|
||||
|
||||
return (new_index, new_octave)
|
||||
|
||||
def add(self, interval: int) -> Tone:
|
||||
def add(self, interval: int, *, prefer_flats: bool = False) -> Tone:
|
||||
"""Return a new Tone that is *interval* semitones above this one.
|
||||
|
||||
Args:
|
||||
interval: Number of semitones to add (positive = up).
|
||||
prefer_flats: If True, use flat spellings (Bb, Eb) instead
|
||||
of sharp spellings (A#, D#) for accidentals.
|
||||
|
||||
Returns:
|
||||
A new ``Tone`` instance.
|
||||
"""
|
||||
index, octave = self._math(interval)
|
||||
return self.from_index(index, octave=octave, system=self.system)
|
||||
return self.from_index(index, octave=octave, system=self.system, prefer_flats=prefer_flats)
|
||||
|
||||
def subtract(self, interval: int) -> Tone:
|
||||
"""Return a new Tone that is *interval* semitones below this one.
|
||||
|
||||
+14
-14
@@ -238,8 +238,8 @@ def test_c_minor_scale():
|
||||
c = TonedScale(tonic="C4")
|
||||
minor = c["minor"]
|
||||
names = [t.name for t in minor.tones]
|
||||
# C D Eb F G Ab Bb C (using sharps: D#, G#, A#)
|
||||
assert names == ["C", "D", "D#", "F", "G", "G#", "A#", "C"]
|
||||
# C D Eb F G Ab Bb C (using flats for flat keys)
|
||||
assert names == ["C", "D", "Eb", "F", "G", "Ab", "Bb", "C"]
|
||||
|
||||
|
||||
def test_c_harmonic_minor_scale():
|
||||
@@ -247,7 +247,7 @@ def test_c_harmonic_minor_scale():
|
||||
hminor = c["harmonic minor"]
|
||||
names = [t.name for t in hminor.tones]
|
||||
# C D Eb F G Ab B C (raised 7th)
|
||||
assert names == ["C", "D", "D#", "F", "G", "G#", "B", "C"]
|
||||
assert names == ["C", "D", "Eb", "F", "G", "Ab", "B", "C"]
|
||||
|
||||
|
||||
def test_g_major_scale():
|
||||
@@ -308,7 +308,7 @@ def test_c_dorian():
|
||||
dorian = c["dorian"]
|
||||
names = [t.name for t in dorian.tones]
|
||||
# Dorian: W H W W W H W → C D Eb F G A Bb C
|
||||
assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"]
|
||||
assert names == ["C", "D", "Eb", "F", "G", "A", "Bb", "C"]
|
||||
|
||||
|
||||
def test_c_phrygian():
|
||||
@@ -316,7 +316,7 @@ def test_c_phrygian():
|
||||
phrygian = c["phrygian"]
|
||||
names = [t.name for t in phrygian.tones]
|
||||
# Phrygian: H W W W H W W → C Db Eb F G Ab Bb C
|
||||
assert names == ["C", "C#", "D#", "F", "G", "G#", "A#", "C"]
|
||||
assert names == ["C", "Db", "Eb", "F", "G", "Ab", "Bb", "C"]
|
||||
|
||||
|
||||
def test_c_lydian():
|
||||
@@ -332,7 +332,7 @@ def test_c_mixolydian():
|
||||
mixolydian = c["mixolydian"]
|
||||
names = [t.name for t in mixolydian.tones]
|
||||
# Mixolydian: W W H W W H W → C D E F G A Bb C
|
||||
assert names == ["C", "D", "E", "F", "G", "A", "A#", "C"]
|
||||
assert names == ["C", "D", "E", "F", "G", "A", "Bb", "C"]
|
||||
|
||||
|
||||
def test_c_locrian():
|
||||
@@ -340,7 +340,7 @@ def test_c_locrian():
|
||||
locrian = c["locrian"]
|
||||
names = [t.name for t in locrian.tones]
|
||||
# Locrian: H W W H W W W → C Db Eb F Gb Ab Bb C
|
||||
assert names == ["C", "C#", "D#", "F", "F#", "G#", "A#", "C"]
|
||||
assert names == ["C", "Db", "Eb", "F", "Gb", "Ab", "Bb", "C"]
|
||||
|
||||
|
||||
# ── Chords ───────────────────────────────────────────────────────────────────
|
||||
@@ -976,7 +976,7 @@ def test_f_major_scale():
|
||||
f = TonedScale(tonic="F4")
|
||||
major = f["major"]
|
||||
names = [t.name for t in major.tones]
|
||||
assert names == ["F", "G", "A", "A#", "C", "D", "E", "F"]
|
||||
assert names == ["F", "G", "A", "Bb", "C", "D", "E", "F"]
|
||||
|
||||
|
||||
def test_a_minor_scale():
|
||||
@@ -2299,7 +2299,7 @@ def test_japanese_hirajoshi():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
|
||||
h = c["hirajoshi"]
|
||||
names = [t.name for t in h]
|
||||
assert names == ["C", "D", "D#", "G", "G#", "C"]
|
||||
assert names == ["C", "D", "Eb", "G", "Ab", "C"]
|
||||
|
||||
|
||||
def test_japanese_in_scale():
|
||||
@@ -2307,7 +2307,7 @@ def test_japanese_in_scale():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
|
||||
s = c["in"]
|
||||
names = [t.name for t in s]
|
||||
assert names == ["C", "C#", "F", "G", "G#", "C"]
|
||||
assert names == ["C", "Db", "F", "G", "Ab", "C"]
|
||||
|
||||
|
||||
def test_japanese_yo_scale():
|
||||
@@ -2323,7 +2323,7 @@ def test_japanese_iwato():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
|
||||
s = c["iwato"]
|
||||
names = [t.name for t in s]
|
||||
assert names == ["C", "C#", "F", "F#", "A#", "C"]
|
||||
assert names == ["C", "Db", "F", "Gb", "Bb", "C"]
|
||||
|
||||
|
||||
def test_japanese_kumoi():
|
||||
@@ -2331,7 +2331,7 @@ def test_japanese_kumoi():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
|
||||
s = c["kumoi"]
|
||||
names = [t.name for t in s]
|
||||
assert names == ["C", "D", "D#", "G", "A", "C"]
|
||||
assert names == ["C", "D", "Eb", "G", "A", "C"]
|
||||
|
||||
|
||||
def test_japanese_ritsu():
|
||||
@@ -2339,7 +2339,7 @@ def test_japanese_ritsu():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["japanese"])
|
||||
s = c["ritsu"]
|
||||
names = [t.name for t in s]
|
||||
assert names == ["C", "D", "D#", "F", "G", "A", "A#", "C"]
|
||||
assert names == ["C", "D", "Eb", "F", "G", "A", "Bb", "C"]
|
||||
|
||||
|
||||
def test_japanese_all_scales_available():
|
||||
@@ -2385,7 +2385,7 @@ def test_blues_scale():
|
||||
c = TonedScale(tonic="C4", system=SYSTEMS["blues"])
|
||||
s = c["blues"]
|
||||
names = s.note_names
|
||||
assert names == ["C", "D#", "F", "F#", "G", "A#", "C"]
|
||||
assert names == ["C", "Eb", "F", "Gb", "G", "Bb", "C"]
|
||||
assert len(names) == 7 # 6 notes + octave
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user