subsequence 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. subsequence-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,436 @@
1
+ """Interval and scale definitions, plus the helpers that resolve them.
2
+
3
+ Holds ``INTERVAL_DEFINITIONS`` (named scales and chords as semitone lists) and
4
+ the functions that work against it — ``scale_notes``, ``scale_pitch_classes``,
5
+ ``quantize_pitch``, ``register_scale`` and friends.
6
+ """
7
+
8
+ import logging
9
+ import typing
10
+
11
+ import subsequence.chords
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ INTERVAL_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
+
83
+ IONIAN_QUALITIES: typing.List[str] = [
84
+ "major", "minor", "minor", "major", "major", "minor", "diminished"
85
+ ]
86
+
87
+ DORIAN_QUALITIES: typing.List[str] = [
88
+ "minor", "minor", "major", "major", "minor", "diminished", "major"
89
+ ]
90
+
91
+ PHRYGIAN_QUALITIES: typing.List[str] = [
92
+ "minor", "major", "major", "minor", "diminished", "major", "minor"
93
+ ]
94
+
95
+ LYDIAN_QUALITIES: typing.List[str] = [
96
+ "major", "major", "minor", "diminished", "major", "minor", "minor"
97
+ ]
98
+
99
+ MIXOLYDIAN_QUALITIES: typing.List[str] = [
100
+ "major", "minor", "diminished", "major", "minor", "minor", "major"
101
+ ]
102
+
103
+ AEOLIAN_QUALITIES: typing.List[str] = [
104
+ "minor", "diminished", "major", "minor", "minor", "major", "major"
105
+ ]
106
+
107
+ LOCRIAN_QUALITIES: typing.List[str] = [
108
+ "diminished", "major", "minor", "minor", "major", "major", "minor"
109
+ ]
110
+
111
+ # -- Non-modal scales --
112
+
113
+ HARMONIC_MINOR_QUALITIES: typing.List[str] = [
114
+ "minor", "diminished", "augmented", "minor", "major", "major", "diminished"
115
+ ]
116
+
117
+ MELODIC_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().
126
+ SCALE_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.
150
+ DIATONIC_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
+
159
+ def 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
+
195
+ def 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
+
279
+ def 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
+
327
+ def 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
+
339
+ def 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
+
406
+ def 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
@@ -0,0 +1,249 @@
1
+ """Single-keystroke input listener for live compositions.
2
+
3
+ Provides a background thread that reads individual keystrokes from stdin
4
+ without requiring the user to press Enter. Designed to work alongside
5
+ :class:`subsequence.display.Display` without conflicts — the display writes
6
+ to **stderr** while this module reads from **stdin**.
7
+
8
+ **Platform support:** Linux and macOS. Requires :mod:`tty` and :mod:`termios`,
9
+ which are only available on POSIX systems. On unsupported platforms (e.g.
10
+ Windows, or environments where stdin is not a real TTY), the listener starts
11
+ in a degraded mode and logs a warning instead of raising an exception.
12
+
13
+ Check :data:`HOTKEYS_SUPPORTED` at import time to know whether the current
14
+ platform can support hotkeys.
15
+
16
+ This module is used internally by :class:`subsequence.composition.Composition`
17
+ when hotkeys are enabled via ``composition.hotkeys()``. You do not need to
18
+ import it directly.
19
+ """
20
+
21
+ import logging
22
+ import queue
23
+ import select
24
+ import sys
25
+ import threading
26
+ import typing
27
+
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Platform capability detection
34
+ # ---------------------------------------------------------------------------
35
+
36
+ #: ``True`` when the current platform supports single-keystroke input.
37
+ #:
38
+ #: Requires :mod:`tty` and :mod:`termios` (POSIX-only) and a real TTY on
39
+ #: stdin. Check this before enabling hotkeys if you need to branch on
40
+ #: platform support:
41
+ #:
42
+ #: .. code-block:: python
43
+ #:
44
+ #: from subsequence.keystroke import HOTKEYS_SUPPORTED
45
+ #: if HOTKEYS_SUPPORTED:
46
+ #: composition.hotkeys()
47
+ HOTKEYS_SUPPORTED: bool = False
48
+
49
+ #: Short human-readable explanation of why hotkeys are not supported, or
50
+ #: ``None`` when :data:`HOTKEYS_SUPPORTED` is ``True``.
51
+ HOTKEYS_UNAVAILABLE_REASON: typing.Optional[str] = None
52
+
53
+ try:
54
+ import termios
55
+ import tty
56
+
57
+ if not sys.stdin.isatty():
58
+ raise OSError("stdin is not a TTY (running in a pipe or non-interactive context)")
59
+
60
+ # Quick sanity check — attempt to read and restore the current settings.
61
+ _fd = sys.stdin.fileno()
62
+ _saved = termios.tcgetattr(_fd)
63
+ termios.tcsetattr(_fd, termios.TCSADRAIN, _saved)
64
+
65
+ HOTKEYS_SUPPORTED = True
66
+
67
+ except ImportError:
68
+ HOTKEYS_UNAVAILABLE_REASON = (
69
+ "The 'tty' and 'termios' modules are not available on this platform. "
70
+ "Hotkeys require a POSIX operating system (Linux or macOS)."
71
+ )
72
+ except OSError as _e:
73
+ HOTKEYS_UNAVAILABLE_REASON = (
74
+ f"Hotkeys require an interactive terminal (TTY) on stdin. "
75
+ f"Reason: {_e}"
76
+ )
77
+ except Exception as _e:
78
+ HOTKEYS_UNAVAILABLE_REASON = f"Hotkeys unavailable: {_e}"
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Listener class
83
+ # ---------------------------------------------------------------------------
84
+
85
+ class KeystrokeListener:
86
+
87
+ """Background daemon thread that reads single keystrokes from stdin.
88
+
89
+ Puts stdin into *cbreak* mode so each keypress is delivered immediately,
90
+ without waiting for Enter. Keystrokes are placed in a thread-safe queue
91
+ and retrieved by the caller via :meth:`drain`.
92
+
93
+ Terminal settings are always restored on shutdown, even if an exception
94
+ occurs, so a crashed listener will not leave the terminal in a broken state.
95
+
96
+ If the current platform does not support hotkeys (:data:`HOTKEYS_SUPPORTED`
97
+ is ``False``), :meth:`start` logs a warning and returns immediately without
98
+ starting the thread. All other methods remain safe no-ops.
99
+
100
+ Example::
101
+
102
+ listener = KeystrokeListener()
103
+ listener.start()
104
+
105
+ # ...later, from the event loop...
106
+ for key in listener.drain():
107
+ handle(key)
108
+
109
+ listener.stop()
110
+ """
111
+
112
+ def __init__ (self) -> None:
113
+
114
+ """Initialise the listener in a stopped state."""
115
+
116
+ self._queue: queue.Queue[str] = queue.Queue()
117
+ self._thread: typing.Optional[threading.Thread] = None
118
+ self._running: bool = False
119
+ self._old_settings: typing.Optional[typing.List[typing.Any]] = None
120
+
121
+ #: ``True`` after a successful :meth:`start` on a supported platform.
122
+ self.active: bool = False
123
+
124
+ def start (self) -> None:
125
+
126
+ """Start the background keystroke listener thread.
127
+
128
+ Puts stdin into cbreak mode and begins reading. Call :meth:`stop`
129
+ to restore normal terminal behaviour. Safe to call more than once —
130
+ a second call while already running is a no-op.
131
+
132
+ If :data:`HOTKEYS_SUPPORTED` is ``False``, logs a warning and returns
133
+ without starting the thread. :attr:`active` will remain ``False``.
134
+ """
135
+
136
+ if self._running:
137
+ return
138
+
139
+ # A previous listener may still be inside its ~0.1 s poll: wait for it,
140
+ # or it would see the new _running=True, never exit, and the new thread
141
+ # would snapshot CBREAK mode as the "original" terminal settings.
142
+ if self._thread is not None and self._thread.is_alive():
143
+ self._thread.join(timeout=0.5)
144
+
145
+ if not HOTKEYS_SUPPORTED:
146
+ logger.warning(
147
+ f"Hotkeys are not available on this system and will be disabled. "
148
+ f"{HOTKEYS_UNAVAILABLE_REASON}"
149
+ )
150
+ return
151
+
152
+ self._running = True
153
+ self.active = True
154
+ self._thread = threading.Thread(
155
+ target = self._listen,
156
+ name = "subsequence-keystroke-listener",
157
+ daemon = True, # Dies automatically when the main thread exits.
158
+ )
159
+ self._thread.start()
160
+
161
+ def stop (self) -> None:
162
+
163
+ """Signal the listener to stop and restore the terminal.
164
+
165
+ Waits briefly for the background thread (it polls every ~0.1 s), then
166
+ restores the terminal settings directly if the thread has not done so —
167
+ a daemon thread killed at interpreter exit never runs its ``finally``
168
+ block, which used to leave the shell in cbreak mode (no echo) on most
169
+ clean exits. Safe to call on an unsupported platform — it is a no-op.
170
+ """
171
+
172
+ self._running = False
173
+ self.active = False
174
+
175
+ if self._thread is not None and self._thread.is_alive():
176
+ self._thread.join(timeout=0.5)
177
+
178
+ # Belt and braces: if the thread is somehow still alive (blocked
179
+ # read), restore the terminal from here - tcsetattr is idempotent.
180
+ if self._thread is not None and self._thread.is_alive() and self._old_settings is not None:
181
+ import termios # noqa: PLC0415
182
+ termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._old_settings)
183
+
184
+ def drain (self) -> typing.List[str]:
185
+
186
+ """Return all keystrokes that have arrived since the last drain.
187
+
188
+ Non-blocking. Returns an empty list if nothing has been pressed, or
189
+ if the listener is not active. Safe to call at any time.
190
+
191
+ Returns:
192
+ A list of single-character strings, one per keypress, in order.
193
+ """
194
+
195
+ keys: typing.List[str] = []
196
+
197
+ while True:
198
+ try:
199
+ keys.append(self._queue.get_nowait())
200
+ except queue.Empty:
201
+ break
202
+
203
+ return keys
204
+
205
+ def _listen (self) -> None:
206
+
207
+ """Internal thread target. Runs until ``_running`` is set False.
208
+
209
+ Uses :func:`select.select` with a short timeout so the thread can
210
+ notice the ``_running = False`` signal without blocking indefinitely.
211
+ Terminal settings are restored in the ``finally`` block so they are
212
+ always cleaned up, even if an exception occurs.
213
+ """
214
+
215
+ # These imports are guaranteed safe here — _listen is only called
216
+ # when HOTKEYS_SUPPORTED is True, which already confirmed they exist.
217
+ import termios # noqa: PLC0415
218
+ import tty # noqa: PLC0415
219
+
220
+ fd = sys.stdin.fileno()
221
+ old_settings = termios.tcgetattr(fd)
222
+
223
+ # Shared with stop() so it can restore the terminal if this thread is
224
+ # killed before the finally block runs (daemon threads at exit).
225
+ self._old_settings = old_settings
226
+
227
+ try:
228
+ # cbreak: one character at a time, no Enter required.
229
+ # Differs from raw in that Ctrl+C / Ctrl+Z still work normally.
230
+ tty.setcbreak(fd)
231
+
232
+ while self._running:
233
+ # Poll with a short timeout so we can check _running regularly.
234
+ ready, _, _ = select.select([sys.stdin], [], [], 0.1)
235
+ if ready:
236
+ char = sys.stdin.read(1)
237
+ if char:
238
+ self._queue.put(char)
239
+
240
+ except Exception:
241
+ # A broken listener must not crash the composition — but dying
242
+ # silently left "why did my hotkeys stop working?" unanswerable
243
+ # (the finally below marks the listener inactive).
244
+ logger.warning("Keystroke listener stopped after an unexpected error — hotkeys are now inactive", exc_info=True)
245
+
246
+ finally:
247
+ # Always restore terminal, even after exceptions.
248
+ termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
249
+ self.active = False