from __future__ import annotations
from typing import Optional, Union
from .systems import SYSTEMS, System
from .tones import Tone
[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:
tone = tone.add(12 * octaves_up)
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.
Accepts Roman numerals like ``"I"``, ``"IV"``, ``"V"``,
``"ii"``, ``"vi"``. Lowercase = minor triad, uppercase = major
triad. Add ``"7"`` suffix for seventh chords.
Example::
>>> scale.progression("I", "IV", "V", "I")
[<Chord (C,E,G)>, <Chord (F,A,C)>, <Chord (G,B,D)>, <Chord (C,E,G)>]
"""
from ._statics import roman2int
chords = []
for num in numerals:
is_seventh = num.endswith("7")
clean = num.rstrip("7")
# Handle flat-degree prefixes: bVI, bVII, bIII, etc.
flat_offset = 0
if clean.startswith("b") and len(clean) > 1:
clean = clean[1:]
flat_offset = -1 # one semitone down
elif clean.startswith("#") and len(clean) > 1:
clean = clean[1:]
flat_offset = 1 # one semitone up
degree = roman2int(clean.upper()) - 1
if is_seventh:
chord = self.seventh(degree)
else:
chord = self.triad(degree)
if flat_offset != 0:
chord = chord.transpose(flat_offset)
chords.append(chord)
return chords
[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)
is_seventh = s.endswith("7")
clean = s.rstrip("7m")
degree = int(clean) - 1
if 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
notes = set(note_names)
best = None
chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
scale_names = ["major", "minor", "harmonic minor",
"dorian", "phrygian", "lydian", "mixolydian",
"aeolian", "locrian"]
for tonic in chromatic:
ts = TonedScale(tonic=f"{tonic}4")
for scale_name in ts.scales:
try:
scale = ts[scale_name]
scale_notes = set(scale.note_names)
match = len(notes & scale_notes)
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, ...]]]:
# 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(item)
return result
PROGRESSIONS = {
# Rock / Pop / Folk
"I-IV-V-I": ("I", "IV", "V", "I"),
"I-V-vi-IV": ("I", "V", "vi", "IV"),
"I-vi-IV-V": ("I", "vi", "IV", "V"),
"I-IV-vi-V": ("I", "IV", "vi", "V"),
"vi-IV-I-V": ("vi", "IV", "I", "V"),
# Blues
"12-bar blues": ("I", "I", "I", "I", "IV", "IV", "I", "I", "V", "IV", "I", "V"),
# Jazz
"ii-V-I": ("ii", "V7", "I"),
"I-vi-ii-V": ("I", "vi", "ii", "V"), # rhythm changes A section
"iii-vi-ii-V": ("iii", "vi", "ii", "V"), # jazz turnaround
# Classical / Film
"i-bVI-bIII-bVII": ("i", "VI", "III", "VII"),
"Pachelbel": ("I", "V", "vi", "iii", "IV", "I", "IV", "V"),
# Flamenco / Spanish
"Andalusian": ("i", "VII", "VI", "V"),
# Modal
"Dorian vamp": ("i", "IV"),
"Mixolydian vamp": ("I", "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.
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 C major>
Returns:
The best-matching Key, or None if no notes given.
"""
if not note_names:
return None
notes = set(note_names)
best_key = None
best_score = (-1, 0)
chromatic = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
for tonic in chromatic:
for mode in ("major", "minor"):
try:
k = cls(tonic, mode)
scale_notes = set(k.note_names)
match = len(notes & scale_notes)
# Tiebreak: prefer major over minor
score = (match, 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."
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>
"""
target = self._scale.tones[degree - 1]
# 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)
[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:
raise KeyError(scale)
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))
@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"]
# 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