subsequence.intervals

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

Construct diatonic chords from a scale.