subsequence.intervals

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.

  1"""Interval and scale definitions, plus the helpers that resolve them.
  2
  3Holds ``INTERVAL_DEFINITIONS`` (named scales and chords as semitone lists) and
  4the functions that work against it — ``scale_notes``, ``scale_pitch_classes``,
  5``quantize_pitch``, ``register_scale`` and friends.
  6"""
  7
  8import logging
  9import typing
 10
 11import subsequence.chords
 12
 13
 14logger = logging.getLogger(__name__)
 15
 16
 17INTERVAL_DEFINITIONS: typing.Dict[str, typing.List[int]] = {
 18	"augmented": [0, 3, 4, 7, 8, 11],
 19	"augmented_7th": [0, 4, 8, 10],
 20	"augmented_triad": [0, 4, 8],
 21	"blues_scale": [0, 3, 5, 6, 7, 10],
 22	"chromatic": [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
 23	"diminished_7th": [0, 3, 6, 9],
 24	"diminished_triad": [0, 3, 6],
 25	"dominant_7th": [0, 4, 7, 10],
 26	"dominant_9th": [0, 4, 7, 10, 14],
 27	"dorian_mode": [0, 2, 3, 5, 7, 9, 10],
 28	"double_harmonic": [0, 1, 4, 5, 7, 8, 11],
 29	"enigmatic": [0, 1, 4, 6, 8, 10, 11],
 30	"half_diminished_7th": [0, 3, 6, 10],
 31	"harmonic_minor": [0, 2, 3, 5, 7, 8, 11],
 32	"hungarian_minor": [0, 2, 3, 6, 7, 8, 11],
 33	"locrian_mode": [0, 1, 3, 5, 6, 8, 10],
 34	"lydian": [0, 2, 4, 6, 7, 9, 11],
 35	"lydian_dominant": [0, 2, 4, 6, 7, 9, 10],
 36	"major_6th": [0, 4, 7, 9],
 37	"major_7th": [0, 4, 7, 11],
 38	"major_9th": [0, 4, 7, 11, 14],
 39	"major_ionian": [0, 2, 4, 5, 7, 9, 11],
 40	"major_pentatonic": [0, 2, 4, 7, 9],
 41	"major_triad": [0, 4, 7],
 42	"melodic_minor": [0, 2, 3, 5, 7, 9, 11],
 43	"minor_6th": [0, 3, 7, 9],
 44	"minor_7th": [0, 3, 7, 10],
 45	"minor_9th": [0, 3, 7, 10, 14],
 46	"minor_blues": [0, 3, 5, 6, 7, 10],
 47	"minor_major_7th": [0, 3, 7, 11],
 48	"minor_pentatonic": [0, 3, 5, 7, 10],
 49	"minor_triad": [0, 3, 7],
 50	"mixolydian": [0, 2, 4, 5, 7, 9, 10],
 51	"natural_minor": [0, 2, 3, 5, 7, 8, 10],
 52	"neapolitan_major": [0, 1, 3, 5, 7, 9, 11],
 53	"phrygian_dominant": [0, 1, 4, 5, 7, 8, 10],
 54	"phrygian_mode": [0, 1, 3, 5, 7, 8, 10],
 55	"power_chord": [0, 7],
 56	"superlocrian": [0, 1, 3, 4, 6, 8, 10],
 57	"sus2": [0, 2, 7],
 58	"sus4": [0, 5, 7],
 59	"whole_tone": [0, 2, 4, 6, 8, 10],
 60	# -- Non-western / pentatonic scales --
 61	"hirajoshi": [0, 2, 3, 7, 8],
 62	"in_sen": [0, 1, 5, 7, 10],
 63	"iwato": [0, 1, 5, 6, 10],
 64	"yo": [0, 2, 5, 7, 9],
 65	"egyptian": [0, 2, 5, 7, 10],
 66	"root": [0],
 67	"fifth": [0, 7],
 68	"minor_3rd": [0, 3],
 69	"tritone": [0, 6],
 70}
 71
 72
 73# ---------------------------------------------------------------------------
 74# Diatonic chord quality constants.
 75#
 76# Each list contains 7 chord quality strings, one per scale degree (I–VII).
 77# These can be paired with the corresponding scale intervals from
 78# INTERVAL_DEFINITIONS to build diatonic Chord objects for any key.
 79# ---------------------------------------------------------------------------
 80
 81# -- Church modes (rotations of the major scale) --
 82
 83IONIAN_QUALITIES: typing.List[str] = [
 84	"major", "minor", "minor", "major", "major", "minor", "diminished"
 85]
 86
 87DORIAN_QUALITIES: typing.List[str] = [
 88	"minor", "minor", "major", "major", "minor", "diminished", "major"
 89]
 90
 91PHRYGIAN_QUALITIES: typing.List[str] = [
 92	"minor", "major", "major", "minor", "diminished", "major", "minor"
 93]
 94
 95LYDIAN_QUALITIES: typing.List[str] = [
 96	"major", "major", "minor", "diminished", "major", "minor", "minor"
 97]
 98
 99MIXOLYDIAN_QUALITIES: typing.List[str] = [
100	"major", "minor", "diminished", "major", "minor", "minor", "major"
101]
102
103AEOLIAN_QUALITIES: typing.List[str] = [
104	"minor", "diminished", "major", "minor", "minor", "major", "major"
105]
106
107LOCRIAN_QUALITIES: typing.List[str] = [
108	"diminished", "major", "minor", "minor", "major", "major", "minor"
109]
110
111# -- Non-modal scales --
112
113HARMONIC_MINOR_QUALITIES: typing.List[str] = [
114	"minor", "diminished", "augmented", "minor", "major", "major", "diminished"
115]
116
117MELODIC_MINOR_QUALITIES: typing.List[str] = [
118	"minor", "minor", "augmented", "major", "major", "diminished", "diminished"
119]
120
121
122# Map mode/scale names to (interval_key, qualities) for use by helpers.
123# qualities is None for scales without predefined chord mappings — these
124# can still be used with scale_pitch_classes() and p.snap_to_scale(), but not
125# with diatonic_chords() or composition.harmony().
126SCALE_MODE_MAP: typing.Dict[str, typing.Tuple[str, typing.Optional[typing.List[str]]]] = {
127	# -- Western diatonic modes (7-note, with chord qualities) --
128	"ionian":         ("major_ionian",     IONIAN_QUALITIES),
129	"major":          ("major_ionian",     IONIAN_QUALITIES),
130	"dorian":         ("dorian_mode",      DORIAN_QUALITIES),
131	"phrygian":       ("phrygian_mode",    PHRYGIAN_QUALITIES),
132	"lydian":         ("lydian",           LYDIAN_QUALITIES),
133	"mixolydian":     ("mixolydian",       MIXOLYDIAN_QUALITIES),
134	"aeolian":        ("natural_minor",    AEOLIAN_QUALITIES),
135	"minor":          ("natural_minor",    AEOLIAN_QUALITIES),
136	"locrian":        ("locrian_mode",     LOCRIAN_QUALITIES),
137	"harmonic_minor": ("harmonic_minor",   HARMONIC_MINOR_QUALITIES),
138	"melodic_minor":  ("melodic_minor",    MELODIC_MINOR_QUALITIES),
139	# -- Non-western and pentatonic scales (no chord qualities) --
140	"hirajoshi":      ("hirajoshi",        None),
141	"in_sen":         ("in_sen",           None),
142	"iwato":          ("iwato",            None),
143	"yo":             ("yo",               None),
144	"egyptian":       ("egyptian",         None),
145	"major_pentatonic": ("major_pentatonic", None),
146	"minor_pentatonic": ("minor_pentatonic", None),
147}
148
149# Backwards-compatible alias.
150DIATONIC_MODE_MAP = SCALE_MODE_MAP
151
152
153# Snapshot of every built-in scale name, taken at import time.  register_scale()
154# refuses to overwrite these so a custom scale can never silently change what
155# "minor" or "hirajoshi" means mid-composition.
156_BUILTIN_SCALE_NAMES: typing.FrozenSet[str] = frozenset(INTERVAL_DEFINITIONS) | frozenset(SCALE_MODE_MAP)
157
158
159def scale_pitch_classes (key_pc: int, mode: str = "ionian") -> typing.List[int]:
160
161	"""
162	Return the pitch classes (0–11) that belong to a key and mode.
163
164	Parameters:
165		key_pc: Root pitch class (0 = C, 1 = C#/Db, …, 11 = B).
166		mode: Scale mode name. Supports all keys of ``DIATONIC_MODE_MAP``
167		      (e.g. ``"ionian"``, ``"dorian"``, ``"minor"``, ``"harmonic_minor"``).
168
169	Returns:
170		Pitch classes in scale-degree order, starting from the root
171		(length varies by mode). Values wrap mod-12, so the list is
172		not numerically sorted for non-C roots.
173
174	Example:
175		```python
176		# C major pitch classes
177		scale_pitch_classes(0, "ionian")  # → [0, 2, 4, 5, 7, 9, 11]
178
179		# A minor pitch classes
180		scale_pitch_classes(9, "aeolian")  # → [9, 11, 0, 2, 4, 5, 7] (mod-12)
181		```
182	"""
183
184	if mode not in SCALE_MODE_MAP:
185		raise ValueError(
186			f"Unknown mode '{mode}'. Available: {sorted(SCALE_MODE_MAP)}. "
187			"Use register_scale() to add custom scales."
188		)
189
190	scale_key, _ = SCALE_MODE_MAP[mode]
191	intervals = get_intervals(scale_key)
192	return [(key_pc + i) % 12 for i in intervals]
193
194
195def scale_notes (
196	key: str,
197	mode: str = "ionian",
198	low: int = 60,
199	high: int = 72,
200	count: typing.Optional[int] = None,
201) -> typing.List[int]:
202
203	"""Return MIDI note numbers for a scale within a pitch range.
204
205	Parameters:
206		key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.).
207		     This acts as a **pitch-class filter only** — it determines which
208		     semitone positions (0–11) are valid members of the scale, but does
209		     not affect which octave notes are drawn from. Notes are selected
210		     starting from ``low`` upward; ``key`` controls *which* notes are
211		     kept, not where the sequence starts. To guarantee the first
212		     returned note is the root, ``low`` must be a MIDI number whose
213		     pitch class matches ``key``. When starting from an arbitrary MIDI
214		     number, derive the key name with
215		     ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``.
216		mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP`
217		      (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``,
218		      ``"major_pentatonic"``). Use :func:`register_scale` for custom scales.
219		low: Lowest MIDI note (inclusive). When ``count`` is set, this is
220		     the starting note from which the scale ascends. **If ``low`` is
221		     not a member of the scale defined by ``key``, it is silently
222		     skipped** and the first returned note will be the next in-scale
223		     pitch above ``low``.
224		high: Highest MIDI note (inclusive). Ignored when ``count`` is set.
225		count: Exact number of notes to return. Notes ascend from ``low``
226		       through successive scale degrees, cycling into higher octaves
227		       as needed. When ``None`` (default), all scale tones between
228		       ``low`` and ``high`` are returned.
229
230	Returns:
231		Sorted list of MIDI note numbers.
232
233	Examples:
234		```python
235		import subsequence
236		import subsequence.constants.midi_notes as notes
237
238		# C major: all tones from middle C to C5
239		subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
240		# → [60, 62, 64, 65, 67, 69, 71, 72]
241
242		# E natural minor (aeolian) across one octave
243		subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3)
244		# → [40, 42, 43, 45, 47, 48, 50, 52]
245
246		# 15 notes of A minor pentatonic ascending from A3
247		subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15)
248		# → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91]
249
250		# Misalignment: key="E" but low=C4 — first note is C, not E
251		subsequence.scale_notes("E", "minor", low=60, count=4)
252		# → [60, 62, 64, 66]  (C D E F# — all in E natural minor, but starts on C)
253
254		# Fix: derive key name from root_pitch so low is always in the scale
255		root_pitch = 64  # E4
256		key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]  # → "E"
257		subsequence.scale_notes(key, "minor", low=root_pitch, count=4)
258		# → [64, 66, 67, 69]  (E F# G A — starts on the root)
259		```
260	"""
261
262	key_pc = subsequence.chords.key_name_to_pc(key)
263	pcs = set(scale_pitch_classes(key_pc, mode))
264
265	if count is not None:
266		if not pcs:
267			return []
268		result: typing.List[int] = []
269		pitch = low
270		while len(result) < count and pitch <= 127:
271			if pitch % 12 in pcs:
272				result.append(pitch)
273			pitch += 1
274		return result
275
276	return [p for p in range(low, high + 1) if p % 12 in pcs]
277
278
279def quantize_pitch (pitch: int, scale_pcs: typing.Sequence[int]) -> int:
280
281	"""
282	Snap a MIDI pitch to the nearest note in the given scale.
283
284	Searches outward in semitone steps from the input pitch.  When two
285	notes are equidistant (e.g. C# between C and D in C major), the
286	upward direction is preferred.
287
288	Parameters:
289		pitch: MIDI note number to quantize.
290		scale_pcs: Pitch classes accepted by the scale (0–11). Typically
291		           the output of :func:`scale_pitch_classes`.
292
293	Returns:
294		A MIDI note number that lies within the scale.
295
296	Example:
297		```python
298		# Snap C# (61) to C (60) in C major
299		scale = scale_pitch_classes(0, "ionian")  # [0, 2, 4, 5, 7, 9, 11]
300		quantize_pitch(61, scale)  # → 60
301		```
302	"""
303
304	pc = pitch % 12
305
306	if pc in scale_pcs:
307		return pitch
308
309	for offset in range(1, 7):
310		if (pc + offset) % 12 in scale_pcs:
311			return pitch + offset
312		if (pc - offset) % 12 in scale_pcs:
313			return pitch - offset
314
315	# The search radius of ±6 semitones covers every gap in every scale with
316	# no gap wider than one tritone.  A wider gap (unusual custom scale) falls
317	# through here and keeps the original off-scale pitch — warn so the caller
318	# knows the result is not actually snapped to the scale.
319	logger.warning(
320		"quantize_pitch: no scale note within ±6 semitones of MIDI %d (pc=%d); "
321		"returning pitch unquantized. scale_pcs=%s",
322		pitch, pc, sorted(scale_pcs),
323	)
324	return pitch
325
326
327def get_intervals (name: str) -> typing.List[int]:
328
329	"""
330	Return a named interval list from the registry.
331	"""
332
333	if name not in INTERVAL_DEFINITIONS:
334		raise ValueError(f"Unknown interval set: {name}")
335
336	return list(INTERVAL_DEFINITIONS[name])
337
338
339def register_scale (
340	name: str,
341	intervals: typing.List[int],
342	qualities: typing.Optional[typing.List[str]] = None
343) -> None:
344
345	"""
346	Register a custom scale for use with ``p.snap_to_scale()`` and
347	``scale_pitch_classes()``.
348
349	Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be
350	overwritten.  Custom names may be re-registered freely — live reload
351	re-runs registration on every save, so this must not raise.
352
353	Parameters:
354		name: Scale name (used in ``p.snap_to_scale(key, name)``).  Must not
355			be the name of a built-in scale.
356		intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]``
357			for Hirajōshi). Must be whole numbers, start with 0, ascend
358			strictly, and stay within 0–11.
359		qualities: Optional chord quality per scale degree (e.g.
360			``["minor", "major", "minor", "major", "diminished"]``).
361			Required only if you want to use the scale with
362			``diatonic_chords()`` or ``diatonic_chord_sequence()``.
363
364	Raises:
365		ValueError: If *name* is a built-in scale, or *intervals* /
366			*qualities* fail the rules above.
367
368	Example::
369
370		import subsequence
371
372		subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])
373
374		@comp.pattern(channel=0, length=4)
375		def melody (p):
376			p.note(60, beat=0)
377			p.snap_to_scale("C", "raga_bhairav")
378	"""
379
380	if name in _BUILTIN_SCALE_NAMES:
381		raise ValueError(
382			f"Cannot overwrite built-in scale '{name}'. "
383			"Choose a different name for your custom scale."
384		)
385
386	if not intervals:
387		raise ValueError("intervals must not be empty")
388	if not all(isinstance(i, int) for i in intervals):
389		raise ValueError("intervals must be whole numbers (semitone offsets)")
390	if intervals[0] != 0:
391		raise ValueError("intervals must start with 0")
392	if any(b <= a for a, b in zip(intervals, intervals[1:])):
393		raise ValueError("intervals must be strictly ascending")
394	if any(i < 0 or i > 11 for i in intervals):
395		raise ValueError("intervals must contain values between 0 and 11")
396	if qualities is not None and len(qualities) != len(intervals):
397		raise ValueError(
398			f"qualities length ({len(qualities)}) must match "
399			f"intervals length ({len(intervals)})"
400		)
401
402	INTERVAL_DEFINITIONS[name] = intervals
403	SCALE_MODE_MAP[name] = (name, qualities)
404
405
406def get_diatonic_intervals (
407	scale_notes: typing.List[int],
408	intervals: typing.Optional[typing.List[int]] = None,
409	mode: str = "scale"
410) -> typing.List[typing.List[int]]:
411
412	"""
413	Construct diatonic chords from a scale.
414	"""
415
416	if intervals is None:
417		intervals = [0, 2, 4]
418
419	if mode not in ("scale", "chromatic"):
420		raise ValueError("mode must be 'scale' or 'chromatic'")
421
422	diatonic_intervals: typing.List[typing.List[int]] = []
423	num_scale_notes = len(scale_notes)
424
425	for i in range(num_scale_notes):
426
427		if mode == "scale":
428			chord = [scale_notes[(i + offset) % num_scale_notes] for offset in intervals]
429
430		else:
431			root = scale_notes[i]
432			chord = [(root + offset) % 12 for offset in intervals]
433
434		diatonic_intervals.append(chord)
435
436	return diatonic_intervals
logger = <Logger subsequence.intervals (WARNING)>
INTERVAL_DEFINITIONS: Dict[str, 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], '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]}
IONIAN_QUALITIES: List[str] = ['major', 'minor', 'minor', 'major', 'major', 'minor', 'diminished']
DORIAN_QUALITIES: List[str] = ['minor', 'minor', 'major', 'major', 'minor', 'diminished', 'major']
PHRYGIAN_QUALITIES: List[str] = ['minor', 'major', 'major', 'minor', 'diminished', 'major', 'minor']
LYDIAN_QUALITIES: List[str] = ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor']
MIXOLYDIAN_QUALITIES: List[str] = ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major']
AEOLIAN_QUALITIES: List[str] = ['minor', 'diminished', 'major', 'minor', 'minor', 'major', 'major']
LOCRIAN_QUALITIES: List[str] = ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor']
HARMONIC_MINOR_QUALITIES: List[str] = ['minor', 'diminished', 'augmented', 'minor', 'major', 'major', 'diminished']
MELODIC_MINOR_QUALITIES: List[str] = ['minor', 'minor', 'augmented', 'major', 'major', 'diminished', 'diminished']
SCALE_MODE_MAP: Dict[str, Tuple[str, Optional[List[str]]]] = {'ionian': ('major_ionian', ['major', 'minor', 'minor', 'major', 'major', 'minor', 'diminished']), 'major': ('major_ionian', ['major', 'minor', 'minor', 'major', 'major', 'minor', 'diminished']), 'dorian': ('dorian_mode', ['minor', 'minor', 'major', 'major', 'minor', 'diminished', 'major']), 'phrygian': ('phrygian_mode', ['minor', 'major', 'major', 'minor', 'diminished', 'major', 'minor']), 'lydian': ('lydian', ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor']), 'mixolydian': ('mixolydian', ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major']), 'aeolian': ('natural_minor', ['minor', 'diminished', 'major', 'minor', 'minor', 'major', 'major']), 'minor': ('natural_minor', ['minor', 'diminished', 'major', 'minor', 'minor', 'major', 'major']), 'locrian': ('locrian_mode', ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor']), 'harmonic_minor': ('harmonic_minor', ['minor', 'diminished', 'augmented', 'minor', 'major', 'major', 'diminished']), 'melodic_minor': ('melodic_minor', ['minor', 'minor', 'augmented', 'major', 'major', 'diminished', 'diminished']), '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)}
DIATONIC_MODE_MAP = {'ionian': ('major_ionian', ['major', 'minor', 'minor', 'major', 'major', 'minor', 'diminished']), 'major': ('major_ionian', ['major', 'minor', 'minor', 'major', 'major', 'minor', 'diminished']), 'dorian': ('dorian_mode', ['minor', 'minor', 'major', 'major', 'minor', 'diminished', 'major']), 'phrygian': ('phrygian_mode', ['minor', 'major', 'major', 'minor', 'diminished', 'major', 'minor']), 'lydian': ('lydian', ['major', 'major', 'minor', 'diminished', 'major', 'minor', 'minor']), 'mixolydian': ('mixolydian', ['major', 'minor', 'diminished', 'major', 'minor', 'minor', 'major']), 'aeolian': ('natural_minor', ['minor', 'diminished', 'major', 'minor', 'minor', 'major', 'major']), 'minor': ('natural_minor', ['minor', 'diminished', 'major', 'minor', 'minor', 'major', 'major']), 'locrian': ('locrian_mode', ['diminished', 'major', 'minor', 'minor', 'major', 'major', 'minor']), 'harmonic_minor': ('harmonic_minor', ['minor', 'diminished', 'augmented', 'minor', 'major', 'major', 'diminished']), 'melodic_minor': ('melodic_minor', ['minor', 'minor', 'augmented', 'major', 'major', 'diminished', 'diminished']), '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)}
def scale_pitch_classes(key_pc: int, mode: str = 'ionian') -> List[int]:
160def scale_pitch_classes (key_pc: int, mode: str = "ionian") -> typing.List[int]:
161
162	"""
163	Return the pitch classes (0–11) that belong to a key and mode.
164
165	Parameters:
166		key_pc: Root pitch class (0 = C, 1 = C#/Db, …, 11 = B).
167		mode: Scale mode name. Supports all keys of ``DIATONIC_MODE_MAP``
168		      (e.g. ``"ionian"``, ``"dorian"``, ``"minor"``, ``"harmonic_minor"``).
169
170	Returns:
171		Pitch classes in scale-degree order, starting from the root
172		(length varies by mode). Values wrap mod-12, so the list is
173		not numerically sorted for non-C roots.
174
175	Example:
176		```python
177		# C major pitch classes
178		scale_pitch_classes(0, "ionian")  # → [0, 2, 4, 5, 7, 9, 11]
179
180		# A minor pitch classes
181		scale_pitch_classes(9, "aeolian")  # → [9, 11, 0, 2, 4, 5, 7] (mod-12)
182		```
183	"""
184
185	if mode not in SCALE_MODE_MAP:
186		raise ValueError(
187			f"Unknown mode '{mode}'. Available: {sorted(SCALE_MODE_MAP)}. "
188			"Use register_scale() to add custom scales."
189		)
190
191	scale_key, _ = SCALE_MODE_MAP[mode]
192	intervals = get_intervals(scale_key)
193	return [(key_pc + i) % 12 for i in intervals]

Return the pitch classes (0–11) that belong to a key and mode.

Arguments:
  • 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:
# 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)
def scale_notes( key: str, mode: str = 'ionian', low: int = 60, high: int = 72, count: Optional[int] = None) -> List[int]:
196def scale_notes (
197	key: str,
198	mode: str = "ionian",
199	low: int = 60,
200	high: int = 72,
201	count: typing.Optional[int] = None,
202) -> typing.List[int]:
203
204	"""Return MIDI note numbers for a scale within a pitch range.
205
206	Parameters:
207		key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.).
208		     This acts as a **pitch-class filter only** — it determines which
209		     semitone positions (0–11) are valid members of the scale, but does
210		     not affect which octave notes are drawn from. Notes are selected
211		     starting from ``low`` upward; ``key`` controls *which* notes are
212		     kept, not where the sequence starts. To guarantee the first
213		     returned note is the root, ``low`` must be a MIDI number whose
214		     pitch class matches ``key``. When starting from an arbitrary MIDI
215		     number, derive the key name with
216		     ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``.
217		mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP`
218		      (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``,
219		      ``"major_pentatonic"``). Use :func:`register_scale` for custom scales.
220		low: Lowest MIDI note (inclusive). When ``count`` is set, this is
221		     the starting note from which the scale ascends. **If ``low`` is
222		     not a member of the scale defined by ``key``, it is silently
223		     skipped** and the first returned note will be the next in-scale
224		     pitch above ``low``.
225		high: Highest MIDI note (inclusive). Ignored when ``count`` is set.
226		count: Exact number of notes to return. Notes ascend from ``low``
227		       through successive scale degrees, cycling into higher octaves
228		       as needed. When ``None`` (default), all scale tones between
229		       ``low`` and ``high`` are returned.
230
231	Returns:
232		Sorted list of MIDI note numbers.
233
234	Examples:
235		```python
236		import subsequence
237		import subsequence.constants.midi_notes as notes
238
239		# C major: all tones from middle C to C5
240		subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
241		# → [60, 62, 64, 65, 67, 69, 71, 72]
242
243		# E natural minor (aeolian) across one octave
244		subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3)
245		# → [40, 42, 43, 45, 47, 48, 50, 52]
246
247		# 15 notes of A minor pentatonic ascending from A3
248		subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15)
249		# → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91]
250
251		# Misalignment: key="E" but low=C4 — first note is C, not E
252		subsequence.scale_notes("E", "minor", low=60, count=4)
253		# → [60, 62, 64, 66]  (C D E F# — all in E natural minor, but starts on C)
254
255		# Fix: derive key name from root_pitch so low is always in the scale
256		root_pitch = 64  # E4
257		key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]  # → "E"
258		subsequence.scale_notes(key, "minor", low=root_pitch, count=4)
259		# → [64, 66, 67, 69]  (E F# G A — starts on the root)
260		```
261	"""
262
263	key_pc = subsequence.chords.key_name_to_pc(key)
264	pcs = set(scale_pitch_classes(key_pc, mode))
265
266	if count is not None:
267		if not pcs:
268			return []
269		result: typing.List[int] = []
270		pitch = low
271		while len(result) < count and pitch <= 127:
272			if pitch % 12 in pcs:
273				result.append(pitch)
274			pitch += 1
275		return result
276
277	return [p for p in range(low, high + 1) if p % 12 in pcs]

Return MIDI note numbers for a scale within a pitch range.

Arguments:
  • 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 SCALE_MODE_MAP (e.g. "ionian", "dorian", "natural_minor", "major_pentatonic"). Use 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:
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)
def quantize_pitch(pitch: int, scale_pcs: Sequence[int]) -> int:
280def quantize_pitch (pitch: int, scale_pcs: typing.Sequence[int]) -> int:
281
282	"""
283	Snap a MIDI pitch to the nearest note in the given scale.
284
285	Searches outward in semitone steps from the input pitch.  When two
286	notes are equidistant (e.g. C# between C and D in C major), the
287	upward direction is preferred.
288
289	Parameters:
290		pitch: MIDI note number to quantize.
291		scale_pcs: Pitch classes accepted by the scale (0–11). Typically
292		           the output of :func:`scale_pitch_classes`.
293
294	Returns:
295		A MIDI note number that lies within the scale.
296
297	Example:
298		```python
299		# Snap C# (61) to C (60) in C major
300		scale = scale_pitch_classes(0, "ionian")  # [0, 2, 4, 5, 7, 9, 11]
301		quantize_pitch(61, scale)  # → 60
302		```
303	"""
304
305	pc = pitch % 12
306
307	if pc in scale_pcs:
308		return pitch
309
310	for offset in range(1, 7):
311		if (pc + offset) % 12 in scale_pcs:
312			return pitch + offset
313		if (pc - offset) % 12 in scale_pcs:
314			return pitch - offset
315
316	# The search radius of ±6 semitones covers every gap in every scale with
317	# no gap wider than one tritone.  A wider gap (unusual custom scale) falls
318	# through here and keeps the original off-scale pitch — warn so the caller
319	# knows the result is not actually snapped to the scale.
320	logger.warning(
321		"quantize_pitch: no scale note within ±6 semitones of MIDI %d (pc=%d); "
322		"returning pitch unquantized. scale_pcs=%s",
323		pitch, pc, sorted(scale_pcs),
324	)
325	return pitch

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.

