"""Interval and scale definitions, plus the helpers that resolve them.
Holds ``INTERVAL_DEFINITIONS`` (named scales and chords as semitone lists) and
the functions that work against it — ``scale_notes``, ``scale_pitch_classes``,
``quantize_pitch``, ``register_scale`` and friends.
"""
import logging
import typing
import subsequence.chords
logger = logging.getLogger(__name__)
INTERVAL_DEFINITIONS: typing.Dict[str, typing.List[int]] = {
"augmented": [0, 3, 4, 7, 8, 11],
"augmented_7th": [0, 4, 8, 10],
"augmented_triad": [0, 4, 8],
"blues_scale": [0, 3, 5, 6, 7, 10],
"chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
"diminished_7th": [0, 3, 6, 9],
"diminished_triad": [0, 3, 6],
"dominant_7th": [0, 4, 7, 10],
"dominant_9th": [0, 4, 7, 10, 14],
"dorian_mode": [0, 2, 3, 5, 7, 9, 10],
"double_harmonic": [0, 1, 4, 5, 7, 8, 11],
"enigmatic": [0, 1, 4, 6, 8, 10, 11],
"half_diminished_7th": [0, 3, 6, 10],
"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
"hungarian_minor": [0, 2, 3, 6, 7, 8, 11],
"locrian_mode": [0, 1, 3, 5, 6, 8, 10],
"lydian": [0, 2, 4, 6, 7, 9, 11],
"lydian_dominant": [0, 2, 4, 6, 7, 9, 10],
"major_6th": [0, 4, 7, 9],
"major_7th": [0, 4, 7, 11],
"major_9th": [0, 4, 7, 11, 14],
"major_ionian": [0, 2, 4, 5, 7, 9, 11],
"major_pentatonic": [0, 2, 4, 7, 9],
"major_triad": [0, 4, 7],
"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
"minor_6th": [0, 3, 7, 9],
"minor_7th": [0, 3, 7, 10],
"minor_9th": [0, 3, 7, 10, 14],
"minor_blues": [0, 3, 5, 6, 7, 10],
"minor_major_7th": [0, 3, 7, 11],
"minor_pentatonic": [0, 3, 5, 7, 10],
"minor_triad": [0, 3, 7],
"mixolydian": [0, 2, 4, 5, 7, 9, 10],
"natural_minor": [0, 2, 3, 5, 7, 8, 10],
"neapolitan_major": [0, 1, 3, 5, 7, 9, 11],
"phrygian_dominant": [0, 1, 4, 5, 7, 8, 10],
"phrygian_mode": [0, 1, 3, 5, 7, 8, 10],
"power_chord": [0, 7],
"superlocrian": [0, 1, 3, 4, 6, 8, 10],
"sus2": [0, 2, 7],
"sus4": [0, 5, 7],
"whole_tone": [0, 2, 4, 6, 8, 10],
# -- Non-western / pentatonic scales --
"hirajoshi": [0, 2, 3, 7, 8],
"in_sen": [0, 1, 5, 7, 10],
"iwato": [0, 1, 5, 6, 10],
"yo": [0, 2, 5, 7, 9],
"egyptian": [0, 2, 5, 7, 10],
"root": [0],
"fifth": [0, 7],
"minor_3rd": [0, 3],
"tritone": [0, 6],
}
# ---------------------------------------------------------------------------
# Diatonic chord quality constants.
#
# Each list contains 7 chord quality strings, one per scale degree (I–VII).
# These can be paired with the corresponding scale intervals from
# INTERVAL_DEFINITIONS to build diatonic Chord objects for any key.
# ---------------------------------------------------------------------------
# -- Church modes (rotations of the major scale) --
IONIAN_QUALITIES: typing.List[str] = [
"major", "minor", "minor", "major", "major", "minor", "diminished"
]
DORIAN_QUALITIES: typing.List[str] = [
"minor", "minor", "major", "major", "minor", "diminished", "major"
]
PHRYGIAN_QUALITIES: typing.List[str] = [
"minor", "major", "major", "minor", "diminished", "major", "minor"
]
LYDIAN_QUALITIES: typing.List[str] = [
"major", "major", "minor", "diminished", "major", "minor", "minor"
]
MIXOLYDIAN_QUALITIES: typing.List[str] = [
"major", "minor", "diminished", "major", "minor", "minor", "major"
]
AEOLIAN_QUALITIES: typing.List[str] = [
"minor", "diminished", "major", "minor", "minor", "major", "major"
]
LOCRIAN_QUALITIES: typing.List[str] = [
"diminished", "major", "minor", "minor", "major", "major", "minor"
]
# -- Non-modal scales --
HARMONIC_MINOR_QUALITIES: typing.List[str] = [
"minor", "diminished", "augmented", "minor", "major", "major", "diminished"
]
MELODIC_MINOR_QUALITIES: typing.List[str] = [
"minor", "minor", "augmented", "major", "major", "diminished", "diminished"
]
# Map mode/scale names to (interval_key, qualities) for use by helpers.
# qualities is None for scales without predefined chord mappings — these
# can still be used with scale_pitch_classes() and p.snap_to_scale(), but not
# with diatonic_chords() or composition.harmony().
SCALE_MODE_MAP: typing.Dict[str, typing.Tuple[str, typing.Optional[typing.List[str]]]] = {
# -- Western diatonic modes (7-note, with chord qualities) --
"ionian": ("major_ionian", IONIAN_QUALITIES),
"major": ("major_ionian", IONIAN_QUALITIES),
"dorian": ("dorian_mode", DORIAN_QUALITIES),
"phrygian": ("phrygian_mode", PHRYGIAN_QUALITIES),
"lydian": ("lydian", LYDIAN_QUALITIES),
"mixolydian": ("mixolydian", MIXOLYDIAN_QUALITIES),
"aeolian": ("natural_minor", AEOLIAN_QUALITIES),
"minor": ("natural_minor", AEOLIAN_QUALITIES),
"locrian": ("locrian_mode", LOCRIAN_QUALITIES),
"harmonic_minor": ("harmonic_minor", HARMONIC_MINOR_QUALITIES),
"melodic_minor": ("melodic_minor", MELODIC_MINOR_QUALITIES),
# -- Non-western and pentatonic scales (no chord qualities) --
"hirajoshi": ("hirajoshi", None),
"in_sen": ("in_sen", None),
"iwato": ("iwato", None),
"yo": ("yo", None),
"egyptian": ("egyptian", None),
"major_pentatonic": ("major_pentatonic", None),
"minor_pentatonic": ("minor_pentatonic", None),
}
# Backwards-compatible alias.
DIATONIC_MODE_MAP = SCALE_MODE_MAP
# Snapshot of every built-in scale name, taken at import time. register_scale()
# refuses to overwrite these so a custom scale can never silently change what
# "minor" or "hirajoshi" means mid-composition.
_BUILTIN_SCALE_NAMES: typing.FrozenSet[str] = frozenset(INTERVAL_DEFINITIONS) | frozenset(SCALE_MODE_MAP)
[docs]
def scale_pitch_classes (key_pc: int, mode: str = "ionian") -> typing.List[int]:
"""
Return the pitch classes (0–11) that belong to a key and mode.
Parameters:
key_pc: Root pitch class (0 = C, 1 = C#/Db, …, 11 = B).
mode: Scale mode name. Supports all keys of ``DIATONIC_MODE_MAP``
(e.g. ``"ionian"``, ``"dorian"``, ``"minor"``, ``"harmonic_minor"``).
Returns:
Pitch classes in scale-degree order, starting from the root
(length varies by mode). Values wrap mod-12, so the list is
not numerically sorted for non-C roots.
Example:
```python
# C major pitch classes
scale_pitch_classes(0, "ionian") # → [0, 2, 4, 5, 7, 9, 11]
# A minor pitch classes
scale_pitch_classes(9, "aeolian") # → [9, 11, 0, 2, 4, 5, 7] (mod-12)
```
"""
if mode not in SCALE_MODE_MAP:
raise ValueError(
f"Unknown mode '{mode}'. Available: {sorted(SCALE_MODE_MAP)}. "
"Use register_scale() to add custom scales."
)
scale_key, _ = SCALE_MODE_MAP[mode]
intervals = get_intervals(scale_key)
return [(key_pc + i) % 12 for i in intervals]
[docs]
def scale_notes (
key: str,
mode: str = "ionian",
low: int = 60,
high: int = 72,
count: typing.Optional[int] = None,
) -> typing.List[int]:
"""Return MIDI note numbers for a scale within a pitch range.
Parameters:
key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.).
This acts as a **pitch-class filter only** — it determines which
semitone positions (0–11) are valid members of the scale, but does
not affect which octave notes are drawn from. Notes are selected
starting from ``low`` upward; ``key`` controls *which* notes are
kept, not where the sequence starts. To guarantee the first
returned note is the root, ``low`` must be a MIDI number whose
pitch class matches ``key``. When starting from an arbitrary MIDI
number, derive the key name with
``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``.
mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP`
(e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``,
``"major_pentatonic"``). Use :func:`register_scale` for custom scales.
low: Lowest MIDI note (inclusive). When ``count`` is set, this is
the starting note from which the scale ascends. **If ``low`` is
not a member of the scale defined by ``key``, it is silently
skipped** and the first returned note will be the next in-scale
pitch above ``low``.
high: Highest MIDI note (inclusive). Ignored when ``count`` is set.
count: Exact number of notes to return. Notes ascend from ``low``
through successive scale degrees, cycling into higher octaves
as needed. When ``None`` (default), all scale tones between
``low`` and ``high`` are returned.
Returns:
Sorted list of MIDI note numbers.
Examples:
```python
import subsequence
import subsequence.constants.midi_notes as notes
# C major: all tones from middle C to C5
subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
# → [60, 62, 64, 65, 67, 69, 71, 72]
# E natural minor (aeolian) across one octave
subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3)
# → [40, 42, 43, 45, 47, 48, 50, 52]
# 15 notes of A minor pentatonic ascending from A3
subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15)
# → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91]
# Misalignment: key="E" but low=C4 — first note is C, not E
subsequence.scale_notes("E", "minor", low=60, count=4)
# → [60, 62, 64, 66] (C D E F# — all in E natural minor, but starts on C)
# Fix: derive key name from root_pitch so low is always in the scale
root_pitch = 64 # E4
key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12] # → "E"
subsequence.scale_notes(key, "minor", low=root_pitch, count=4)
# → [64, 66, 67, 69] (E F# G A — starts on the root)
```
"""
key_pc = subsequence.chords.key_name_to_pc(key)
pcs = set(scale_pitch_classes(key_pc, mode))
if count is not None:
if not pcs:
return []
result: typing.List[int] = []
pitch = low
while len(result) < count and pitch <= 127:
if pitch % 12 in pcs:
result.append(pitch)
pitch += 1
return result
return [p for p in range(low, high + 1) if p % 12 in pcs]
[docs]
def quantize_pitch (pitch: int, scale_pcs: typing.Sequence[int]) -> int:
"""
Snap a MIDI pitch to the nearest note in the given scale.
Searches outward in semitone steps from the input pitch. When two
notes are equidistant (e.g. C# between C and D in C major), the
upward direction is preferred.
Parameters:
pitch: MIDI note number to quantize.
scale_pcs: Pitch classes accepted by the scale (0–11). Typically
the output of :func:`scale_pitch_classes`.
Returns:
A MIDI note number that lies within the scale.
Example:
```python
# Snap C# (61) to C (60) in C major
scale = scale_pitch_classes(0, "ionian") # [0, 2, 4, 5, 7, 9, 11]
quantize_pitch(61, scale) # → 60
```
"""
pc = pitch % 12
if pc in scale_pcs:
return pitch
for offset in range(1, 7):
if (pc + offset) % 12 in scale_pcs:
return pitch + offset
if (pc - offset) % 12 in scale_pcs:
return pitch - offset
# The search radius of ±6 semitones covers every gap in every scale with
# no gap wider than one tritone. A wider gap (unusual custom scale) falls
# through here and keeps the original off-scale pitch — warn so the caller
# knows the result is not actually snapped to the scale.
logger.warning(
"quantize_pitch: no scale note within ±6 semitones of MIDI %d (pc=%d); "
"returning pitch unquantized. scale_pcs=%s",
pitch, pc, sorted(scale_pcs),
)
return pitch
[docs]
def get_intervals (name: str) -> typing.List[int]:
"""
Return a named interval list from the registry.
"""
if name not in INTERVAL_DEFINITIONS:
raise ValueError(f"Unknown interval set: {name}")
return list(INTERVAL_DEFINITIONS[name])
[docs]
def register_scale (
name: str,
intervals: typing.List[int],
qualities: typing.Optional[typing.List[str]] = None
) -> None:
"""
Register a custom scale for use with ``p.snap_to_scale()`` and
``scale_pitch_classes()``.
Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be
overwritten. Custom names may be re-registered freely — live reload
re-runs registration on every save, so this must not raise.
Parameters:
name: Scale name (used in ``p.snap_to_scale(key, name)``). Must not
be the name of a built-in scale.
intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]``
for Hirajōshi). Must be whole numbers, start with 0, ascend
strictly, and stay within 0–11.
qualities: Optional chord quality per scale degree (e.g.
``["minor", "major", "minor", "major", "diminished"]``).
Required only if you want to use the scale with
``diatonic_chords()`` or ``diatonic_chord_sequence()``.
Raises:
ValueError: If *name* is a built-in scale, or *intervals* /
*qualities* fail the rules above.
Example::
import subsequence
subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])
@comp.pattern(channel=0, length=4)
def melody (p):
p.note(60, beat=0)
p.snap_to_scale("C", "raga_bhairav")
"""
if name in _BUILTIN_SCALE_NAMES:
raise ValueError(
f"Cannot overwrite built-in scale '{name}'. "
"Choose a different name for your custom scale."
)
if not intervals:
raise ValueError("intervals must not be empty")
if not all(isinstance(i, int) for i in intervals):
raise ValueError("intervals must be whole numbers (semitone offsets)")
if intervals[0] != 0:
raise ValueError("intervals must start with 0")
if any(b <= a for a, b in zip(intervals, intervals[1:])):
raise ValueError("intervals must be strictly ascending")
if any(i < 0 or i > 11 for i in intervals):
raise ValueError("intervals must contain values between 0 and 11")
if qualities is not None and len(qualities) != len(intervals):
raise ValueError(
f"qualities length ({len(qualities)}) must match "
f"intervals length ({len(intervals)})"
)
INTERVAL_DEFINITIONS[name] = intervals
SCALE_MODE_MAP[name] = (name, qualities)
[docs]
def get_diatonic_intervals (
scale_notes: typing.List[int],
intervals: typing.Optional[typing.List[int]] = None,
mode: str = "scale"
) -> typing.List[typing.List[int]]:
"""
Construct diatonic chords from a scale.
"""
if intervals is None:
intervals = [0, 2, 4]
if mode not in ("scale", "chromatic"):
raise ValueError("mode must be 'scale' or 'chromatic'")
diatonic_intervals: typing.List[typing.List[int]] = []
num_scale_notes = len(scale_notes)
for i in range(num_scale_notes):
if mode == "scale":
chord = [scale_notes[(i + offset) % num_scale_notes] for offset in intervals]
else:
root = scale_notes[i]
chord = [(root + offset) % 12 for offset in intervals]
diatonic_intervals.append(chord)
return diatonic_intervals