from __future__ import annotations
from typing import Optional, Union
import numeral
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):
degree = numeral.int2roman(i + 1, only_ascii=True)
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 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)>]
"""
import numeral as numeral_mod
chords = []
for num in numerals:
is_seventh = num.endswith("7")
clean = num.rstrip("7")
degree = numeral_mod.roman2int(clean.upper()) - 1
if is_seventh:
chords.append(self.seventh(degree))
else:
chords.append(self.triad(degree))
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]
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 degree(self, item: Union[str, int, slice], major: Optional[bool] = None, minor: bool = False) -> Optional[Union[Tone, tuple[Tone, ...]]]:
# TODO: cleanup degrees.
# 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):
degrees.append(numeral.int2roman(i + 1, only_ascii=True))
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]
@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
@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]
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:
pass
@property
def scales(self) -> tuple[str, ...]:
"""Tuple of all available scale names in this system."""
return tuple(self._scales.keys())
@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
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"]
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)
scales[scale] = Scale(tones=tuple(working_scale))
self._cached_scales = scales
return scales