Arguments:
  • pitch: MIDI note number to quantize.
  • scale_pcs: Pitch classes accepted by the scale (0–11). Typically the output of scale_pitch_classes().
Returns:

A MIDI note number that lies within the scale.

Example:
# 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
def get_intervals(name: str) -> List[int]:
328def get_intervals (name: str) -> typing.List[int]:
329
330	"""
331	Return a named interval list from the registry.
332	"""
333
334	if name not in INTERVAL_DEFINITIONS:
335		raise ValueError(f"Unknown interval set: {name}")
336
337	return list(INTERVAL_DEFINITIONS[name])

Return a named interval list from the registry.

def register_scale( name: str, intervals: List[int], qualities: Optional[List[str]] = None) -> None:
340def register_scale (
341	name: str,
342	intervals: typing.List[int],
343	qualities: typing.Optional[typing.List[str]] = None
344) -> None:
345
346	"""
347	Register a custom scale for use with ``p.snap_to_scale()`` and
348	``scale_pitch_classes()``.
349
350	Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be
351	overwritten.  Custom names may be re-registered freely — live reload
352	re-runs registration on every save, so this must not raise.
353
354	Parameters:
355		name: Scale name (used in ``p.snap_to_scale(key, name)``).  Must not
356			be the name of a built-in scale.
357		intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]``
358			for Hirajōshi). Must be whole numbers, start with 0, ascend
359			strictly, and stay within 0–11.
360		qualities: Optional chord quality per scale degree (e.g.
361			``["minor", "major", "minor", "major", "diminished"]``).
362			Required only if you want to use the scale with
363			``diatonic_chords()`` or ``diatonic_chord_sequence()``.
364
365	Raises:
366		ValueError: If *name* is a built-in scale, or *intervals* /
367			*qualities* fail the rules above.
368
369	Example::
370
371		import subsequence
372
373		subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])
374
375		@comp.pattern(channel=0, length=4)
376		def melody (p):
377			p.note(60, beat=0)
378			p.snap_to_scale("C", "raga_bhairav")
379	"""
380
381	if name in _BUILTIN_SCALE_NAMES:
382		raise ValueError(
383			f"Cannot overwrite built-in scale '{name}'. "
384			"Choose a different name for your custom scale."
385		)
386
387	if not intervals:
388		raise ValueError("intervals must not be empty")
389	if not all(isinstance(i, int) for i in intervals):
390		raise ValueError("intervals must be whole numbers (semitone offsets)")
391	if intervals[0] != 0:
392		raise ValueError("intervals must start with 0")
393	if any(b <= a for a, b in zip(intervals, intervals[1:])):
394		raise ValueError("intervals must be strictly ascending")
395	if any(i < 0 or i > 11 for i in intervals):
396		raise ValueError("intervals must contain values between 0 and 11")
397	if qualities is not None and len(qualities) != len(intervals):
398		raise ValueError(
399			f"qualities length ({len(qualities)}) must match "
400			f"intervals length ({len(intervals)})"
401		)
402
403	INTERVAL_DEFINITIONS[name] = intervals
404	SCALE_MODE_MAP[name] = (name, qualities)

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.

Arguments:
  • 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")
def get_diatonic_intervals( scale_notes: List[int], intervals: Optional[List[int]] = None, mode: str = 'scale') -> List[List[int]]:
407def get_diatonic_intervals (
408	scale_notes: typing.List[int],
409	intervals: typing.Optional[typing.List[int]] = None,
410	mode: str = "scale"
411) -> typing.List[typing.List[int]]:
412
413	"""
414	Construct diatonic chords from a scale.
415	"""
416
417	if intervals is None:
418		intervals = [0, 2, 4]
419
420	if mode not in ("scale", "chromatic"):
421		raise ValueError("mode must be 'scale' or 'chromatic'")
422
423	diatonic_intervals: typing.List[typing.List[int]] = []
424	num_scale_notes = len(scale_notes)
425
426	for i in range(num_scale_notes):
427
428		if mode == "scale":
429			chord = [scale_notes[(i + offset) % num_scale_notes] for offset in intervals]
430
431		else:
432			root = scale_notes[i]
433			chord = [(root + offset) % 12 for offset in intervals]
434
435		diatonic_intervals.append(chord)
436
437	return diatonic_intervals

Construct diatonic chords from a scale.