subsequence 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. subsequence-0.6.4.dist-info/top_level.txt +1 -0
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
+ ))