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:
2026-03-23 09:22:39 -04:00
parent a5ffdc6104
commit d2058668a6
7 changed files with 74 additions and 42 deletions
+6 -6
View File
@@ -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
----------------------------
+2 -2
View File
@@ -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"]
+7 -7
View File
@@ -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
~~~~~~~~~~~~~~~~~~~
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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