Source code for pytheory.scales

from __future__ import annotations

from typing import Optional, Union

from .systems import SYSTEMS, System
from .tones import Tone

_PC_LETTERS = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11}


def _pitch_classes(names):
    """Note names → set of pitch-class ints (enharmonic-aware).

    Accepts a letter plus any run of sharps/flats ("C", "A#", "Bb",
    "C##"); a trailing octave digit is ignored, and names that don't
    parse as Western notes are skipped.
    """
    out = set()
    for name in names:
        name = str(name).strip()
        if not name or name[0].upper() not in _PC_LETTERS:
            continue
        pc = _PC_LETTERS[name[0].upper()]
        for ch in name[1:]:
            if ch in "#♯":
                pc += 1
            elif ch in "b♭":
                pc -= 1
            else:
                break
        out.add(pc % 12)
    return out


[docs] class Scale:
[docs] def __init__(self, *, tones: tuple[Tone, ...], degrees: Optional[tuple[str, ...]] = None, system: Union[str, System] = 'western') -> None: """Initialize a Scale from a sequence of Tones. Args: tones: The tones that make up the scale. degrees: Optional names for each scale degree (must match length of *tones*). system: A tone system name or :class:`System` instance. Raises: ValueError: If *degrees* is provided but its length differs from *tones*. """ self.tones = tones self.degrees = degrees if isinstance(system, str): self.system_name = system self._system = None else: self.system_name = None self._system = system if self.degrees: if not len(self.tones) == len(self.degrees): raise ValueError("The number of tones and degrees must be equal!")
@property def system(self) -> Optional[System]: """Return the tone system for this scale. Resolves a system name to a :class:`System` object on first access. """ if self._system: return self._system if self.system_name: return SYSTEMS[self.system_name]
[docs] def __repr__(self) -> str: r = [] for (i, tone) in enumerate(self.tones): from ._statics import int2roman degree = int2roman(i + 1) r += [f"{degree}={tone.full_name}"] r = " ".join(r) return f"<Scale {r}>"
[docs] def __iter__(self): """Iterate over the tones in this scale.""" return iter(self.tones)
[docs] def __len__(self) -> int: """Return the number of tones in this scale (including the octave).""" return len(self.tones)
[docs] def __contains__(self, item: Union[str, Tone]) -> bool: """Check whether a tone or note name belongs to this scale.""" if isinstance(item, str): return any(item == t.name for t in self.tones) return item in self.tones
@property def note_names(self) -> list[str]: """List of note names in this scale.""" return [t.name for t in self.tones]
[docs] def fitness(self, *note_names: str) -> float: """Score how well a set of notes fits this scale (0.0–1.0). Returns the fraction of the given notes that appear in the scale. Useful for melody analysis — testing whether a phrase belongs to a particular scale or mode. Args: *note_names: Note name strings (e.g. ``"C"``, ``"F#"``). Returns: A float from 0.0 (no notes match) to 1.0 (all notes match). Example:: >>> c_major = TonedScale(tonic="C4")["major"] >>> c_major.fitness("C", "D", "E", "G") 1.0 >>> c_major.fitness("C", "D", "F#", "G") 0.75 >>> c_major.fitness("C#", "D#", "F#") 0.0 """ if not note_names: return 0.0 scale_notes = set(self.note_names) matches = sum(1 for n in note_names if n in scale_notes) return matches / len(note_names)
_DEGREE_NAMES = [ "tonic", "supertonic", "mediant", "subdominant", "dominant", "submediant", "leading tone", ] _MINOR_DEGREE_NAMES = [ "tonic", "supertonic", "mediant", "subdominant", "dominant", "submediant", "subtonic", ]
[docs] def degree_name(self, n: int, *, minor: bool = False) -> str: """Return the traditional name for the nth scale degree (0-indexed). Args: n: The scale degree index (0 = tonic, 1 = supertonic, etc.). minor: If True, use "subtonic" instead of "leading tone" for degree 6. Returns: A string like "tonic", "dominant", etc. Example:: >>> TonedScale(tonic="C4")["major"].degree_name(0) 'tonic' >>> TonedScale(tonic="C4")["major"].degree_name(4) 'dominant' """ names = self._MINOR_DEGREE_NAMES if minor else self._DEGREE_NAMES if 0 <= n < len(names): return names[n] return f"degree {n}"
[docs] def chord(self, *degrees: int) -> Chord: """Build a Chord from scale degrees (0-indexed). Wraps around if degrees exceed the scale length, transposing up by an octave as needed. Example: scale.chord(0, 2, 4) builds a triad from the 1st, 3rd, 5th. """ from .chords import Chord # Scale has N tones where the last is the octave of the first, # so the unique tones are tones[:-1] (length N-1). unique = len(self.tones) - 1 result = [] for d in degrees: idx = d % unique octaves_up = d // unique tone = self.tones[idx] if octaves_up > 0: # Keep the scale tone's spelling across the octave jump — # plain .add(12) would re-spell Eb as D#. tone = tone.add(12 * octaves_up, prefer_flats="b" in tone.name) result.append(tone) return Chord(tones=result)
[docs] def transpose(self, semitones: int) -> Scale: """Return a new Scale transposed by the given number of semitones. Every tone is shifted by the same interval, preserving the scale's interval pattern. Example:: >>> c_major = TonedScale(tonic="C4")["major"] >>> d_major = c_major.transpose(2) >>> d_major.note_names ['D', 'E', 'F#', 'G', 'A', 'B', 'C#', 'D'] """ from .chords import Chord new_tones = tuple(t.add(semitones) for t in self.tones) return Scale(tones=new_tones)
[docs] def triad(self, root: int = 0) -> Chord: """Build a triad starting from the given scale degree (0-indexed). Returns a chord with the root, 3rd, and 5th above it. """ return self.chord(root, root + 2, root + 4)
[docs] def seventh(self, root: int = 0) -> Chord: """Build a seventh chord from the given scale degree (0-indexed). Returns a chord with the root, 3rd, 5th, and 7th. """ return self.chord(root, root + 2, root + 4, root + 6)
[docs] def progression(self, *numerals: str) -> list[Chord]: """Build a chord progression from Roman numeral strings. The chord's **quality follows the numeral**, not just the scale, so the notation says exactly what you mean: - **case** sets major vs minor — ``"V"`` is a major triad, ``"v"`` a minor one (so an uppercase ``"V"`` in a minor key gives the harmonic-minor dominant, and ``"IV"`` in minor gives the Dorian major-IV); - **quality markers** ``"°"``/``"o"`` (diminished), ``"ø"`` (half-diminished), ``"+"`` (augmented), ``"maj"`` (major 7th); - a **flat/sharp prefix** borrows from outside the key — ``"bVII"`` is a major triad on the lowered 7th degree (the Mixolydian/​rock ♭VII), ``"bII"`` the Neapolitan, and so on; - a trailing **``"7"``** makes it a seventh chord (dominant for an uppercase numeral, minor for lowercase); - a **slash** marks a secondary/applied chord — ``"V7/V"`` is the dominant of the dominant, ``"vii°/ii"`` the leading-tone chord of ``ii`` (the part before the slash is read in a major key rooted on the target). Example:: >>> scale.progression("I", "IV", "V7", "I") [<Chord C major>, <Chord F major>, <Chord G dominant 7th>, <Chord C major>] >>> scale.progression("I", "bVII") # Mixolydian vamp [<Chord C major>, <Chord Bb major>] >>> scale.progression("I", "V7/V", "V7", "I") [<Chord C major>, <Chord D dominant 7th>, <Chord G dominant 7th>, <Chord C major>] """ return [self._chord_from_numeral(num) for num in numerals]
def _chord_from_numeral(self, numeral: str) -> Chord: """Resolve a single Roman-numeral string to a Chord (see :meth:`progression`).""" from ._statics import roman2int from .chords import Chord s = numeral.strip() # Secondary / applied chords: "V7/V", "V/ii", "vii°/V". The part # after the slash names the target; the part before is interpreted # in a major key rooted on that target's root. if "/" in s: applied, target = s.split("/", 1) target_root = self._chord_from_numeral(target).tones[0] return TonedScale(tonic=target_root)["major"]._chord_from_numeral(applied) # Accidental prefix — borrow from outside the key. offset = 0 if s[:1] in ("b", "♭"): offset, s = -1, s[1:] elif s[:1] in ("#", "♯"): offset, s = 1, s[1:] # Trailing seventh. is_seventh = s.endswith("7") if is_seventh: s = s[:-1] # Quality markers (stripped to leave the bare roman numeral). quality = "case" low = s.lower() if s.endswith(("°", "o")): quality, s = "dim", s[:-1] elif low.endswith("dim"): quality, s = "dim", s[:-3] elif s.endswith("ø"): quality, s = "halfdim", s[:-1] elif s.endswith("+"): quality, s = "aug", s[:-1] elif low.endswith("aug"): quality, s = "aug", s[:-3] elif low.endswith("maj"): quality, s = "maj", s[:-3] # What's left must be a bare Roman numeral I–VII; reject garbage # ("XYZ", "IIII", "") instead of silently producing a bogus chord. if s.upper() not in ("I", "II", "III", "IV", "V", "VI", "VII"): raise ValueError(f"Invalid Roman numeral: {numeral!r}") degree = roman2int(s.upper()) - 1 is_major = s.isupper() unique = len(self.tones) - 1 if offset: # An accidental makes the numeral chromatic: it's measured from # the parallel major (bVI = Ab in any C-rooted key), not stacked # on top of a scale degree that may already be altered — otherwise # bVI in C minor would read Abb. Bare numerals stay diatonic. _MAJOR_STEPS = (0, 2, 4, 5, 7, 9, 11) semis = _MAJOR_STEPS[degree % 7] + 12 * (degree // 7) + offset root = self.tones[0].add(semis, prefer_flats=(offset < 0)) else: # Root = the scale degree. root = self.tones[degree % unique] if degree >= unique: root = root.add(12 * (degree // unique)) # Intervals above the root for the requested quality. if quality == "dim": intervals = [3, 6, 9] if is_seventh else [3, 6] elif quality == "halfdim": intervals = [3, 6, 10] elif quality == "aug": intervals = [4, 8, 10] if is_seventh else [4, 8] elif quality == "maj": intervals = [4, 7, 11] if is_seventh else [4, 7] elif is_major: intervals = [4, 7, 10] if is_seventh else [4, 7] # dominant 7th else: intervals = [3, 7, 10] if is_seventh else [3, 7] # minor 7th return Chord(tones=[root] + [root.add(i) for i in intervals])
[docs] def nashville(self, *numbers: Union[int, str]) -> list[Chord]: """Build a chord progression using Nashville number system. The `Nashville number system <https://en.wikipedia.org/wiki/Nashville_Number_System>`_ uses Arabic numerals instead of Roman numerals. It's the standard chart system in Nashville recording studios. Numbers 1-7 build diatonic triads. Suffix ``"7"`` for seventh chords, ``"m"`` to force minor. Example:: >>> scale.nashville(1, 4, 5, 1) [<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>] """ from .chords import Chord chords = [] for num in numbers: s = str(num).strip() # A leading accidental borrows a chord from outside the key. The # degree is the first digit, so the "7" in "b7" is the degree # (♭VII), not a seventh-chord marker. offset = 0 if s[:1] in ("b", "♭"): offset, s = -1, s[1:] elif s[:1] in ("#", "♯"): offset, s = 1, s[1:] degree = int(s[0]) - 1 suffix = s[1:] is_seventh = suffix.endswith("7") if is_seventh: suffix = suffix[:-1] force_minor = suffix.endswith("m") or suffix == "-" if offset != 0: # Borrowed chord on the altered degree — major by default # (b7 = Bb major), minor with the "m" modifier. root = self.triad(degree).tones[0].add( offset, prefer_flats=(offset < 0)) ivs = ([3, 7, 10] if is_seventh else [3, 7]) if force_minor \ else ([4, 7, 10] if is_seventh else [4, 7]) chords.append(Chord(tones=[root] + [root.add(i) for i in ivs])) elif force_minor: # Borrow a minor chord on this degree (4m = F minor in C). # Start from the correctly-spelled diatonic triad and flatten # the third by letter (A→Ab, not G#) so the spelling reads. base = self.triad(degree).tones root, third, fifth = base[0], base[1], base[2] if (third._index - root._index) % 12 == 4: # major 3rd third = third.add(-1, prefer_flats=True) if (fifth._index - root._index) % 12 == 6: # dim 5th (vii°) fifth = fifth.add(1) tones = [root, third, fifth] if is_seventh: tones.append(root.add(10, prefer_flats=True)) # minor 7th chords.append(Chord(tones=tones)) elif is_seventh: chords.append(self.seventh(degree)) else: chords.append(self.triad(degree)) return chords
[docs] @staticmethod def detect(*note_names: str) -> Optional[tuple[str, str, int]]: """Detect the most likely scale from a set of note names. Tries all scales in the Western system and returns the best match as a ``(tonic, scale_name, match_count)`` tuple. Example:: >>> Scale.detect("C", "D", "E", "F", "G", "A", "B") ('C', 'major', 7) >>> Scale.detect("C", "D", "Eb", "F", "G", "Ab", "Bb") ('C', 'minor', 7) """ if not note_names: return None # Compare pitch classes, not spellings — "A#" and "Bb" are the # same note, and some scales render with mixed spellings. notes = _pitch_classes(note_names) if not notes: return None best = None chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] for tonic in chromatic: ts = TonedScale(tonic=f"{tonic}4") for scale_name in ts.scales: try: scale = ts[scale_name] scale_pcs = _pitch_classes(scale.note_names) match = len(notes & scale_pcs) score = (match, 1 if scale_name == "major" else 0) if best is None or score > best[0]: best = (score, tonic, scale_name, match) except (KeyError, ValueError): continue if best: return (best[1], best[2], best[3]) return None
[docs] @staticmethod def recommend(*note_names: str, top: int = 5) -> list[tuple[str, str, float]]: """Recommend the best-matching scales for a set of notes, ranked by fitness. Tests the given notes against every scale in the Western system and returns the top matches. Useful for figuring out what scale a melody or chord progression belongs to, or finding alternative scales to play over a set of changes. Args: *note_names: Note name strings (e.g. ``"C"``, ``"E"``, ``"G"``). top: Number of results to return (default 5). Returns: A list of ``(tonic, scale_name, fitness)`` tuples sorted by fitness descending. Fitness is 0.0–1.0. Example:: >>> Scale.recommend("C", "D", "E", "G", "A") [('C', 'major', 1.0), ('G', 'major', 1.0), ...] >>> Scale.recommend("C", "Eb", "F", "Gb", "G", "Bb") [('C', 'blues', 1.0), ...] """ if not note_names: return [] results = [] chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] for tonic in chromatic: ts = TonedScale(tonic=f"{tonic}4") for scale_name in ts.scales: try: scale = ts[scale_name] fit = scale.fitness(*note_names) if fit > 0: results.append((tonic, scale_name, fit)) except (KeyError, ValueError): continue # Penalize chromatic scale — it matches everything but tells you nothing # Also prefer scales whose length is closer to the input length input_len = len(note_names) def _score(r): tonic, name, fit = r penalty = 0.5 if "chromatic" in name else 0 return (-fit + penalty, abs(input_len - 7), name, tonic) results.sort(key=_score) return results[:top]
[docs] def harmonize(self) -> list[Chord]: """Build diatonic triads on every scale degree. Returns a list of Chords — one triad for each degree of the scale. In a major scale this produces: I, ii, iii, IV, V, vi, vii°. Example:: >>> [c.identify() for c in TonedScale(tonic="C4")["major"].harmonize()] ['C major', 'D minor', 'E minor', 'F major', 'G major', 'A minor', 'B diminished'] """ unique = len(self.tones) - 1 return [self.triad(i) for i in range(unique)]
[docs] def parallel_modes(self) -> dict[str, list[str]]: """All modes that share the same notes as this scale. For example, C major shares its notes with D dorian, E phrygian, F lydian, G mixolydian, A aeolian, and B locrian. Returns: A dict mapping ``"tonic mode"`` to note name lists. Example:: >>> c_major = TonedScale(tonic="C4")["major"] >>> c_major.parallel_modes() {'C ionian': ['C', 'D', 'E', ...], 'D dorian': ['D', 'E', 'F', ...], ...} """ mode_names = ["ionian", "dorian", "phrygian", "lydian", "mixolydian", "aeolian", "locrian"] unique = len(self.tones) - 1 if unique != 7: return {} result = {} for i, mode in enumerate(mode_names): if i >= unique: break tonic = self.tones[i] ts = TonedScale(tonic=tonic.full_name) try: scale = ts[mode] result[f"{tonic.name} {mode}"] = scale.note_names except KeyError: continue return result
[docs] def degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]: """Look up the tone(s) at a scale degree. Args: item: The degree to retrieve. May be an integer index (0-indexed), a slice, a Roman numeral (``"IV"``), or a degree/mode name (``"dominant"``, ``"dorian"``). major / minor: Mutually exclusive hints used when resolving an ambiguous degree reference. Pass at most one. Returns: A single :class:`Tone`, a tuple of Tones (for a slice), or ``None`` if the degree could not be resolved. Raises: ValueError: If both *major* and *minor* are truthy. """ # Ensure that both major and minor aren't passed. if all((major, minor)): raise ValueError("You can only specify one of the following: major, minor.") # Roman–style degree reference (e.g. "IV"). if isinstance(item, str): degrees = [] for (i, tone) in enumerate(self.tones): from ._statics import int2roman degrees.append(int2roman(i + 1)) if item in degrees: item = degrees.index(item) # If we're still a string, attempt to grab degree name. if isinstance(item, str): for i, _degree in enumerate(self.system.degrees): # Match degree name (e.g. "tonic", "dominant") if item == _degree[0]: item = i break # Also match mode name (e.g. "ionian", "dorian") if item in _degree[1]: item = i break # List/Tuple–style reference. if isinstance(item, int) or isinstance(item, slice): return self.tones[item]
[docs] def __getitem__(self, item: Union[str, int, slice]) -> Union[Tone, tuple[Tone, ...]]: """Retrieve a tone by scale degree (integer, Roman numeral, or degree name). Raises: KeyError: If the given degree is not found in this scale. """ result = self.degree(item) if result is None: raise KeyError( f"No scale degree {item!r}. Use an integer (1-indexed), a " f"Roman numeral (e.g. 'IV'), or a degree name (e.g. 'dominant')." ) return result
PROGRESSIONS = { # Rock / Pop / Folk "I-IV-V-I": ("I", "IV", "V", "I"), "I-V-vi-IV": ("I", "V", "vi", "IV"), # the "four-chord song" "I-vi-IV-V": ("I", "vi", "IV", "V"), # 50s doo-wop "I-IV-vi-V": ("I", "IV", "vi", "V"), "vi-IV-I-V": ("vi", "IV", "I", "V"), "I-iii-IV-V": ("I", "iii", "IV", "V"), "I-IV-I-V": ("I", "IV", "I", "V"), # "La Bamba", "Twist and Shout" "I-iii-vi-IV": ("I", "iii", "vi", "IV"), "I-IV-V-vi": ("I", "IV", "V", "vi"), # deceptive pop "vi-V-IV-V": ("vi", "V", "IV", "V"), # Blues "12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"), "12-bar quick change": ("I", "IV", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"), "8-bar blues": ("I", "I", "IV", "IV", "I", "V", "I", "V"), "minor 12-bar blues": ("i", "i", "i", "i", "iv", "iv", "i", "i", "V7", "iv", "i", "V7"), # Jazz "ii-V-I": ("ii", "V7", "I"), "vi-ii-V-I": ("vi", "ii", "V7", "I"), # extended turnaround "I-vi-ii-V": ("I", "vi", "ii", "V"), # rhythm changes A section "iii-vi-ii-V": ("iii", "vi", "ii", "V"), # jazz turnaround "minor ii-V-i": ("ii°", "V7", "i"), "ragtime": ("I", "V7/ii", "V7/V", "V7"), # Sweet Georgia Brown "rhythm changes bridge": ("V7/vi", "V7/ii", "V7/V", "V7"), # Classical / Film "i-bVI-bIII-bVII": ("i", "VI", "III", "VII"), "Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"), "I-V-vi-iii-IV": ("I", "V", "vi", "iii", "IV"), "circle of fifths": ("I", "IV", "vii°", "iii", "vi", "ii", "V", "I"), # Flamenco / Spanish "Andalusian": ("i", "VII", "VI", "V"), # Minor "i-iv-v-i": ("i", "iv", "v", "i"), "i-iv-VII-III": ("i", "iv", "VII", "III"), "i-VI-VII-i": ("i", "VI", "VII", "i"), "i-v-VI-IV": ("i", "v", "VI", "IV"), # "epic" minor "i-VII-i": ("i", "VII", "i"), # Modal "Dorian vamp": ("i", "IV"), "Mixolydian vamp": ("I", "bVII"), "Aeolian": ("i", "VI", "VII"), } """Common chord progressions as Roman numeral tuples. Use with :meth:`Scale.progression` or :meth:`Key.progression`:: Key("C", "major").progression(*PROGRESSIONS["I-V-vi-IV"]) """
[docs] class Key: """A musical key — a convenient entry point for scales and harmony. A Key represents a tonic note and a mode. It provides quick access to the scale, diatonic chords, and common progressions. Example:: >>> key = Key("C", "major") >>> key.scale.note_names ['C', 'D', 'E', 'F', 'G', 'A', 'B', 'C'] >>> key.chords ['C major', 'D minor', 'E minor', 'F major', ...] >>> key.progression("I", "V", "vi", "IV") [<Chord (C,E,G)>, <Chord (G,B,D)>, ...] """
[docs] def __init__(self, tonic: str, mode: str = "major", system: Optional[Union[str, System]] = None) -> None: if system is None: system = SYSTEMS["western"] elif isinstance(system, str): system = SYSTEMS[system] self.tonic_name = tonic self.mode = mode self._system = system self._toned_scale = TonedScale(tonic=f"{tonic}4", system=system) self._scale = self._toned_scale[mode]
[docs] @classmethod def detect(cls, *note_names: str) -> Optional[Key]: """Detect the most likely key from a set of note names. Tries every possible major and minor key and returns the one whose scale contains the most of the given notes. Notes are compared as pitch classes, so enharmonic spellings don't matter — ``"A#"`` and ``"Bb"`` count the same. Ties go to the key whose tonic (then fifth) is actually among the notes, then to major over minor. Example:: >>> Key.detect("C", "D", "E", "F", "G", "A", "B") <Key C major> >>> Key.detect("A", "B", "C", "D", "E", "F", "G") <Key C major> >>> Key.detect("A", "C", "E") <Key A minor> Returns: The best-matching Key, or None if no notes given. """ if not note_names: return None # Compare pitch classes, not spellings — "A#" and "Bb" are the # same note, and a key whose scale renders with mixed spellings # must not get extra name-matches for it. notes = _pitch_classes(note_names) if not notes: return None best_key = None best_score = (-1, 0, 0, 0) # Conventional key spellings — Bb major, not the theoretical # A# major; C# minor, not Db minor. tonics = { "major": ["C", "Db", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"], "minor": ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"], } for mode in ("major", "minor"): for tonic_pc, tonic in enumerate(tonics[mode]): try: k = cls(tonic, mode) scale_pcs = _pitch_classes(k.note_names) match = len(notes & scale_pcs) # Tiebreaks: a key is more plausible when its tonic # (then its fifth) actually occurs in the notes; # prefer major over minor last. score = (match, 1 if tonic_pc in notes else 0, 1 if (tonic_pc + 7) % 12 in notes else 0, 1 if mode == "major" else 0) if score > best_score: best_score = score best_key = k except (KeyError, ValueError): continue return best_key
[docs] def __repr__(self) -> str: return f"<Key {self.tonic_name} {self.mode}>"
def __str__(self) -> str: return f"{self.tonic_name} {self.mode}" @property def scale(self) -> Scale: """The scale for this key.""" return self._scale @property def note_names(self) -> list[str]: """Note names in this key's scale.""" return self._scale.note_names @property def chords(self) -> list[str]: """Names of all diatonic triads in this key.""" return [c.identify() for c in self._scale.harmonize()] @property def seventh_chords(self) -> list[str]: """Names of all diatonic seventh chords in this key.""" unique = len(self._scale.tones) - 1 return [self._scale.seventh(i).identify() for i in range(unique)]
[docs] def triad(self, degree: int) -> Chord: """Build a diatonic triad on the given degree (0-indexed).""" return self._scale.triad(degree)
[docs] def seventh(self, degree: int) -> Chord: """Build a diatonic seventh chord on the given degree (0-indexed).""" return self._scale.seventh(degree)
[docs] def progression(self, *numerals: str) -> list[Chord]: """Build a chord progression from Roman numerals. Example:: >>> Key("G", "major").progression("I", "IV", "V7", "I") """ return self._scale.progression(*numerals)
[docs] def nashville(self, *numbers: Union[int, str]) -> list[Chord]: """Build a chord progression using Nashville numbers. Example:: >>> Key("G", "major").nashville(1, 4, 5, 1) """ return self._scale.nashville(*numbers)
[docs] def secondary_dominant(self, degree: int) -> Chord: """Build a secondary dominant (V/x) for the given scale degree. A secondary dominant is the dominant chord of a non-tonic degree. For example, in C major, V/V is D major (the V chord of G). Secondary dominants create momentary tonicizations that add color and forward motion. Common secondary dominants: - V/V (e.g. D7 in C major) — approaches the dominant - V/ii (e.g. A7 in C major) — approaches the supertonic - V/vi (e.g. E7 in C major) — approaches the relative minor Args: degree: Scale degree to target (1-indexed). ``5`` means "build the V of the 5th degree." Degrees past the octave wrap (``9`` targets the 2nd degree); a non-positive degree raises ``ValueError``. Returns: A dominant 7th Chord that resolves to the given degree. Example:: >>> Key("C", "major").secondary_dominant(5) # V/V = D7 <Chord D dominant 7th> """ if not isinstance(degree, int) or degree < 1: raise ValueError( f"secondary_dominant degree must be a positive integer " f"(1-indexed scale degree), got {degree!r}" ) # Wrap degrees past the octave so e.g. V/9 == V/2 rather than crashing. # tones[] ends with the octave repeat of the tonic, so wrap over the # distinct degrees (len - 1) to treat degree 8 as degree 1. n_degrees = len(self._scale.tones) - 1 target = self._scale.tones[(degree - 1) % n_degrees] # Build a dominant 7th a perfect 5th above the target from .chords import Chord root = target.add(7) return Chord(tones=[root, root.add(4), root.add(7), root.add(10)])
[docs] def common_progressions(self) -> dict[str, list]: """Named chord progressions realized in this key. Returns a dict mapping progression names (from ``PROGRESSIONS``) to lists of Chord objects built in this key. Example:: >>> key = Key("C", "major") >>> for name, chords in key.common_progressions().items(): ... symbols = [c.symbol or str(c) for c in chords] ... print(f"{name}: {' → '.join(symbols)}") I-IV-V-I: C → F → G → C I-V-vi-IV: C → G → Am → F ... """ result = {} for name, numerals in PROGRESSIONS.items(): try: result[name] = self.progression(*numerals) except (KeyError, ValueError, IndexError): continue return result
[docs] @classmethod def all_keys(cls) -> list[Key]: """Return all 24 major and minor keys. Returns: A list of Key objects for all 12 major and 12 minor keys. Example:: >>> for k in Key.all_keys(): ... print(k) """ chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"] keys = [] for tonic in chromatic: keys.append(cls(tonic, "major")) keys.append(cls(tonic, "minor")) return keys
@property def signature(self) -> dict: """The key signature — number and names of sharps or flats. In Western music, each key has a unique key signature that tells you which notes are sharped or flatted throughout a piece. Returns: A dict with: - ``sharps`` (int): number of sharps (0 if flat key) - ``flats`` (int): number of flats (0 if sharp key) - ``accidentals`` (list[str]): the sharped/flatted note names Example:: >>> Key("G", "major").signature {'sharps': 1, 'flats': 0, 'accidentals': ['F#']} >>> Key("F", "major").signature {'sharps': 0, 'flats': 1, 'accidentals': ['Bb']} >>> Key("C", "major").signature {'sharps': 0, 'flats': 0, 'accidentals': []} """ # Compare scale notes against the natural notes C D E F G A B naturals = {"C", "D", "E", "F", "G", "A", "B"} scale_notes = set(self.note_names[:-1]) # exclude octave sharps = [n for n in scale_notes if "#" in n] flats = [n for n in scale_notes if "b" in n[1:]] # skip first char for B # Order sharps: F C G D A E B sharp_order = ["F#", "C#", "G#", "D#", "A#", "E#", "B#"] flat_order = ["Bb", "Eb", "Ab", "Db", "Gb", "Cb", "Fb"] sharps_sorted = [s for s in sharp_order if s in sharps] flats_sorted = [f for f in flat_order if f in flats] if sharps_sorted: return {"sharps": len(sharps_sorted), "flats": 0, "accidentals": sharps_sorted} elif flats_sorted: return {"sharps": 0, "flats": len(flats_sorted), "accidentals": flats_sorted} else: return {"sharps": 0, "flats": 0, "accidentals": []} @property def borrowed_chords(self) -> list[str]: """Chords borrowed from the parallel key. Modal interchange (or modal mixture) borrows chords from the parallel major or minor key. In C major, the parallel minor is C minor, which provides chords like Ab major, Bb major, and Eb major — commonly heard in rock, film, and pop music. Returns: A list of chord names from the parallel key that are NOT in the current key's diatonic chords. Example:: >>> Key("C", "major").borrowed_chords ['C minor', 'D diminished', 'D# major', ...] """ par = self.parallel if par is None: return [] own = set(self.chords) return [c for c in par.chords if c not in own]
[docs] def random_progression(self, length: int = 4) -> list: """Generate a random diatonic chord progression. Uses weighted probabilities based on common chord function: I and vi are most common, IV and V are very common, ii is common, iii and viidim are rare. Always starts on I and ends on I or V. Args: length: Number of chords (default 4). Returns: A list of Chord objects. Example:: >>> Key("C", "major").random_progression(4) [<Chord C major>, <Chord F major>, <Chord G major>, <Chord C major>] """ import random harmonized = self._scale.harmonize() unique = len(harmonized) # Weights: I=high, ii=med, iii=low, IV=high, V=high, vi=med, vii=low weights = [10, 5, 2, 8, 8, 5, 1] if unique < len(weights): weights = weights[:unique] chords = [harmonized[0]] # Start on I for _ in range(length - 2): chords.append(random.choices(harmonized, weights=weights, k=1)[0]) if length > 1: # End on I or V chords.append(random.choice([harmonized[0], harmonized[4 % unique]])) return chords
# Common chord movement tendencies in major keys (0-indexed degrees). # Maps each degree to a list of likely next degrees, ordered by frequency. _TENDENCY = { 0: [3, 4, 5, 1], # I → IV, V, vi, ii 1: [4, 0, 6], # ii → V, I, vii 2: [5, 3, 1], # iii → vi, IV, ii 3: [4, 0, 1], # IV → V, I, ii 4: [0, 5, 3], # V → I, vi, IV 5: [1, 3, 4], # vi → ii, IV, V 6: [0, 5], # vii° → I, vi }
[docs] def suggest_next(self, chord) -> list: """Suggest likely next chords based on voice-leading tendencies. Given a chord in this key, returns a ranked list of chords that commonly follow it, based on standard functional harmony rules (e.g. V → I, ii → V, IV → V). Args: chord: A Chord object currently being played. Returns: A list of Chord objects, most likely first. Example:: >>> key = Key("C", "major") >>> g = key.triad(4) # G major (V) >>> [c.symbol for c in key.suggest_next(g)] ['C', 'Am', 'F'] """ harmonized = self._scale.harmonize() unique = len(harmonized) # Find which degree this chord is chord_id = chord.identify() if not chord_id: return harmonized[:3] degree = None for i, h in enumerate(harmonized): if h.identify() == chord_id: degree = i break if degree is None: return harmonized[:3] tendencies = self._TENDENCY.get(degree, [0]) return [harmonized[d % unique] for d in tendencies]
@property def relative(self) -> Optional[Key]: """The relative major or minor key. If this is a major key, returns the relative minor (vi). If this is a minor key, returns the relative major (bIII). """ if self.mode == "major": # Relative minor starts on the 6th degree minor_tonic = self._scale.tones[5].name return Key(minor_tonic, "minor") elif self.mode in ("minor", "aeolian"): # Relative major starts on the 3rd degree major_tonic = self._scale.tones[2].name return Key(major_tonic, "major") return None @property def parallel(self) -> Optional[Key]: """The parallel major or minor key (same tonic, different mode).""" if self.mode == "major": return Key(self.tonic_name, "minor") elif self.mode in ("minor", "aeolian"): return Key(self.tonic_name, "major") return None
[docs] def modulation_path(self, target: Key) -> list: """Suggest a chord-by-chord path from this key to a target key. Strategy: - Find pivot chords (common to both keys) - Build: [I of current key, pivot chord, V of target key, I of target key] - If no pivot chord exists, use chromatic approach: [current I, target V, target I] Args: target: The target Key to modulate to. Returns: A list of Chord objects forming a modulation path. Example:: >>> path = Key("C", "major").modulation_path(Key("G", "major")) >>> len(path) 4 """ from .chords import Chord current_I = self.triad(0) target_V = target.triad(4) target_I = target.triad(0) # Find pivot chords own_chords = self._scale.harmonize() target_chord_names = set(target.chords) pivot = None for c in own_chords: cid = c.identify() if cid and cid in target_chord_names and cid != current_I.identify(): pivot = c break if pivot is not None: return [current_I, pivot, target_V, target_I] else: # Chromatic approach - no pivot chord return [current_I, target_V, target_I]
[docs] def pivot_chords(self, target: Key) -> list[str]: """Find chords common to this key and a target key. Pivot chords are the bridge for modulation — they belong to both keys, so a listener accepts them in either context. The more pivot chords two keys share, the smoother the modulation. Closely related keys (e.g. C major → G major) share many pivot chords. Distant keys (e.g. C major → F# major) share few or none. Args: target: The key to modulate to. Returns: A list of chord name strings common to both keys. Example:: >>> Key("C", "major").pivot_chords(Key("G", "major")) ['G major', 'A minor', 'B minor', 'C major', 'D major', 'E minor'] """ own = set(self.chords) other = set(target.chords) return sorted(own & other)
# Functional grouping of scale degrees (0-indexed). The same degree # positions carry tonic / subdominant / dominant function in both # major and minor keys — function follows scale degree, not quality. _FUNCTION_DEGREES = { "tonic": (0, 2, 5), # I/i, iii/III, vi/VI "subdominant": (1, 3), # ii/ii°, IV/iv "dominant": (4, 6), # V/v, vii°/VII }
[docs] def chords_by_function(self) -> dict[str, list[Chord]]: """Group the diatonic triads by harmonic function. Functional harmony sorts the seven diatonic chords into three families by how they behave: **tonic** chords feel like home (I, iii, vi), **subdominant** chords move away from it (ii, IV), and **dominant** chords pull back toward it (V, vii°). Chords in the same family are largely interchangeable — swapping vi for I, or ii for IV, keeps a progression's function intact while changing its color. The grouping is by scale degree, so it holds for minor keys too (i/III/VI are tonic, ii°/iv subdominant, V/VII dominant). Returns: A dict with ``"tonic"``, ``"subdominant"`` and ``"dominant"`` keys, each mapping to a list of :class:`Chord` objects. Example:: >>> fams = Key("C", "major").chords_by_function() >>> [c.symbol for c in fams["tonic"]] ['C', 'Em', 'Am'] >>> [c.symbol for c in fams["dominant"]] ['G', 'Bdim'] """ harmonized = self._scale.harmonize() n = len(harmonized) result: dict[str, list[Chord]] = {} for function, degrees in self._FUNCTION_DEGREES.items(): result[function] = [harmonized[d] for d in degrees if d < n] return result
[docs] def tonic_chords(self) -> list[Chord]: """Diatonic chords with tonic function (I, iii, vi).""" return self.chords_by_function()["tonic"]
[docs] def subdominant_chords(self) -> list[Chord]: """Diatonic chords with subdominant function (ii, IV).""" return self.chords_by_function()["subdominant"]
[docs] def dominant_chords(self) -> list[Chord]: """Diatonic chords with dominant function (V, vii°).""" return self.chords_by_function()["dominant"]
[docs] def circle_of_fifths(self) -> dict: """Map this key's neighborhood on the circle of fifths. The circle of fifths arranges keys so that immediate neighbors differ by a single accidental and share most of their diatonic chords — which is exactly what makes them feel close and easy to modulate between. Returns relational data centered on this key: - ``key`` — this Key - ``position`` — signed place on the circle: number of sharps, or negative for flats (C major = 0, G major = 1, F major = -1) - ``relative`` — the relative minor/major (shares all notes) - ``parallel`` — the parallel minor/major (shares the tonic) - ``dominant`` — neighbor a fifth up (sharp side), with the diatonic chords shared with it - ``subdominant`` — neighbor a fifth down (flat side), with the diatonic chords shared with it - ``circle`` — the twelve keys of this mode, clockwise from here Example:: >>> cof = Key("C", "major").circle_of_fifths() >>> str(cof["dominant"]["key"]), str(cof["subdominant"]["key"]) ('G major', 'F major') >>> str(cof["relative"]), str(cof["parallel"]) ('A minor', 'C minor') >>> len(cof["dominant"]["shared_chords"]) 4 """ # The key's own spelling gives conventional neighbor names: # the 5th scale degree is the dominant, the 4th the subdominant. dominant = Key(self._scale.tones[4].name, self.mode, self._system) subdominant = Key(self._scale.tones[3].name, self.mode, self._system) sig = self.signature position = sig["sharps"] - sig["flats"] circle = self._circle_keys() return { "key": self, "position": position, "relative": self.relative, "parallel": self.parallel, "dominant": {"key": dominant, "shared_chords": self.pivot_chords(dominant)}, "subdominant": {"key": subdominant, "shared_chords": self.pivot_chords(subdominant)}, "circle": circle, }
# Conventional key-name spelling for each pitch class around the # circle, so the twelve-key tour avoids double accidentals. _CIRCLE_NAMES = { "major": {0: "C", 7: "G", 2: "D", 9: "A", 4: "E", 11: "B", 6: "Gb", 1: "Db", 8: "Ab", 3: "Eb", 10: "Bb", 5: "F"}, "minor": {9: "A", 4: "E", 11: "B", 6: "F#", 1: "C#", 8: "G#", 3: "Eb", 10: "Bb", 5: "F", 0: "C", 7: "G", 2: "D"}, } def _circle_keys(self) -> list[Key]: """The twelve keys of this mode, clockwise (by fifths) from here.""" tonic_pc = Tone.from_string( f"{self.tonic_name}4", system=self._system).midi % 12 names = self._CIRCLE_NAMES.get( "minor" if self.mode in ("minor", "aeolian") else "major" if self.mode == "major" else None) keys = [] for i in range(12): pc = (tonic_pc + 7 * i) % 12 if names is not None: keys.append(Key(names[pc], self.mode, self._system)) else: # Unusual mode: step by fifths, accept default spelling. name = Tone.from_string( f"{self.tonic_name}4", system=self._system).add(7 * i).name keys.append(Key(name, self.mode, self._system)) return keys
[docs] def negative_harmony(self) -> dict: """Negative-harmony map of this key (Ernst Levy reflection). Negative harmony mirrors every pitch across the axis that runs between the tonic and the dominant. The reflection turns a major key's harmony into its "shadow" — major becomes minor, the dominant becomes a minor subdominant — while preserving the gravitational pull toward the tonic. Returns: A dict with: - ``axis`` — the tonic↔dominant pair the mirror runs between - ``axis_notes`` — the two pitches the mirror actually bisects (the "hinge": where major and minor third meet) - ``negative_dominant`` — the reflection of V, the chord that does the dominant's job in the negative world and so **bridges** the two harmonic families (e.g. C major → Fm) - ``scale`` — the negative scale's note names, from the tonic - ``chords`` — each diatonic triad's negative reflection Example:: >>> neg = Key("C", "major").negative_harmony() >>> neg["axis"] ('C', 'G') >>> neg["negative_dominant"].symbol 'Fm' >>> neg["scale"] ['C', 'D', 'Eb', 'F', 'G', 'Ab', 'Bb'] """ tonic_pc = Tone.from_string( f"{self.tonic_name}4", system=self._system).midi % 12 axis_sum = (2 * tonic_pc + 7) % 12 # Negative harmony darkens toward minor, so flat spellings read # more naturally than sharps (Eb, not D#). _FLATS = ["C", "Db", "D", "Eb", "E", "F", "Gb", "G", "Ab", "A", "Bb", "B"] def pc_name(pc: int) -> str: return _FLATS[pc % 12] hinge_a, hinge_b = axis_sum // 2, axis_sum - axis_sum // 2 harmonized = self._scale.harmonize() neg_chords = [c.negative_harmony(self) for c in harmonized] # Negative scale, spelled from the tonic upward. scale_pcs = sorted( {(axis_sum - (t.midi % 12)) % 12 for t in self._scale.tones}, key=lambda pc: (pc - tonic_pc) % 12) scale = [pc_name(pc) for pc in scale_pcs] return { "axis": (self.tonic_name, self._scale.tones[4].name), "axis_notes": (pc_name(hinge_a), pc_name(hinge_b)), "negative_dominant": self.triad(4).negative_harmony(self), "scale": scale, "chords": neg_chords, }
[docs] class TonedScale:
[docs] def __init__(self, *, system: Union[str, System] = SYSTEMS["western"], tonic: Union[str, Tone]) -> None: """Initialize a TonedScale with a tonic note and tone system. Args: system: A tone system name or :class:`System` instance. tonic: The tonic note as a string (e.g. ``"C4"``) or :class:`Tone`. """ if isinstance(system, str): system = SYSTEMS[system] self.system = system if not isinstance(tonic, Tone): tonic = Tone.from_string(tonic, system=self.system) self.tonic = tonic self._cached_scales: Optional[dict[str, Scale]] = None
[docs] def __repr__(self) -> str: return f"<TonedScale system={self.system!r} tonic={self.tonic}>"
[docs] def __getitem__(self, scale: str) -> Scale: """Retrieve a scale by name. Raises: KeyError: If the named scale is not found in this system. """ result = self.get(scale) if result is None: available = ", ".join(self.scales) raise KeyError( f"No scale named {scale!r} for this tone system. " f"Available: {available}" ) return result
[docs] def get(self, scale: str) -> Optional[Scale]: """Look up a scale by name, returning ``None`` if not found.""" try: return self._scales[scale] except KeyError: return None
@property def scales(self) -> tuple[str, ...]: """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)) _LETTERS = "CDEFGAB" _LETTER_PC = {"C": 0, "D": 2, "E": 4, "F": 5, "G": 7, "A": 9, "B": 11} @classmethod def _name_pc(cls, name: str) -> int: """Pitch class (C=0) of a spelled note name like ``"Cb"`` or ``"F#"``.""" pc = cls._LETTER_PC[name[0]] for ch in name[1:]: if ch == "#": pc += 1 elif ch == "b": pc -= 1 return pc % 12 @classmethod def _spell_heptatonic(cls, tonic, reference_scale, system): """Spell a 7-note scale so each letter A–G appears exactly once. A single sharp-or-flat preference can't spell every key — Gb major needs Cb (not B), F# major needs E# (not F). The diatonic rule is instead one letter per degree, with whatever accidental the pitch requires. Returns a list of Tones (tonic first), or ``None`` if the scale isn't a clean one-letter-per-degree heptatonic — the caller then falls back to the sharp/flat heuristic. """ # Letter-per-degree spelling is specific to Western C–B note names; # other systems (Arabic solfège, 53-tone makam, 22-shruti) keep the # heuristic fallback. if system is not SYSTEMS.get("western"): return None if len(reference_scale) != 7 or not tonic.name or tonic.name[0] not in cls._LETTERS: return None start = cls._LETTERS.index(tonic.name[0]) tonic_pc = cls._name_pc(tonic.name) base_oct = tonic.octave if tonic.octave is not None else 0 # The constructor stores Cb/B# at the boundary-adjusted octave (Cb4 # is kept as octave 3). Recover the *notated* octave so the degrees # above it are placed in the right register. if tonic.octave is not None and len(tonic.name) > 1: if tonic.name[0] == "C" and "b" in tonic.name[1:]: base_oct += 1 elif tonic.name[0] == "B" and "#" in tonic.name[1:]: base_oct -= 1 cum, semis = 0, [0] for iv in reference_scale: cum += iv semis.append(cum) tones = [tonic] for d in range(1, len(semis)): target_pc = (tonic_pc + semis[d]) % 12 letter = cls._LETTERS[(start + d) % 7] acc = ((target_pc - cls._LETTER_PC[letter] + 6) % 12) - 6 # -6..5 if acc < -2 or acc > 2: return None # would need a triple accidental — bail out acc_str = "#" * acc if acc > 0 else "b" * (-acc) octave = base_oct + (start + d) // 7 tones.append(Tone(name=letter + acc_str, octave=octave, system=system)) return tones @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]: reference_scale = self.system.scales[scale_type][scale]["intervals"] # Diatonic scales: spell one letter per degree, which is the # only thing that gets Gb major (…Cb…) and F# major (…E#…) # right. Falls back to the heuristic for non-heptatonic or # exotic scales. spelled = self._spell_heptatonic(self.tonic, reference_scale, self.system) if spelled is not None: scales[scale] = Scale(tones=tuple(spelled)) continue # First pass: build with sharps (default) working_scale = [self.tonic] current_tone = self.tonic 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 return scales