diff --git a/docs/guide/cookbook.rst b/docs/guide/cookbook.rst index 830f0ad..9c58793 100644 --- a/docs/guide/cookbook.rst +++ b/docs/guide/cookbook.rst @@ -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 ---------------------------- diff --git a/docs/guide/quickstart.rst b/docs/guide/quickstart.rst index 7e4272b..2afe72f 100644 --- a/docs/guide/quickstart.rst +++ b/docs/guide/quickstart.rst @@ -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"] diff --git a/docs/guide/scales.rst b/docs/guide/scales.rst index 03e23da..c4b117e 100644 --- a/docs/guide/scales.rst +++ b/docs/guide/scales.rst @@ -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 `_ (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 `_ (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 `_ (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 `_ (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 ~~~~~~~~~~~~~~~~~~~ diff --git a/docs/guide/systems.rst b/docs/guide/systems.rst index 63a0678..67fa16e 100644 --- a/docs/guide/systems.rst +++ b/docs/guide/systems.rst @@ -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 diff --git a/pytheory/scales.py b/pytheory/scales.py index 3393bcf..1d776b9 100644 --- a/pytheory/scales.py +++ b/pytheory/scales.py @@ -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 diff --git a/pytheory/tones.py b/pytheory/tones.py index efe9f5a..64f6300 100644 --- a/pytheory/tones.py +++ b/pytheory/tones.py @@ -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. diff --git a/test_pytheory.py b/test_pytheory.py index 8d813f3..2b30862 100644 --- a/test_pytheory.py +++ b/test_pytheory.py @@ -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