subsequence 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. subsequence-0.6.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1208 @@
1
+ """Mixin class providing MIDI and OSC control-message methods for PatternBuilder.
2
+
3
+ This module is not intended to be used directly. ``PatternMidiMixin``
4
+ is inherited by ``PatternBuilder`` in ``pattern_builder.py``.
5
+ """
6
+
7
+ import typing
8
+
9
+ import pymididefs.cc
10
+ import pymididefs.rpn
11
+
12
+ import subsequence.constants
13
+ import subsequence.easing
14
+ import subsequence.pattern
15
+
16
+
17
+ class PatternMidiMixin:
18
+
19
+ """MIDI control, OSC, and note-correlated pitch bend methods for PatternBuilder.
20
+
21
+ All methods here operate on ``self._pattern`` (a ``Pattern`` instance),
22
+ which is set by ``PatternBuilder.__init__``.
23
+ """
24
+
25
+ # ── Instance attributes provided by PatternBuilder at runtime ────────
26
+ _pattern: subsequence.pattern.Pattern
27
+ _default_grid: int
28
+ _cc_name_map: typing.Optional[typing.Dict[str, int]]
29
+ _nrpn_name_map: typing.Optional[typing.Dict[str, int]]
30
+
31
+ if typing.TYPE_CHECKING:
32
+ import subsequence.pattern_builder # noqa: F401 — type-checking only
33
+ def _resolve_cc (self, control: typing.Union[int, str]) -> int: ...
34
+ def _resolve_nrpn (self, parameter: typing.Union[int, str]) -> int: ...
35
+ def _resolve_rpn (self, parameter: typing.Union[int, str]) -> int: ...
36
+
37
+ # ── Shared ramp helper ──────────────────────────────────────────────────
38
+
39
+ def _ramp_pulse_span (
40
+ self,
41
+ pulse_start: int,
42
+ pulse_end: int,
43
+ start: float,
44
+ end: float,
45
+ shape: typing.Union[str, subsequence.easing.EasingFn],
46
+ resolution: int,
47
+ event_fn: typing.Callable[[int, float], None],
48
+ ) -> None:
49
+
50
+ """Walk from pulse_start to pulse_end, calling event_fn(pulse, value) at each step.
51
+
52
+ The pulse-domain kernel shared by every ramp on this mixin — the
53
+ beat-based ramps via :meth:`_ramp_pulses` and the note-correlated pitch
54
+ bends via :meth:`_generate_bend_events`. ``event_fn`` receives the pulse
55
+ position and the linearly-interpolated (then eased) value, and is
56
+ responsible for creating and appending the event.
57
+ """
58
+
59
+ span = pulse_end - pulse_start
60
+
61
+ if span <= 0:
62
+ return
63
+
64
+ if resolution < 1:
65
+ raise ValueError("resolution must be at least 1 pulse")
66
+
67
+ easing_fn = subsequence.easing.get_easing(shape)
68
+ pulse = pulse_start
69
+
70
+ while pulse <= pulse_end:
71
+ t = (pulse - pulse_start) / span
72
+ eased_t = easing_fn(t)
73
+ interpolated = start + (end - start) * eased_t
74
+ event_fn(pulse, interpolated)
75
+ pulse += resolution
76
+
77
+ # The loop lands on pulse_end only when resolution divides the span —
78
+ # otherwise emit the target explicitly, so a ramp always reaches the
79
+ # value it was asked to reach.
80
+ if span % resolution != 0:
81
+ event_fn(pulse_end, start + (end - start) * easing_fn(1.0))
82
+
83
+ def _ramp_pulses (
84
+ self,
85
+ beat_start: float,
86
+ beat_end: float,
87
+ start: float,
88
+ end: float,
89
+ shape: typing.Union[str, subsequence.easing.EasingFn],
90
+ resolution: int,
91
+ event_fn: typing.Callable[[int, float], None],
92
+ ) -> None:
93
+
94
+ """Walk from beat_start to beat_end, calling event_fn(pulse, value) at each step.
95
+
96
+ The beat-based entry to :meth:`_ramp_pulse_span`, shared by
97
+ ``cc_ramp()``, ``pitch_bend_ramp()``, ``nrpn_ramp()``/``rpn_ramp()``,
98
+ and ``osc_ramp()``.
99
+ """
100
+
101
+ pulse_start = int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE)
102
+ pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
103
+
104
+ self._ramp_pulse_span(pulse_start, pulse_end, start, end, shape, resolution, event_fn)
105
+
106
+ # ── CC messages ─────────────────────────────────────────────────────────
107
+
108
+ def cc (self, control: typing.Union[int, str], value: int, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
109
+
110
+ """
111
+ Send a single CC message at a beat position.
112
+
113
+ Parameters:
114
+ control: MIDI CC number (0–127), or a string name resolved
115
+ via the pattern's ``cc_name_map``.
116
+ value: CC value (0–127); out-of-range values are clamped.
117
+ beat: Beat position within the pattern.
118
+ """
119
+
120
+ cc_num: int = self._resolve_cc(control)
121
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
122
+
123
+ # Clamp to the 7-bit CC range like every sibling (cc_ramp / program_change
124
+ # / pitch_bend) so a computed out-of-range value is corrected here rather
125
+ # than silently dropped and logged when mido rejects it at dispatch time.
126
+ clamped_value = max(0, min(127, int(round(value))))
127
+
128
+ self._pattern.cc_events.append(
129
+ subsequence.pattern.CcEvent(
130
+ pulse = pulse,
131
+ message_type = 'control_change',
132
+ control = cc_num,
133
+ value = clamped_value
134
+ )
135
+ )
136
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
137
+
138
+ def cc_ramp (
139
+ self,
140
+ control: typing.Union[int, str],
141
+ start: int,
142
+ end: int,
143
+ beat_start: float = 0.0,
144
+ beat_end: typing.Optional[float] = None,
145
+ resolution: int = 1,
146
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
147
+ ) -> "subsequence.pattern_builder.PatternBuilder":
148
+
149
+ """
150
+ Interpolate a CC value over a beat range.
151
+
152
+ Parameters:
153
+ control: MIDI CC number (0–127), or a string name resolved
154
+ via the pattern's ``cc_name_map``.
155
+ start: Starting CC value (0–127).
156
+ end: Ending CC value (0–127).
157
+ beat_start: Beat position to begin the ramp.
158
+ beat_end: Beat position to end the ramp. Defaults to pattern length.
159
+ resolution: Pulses between CC messages (1 = every pulse, ~20ms at 120 BPM).
160
+ Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound
161
+ stepped at slow tempos.
162
+ shape: Easing curve — a name string (e.g. ``"exponential"``) or any
163
+ callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
164
+ See :mod:`subsequence.easing` for available shapes.
165
+ """
166
+
167
+ cc_num: int = self._resolve_cc(control)
168
+
169
+ if beat_end is None:
170
+ beat_end = self._pattern.length
171
+
172
+ def _event (pulse: int, val: float) -> None:
173
+ self._pattern.cc_events.append(
174
+ subsequence.pattern.CcEvent(
175
+ pulse = pulse,
176
+ message_type = 'control_change',
177
+ control = cc_num,
178
+ value = max(0, min(127, int(round(val))))
179
+ )
180
+ )
181
+
182
+ self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
183
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
184
+
185
+ # ── Pitch bend ──────────────────────────────────────────────────────────
186
+
187
+ def pitch_bend (self, value: float, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
188
+
189
+ """
190
+ Send a single pitch bend message at a beat position.
191
+
192
+ Parameters:
193
+ value: Pitch bend amount, normalised from -1.0 to 1.0.
194
+ beat: Beat position within the pattern.
195
+ """
196
+
197
+ # The asymmetric clamp is correct: MIDI's 14-bit bend range is -8192..+8191.
198
+ midi_value = max(-8192, min(8191, int(round(value * 8192))))
199
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
200
+
201
+ self._pattern.cc_events.append(
202
+ subsequence.pattern.CcEvent(
203
+ pulse = pulse,
204
+ message_type = 'pitchwheel',
205
+ value = midi_value
206
+ )
207
+ )
208
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
209
+
210
+ def pitch_bend_ramp (
211
+ self,
212
+ start: float,
213
+ end: float,
214
+ beat_start: float = 0.0,
215
+ beat_end: typing.Optional[float] = None,
216
+ resolution: int = 1,
217
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
218
+ ) -> "subsequence.pattern_builder.PatternBuilder":
219
+
220
+ """
221
+ Interpolate pitch bend over a beat range.
222
+
223
+ Parameters:
224
+ start: Starting pitch bend (-1.0 to 1.0).
225
+ end: Ending pitch bend (-1.0 to 1.0).
226
+ beat_start: Beat position to begin the ramp.
227
+ beat_end: Beat position to end the ramp. Defaults to pattern length.
228
+ resolution: Pulses between pitch bend messages (1 = every pulse).
229
+ Higher values (e.g. 2 or 4) reduce MIDI traffic density but may sound
230
+ stepped at slow tempos.
231
+ shape: Easing curve — a name string (e.g. ``"ease_out"``) or any
232
+ callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
233
+ See :mod:`subsequence.easing` for available shapes.
234
+ """
235
+
236
+ if beat_end is None:
237
+ beat_end = self._pattern.length
238
+
239
+ def _event (pulse: int, val: float) -> None:
240
+ self._pattern.cc_events.append(
241
+ subsequence.pattern.CcEvent(
242
+ pulse = pulse,
243
+ message_type = 'pitchwheel',
244
+ value = max(-8192, min(8191, int(round(val * 8192))))
245
+ )
246
+ )
247
+
248
+ self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event)
249
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
250
+
251
+ # ── RPN / NRPN parameter control ────────────────────────────────────────
252
+ # RPN (Registered) and NRPN (Non-Registered) Parameter Numbers are the
253
+ # standard MIDI conventions for addressing parameters beyond the 128 CC
254
+ # slots, with optional 14-bit value precision. Both are sequences of
255
+ # regular control_change messages co-scheduled at the same pulse:
256
+ # CC 99 / 98 NRPN parameter MSB / LSB (or 101 / 100 for RPN)
257
+ # CC 6 / 38 Data Entry MSB / LSB
258
+ # CC 101=127, 100=127 NULL — defensive deselect
259
+ # The Sequencer's MidiEvent.sequence tie-breaker preserves emission order
260
+ # at the same pulse, so the synth assigns the value to the right parameter.
261
+
262
+ def _append_param_select (self, pulse: int, parameter: int, msb_cc: int, lsb_cc: int) -> None:
263
+
264
+ """Emit the two-CC parameter-select pair (NRPN: 99/98, RPN: 101/100).
265
+
266
+ Events are emitted on the pattern's channel — leaving ``CcEvent.channel``
267
+ unset (None) lets the sequencer fall through to ``pattern.channel``
268
+ at dispatch time, which is the normal behaviour for every other CC
269
+ method on this mixin.
270
+ """
271
+
272
+ param_msb, param_lsb = pymididefs.cc.pack_14bit(parameter)
273
+
274
+ self._pattern.cc_events.append(
275
+ subsequence.pattern.CcEvent(
276
+ pulse = pulse,
277
+ message_type = 'control_change',
278
+ control = msb_cc,
279
+ value = param_msb,
280
+ )
281
+ )
282
+ self._pattern.cc_events.append(
283
+ subsequence.pattern.CcEvent(
284
+ pulse = pulse,
285
+ message_type = 'control_change',
286
+ control = lsb_cc,
287
+ value = param_lsb,
288
+ )
289
+ )
290
+
291
+ def _append_data_entry (self, pulse: int, value: int, fine: bool) -> None:
292
+
293
+ """Emit Data Entry MSB (and LSB if fine=True) for a parameter value."""
294
+
295
+ if fine:
296
+ value_msb, value_lsb = pymididefs.cc.pack_14bit(value)
297
+ else:
298
+ if not 0 <= value <= 127:
299
+ raise ValueError(f"NRPN/RPN value must be 0–127 when fine=False, got {value}")
300
+ value_msb = value
301
+ value_lsb = None
302
+
303
+ self._pattern.cc_events.append(
304
+ subsequence.pattern.CcEvent(
305
+ pulse = pulse,
306
+ message_type = 'control_change',
307
+ control = pymididefs.cc.DATA_ENTRY_MSB,
308
+ value = value_msb,
309
+ )
310
+ )
311
+
312
+ if value_lsb is not None:
313
+ self._pattern.cc_events.append(
314
+ subsequence.pattern.CcEvent(
315
+ pulse = pulse,
316
+ message_type = 'control_change',
317
+ control = pymididefs.cc.DATA_ENTRY_LSB,
318
+ value = value_lsb,
319
+ )
320
+ )
321
+
322
+ def _validate_ramp_endpoints (self, start: int, end: int, fine: bool) -> None:
323
+
324
+ """Reject out-of-range NRPN/RPN ramp endpoints up front.
325
+
326
+ Mirrors the strict behaviour of ``_append_data_entry`` for one-shots
327
+ so a typo (e.g. forgetting ``fine=True`` with a 14-bit value) raises
328
+ immediately rather than silently clamping at every ramp step.
329
+ """
330
+
331
+ limit = 16383 if fine else 127
332
+
333
+ for label, value in (("start", start), ("end", end)):
334
+ if not 0 <= value <= limit:
335
+ raise ValueError(f"NRPN/RPN ramp {label} must be 0–{limit} (fine={fine}), got {value}")
336
+
337
+ def _append_null_reset (self, pulse: int) -> None:
338
+
339
+ """Emit the RPN NULL sentinel (CC 101=127, CC 100=127) to deselect.
340
+
341
+ Defensive practice: prevents a stray later CC 6 / 38 from being
342
+ applied to whichever parameter was last selected on this channel.
343
+ """
344
+
345
+ null_msb, null_lsb = pymididefs.cc.pack_14bit(pymididefs.rpn.NULL_PARAMETER)
346
+
347
+ self._pattern.cc_events.append(
348
+ subsequence.pattern.CcEvent(
349
+ pulse = pulse,
350
+ message_type = 'control_change',
351
+ control = pymididefs.cc.RPN_MSB,
352
+ value = null_msb,
353
+ )
354
+ )
355
+ self._pattern.cc_events.append(
356
+ subsequence.pattern.CcEvent(
357
+ pulse = pulse,
358
+ message_type = 'control_change',
359
+ control = pymididefs.cc.RPN_LSB,
360
+ value = null_lsb,
361
+ )
362
+ )
363
+
364
+ def nrpn (
365
+ self,
366
+ parameter: typing.Union[int, str],
367
+ value: int,
368
+ beat: float = 0.0,
369
+ fine: bool = False,
370
+ null_reset: bool = True,
371
+ ) -> "subsequence.pattern_builder.PatternBuilder":
372
+
373
+ """
374
+ Send a single NRPN parameter write at a beat position.
375
+
376
+ NRPN (Non-Registered Parameter Number) addresses synth-specific
377
+ parameters that don't fit into the 128 standard CC slots — Sequential,
378
+ Korg, Roland, Elektron and others use it heavily for filter cutoff,
379
+ envelope amounts, oscillator detune, and similar deep parameters.
380
+ Many such parameters need values beyond 0–127 (e.g. 0–1023, 0–254);
381
+ set ``fine=True`` for full 14-bit precision.
382
+
383
+ Emitted on the pattern's MIDI channel. To target a different channel
384
+ (e.g. a per-channel RPN config), define a separate pattern on that
385
+ channel or use ``composition.trigger(channel=…)`` for a one-shot.
386
+
387
+ Parameters:
388
+ parameter: 14-bit NRPN parameter number (0–16383), or a string
389
+ resolved via the pattern's ``nrpn_name_map``.
390
+ value: Parameter value. 0–127 if ``fine=False``; 0–16383 if
391
+ ``fine=True``.
392
+ beat: Beat position within the pattern.
393
+ fine: If True, send 14-bit value via Data Entry MSB+LSB
394
+ (CC 6 + CC 38). If False (default), send only Data Entry
395
+ MSB — sufficient for the common 0–127 range.
396
+ null_reset: If True (default), follow with the RPN null sentinel
397
+ to deselect the active parameter and prevent stray later
398
+ CC 6 / 38 messages from hitting it.
399
+
400
+ Example:
401
+ ```python
402
+ # Sequential Take 5 fine-tune (14-bit, range 0–1400)
403
+ p.nrpn(9, 700, fine=True)
404
+
405
+ # Roland JV-1080 reverb level (7-bit)
406
+ p.nrpn(0x0140, 80)
407
+ ```
408
+ """
409
+
410
+ param = self._resolve_nrpn(parameter)
411
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
412
+
413
+ self._append_param_select(pulse, param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB)
414
+ self._append_data_entry(pulse, value, fine)
415
+
416
+ if null_reset:
417
+ self._append_null_reset(pulse)
418
+
419
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
420
+
421
+ def rpn (
422
+ self,
423
+ parameter: typing.Union[int, str],
424
+ value: int,
425
+ beat: float = 0.0,
426
+ fine: bool = False,
427
+ null_reset: bool = True,
428
+ ) -> "subsequence.pattern_builder.PatternBuilder":
429
+
430
+ """
431
+ Send a single RPN parameter write at a beat position.
432
+
433
+ RPN (Registered Parameter Number) addresses the small standardised
434
+ set of parameters defined by the MIDI specification — pitch bend
435
+ range, master tuning, modulation depth — supported by virtually any
436
+ MIDI synth. String names resolve via ``pymididefs.rpn.RPN_MAP``
437
+ out of the box, no map needed.
438
+
439
+ Standard RPN names: ``pitch_bend_sensitivity``,
440
+ ``channel_fine_tuning``, ``channel_coarse_tuning``,
441
+ ``tuning_program_select``, ``tuning_bank_select``,
442
+ ``modulation_depth_range``.
443
+
444
+ Emitted on the pattern's MIDI channel.
445
+
446
+ Parameters:
447
+ parameter: 14-bit RPN parameter number (0–16383), or one of the
448
+ standard string names above.
449
+ value: Parameter value. 0–127 if ``fine=False``; 0–16383 if
450
+ ``fine=True``. Pitch bend sensitivity uses MSB = semitones
451
+ and LSB = cents, so set ``fine=True`` for sub-semitone control.
452
+ beat: Beat position within the pattern.
453
+ fine: If True, send 14-bit value via Data Entry MSB+LSB.
454
+ null_reset: If True (default), follow with the RPN null sentinel.
455
+
456
+ Example:
457
+ ```python
458
+ # Set pitch bend range to ±12 semitones
459
+ p.rpn("pitch_bend_sensitivity", 12)
460
+
461
+ # 4 semitones plus 50 cents
462
+ p.rpn("pitch_bend_sensitivity", 4 * 128 + 50, fine=True)
463
+ ```
464
+ """
465
+
466
+ param = self._resolve_rpn(parameter)
467
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
468
+
469
+ self._append_param_select(pulse, param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB)
470
+ self._append_data_entry(pulse, value, fine)
471
+
472
+ if null_reset:
473
+ self._append_null_reset(pulse)
474
+
475
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
476
+
477
+ def nrpn_ramp (
478
+ self,
479
+ parameter: typing.Union[int, str],
480
+ start: int,
481
+ end: int,
482
+ beat_start: float = 0.0,
483
+ beat_end: typing.Optional[float] = None,
484
+ resolution: int = 4,
485
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
486
+ fine: bool = True,
487
+ null_reset: bool = True,
488
+ ) -> "subsequence.pattern_builder.PatternBuilder":
489
+
490
+ """
491
+ Interpolate an NRPN value over a beat range.
492
+
493
+ The parameter is selected once at ``beat_start``; subsequent steps
494
+ emit only Data Entry messages. Synths track the most recently
495
+ selected NRPN per the spec, so re-selecting per step would just
496
+ waste bandwidth. If ``null_reset=True`` the RPN null sentinel is
497
+ appended once at ``beat_end``.
498
+
499
+ **Mid-ramp parameter persistence:** between ``beat_start`` and
500
+ ``beat_end`` the synth still has this NRPN selected. Avoid issuing
501
+ ``p.cc(6, …)`` or ``p.cc(38, …)`` on the same channel during the
502
+ ramp window — they would land on the ramped parameter rather than
503
+ acting as plain data-entry CCs.
504
+
505
+ Bandwidth note: with ``fine=True`` (default) every step emits two
506
+ CCs. Default ``resolution=4`` is one update every four pulses
507
+ (~83 ms at 120 BPM, where one pulse is ~21 ms), which keeps the bus
508
+ lightly loaded. Increase
509
+ ``resolution`` (e.g. ``8``) on slow DIN-MIDI links if you hear
510
+ other messages getting delayed.
511
+
512
+ Emitted on the pattern's MIDI channel.
513
+
514
+ Parameters:
515
+ parameter: 14-bit NRPN parameter number, or a string resolved
516
+ via the pattern's ``nrpn_name_map``.
517
+ start: Starting value (0–16383 when ``fine=True``, 0–127 when False).
518
+ end: Ending value.
519
+ beat_start: Beat position to begin the ramp.
520
+ beat_end: Beat position to end the ramp. Defaults to pattern length.
521
+ resolution: Pulses between Data Entry messages (default 4).
522
+ shape: Easing curve — string name or callable [0, 1] → [0, 1].
523
+ fine: If True (default), use full 14-bit Data Entry MSB+LSB.
524
+ null_reset: If True (default), append the null sentinel at the
525
+ end of the ramp (not per step).
526
+ """
527
+
528
+ param = self._resolve_nrpn(parameter)
529
+ self._validate_ramp_endpoints(start, end, fine)
530
+
531
+ if beat_end is None:
532
+ beat_end = self._pattern.length
533
+
534
+ pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
535
+
536
+ self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.NRPN_MSB, pymididefs.cc.NRPN_LSB)
537
+
538
+ def _event (pulse: int, val: float) -> None:
539
+ # Clamp guards against custom easing callables that overshoot [0, 1].
540
+ if fine:
541
+ value = max(0, min(16383, int(round(val))))
542
+ else:
543
+ value = max(0, min(127, int(round(val))))
544
+ self._append_data_entry(pulse, value, fine)
545
+
546
+ self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
547
+
548
+ if null_reset:
549
+ self._append_null_reset(pulse_end)
550
+
551
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
552
+
553
+ def rpn_ramp (
554
+ self,
555
+ parameter: typing.Union[int, str],
556
+ start: int,
557
+ end: int,
558
+ beat_start: float = 0.0,
559
+ beat_end: typing.Optional[float] = None,
560
+ resolution: int = 4,
561
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
562
+ fine: bool = True,
563
+ null_reset: bool = True,
564
+ ) -> "subsequence.pattern_builder.PatternBuilder":
565
+
566
+ """Interpolate an RPN value over a beat range.
567
+
568
+ Identical to :meth:`nrpn_ramp` but uses CC 101 / 100 for parameter
569
+ selection. String names resolve via ``pymididefs.rpn.RPN_MAP``.
570
+ The same mid-ramp persistence note applies: avoid plain ``p.cc(6, …)``
571
+ on this channel during the ramp window.
572
+ """
573
+
574
+ param = self._resolve_rpn(parameter)
575
+ self._validate_ramp_endpoints(start, end, fine)
576
+
577
+ if beat_end is None:
578
+ beat_end = self._pattern.length
579
+
580
+ pulse_end = int(beat_end * subsequence.constants.MIDI_QUARTER_NOTE)
581
+
582
+ self._append_param_select(int(beat_start * subsequence.constants.MIDI_QUARTER_NOTE), param, pymididefs.cc.RPN_MSB, pymididefs.cc.RPN_LSB)
583
+
584
+ def _event (pulse: int, val: float) -> None:
585
+ # Clamp guards against custom easing callables that overshoot [0, 1].
586
+ if fine:
587
+ value = max(0, min(16383, int(round(val))))
588
+ else:
589
+ value = max(0, min(127, int(round(val))))
590
+ self._append_data_entry(pulse, value, fine)
591
+
592
+ self._ramp_pulses(beat_start, beat_end, float(start), float(end), shape, resolution, _event)
593
+
594
+ if null_reset:
595
+ self._append_null_reset(pulse_end)
596
+
597
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
598
+
599
+ # ── Program change and SysEx ─────────────────────────────────────────────
600
+
601
+ def program_change (
602
+ self,
603
+ program: int,
604
+ beat: float = 0.0,
605
+ bank_msb: typing.Optional[int] = None,
606
+ bank_lsb: typing.Optional[int] = None,
607
+ ) -> "subsequence.pattern_builder.PatternBuilder":
608
+
609
+ """Send a Program Change message, optionally preceded by bank select.
610
+
611
+ Switches the instrument patch on this pattern's MIDI channel.
612
+ Program numbers follow the General MIDI numbering (0–127, where
613
+ e.g. 0 = Acoustic Grand Piano, 40 = Violin, 33 = Electric Bass).
614
+
615
+ To select a patch in a specific bank, provide ``bank_msb`` and/or
616
+ ``bank_lsb``. The bank select CC messages (CC 0 for MSB, CC 32 for
617
+ LSB) are sent at the same beat position immediately before the
618
+ program change, in the order the synthesizer expects.
619
+
620
+ Parameters:
621
+ program: Program (patch) number (0–127).
622
+ beat: Beat position within the pattern (default 0.0).
623
+ bank_msb: Bank select coarse (CC 0), 0–127. ``None`` = omit.
624
+ bank_lsb: Bank select fine (CC 32), 0–127. ``None`` = omit.
625
+
626
+ Example:
627
+ ```python
628
+ @composition.pattern(channel=1, beats=4)
629
+ def strings (p):
630
+ # GM — no bank needed
631
+ p.program_change(48)
632
+
633
+ # Roland JV-1080 bank 1, patch 48
634
+ p.program_change(48, bank_msb=81, bank_lsb=0)
635
+
636
+ # Change patch only at the first bar of each section
637
+ if p.section.bar == 0:
638
+ p.program_change(48, bank_msb=1)
639
+ ```
640
+ """
641
+
642
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
643
+
644
+ if bank_msb is not None:
645
+ self._pattern.cc_events.append(
646
+ subsequence.pattern.CcEvent(
647
+ pulse = pulse,
648
+ message_type = 'control_change',
649
+ control = 0,
650
+ value = max(0, min(127, int(round(bank_msb)))),
651
+ )
652
+ )
653
+
654
+ if bank_lsb is not None:
655
+ self._pattern.cc_events.append(
656
+ subsequence.pattern.CcEvent(
657
+ pulse = pulse,
658
+ message_type = 'control_change',
659
+ control = 32,
660
+ value = max(0, min(127, int(round(bank_lsb)))),
661
+ )
662
+ )
663
+
664
+ self._pattern.cc_events.append(
665
+ subsequence.pattern.CcEvent(
666
+ pulse = pulse,
667
+ message_type = 'program_change',
668
+ value = max(0, min(127, int(round(program)))),
669
+ )
670
+ )
671
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
672
+
673
+ def sysex (self, data: typing.Union[bytes, typing.List[int]], beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
674
+
675
+ """
676
+ Send a System Exclusive (SysEx) message at a beat position.
677
+
678
+ SysEx messages allow deep integration with synthesizers and other
679
+ hardware: patch dumps, parameter control, and vendor-specific commands.
680
+ The ``data`` argument should contain only the inner payload bytes,
681
+ without the surrounding ``0xF0`` / ``0xF7`` framing — mido adds those
682
+ automatically.
683
+
684
+ Parameters:
685
+ data: SysEx payload as ``bytes`` or a list of integers (0–127).
686
+ beat: Beat position within the pattern (default 0.0).
687
+
688
+ Example:
689
+ ```python
690
+ # GM System On — reset a GM-compatible device to defaults
691
+ p.sysex([0x7E, 0x7F, 0x09, 0x01])
692
+ ```
693
+ """
694
+
695
+ # Validate at build time: MIDI sysex payloads are 7-bit. A byte over
696
+ # 127 would be rejected by mido at dispatch and the message silently
697
+ # dropped every cycle with a misleading "device disconnected" log.
698
+ invalid = [b for b in data if not 0 <= b <= 127]
699
+
700
+ if invalid:
701
+ raise ValueError(
702
+ f"sysex data bytes must be 0-127 (7-bit MIDI data) — got {invalid[:4]}. "
703
+ "Mask computed values (checksums, packed parameters) with & 0x7F first."
704
+ )
705
+
706
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
707
+
708
+ self._pattern.cc_events.append(
709
+ subsequence.pattern.CcEvent(
710
+ pulse = pulse,
711
+ message_type = 'sysex',
712
+ data = bytes(data)
713
+ )
714
+ )
715
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
716
+
717
+ # ── OSC messages ─────────────────────────────────────────────────────────
718
+
719
+ def osc (self, address: str, *args: typing.Any, beat: float = 0.0) -> "subsequence.pattern_builder.PatternBuilder":
720
+
721
+ """
722
+ Send an OSC message at a beat position.
723
+
724
+ Requires ``composition.osc()`` to be called before ``composition.play()``.
725
+ If no OSC server is configured the event is silently dropped.
726
+
727
+ Parameters:
728
+ address: OSC address path (e.g. ``"/mixer/fader/1"``).
729
+ ``*args``: OSC arguments — float, int, str, or bytes.
730
+ beat: Beat position within the pattern (default 0.0).
731
+
732
+ Example:
733
+ ```python
734
+ # Enable a chorus effect at beat 2
735
+ p.osc("/fx/chorus/enable", 1, beat=2.0)
736
+
737
+ # Set a mixer pan value immediately
738
+ p.osc("/mixer/pan/1", -0.5)
739
+ ```
740
+ """
741
+
742
+ pulse = int(beat * subsequence.constants.MIDI_QUARTER_NOTE)
743
+
744
+ self._pattern.osc_events.append(
745
+ subsequence.pattern.OscEvent(
746
+ pulse = pulse,
747
+ address = address,
748
+ args = args
749
+ )
750
+ )
751
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
752
+
753
+ def osc_ramp (
754
+ self,
755
+ address: str,
756
+ start: float,
757
+ end: float,
758
+ beat_start: float = 0.0,
759
+ beat_end: typing.Optional[float] = None,
760
+ resolution: int = 4,
761
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear"
762
+ ) -> "subsequence.pattern_builder.PatternBuilder":
763
+
764
+ """
765
+ Interpolate an OSC float value over a beat range.
766
+
767
+ Generates one OSC message per ``resolution`` pulses, sending the
768
+ interpolated value to ``address`` at each step. Useful for smoothly
769
+ automating mixer faders, effect parameters, and other continuous controls
770
+ on a remote machine.
771
+
772
+ Requires ``composition.osc()`` to be called before ``composition.play()``.
773
+ If no OSC server is configured the events are silently dropped.
774
+
775
+ Parameters:
776
+ address: OSC address path (e.g. ``"/mixer/fader/1"``).
777
+ start: Starting float value.
778
+ end: Ending float value.
779
+ beat_start: Beat position to begin the ramp (default 0.0).
780
+ beat_end: Beat position to end the ramp. Defaults to pattern length.
781
+ resolution: Pulses between OSC messages (default 4 — approximately
782
+ 6 messages per beat at 120 BPM, which is smooth for fader
783
+ automation while keeping UDP traffic light). Use ``resolution=1``
784
+ for pulse-level precision.
785
+ shape: Easing curve — a name string (e.g. ``"ease_in"``) or any
786
+ callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
787
+ See :mod:`subsequence.easing` for available shapes.
788
+
789
+ Example:
790
+ ```python
791
+ # Fade a mixer fader up over 4 beats
792
+ p.osc_ramp("/mixer/fader/1", start=0.0, end=1.0)
793
+
794
+ # Ease in a reverb send over the last 2 beats
795
+ p.osc_ramp("/fx/reverb/wet", 0.0, 0.8, beat_start=2, beat_end=4, shape="ease_in")
796
+ ```
797
+ """
798
+
799
+ if beat_end is None:
800
+ beat_end = self._pattern.length
801
+
802
+ def _event (pulse: int, val: float) -> None:
803
+ self._pattern.osc_events.append(
804
+ subsequence.pattern.OscEvent(
805
+ pulse = pulse,
806
+ address = address,
807
+ args = (val,)
808
+ )
809
+ )
810
+
811
+ self._ramp_pulses(beat_start, beat_end, start, end, shape, resolution, _event)
812
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
813
+
814
+ # ── Note-correlated pitch bend ────────────────────────────────────────────
815
+
816
+ def _generate_bend_events (
817
+ self,
818
+ start_value: float,
819
+ end_value: float,
820
+ pulse_start: int,
821
+ pulse_end: int,
822
+ resolution: int,
823
+ shape: typing.Union[str, subsequence.easing.EasingFn],
824
+ ) -> None:
825
+
826
+ """Generate a series of pitchwheel CcEvents between two pulse positions.
827
+
828
+ Used by ``bend()``, ``portamento()``, and ``slide()``. Delegates the
829
+ span/resolution walk (including the emit-the-endpoint rule) to
830
+ :meth:`_ramp_pulse_span` and contributes only the pitchwheel
831
+ conversion — normalised value scaled to 14-bit and clamped — appending
832
+ events directly to ``self._pattern.cc_events``.
833
+
834
+ Parameters:
835
+ start_value: Normalised bend at the start of the ramp (-1.0 to 1.0).
836
+ end_value: Normalised bend at the end of the ramp (-1.0 to 1.0).
837
+ pulse_start: Absolute pulse position to start the ramp.
838
+ pulse_end: Absolute pulse position to end the ramp.
839
+ resolution: Number of pulses between consecutive events.
840
+ shape: Easing curve name or callable.
841
+ """
842
+
843
+ def _event (pulse: int, val: float) -> None:
844
+ midi_value = max(-8192, min(8191, int(round(val * 8192))))
845
+ self._pattern.cc_events.append(
846
+ subsequence.pattern.CcEvent(
847
+ pulse = pulse,
848
+ message_type = 'pitchwheel',
849
+ value = midi_value,
850
+ )
851
+ )
852
+
853
+ self._ramp_pulse_span(pulse_start, pulse_end, start_value, end_value, shape, resolution, _event)
854
+
855
+ def bend (
856
+ self,
857
+ note: int,
858
+ amount: float,
859
+ start: float = 0.0,
860
+ end: float = 1.0,
861
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
862
+ resolution: int = 1,
863
+ ) -> "subsequence.pattern_builder.PatternBuilder":
864
+
865
+ """Bend a specific note by index.
866
+
867
+ Generates a pitch bend ramp that covers a fraction of the target note's
868
+ duration, then resets to 0.0 at the next note's onset. Call this
869
+ *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final.
870
+
871
+ Parameters:
872
+ note: Note index (0 = first, -1 = last, etc.).
873
+ amount: Target bend normalised to -1.0..1.0 (positive = up).
874
+ With a standard ±2-semitone pitch wheel range, 0.5 = 1 semitone.
875
+ start: Fraction of the note's duration at which the ramp begins
876
+ (0.0 = note onset, default).
877
+ end: Fraction of the note's duration at which the ramp ends
878
+ (1.0 = note end, default).
879
+ shape: Easing curve — a name string (e.g. ``"ease_in"``) or any
880
+ callable mapping [0, 1] → [0, 1]. Defaults to ``"linear"``.
881
+ resolution: Pulses between pitch bend messages.
882
+
883
+ Raises:
884
+ IndexError: If *note* is out of range for the current pattern.
885
+
886
+ Example:
887
+ ```python
888
+ p.sequence(steps=[0, 4, 8, 12], pitches=midi_notes.E1)
889
+ p.legato(0.95)
890
+
891
+ # Bend the last note up one semitone (with ±2 st range), easing in
892
+ p.bend(note=-1, amount=0.5, shape="ease_in")
893
+
894
+ # Bend the second note down, starting halfway through
895
+ p.bend(note=1, amount=-0.3, start=0.5)
896
+ ```
897
+ """
898
+
899
+ if not self._pattern.steps:
900
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
901
+
902
+ sorted_positions = sorted(self._pattern.steps.keys())
903
+
904
+ # Resolve note index (supports negative indexing)
905
+ position = sorted_positions[note]
906
+ note_idx = note if note >= 0 else len(sorted_positions) + note
907
+
908
+ # Duration: use the longest note at this step
909
+ step = self._pattern.steps[position]
910
+ note_duration = max(n.duration for n in step.notes)
911
+
912
+ # Clamp start/end fractions and compute pulse range for the ramp
913
+ start_clamped = max(0.0, min(1.0, start))
914
+ end_clamped = max(0.0, min(1.0, end))
915
+ bend_start_pulse = position + int(note_duration * start_clamped)
916
+ bend_end_pulse = position + int(note_duration * end_clamped)
917
+
918
+ self._generate_bend_events(0.0, amount, bend_start_pulse, bend_end_pulse, resolution, shape)
919
+
920
+ # Reset bend at the next note's onset. For the last note that is the
921
+ # NEXT cycle's first onset (total + first), not pulse 0 - a bend tail
922
+ # spilling past the cycle end was cancelled mid-flight by a pulse-0
923
+ # reset, leaving the next cycle's first note bent.
924
+ if note_idx < len(sorted_positions) - 1:
925
+ reset_pulse = sorted_positions[note_idx + 1]
926
+ else:
927
+ total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
928
+ reset_pulse = total_pulses + sorted_positions[0]
929
+
930
+ reset_midi = max(-8192, min(8191, int(round(0.0 * 8192))))
931
+ self._pattern.cc_events.append(
932
+ subsequence.pattern.CcEvent(
933
+ pulse = reset_pulse,
934
+ message_type = 'pitchwheel',
935
+ value = reset_midi,
936
+ )
937
+ )
938
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
939
+
940
+ def portamento (
941
+ self,
942
+ time: float = 0.15,
943
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
944
+ resolution: int = 1,
945
+ bend_range: typing.Optional[float] = 2.0,
946
+ wrap: bool = True,
947
+ ) -> "subsequence.pattern_builder.PatternBuilder":
948
+
949
+ """Glide between all consecutive notes using pitch bend.
950
+
951
+ Generates a pitch bend ramp in the tail of each note, bending toward
952
+ the next note's pitch, then resets at the next note's onset. Call this
953
+ *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations are final.
954
+
955
+ Most effective on mono instruments where pitch bend is per-channel.
956
+
957
+ Parameters:
958
+ time: Fraction of each note's duration used for the glide
959
+ (default 0.15 — last 15% of the note).
960
+ shape: Easing curve. Defaults to ``"linear"``.
961
+ resolution: Pulses between pitch bend messages.
962
+ bend_range: Instrument's pitch wheel range in semitones
963
+ (default 2.0 — standard ±2 st). Pairs with intervals larger
964
+ than this value are skipped. Pass ``None`` to disable range
965
+ checking and always generate the bend (large intervals are
966
+ clamped to ±1.0).
967
+ wrap: If ``True`` (default), glide from the last note toward the
968
+ first note of the next cycle.
969
+
970
+ Example:
971
+ ```python
972
+ p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43])
973
+ p.legato(0.95)
974
+
975
+ # Gentle glide across all note transitions
976
+ p.portamento(time=0.15, shape="ease_in_out")
977
+
978
+ # Wide bend range (synth set to ±12 semitones)
979
+ p.portamento(time=0.2, bend_range=12)
980
+
981
+ # No range limit — bend as far as MIDI allows
982
+ p.portamento(time=0.1, bend_range=None)
983
+ ```
984
+ """
985
+
986
+ if bend_range is not None and bend_range <= 0:
987
+ raise ValueError(
988
+ f"bend_range must be a positive number of semitones (your instrument's "
989
+ f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking."
990
+ )
991
+
992
+ if not self._pattern.steps:
993
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
994
+
995
+ sorted_positions = sorted(self._pattern.steps.keys())
996
+ n = len(sorted_positions)
997
+
998
+ def _lowest_pitch (pos: int) -> int:
999
+ return min(note.pitch for note in self._pattern.steps[pos].notes)
1000
+
1001
+ def _longest_duration (pos: int) -> int:
1002
+ return max(note.duration for note in self._pattern.steps[pos].notes)
1003
+
1004
+ for i in range(n):
1005
+ a_pos = sorted_positions[i]
1006
+ is_last = (i == n - 1)
1007
+
1008
+ if is_last:
1009
+ if not wrap:
1010
+ continue
1011
+ b_pos = sorted_positions[0]
1012
+ else:
1013
+ b_pos = sorted_positions[i + 1]
1014
+
1015
+ interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos)
1016
+
1017
+ if bend_range is not None and abs(interval) > bend_range:
1018
+ continue
1019
+
1020
+ normaliser = bend_range if bend_range is not None else 2.0
1021
+ amount = max(-1.0, min(1.0, interval / normaliser))
1022
+
1023
+ a_duration = _longest_duration(a_pos)
1024
+ glide_start_pulse = a_pos + int(a_duration * (1.0 - time))
1025
+ glide_end_pulse = a_pos + a_duration
1026
+
1027
+ self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape)
1028
+
1029
+ # Reset at the destination note's onset. For the wrap-around pair
1030
+ # that is the NEXT cycle's first onset (total + first), not pulse 0
1031
+ # - a glide spilling past the cycle end was cancelled mid-flight by
1032
+ # the pulse-0 reset, leaving the first note fully bent.
1033
+ if is_last:
1034
+ total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
1035
+ reset_pulse = total_pulses + sorted_positions[0]
1036
+ else:
1037
+ reset_pulse = b_pos
1038
+ self._pattern.cc_events.append(
1039
+ subsequence.pattern.CcEvent(
1040
+ pulse = reset_pulse,
1041
+ message_type = 'pitchwheel',
1042
+ value = 0,
1043
+ )
1044
+ )
1045
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
1046
+
1047
+ def slide (
1048
+ self,
1049
+ notes: typing.Optional[typing.List[int]] = None,
1050
+ steps: typing.Optional[typing.List[int]] = None,
1051
+ time: float = 0.15,
1052
+ shape: typing.Union[str, subsequence.easing.EasingFn] = "linear",
1053
+ resolution: int = 1,
1054
+ bend_range: typing.Optional[float] = 2.0,
1055
+ wrap: bool = True,
1056
+ extend: bool = True,
1057
+ ) -> "subsequence.pattern_builder.PatternBuilder":
1058
+
1059
+ """TB-303-style selective slide into specific notes.
1060
+
1061
+ Like ``portamento()`` but only applies to flagged destination notes.
1062
+ Specify target notes by index (``notes=[1, 3]``) or by step grid
1063
+ position (``steps=[4, 12]``). If ``extend=True`` (default) the
1064
+ preceding note's duration is extended to meet the slide target, matching
1065
+ the 303's behaviour where slide notes do not retrigger.
1066
+
1067
+ Call this *after* ``legato()`` / ``detached()`` / ``duration()`` so that note durations
1068
+ are final.
1069
+
1070
+ Parameters:
1071
+ notes: List of note indices to slide *into* (0 = first).
1072
+ Supports negative indexing. Mutually exclusive with *steps*.
1073
+ steps: List of step grid indices to slide *into*.
1074
+ Converted to pulse positions using ``self._default_grid``.
1075
+ Mutually exclusive with *notes*.
1076
+ time: Fraction of the preceding note's duration used for the glide.
1077
+ shape: Easing curve. Defaults to ``"linear"``.
1078
+ resolution: Pulses between pitch bend messages.
1079
+ bend_range: Instrument's pitch wheel range in semitones
1080
+ (default 2.0). Pairs with larger intervals are skipped.
1081
+ Pass ``None`` to disable range checking.
1082
+ wrap: If ``True`` (default), include a wrap-around slide from the
1083
+ last note back toward the first.
1084
+ extend: If ``True`` (default), extend the preceding note's duration
1085
+ to reach the slide target's onset — 303-style legato through
1086
+ the glide.
1087
+
1088
+ Raises:
1089
+ ValueError: If neither or both of *notes* and *steps* are provided,
1090
+ or a note index falls outside the pattern's notes.
1091
+
1092
+ Example:
1093
+ ```python
1094
+ p.sequence(steps=[0, 4, 8, 12], pitches=[40, 42, 40, 43])
1095
+ p.legato(0.95)
1096
+
1097
+ # Slide into the 2nd and 4th notes
1098
+ p.slide(notes=[1, 3], time=0.2, shape="ease_in")
1099
+
1100
+ # Same using step grid indices
1101
+ p.slide(steps=[4, 12], time=0.2, shape="ease_in")
1102
+
1103
+ # Slide without extending the preceding note
1104
+ p.slide(notes=[1, 3], extend=False)
1105
+ ```
1106
+ """
1107
+
1108
+ if notes is None and steps is None:
1109
+ raise ValueError("slide() requires either 'notes' or 'steps'")
1110
+
1111
+ if notes is not None and steps is not None:
1112
+ raise ValueError("slide() takes notes= or steps=, not both — they name the same slide targets two different ways")
1113
+
1114
+ if bend_range is not None and bend_range <= 0:
1115
+ raise ValueError(
1116
+ f"bend_range must be a positive number of semitones (your instrument's "
1117
+ f"pitch-wheel range) — got {bend_range}. Pass None to disable range checking."
1118
+ )
1119
+
1120
+ if not self._pattern.steps:
1121
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)
1122
+
1123
+ sorted_positions = sorted(self._pattern.steps.keys())
1124
+ total_pulses = int(self._pattern.length * subsequence.constants.MIDI_QUARTER_NOTE)
1125
+ n = len(sorted_positions)
1126
+
1127
+ # Resolve flagged pulse positions
1128
+ if notes is not None:
1129
+ flagged: typing.Set[int] = set()
1130
+ for idx in notes:
1131
+ if not -n <= idx < n:
1132
+ raise ValueError(f"slide() note index {idx} is outside this pattern's {n} notes")
1133
+
1134
+ flagged.add(sorted_positions[idx])
1135
+ else:
1136
+ # steps is not None. Resolve each grid step to the SAME pulse the
1137
+ # placement methods use — int(step * (length / grid) * PPQ) — so the
1138
+ # flag lands on the note even when the grid doesn't divide the bar
1139
+ # evenly. Floored uniform spacing (total_pulses // grid) drifts out of
1140
+ # alignment on non-divisor grids, silently flagging nothing.
1141
+ step_beats = self._pattern.length / self._default_grid
1142
+ flagged = set()
1143
+ for s in (steps or []):
1144
+ flagged.add(int(s * step_beats * subsequence.constants.MIDI_QUARTER_NOTE))
1145
+
1146
+ def _lowest_pitch (pos: int) -> int:
1147
+ return min(note.pitch for note in self._pattern.steps[pos].notes)
1148
+
1149
+ def _longest_duration (pos: int) -> int:
1150
+ return max(note.duration for note in self._pattern.steps[pos].notes)
1151
+
1152
+ for i in range(n):
1153
+ a_pos = sorted_positions[i]
1154
+ is_last = (i == n - 1)
1155
+
1156
+ if is_last:
1157
+ if not wrap:
1158
+ continue
1159
+ b_pos = sorted_positions[0]
1160
+ else:
1161
+ b_pos = sorted_positions[i + 1]
1162
+
1163
+ # Only generate glide if the destination is flagged
1164
+ if b_pos not in flagged:
1165
+ continue
1166
+
1167
+ interval = _lowest_pitch(b_pos) - _lowest_pitch(a_pos)
1168
+
1169
+ if bend_range is not None and abs(interval) > bend_range:
1170
+ continue
1171
+
1172
+ normaliser = bend_range if bend_range is not None else 2.0
1173
+ amount = max(-1.0, min(1.0, interval / normaliser))
1174
+
1175
+ # Optionally extend preceding note to meet the target onset (303 style)
1176
+ if extend:
1177
+ if is_last:
1178
+ gap = (total_pulses - a_pos) + sorted_positions[0]
1179
+ else:
1180
+ gap = b_pos - a_pos
1181
+ for note in self._pattern.steps[a_pos].notes:
1182
+ note.duration = gap
1183
+
1184
+ # Read the duration AFTER any extension so the glide occupies the
1185
+ # tail of the note as actually played and lands on the target
1186
+ # onset. (Reading it before the extend block made the bend jump
1187
+ # near the note's start and then hold flat - the opposite of a
1188
+ # slide.)
1189
+ a_duration = _longest_duration(a_pos)
1190
+
1191
+ glide_start_pulse = a_pos + int(a_duration * (1.0 - time))
1192
+ glide_end_pulse = a_pos + a_duration
1193
+
1194
+ self._generate_bend_events(0.0, amount, glide_start_pulse, glide_end_pulse, resolution, shape)
1195
+
1196
+ # Reset at the destination note's onset. For the wrap-around pair
1197
+ # the destination is the NEXT cycle's first onset (total_pulses +
1198
+ # first onset): resetting at pulse 0 fired while a spilled glide
1199
+ # was still in flight, so the destination note played fully bent.
1200
+ reset_pulse = b_pos if not is_last else total_pulses + sorted_positions[0]
1201
+ self._pattern.cc_events.append(
1202
+ subsequence.pattern.CcEvent(
1203
+ pulse = reset_pulse,
1204
+ message_type = 'pitchwheel',
1205
+ value = 0,
1206
+ )
1207
+ )
1208
+ return typing.cast("subsequence.pattern_builder.PatternBuilder", self)