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.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
subsequence/intervals.py
ADDED
|
@@ -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
|
subsequence/keystroke.py
ADDED
|
@@ -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
|