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/osc.py ADDED
@@ -0,0 +1,194 @@
1
+ """OSC integration for realtime control and state broadcasting.
2
+
3
+ Start the OSC server by calling ``composition.osc()`` before ``composition.play()``.
4
+ The server listens on a UDP port (default 9000) for incoming control messages
5
+ and sends state updates to a target host/port (default 127.0.0.1:9001).
6
+
7
+ Built-in Receive Handlers
8
+ ─────────────────────────
9
+
10
+ - ``/bpm <int>``: Set tempo
11
+ - ``/mute/<name>``: Mute a pattern
12
+ - ``/unmute/<name>``: Unmute a pattern
13
+ - ``/data/<key> <value>``: Update shared data (supports int, float, str)
14
+
15
+ Built-in Send Events
16
+ ────────────────────
17
+
18
+ - ``/bar <int>``: On bar change
19
+ - ``/chord <string>``: On chord change
20
+ - ``/section <string>``: On section change
21
+ - ``/bpm <int>``: On tempo change
22
+ """
23
+
24
+ import asyncio
25
+ import logging
26
+ import typing
27
+
28
+ import pythonosc.dispatcher
29
+ import pythonosc.osc_server
30
+ import pythonosc.udp_client
31
+
32
+ if typing.TYPE_CHECKING:
33
+ from subsequence.composition import Composition
34
+
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class OscServer:
40
+
41
+ """Async OSC server/client for bi-directional communication."""
42
+
43
+ def __init__ (
44
+ self,
45
+ composition: "Composition",
46
+ receive_port: int = 9000,
47
+ send_port: int = 9001,
48
+ send_host: str = "127.0.0.1",
49
+ receive_host: str = "0.0.0.0"
50
+ ) -> None:
51
+
52
+ """
53
+ Wire up the OSC ports and built-in control handlers; call start() to begin listening.
54
+ """
55
+
56
+ self._composition = composition
57
+ self._receive_port = receive_port
58
+ self._receive_host = receive_host
59
+ self._send_port = send_port
60
+ self._send_host = send_host
61
+
62
+ self._server: typing.Optional[typing.Any] = None
63
+ self._transport: typing.Optional[asyncio.BaseTransport] = None
64
+ self._client: typing.Optional[pythonosc.udp_client.SimpleUDPClient] = None
65
+ self._dispatcher = pythonosc.dispatcher.Dispatcher()
66
+
67
+ # Register built-in handlers
68
+ self._dispatcher.map("/bpm", self._handle_bpm)
69
+ self._dispatcher.map("/mute/*", self._handle_mute)
70
+ self._dispatcher.map("/unmute/*", self._handle_unmute)
71
+ self._dispatcher.map("/data/*", self._handle_data)
72
+
73
+
74
+ async def start (self) -> None:
75
+
76
+ """Start the OSC server and client."""
77
+
78
+ # client for sending
79
+ self._client = pythonosc.udp_client.SimpleUDPClient(self._send_host, self._send_port)
80
+
81
+ # server for receiving
82
+ self._server = pythonosc.osc_server.AsyncIOOSCUDPServer(
83
+ (self._receive_host, self._receive_port),
84
+ self._dispatcher,
85
+ asyncio.get_event_loop() # type: ignore[arg-type]
86
+ )
87
+
88
+ transport, _ = await self._server.create_serve_endpoint()
89
+ self._transport = transport
90
+
91
+ logger.info(f"OSC listening on :{self._receive_port}, sending to {self._send_host}:{self._send_port}")
92
+
93
+
94
+ async def stop (self) -> None:
95
+
96
+ """Stop the OSC server and close the outgoing client socket."""
97
+
98
+ if self._transport:
99
+ self._transport.close()
100
+ self._transport = None
101
+ logger.info("OSC server stopped")
102
+
103
+ if self._client is not None:
104
+ # python-osc's SimpleUDPClient owns a raw socket; close it so a
105
+ # stopped composition doesn't keep a zombie sender alive.
106
+ try:
107
+ self._client._sock.close()
108
+ except (AttributeError, OSError):
109
+ pass
110
+ self._client = None
111
+
112
+
113
+ def send (self, address: str, *args: typing.Any) -> None:
114
+
115
+ """Send an OSC message."""
116
+
117
+ if self._client:
118
+ try:
119
+ self._client.send_message(address, args)
120
+ except Exception as e:
121
+ logger.warning(f"OSC send error: {e}")
122
+
123
+
124
+ def map (self, address: str, handler: typing.Callable) -> None:
125
+
126
+ """Register a custom OSC handler."""
127
+
128
+ self._dispatcher.map(address, handler)
129
+
130
+
131
+ # Handlers
132
+
133
+ def _handle_bpm (self, address: str, *args: typing.Any) -> None:
134
+
135
+ """
136
+ Set the composition tempo from an incoming ``/bpm <int>`` message.
137
+ """
138
+
139
+ if not args:
140
+ return
141
+
142
+ try:
143
+ bpm = int(args[0])
144
+ self._composition.set_bpm(bpm)
145
+ except (ValueError, TypeError):
146
+ logger.warning(f"Invalid OSC BPM argument: {args[0]}")
147
+
148
+ def _handle_mute (self, address: str, *args: typing.Any) -> None:
149
+
150
+ """
151
+ Silence the pattern named in an incoming ``/mute/<name>`` message.
152
+ """
153
+
154
+ # address is like /mute/drums
155
+ parts = address.split("/")
156
+ if len(parts) >= 3:
157
+ name = parts[2]
158
+ self._composition.mute(name)
159
+
160
+ def _handle_unmute (self, address: str, *args: typing.Any) -> None:
161
+
162
+ """
163
+ Bring back the pattern named in an incoming ``/unmute/<name>`` message.
164
+ """
165
+
166
+ parts = address.split("/")
167
+ if len(parts) >= 3:
168
+ name = parts[2]
169
+ self._composition.unmute(name)
170
+
171
+ def _handle_data (self, address: str, *args: typing.Any) -> None:
172
+
173
+ """
174
+ Update a composition.data value from an incoming ``/data/<key> <value>`` message, preserving the existing numeric type.
175
+ """
176
+
177
+ # address is like /data/intensity
178
+ if not args:
179
+ return
180
+
181
+ parts = address.split("/")
182
+ if len(parts) >= 3:
183
+ key = parts[2]
184
+ val = args[0]
185
+ if key in self._composition.data:
186
+ existing = self._composition.data[key]
187
+ if isinstance(existing, (float, int)):
188
+ try:
189
+ val = float(val) if isinstance(existing, float) else int(val)
190
+ except (ValueError, TypeError):
191
+ logger.warning(f"OSC /data: failed to cast {val} to numeric for key {key}")
192
+ return
193
+
194
+ self._composition.data[key] = val
subsequence/pattern.py ADDED
@@ -0,0 +1,363 @@
1
+ """Immutable note and pattern data types — the rendered output layer.
2
+
3
+ Defines ``Note`` (a single scheduled MIDI event) alongside the control-event
4
+ records (``CcEvent``, ``RawNoteEvent``, ``OscEvent``) and ``Pattern``, the
5
+ ordered bag of events that ``PatternBuilder`` produces and the sequencer
6
+ schedules. These are plain data; the building verbs live in
7
+ ``pattern_builder``.
8
+ """
9
+
10
+ import dataclasses
11
+ import typing
12
+
13
+ import subsequence.constants
14
+ import subsequence.constants.velocity
15
+
16
+
17
+ # A mirror destination: ``(device, channel)`` or, to re-resolve drum names
18
+ # per device, ``(device, channel, drum_note_map)``. The optional third element
19
+ # lets a mirrored drum hit sound the correct voice on a device whose drum map
20
+ # differs from the primary's — see ``Sequencer.schedule_pattern``. The
21
+ # user-facing entry points accept any of these (and lists, for JSON sources);
22
+ # ``Composition._resolve_mirrors`` normalises channel numbering before storing.
23
+ MirrorSpec = typing.Union[
24
+ typing.Tuple[int, int],
25
+ typing.Tuple[int, int, typing.Optional[typing.Dict[str, int]]],
26
+ ]
27
+
28
+
29
+ @dataclasses.dataclass
30
+ class Note:
31
+
32
+ """
33
+ Represents a single MIDI note.
34
+ """
35
+
36
+ pitch: int
37
+ velocity: int
38
+ duration: int
39
+ channel: int
40
+ origin: typing.Optional[str] = None # Original drum-name string (if the pitch was named), kept so mirror destinations can re-resolve it through their own drum_note_map. None for numeric/pitched notes.
41
+ primary_unmapped: bool = False # True when origin was NOT in the pattern's own (primary) drum_note_map — the primary device has no such voice, so it stays silent; only mirror destinations whose maps contain origin sound it. pitch then holds a placeholder (a mirror's value) used only by transforms/display, never for playback.
42
+
43
+
44
+ @dataclasses.dataclass
45
+ class CcEvent:
46
+
47
+ """
48
+ A MIDI non-note event (CC, pitch bend, program change, SysEx) at a pulse position.
49
+ """
50
+
51
+ pulse: int
52
+ message_type: str # 'control_change', 'pitchwheel', 'program_change', or 'sysex'
53
+ control: int = 0 # CC number (0–127), ignored for other types
54
+ value: int = 0 # 0–127 for CC/program_change, -8192..8191 for pitchwheel
55
+ data: typing.Optional[bytes] = None # Raw bytes payload for SysEx messages
56
+ channel: typing.Optional[int] = None # If set, overrides pattern.channel for this event
57
+ device: typing.Optional[int] = None # If set, overrides pattern.device for this event
58
+ priority: int = 0 # Same-pulse dispatch order vs notes: negative fires BEFORE note_on (tuning onset bends), 0 keeps FIFO order
59
+
60
+
61
+ @dataclasses.dataclass
62
+ class RawNoteEvent:
63
+
64
+ """
65
+ An explicit Note On or Note Off event at a pulse position, ignoring durations.
66
+ Used for drones and infinite notes.
67
+ """
68
+
69
+ pulse: int
70
+ message_type: str # 'note_on' or 'note_off'
71
+ pitch: int
72
+ velocity: int = 0
73
+ origin: typing.Optional[str] = None # Original drum-name string, kept so mirror destinations re-resolve it through their own drum_note_map (same contract as Note.origin)
74
+ primary_unmapped: bool = False # Kept for _destination_pitch compatibility; always False for drones (an unvoiceable name is dropped at build time)
75
+
76
+
77
+ @dataclasses.dataclass
78
+ class OscEvent:
79
+
80
+ """
81
+ An OSC message scheduled at a pulse position within a pattern.
82
+ """
83
+
84
+ pulse: int
85
+ address: str
86
+ args: typing.Tuple[typing.Any, ...] = ()
87
+
88
+
89
+ @dataclasses.dataclass
90
+ class Step:
91
+
92
+ """
93
+ Represents a collection of notes at a single point in time.
94
+ """
95
+
96
+ notes: typing.List[Note] = dataclasses.field(default_factory=list)
97
+
98
+
99
+ class Pattern:
100
+
101
+ """
102
+ Allows us to define and manipulate music pattern objects.
103
+ """
104
+
105
+ def __init__ (self, channel: int, length: float = 16, reschedule_lookahead: float = 1, device: int = 0, mirrors: typing.Optional[typing.Iterable[MirrorSpec]] = None) -> None:
106
+
107
+ """
108
+ Initialize a new pattern with MIDI channel, length in beats, and reschedule lookahead.
109
+
110
+ Parameters:
111
+ channel: The MIDI channel (0-15) this pattern will output to.
112
+ length: The duration of the pattern before it loops/rebuilds, measured
113
+ in beats (e.g., 16 = 4 bars in 4/4 time). Defaults to 16.
114
+ reschedule_lookahead: How many beats before the end of the pattern the next
115
+ cycle is built. Defaults to 1 beat. This provides a safe computational
116
+ buffer so events are queued before the clock actually needs them.
117
+ device: Output device index (0-indexed). 0 = primary device (default).
118
+ mirrors: Additional ``(device, channel)`` destinations to duplicate every
119
+ note, CC, pitch bend, program change, SysEx, NRPN/RPN burst, and
120
+ drone event onto. Both ``device`` and ``channel`` are 0-indexed in
121
+ canonical form; the user-facing entry points (decorator and runtime
122
+ API on ``Composition``) translate the user's channel-numbering
123
+ convention before storing here. An entry may carry an optional
124
+ third element — a ``drum_note_map`` — so a mirrored drum hit is
125
+ re-resolved by name to that device's own note number (see
126
+ ``Sequencer.schedule_pattern``).
127
+ """
128
+
129
+ self.channel = channel
130
+ self.length = length
131
+ self.reschedule_lookahead = reschedule_lookahead
132
+ self.device = device
133
+ self.mirrors: typing.List[MirrorSpec] = list(mirrors) if mirrors else []
134
+
135
+ # Set to True by ``Composition.unregister()`` to signal the sequencer's
136
+ # reschedule loop to stop re-adding this pattern. Lazy removal: events
137
+ # already queued in ``event_queue`` play out; sustaining notes are
138
+ # stopped by the unregister() call, but no new cycles fire.
139
+ self._removed: bool = False
140
+
141
+ # Absolute pulse where the cycle currently being (re)built starts.
142
+ # Written by the sequencer on schedule and on every reschedule; read
143
+ # by rebuilds that place the cycle on the absolute beat axis (the
144
+ # harmony window).
145
+ self._cycle_start_pulse: int = 0
146
+
147
+ self.steps: typing.Dict[int, Step] = {}
148
+ self.cc_events: typing.List[CcEvent] = []
149
+ self.osc_events: typing.List[OscEvent] = []
150
+ self.raw_note_events: typing.List[RawNoteEvent] = []
151
+
152
+ # Drum names already warned about (absent from every destination map)
153
+ # so the per-cycle rebuild warns once, not every bar. A hot-reload
154
+ # builds a fresh Pattern, which resets this — re-surfacing the warning.
155
+ self._warned_drum_names: typing.Set[str] = set()
156
+
157
+ # Likewise warn once if a positioned chord/strum (beat != 0) uses sustain=/detached=,
158
+ # which size their ring from the pattern length rather than from beat.
159
+ self._warned_positioned_articulation: bool = False
160
+
161
+
162
+ def add_note (self, position: int, pitch: int, velocity: int, duration: int, origin: typing.Optional[str] = None, primary_unmapped: bool = False) -> None:
163
+
164
+ """
165
+ Add a note to the pattern at a specific pulse position.
166
+
167
+ ``origin`` is the original drum-name string when the pitch was named
168
+ (e.g. ``"hi_hat_closed"``), or ``None`` for numeric pitches. It is
169
+ carried on the Note so mirror destinations can re-resolve the name
170
+ through their own ``drum_note_map`` — see ``Sequencer.schedule_pattern``.
171
+
172
+ ``primary_unmapped`` marks a named hit whose ``origin`` is absent from
173
+ this pattern's own ``drum_note_map`` but present in a mirror's — the
174
+ primary device can't voice it, so it stays silent and only the mapping
175
+ mirror(s) sound it.
176
+ """
177
+
178
+ if position not in self.steps:
179
+ self.steps[position] = Step()
180
+
181
+ note = Note(
182
+ pitch = pitch,
183
+ velocity = velocity,
184
+ duration = duration,
185
+ channel = self.channel,
186
+ origin = origin,
187
+ primary_unmapped = primary_unmapped
188
+ )
189
+
190
+ self.steps[position].notes.append(note)
191
+
192
+
193
+ def add_sequence (self, sequence: typing.List[int], spacing_pulses: int, pitch: int, velocity: typing.Union[int, typing.List[int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, note_duration: int = 6) -> None:
194
+
195
+ """
196
+ Add a sequence of notes to the pattern.
197
+ """
198
+
199
+ if isinstance(velocity, int):
200
+ velocity = [velocity] * len(sequence)
201
+
202
+ # An explicit empty velocity list with hits to place has no velocity
203
+ # to give them — say so instead of a bare ZeroDivisionError at the
204
+ # modulo below (matches the builder-level _expand_sequence_param).
205
+ if not velocity and any(sequence):
206
+ raise ValueError("add_sequence(): velocity list cannot be empty")
207
+
208
+ for i, hit in enumerate(sequence):
209
+
210
+ if hit:
211
+
212
+ # Handle case where velocity list might be shorter than sequence
213
+ vel = velocity[i % len(velocity)]
214
+
215
+ self.add_note(
216
+ position = i * spacing_pulses,
217
+ pitch = pitch,
218
+ velocity = int(vel),
219
+ duration = note_duration
220
+ )
221
+
222
+ def add_note_beats (self, beat_position: float, pitch: int, velocity: int, duration_beats: float, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, origin: typing.Optional[str] = None, primary_unmapped: bool = False) -> None:
223
+
224
+ """
225
+ Add a note to the pattern at a beat position.
226
+
227
+ ``origin`` and ``primary_unmapped`` are forwarded to ``add_note`` so
228
+ the resulting Note carries the drum name and its primary-map status.
229
+ """
230
+
231
+ if beat_position < 0:
232
+ raise ValueError("Beat position cannot be negative")
233
+
234
+ if duration_beats <= 0:
235
+ raise ValueError("Beat duration must be positive")
236
+
237
+ if pulses_per_beat <= 0:
238
+ raise ValueError("Pulses per beat must be positive")
239
+
240
+ position = int(beat_position * pulses_per_beat)
241
+
242
+ # A positive duration shorter than one pulse clamps to one pulse —
243
+ # the shortest sound the clock can represent — matching the duration
244
+ # transforms (legato/detached/stretch), which clamp the same way.
245
+ duration = max(1, int(duration_beats * pulses_per_beat))
246
+
247
+ self.add_note(
248
+ position = position,
249
+ pitch = pitch,
250
+ velocity = velocity,
251
+ duration = duration,
252
+ origin = origin,
253
+ primary_unmapped = primary_unmapped
254
+ )
255
+
256
+
257
+ def add_sequence_beats (self, sequence: typing.List[int], spacing_beats: float, pitch: int, velocity: typing.Union[int, typing.List[int]] = subsequence.constants.velocity.DEFAULT_VELOCITY, note_duration_beats: float = 0.25, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE) -> None:
258
+
259
+ """
260
+ Add a sequence of notes using beat durations.
261
+ """
262
+
263
+ if spacing_beats <= 0:
264
+ raise ValueError("Spacing must be positive")
265
+
266
+ if note_duration_beats <= 0:
267
+ raise ValueError("Note duration must be positive")
268
+
269
+ if pulses_per_beat <= 0:
270
+ raise ValueError("Pulses per beat must be positive")
271
+
272
+ spacing_pulses = int(spacing_beats * pulses_per_beat)
273
+ note_duration = int(note_duration_beats * pulses_per_beat)
274
+
275
+ if spacing_pulses <= 0:
276
+ raise ValueError("Spacing must be at least one pulse")
277
+
278
+ if note_duration <= 0:
279
+ raise ValueError("Note duration must be at least one pulse")
280
+
281
+ self.add_sequence(
282
+ sequence = sequence,
283
+ spacing_pulses = spacing_pulses,
284
+ pitch = pitch,
285
+ velocity = velocity,
286
+ note_duration = note_duration
287
+ )
288
+
289
+ def add_arpeggio_beats (self, pitches: typing.List[int], spacing_beats: float, velocity: int = subsequence.constants.velocity.DEFAULT_VELOCITY, duration_beats: typing.Optional[float] = None, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE) -> None:
290
+
291
+ """
292
+ Add an arpeggio that cycles through pitches at regular intervals.
293
+ """
294
+
295
+ if not pitches:
296
+ raise ValueError("Pitches list cannot be empty")
297
+
298
+ if spacing_beats <= 0:
299
+ raise ValueError("Spacing must be positive")
300
+
301
+ if pulses_per_beat <= 0:
302
+ raise ValueError("Pulses per beat must be positive")
303
+
304
+ if duration_beats is None:
305
+ duration_beats = spacing_beats
306
+
307
+ if duration_beats <= 0:
308
+ raise ValueError("Note duration must be positive")
309
+
310
+ beat = 0.0
311
+ pitch_index = 0
312
+
313
+ while beat < self.length:
314
+ pitch = pitches[pitch_index % len(pitches)]
315
+ self.add_note_beats(
316
+ beat_position = beat,
317
+ pitch = pitch,
318
+ velocity = velocity,
319
+ duration_beats = duration_beats,
320
+ pulses_per_beat = pulses_per_beat
321
+ )
322
+ beat += spacing_beats
323
+ pitch_index += 1
324
+
325
+
326
+ def add_raw_note_beats (self, message_type: str, beat_position: float, pitch: int, velocity: int = 0, pulses_per_beat: int = subsequence.constants.MIDI_QUARTER_NOTE, origin: typing.Optional[str] = None) -> None:
327
+
328
+ """
329
+ Add a raw Note On or Note Off event at a beat position (ignores duration).
330
+
331
+ ``origin`` carries the drum-name string (if the pitch was named) so
332
+ mirror destinations can re-resolve it through their own maps.
333
+ """
334
+
335
+ if message_type not in ('note_on', 'note_off'):
336
+ raise ValueError("message_type must be 'note_on' or 'note_off'")
337
+
338
+ if beat_position < 0:
339
+ raise ValueError("Beat position cannot be negative")
340
+
341
+ if pulses_per_beat <= 0:
342
+ raise ValueError("Pulses per beat must be positive")
343
+
344
+ position = int(beat_position * pulses_per_beat)
345
+
346
+ self.raw_note_events.append(
347
+ RawNoteEvent(
348
+ pulse = position,
349
+ message_type = message_type,
350
+ pitch = pitch,
351
+ velocity = velocity,
352
+ origin = origin
353
+ )
354
+ )
355
+
356
+
357
+ def on_reschedule (self) -> None:
358
+
359
+ """
360
+ Hook called immediately before the pattern is rescheduled.
361
+ """
362
+
363
+ return None