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/motifs.py
ADDED
|
@@ -0,0 +1,2356 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Motif and Phrase — immutable musical values.
|
|
3
|
+
|
|
4
|
+
A :class:`Motif` is a short musical figure stored as a value: a frozen tuple
|
|
5
|
+
of timed note events (with *specification* pitches — scale degrees, chord
|
|
6
|
+
tones, drum names, or absolute MIDI) plus an optional stream of control
|
|
7
|
+
gestures (CC sweeps, pitch bends, NRPN/RPN moves), and an explicit length in
|
|
8
|
+
beats. A :class:`Phrase` is a frozen sequence of Motifs whose segmentation
|
|
9
|
+
is preserved.
|
|
10
|
+
|
|
11
|
+
Values are frozen dataclasses: immutable, deterministic to construct,
|
|
12
|
+
hashable, printable, and safe to define at module level in a live-coded
|
|
13
|
+
file. They carry no playback position — the engine owns position; values
|
|
14
|
+
are placed onto patterns with ``p.motif()`` / ``p.phrase()``.
|
|
15
|
+
|
|
16
|
+
Pitch is resolved late: a stored :class:`Degree` or :class:`ChordTone`
|
|
17
|
+
becomes a MIDI note only at placement, against the key/scale (and, where
|
|
18
|
+
applicable, the chord) in effect at that event's own beat. The same motif
|
|
19
|
+
therefore sounds different under different harmony — by design.
|
|
20
|
+
|
|
21
|
+
The combination algebra:
|
|
22
|
+
|
|
23
|
+
- ``a + b`` — sequential: a Phrase of the two (segmentation preserved).
|
|
24
|
+
- ``a.then(b)`` / ``Motif.join([...])`` — closed sequential concat (one longer Motif).
|
|
25
|
+
- ``a & b`` / ``a.stack(b)`` — parallel merge (event union; length = max).
|
|
26
|
+
- ``m * n`` — repetition: a Phrase of n segments (``m * 1`` is ``m``).
|
|
27
|
+
- ``m.slice(start, end)`` — a window; durations and ramps truncate at the cut.
|
|
28
|
+
|
|
29
|
+
Transforms are pure and return new values. Time transforms (``reverse``,
|
|
30
|
+
``rotate``, ``stretch``, ``slice``) carry control gestures with them — a
|
|
31
|
+
reversed rising sweep becomes a falling one; pitch- and note-scoped
|
|
32
|
+
transforms (``transpose``, ``invert``, ``pitched``, ``accent``,
|
|
33
|
+
``with_velocity``, ``quantize``) leave control gestures untouched.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import dataclasses
|
|
37
|
+
import math
|
|
38
|
+
import random
|
|
39
|
+
import typing
|
|
40
|
+
import warnings
|
|
41
|
+
|
|
42
|
+
import subsequence.cadences
|
|
43
|
+
import subsequence.constants.velocity
|
|
44
|
+
import subsequence.easing
|
|
45
|
+
import subsequence.intervals
|
|
46
|
+
import subsequence.sequence_utils
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
_DEFAULT_VELOCITY = subsequence.constants.velocity.DEFAULT_VELOCITY
|
|
50
|
+
|
|
51
|
+
# Degree ints beyond this are almost certainly pasted MIDI note numbers
|
|
52
|
+
# (e.g. 60 for middle C), not scale degrees; fail loud rather than emit a
|
|
53
|
+
# squeal eight octaves up.
|
|
54
|
+
_MAX_PLAUSIBLE_DEGREE = 24
|
|
55
|
+
|
|
56
|
+
# World-rhythm timelines: name → (onset step indices, grid pulses, default
|
|
57
|
+
# voice). Onset positions are the exact pulse indices catalogued in
|
|
58
|
+
# Toussaint, "The Geometry of Musical Rhythm" — the clave family and tresillo/
|
|
59
|
+
# cinquillo on a 16- or 8-pulse bar, the West-African bell patterns on 12.
|
|
60
|
+
# Read by Motif.preset(). Default voices are General MIDI percussion names
|
|
61
|
+
# (so a preset with no pitch= sounds against the standard GM drum map);
|
|
62
|
+
# override with pitch= for any other kit.
|
|
63
|
+
_WORLD_RHYTHMS: typing.Dict[str, typing.Tuple[typing.Tuple[int, ...], int, str]] = {
|
|
64
|
+
# Cuban clave family (16-pulse bar).
|
|
65
|
+
"son_clave_3_2": ((0, 3, 6, 10, 12), 16, "claves"),
|
|
66
|
+
"son_clave_2_3": ((2, 4, 8, 11, 14), 16, "claves"),
|
|
67
|
+
"rumba_clave_3_2": ((0, 3, 7, 10, 12), 16, "claves"),
|
|
68
|
+
"rumba_clave_2_3": ((2, 4, 8, 11, 15), 16, "claves"),
|
|
69
|
+
"bossa_nova_clave": ((0, 3, 6, 10, 13), 16, "side_stick"),
|
|
70
|
+
# Tresillo / cinquillo (the 3-3-2 family).
|
|
71
|
+
"tresillo": ((0, 3, 6), 8, "low_conga"),
|
|
72
|
+
"tresillo_16": ((0, 6, 12), 16, "low_conga"),
|
|
73
|
+
"cinquillo": ((0, 2, 3, 5, 6), 8, "low_conga"),
|
|
74
|
+
# West-African / Cuban 4-4 bell timelines (16-pulse).
|
|
75
|
+
"shiko": ((0, 4, 6, 10, 12), 16, "cowbell"),
|
|
76
|
+
"soukous": ((0, 3, 6, 10, 11), 16, "cowbell"),
|
|
77
|
+
"gahu": ((0, 3, 6, 10, 14), 16, "cowbell"),
|
|
78
|
+
"samba_necklace": ((0, 3, 5, 7, 10, 12, 14), 16, "side_stick"),
|
|
79
|
+
# The "standard pattern" / bembé bell on a 12-pulse cycle.
|
|
80
|
+
"bembe": ((0, 2, 4, 5, 7, 9, 11), 12, "cowbell"),
|
|
81
|
+
# bembe_euclidean is the specific Toussaint-catalogued Euclidean rotation
|
|
82
|
+
# of the bembé necklace (intervals 2-1-2-2-1-2-2); it differs from this
|
|
83
|
+
# library's own generate_euclidean_sequence(12, 7) default rotation.
|
|
84
|
+
"bembe_euclidean": ((0, 2, 3, 5, 7, 8, 10), 12, "cowbell"),
|
|
85
|
+
# Fume-fume is the FIVE-onset Ghanaian bell — Toussaint catalogues it as
|
|
86
|
+
# a rotation of E(5,12), not the seven-onset standard pattern above.
|
|
87
|
+
"fume_fume": ((0, 2, 4, 7, 9), 12, "cowbell"),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
_CHORD_TONE_NAMES = {"root": 1, "third": 2, "fifth": 3, "seventh": 4}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ── Pitch specifications ────────────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
@dataclasses.dataclass(frozen=True)
|
|
96
|
+
class Degree:
|
|
97
|
+
|
|
98
|
+
"""
|
|
99
|
+
A scale degree — 1-based, resolved against key + scale at placement.
|
|
100
|
+
|
|
101
|
+
Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the
|
|
102
|
+
scale length and resolve into higher octaves). ``octave`` shifts whole
|
|
103
|
+
octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened).
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
step: int
|
|
107
|
+
octave: int = 0
|
|
108
|
+
chroma: int = 0
|
|
109
|
+
|
|
110
|
+
def __post_init__ (self) -> None:
|
|
111
|
+
|
|
112
|
+
"""Validate that the degree is 1-based and plausibly a degree."""
|
|
113
|
+
|
|
114
|
+
if self.step < 1:
|
|
115
|
+
raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclasses.dataclass(frozen=True)
|
|
119
|
+
class ChordTone:
|
|
120
|
+
|
|
121
|
+
"""
|
|
122
|
+
An index into the current chord's tones — 1-based, resolved at placement.
|
|
123
|
+
|
|
124
|
+
Accepts an int (1 = root, 2 = third, ...) or one of the names
|
|
125
|
+
``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave``
|
|
126
|
+
shifts whole octaves.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
index: int
|
|
130
|
+
octave: int = 0
|
|
131
|
+
|
|
132
|
+
def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None:
|
|
133
|
+
|
|
134
|
+
"""Normalize a tone name to its 1-based index."""
|
|
135
|
+
|
|
136
|
+
if isinstance(index_or_name, str):
|
|
137
|
+
if index_or_name not in _CHORD_TONE_NAMES:
|
|
138
|
+
raise ValueError(
|
|
139
|
+
f"Unknown chord tone name '{index_or_name}' — "
|
|
140
|
+
f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index"
|
|
141
|
+
)
|
|
142
|
+
index = _CHORD_TONE_NAMES[index_or_name]
|
|
143
|
+
else:
|
|
144
|
+
index = index_or_name
|
|
145
|
+
|
|
146
|
+
if index < 1:
|
|
147
|
+
raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}")
|
|
148
|
+
|
|
149
|
+
object.__setattr__(self, "index", index)
|
|
150
|
+
object.__setattr__(self, "octave", octave)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclasses.dataclass(frozen=True)
|
|
154
|
+
class Approach:
|
|
155
|
+
|
|
156
|
+
"""
|
|
157
|
+
A half-step approach into a target pitch at the next chord boundary.
|
|
158
|
+
|
|
159
|
+
Resolves at placement, one semitone below its target (the leading-tone
|
|
160
|
+
approach); a ``ChordTone`` target reads the NEXT chord through the
|
|
161
|
+
harmony window, so the approach lands as the harmony arrives.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
target: typing.Union[int, Degree, ChordTone]
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ── Control signals ─────────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
@dataclasses.dataclass(frozen=True)
|
|
170
|
+
class CC:
|
|
171
|
+
|
|
172
|
+
"""A MIDI CC signal — number, or a name resolved at placement via the pattern's ``cc_name_map``."""
|
|
173
|
+
|
|
174
|
+
control: typing.Union[int, str]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@dataclasses.dataclass(frozen=True)
|
|
178
|
+
class PitchBend:
|
|
179
|
+
|
|
180
|
+
"""The channel pitch-bend wheel; values are normalised -1.0 to 1.0."""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclasses.dataclass(frozen=True)
|
|
184
|
+
class NRPN:
|
|
185
|
+
|
|
186
|
+
"""An NRPN parameter — number, or a name resolved at placement via the pattern's ``nrpn_name_map``."""
|
|
187
|
+
|
|
188
|
+
parameter: typing.Union[int, str]
|
|
189
|
+
fine: bool = False
|
|
190
|
+
null_reset: bool = True
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclasses.dataclass(frozen=True)
|
|
194
|
+
class RPN:
|
|
195
|
+
|
|
196
|
+
"""An RPN parameter — number, or one of the standard RPN names (resolved at placement)."""
|
|
197
|
+
|
|
198
|
+
parameter: typing.Union[int, str]
|
|
199
|
+
fine: bool = False
|
|
200
|
+
null_reset: bool = True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclasses.dataclass(frozen=True)
|
|
204
|
+
class OSC:
|
|
205
|
+
|
|
206
|
+
"""An OSC address; values are sent as the single float argument."""
|
|
207
|
+
|
|
208
|
+
address: str
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
ControlSignal = typing.Union[CC, PitchBend, NRPN, RPN, OSC]
|
|
212
|
+
PitchSpec = typing.Union[int, str, Degree, ChordTone, Approach, None]
|
|
213
|
+
|
|
214
|
+
_SPEC_RANK = {int: 0, str: 1, Degree: 2, ChordTone: 3, Approach: 4, type(None): 5}
|
|
215
|
+
_SIGNAL_RANK = {CC: 0, PitchBend: 1, NRPN: 2, RPN: 3, OSC: 4}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _pitch_sort_key (pitch: PitchSpec) -> tuple:
|
|
219
|
+
|
|
220
|
+
"""A total order over heterogeneous pitch specs (for canonical event order)."""
|
|
221
|
+
|
|
222
|
+
rank = _SPEC_RANK[type(pitch)]
|
|
223
|
+
|
|
224
|
+
if isinstance(pitch, (int, str)):
|
|
225
|
+
return (rank, pitch)
|
|
226
|
+
if isinstance(pitch, Degree):
|
|
227
|
+
return (rank, pitch.step, pitch.octave, pitch.chroma)
|
|
228
|
+
if isinstance(pitch, ChordTone):
|
|
229
|
+
return (rank, pitch.index, pitch.octave)
|
|
230
|
+
if isinstance(pitch, Approach):
|
|
231
|
+
return (rank,) + _pitch_sort_key(pitch.target)
|
|
232
|
+
|
|
233
|
+
return (rank,)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _signal_sort_key (signal: ControlSignal) -> tuple:
|
|
237
|
+
|
|
238
|
+
"""A total order over control signals (for canonical event order)."""
|
|
239
|
+
|
|
240
|
+
rank = _SIGNAL_RANK[type(signal)]
|
|
241
|
+
|
|
242
|
+
if isinstance(signal, CC):
|
|
243
|
+
return (rank, str(signal.control))
|
|
244
|
+
if isinstance(signal, (NRPN, RPN)):
|
|
245
|
+
return (rank, str(signal.parameter), signal.fine, signal.null_reset)
|
|
246
|
+
if isinstance(signal, OSC):
|
|
247
|
+
return (rank, signal.address)
|
|
248
|
+
|
|
249
|
+
return (rank,)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _velocity_key (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Tuple[int, int]:
|
|
253
|
+
|
|
254
|
+
"""Normalise scalar-or-range velocity to a sortable pair."""
|
|
255
|
+
|
|
256
|
+
if isinstance(velocity, tuple):
|
|
257
|
+
return (velocity[0], velocity[1])
|
|
258
|
+
|
|
259
|
+
return (velocity, velocity)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ── Events ──────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
@dataclasses.dataclass(frozen=True)
|
|
265
|
+
class MotifEvent:
|
|
266
|
+
|
|
267
|
+
"""
|
|
268
|
+
One timed note event inside a Motif.
|
|
269
|
+
|
|
270
|
+
``pitch`` is a specification: an absolute MIDI int, a drum name string,
|
|
271
|
+
a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None
|
|
272
|
+
for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which
|
|
273
|
+
must be re-pitched via :meth:`Motif.pitched` before placement.
|
|
274
|
+
``velocity`` is an int or a ``(low, high)`` random-range tuple.
|
|
275
|
+
"""
|
|
276
|
+
|
|
277
|
+
beat: float
|
|
278
|
+
pitch: PitchSpec
|
|
279
|
+
velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY
|
|
280
|
+
duration: float = 0.25
|
|
281
|
+
probability: float = 1.0
|
|
282
|
+
|
|
283
|
+
def __post_init__ (self) -> None:
|
|
284
|
+
|
|
285
|
+
"""Validate ranges that are wrong at any placement."""
|
|
286
|
+
|
|
287
|
+
if self.duration <= 0:
|
|
288
|
+
raise ValueError(f"Event duration must be positive — got {self.duration}")
|
|
289
|
+
if not 0.0 <= self.probability <= 1.0:
|
|
290
|
+
raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
|
|
291
|
+
|
|
292
|
+
def _sort_key (self) -> tuple:
|
|
293
|
+
|
|
294
|
+
"""Canonical ordering key — makes parallel merge order-independent."""
|
|
295
|
+
|
|
296
|
+
return (self.beat, _pitch_sort_key(self.pitch), _velocity_key(self.velocity), self.duration, self.probability)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@dataclasses.dataclass(frozen=True)
|
|
300
|
+
class ControlEvent:
|
|
301
|
+
|
|
302
|
+
"""
|
|
303
|
+
One timed control gesture inside a Motif: a discrete write or a shaped ramp.
|
|
304
|
+
|
|
305
|
+
A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates
|
|
306
|
+
``start`` → ``end`` over ``span`` beats through the easing ``shape``.
|
|
307
|
+
Pulse density (``resolution=``) is deliberately not stored here — beats
|
|
308
|
+
and shapes are music; MIDI traffic density is set at the placement call.
|
|
309
|
+
"""
|
|
310
|
+
|
|
311
|
+
beat: float
|
|
312
|
+
signal: ControlSignal
|
|
313
|
+
start: float
|
|
314
|
+
end: typing.Optional[float] = None
|
|
315
|
+
span: float = 0.0
|
|
316
|
+
shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear"
|
|
317
|
+
probability: float = 1.0
|
|
318
|
+
|
|
319
|
+
def __post_init__ (self) -> None:
|
|
320
|
+
|
|
321
|
+
"""Validate the discrete/ramp invariants."""
|
|
322
|
+
|
|
323
|
+
if (self.end is None) != (self.span == 0.0):
|
|
324
|
+
raise ValueError("A ramp needs both end= and span= (a discrete write has neither)")
|
|
325
|
+
if self.span < 0:
|
|
326
|
+
raise ValueError(f"Ramp span must be non-negative — got {self.span}")
|
|
327
|
+
if not 0.0 <= self.probability <= 1.0:
|
|
328
|
+
raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
|
|
329
|
+
|
|
330
|
+
def _sort_key (self) -> tuple:
|
|
331
|
+
|
|
332
|
+
"""Canonical ordering key — makes parallel merge order-independent."""
|
|
333
|
+
|
|
334
|
+
end = self.start if self.end is None else self.end
|
|
335
|
+
return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability)
|
|
336
|
+
|
|
337
|
+
def _value_at (self, fraction: float) -> float:
|
|
338
|
+
|
|
339
|
+
"""The interpolated value at a 0–1 fraction through the ramp."""
|
|
340
|
+
|
|
341
|
+
if self.end is None:
|
|
342
|
+
return self.start
|
|
343
|
+
|
|
344
|
+
easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape)
|
|
345
|
+
return self.start + (self.end - self.start) * easing_fn(max(0.0, min(1.0, fraction)))
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _expand (name: str, value: typing.Any, n: int) -> list:
|
|
349
|
+
|
|
350
|
+
"""Expand a scalar parameter to n values, or validate a per-event list."""
|
|
351
|
+
|
|
352
|
+
if isinstance(value, (int, float, str)) or value is None or isinstance(value, tuple):
|
|
353
|
+
return [value] * n
|
|
354
|
+
|
|
355
|
+
result = list(value)
|
|
356
|
+
|
|
357
|
+
if len(result) != n:
|
|
358
|
+
raise ValueError(f"{name} has {len(result)} values for {n} events — parallel lists must match")
|
|
359
|
+
|
|
360
|
+
return result
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _computed_length (events: typing.Iterable[MotifEvent], controls: typing.Iterable[ControlEvent]) -> float:
|
|
364
|
+
|
|
365
|
+
"""Default length: the next whole beat at or after the last sounding moment."""
|
|
366
|
+
|
|
367
|
+
ends = [e.beat + e.duration for e in events] + [c.beat + c.span for c in controls]
|
|
368
|
+
return float(math.ceil(max(ends))) if ends else 0.0
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── Motif ───────────────────────────────────────────────────────────────────
|
|
372
|
+
|
|
373
|
+
@dataclasses.dataclass(frozen=True)
|
|
374
|
+
class Motif:
|
|
375
|
+
|
|
376
|
+
"""
|
|
377
|
+
An immutable musical figure: timed note events + control gestures + a length in beats.
|
|
378
|
+
|
|
379
|
+
Construct via the classmethods (:meth:`degrees`, :meth:`notes`,
|
|
380
|
+
:meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture
|
|
381
|
+
constructors, or :meth:`from_events`) rather than positionally.
|
|
382
|
+
``length`` is explicit — a trailing rest is meaningful.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
events: typing.Tuple[MotifEvent, ...]
|
|
386
|
+
length: float
|
|
387
|
+
controls: typing.Tuple[ControlEvent, ...] = ()
|
|
388
|
+
fit: typing.Optional[float] = None # placement default for the fit dial; set by generate()
|
|
389
|
+
|
|
390
|
+
def __post_init__ (self) -> None:
|
|
391
|
+
|
|
392
|
+
"""Validate, and normalise both streams to canonical order."""
|
|
393
|
+
|
|
394
|
+
if self.length < 0:
|
|
395
|
+
raise ValueError(f"Motif length must be non-negative — got {self.length}")
|
|
396
|
+
|
|
397
|
+
object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key)))
|
|
398
|
+
object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key)))
|
|
399
|
+
|
|
400
|
+
# ── constructors ────────────────────────────────────────────────────
|
|
401
|
+
|
|
402
|
+
@classmethod
|
|
403
|
+
def empty (cls) -> "Motif":
|
|
404
|
+
|
|
405
|
+
"""The empty motif (zero events, zero length) — the identity for ``then``."""
|
|
406
|
+
|
|
407
|
+
return cls(events=(), length=0.0)
|
|
408
|
+
|
|
409
|
+
@classmethod
|
|
410
|
+
def from_events (
|
|
411
|
+
cls,
|
|
412
|
+
events: typing.Iterable[MotifEvent],
|
|
413
|
+
length: typing.Optional[float] = None,
|
|
414
|
+
controls: typing.Iterable[ControlEvent] = (),
|
|
415
|
+
) -> "Motif":
|
|
416
|
+
|
|
417
|
+
"""Build a motif from explicit events (power use; length defaults to the next whole beat)."""
|
|
418
|
+
|
|
419
|
+
events = tuple(events)
|
|
420
|
+
controls = tuple(controls)
|
|
421
|
+
|
|
422
|
+
return cls(
|
|
423
|
+
events = events,
|
|
424
|
+
length = _computed_length(events, controls) if length is None else length,
|
|
425
|
+
controls = controls,
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
@classmethod
|
|
429
|
+
def _from_sequence (
|
|
430
|
+
cls,
|
|
431
|
+
pitches: typing.List[PitchSpec],
|
|
432
|
+
beats: typing.Optional[typing.List[float]],
|
|
433
|
+
velocities: typing.Any,
|
|
434
|
+
durations: typing.Any,
|
|
435
|
+
probabilities: typing.Any,
|
|
436
|
+
length: typing.Optional[float],
|
|
437
|
+
) -> "Motif":
|
|
438
|
+
|
|
439
|
+
"""Shared core: one event per element, None = rest (slot still advances)."""
|
|
440
|
+
|
|
441
|
+
n = len(pitches)
|
|
442
|
+
onsets = list(beats) if beats is not None else [float(i) for i in range(n)]
|
|
443
|
+
|
|
444
|
+
if len(onsets) != n:
|
|
445
|
+
raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match")
|
|
446
|
+
|
|
447
|
+
velocity_list = _expand("velocities", velocities, n)
|
|
448
|
+
duration_list = _expand("durations", durations, n)
|
|
449
|
+
probability_list = _expand("probabilities", probabilities, n)
|
|
450
|
+
|
|
451
|
+
events = tuple(
|
|
452
|
+
MotifEvent(
|
|
453
|
+
beat = float(onsets[i]),
|
|
454
|
+
pitch = pitches[i],
|
|
455
|
+
velocity = velocity_list[i],
|
|
456
|
+
duration = float(duration_list[i]),
|
|
457
|
+
probability = float(probability_list[i]),
|
|
458
|
+
)
|
|
459
|
+
for i in range(n)
|
|
460
|
+
if pitches[i] is not None
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
return cls(
|
|
464
|
+
events = events,
|
|
465
|
+
length = _computed_length(events, ()) if length is None else float(length),
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
@classmethod
|
|
469
|
+
def degrees (
|
|
470
|
+
cls,
|
|
471
|
+
degrees: typing.List[typing.Union[int, Degree, None]],
|
|
472
|
+
beats: typing.Optional[typing.List[float]] = None,
|
|
473
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
474
|
+
durations: typing.Any = 1.0,
|
|
475
|
+
probabilities: typing.Any = 1.0,
|
|
476
|
+
length: typing.Optional[float] = None,
|
|
477
|
+
) -> "Motif":
|
|
478
|
+
|
|
479
|
+
"""
|
|
480
|
+
A melody written as 1-based scale degrees, one per beat by default.
|
|
481
|
+
|
|
482
|
+
Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a
|
|
483
|
+
rest (the beat slot still advances), or :class:`Degree` for octave/
|
|
484
|
+
chromatic detail. Resolved against key + scale at placement.
|
|
485
|
+
Durations default to a full beat (each note holds its slot).
|
|
486
|
+
"""
|
|
487
|
+
|
|
488
|
+
converted: typing.List[PitchSpec] = []
|
|
489
|
+
|
|
490
|
+
for element in degrees:
|
|
491
|
+
if isinstance(element, int):
|
|
492
|
+
if element > _MAX_PLAUSIBLE_DEGREE:
|
|
493
|
+
raise ValueError(
|
|
494
|
+
f"Degree {element} is implausibly large — scale degrees are 1-based "
|
|
495
|
+
f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()."
|
|
496
|
+
)
|
|
497
|
+
converted.append(Degree(element))
|
|
498
|
+
elif isinstance(element, Degree) or element is None:
|
|
499
|
+
converted.append(element)
|
|
500
|
+
else:
|
|
501
|
+
raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}")
|
|
502
|
+
|
|
503
|
+
return cls._from_sequence(converted, beats, velocities, durations, probabilities, length)
|
|
504
|
+
|
|
505
|
+
@classmethod
|
|
506
|
+
def notes (
|
|
507
|
+
cls,
|
|
508
|
+
notes: typing.List[typing.Union[int, None]],
|
|
509
|
+
beats: typing.Optional[typing.List[float]] = None,
|
|
510
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
511
|
+
durations: typing.Any = 1.0,
|
|
512
|
+
probabilities: typing.Any = 1.0,
|
|
513
|
+
length: typing.Optional[float] = None,
|
|
514
|
+
) -> "Motif":
|
|
515
|
+
|
|
516
|
+
"""A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest."""
|
|
517
|
+
|
|
518
|
+
for element in notes:
|
|
519
|
+
# bool is a subclass of int, but True/False are never MIDI notes.
|
|
520
|
+
if isinstance(element, bool) or not (isinstance(element, int) or element is None):
|
|
521
|
+
raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}")
|
|
522
|
+
|
|
523
|
+
return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
|
|
524
|
+
|
|
525
|
+
@classmethod
|
|
526
|
+
def hits (
|
|
527
|
+
cls,
|
|
528
|
+
pitch: typing.Union[int, str],
|
|
529
|
+
beats: typing.List[float],
|
|
530
|
+
length: typing.Optional[float] = None,
|
|
531
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
532
|
+
durations: typing.Any = 0.1,
|
|
533
|
+
probabilities: typing.Any = 1.0,
|
|
534
|
+
) -> "Motif":
|
|
535
|
+
|
|
536
|
+
"""One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention."""
|
|
537
|
+
|
|
538
|
+
return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length)
|
|
539
|
+
|
|
540
|
+
@classmethod
|
|
541
|
+
def steps (
|
|
542
|
+
cls,
|
|
543
|
+
steps: typing.List[int],
|
|
544
|
+
pitches: typing.Any,
|
|
545
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
546
|
+
durations: typing.Any = 0.1,
|
|
547
|
+
probabilities: typing.Any = 1.0,
|
|
548
|
+
step_duration: float = 0.25,
|
|
549
|
+
length: typing.Optional[float] = None,
|
|
550
|
+
) -> "Motif":
|
|
551
|
+
|
|
552
|
+
"""
|
|
553
|
+
Grid placement — the ``sequence()`` convention: ``steps`` are 0-based
|
|
554
|
+
grid indices (sixteenths by default), ``pitches`` a scalar or
|
|
555
|
+
parallel list of MIDI ints or drum names.
|
|
556
|
+
"""
|
|
557
|
+
|
|
558
|
+
n = len(steps)
|
|
559
|
+
pitch_list = _expand("pitches", pitches, n)
|
|
560
|
+
onsets = [s * step_duration for s in steps]
|
|
561
|
+
|
|
562
|
+
if length is None and n:
|
|
563
|
+
length = float(math.ceil((max(steps) + 1) * step_duration))
|
|
564
|
+
|
|
565
|
+
return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length)
|
|
566
|
+
|
|
567
|
+
@classmethod
|
|
568
|
+
def euclidean (
|
|
569
|
+
cls,
|
|
570
|
+
pulses: int,
|
|
571
|
+
steps: int,
|
|
572
|
+
pitch: typing.Union[int, str],
|
|
573
|
+
length: float = 4.0,
|
|
574
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
575
|
+
durations: typing.Any = 0.1,
|
|
576
|
+
probabilities: typing.Any = 1.0,
|
|
577
|
+
) -> "Motif":
|
|
578
|
+
|
|
579
|
+
"""A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats."""
|
|
580
|
+
|
|
581
|
+
# bool is a subclass of int, but True/False are never MIDI notes.
|
|
582
|
+
if isinstance(pitch, bool):
|
|
583
|
+
raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}")
|
|
584
|
+
|
|
585
|
+
# The kernel returns one 0/1 flag per grid step; onsets are the 1s.
|
|
586
|
+
# It validates pulses first, so pulses > steps still raises clearly.
|
|
587
|
+
flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses)
|
|
588
|
+
|
|
589
|
+
if steps <= 0:
|
|
590
|
+
# A grid of zero steps holds no onsets — an empty motif of the given
|
|
591
|
+
# length, matching pulses=0 on a real grid (the empty-input policy).
|
|
592
|
+
return cls._from_sequence([], [], velocities, durations, probabilities, length)
|
|
593
|
+
|
|
594
|
+
step_duration = length / steps
|
|
595
|
+
onsets = [i * step_duration for i, flag in enumerate(flags) if flag]
|
|
596
|
+
|
|
597
|
+
return cls._from_sequence(
|
|
598
|
+
[pitch] * len(onsets),
|
|
599
|
+
onsets,
|
|
600
|
+
velocities, durations, probabilities, length,
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
@classmethod
|
|
604
|
+
def preset (
|
|
605
|
+
cls,
|
|
606
|
+
name: str,
|
|
607
|
+
pitch: typing.Optional[typing.Union[int, str]] = None,
|
|
608
|
+
length: float = 4.0,
|
|
609
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
610
|
+
durations: typing.Any = 0.1,
|
|
611
|
+
probabilities: typing.Any = 1.0,
|
|
612
|
+
) -> "Motif":
|
|
613
|
+
|
|
614
|
+
"""A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``.
|
|
615
|
+
|
|
616
|
+
Looks a curated timeline up in the world-rhythm table (clave family,
|
|
617
|
+
West-African bell patterns, tresillo/cinquillo, samba) and lays its
|
|
618
|
+
onsets across *length* beats. Onset positions are exact pulse indices
|
|
619
|
+
from Toussaint's "The Geometry of Musical Rhythm"; each preset declares
|
|
620
|
+
its own grid (16 for the clave/4-4 timelines, 12 for the bell
|
|
621
|
+
patterns) and a default drum voice.
|
|
622
|
+
|
|
623
|
+
Parameters:
|
|
624
|
+
name: A preset name (``KeyError``-style ValueError lists them all).
|
|
625
|
+
pitch: The voice — a drum name or MIDI int; defaults to the
|
|
626
|
+
preset's General-MIDI voice (``"claves"``, ``"cowbell"``,
|
|
627
|
+
``"side_stick"``, ``"low_conga"``), so it sounds against the
|
|
628
|
+
standard GM drum map without a ``pitch=``.
|
|
629
|
+
length: Total beats the cycle spans (4 = one common-time bar).
|
|
630
|
+
velocities / durations / probabilities: The parallel-list params.
|
|
631
|
+
|
|
632
|
+
Returns:
|
|
633
|
+
A drum/pitched :class:`Motif` of the timeline's onsets.
|
|
634
|
+
|
|
635
|
+
Raises:
|
|
636
|
+
ValueError: If *name* is not a known preset.
|
|
637
|
+
|
|
638
|
+
Example:
|
|
639
|
+
```python
|
|
640
|
+
clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves"
|
|
641
|
+
bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse
|
|
642
|
+
```
|
|
643
|
+
"""
|
|
644
|
+
|
|
645
|
+
if name not in _WORLD_RHYTHMS:
|
|
646
|
+
known = ", ".join(sorted(_WORLD_RHYTHMS))
|
|
647
|
+
raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.")
|
|
648
|
+
|
|
649
|
+
steps, grid, voice = _WORLD_RHYTHMS[name]
|
|
650
|
+
|
|
651
|
+
return cls.steps(
|
|
652
|
+
steps = list(steps),
|
|
653
|
+
pitches = pitch if pitch is not None else voice,
|
|
654
|
+
velocities = velocities,
|
|
655
|
+
durations = durations,
|
|
656
|
+
probabilities = probabilities,
|
|
657
|
+
step_duration = length / grid,
|
|
658
|
+
length = length,
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
# ── control-gesture constructors (mirror the pattern_midi verbs) ────
|
|
662
|
+
|
|
663
|
+
@classmethod
|
|
664
|
+
def _control_writes (
|
|
665
|
+
cls,
|
|
666
|
+
signal: ControlSignal,
|
|
667
|
+
values: typing.List[float],
|
|
668
|
+
beats: typing.List[float],
|
|
669
|
+
length: typing.Optional[float],
|
|
670
|
+
probabilities: typing.Any = 1.0,
|
|
671
|
+
) -> "Motif":
|
|
672
|
+
|
|
673
|
+
"""Shared core for discrete control writes."""
|
|
674
|
+
|
|
675
|
+
if len(values) != len(beats):
|
|
676
|
+
raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match")
|
|
677
|
+
|
|
678
|
+
probability_list = _expand("probabilities", probabilities, len(values))
|
|
679
|
+
|
|
680
|
+
controls = tuple(
|
|
681
|
+
ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i]))
|
|
682
|
+
for i in range(len(values))
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
return cls(
|
|
686
|
+
events = (),
|
|
687
|
+
length = _computed_length((), controls) if length is None else float(length),
|
|
688
|
+
controls = controls,
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
@classmethod
|
|
692
|
+
def _control_ramp (
|
|
693
|
+
cls,
|
|
694
|
+
signal: ControlSignal,
|
|
695
|
+
start: float,
|
|
696
|
+
end: float,
|
|
697
|
+
beat_start: float,
|
|
698
|
+
beat_end: typing.Optional[float],
|
|
699
|
+
shape: typing.Union[str, "subsequence.easing.EasingFn"],
|
|
700
|
+
length: typing.Optional[float],
|
|
701
|
+
probability: float = 1.0,
|
|
702
|
+
) -> "Motif":
|
|
703
|
+
|
|
704
|
+
"""Shared core for shaped control ramps."""
|
|
705
|
+
|
|
706
|
+
if beat_end is None:
|
|
707
|
+
if length is None:
|
|
708
|
+
raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)")
|
|
709
|
+
beat_end = float(length)
|
|
710
|
+
|
|
711
|
+
if beat_end <= beat_start:
|
|
712
|
+
raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})")
|
|
713
|
+
|
|
714
|
+
controls = (
|
|
715
|
+
ControlEvent(
|
|
716
|
+
beat = float(beat_start),
|
|
717
|
+
signal = signal,
|
|
718
|
+
start = float(start),
|
|
719
|
+
end = float(end),
|
|
720
|
+
span = float(beat_end) - float(beat_start),
|
|
721
|
+
shape = shape,
|
|
722
|
+
probability = probability,
|
|
723
|
+
),
|
|
724
|
+
)
|
|
725
|
+
|
|
726
|
+
return cls(
|
|
727
|
+
events = (),
|
|
728
|
+
length = float(math.ceil(beat_end)) if length is None else float(length),
|
|
729
|
+
controls = controls,
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
@classmethod
|
|
733
|
+
def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
|
|
734
|
+
|
|
735
|
+
"""Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement."""
|
|
736
|
+
|
|
737
|
+
return cls._control_writes(CC(control), list(values), list(beats), length, probabilities)
|
|
738
|
+
|
|
739
|
+
@classmethod
|
|
740
|
+
def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
|
|
741
|
+
|
|
742
|
+
"""A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``."""
|
|
743
|
+
|
|
744
|
+
return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)
|
|
745
|
+
|
|
746
|
+
@classmethod
|
|
747
|
+
def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
|
|
748
|
+
|
|
749
|
+
"""Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``."""
|
|
750
|
+
|
|
751
|
+
return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities)
|
|
752
|
+
|
|
753
|
+
@classmethod
|
|
754
|
+
def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
|
|
755
|
+
|
|
756
|
+
"""Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``."""
|
|
757
|
+
|
|
758
|
+
return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)
|
|
759
|
+
|
|
760
|
+
@classmethod
|
|
761
|
+
def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
|
|
762
|
+
|
|
763
|
+
"""Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``."""
|
|
764
|
+
|
|
765
|
+
return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
|
|
766
|
+
|
|
767
|
+
@classmethod
|
|
768
|
+
def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
|
|
769
|
+
|
|
770
|
+
"""An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``."""
|
|
771
|
+
|
|
772
|
+
return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
|
|
773
|
+
|
|
774
|
+
@classmethod
|
|
775
|
+
def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
|
|
776
|
+
|
|
777
|
+
"""Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``."""
|
|
778
|
+
|
|
779
|
+
return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
|
|
780
|
+
|
|
781
|
+
@classmethod
|
|
782
|
+
def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
|
|
783
|
+
|
|
784
|
+
"""An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``."""
|
|
785
|
+
|
|
786
|
+
return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
|
|
787
|
+
|
|
788
|
+
@classmethod
|
|
789
|
+
def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
|
|
790
|
+
|
|
791
|
+
"""Discrete OSC float sends at beat positions — mirrors ``p.osc()``."""
|
|
792
|
+
|
|
793
|
+
return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
|
|
794
|
+
|
|
795
|
+
@classmethod
|
|
796
|
+
def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
|
|
797
|
+
|
|
798
|
+
"""An OSC float swept over a beat range — mirrors ``p.osc_ramp()``."""
|
|
799
|
+
|
|
800
|
+
return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability)
|
|
801
|
+
|
|
802
|
+
# ── the algebra ─────────────────────────────────────────────────────
|
|
803
|
+
|
|
804
|
+
def then (self, other: "Motif") -> "Motif":
|
|
805
|
+
|
|
806
|
+
"""Closed sequential concat: glue *other* after this motif into ONE longer motif."""
|
|
807
|
+
|
|
808
|
+
if not isinstance(other, Motif):
|
|
809
|
+
raise TypeError(f"then() takes a Motif — got {type(other).__name__}")
|
|
810
|
+
|
|
811
|
+
return Motif(
|
|
812
|
+
events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events),
|
|
813
|
+
length = self.length + other.length,
|
|
814
|
+
controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls),
|
|
815
|
+
# fit is a dial, not content: keep ours, inherit the other's when
|
|
816
|
+
# we have none — join()/tiling folds from empty() (fit=None), and
|
|
817
|
+
# must not silently strip a generated motif's chord-snapping.
|
|
818
|
+
fit = self.fit if self.fit is not None else other.fit,
|
|
819
|
+
)
|
|
820
|
+
|
|
821
|
+
@classmethod
|
|
822
|
+
def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif":
|
|
823
|
+
|
|
824
|
+
"""Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``)."""
|
|
825
|
+
|
|
826
|
+
result = cls.empty()
|
|
827
|
+
|
|
828
|
+
for m in motifs:
|
|
829
|
+
result = result.then(m)
|
|
830
|
+
|
|
831
|
+
return result
|
|
832
|
+
|
|
833
|
+
@classmethod
|
|
834
|
+
def generate (
|
|
835
|
+
cls,
|
|
836
|
+
rhythm: typing.Any,
|
|
837
|
+
length: typing.Optional[float] = None,
|
|
838
|
+
scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None,
|
|
839
|
+
contour: typing.Optional[str] = None,
|
|
840
|
+
end_on: typing.Optional[typing.Union[int, Degree]] = None,
|
|
841
|
+
cadence: typing.Optional[str] = None,
|
|
842
|
+
pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None,
|
|
843
|
+
max_pitches: typing.Optional[int] = None,
|
|
844
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
845
|
+
durations: typing.Any = 0.25,
|
|
846
|
+
seed: typing.Optional[int] = None,
|
|
847
|
+
rng: typing.Optional[random.Random] = None,
|
|
848
|
+
state: typing.Optional[typing.Any] = None,
|
|
849
|
+
nir_strength: float = 0.5,
|
|
850
|
+
pitch_diversity: float = 0.6,
|
|
851
|
+
tessitura_strength: float = 0.6,
|
|
852
|
+
) -> "Motif":
|
|
853
|
+
|
|
854
|
+
"""Generate a melodic motif — rhythm first, pitches walked, a value out.
|
|
855
|
+
|
|
856
|
+
The melody engine emitting a value: you give the **rhythm** (an onset
|
|
857
|
+
list in beats, or another motif whose rhythm to borrow — cross-pattern
|
|
858
|
+
rhythm reuse is shared values); the engine walks pitches over it
|
|
859
|
+
through the soft scoring factors (NIR expectation, contour envelope,
|
|
860
|
+
tessitura regression, diversity), honouring any pins.
|
|
861
|
+
|
|
862
|
+
The result emits **scale degrees** (resolved at placement against the
|
|
863
|
+
composition key/scale), so a generated hook transposes, varies, and
|
|
864
|
+
develops like a hand-written one. ``scale=`` constrains *candidate
|
|
865
|
+
choice only*: a name or interval list masks which pitches the walk
|
|
866
|
+
may use, spelled relative to its best-fit reference (major or minor)
|
|
867
|
+
— bind it in a composition whose scale matches that family and
|
|
868
|
+
resolution is exact. An explicit MIDI pitch pool (a list of note
|
|
869
|
+
numbers) switches to absolute output (the sieve/atonal path).
|
|
870
|
+
|
|
871
|
+
Parameters:
|
|
872
|
+
rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif
|
|
873
|
+
(its onsets are borrowed).
|
|
874
|
+
length: Motif length in beats; defaults to the onsets rounded
|
|
875
|
+
up to a whole 4-beat bar.
|
|
876
|
+
scale: A scale name, an interval list, or an explicit MIDI
|
|
877
|
+
pitch pool. ``None`` = the plain seven degrees.
|
|
878
|
+
contour: Envelope shaping the line's height over its span —
|
|
879
|
+
``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``.
|
|
880
|
+
end_on: Degree the line must end on — sugar for ``pins={-1: ...}``.
|
|
881
|
+
Degree semantics: raises with an explicit MIDI pool (pin the
|
|
882
|
+
exact note instead).
|
|
883
|
+
cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/
|
|
884
|
+
``"fakeout"``) — the line closes on that cadence's melodic
|
|
885
|
+
degree (1 for the full closes and the fakeout, 5 for the
|
|
886
|
+
open half). Sugar for ``end_on=``; conflicts with it, and
|
|
887
|
+
raises with an explicit MIDI pool like ``end_on=``.
|
|
888
|
+
pins: ``{position: degree}`` — 1-based note positions (``-1`` =
|
|
889
|
+
the last, the Python idiom); the engine fills between. With
|
|
890
|
+
an explicit MIDI pool there are no degrees to read, so each
|
|
891
|
+
pin is the exact MIDI note to play (``Degree`` pins raise).
|
|
892
|
+
max_pitches: Cap on distinct pitches (a tight pool is a hook);
|
|
893
|
+
keeps the most central candidates.
|
|
894
|
+
velocities / durations: Scalar or per-note list (the parallel-
|
|
895
|
+
list convention).
|
|
896
|
+
seed: Seed for the walk (required or warned — module-level
|
|
897
|
+
nondeterminism breaks live reload).
|
|
898
|
+
rng: Explicit stream (overrides ``seed``).
|
|
899
|
+
state: A ``MelodicState`` whose dials, scoring factors, and
|
|
900
|
+
melodic history seed the walk. It is **copied** — building
|
|
901
|
+
a value never mutates a module-level live object. The
|
|
902
|
+
candidate pool is not carried over: it is always rebuilt
|
|
903
|
+
from ``scale=`` (pass an explicit pool there instead),
|
|
904
|
+
though the state's key still sets the tonic that the NIR
|
|
905
|
+
closure rule lands on.
|
|
906
|
+
nir_strength / pitch_diversity / tessitura_strength: The walk's
|
|
907
|
+
dials when no ``state`` is given.
|
|
908
|
+
|
|
909
|
+
Example:
|
|
910
|
+
```python
|
|
911
|
+
hook = subsequence.Motif.generate(
|
|
912
|
+
rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic",
|
|
913
|
+
contour="arch", end_on=1, seed=7,
|
|
914
|
+
)
|
|
915
|
+
```
|
|
916
|
+
"""
|
|
917
|
+
|
|
918
|
+
import subsequence.melodic_state
|
|
919
|
+
|
|
920
|
+
onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm]
|
|
921
|
+
|
|
922
|
+
if cadence is not None:
|
|
923
|
+
if end_on is not None:
|
|
924
|
+
raise ValueError("cadence= already names the close degree — it conflicts with end_on=")
|
|
925
|
+
end_on = subsequence.cadences.cadence_formula(cadence).close_degree
|
|
926
|
+
|
|
927
|
+
if not onsets:
|
|
928
|
+
raise ValueError("generate() needs at least one onset — the rhythm comes first")
|
|
929
|
+
if sorted(onsets) != onsets:
|
|
930
|
+
raise ValueError("rhythm onsets must ascend")
|
|
931
|
+
|
|
932
|
+
if length is None:
|
|
933
|
+
length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0)
|
|
934
|
+
if onsets[-1] >= length:
|
|
935
|
+
raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}")
|
|
936
|
+
|
|
937
|
+
if rng is None:
|
|
938
|
+
if seed is None:
|
|
939
|
+
warnings.warn(
|
|
940
|
+
"generate() without seed= is nondeterministic — pass seed= so the "
|
|
941
|
+
"value survives live reload",
|
|
942
|
+
stacklevel = 2,
|
|
943
|
+
)
|
|
944
|
+
rng = random.Random()
|
|
945
|
+
else:
|
|
946
|
+
rng = random.Random(seed)
|
|
947
|
+
|
|
948
|
+
# --- The candidate pool ------------------------------------------------
|
|
949
|
+
absolute_pool: typing.Optional[typing.List[int]] = None
|
|
950
|
+
intervals: typing.List[int]
|
|
951
|
+
|
|
952
|
+
if scale is None:
|
|
953
|
+
intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian"))
|
|
954
|
+
elif isinstance(scale, str):
|
|
955
|
+
intervals = list(subsequence.intervals.scale_pitch_classes(0, scale))
|
|
956
|
+
else:
|
|
957
|
+
values = [int(v) for v in scale]
|
|
958
|
+
if values and (min(values) != 0 or max(values) > 11):
|
|
959
|
+
absolute_pool = sorted(values) # an explicit MIDI pool: absolute output
|
|
960
|
+
intervals = []
|
|
961
|
+
else:
|
|
962
|
+
intervals = sorted(set(values))
|
|
963
|
+
|
|
964
|
+
# Best-fit reference scale for degree spelling: whichever of major/
|
|
965
|
+
# minor contains more of the pool (ties to major). Bound under a
|
|
966
|
+
# matching composition scale, resolution is exact.
|
|
967
|
+
if absolute_pool is None:
|
|
968
|
+
ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian"))
|
|
969
|
+
aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor"))
|
|
970
|
+
reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian"
|
|
971
|
+
reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name))
|
|
972
|
+
|
|
973
|
+
# --- The walking state (copied, never mutated in place) ----------------
|
|
974
|
+
if state is not None:
|
|
975
|
+
walker = state.clone()
|
|
976
|
+
walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a
|
|
977
|
+
# note, so the walker never rests (and never falls
|
|
978
|
+
# back to a stuck repeat) — rests come from the rhythm
|
|
979
|
+
else:
|
|
980
|
+
walker = subsequence.melodic_state.MelodicState(
|
|
981
|
+
nir_strength = nir_strength,
|
|
982
|
+
pitch_diversity = pitch_diversity,
|
|
983
|
+
tessitura_strength = tessitura_strength,
|
|
984
|
+
chord_weight = 0.0, # values have no chord context; fit applies at placement
|
|
985
|
+
)
|
|
986
|
+
|
|
987
|
+
if absolute_pool is not None:
|
|
988
|
+
walker.set_pool(absolute_pool)
|
|
989
|
+
else:
|
|
990
|
+
# Offsets over ~1.5 octaves anchored at 60 — register is decided
|
|
991
|
+
# at placement (root=), so the anchor is arbitrary and erased.
|
|
992
|
+
walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19])
|
|
993
|
+
|
|
994
|
+
if max_pitches is not None:
|
|
995
|
+
if max_pitches < 1:
|
|
996
|
+
raise ValueError("max_pitches must be at least 1")
|
|
997
|
+
pool = sorted(walker._pitch_pool)
|
|
998
|
+
centre = pool[len(pool) // 2]
|
|
999
|
+
walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches]))
|
|
1000
|
+
|
|
1001
|
+
# --- Pins ---------------------------------------------------------------
|
|
1002
|
+
resolved_pins: typing.Dict[int, int] = {}
|
|
1003
|
+
combined = dict(pins or {})
|
|
1004
|
+
|
|
1005
|
+
# cadence=/end_on= name scale DEGREES — meaningless against an explicit
|
|
1006
|
+
# MIDI pool, where they would silently land as raw (sub-audio) note
|
|
1007
|
+
# numbers.
|
|
1008
|
+
if absolute_pool is not None and end_on is not None:
|
|
1009
|
+
raise ValueError(
|
|
1010
|
+
"cadence=/end_on= name scale degrees, but this motif uses an "
|
|
1011
|
+
"explicit MIDI pool — pin the exact closing note instead: "
|
|
1012
|
+
"pins={-1: <midi note>}"
|
|
1013
|
+
)
|
|
1014
|
+
|
|
1015
|
+
if end_on is not None:
|
|
1016
|
+
if -1 in combined or len(onsets) in combined:
|
|
1017
|
+
raise ValueError("end_on conflicts with a pin on the last note — they name the same position")
|
|
1018
|
+
combined[-1] = end_on
|
|
1019
|
+
|
|
1020
|
+
for pin_position, pin_spec in combined.items():
|
|
1021
|
+
if not isinstance(pin_position, int) or isinstance(pin_position, bool):
|
|
1022
|
+
raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}")
|
|
1023
|
+
index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position
|
|
1024
|
+
if not 0 <= index < len(onsets):
|
|
1025
|
+
raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm")
|
|
1026
|
+
if absolute_pool is not None:
|
|
1027
|
+
# A raw int pins the exact MIDI note; a Degree has no meaning
|
|
1028
|
+
# here (the pool defines no scale to read it against).
|
|
1029
|
+
if not isinstance(pin_spec, int) or isinstance(pin_spec, bool):
|
|
1030
|
+
raise ValueError(
|
|
1031
|
+
f"pin {pin_spec!r} is a scale degree, but this motif uses an "
|
|
1032
|
+
"explicit MIDI pool — pin the exact MIDI note instead "
|
|
1033
|
+
"(e.g. pins={-1: 52})"
|
|
1034
|
+
)
|
|
1035
|
+
resolved_pins[index] = int(pin_spec)
|
|
1036
|
+
else:
|
|
1037
|
+
degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec))
|
|
1038
|
+
step_index = (degree.step - 1) % len(reference)
|
|
1039
|
+
carry = (degree.step - 1) // len(reference)
|
|
1040
|
+
resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma
|
|
1041
|
+
|
|
1042
|
+
# --- The walk -----------------------------------------------------------
|
|
1043
|
+
envelopes: typing.Dict[str, typing.Callable[[float], float]] = {
|
|
1044
|
+
"arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos),
|
|
1045
|
+
"valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos),
|
|
1046
|
+
"ascending": lambda pos: 0.1 + 0.85 * pos,
|
|
1047
|
+
"descending": lambda pos: 0.95 - 0.85 * pos,
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if contour is not None and contour not in envelopes:
|
|
1051
|
+
known = ", ".join(sorted(envelopes))
|
|
1052
|
+
raise ValueError(f"unknown contour {contour!r} — expected one of: {known}")
|
|
1053
|
+
|
|
1054
|
+
chosen_pitches: typing.List[int] = []
|
|
1055
|
+
|
|
1056
|
+
for index, onset in enumerate(onsets):
|
|
1057
|
+
|
|
1058
|
+
if index in resolved_pins:
|
|
1059
|
+
pitch = resolved_pins[index]
|
|
1060
|
+
walker.record(pitch) # pins enter the NIR context like chosen notes
|
|
1061
|
+
else:
|
|
1062
|
+
span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0
|
|
1063
|
+
target = envelopes[contour](span_position) if contour is not None else None
|
|
1064
|
+
picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target)
|
|
1065
|
+
pitch = picked if picked is not None else walker._pitch_pool[0]
|
|
1066
|
+
|
|
1067
|
+
chosen_pitches.append(pitch)
|
|
1068
|
+
|
|
1069
|
+
# --- Emission ------------------------------------------------------------
|
|
1070
|
+
velocity_values = _expand("velocities", velocities, len(onsets))
|
|
1071
|
+
duration_values = _expand("durations", durations, len(onsets))
|
|
1072
|
+
|
|
1073
|
+
events = []
|
|
1074
|
+
|
|
1075
|
+
for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)):
|
|
1076
|
+
|
|
1077
|
+
spec: PitchSpec
|
|
1078
|
+
|
|
1079
|
+
if absolute_pool is not None:
|
|
1080
|
+
spec = pitch
|
|
1081
|
+
else:
|
|
1082
|
+
offset = pitch - 60
|
|
1083
|
+
octave, pc = divmod(offset, 12)
|
|
1084
|
+
if pc in reference:
|
|
1085
|
+
spec = Degree(reference.index(pc) + 1, octave = octave)
|
|
1086
|
+
elif (pc + 1) % 12 in reference and pc + 1 <= 11:
|
|
1087
|
+
spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1)
|
|
1088
|
+
else:
|
|
1089
|
+
spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1)
|
|
1090
|
+
|
|
1091
|
+
events.append(MotifEvent(
|
|
1092
|
+
beat = onset,
|
|
1093
|
+
pitch = spec,
|
|
1094
|
+
velocity = velocity_values[index],
|
|
1095
|
+
duration = float(duration_values[index]),
|
|
1096
|
+
))
|
|
1097
|
+
|
|
1098
|
+
return cls(events = tuple(events), length = float(length), fit = 0.7)
|
|
1099
|
+
|
|
1100
|
+
def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif":
|
|
1101
|
+
|
|
1102
|
+
"""
|
|
1103
|
+
Parallel merge (the spelled form of ``&``): event union, length = max.
|
|
1104
|
+
|
|
1105
|
+
No implicit tiling — a short gesture stacked under a long figure
|
|
1106
|
+
plays once. Phrase operands flatten first.
|
|
1107
|
+
"""
|
|
1108
|
+
|
|
1109
|
+
if isinstance(other, Phrase):
|
|
1110
|
+
merged = other.flatten()
|
|
1111
|
+
elif isinstance(other, Motif):
|
|
1112
|
+
merged = other
|
|
1113
|
+
else:
|
|
1114
|
+
raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}")
|
|
1115
|
+
|
|
1116
|
+
return Motif(
|
|
1117
|
+
events = self.events + merged.events,
|
|
1118
|
+
length = max(self.length, merged.length),
|
|
1119
|
+
controls = self.controls + merged.controls,
|
|
1120
|
+
fit = self.fit,
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
def slice (self, start: float, end: float) -> "Motif":
|
|
1124
|
+
|
|
1125
|
+
"""
|
|
1126
|
+
A window onto the motif, on its own authority: events starting outside
|
|
1127
|
+
are dropped; durations and ramp spans truncate at the cut (a truncated
|
|
1128
|
+
ramp ends at its interpolated cut value). Beats shift so the window
|
|
1129
|
+
starts at 0.
|
|
1130
|
+
"""
|
|
1131
|
+
|
|
1132
|
+
if end <= start:
|
|
1133
|
+
raise ValueError(f"slice end ({end}) must be after start ({start})")
|
|
1134
|
+
|
|
1135
|
+
events = tuple(
|
|
1136
|
+
dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat))
|
|
1137
|
+
for e in self.events
|
|
1138
|
+
if start <= e.beat < end
|
|
1139
|
+
)
|
|
1140
|
+
|
|
1141
|
+
controls = []
|
|
1142
|
+
|
|
1143
|
+
for c in self.controls:
|
|
1144
|
+
if not (start <= c.beat < end):
|
|
1145
|
+
continue
|
|
1146
|
+
if c.end is not None and c.beat + c.span > end:
|
|
1147
|
+
kept = end - c.beat
|
|
1148
|
+
controls.append(dataclasses.replace(
|
|
1149
|
+
c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span),
|
|
1150
|
+
))
|
|
1151
|
+
else:
|
|
1152
|
+
controls.append(dataclasses.replace(c, beat=c.beat - start))
|
|
1153
|
+
|
|
1154
|
+
return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit)
|
|
1155
|
+
|
|
1156
|
+
def __add__ (self, other: typing.Any) -> "Phrase":
|
|
1157
|
+
|
|
1158
|
+
"""``a + b`` — sequential: a two-segment Phrase (segmentation preserved)."""
|
|
1159
|
+
|
|
1160
|
+
if isinstance(other, Motif):
|
|
1161
|
+
return Phrase((self, other))
|
|
1162
|
+
|
|
1163
|
+
return NotImplemented
|
|
1164
|
+
|
|
1165
|
+
def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]:
|
|
1166
|
+
|
|
1167
|
+
"""``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty."""
|
|
1168
|
+
|
|
1169
|
+
if not isinstance(count, int):
|
|
1170
|
+
return NotImplemented
|
|
1171
|
+
if count < 0:
|
|
1172
|
+
raise ValueError(f"Repetition count must be non-negative — got {count}")
|
|
1173
|
+
if count == 0:
|
|
1174
|
+
return Motif.empty()
|
|
1175
|
+
if count == 1:
|
|
1176
|
+
return self
|
|
1177
|
+
|
|
1178
|
+
return Phrase((self,) * count)
|
|
1179
|
+
|
|
1180
|
+
__rmul__ = __mul__
|
|
1181
|
+
|
|
1182
|
+
def __and__ (self, other: typing.Any) -> "Motif":
|
|
1183
|
+
|
|
1184
|
+
"""``a & b`` — parallel merge; the spelled form is :meth:`stack`."""
|
|
1185
|
+
|
|
1186
|
+
if isinstance(other, (Motif, Phrase)):
|
|
1187
|
+
return self.stack(other)
|
|
1188
|
+
|
|
1189
|
+
return NotImplemented
|
|
1190
|
+
|
|
1191
|
+
# ── transforms (pure; control gestures ride time, ignore pitch) ─────
|
|
1192
|
+
|
|
1193
|
+
def reverse (self) -> "Motif":
|
|
1194
|
+
|
|
1195
|
+
"""Mirror the figure in time; ramps swap direction (a rising sweep falls)."""
|
|
1196
|
+
|
|
1197
|
+
events = tuple(
|
|
1198
|
+
dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration))
|
|
1199
|
+
for e in self.events
|
|
1200
|
+
)
|
|
1201
|
+
controls = tuple(
|
|
1202
|
+
dataclasses.replace(
|
|
1203
|
+
c,
|
|
1204
|
+
beat = max(0.0, self.length - c.beat - c.span),
|
|
1205
|
+
start = c.start if c.end is None else c.end,
|
|
1206
|
+
end = c.end if c.end is None else c.start,
|
|
1207
|
+
)
|
|
1208
|
+
for c in self.controls
|
|
1209
|
+
)
|
|
1210
|
+
|
|
1211
|
+
return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
|
|
1212
|
+
|
|
1213
|
+
def rotate (self, beats: float) -> "Motif":
|
|
1214
|
+
|
|
1215
|
+
"""Shift every onset by *beats*, wrapping modulo the length (spans ride along)."""
|
|
1216
|
+
|
|
1217
|
+
if self.length == 0:
|
|
1218
|
+
return self
|
|
1219
|
+
|
|
1220
|
+
events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events)
|
|
1221
|
+
controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls)
|
|
1222
|
+
|
|
1223
|
+
return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
|
|
1224
|
+
|
|
1225
|
+
def stretch (self, factor: float) -> "Motif":
|
|
1226
|
+
|
|
1227
|
+
"""Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length."""
|
|
1228
|
+
|
|
1229
|
+
if factor <= 0:
|
|
1230
|
+
raise ValueError(f"Stretch factor must be positive — got {factor}")
|
|
1231
|
+
|
|
1232
|
+
events = tuple(
|
|
1233
|
+
dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor)
|
|
1234
|
+
for e in self.events
|
|
1235
|
+
)
|
|
1236
|
+
controls = tuple(
|
|
1237
|
+
dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor)
|
|
1238
|
+
for c in self.controls
|
|
1239
|
+
)
|
|
1240
|
+
|
|
1241
|
+
return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit)
|
|
1242
|
+
|
|
1243
|
+
def quantize (self, grid: float) -> "Motif":
|
|
1244
|
+
|
|
1245
|
+
"""Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched).
|
|
1246
|
+
|
|
1247
|
+
An onset exactly midway between grid lines snaps LATER (round half
|
|
1248
|
+
up) — every midpoint moves the same way, the predictable behaviour
|
|
1249
|
+
for a musician. (Python's own ``round()`` is half-to-even, which
|
|
1250
|
+
made exact midpoints snap in alternating directions.)
|
|
1251
|
+
"""
|
|
1252
|
+
|
|
1253
|
+
if grid <= 0:
|
|
1254
|
+
raise ValueError(f"Quantize grid must be positive — got {grid}")
|
|
1255
|
+
|
|
1256
|
+
events = tuple(
|
|
1257
|
+
dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid)
|
|
1258
|
+
for e in self.events
|
|
1259
|
+
)
|
|
1260
|
+
|
|
1261
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1262
|
+
|
|
1263
|
+
def accent (self, beat: float, amount: int = 20) -> "Motif":
|
|
1264
|
+
|
|
1265
|
+
"""Add *amount* velocity to every note at the given beat position (0-based beats)."""
|
|
1266
|
+
|
|
1267
|
+
def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]:
|
|
1268
|
+
# Clamp both ends: a negative amount (a de-accent) must not store
|
|
1269
|
+
# a velocity below 1, which MIDI cannot play.
|
|
1270
|
+
if isinstance(velocity, tuple):
|
|
1271
|
+
return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount)))
|
|
1272
|
+
return max(1, min(127, velocity + amount))
|
|
1273
|
+
|
|
1274
|
+
events = tuple(
|
|
1275
|
+
dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e
|
|
1276
|
+
for e in self.events
|
|
1277
|
+
)
|
|
1278
|
+
|
|
1279
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1280
|
+
|
|
1281
|
+
def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif":
|
|
1282
|
+
|
|
1283
|
+
"""Replace every note's velocity (an int, or a ``(low, high)`` random range)."""
|
|
1284
|
+
|
|
1285
|
+
events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events)
|
|
1286
|
+
|
|
1287
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1288
|
+
|
|
1289
|
+
def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec:
|
|
1290
|
+
|
|
1291
|
+
"""One varied pitch: a small melodic nudge that always changes the note.
|
|
1292
|
+
|
|
1293
|
+
Degrees move by scale steps, MIDI ints by semitones, chord tones by
|
|
1294
|
+
index; an Approach's target is nudged. Drum names raise — a varied
|
|
1295
|
+
drum is a different instrument, not a variation.
|
|
1296
|
+
"""
|
|
1297
|
+
|
|
1298
|
+
if isinstance(pitch, Degree):
|
|
1299
|
+
steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1]
|
|
1300
|
+
return dataclasses.replace(pitch, step = rng.choice(steps))
|
|
1301
|
+
if isinstance(pitch, ChordTone):
|
|
1302
|
+
indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1]
|
|
1303
|
+
return ChordTone(rng.choice(indices), octave = pitch.octave)
|
|
1304
|
+
if isinstance(pitch, Approach):
|
|
1305
|
+
nudged = self._nudged_pitch(pitch.target, rng)
|
|
1306
|
+
if not isinstance(nudged, (int, Degree, ChordTone)):
|
|
1307
|
+
raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content")
|
|
1308
|
+
return Approach(nudged)
|
|
1309
|
+
if isinstance(pitch, int):
|
|
1310
|
+
return pitch + rng.choice((-2, -1, 1, 2))
|
|
1311
|
+
|
|
1312
|
+
raise TypeError(
|
|
1313
|
+
f"vary() moves pitches — {type(pitch).__name__} content cannot vary "
|
|
1314
|
+
"(a varied drum is a different instrument)"
|
|
1315
|
+
)
|
|
1316
|
+
|
|
1317
|
+
def vary (
|
|
1318
|
+
self,
|
|
1319
|
+
notes: int = 1,
|
|
1320
|
+
position: str = "end",
|
|
1321
|
+
seed: typing.Optional[int] = None,
|
|
1322
|
+
rng: typing.Optional[random.Random] = None,
|
|
1323
|
+
keep_contour: bool = False,
|
|
1324
|
+
) -> "Motif":
|
|
1325
|
+
|
|
1326
|
+
"""Replace a few pitches, preserving the rhythm — the smallest variation.
|
|
1327
|
+
|
|
1328
|
+
Rhythm, velocities, durations, rests, and control gestures are
|
|
1329
|
+
untouched; only the chosen notes' pitches move (by a small melodic
|
|
1330
|
+
nudge: scale steps for degrees, semitones for MIDI ints).
|
|
1331
|
+
|
|
1332
|
+
Parameters:
|
|
1333
|
+
notes: How many pitched notes to vary (clamped to what exists).
|
|
1334
|
+
position: Which notes — ``"end"`` (the tail, the default),
|
|
1335
|
+
``"start"``, or ``"anywhere"`` (drawn from the stream).
|
|
1336
|
+
seed: Seed for the variation. A standalone vary without a seed
|
|
1337
|
+
warns — module-level nondeterminism breaks live reload.
|
|
1338
|
+
rng: An explicit random stream (overrides ``seed``; used by
|
|
1339
|
+
recipe machinery).
|
|
1340
|
+
keep_contour: When True, the variation preserves the line's
|
|
1341
|
+
CSEG — every varied note keeps its rank relations with
|
|
1342
|
+
every other note, so the melodic shape is identical (the
|
|
1343
|
+
motif-identity guard). Where no nudge can preserve the
|
|
1344
|
+
contour, that note stays unchanged — shape wins over
|
|
1345
|
+
motion.
|
|
1346
|
+
|
|
1347
|
+
Example:
|
|
1348
|
+
```python
|
|
1349
|
+
answer = call.vary(notes=1, seed=4) # same figure, new tail note
|
|
1350
|
+
```
|
|
1351
|
+
"""
|
|
1352
|
+
|
|
1353
|
+
if notes < 0:
|
|
1354
|
+
raise ValueError(f"notes must be at least 0, got {notes}")
|
|
1355
|
+
if position not in ("end", "start", "anywhere"):
|
|
1356
|
+
raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}')
|
|
1357
|
+
|
|
1358
|
+
if rng is None:
|
|
1359
|
+
if seed is None:
|
|
1360
|
+
warnings.warn(
|
|
1361
|
+
"vary() without seed= is nondeterministic — pass seed= so the "
|
|
1362
|
+
"value survives live reload",
|
|
1363
|
+
stacklevel = 2,
|
|
1364
|
+
)
|
|
1365
|
+
rng = random.Random()
|
|
1366
|
+
else:
|
|
1367
|
+
rng = random.Random(seed)
|
|
1368
|
+
|
|
1369
|
+
pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
|
|
1370
|
+
count = min(notes, len(pitched_indices))
|
|
1371
|
+
|
|
1372
|
+
if count == 0:
|
|
1373
|
+
return self
|
|
1374
|
+
|
|
1375
|
+
if position == "end":
|
|
1376
|
+
chosen = pitched_indices[-count:]
|
|
1377
|
+
elif position == "start":
|
|
1378
|
+
chosen = pitched_indices[:count]
|
|
1379
|
+
else:
|
|
1380
|
+
chosen = sorted(rng.sample(pitched_indices, count))
|
|
1381
|
+
|
|
1382
|
+
events = list(self.events)
|
|
1383
|
+
|
|
1384
|
+
for index in chosen:
|
|
1385
|
+
if keep_contour:
|
|
1386
|
+
replacement = self._contour_safe_nudge(events, index, pitched_indices, rng)
|
|
1387
|
+
if replacement is not None:
|
|
1388
|
+
events[index] = dataclasses.replace(events[index], pitch = replacement)
|
|
1389
|
+
else:
|
|
1390
|
+
events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng))
|
|
1391
|
+
|
|
1392
|
+
return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
|
|
1393
|
+
|
|
1394
|
+
@staticmethod
|
|
1395
|
+
def _rank_value (pitch: PitchSpec) -> float:
|
|
1396
|
+
|
|
1397
|
+
"""A comparable height for contour ranking (uniform content only)."""
|
|
1398
|
+
|
|
1399
|
+
if isinstance(pitch, Degree):
|
|
1400
|
+
return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma
|
|
1401
|
+
if isinstance(pitch, ChordTone):
|
|
1402
|
+
return pitch.octave * 4 + pitch.index
|
|
1403
|
+
if isinstance(pitch, int):
|
|
1404
|
+
return float(pitch)
|
|
1405
|
+
|
|
1406
|
+
raise TypeError(f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height")
|
|
1407
|
+
|
|
1408
|
+
def _contour_safe_nudge (
|
|
1409
|
+
self,
|
|
1410
|
+
events: typing.List[MotifEvent],
|
|
1411
|
+
index: int,
|
|
1412
|
+
pitched_indices: typing.List[int],
|
|
1413
|
+
rng: random.Random,
|
|
1414
|
+
) -> typing.Optional[PitchSpec]:
|
|
1415
|
+
|
|
1416
|
+
"""A nudge for events[index] that preserves its CSEG rank relations.
|
|
1417
|
+
|
|
1418
|
+
Candidates are the usual small nudges, filtered to those keeping the
|
|
1419
|
+
note's above/below/equal relation to every other pitched note. One
|
|
1420
|
+
rng draw happens regardless (stream stability); ``None`` means no
|
|
1421
|
+
candidate preserves the shape — leave the note alone.
|
|
1422
|
+
"""
|
|
1423
|
+
|
|
1424
|
+
pitch = events[index].pitch
|
|
1425
|
+
|
|
1426
|
+
if isinstance(pitch, Degree):
|
|
1427
|
+
candidates: typing.List[PitchSpec] = [
|
|
1428
|
+
dataclasses.replace(pitch, step = pitch.step + delta)
|
|
1429
|
+
for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1
|
|
1430
|
+
]
|
|
1431
|
+
elif isinstance(pitch, int):
|
|
1432
|
+
candidates = [pitch + delta for delta in (-2, -1, 1, 2)]
|
|
1433
|
+
else:
|
|
1434
|
+
raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content")
|
|
1435
|
+
|
|
1436
|
+
original = self._rank_value(pitch)
|
|
1437
|
+
others = [
|
|
1438
|
+
(self._rank_value(events[other].pitch), other)
|
|
1439
|
+
for other in pitched_indices if other != index
|
|
1440
|
+
]
|
|
1441
|
+
|
|
1442
|
+
def preserves (candidate: PitchSpec) -> bool:
|
|
1443
|
+
height = self._rank_value(candidate)
|
|
1444
|
+
for other_height, _ in others:
|
|
1445
|
+
before = (original > other_height) - (original < other_height)
|
|
1446
|
+
after = (height > other_height) - (height < other_height)
|
|
1447
|
+
if before != after:
|
|
1448
|
+
return False
|
|
1449
|
+
return True
|
|
1450
|
+
|
|
1451
|
+
surviving = [candidate for candidate in candidates if preserves(candidate)]
|
|
1452
|
+
|
|
1453
|
+
# One draw either way, so adding keep_contour never shifts the stream
|
|
1454
|
+
# consumed by the notes around this one.
|
|
1455
|
+
draw = rng.random()
|
|
1456
|
+
|
|
1457
|
+
if not surviving:
|
|
1458
|
+
return None
|
|
1459
|
+
|
|
1460
|
+
return surviving[int(draw * len(surviving)) % len(surviving)]
|
|
1461
|
+
|
|
1462
|
+
def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif":
|
|
1463
|
+
|
|
1464
|
+
"""Call → response: re-aim the tail to a stable degree.
|
|
1465
|
+
|
|
1466
|
+
The classic consequent move — the figure repeats but its last pitched
|
|
1467
|
+
note lands home (degree 1 by default; pass ``to=5`` for a half-close,
|
|
1468
|
+
or a full ``Degree`` for register control). Everything else —
|
|
1469
|
+
rhythm, the other pitches, velocities, controls — is untouched.
|
|
1470
|
+
|
|
1471
|
+
Degree content only: absolute MIDI has no degrees to re-aim (build
|
|
1472
|
+
the call with ``motif([...])``), and drums raise.
|
|
1473
|
+
"""
|
|
1474
|
+
|
|
1475
|
+
target = to if isinstance(to, Degree) else Degree(int(to))
|
|
1476
|
+
|
|
1477
|
+
pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
|
|
1478
|
+
|
|
1479
|
+
if not pitched_indices:
|
|
1480
|
+
return self
|
|
1481
|
+
|
|
1482
|
+
last = self.events[pitched_indices[-1]]
|
|
1483
|
+
|
|
1484
|
+
if not isinstance(last.pitch, Degree):
|
|
1485
|
+
raise TypeError(
|
|
1486
|
+
f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} "
|
|
1487
|
+
"content (build the call with motif([...]) for degree content)"
|
|
1488
|
+
)
|
|
1489
|
+
|
|
1490
|
+
if isinstance(to, int):
|
|
1491
|
+
# Keep the call's register: only the step is re-aimed.
|
|
1492
|
+
target = dataclasses.replace(last.pitch, step = int(to), chroma = 0)
|
|
1493
|
+
|
|
1494
|
+
events = list(self.events)
|
|
1495
|
+
events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target)
|
|
1496
|
+
|
|
1497
|
+
return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
|
|
1498
|
+
|
|
1499
|
+
def pitched (self, spec: PitchSpec) -> "Motif":
|
|
1500
|
+
|
|
1501
|
+
"""
|
|
1502
|
+
Replace every pitch with one spec — a kick rhythm becomes a bass line.
|
|
1503
|
+
|
|
1504
|
+
``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord
|
|
1505
|
+
tones; any other string is a drum name; ints are MIDI; Degree /
|
|
1506
|
+
ChordTone / Approach pass through.
|
|
1507
|
+
"""
|
|
1508
|
+
|
|
1509
|
+
if isinstance(spec, str) and spec in _CHORD_TONE_NAMES:
|
|
1510
|
+
spec = ChordTone(spec)
|
|
1511
|
+
|
|
1512
|
+
events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events)
|
|
1513
|
+
|
|
1514
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1515
|
+
|
|
1516
|
+
def rhythm (self) -> "Motif":
|
|
1517
|
+
|
|
1518
|
+
"""
|
|
1519
|
+
Strip pitches (and control gestures): a reusable rhythmic skeleton.
|
|
1520
|
+
|
|
1521
|
+
Timing, velocities, durations, and probabilities survive; re-pitch
|
|
1522
|
+
with :meth:`pitched` before placement (placing a skeleton raises).
|
|
1523
|
+
"""
|
|
1524
|
+
|
|
1525
|
+
events = tuple(dataclasses.replace(e, pitch=None) for e in self.events)
|
|
1526
|
+
|
|
1527
|
+
return Motif(events=events, length=self.length)
|
|
1528
|
+
|
|
1529
|
+
def onsets (self) -> typing.List[float]:
|
|
1530
|
+
|
|
1531
|
+
"""The note onset beats, in order — ready for rhythm-first generation."""
|
|
1532
|
+
|
|
1533
|
+
return [e.beat for e in self.events]
|
|
1534
|
+
|
|
1535
|
+
def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif":
|
|
1536
|
+
|
|
1537
|
+
"""
|
|
1538
|
+
Transpose pitched content; the keyword names the unit.
|
|
1539
|
+
|
|
1540
|
+
``steps=`` moves scale degrees diatonically (the sequencing move) and
|
|
1541
|
+
raises on absolute-MIDI or drum content; ``semitones=`` is the
|
|
1542
|
+
literal chromatic form for MIDI ints and degrees. Drum motifs raise
|
|
1543
|
+
on both — a transposed drum name is a different instrument, not a
|
|
1544
|
+
transposition.
|
|
1545
|
+
"""
|
|
1546
|
+
|
|
1547
|
+
if (steps is None) == (semitones is None):
|
|
1548
|
+
raise ValueError("transpose() takes exactly one of steps= or semitones=")
|
|
1549
|
+
|
|
1550
|
+
def move (pitch: PitchSpec) -> PitchSpec:
|
|
1551
|
+
|
|
1552
|
+
if pitch is None:
|
|
1553
|
+
return None
|
|
1554
|
+
|
|
1555
|
+
if isinstance(pitch, Approach):
|
|
1556
|
+
moved = move(pitch.target)
|
|
1557
|
+
if not isinstance(moved, (int, Degree, ChordTone)):
|
|
1558
|
+
raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content")
|
|
1559
|
+
return Approach(moved)
|
|
1560
|
+
|
|
1561
|
+
if steps is not None:
|
|
1562
|
+
if isinstance(pitch, Degree):
|
|
1563
|
+
return dataclasses.replace(pitch, step=pitch.step + steps)
|
|
1564
|
+
raise TypeError(
|
|
1565
|
+
f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content "
|
|
1566
|
+
f"has no degrees (use semitones= for MIDI ints)"
|
|
1567
|
+
)
|
|
1568
|
+
|
|
1569
|
+
assert semitones is not None # exactly one of steps/semitones is set (validated above)
|
|
1570
|
+
|
|
1571
|
+
if isinstance(pitch, int):
|
|
1572
|
+
return pitch + semitones
|
|
1573
|
+
if isinstance(pitch, Degree):
|
|
1574
|
+
return dataclasses.replace(pitch, chroma=pitch.chroma + semitones)
|
|
1575
|
+
raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content")
|
|
1576
|
+
|
|
1577
|
+
events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events)
|
|
1578
|
+
|
|
1579
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1580
|
+
|
|
1581
|
+
def invert (self, pivot: typing.Optional[int] = None) -> "Motif":
|
|
1582
|
+
|
|
1583
|
+
"""
|
|
1584
|
+
Mirror pitches around a pivot: MIDI content around a MIDI pivot,
|
|
1585
|
+
degree content around a degree pivot (default: the first note's pitch).
|
|
1586
|
+
Drum motifs raise.
|
|
1587
|
+
"""
|
|
1588
|
+
|
|
1589
|
+
pitched_events = [e for e in self.events if e.pitch is not None]
|
|
1590
|
+
|
|
1591
|
+
if not pitched_events:
|
|
1592
|
+
return self
|
|
1593
|
+
|
|
1594
|
+
first = pitched_events[0].pitch
|
|
1595
|
+
|
|
1596
|
+
if pivot is None:
|
|
1597
|
+
if isinstance(first, int):
|
|
1598
|
+
pivot = first
|
|
1599
|
+
elif isinstance(first, Degree):
|
|
1600
|
+
pivot = first.step
|
|
1601
|
+
else:
|
|
1602
|
+
raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content")
|
|
1603
|
+
|
|
1604
|
+
def mirror (pitch: PitchSpec) -> PitchSpec:
|
|
1605
|
+
|
|
1606
|
+
if pitch is None:
|
|
1607
|
+
return None
|
|
1608
|
+
if isinstance(pitch, int):
|
|
1609
|
+
return 2 * pivot - pitch
|
|
1610
|
+
if isinstance(pitch, Degree):
|
|
1611
|
+
mirrored = 2 * pivot - pitch.step
|
|
1612
|
+
if mirrored < 1:
|
|
1613
|
+
raise ValueError(
|
|
1614
|
+
f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — "
|
|
1615
|
+
f"raise the pivot or use Degree octaves"
|
|
1616
|
+
)
|
|
1617
|
+
# Reflection around the pivot (read at octave 0) is an isometry, so a
|
|
1618
|
+
# note's register flips too: a degree an octave above the pivot lands an
|
|
1619
|
+
# octave below it. Negating octave needs no scale length and leaves
|
|
1620
|
+
# octave-0 content unchanged.
|
|
1621
|
+
return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma)
|
|
1622
|
+
raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content")
|
|
1623
|
+
|
|
1624
|
+
events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events)
|
|
1625
|
+
|
|
1626
|
+
return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
|
|
1627
|
+
|
|
1628
|
+
# ── description ─────────────────────────────────────────────────────
|
|
1629
|
+
|
|
1630
|
+
def describe (self) -> str:
|
|
1631
|
+
|
|
1632
|
+
"""A readable one-line summary: length, notes (pitch@beat), and control gestures."""
|
|
1633
|
+
|
|
1634
|
+
notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events)
|
|
1635
|
+
parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"]
|
|
1636
|
+
|
|
1637
|
+
if self.controls:
|
|
1638
|
+
gestures = ", ".join(_control_label(c) for c in self.controls)
|
|
1639
|
+
parts.append(f"controls [{gestures}]")
|
|
1640
|
+
|
|
1641
|
+
return " ".join(parts)
|
|
1642
|
+
|
|
1643
|
+
def __str__ (self) -> str:
|
|
1644
|
+
|
|
1645
|
+
"""Printable form (same as :meth:`describe`)."""
|
|
1646
|
+
|
|
1647
|
+
return self.describe()
|
|
1648
|
+
|
|
1649
|
+
|
|
1650
|
+
def _pitch_label (pitch: PitchSpec) -> str:
|
|
1651
|
+
|
|
1652
|
+
"""Compact label for a pitch spec in describe() output."""
|
|
1653
|
+
|
|
1654
|
+
if pitch is None:
|
|
1655
|
+
return "·"
|
|
1656
|
+
if isinstance(pitch, Degree):
|
|
1657
|
+
marks = ("+" * pitch.octave if pitch.octave > 0 else "-" * -pitch.octave)
|
|
1658
|
+
chroma = (f"#{pitch.chroma}" if pitch.chroma > 0 else f"b{-pitch.chroma}" if pitch.chroma < 0 else "")
|
|
1659
|
+
return f"^{pitch.step}{marks}{chroma}"
|
|
1660
|
+
if isinstance(pitch, ChordTone):
|
|
1661
|
+
return f"tone{pitch.index}"
|
|
1662
|
+
if isinstance(pitch, Approach):
|
|
1663
|
+
return f">{_pitch_label(pitch.target)}"
|
|
1664
|
+
|
|
1665
|
+
return str(pitch)
|
|
1666
|
+
|
|
1667
|
+
|
|
1668
|
+
def _control_label (c: ControlEvent) -> str:
|
|
1669
|
+
|
|
1670
|
+
"""Compact label for a control event in describe() output."""
|
|
1671
|
+
|
|
1672
|
+
if isinstance(c.signal, CC):
|
|
1673
|
+
name = f"CC{c.signal.control}" if isinstance(c.signal.control, int) else f"CC:{c.signal.control}"
|
|
1674
|
+
elif isinstance(c.signal, PitchBend):
|
|
1675
|
+
name = "bend"
|
|
1676
|
+
elif isinstance(c.signal, NRPN):
|
|
1677
|
+
name = f"NRPN{c.signal.parameter}"
|
|
1678
|
+
elif isinstance(c.signal, RPN):
|
|
1679
|
+
name = f"RPN{c.signal.parameter}"
|
|
1680
|
+
else:
|
|
1681
|
+
name = c.signal.address
|
|
1682
|
+
|
|
1683
|
+
if c.end is None:
|
|
1684
|
+
return f"{name}={c.start:g}@{c.beat:g}"
|
|
1685
|
+
|
|
1686
|
+
return f"{name} {c.start:g}→{c.end:g} over {c.beat:g}–{c.beat + c.span:g}"
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
# ── Phrase ──────────────────────────────────────────────────────────────────
|
|
1690
|
+
|
|
1691
|
+
@dataclasses.dataclass(frozen=True)
|
|
1692
|
+
class _PhraseRecipe:
|
|
1693
|
+
|
|
1694
|
+
"""Provenance of a generated phrase — what reroll() regenerates from.
|
|
1695
|
+
|
|
1696
|
+
Generated values carry their recipe (the generator spec and seed) so
|
|
1697
|
+
per-region regeneration is possible; a hand-written or transformed
|
|
1698
|
+
phrase has none, and rerolling it raises loudly.
|
|
1699
|
+
|
|
1700
|
+
Attributes:
|
|
1701
|
+
source: The motif the phrase was developed from.
|
|
1702
|
+
plan: The unit-label tuple, or the recipe name.
|
|
1703
|
+
bars: The phrase length in bars.
|
|
1704
|
+
seed: The development seed (None = the unseeded warning path).
|
|
1705
|
+
beats_per_bar: The bar size the plan was spread against.
|
|
1706
|
+
cadence: The cadence name the phrase closes on (``sentence()``/
|
|
1707
|
+
``period()`` record it; ``develop()`` leaves it ``None``).
|
|
1708
|
+
"""
|
|
1709
|
+
|
|
1710
|
+
source: Motif
|
|
1711
|
+
plan: typing.Union[typing.Tuple[str, ...], str]
|
|
1712
|
+
bars: int
|
|
1713
|
+
seed: typing.Optional[int]
|
|
1714
|
+
beats_per_bar: float = 4.0
|
|
1715
|
+
cadence: typing.Optional[str] = None
|
|
1716
|
+
|
|
1717
|
+
|
|
1718
|
+
def _contrast_unit (source: Motif, rng: random.Random) -> Motif:
|
|
1719
|
+
|
|
1720
|
+
"""A generated contrast unit: the source's rhythm, freshly re-pitched.
|
|
1721
|
+
|
|
1722
|
+
Roughly half the pitched notes move (small melodic nudges), so the unit
|
|
1723
|
+
is recognisably related but melodically new. The richer rhythm-first
|
|
1724
|
+
generator arrives with the melody engine stage.
|
|
1725
|
+
"""
|
|
1726
|
+
|
|
1727
|
+
pitched = sum(1 for event in source.events if event.pitch is not None)
|
|
1728
|
+
|
|
1729
|
+
return source.vary(notes = max(1, pitched // 2), position = "anywhere", rng = rng)
|
|
1730
|
+
|
|
1731
|
+
|
|
1732
|
+
def _call_response_units (call: Motif, seed: typing.Optional[int]) -> typing.List[Motif]:
|
|
1733
|
+
|
|
1734
|
+
"""The call_response recipe: call, answer, call, varied answer."""
|
|
1735
|
+
|
|
1736
|
+
response = call.answer()
|
|
1737
|
+
varied = response.vary(notes = 1, position = "end", rng = random.Random(f"{seed}:cr:vary"))
|
|
1738
|
+
|
|
1739
|
+
return [call, response, call, varied]
|
|
1740
|
+
|
|
1741
|
+
|
|
1742
|
+
def _tile_source (motif: Motif, bars: int, unit_count: int, beats_per_bar: float) -> Motif:
|
|
1743
|
+
|
|
1744
|
+
"""Validate the bars/unit arithmetic and tile the motif up to one unit.
|
|
1745
|
+
|
|
1746
|
+
A 1-bar hook in 2-bar units repeats — the unit is the tile, and
|
|
1747
|
+
answer()/vary() act on the whole tile (its tail is the unit's tail).
|
|
1748
|
+
"""
|
|
1749
|
+
|
|
1750
|
+
if bars % unit_count != 0:
|
|
1751
|
+
raise ValueError(
|
|
1752
|
+
f"bars={bars} does not divide evenly across {unit_count} plan units — "
|
|
1753
|
+
"each unit must fill a whole number of bars"
|
|
1754
|
+
)
|
|
1755
|
+
|
|
1756
|
+
unit_beats = bars * beats_per_bar / unit_count
|
|
1757
|
+
|
|
1758
|
+
if motif.length <= 0:
|
|
1759
|
+
raise ValueError("cannot develop an empty motif")
|
|
1760
|
+
|
|
1761
|
+
tiling = unit_beats / motif.length
|
|
1762
|
+
|
|
1763
|
+
if abs(tiling - round(tiling)) > 1e-9 or round(tiling) < 1:
|
|
1764
|
+
raise ValueError(
|
|
1765
|
+
f"the motif is {motif.length:g} beats but each of the {unit_count} plan units "
|
|
1766
|
+
f"spans {unit_beats:g} beats ({bars} bars / {unit_count} units) — units must be "
|
|
1767
|
+
"a whole tiling of the motif (adjust bars, the plan, or the motif's length)"
|
|
1768
|
+
)
|
|
1769
|
+
|
|
1770
|
+
return motif if round(tiling) == 1 else Motif.join([motif] * int(round(tiling)))
|
|
1771
|
+
|
|
1772
|
+
|
|
1773
|
+
# The curated recipe table — names reserved for plans whose semantics exceed
|
|
1774
|
+
# a label skeleton. Each entry is (unit_count, builder); the builder takes
|
|
1775
|
+
# (source_motif, seed) and returns exactly unit_count units.
|
|
1776
|
+
_PHRASE_RECIPES: typing.Dict[str, typing.Tuple[int, typing.Callable[[Motif, typing.Optional[int]], typing.List[Motif]]]] = {
|
|
1777
|
+
"call_response": (4, _call_response_units),
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
|
|
1781
|
+
@dataclasses.dataclass(frozen=True)
|
|
1782
|
+
class Phrase:
|
|
1783
|
+
|
|
1784
|
+
"""
|
|
1785
|
+
A sequence of Motifs with segmentation preserved.
|
|
1786
|
+
|
|
1787
|
+
Segmentation is the unit of editing — it is what development and
|
|
1788
|
+
per-region regeneration operate on. ``flatten()`` erases it into one
|
|
1789
|
+
long Motif. Length is the sum of segment lengths.
|
|
1790
|
+
|
|
1791
|
+
A phrase made by :meth:`develop` carries its recipe, so
|
|
1792
|
+
:meth:`reroll` can regenerate a region; transforms and hand edits
|
|
1793
|
+
return recipe-less phrases (their notes no longer come from the
|
|
1794
|
+
recipe, so there is nothing honest to regenerate from).
|
|
1795
|
+
"""
|
|
1796
|
+
|
|
1797
|
+
segments: typing.Tuple[Motif, ...]
|
|
1798
|
+
recipe: typing.Optional[_PhraseRecipe]
|
|
1799
|
+
|
|
1800
|
+
def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None:
|
|
1801
|
+
|
|
1802
|
+
"""Coerce any iterable of Motifs."""
|
|
1803
|
+
|
|
1804
|
+
segments = tuple(segments)
|
|
1805
|
+
|
|
1806
|
+
for segment in segments:
|
|
1807
|
+
if not isinstance(segment, Motif):
|
|
1808
|
+
raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}")
|
|
1809
|
+
|
|
1810
|
+
object.__setattr__(self, "segments", segments)
|
|
1811
|
+
object.__setattr__(self, "recipe", recipe)
|
|
1812
|
+
|
|
1813
|
+
@property
|
|
1814
|
+
def length (self) -> float:
|
|
1815
|
+
|
|
1816
|
+
"""Total length in beats (sum of segment lengths)."""
|
|
1817
|
+
|
|
1818
|
+
return sum(segment.length for segment in self.segments)
|
|
1819
|
+
|
|
1820
|
+
@classmethod
|
|
1821
|
+
def develop (
|
|
1822
|
+
cls,
|
|
1823
|
+
motif: Motif,
|
|
1824
|
+
bars: int = 8,
|
|
1825
|
+
plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None,
|
|
1826
|
+
seed: typing.Optional[int] = None,
|
|
1827
|
+
beats_per_bar: float = 4.0,
|
|
1828
|
+
) -> "Phrase":
|
|
1829
|
+
|
|
1830
|
+
"""Grow a motif into a phrase by a plan — the phrase generator.
|
|
1831
|
+
|
|
1832
|
+
``plan`` follows the standard form. The literal form is a **list of
|
|
1833
|
+
unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently
|
|
1834
|
+
``["a"] * 3 + ["b"]``: the first label is the given motif, each new
|
|
1835
|
+
label is a generated contrast unit (the source's rhythm, freshly
|
|
1836
|
+
re-pitched), a repeated label is a restatement, and *bars* spreads
|
|
1837
|
+
evenly across the units. A bare string is a **recipe name** from
|
|
1838
|
+
the curated table — ``plan="call_response"`` (call, answer, call,
|
|
1839
|
+
varied answer) — reserved for plans whose semantics exceed a label
|
|
1840
|
+
skeleton. A letter string is not a plan: a sequence of labels is a
|
|
1841
|
+
sequence, so it is a list.
|
|
1842
|
+
|
|
1843
|
+
The result carries its recipe, so :meth:`reroll` can regenerate a
|
|
1844
|
+
region later.
|
|
1845
|
+
|
|
1846
|
+
Parameters:
|
|
1847
|
+
motif: The source unit (its length must be ``bars / len(units)``
|
|
1848
|
+
bars — the plan's units tile the phrase exactly).
|
|
1849
|
+
bars: Phrase length in bars (must divide evenly by the unit
|
|
1850
|
+
count).
|
|
1851
|
+
plan: A list of unit labels, or a recipe name.
|
|
1852
|
+
seed: Seed for the generated units. Without one, develop()
|
|
1853
|
+
warns — module-level nondeterminism breaks live reload.
|
|
1854
|
+
beats_per_bar: Bar size in beats (the value is context-free;
|
|
1855
|
+
4 is the common-time default).
|
|
1856
|
+
|
|
1857
|
+
Example:
|
|
1858
|
+
```python
|
|
1859
|
+
call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
|
|
1860
|
+
lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11)
|
|
1861
|
+
```
|
|
1862
|
+
"""
|
|
1863
|
+
|
|
1864
|
+
if plan is None:
|
|
1865
|
+
raise ValueError(
|
|
1866
|
+
'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) '
|
|
1867
|
+
'or a recipe name (plan="call_response")'
|
|
1868
|
+
)
|
|
1869
|
+
|
|
1870
|
+
if seed is None:
|
|
1871
|
+
warnings.warn(
|
|
1872
|
+
"develop() without seed= is nondeterministic — pass seed= so the "
|
|
1873
|
+
"value survives live reload",
|
|
1874
|
+
stacklevel = 2,
|
|
1875
|
+
)
|
|
1876
|
+
|
|
1877
|
+
# How many units the plan asks for — known before any unit is built,
|
|
1878
|
+
# so a short motif can tile up to the unit size first.
|
|
1879
|
+
if isinstance(plan, str):
|
|
1880
|
+
if plan not in _PHRASE_RECIPES:
|
|
1881
|
+
known = ", ".join(sorted(_PHRASE_RECIPES))
|
|
1882
|
+
hint = ""
|
|
1883
|
+
if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan):
|
|
1884
|
+
spelled = ", ".join(repr(c) for c in plan)
|
|
1885
|
+
hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]."
|
|
1886
|
+
raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}")
|
|
1887
|
+
unit_count = _PHRASE_RECIPES[plan][0]
|
|
1888
|
+
else:
|
|
1889
|
+
labels = list(plan)
|
|
1890
|
+
if not labels or not all(isinstance(label, str) and label for label in labels):
|
|
1891
|
+
raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']")
|
|
1892
|
+
unit_count = len(labels)
|
|
1893
|
+
|
|
1894
|
+
source = _tile_source(motif, bars, unit_count, beats_per_bar)
|
|
1895
|
+
|
|
1896
|
+
# An unseeded call draws a fresh salt so repeated calls genuinely
|
|
1897
|
+
# differ, as the warning above promises — interpolating None gave the
|
|
1898
|
+
# FIXED seed "None:..." and silently returned the same phrase every
|
|
1899
|
+
# time.
|
|
1900
|
+
salt = seed if seed is not None else random.randrange(2 ** 32)
|
|
1901
|
+
|
|
1902
|
+
if isinstance(plan, str):
|
|
1903
|
+
units = _PHRASE_RECIPES[plan][1](source, salt)
|
|
1904
|
+
stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan
|
|
1905
|
+
else:
|
|
1906
|
+
generated: typing.Dict[str, Motif] = {labels[0]: source}
|
|
1907
|
+
for label in labels:
|
|
1908
|
+
if label not in generated:
|
|
1909
|
+
generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}"))
|
|
1910
|
+
units = [generated[label] for label in labels]
|
|
1911
|
+
stored_plan = tuple(labels)
|
|
1912
|
+
|
|
1913
|
+
return cls(units, recipe = _PhraseRecipe(
|
|
1914
|
+
source = motif,
|
|
1915
|
+
plan = stored_plan,
|
|
1916
|
+
bars = bars,
|
|
1917
|
+
seed = seed,
|
|
1918
|
+
beats_per_bar = beats_per_bar,
|
|
1919
|
+
))
|
|
1920
|
+
|
|
1921
|
+
def reroll (
|
|
1922
|
+
self,
|
|
1923
|
+
bar: typing.Optional[int] = None,
|
|
1924
|
+
bars: typing.Optional[typing.Sequence[int]] = None,
|
|
1925
|
+
seed: typing.Optional[int] = None,
|
|
1926
|
+
) -> "Phrase":
|
|
1927
|
+
|
|
1928
|
+
"""Regenerate only the named bars — rhythm and boundary pitches kept.
|
|
1929
|
+
|
|
1930
|
+
Within each named bar, the first and last pitched notes stay (the
|
|
1931
|
+
boundary pins) and the interior pitches re-roll from a fresh per-bar
|
|
1932
|
+
stream salted by ``seed=`` (an unseeded call draws a fresh salt, so
|
|
1933
|
+
each call genuinely differs); onsets, durations, velocities, rests,
|
|
1934
|
+
drums, and control gestures are untouched. Segmentation and the
|
|
1935
|
+
recipe survive, so rerolls compose.
|
|
1936
|
+
|
|
1937
|
+
Only a phrase that carries a recipe can reroll — a hand-written or
|
|
1938
|
+
transformed phrase raises loudly (its notes no longer come from a
|
|
1939
|
+
generator, so regenerating them would invent music).
|
|
1940
|
+
|
|
1941
|
+
Parameters:
|
|
1942
|
+
bar: A single 1-based bar to reroll.
|
|
1943
|
+
bars: A list of 1-based bars (the paired plural spelling).
|
|
1944
|
+
seed: Seed for the new pitches (salted per bar). Without one,
|
|
1945
|
+
reroll() warns.
|
|
1946
|
+
|
|
1947
|
+
Example:
|
|
1948
|
+
```python
|
|
1949
|
+
lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept
|
|
1950
|
+
```
|
|
1951
|
+
"""
|
|
1952
|
+
|
|
1953
|
+
if self.recipe is None:
|
|
1954
|
+
raise ValueError(
|
|
1955
|
+
"this phrase carries no recipe (it was written by hand, or transformed "
|
|
1956
|
+
"since generation) — reroll() regenerates from a recipe; edit segments "
|
|
1957
|
+
"with replace(), or rebuild with Phrase.develop()"
|
|
1958
|
+
)
|
|
1959
|
+
|
|
1960
|
+
if (bar is None) == (bars is None):
|
|
1961
|
+
raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)")
|
|
1962
|
+
|
|
1963
|
+
region = [bar] if bar is not None else list(bars or [])
|
|
1964
|
+
beats_per_bar = self.recipe.beats_per_bar
|
|
1965
|
+
total_bars = int(round(self.length / beats_per_bar))
|
|
1966
|
+
|
|
1967
|
+
for number in region:
|
|
1968
|
+
if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars:
|
|
1969
|
+
raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})")
|
|
1970
|
+
|
|
1971
|
+
if seed is None:
|
|
1972
|
+
warnings.warn(
|
|
1973
|
+
"reroll() without seed= is nondeterministic — pass seed= so the "
|
|
1974
|
+
"value survives live reload",
|
|
1975
|
+
stacklevel = 2,
|
|
1976
|
+
)
|
|
1977
|
+
|
|
1978
|
+
# Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would
|
|
1979
|
+
# "re-roll" to the identical pitches every time.
|
|
1980
|
+
salt = seed if seed is not None else random.randrange(2 ** 32)
|
|
1981
|
+
|
|
1982
|
+
windows = [
|
|
1983
|
+
((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}"))
|
|
1984
|
+
for number in sorted(set(region))
|
|
1985
|
+
]
|
|
1986
|
+
|
|
1987
|
+
new_segments: typing.List[Motif] = []
|
|
1988
|
+
offset = 0.0
|
|
1989
|
+
|
|
1990
|
+
for segment in self.segments:
|
|
1991
|
+
|
|
1992
|
+
events = list(segment.events)
|
|
1993
|
+
|
|
1994
|
+
for window_start, window_end, rng in windows:
|
|
1995
|
+
|
|
1996
|
+
inside = [
|
|
1997
|
+
index for index, event in enumerate(events)
|
|
1998
|
+
if window_start <= offset + event.beat < window_end
|
|
1999
|
+
and event.pitch is not None and not isinstance(event.pitch, str)
|
|
2000
|
+
]
|
|
2001
|
+
|
|
2002
|
+
# Boundary pins: the first and last pitched notes of the bar
|
|
2003
|
+
# stay; only the interior re-rolls.
|
|
2004
|
+
for index in inside[1:-1]:
|
|
2005
|
+
events[index] = dataclasses.replace(
|
|
2006
|
+
events[index],
|
|
2007
|
+
pitch = segment._nudged_pitch(events[index].pitch, rng),
|
|
2008
|
+
)
|
|
2009
|
+
|
|
2010
|
+
new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls))
|
|
2011
|
+
offset += segment.length
|
|
2012
|
+
|
|
2013
|
+
return Phrase(new_segments, recipe = self.recipe)
|
|
2014
|
+
|
|
2015
|
+
def flatten (self) -> Motif:
|
|
2016
|
+
|
|
2017
|
+
"""Erase segmentation: one long Motif (the monoid homomorphism onto ``then``)."""
|
|
2018
|
+
|
|
2019
|
+
return Motif.join(self.segments)
|
|
2020
|
+
|
|
2021
|
+
# ── algebra ─────────────────────────────────────────────────────────
|
|
2022
|
+
|
|
2023
|
+
def __add__ (self, other: typing.Any) -> "Phrase":
|
|
2024
|
+
|
|
2025
|
+
"""Append a Motif segment, or concatenate another Phrase's segments."""
|
|
2026
|
+
|
|
2027
|
+
if isinstance(other, Motif):
|
|
2028
|
+
return Phrase(self.segments + (other,))
|
|
2029
|
+
if isinstance(other, Phrase):
|
|
2030
|
+
return Phrase(self.segments + other.segments)
|
|
2031
|
+
|
|
2032
|
+
return NotImplemented
|
|
2033
|
+
|
|
2034
|
+
def __radd__ (self, other: typing.Any) -> "Phrase":
|
|
2035
|
+
|
|
2036
|
+
"""A Motif on the left prepends as a segment."""
|
|
2037
|
+
|
|
2038
|
+
if isinstance(other, Motif):
|
|
2039
|
+
return Phrase((other,) + self.segments)
|
|
2040
|
+
|
|
2041
|
+
return NotImplemented
|
|
2042
|
+
|
|
2043
|
+
def __mul__ (self, count: int) -> "Phrase":
|
|
2044
|
+
|
|
2045
|
+
"""Tile the segments *count* times."""
|
|
2046
|
+
|
|
2047
|
+
if not isinstance(count, int):
|
|
2048
|
+
return NotImplemented
|
|
2049
|
+
if count < 0:
|
|
2050
|
+
raise ValueError(f"Repetition count must be non-negative — got {count}")
|
|
2051
|
+
|
|
2052
|
+
return Phrase(self.segments * count)
|
|
2053
|
+
|
|
2054
|
+
__rmul__ = __mul__
|
|
2055
|
+
|
|
2056
|
+
def __and__ (self, other: typing.Any) -> Motif:
|
|
2057
|
+
|
|
2058
|
+
"""Parallel merge is vertical: Phrase operands flatten to Motif first."""
|
|
2059
|
+
|
|
2060
|
+
if isinstance(other, (Motif, Phrase)):
|
|
2061
|
+
return self.flatten().stack(other)
|
|
2062
|
+
|
|
2063
|
+
return NotImplemented
|
|
2064
|
+
|
|
2065
|
+
def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif:
|
|
2066
|
+
|
|
2067
|
+
"""The spelled form of ``&`` — flattens, then merges."""
|
|
2068
|
+
|
|
2069
|
+
return self.flatten().stack(other)
|
|
2070
|
+
|
|
2071
|
+
def slice (self, start: float, end: float) -> "Phrase":
|
|
2072
|
+
|
|
2073
|
+
"""A window; re-segments at the cut points (partial segments are sliced)."""
|
|
2074
|
+
|
|
2075
|
+
segments = []
|
|
2076
|
+
offset = 0.0
|
|
2077
|
+
|
|
2078
|
+
for segment in self.segments:
|
|
2079
|
+
seg_start, seg_end = offset, offset + segment.length
|
|
2080
|
+
lo, hi = max(start, seg_start), min(end, seg_end)
|
|
2081
|
+
if lo < hi:
|
|
2082
|
+
segments.append(segment.slice(lo - seg_start, hi - seg_start))
|
|
2083
|
+
offset = seg_end
|
|
2084
|
+
|
|
2085
|
+
return Phrase(segments)
|
|
2086
|
+
|
|
2087
|
+
def replace (self, position: int, motif: Motif) -> "Phrase":
|
|
2088
|
+
|
|
2089
|
+
"""Replace the segment at a 1-based position (musicians count from one)."""
|
|
2090
|
+
|
|
2091
|
+
if not 1 <= position <= len(self.segments):
|
|
2092
|
+
raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)")
|
|
2093
|
+
|
|
2094
|
+
segments = list(self.segments)
|
|
2095
|
+
segments[position - 1] = motif
|
|
2096
|
+
|
|
2097
|
+
return Phrase(segments)
|
|
2098
|
+
|
|
2099
|
+
# ── transforms: lifted segment-wise, except time-reordering ─────────
|
|
2100
|
+
|
|
2101
|
+
def reverse (self) -> "Phrase":
|
|
2102
|
+
|
|
2103
|
+
"""Reverse the whole timeline: segments reverse order AND each reverses internally."""
|
|
2104
|
+
|
|
2105
|
+
return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
|
|
2106
|
+
|
|
2107
|
+
def rotate (self, beats: float) -> "Phrase":
|
|
2108
|
+
|
|
2109
|
+
"""Rotate the whole timeline modulo the total length, then re-segment at the original boundaries."""
|
|
2110
|
+
|
|
2111
|
+
flat = self.flatten().rotate(beats)
|
|
2112
|
+
segments = []
|
|
2113
|
+
offset = 0.0
|
|
2114
|
+
|
|
2115
|
+
# Re-segment by onset (events keep their full durations — a note may
|
|
2116
|
+
# ring past its new segment, exactly as it does on the flat timeline).
|
|
2117
|
+
for segment in self.segments:
|
|
2118
|
+
lo, hi = offset, offset + segment.length
|
|
2119
|
+
segments.append(Motif(
|
|
2120
|
+
events = tuple(
|
|
2121
|
+
dataclasses.replace(e, beat=e.beat - lo)
|
|
2122
|
+
for e in flat.events if lo <= e.beat < hi
|
|
2123
|
+
),
|
|
2124
|
+
length = segment.length,
|
|
2125
|
+
controls = tuple(
|
|
2126
|
+
dataclasses.replace(c, beat=c.beat - lo)
|
|
2127
|
+
for c in flat.controls if lo <= c.beat < hi
|
|
2128
|
+
),
|
|
2129
|
+
))
|
|
2130
|
+
offset = hi
|
|
2131
|
+
|
|
2132
|
+
return Phrase(segments)
|
|
2133
|
+
|
|
2134
|
+
def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase":
|
|
2135
|
+
|
|
2136
|
+
"""Apply a Motif transform to every segment."""
|
|
2137
|
+
|
|
2138
|
+
return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments))
|
|
2139
|
+
|
|
2140
|
+
def stretch (self, factor: float) -> "Phrase":
|
|
2141
|
+
|
|
2142
|
+
"""Scale time in every segment (lengths scale with them)."""
|
|
2143
|
+
|
|
2144
|
+
return self._lift("stretch", factor)
|
|
2145
|
+
|
|
2146
|
+
def quantize (self, grid: float) -> "Phrase":
|
|
2147
|
+
|
|
2148
|
+
"""Snap note onsets segment-wise."""
|
|
2149
|
+
|
|
2150
|
+
return self._lift("quantize", grid)
|
|
2151
|
+
|
|
2152
|
+
def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase":
|
|
2153
|
+
|
|
2154
|
+
"""Replace every note's velocity, segment-wise."""
|
|
2155
|
+
|
|
2156
|
+
return self._lift("with_velocity", velocity)
|
|
2157
|
+
|
|
2158
|
+
def pitched (self, spec: PitchSpec) -> "Phrase":
|
|
2159
|
+
|
|
2160
|
+
"""Replace every pitch, segment-wise."""
|
|
2161
|
+
|
|
2162
|
+
return self._lift("pitched", spec)
|
|
2163
|
+
|
|
2164
|
+
def rhythm (self) -> "Phrase":
|
|
2165
|
+
|
|
2166
|
+
"""Strip pitches segment-wise: a phrase-shaped skeleton."""
|
|
2167
|
+
|
|
2168
|
+
return self._lift("rhythm")
|
|
2169
|
+
|
|
2170
|
+
def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase":
|
|
2171
|
+
|
|
2172
|
+
"""Transpose every segment (see :meth:`Motif.transpose`)."""
|
|
2173
|
+
|
|
2174
|
+
return self._lift("transpose", steps=steps, semitones=semitones)
|
|
2175
|
+
|
|
2176
|
+
def invert (self, pivot: typing.Optional[int] = None) -> "Phrase":
|
|
2177
|
+
|
|
2178
|
+
"""Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`)."""
|
|
2179
|
+
|
|
2180
|
+
if pivot is None:
|
|
2181
|
+
for segment in self.segments:
|
|
2182
|
+
for event in segment.events:
|
|
2183
|
+
if event.pitch is not None:
|
|
2184
|
+
if isinstance(event.pitch, int):
|
|
2185
|
+
pivot = event.pitch
|
|
2186
|
+
elif isinstance(event.pitch, Degree):
|
|
2187
|
+
pivot = event.pitch.step
|
|
2188
|
+
break
|
|
2189
|
+
if pivot is not None:
|
|
2190
|
+
break
|
|
2191
|
+
|
|
2192
|
+
return self._lift("invert", pivot=pivot)
|
|
2193
|
+
|
|
2194
|
+
def describe (self) -> str:
|
|
2195
|
+
|
|
2196
|
+
"""A readable summary: total length and each segment on its own line."""
|
|
2197
|
+
|
|
2198
|
+
header = f"Phrase {self.length:g} beats, {len(self.segments)} segments"
|
|
2199
|
+
lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)]
|
|
2200
|
+
|
|
2201
|
+
return "\n".join([header] + lines)
|
|
2202
|
+
|
|
2203
|
+
def __str__ (self) -> str:
|
|
2204
|
+
|
|
2205
|
+
"""Printable form (same as :meth:`describe`)."""
|
|
2206
|
+
|
|
2207
|
+
return self.describe()
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def motif (
|
|
2211
|
+
degrees: typing.List[typing.Union[int, Degree, None]],
|
|
2212
|
+
beats: typing.Optional[typing.List[float]] = None,
|
|
2213
|
+
velocities: typing.Any = _DEFAULT_VELOCITY,
|
|
2214
|
+
durations: typing.Any = 1.0,
|
|
2215
|
+
probabilities: typing.Any = 1.0,
|
|
2216
|
+
length: typing.Optional[float] = None,
|
|
2217
|
+
) -> Motif:
|
|
2218
|
+
|
|
2219
|
+
"""
|
|
2220
|
+
The lowercase shortcut: a melody as 1-based scale degrees.
|
|
2221
|
+
|
|
2222
|
+
``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` —
|
|
2223
|
+
relative pitch is the primary form. For absolute MIDI note numbers use
|
|
2224
|
+
``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so
|
|
2225
|
+
a pasted MIDI list fails loud instead of squealing octaves up.
|
|
2226
|
+
"""
|
|
2227
|
+
|
|
2228
|
+
return Motif.degrees(
|
|
2229
|
+
degrees,
|
|
2230
|
+
beats = beats,
|
|
2231
|
+
velocities = velocities,
|
|
2232
|
+
durations = durations,
|
|
2233
|
+
probabilities = probabilities,
|
|
2234
|
+
length = length,
|
|
2235
|
+
)
|
|
2236
|
+
|
|
2237
|
+
|
|
2238
|
+
def sentence (
|
|
2239
|
+
motif: Motif,
|
|
2240
|
+
bars: int = 8,
|
|
2241
|
+
cadence: str = "strong",
|
|
2242
|
+
seed: typing.Optional[int] = None,
|
|
2243
|
+
beats_per_bar: float = 4.0,
|
|
2244
|
+
) -> Phrase:
|
|
2245
|
+
|
|
2246
|
+
"""The classical sentence, as a thin combinator — idea, idea, drive, close.
|
|
2247
|
+
|
|
2248
|
+
Four units: the basic idea stated twice (the presentation), a generated
|
|
2249
|
+
contrast unit (the continuation — the source's rhythm, freshly
|
|
2250
|
+
re-pitched), and a second contrast unit whose tail lands on the
|
|
2251
|
+
cadence's close degree (the cadential close). An 8-bar sentence from a
|
|
2252
|
+
2-bar idea is the textbook proportion; a shorter idea tiles up to the
|
|
2253
|
+
unit size first.
|
|
2254
|
+
|
|
2255
|
+
The melodic side of a cadence only — pair it with the harmonic side
|
|
2256
|
+
(``prog.cadence()``, ``Progression.generate(cadence=)``, or
|
|
2257
|
+
``request_cadence()``) and the two arrive together.
|
|
2258
|
+
|
|
2259
|
+
Parameters:
|
|
2260
|
+
motif: The basic idea (degree content — the close re-aims a degree).
|
|
2261
|
+
bars: Sentence length (must divide evenly across the 4 units).
|
|
2262
|
+
cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5,
|
|
2263
|
+
``"soft"``/``"fakeout"`` on 1 (theory aliases accepted).
|
|
2264
|
+
seed: Seed for the generated continuation units (seed-or-warn).
|
|
2265
|
+
beats_per_bar: Bar size in beats (context-free; 4 is the default).
|
|
2266
|
+
|
|
2267
|
+
Example:
|
|
2268
|
+
```python
|
|
2269
|
+
idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
|
|
2270
|
+
verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11)
|
|
2271
|
+
```
|
|
2272
|
+
"""
|
|
2273
|
+
|
|
2274
|
+
spec = subsequence.cadences.cadence_formula(cadence)
|
|
2275
|
+
|
|
2276
|
+
if seed is None:
|
|
2277
|
+
warnings.warn(
|
|
2278
|
+
"sentence() without seed= is nondeterministic — pass seed= so the "
|
|
2279
|
+
"value survives live reload",
|
|
2280
|
+
stacklevel = 2,
|
|
2281
|
+
)
|
|
2282
|
+
|
|
2283
|
+
source = _tile_source(motif, bars, 4, beats_per_bar)
|
|
2284
|
+
|
|
2285
|
+
# Unseeded calls draw a fresh salt (a fixed "None:..." seed would return
|
|
2286
|
+
# the same sentence every time, belying the warning above).
|
|
2287
|
+
salt = seed if seed is not None else random.randrange(2 ** 32)
|
|
2288
|
+
|
|
2289
|
+
continuation = _contrast_unit(source, random.Random(f"{salt}:sentence:continuation"))
|
|
2290
|
+
cadential = _contrast_unit(source, random.Random(f"{salt}:sentence:cadential")).answer(to = spec.close_degree)
|
|
2291
|
+
|
|
2292
|
+
return Phrase([source, source, continuation, cadential], recipe = _PhraseRecipe(
|
|
2293
|
+
source = motif,
|
|
2294
|
+
plan = "sentence",
|
|
2295
|
+
bars = bars,
|
|
2296
|
+
seed = seed,
|
|
2297
|
+
beats_per_bar = beats_per_bar,
|
|
2298
|
+
cadence = spec.name,
|
|
2299
|
+
))
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
def period (
|
|
2303
|
+
antecedent: typing.Union[Motif, Phrase],
|
|
2304
|
+
cadence: str = "strong",
|
|
2305
|
+
beats_per_bar: float = 4.0,
|
|
2306
|
+
) -> Phrase:
|
|
2307
|
+
|
|
2308
|
+
"""The classical period, as a thin combinator — question, then answer.
|
|
2309
|
+
|
|
2310
|
+
Two halves: the antecedent with its tail re-aimed to the open half-close
|
|
2311
|
+
(degree 5 — the question), then the same material restated with its tail
|
|
2312
|
+
on the cadence's close degree (the answer). The two halves differ
|
|
2313
|
+
exactly at their closes — the open/closed contrast *is* the period.
|
|
2314
|
+
|
|
2315
|
+
Deterministic: no notes are generated, only the two tail notes re-aim
|
|
2316
|
+
(so there is no seed). Vary the consequent yourself for a looser
|
|
2317
|
+
restatement: ``period(a).reroll(bar=7, seed=4)``.
|
|
2318
|
+
|
|
2319
|
+
Parameters:
|
|
2320
|
+
antecedent: The first half — a Motif, or a Phrase whose segmentation
|
|
2321
|
+
is kept (only its last segment's tail re-aims).
|
|
2322
|
+
cadence: The consequent's close — ``"strong"`` lands on 1 (theory
|
|
2323
|
+
aliases accepted).
|
|
2324
|
+
beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows.
|
|
2325
|
+
|
|
2326
|
+
Example:
|
|
2327
|
+
```python
|
|
2328
|
+
idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8)
|
|
2329
|
+
lead = subsequence.period(idea) # 16 beats: half-close, then home
|
|
2330
|
+
```
|
|
2331
|
+
"""
|
|
2332
|
+
|
|
2333
|
+
spec = subsequence.cadences.cadence_formula(cadence)
|
|
2334
|
+
open_degree = subsequence.cadences.cadence_formula("open").close_degree
|
|
2335
|
+
|
|
2336
|
+
units = list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent]
|
|
2337
|
+
|
|
2338
|
+
if not units or sum(unit.length for unit in units) <= 0:
|
|
2339
|
+
raise ValueError("cannot build a period from an empty antecedent")
|
|
2340
|
+
|
|
2341
|
+
tail = units[-1]
|
|
2342
|
+
|
|
2343
|
+
antecedent_units = units[:-1] + [tail.answer(to = open_degree)]
|
|
2344
|
+
consequent_units = units[:-1] + [tail.answer(to = spec.close_degree)]
|
|
2345
|
+
|
|
2346
|
+
source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent
|
|
2347
|
+
total_beats = 2 * sum(unit.length for unit in units)
|
|
2348
|
+
|
|
2349
|
+
return Phrase(antecedent_units + consequent_units, recipe = _PhraseRecipe(
|
|
2350
|
+
source = source,
|
|
2351
|
+
plan = "period",
|
|
2352
|
+
bars = int(round(total_beats / beats_per_bar)),
|
|
2353
|
+
seed = None,
|
|
2354
|
+
beats_per_bar = beats_per_bar,
|
|
2355
|
+
cadence = spec.name,
|
|
2356
|
+
))
|