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,2086 @@
1
+ """The ``Sequencer`` — clock, scheduling, and MIDI delivery.
2
+
3
+ Owns the pulse clock, the event heap, and the output ports, turning scheduled
4
+ patterns and callbacks into timed MIDI messages. ``Composition`` drives one
5
+ internally; you rarely construct it directly.
6
+ """
7
+
8
+ import asyncio
9
+ import collections
10
+ import dataclasses
11
+ import heapq
12
+ import itertools
13
+ import datetime
14
+ import logging
15
+ import threading
16
+ import time
17
+ import typing
18
+
19
+ import mido
20
+
21
+ import subsequence.constants
22
+ import subsequence.easing
23
+ import subsequence.event_emitter
24
+ import subsequence.held_notes
25
+ import subsequence.midi_utils
26
+
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ @typing.runtime_checkable
32
+ class PatternLike (typing.Protocol):
33
+
34
+ """
35
+ Protocol for pattern objects that can be scheduled.
36
+ """
37
+
38
+ channel: int
39
+ device: int
40
+ length: float
41
+ reschedule_lookahead: float
42
+ steps: typing.Dict[int, typing.Any]
43
+ _cycle_start_pulse: int
44
+
45
+
46
+ def on_reschedule (self) -> None:
47
+
48
+ """
49
+ Hook called immediately before the pattern is rescheduled.
50
+ """
51
+
52
+ ...
53
+
54
+
55
+ @dataclasses.dataclass (order=True)
56
+ class MidiEvent:
57
+
58
+ """
59
+ Represents a MIDI event scheduled at a specific pulse.
60
+
61
+ ``sequence`` is a FIFO tie-breaker: when two events share a pulse, the
62
+ one pushed first dispatches first. Without it, ``heapq`` ordering of
63
+ same-pulse events is undefined, which would break NRPN/RPN sequences
64
+ (CC 99 → 98 → 6 → 38) and risk reordering bank-select-before-program-change.
65
+ The Sequencer assigns ``sequence`` via its monotonic ``_event_counter``
66
+ at push time (see ``Sequencer._push_event``); direct constructions in
67
+ tests can leave it at the default.
68
+
69
+ ``priority`` outranks the FIFO tie-breaker at a shared pulse: events
70
+ with a lower priority dispatch first. Notes are pushed before CC
71
+ events, so a tuning onset bend (which must reach the synth BEFORE its
72
+ note_on) carries priority ``-1``; everything else stays at ``0``.
73
+ """
74
+
75
+ pulse: int
76
+ message_type: str = dataclasses.field(compare=False)
77
+ channel: int = dataclasses.field(compare=False)
78
+ note: int = dataclasses.field(compare=False, default=0)
79
+ velocity: int = dataclasses.field(compare=False, default=0)
80
+ control: int = dataclasses.field(compare=False, default=0)
81
+ value: int = dataclasses.field(compare=False, default=0)
82
+ data: typing.Any = dataclasses.field(compare=False, default=None)
83
+ device: int = dataclasses.field(compare=False, default=0)
84
+ priority: int = 0
85
+ sequence: int = 0
86
+
87
+
88
+ def to_mido (self) -> typing.Optional[typing.Union[mido.Message, mido.MetaMessage]]:
89
+
90
+ """Convert this event to a mido.Message, or None if it's an internal type (like OSC)."""
91
+
92
+ if self.message_type in ('note_on', 'note_off'):
93
+ return mido.Message(
94
+ self.message_type,
95
+ channel = self.channel,
96
+ note = self.note,
97
+ velocity = self.velocity
98
+ )
99
+
100
+ if self.message_type == 'control_change':
101
+ return mido.Message(
102
+ 'control_change',
103
+ channel = self.channel,
104
+ control = self.control,
105
+ value = self.value
106
+ )
107
+
108
+ if self.message_type == 'pitchwheel':
109
+ return mido.Message(
110
+ 'pitchwheel',
111
+ channel = self.channel,
112
+ pitch = self.value
113
+ )
114
+
115
+ if self.message_type == 'program_change':
116
+ return mido.Message(
117
+ 'program_change',
118
+ channel = self.channel,
119
+ program = self.value
120
+ )
121
+
122
+ if self.message_type == 'sysex':
123
+ return mido.Message(
124
+ 'sysex',
125
+ data = self.data if self.data is not None else b''
126
+ )
127
+
128
+ return None
129
+
130
+
131
+ @classmethod
132
+ def from_mido (cls, pulse: int, msg: typing.Union[mido.Message, mido.MetaMessage], device: int = 0) -> "MidiEvent":
133
+
134
+ """Convert a mido.Message to a MidiEvent."""
135
+
136
+ if msg.type == 'pitchwheel':
137
+ return cls(
138
+ pulse = pulse,
139
+ message_type = 'pitchwheel',
140
+ channel = msg.channel,
141
+ value = msg.pitch,
142
+ device = device,
143
+ )
144
+
145
+ if msg.type == 'control_change':
146
+ return cls(
147
+ pulse = pulse,
148
+ message_type = 'control_change',
149
+ channel = msg.channel,
150
+ control = msg.control,
151
+ value = msg.value,
152
+ device = device,
153
+ )
154
+
155
+ return cls(
156
+ pulse = pulse,
157
+ message_type = msg.type,
158
+ channel = getattr(msg, 'channel', 0),
159
+ value = getattr(msg, 'value', 0),
160
+ note = getattr(msg, 'note', 0),
161
+ velocity = getattr(msg, 'velocity', 0),
162
+ data = getattr(msg, 'data', None),
163
+ control = getattr(msg, 'control', 0),
164
+ device = device,
165
+ )
166
+
167
+
168
+ @dataclasses.dataclass
169
+ class _MirrorTarget:
170
+
171
+ """A resolved fan-out destination — a ``(device, channel)`` plus an optional
172
+ per-device ``drum_note_map`` used to re-resolve mirrored drum names.
173
+
174
+ Constructed transiently inside ``schedule_pattern`` (never stored on the
175
+ Pattern, never public) purely for ergonomic attribute access in the
176
+ fan-out loops. Stored mirror entries remain plain tuples — a dict-bearing
177
+ entry would be unhashable and could not live in the ``set`` used by
178
+ ``_stop_pattern_notes``.
179
+ """
180
+
181
+ device: int
182
+ channel: int
183
+ drum_note_map: typing.Optional[typing.Dict[str, int]] = None
184
+
185
+
186
+ def _to_target (entry: typing.Sequence[typing.Any]) -> _MirrorTarget:
187
+
188
+ """Coerce a stored mirror entry to a ``_MirrorTarget``.
189
+
190
+ *entry* is ``(device, channel)`` or ``(device, channel, drum_note_map)`` —
191
+ a tuple or list. Branching on length here keeps the 2-vs-3 split in one
192
+ place (and off the typed ``MirrorSpec`` union).
193
+ """
194
+
195
+ drum_map = entry[2] if len(entry) == 3 else None
196
+ return _MirrorTarget(entry[0], entry[1], drum_map)
197
+
198
+
199
+ def _destination_pitch (note: typing.Any, target: _MirrorTarget, primary: bool) -> typing.Optional[int]:
200
+
201
+ """Return the MIDI note to emit for *note* at *target*, or None to drop it.
202
+
203
+ Drum names are resolved per destination: a destination that has no voice for
204
+ the name stays silent (returns None) rather than sounding a wrong number.
205
+
206
+ - A ``primary_unmapped`` note (its ``origin`` was absent from the pattern's
207
+ own map) has no real primary pitch — only a mirror whose map contains the
208
+ name voices it; the primary and raw (map-less) mirrors return None.
209
+ - Otherwise the primary and raw 2-tuple mirrors copy ``note.pitch``; a
210
+ symbolic (map-bearing) mirror re-resolves ``note.origin`` through its own
211
+ map, dropping a named voice it lacks and copying an origin-less literal
212
+ pitch.
213
+ """
214
+
215
+ if note.primary_unmapped:
216
+ # No real primary note exists; only a mapping mirror can sound it.
217
+ if primary or target.drum_note_map is None:
218
+ return None
219
+ if note.origin is not None and note.origin in target.drum_note_map:
220
+ return target.drum_note_map[note.origin]
221
+ return None
222
+
223
+ if primary or target.drum_note_map is None:
224
+ return typing.cast(int, note.pitch)
225
+
226
+ if note.origin is not None:
227
+ if note.origin in target.drum_note_map:
228
+ return target.drum_note_map[note.origin]
229
+ return None # a named voice this device lacks → silent (not a wrong note)
230
+
231
+ return typing.cast(int, note.pitch)
232
+
233
+
234
+ @dataclasses.dataclass
235
+ class ScheduledPattern:
236
+
237
+ """
238
+ Tracks a repeating pattern and its scheduling metadata.
239
+ """
240
+
241
+ pattern: PatternLike
242
+ cycle_start_pulse: int
243
+ length_pulses: int
244
+ lookahead_pulses: int
245
+ next_reschedule_pulse: int
246
+
247
+
248
+ @dataclasses.dataclass
249
+ class ScheduledCallback:
250
+
251
+ """
252
+ Tracks a repeating callback and its scheduling metadata.
253
+ """
254
+
255
+ callback: typing.Callable[[int], typing.Any]
256
+ cycle_start_pulse: int
257
+ interval_pulses: int
258
+ lookahead_pulses: int
259
+ next_fire_pulse: int
260
+
261
+
262
+ @dataclasses.dataclass
263
+ class ScheduledCallbackSequence:
264
+
265
+ """Tracks a self-rescheduling callback whose firing interval varies per hop.
266
+
267
+ The variable-interval counterpart to :class:`ScheduledCallback` — built
268
+ for clocks that walk irregular spans (the harmonic clock under a bound
269
+ progression). Each fire targets a *boundary* pulse; the callback's
270
+ return value sets the distance to the next boundary.
271
+
272
+ Attributes:
273
+ callback: Called with the boundary pulse it is preparing. Returns
274
+ the number of **beats** to the next boundary, or ``None`` to
275
+ stop firing (the sequence is dropped from the queue). May be
276
+ sync or async.
277
+ boundary_pulse: The pulse this fire prepares (the musical boundary,
278
+ not the fire time).
279
+ lookahead_pulses: How far before each boundary the callback fires.
280
+ next_fire_pulse: When the next fire is due.
281
+ """
282
+
283
+ callback: typing.Callable[[int], typing.Union[typing.Optional[float], typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]]]]
284
+ boundary_pulse: int
285
+ lookahead_pulses: int
286
+ next_fire_pulse: int
287
+
288
+
289
+ @dataclasses.dataclass
290
+ class BpmTransition:
291
+
292
+ """State for a gradual BPM transition."""
293
+
294
+ start_bpm: float
295
+ target_bpm: float
296
+ total_pulses: int
297
+ elapsed_pulses: int = 0
298
+ easing_fn: typing.Callable[[float], float] = dataclasses.field(default=subsequence.easing.linear)
299
+
300
+
301
+ class Sequencer:
302
+
303
+ """
304
+ The engine that drives Subsequence timing and MIDI output.
305
+
306
+ The ``Sequencer`` maintains a stable clock (internal or external),
307
+ handles the scheduling of MIDI events, and triggers pattern rebuilds.
308
+ """
309
+
310
+ def __init__ (
311
+ self,
312
+ output_device_name: typing.Optional[str] = None,
313
+ initial_bpm: float = 125,
314
+ time_signature: typing.Tuple[int, int] = (4, 4),
315
+ input_device_name: typing.Optional[str] = None,
316
+ clock_follow: bool = False,
317
+ clock_output: bool = False,
318
+ record: bool = False,
319
+ record_filename: typing.Optional[str] = None,
320
+ spin_wait: bool = True,
321
+ _jitter_log: typing.Optional[typing.List[float]] = None
322
+ ) -> None:
323
+
324
+ """Initialize the sequencer with MIDI devices and initial BPM.
325
+
326
+ Parameters:
327
+ output_device_name: MIDI output device name. When omitted, auto-discovers
328
+ available devices - uses the only device if one is found, or prompts
329
+ the user to choose if multiple are available.
330
+ initial_bpm: Tempo in BPM (ignored when clock_follow is True)
331
+ input_device_name: Optional MIDI input device name for clock/transport
332
+ clock_follow: When True, follow external MIDI clock instead of internal clock
333
+ clock_output: When True, send MIDI timing clock (0xF8), start (0xFA), and
334
+ stop (0xFC) messages so connected hardware can sync to Subsequence's
335
+ tempo. Mutually exclusive with ``clock_follow`` (ignored when both
336
+ are set, to prevent feedback loops).
337
+ record: When True, record all MIDI events to a file.
338
+ record_filename: Optional filename for the recording (defaults to timestamp).
339
+ spin_wait: When True (default), use a hybrid sleep+spin strategy for the
340
+ final sub-millisecond of each pulse interval. This significantly
341
+ reduces clock jitter at the cost of ~1–5% extra CPU. Set to False
342
+ to use pure ``asyncio.sleep()`` (lower CPU, higher jitter).
343
+ _jitter_log: Optional list to append per-pulse jitter values (seconds)
344
+ to during playback. Intended for the clock jitter benchmark — not
345
+ for general use.
346
+ """
347
+
348
+ if clock_follow and input_device_name is None:
349
+ raise ValueError("clock_follow=True requires an input_device_name")
350
+
351
+ self.output_device_name = output_device_name
352
+ self.input_device_name = input_device_name
353
+ self.time_signature = time_signature
354
+ self.clock_follow = clock_follow
355
+ self.clock_device_idx: int = 0
356
+ self.clock_output = clock_output and not clock_follow
357
+ self.pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE
358
+
359
+ # Recording state
360
+ self.recording = record
361
+ self.record_filename = record_filename
362
+ self.recorded_events: typing.List[typing.Tuple[float, typing.Union[mido.Message, mido.MetaMessage]]] = []
363
+
364
+ # Render mode: run as fast as possible and stop after render_bars or render_max_seconds.
365
+ # Both limits are optional — at least one must be set (enforced in Composition.render).
366
+ self.render_mode: bool = False
367
+ self.render_bars: int = 0 # 0 = no bar limit
368
+ self.render_max_seconds: typing.Optional[float] = None # None = no time limit
369
+ self._render_elapsed_seconds: float = 0.0
370
+
371
+
372
+ # CC input mapping — populated from Composition.cc_map()
373
+ self.cc_mappings: typing.List[typing.Dict[str, typing.Any]] = []
374
+ # CC forwarding — populated from Composition.cc_forward()
375
+ self.cc_forwards: typing.List[typing.Dict[str, typing.Any]] = []
376
+ # Buffer for queued CC forwards: deque of (pulse, mido.Message) tuples.
377
+ # Appended on the mido callback thread; drained on the event loop thread.
378
+ self._forward_buffer: collections.deque = collections.deque()
379
+ # Shared reference to composition.data so CC mappings can update it
380
+ self._composition_data: typing.Dict[str, typing.Any] = {}
381
+
382
+ # Held-note input — populated from Composition.note_input().
383
+ # The tracker (created in _run when note_input was declared) and a buffer
384
+ # of raw (is_on, pitch, velocity, perf_counter) note events. The buffer is
385
+ # appended on the mido callback thread and drained on the loop thread in
386
+ # _advance_pulse, so all tracker state stays single-threaded — no lock.
387
+ self._held_notes: typing.Optional[subsequence.held_notes.HeldNotes] = None
388
+ self._note_input_buffer: collections.deque = collections.deque()
389
+ self._note_input_channel: typing.Optional[int] = None # None = any channel
390
+ self._note_input_device: typing.Optional[int] = None # None = any input device
391
+
392
+ # Ableton Link clock — set by Composition._run() when comp.link() was called.
393
+ # Must be initialized before set_bpm() is called below.
394
+ self._link_clock: typing.Optional[typing.Any] = None
395
+
396
+ # Multi-device MIDI port registries.
397
+ # Output device 0 is always the primary/default device.
398
+ self._output_devices: subsequence.midi_utils.MidiDeviceRegistry = subsequence.midi_utils.MidiDeviceRegistry()
399
+ self._input_devices: subsequence.midi_utils.MidiDeviceRegistry = subsequence.midi_utils.MidiDeviceRegistry()
400
+
401
+ # Internal state initialization (needed before set_bpm)
402
+ self._midi_input_queue: typing.Optional[asyncio.Queue] = None
403
+ self._input_loop: typing.Optional[asyncio.AbstractEventLoop] = None
404
+ self._event_loop: typing.Optional[asyncio.AbstractEventLoop] = None
405
+ self._clock_tick_times: typing.List[float] = []
406
+ self._waiting_for_start: bool = False
407
+
408
+ self.event_queue: typing.List[MidiEvent] = []
409
+ self.task: typing.Optional[asyncio.Task] = None
410
+ self.start_time = 0.0
411
+ self.pulse_count = 0
412
+ self.current_bar: int = -1
413
+ self.current_beat: int = -1
414
+ self.active_notes: typing.Set[typing.Tuple[int, int, int]] = set() # (device, channel, note)
415
+
416
+ # Device latency compensation: cached max across all output devices, and
417
+ # the set of in-flight deferred sends (call_later handles) awaiting their
418
+ # per-device offset. See _dispatch_with_compensation() and stop().
419
+ self._max_device_latency_ms: float = 0.0
420
+ self._pending_sends: typing.Set[asyncio.TimerHandle] = set()
421
+
422
+ # Strong references to fire-and-forget bar/beat/event tasks. Without
423
+ # retaining them asyncio holds only a weak reference and the task can be
424
+ # garbage-collected mid-flight; the done-callback also surfaces exceptions
425
+ # that a bare create_task would otherwise swallow until GC.
426
+ self._background_tasks: typing.Set[asyncio.Task] = set()
427
+
428
+ self.queue_lock = asyncio.Lock()
429
+ self.pattern_lock = asyncio.Lock()
430
+ self.reschedule_queue: typing.List[typing.Tuple[int, int, ScheduledPattern]] = []
431
+ self._reschedule_counter = itertools.count()
432
+ self.events = subsequence.event_emitter.EventEmitter()
433
+ self.callback_lock = asyncio.Lock()
434
+ self.callback_queue: typing.List[typing.Tuple[int, int, ScheduledCallback]] = []
435
+ self._callback_counter = itertools.count()
436
+
437
+ # Variable-interval callback sequences share callback_lock; they fire
438
+ # after the fixed callbacks at the same pulse (see
439
+ # _maybe_reschedule_patterns), preserving form-before-harmony ordering.
440
+ self.callback_sequence_queue: typing.List[typing.Tuple[int, int, ScheduledCallbackSequence]] = []
441
+
442
+ # FIFO tie-breaker for same-pulse MidiEvents in event_queue. Without
443
+ # it, ``heapq`` ordering of equal-pulse events is undefined, which
444
+ # breaks NRPN/RPN bursts (CC 99 → 98 → 6 → 38 must stay in order).
445
+ self._event_counter = itertools.count()
446
+
447
+ # Serialises actual port writes. Every send runs on the event-loop
448
+ # thread EXCEPT instant-mode cc_forward, which fires on the mido input
449
+ # callback thread — without this lock the two threads could interleave
450
+ # bytes on the same port and corrupt a multi-byte message.
451
+ self._port_send_lock = threading.Lock()
452
+
453
+ self.data: typing.Dict[str, typing.Any] = {}
454
+
455
+ # Timing variables
456
+ self.current_bpm: float = 0
457
+ self.seconds_per_beat = 0.0
458
+ self.seconds_per_pulse = 0.0
459
+ self.running = False
460
+ self._bpm_transition: typing.Optional[BpmTransition] = None
461
+ self._spin_wait: bool = spin_wait
462
+ # Spin threshold: sleep all the way to this many seconds before the target,
463
+ # then busy-wait for the remainder. 1ms is enough to absorb OS wakeup latency
464
+ # while keeping spin time short enough not to starve the event loop.
465
+ self._spin_threshold: float = 0.001
466
+ self._jitter_log: typing.Optional[typing.List[float]] = _jitter_log
467
+
468
+ self.set_bpm(initial_bpm)
469
+
470
+ self._init_midi_output()
471
+
472
+ # OSC server reference — set by Composition after osc_server.start()
473
+ self.osc_server: typing.Optional[typing.Any] = None
474
+
475
+ # ------------------------------------------------------------------
476
+ # Backward-compatible properties: midi_out / midi_in
477
+ # External code and tests may reference these directly. They always
478
+ # resolve to device 0 of the respective registry.
479
+ # ------------------------------------------------------------------
480
+
481
+ @property
482
+ def midi_out (self) -> typing.Optional[typing.Any]:
483
+ """Return the primary output port (device 0), or None."""
484
+ return self._output_devices.get(0)
485
+
486
+ @midi_out.setter
487
+ def midi_out (self, value: typing.Optional[typing.Any]) -> None:
488
+ """Allow test code to inject a fake output port as device 0."""
489
+ if value is None:
490
+ return
491
+ if len(self._output_devices) == 0:
492
+ self._output_devices.add("default", value)
493
+ else:
494
+ self._output_devices.replace(0, value)
495
+
496
+ @property
497
+ def midi_in (self) -> typing.Optional[typing.Any]:
498
+ """Return the primary input port (device 0), or None."""
499
+ return self._input_devices.get(0)
500
+
501
+ @midi_in.setter
502
+ def midi_in (self, value: typing.Optional[typing.Any]) -> None:
503
+ """Allow test code to inject a fake input port as device 0."""
504
+ if value is None:
505
+ return
506
+ if len(self._input_devices) == 0:
507
+ self._input_devices.add("default", value)
508
+ else:
509
+ self._input_devices.replace(0, value)
510
+
511
+ def add_output_device (self, name: str, port: typing.Any, latency_ms: float = 0.0) -> int:
512
+ """Register an additional output device. Returns the device index.
513
+
514
+ *latency_ms* is the device's physical output latency for compensation
515
+ (see :meth:`set_device_latency`).
516
+ """
517
+ idx = self._output_devices.add(name, port, latency_ms)
518
+ self._max_device_latency_ms = self._output_devices.max_latency()
519
+ return idx
520
+
521
+ def add_input_device (self, name: str, port: typing.Any) -> int:
522
+ """Register an additional input device. Returns the device index."""
523
+ return self._input_devices.add(name, port)
524
+
525
+ def set_device_latency (self, device: subsequence.midi_utils.DeviceId, latency_ms: float) -> None:
526
+
527
+ """Set an output device's physical latency (ms) for delay compensation.
528
+
529
+ Latency is normalised engine-wide: the slowest device plays at its
530
+ logical time and every faster device's output is deferred by
531
+ ``max_latency − its_latency`` so all devices sound together. See
532
+ :meth:`_dispatch_with_compensation`.
533
+
534
+ Parameters:
535
+ device: Output device id (int index, name str, or None for device 0).
536
+ latency_ms: Non-negative physical output latency in milliseconds.
537
+ Raises ``ValueError`` if negative or the device is unknown.
538
+ """
539
+
540
+ self._output_devices.set_latency(device, latency_ms)
541
+ self._max_device_latency_ms = self._output_devices.max_latency()
542
+
543
+ def _record_event (self, pulse: int, message: typing.Union[mido.Message, mido.MetaMessage]) -> None:
544
+
545
+ """Record a MIDI message with an absolute pulse timestamp for later export."""
546
+
547
+ if not self.recording:
548
+ return
549
+
550
+ self.recorded_events.append((float(pulse), message))
551
+
552
+ def save_recording (self) -> None:
553
+
554
+ """Save the recorded session to a MIDI file."""
555
+
556
+ if not self.recording or not self.recorded_events:
557
+ return
558
+
559
+ if self.record_filename:
560
+ filename = self.record_filename
561
+ else:
562
+ now = datetime.datetime.now()
563
+ filename = now.strftime("session_%Y%m%d_%H%M%S.mid")
564
+
565
+ logger.info(f"Saving MIDI recording ({len(self.recorded_events)} events) to {filename}...")
566
+
567
+ mid = mido.MidiFile(type=1) # Type 1 = multiple tracks (though we might just use one)
568
+ track = mido.MidiTrack()
569
+ mid.tracks.append(track)
570
+
571
+ # Resolution (ticks per beat). Standard is 480.
572
+ # Subsequence uses 24 PPQN internal.
573
+ # To get 480 PPQN output without losing precision, we scale up by 20.
574
+ ticks_per_pulse = 20
575
+ mid.ticks_per_beat = 480
576
+
577
+ # Sort events by pulse just in case
578
+ self.recorded_events.sort(key=lambda x: x[0])
579
+
580
+ last_pulse = 0.0
581
+
582
+ for pulse, message in self.recorded_events:
583
+
584
+ delta_pulses = pulse - last_pulse
585
+ delta_ticks = int(delta_pulses * ticks_per_pulse)
586
+
587
+ # Ensure delta is non-negative (floating point jitter?)
588
+ if delta_ticks < 0:
589
+ delta_ticks = 0
590
+
591
+ message.time = delta_ticks
592
+ track.append(message)
593
+
594
+ last_pulse = pulse
595
+
596
+ try:
597
+ mid.save(filename)
598
+ logger.info(f"Saved {filename}")
599
+ except Exception as e:
600
+ logger.error(f"Failed to save MIDI recording: {e}")
601
+
602
+ def disable_spin_wait (self) -> None:
603
+
604
+ """Disable the hybrid sleep+spin timing strategy.
605
+
606
+ By default the sequencer busy-waits for the final sub-millisecond of each
607
+ pulse interval to minimise clock jitter. Call this to revert to pure
608
+ ``asyncio.sleep()`` — lower CPU usage at the cost of higher jitter (typically
609
+ ±0.5–2 ms on Linux vs ~3–4 μs with spin-wait enabled (see the README clock-accuracy benchmark)).
610
+
611
+ Can also be set at construction time: ``Sequencer(spin_wait=False)``.
612
+ """
613
+
614
+ self._spin_wait = False
615
+
616
+
617
+ def set_bpm (self, bpm: float) -> None:
618
+
619
+ """
620
+ Instantly change the tempo.
621
+
622
+ Note: If ``clock_follow`` is enabled and the sequencer is running,
623
+ this method will be ignored as the tempo is slaved to the external source.
624
+ When Ableton Link is active, the new BPM is proposed to the Link network
625
+ instead of being applied locally — the network-authoritative tempo is
626
+ then picked up on the next pulse.
627
+ """
628
+
629
+ # Validate BEFORE the Link branch — a zero/negative tempo must never
630
+ # be proposed to the whole Link session.
631
+ if bpm <= 0:
632
+ raise ValueError("BPM must be positive")
633
+
634
+ if self.clock_follow and self.running:
635
+ logger.info("BPM is controlled by external clock - set_bpm() ignored")
636
+ return
637
+
638
+ if self._link_clock is not None and self.running:
639
+ self._link_clock.request_tempo(bpm)
640
+ logger.info(f"BPM {bpm:.2f} proposed to Ableton Link session")
641
+ return
642
+
643
+ self._bpm_transition = None
644
+ self.current_bpm = bpm
645
+ self.seconds_per_beat = 60.0 / self.current_bpm
646
+ self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat
647
+
648
+ logger.info(f"BPM set to {self.current_bpm:.2f}")
649
+
650
+ if self.recording:
651
+ tempo = mido.bpm2tempo(self.current_bpm)
652
+ self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=tempo))
653
+
654
+
655
+ def set_target_bpm (self, target_bpm: float, bars: int, shape: typing.Union[str, subsequence.easing.EasingFn] = "linear") -> None:
656
+
657
+ """
658
+ Smoothly transition to a new tempo over a fixed number of bars.
659
+
660
+ Parameters:
661
+ target_bpm: The BPM to ramp toward.
662
+ bars: Duration of the transition in bars.
663
+ shape: Easing curve — a name string (e.g. ``"ease_in_out"``) or any
664
+ callable that maps [0, 1] → [0, 1]. Defaults to ``"linear"``.
665
+ ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural-
666
+ sounding tempo changes. See :mod:`subsequence.easing`.
667
+
668
+ Note:
669
+ When Ableton Link is active the shared network tempo is authoritative,
670
+ so a local ramp cannot be honoured — this call is ignored. Use
671
+ ``set_bpm()`` to propose a new tempo to the Link session instead.
672
+ """
673
+
674
+ if self.clock_follow and self.running:
675
+ logger.info("BPM is controlled by external clock - set_target_bpm() ignored")
676
+ return
677
+
678
+ if self._link_clock is not None and self.running:
679
+ logger.info("Tempo is controlled by the Ableton Link session - set_target_bpm() ramp ignored; use set_bpm() to propose a new Link tempo")
680
+ return
681
+
682
+ if target_bpm <= 0:
683
+ raise ValueError("Target BPM must be positive")
684
+
685
+ if bars <= 0:
686
+ raise ValueError("Transition bars must be positive")
687
+
688
+ total_pulses = bars * self.pulses_per_beat * self.time_signature[0]
689
+
690
+ self._bpm_transition = BpmTransition(
691
+ start_bpm=self.current_bpm,
692
+ target_bpm=target_bpm,
693
+ total_pulses=total_pulses,
694
+ easing_fn=subsequence.easing.get_easing(shape)
695
+ )
696
+
697
+ logger.info(f"BPM transition: {self.current_bpm:.2f} → {target_bpm:.2f} over {bars} bars ({shape!r})")
698
+
699
+
700
+ def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None:
701
+
702
+ """
703
+ Register a callback for a named event.
704
+ """
705
+
706
+ self.events.on(event_name, callback)
707
+
708
+
709
+ def _init_midi_output (self) -> None:
710
+
711
+ """Initialize the primary MIDI output port (device 0).
712
+
713
+ When ``output_device_name`` was provided, opens that device directly.
714
+ When omitted, auto-discovers available devices: uses the only one if
715
+ exactly one is found, or prompts the user to choose if several exist.
716
+ """
717
+
718
+ device_name, midi_out = subsequence.midi_utils.select_output_device(self.output_device_name)
719
+
720
+ if device_name and midi_out is not None:
721
+ self.output_device_name = device_name
722
+ self._output_devices.add(device_name, midi_out)
723
+
724
+
725
+ def _open_midi_inputs (self) -> None:
726
+
727
+ """
728
+ Set up the internal event loops and MIDI input ports.
729
+
730
+ This is called automatically by start(), but may be called manually
731
+ by Composition._run() earlier in the startup sequence to ensure
732
+ MIDI CC configuration is shared before ports begin background draining.
733
+ """
734
+
735
+ if self.input_device_name is not None and self._midi_input_queue is None:
736
+ self._input_loop = asyncio.get_running_loop()
737
+ self._midi_input_queue = asyncio.Queue()
738
+ self._init_midi_input()
739
+
740
+
741
+ def _init_midi_input (self) -> None:
742
+
743
+ """Initialize the primary MIDI input port (device 0) with a callback."""
744
+
745
+ if self.input_device_name is None:
746
+ return
747
+
748
+ callback = self._make_input_callback(0)
749
+ device_name, midi_in = subsequence.midi_utils.select_input_device(self.input_device_name, callback)
750
+
751
+ if device_name and midi_in is not None:
752
+ self.input_device_name = device_name
753
+ self._input_devices.add(device_name, midi_in)
754
+
755
+ def _make_input_callback (self, device_idx: int) -> typing.Callable:
756
+ """Return a mido callback closure that tags messages with *device_idx*."""
757
+
758
+ def _callback (message: typing.Any) -> None:
759
+ self._on_midi_input(message, device_idx)
760
+
761
+ return _callback
762
+
763
+
764
+ def _on_midi_input (self, message: typing.Any, device_idx: int = 0) -> None:
765
+
766
+ """Handle incoming MIDI messages from the input port callback thread.
767
+
768
+ This runs in mido's callback thread. Clock/transport messages are
769
+ forwarded to the asyncio event loop via call_soon_threadsafe.
770
+
771
+ CC input mappings are applied immediately here. Single dict writes
772
+ are safe from a non-asyncio thread under CPython's GIL.
773
+
774
+ Parameters:
775
+ message: The incoming mido.Message.
776
+ device_idx: Index of the input device this message arrived on (0 = primary).
777
+ """
778
+
779
+ if self._midi_input_queue is None or self._input_loop is None:
780
+ return
781
+
782
+ # The queue feeds only the external-clock loop, so enqueue only when
783
+ # following: with the internal clock nothing ever drains it, and a
784
+ # synced device's 24-ticks-per-beat clock would grow it forever.
785
+ if self.clock_follow:
786
+ self._input_loop.call_soon_threadsafe(
787
+ self._midi_input_queue.put_nowait, (device_idx, message)
788
+ )
789
+
790
+ # Apply CC input mappings: map incoming CC values to composition.data.
791
+ if message.type == 'control_change' and self.cc_mappings:
792
+ for mapping in self.cc_mappings:
793
+ if message.control != mapping['cc']:
794
+ continue
795
+ ch = mapping.get('channel')
796
+ if ch is not None and message.channel != ch:
797
+ continue
798
+ # Filter by input device if specified (None = any device).
799
+ in_dev = mapping.get('input_device')
800
+ if in_dev is not None and device_idx != in_dev:
801
+ continue
802
+ scaled = mapping['min_val'] + (message.value / 127.0) * (mapping['max_val'] - mapping['min_val'])
803
+ self._composition_data[mapping['data_key']] = scaled
804
+
805
+ # Apply CC forwards: route incoming CC to MIDI output in real-time.
806
+ if message.type == 'control_change' and self.cc_forwards:
807
+ for fwd in self.cc_forwards:
808
+ if message.control != fwd['cc']:
809
+ continue
810
+ ch = fwd.get('channel')
811
+ if ch is not None and message.channel != ch:
812
+ continue
813
+ # Filter by input device if specified (None = any device).
814
+ in_dev = fwd.get('input_device')
815
+ if in_dev is not None and device_idx != in_dev:
816
+ continue
817
+ try:
818
+ out_msg = fwd['transform'](message.value, message.channel)
819
+ except Exception:
820
+ logger.exception("CC forward transform failed")
821
+ continue
822
+ if out_msg is None:
823
+ continue
824
+ if fwd['mode'] == 'instant':
825
+ # Route to the specified output device (default: device 0).
826
+ out_dev = fwd.get('output_device', 0)
827
+ port = self._output_devices.get(out_dev)
828
+ if port is not None:
829
+ try:
830
+ # Instant mode fires on the mido callback thread, so the
831
+ # send must take the lock the loop thread also holds.
832
+ self._locked_send(port, out_msg)
833
+ except Exception:
834
+ logger.exception("CC forward send failed")
835
+ else:
836
+ # Queued: buffer for drain in _process_pulse on the event loop
837
+ # thread. The output device travels with the message so the
838
+ # drain can route it (dropping it sent everything to device 0).
839
+ out_dev = fwd.get('output_device')
840
+ self._forward_buffer.append((self.pulse_count, out_msg, 0 if out_dev is None else out_dev))
841
+
842
+ # Buffer incoming note on/off for the held-note tracker. Only the
843
+ # GIL-atomic deque.append happens here; the tracker is updated when the
844
+ # loop thread drains the buffer in _advance_pulse.
845
+ if self._held_notes is not None and message.type in ('note_on', 'note_off'):
846
+ if self._note_input_channel is not None and message.channel != self._note_input_channel:
847
+ return
848
+ if self._note_input_device is not None and device_idx != self._note_input_device:
849
+ return
850
+ # A note_on with velocity 0 is the running-status form of note-off.
851
+ if message.type == 'note_on' and message.velocity > 0:
852
+ self._note_input_buffer.append((True, message.note, message.velocity, time.perf_counter()))
853
+ else:
854
+ self._note_input_buffer.append((False, message.note, 0, time.perf_counter()))
855
+
856
+
857
+
858
+ def _estimate_bpm (self, tick_time: float) -> None:
859
+
860
+ """Estimate BPM from recent MIDI clock tick timestamps for display and recording."""
861
+
862
+ self._clock_tick_times.append(tick_time)
863
+
864
+ # Keep last 48 ticks (2 beats) for averaging.
865
+ if len(self._clock_tick_times) > 48:
866
+ self._clock_tick_times = self._clock_tick_times[-48:]
867
+
868
+ if len(self._clock_tick_times) >= 24:
869
+ # Average interval over last 24 ticks (1 beat).
870
+ recent = self._clock_tick_times[-24:]
871
+ interval = (recent[-1] - recent[0]) / (len(recent) - 1)
872
+
873
+ if interval > 0:
874
+ new_bpm = int(round(60.0 / (interval * self.pulses_per_beat)))
875
+
876
+ # Record tempo changes so a clock-following session's .mid
877
+ # plays back at the external tempo, not the constructor BPM.
878
+ # Integer rounding above already filters tick jitter.
879
+ if self.recording and new_bpm != self.current_bpm and new_bpm > 0:
880
+ self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(new_bpm)))
881
+
882
+ self.current_bpm = new_bpm
883
+
884
+
885
+ def _get_schedule_timing (self, length_beats: float, lookahead_beats: float) -> typing.Tuple[int, int]:
886
+
887
+ """
888
+ Convert schedule length and reschedule lookahead from beats to pulses.
889
+ """
890
+
891
+ if length_beats <= 0:
892
+ raise ValueError("Schedule length must be positive")
893
+
894
+ if lookahead_beats < 0:
895
+ raise ValueError("Reschedule lookahead cannot be negative")
896
+
897
+ if lookahead_beats > length_beats:
898
+ raise ValueError("Reschedule lookahead cannot exceed schedule length")
899
+
900
+ length_pulses = int(length_beats * self.pulses_per_beat)
901
+ lookahead_pulses = int(lookahead_beats * self.pulses_per_beat)
902
+
903
+ if length_pulses <= 0:
904
+ raise ValueError("Schedule length must be at least one pulse")
905
+
906
+ return length_pulses, lookahead_pulses
907
+
908
+
909
+ def _get_pattern_timing (self, pattern: PatternLike) -> typing.Tuple[int, int]:
910
+
911
+ """
912
+ Convert pattern length and reschedule lookahead from beats to pulses.
913
+ """
914
+
915
+ return self._get_schedule_timing(pattern.length, pattern.reschedule_lookahead)
916
+
917
+
918
+ def _push_event (self, event: MidiEvent) -> None:
919
+
920
+ """Push a MidiEvent onto the queue, stamping a FIFO tie-breaker.
921
+
922
+ The ``sequence`` field guarantees that events sharing a ``pulse``
923
+ dispatch in insertion order — required for NRPN/RPN bursts and any
924
+ future protocol that emits multiple co-scheduled CCs (e.g. Bank
925
+ Select before Program Change).
926
+ """
927
+
928
+ event.sequence = next(self._event_counter)
929
+ heapq.heappush(self.event_queue, event)
930
+
931
+
932
+ def _spawn (self, coro: typing.Coroutine) -> None:
933
+
934
+ """Fire-and-forget *coro* on the event loop, tracked and exception-safe.
935
+
936
+ Retains a strong reference until the task completes (so it cannot be
937
+ collected mid-flight) and surfaces any exception via the done-callback —
938
+ a bare ``asyncio.create_task`` drops both, silently losing a raising bar
939
+ or beat callback.
940
+ """
941
+
942
+ task = asyncio.create_task(coro)
943
+ self._background_tasks.add(task)
944
+ task.add_done_callback(self._reap_background_task)
945
+
946
+
947
+ def _reap_background_task (self, task: "asyncio.Task") -> None:
948
+
949
+ """Done-callback: drop the reference and log any exception."""
950
+
951
+ self._background_tasks.discard(task)
952
+
953
+ if not task.cancelled() and task.exception() is not None:
954
+ logger.error("Background task failed", exc_info=task.exception())
955
+
956
+
957
+ async def schedule_pattern (self, pattern: PatternLike, start_pulse: int) -> None:
958
+
959
+ """
960
+ Schedules a pattern's notes and CC events into the sequencer's event queue.
961
+
962
+ If ``pattern.mirrors`` is non-empty, every note, CC, pitch bend, program
963
+ change, SysEx, NRPN/RPN burst, and drone event is duplicated onto each
964
+ mirror destination — see the "MIDI mirroring" section of the README.
965
+
966
+ **Bandwidth note**: each mirror destination multiplies the per-pattern
967
+ event count. A dense pattern with two mirrors emits 3× the original.
968
+ For DIN-MIDI hardware on a saturated bus this can matter; over USB or
969
+ IAC it is rarely a concern.
970
+
971
+ **Tuning + mirrors**: ``CcEvent.channel`` and ``CcEvent.device`` overrides
972
+ (used by polyphonic microtonal tuning to rotate notes onto separate
973
+ channels) apply to the *primary* destination only. Mirror destinations
974
+ always use their own pinned ``(device, channel)`` — i.e. a polyphonic-
975
+ tuning pattern mirrored to another synth will collapse all channel
976
+ rotations onto the mirror's single channel, losing per-note bend
977
+ isolation on that destination. Apply tuning per-pattern if both ends
978
+ need it.
979
+ """
980
+
981
+ # Primary destination first; mirrors follow. Iteration order matters
982
+ # only for human readability when inspecting the queue — FIFO ordering
983
+ # at equal pulses is enforced by ``_push_event`` (the ``sequence``
984
+ # tie-breaker on ``MidiEvent``).
985
+ mirrors = getattr(pattern, 'mirrors', [])
986
+ destinations: typing.List[_MirrorTarget] = [_MirrorTarget(pattern.device, pattern.channel, None)] + [_to_target(entry) for entry in mirrors]
987
+
988
+ async with self.queue_lock:
989
+
990
+ for position, step in pattern.steps.items():
991
+
992
+ abs_pulse = start_pulse + position
993
+
994
+ for note in step.notes:
995
+
996
+ for i, target in enumerate(destinations):
997
+
998
+ # Resolve the drum name for this destination; None means the
999
+ # destination has no voice for it, so it stays silent (no
1000
+ # note_on AND no note_off — nothing can hang).
1001
+ note_value = _destination_pitch(note, target, primary = (i == 0))
1002
+ if note_value is None:
1003
+ # A mirror carrying its own map that lacks this named
1004
+ # voice drops it here by design (faithful-core: silence,
1005
+ # never a wrong note). Surface it at debug level so
1006
+ # "why is the clap missing on the sampler?" is answerable
1007
+ # without guessing — the build-time warning only covers a
1008
+ # name absent from *every* destination.
1009
+ if i != 0 and note.origin is not None and target.drum_note_map is not None:
1010
+ logger.debug(
1011
+ "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination",
1012
+ target.device, target.channel, note.origin,
1013
+ )
1014
+ continue
1015
+
1016
+ # Primary preserves the Note's own channel (so polyphonic
1017
+ # tuning's per-voice channel rotation lands correctly).
1018
+ # Mirrors collapse onto the mirror's pinned channel and
1019
+ # re-resolve the drum name through their own map.
1020
+ note_channel = note.channel if i == 0 else target.channel
1021
+ note_device = pattern.device if i == 0 else target.device
1022
+
1023
+ on_event = MidiEvent(
1024
+ pulse = abs_pulse,
1025
+ message_type = 'note_on',
1026
+ channel = note_channel,
1027
+ note = note_value,
1028
+ velocity = note.velocity,
1029
+ device = note_device,
1030
+ )
1031
+ self._push_event(on_event)
1032
+
1033
+ off_event = MidiEvent(
1034
+ pulse = abs_pulse + note.duration,
1035
+ message_type = 'note_off',
1036
+ channel = note_channel,
1037
+ note = note_value,
1038
+ velocity = 0,
1039
+ device = note_device,
1040
+ )
1041
+ self._push_event(off_event)
1042
+
1043
+ # CC / pitch bend / program change / SysEx events
1044
+ for cc_event in getattr(pattern, 'cc_events', []):
1045
+
1046
+ abs_pulse = start_pulse + cc_event.pulse
1047
+
1048
+ for i, target in enumerate(destinations):
1049
+
1050
+ if i == 0:
1051
+ # Primary: respect per-event channel/device override if set
1052
+ # (e.g. tuning pitch bends targeting specific channels).
1053
+ event_channel = cc_event.channel if cc_event.channel is not None else target.channel
1054
+ event_device = cc_event.device if cc_event.device is not None else target.device
1055
+ else:
1056
+ # Mirror: always use the mirror's pinned (device, channel).
1057
+ # See class docstring for the tuning interaction note.
1058
+ event_channel = target.channel
1059
+ event_device = target.device
1060
+
1061
+ midi_event = MidiEvent(
1062
+ pulse = abs_pulse,
1063
+ message_type = cc_event.message_type,
1064
+ channel = event_channel,
1065
+ control = cc_event.control,
1066
+ value = cc_event.value,
1067
+ data = cc_event.data,
1068
+ device = event_device,
1069
+ priority = getattr(cc_event, 'priority', 0),
1070
+ )
1071
+ self._push_event(midi_event)
1072
+
1073
+ # Raw Note On/Off events (drones)
1074
+ for note_ev in getattr(pattern, 'raw_note_events', []):
1075
+
1076
+ abs_pulse = start_pulse + note_ev.pulse
1077
+
1078
+ for i, target in enumerate(destinations):
1079
+
1080
+ # Same faithful-core rule as step notes: a named drone is
1081
+ # re-resolved through each mirror's own drum_note_map, and
1082
+ # a destination lacking the voice stays silent rather than
1083
+ # sounding the primary device's note number.
1084
+ note_value = _destination_pitch(note_ev, target, primary = (i == 0))
1085
+ if note_value is None:
1086
+ if i != 0 and note_ev.origin is not None and target.drum_note_map is not None:
1087
+ logger.debug(
1088
+ "Mirror device %d channel %d has no voice for drum '%s' — dropped for this destination",
1089
+ target.device, target.channel, note_ev.origin,
1090
+ )
1091
+ continue
1092
+
1093
+ midi_event = MidiEvent(
1094
+ pulse = abs_pulse,
1095
+ message_type = note_ev.message_type,
1096
+ channel = target.channel,
1097
+ note = note_value,
1098
+ velocity = note_ev.velocity,
1099
+ device = target.device,
1100
+ )
1101
+ self._push_event(midi_event)
1102
+
1103
+ # OSC events — never mirrored. OSC isn't bound to a MIDI port and
1104
+ # mirroring it would require a different abstraction (multiple OSC
1105
+ # servers / addresses). If users want to broadcast OSC to several
1106
+ # endpoints, that belongs in the OSC server config, not here.
1107
+ for osc_event in getattr(pattern, 'osc_events', []):
1108
+
1109
+ abs_pulse = start_pulse + osc_event.pulse
1110
+
1111
+ osc_midi_event = MidiEvent(
1112
+ pulse = abs_pulse,
1113
+ message_type = 'osc',
1114
+ channel = 0,
1115
+ data = (osc_event.address, osc_event.args)
1116
+ )
1117
+
1118
+ self._push_event(osc_midi_event)
1119
+
1120
+ logger.debug(f"Scheduled pattern at {start_pulse}, queue size: {len(self.event_queue)}")
1121
+
1122
+
1123
+ async def schedule_pattern_repeating (self, pattern: PatternLike, start_pulse: int) -> None:
1124
+
1125
+ """
1126
+ Schedule a pattern and register it for rescheduling each cycle.
1127
+ """
1128
+
1129
+ length_pulses, lookahead_pulses = self._get_pattern_timing(pattern)
1130
+
1131
+ # Anchor the first cycle for window-reading rebuilds (kept current on
1132
+ # every reschedule in _maybe_reschedule_patterns).
1133
+ pattern._cycle_start_pulse = start_pulse
1134
+
1135
+ await self.schedule_pattern(pattern, start_pulse)
1136
+
1137
+ next_reschedule_pulse = start_pulse + length_pulses - lookahead_pulses
1138
+
1139
+ scheduled_pattern = ScheduledPattern(
1140
+ pattern = pattern,
1141
+ cycle_start_pulse = start_pulse,
1142
+ length_pulses = length_pulses,
1143
+ lookahead_pulses = lookahead_pulses,
1144
+ next_reschedule_pulse = next_reschedule_pulse
1145
+ )
1146
+
1147
+ async with self.pattern_lock:
1148
+ counter = next(self._reschedule_counter)
1149
+ heapq.heappush(self.reschedule_queue, (scheduled_pattern.next_reschedule_pulse, counter, scheduled_pattern))
1150
+
1151
+
1152
+ async def schedule_callback_repeating (self, callback: typing.Callable[[int], typing.Any], interval_beats: float, start_pulse: int = 0, reschedule_lookahead: float = 1) -> None:
1153
+
1154
+ """
1155
+ Schedule a repeating callback on a beat interval.
1156
+ """
1157
+
1158
+ interval_pulses, lookahead_pulses = self._get_schedule_timing(interval_beats, reschedule_lookahead)
1159
+
1160
+ # "Backshift" initialization: treat start_pulse as the *target* of the first fire,
1161
+ # not the start of the first cycle. This ensures callbacks fire `lookahead` before
1162
+ # start_pulse (often ≤ 0, so they fire immediately when playback begins).
1163
+ #
1164
+ # Formula: cycle_start = start_pulse - interval
1165
+ # first_fire = start_pulse - lookahead
1166
+ #
1167
+ # After the first fire the loop advances normally:
1168
+ # next_start = cycle_start + interval = start_pulse
1169
+ # next_fire = start_pulse + interval - lookahead
1170
+ #
1171
+ # Note: if start_pulse = 0, first_fire is negative, so the callback fires
1172
+ # at pulse 0 (the very start of playback). Pass start_pulse = interval_pulses
1173
+ # to skip the initial fire and have the first fire at (interval - lookahead).
1174
+ # The harmonic clock does this because HarmonicState already holds the tonic.
1175
+
1176
+ initial_cycle_start = start_pulse - interval_pulses
1177
+ initial_fire_pulse = start_pulse - lookahead_pulses
1178
+
1179
+ scheduled_callback = ScheduledCallback(
1180
+ callback = callback,
1181
+ cycle_start_pulse = initial_cycle_start,
1182
+ interval_pulses = interval_pulses,
1183
+ lookahead_pulses = lookahead_pulses,
1184
+ next_fire_pulse = initial_fire_pulse
1185
+ )
1186
+
1187
+ async with self.callback_lock:
1188
+ counter = next(self._callback_counter)
1189
+ heapq.heappush(self.callback_queue, (scheduled_callback.next_fire_pulse, counter, scheduled_callback))
1190
+
1191
+
1192
+ async def schedule_callback_sequence (
1193
+ self,
1194
+ callback: typing.Callable[[int], typing.Union[typing.Optional[float], typing.Coroutine[typing.Any, typing.Any, typing.Optional[float]]]],
1195
+ start_pulse: int = 0,
1196
+ reschedule_lookahead: float = 1,
1197
+ ) -> None:
1198
+
1199
+ """Schedule a self-rescheduling callback with a variable firing interval.
1200
+
1201
+ Where :meth:`schedule_callback_repeating` fires on a fixed beat
1202
+ interval, this primitive lets the callback decide each hop: it is
1203
+ called ``lookahead`` before every *boundary* pulse, receives that
1204
+ boundary pulse, and returns the number of beats to the **next**
1205
+ boundary — or ``None`` to stop. Built for clocks that walk irregular
1206
+ spans, e.g. the harmonic clock under a bound progression's
1207
+ harmonic rhythm.
1208
+
1209
+ The first fire targets *start_pulse* as its boundary and is due
1210
+ ``lookahead`` before it (immediately, when that is already past —
1211
+ the same backshift idiom as the repeating scheduler).
1212
+
1213
+ Parameters:
1214
+ callback: ``fn(boundary_pulse) -> beats_to_next_boundary | None``.
1215
+ May be sync or async.
1216
+ start_pulse: The first boundary pulse.
1217
+ reschedule_lookahead: How many beats before each boundary the
1218
+ callback fires.
1219
+ """
1220
+
1221
+ lookahead_pulses = int(reschedule_lookahead * self.pulses_per_beat)
1222
+
1223
+ if lookahead_pulses < 0:
1224
+ raise ValueError("Reschedule lookahead cannot be negative")
1225
+
1226
+ scheduled = ScheduledCallbackSequence(
1227
+ callback = callback,
1228
+ boundary_pulse = start_pulse,
1229
+ lookahead_pulses = lookahead_pulses,
1230
+ next_fire_pulse = start_pulse - lookahead_pulses,
1231
+ )
1232
+
1233
+ async with self.callback_lock:
1234
+ counter = next(self._callback_counter)
1235
+ heapq.heappush(self.callback_sequence_queue, (scheduled.next_fire_pulse, counter, scheduled))
1236
+
1237
+
1238
+ async def play (self) -> None:
1239
+
1240
+ """
1241
+ Convenience method to start playback and wait for completion.
1242
+ """
1243
+
1244
+ await self.start()
1245
+
1246
+ try:
1247
+ if self.task:
1248
+ await self.task
1249
+ except asyncio.CancelledError:
1250
+ pass
1251
+ finally:
1252
+ await self.stop()
1253
+
1254
+
1255
+ def _send_clock_message (self, message_type: str) -> None:
1256
+
1257
+ """Send a bare MIDI system-realtime message (clock, start, stop, continue).
1258
+
1259
+ These messages carry no channel or data bytes — they are sent directly to
1260
+ the output port. Used for MIDI clock output when ``clock_output`` is True.
1261
+
1262
+ Parameters:
1263
+ message_type: One of ``"clock"``, ``"start"``, ``"stop"``, ``"continue"``.
1264
+ """
1265
+
1266
+ for port in self._output_devices:
1267
+ try:
1268
+ self._locked_send(port, mido.Message(message_type))
1269
+ except Exception:
1270
+ logger.exception(f"Failed to send MIDI {message_type} message")
1271
+
1272
+
1273
+ async def start (self) -> None:
1274
+
1275
+ """Start the sequencer playback in a separate asyncio task.
1276
+
1277
+ When an input device is configured, the MIDI input port is opened here
1278
+ (after the event loop is running) so that call_soon_threadsafe works.
1279
+ When ``clock_output`` is True, a MIDI Start (0xFA) message is sent before
1280
+ the first clock tick so connected hardware begins from the top.
1281
+ """
1282
+
1283
+ if self.running:
1284
+ return
1285
+
1286
+ # Set up MIDI input queue before opening the port.
1287
+ self._open_midi_inputs()
1288
+
1289
+ # Store the event loop for thread-safe scheduling (e.g., trigger() from user threads)
1290
+ self._event_loop = asyncio.get_running_loop()
1291
+
1292
+ self._waiting_for_start = self.clock_follow
1293
+ self.running = True
1294
+ self.task = asyncio.create_task(self._run_loop())
1295
+
1296
+ if self.clock_output:
1297
+ self._send_clock_message("start")
1298
+
1299
+ logger.info("Sequencer started")
1300
+
1301
+ await self.events.emit_async("start")
1302
+
1303
+
1304
+ async def stop (self) -> None:
1305
+
1306
+ """
1307
+ Stop the sequencer playback and cleanup resources.
1308
+ """
1309
+
1310
+ if not self.running and not self._output_devices:
1311
+ return
1312
+
1313
+ logger.info("Stopping sequencer...")
1314
+
1315
+ self.running = False
1316
+
1317
+ if self.task:
1318
+ try:
1319
+ await self.task
1320
+ except asyncio.CancelledError:
1321
+ # The loop task was cancelled (the Ctrl-C path) — since
1322
+ # Python 3.8 CancelledError is a BaseException, so a bare
1323
+ # `except Exception` missed it and the whole cleanup below
1324
+ # was skipped.
1325
+ logger.info("Sequencer loop task cancelled - continuing shutdown")
1326
+ except Exception:
1327
+ # A crashed loop must not abort shutdown - the cleanup below
1328
+ # (pending-send cancellation, panic, port close, recording
1329
+ # save) is exactly what a dying session needs most.
1330
+ logger.exception("Sequencer loop task ended with an exception - continuing shutdown")
1331
+
1332
+ # Cancel any latency-compensation deferrals still in flight. Must happen
1333
+ # after the loop has stopped producing (await self.task) and before
1334
+ # close_all() so a pending call_later can't fire on a closed port.
1335
+ # panic() below is the silence authority for any note stranded by this.
1336
+ self._cancel_pending_sends()
1337
+
1338
+ if self.clock_output:
1339
+ self._send_clock_message("stop")
1340
+
1341
+ await self.panic()
1342
+
1343
+ self._output_devices.close_all()
1344
+ self._input_devices.close_all()
1345
+
1346
+ # Leave the Ableton Link session cleanly if one was active, rather than
1347
+ # letting the socket linger in the session until process exit.
1348
+ if self._link_clock is not None:
1349
+ try:
1350
+ self._link_clock.disable()
1351
+ except Exception:
1352
+ logger.exception("Failed to disable Ableton Link clock")
1353
+
1354
+ self._midi_input_queue = None
1355
+ self._input_loop = None
1356
+
1357
+ self.save_recording()
1358
+
1359
+ logger.info("Sequencer stopped")
1360
+
1361
+ async with self.pattern_lock:
1362
+ self.reschedule_queue = []
1363
+ self._reschedule_counter = itertools.count()
1364
+
1365
+ async with self.callback_lock:
1366
+ self.callback_queue = []
1367
+ self.callback_sequence_queue = []
1368
+ self._callback_counter = itertools.count()
1369
+
1370
+ # Note: ``_event_counter`` is intentionally NOT reset here. The two
1371
+ # counters above pair with queues that we do clear, so resetting them
1372
+ # is symmetric. ``self.event_queue`` is left to drain naturally and
1373
+ # may carry stale items into a restart; resetting ``_event_counter``
1374
+ # alongside an un-cleared queue would let a fresh push (sequence=0)
1375
+ # sort ahead of a stale push (sequence=N) at the same pulse. Since
1376
+ # pulse is the primary heap key and the counter never overflows, we
1377
+ # just let it keep counting forever.
1378
+
1379
+ self.active_notes = set()
1380
+
1381
+ await self.events.emit_async("stop")
1382
+
1383
+
1384
+ async def _run_loop (self) -> None:
1385
+
1386
+ """Main playback loop - delegates to internal, external, or Link clock mode."""
1387
+
1388
+ self.start_time = time.perf_counter()
1389
+ self.pulse_count = 0
1390
+ self.current_bar = -1
1391
+
1392
+ pulses_per_bar = self.time_signature[0] * self.pulses_per_beat
1393
+
1394
+ if self.clock_follow and self._midi_input_queue is not None:
1395
+ await self._run_loop_external_clock(pulses_per_bar)
1396
+ elif self._link_clock is not None:
1397
+ await self._run_loop_link_clock(self._link_clock, pulses_per_bar)
1398
+ else:
1399
+ await self._run_loop_internal_clock(pulses_per_bar)
1400
+
1401
+
1402
+ def _check_bar_change (self, pulse: int, pulses_per_bar: int) -> None:
1403
+
1404
+ """Detect bar boundaries and fire bar callbacks + events."""
1405
+
1406
+ new_bar = pulse // pulses_per_bar
1407
+
1408
+ if new_bar > self.current_bar:
1409
+ self.current_bar = new_bar
1410
+
1411
+ # In render mode, stop after the requested number of bars.
1412
+ if self.render_mode and self.render_bars > 0 and self.current_bar >= self.render_bars:
1413
+ self.running = False
1414
+ return
1415
+
1416
+ self._spawn(self.events.emit_async("bar", self.current_bar))
1417
+
1418
+
1419
+ def _check_beat_change (self, pulse: int, pulses_per_beat: int) -> None:
1420
+
1421
+ """Detect beat boundaries within the bar and fire beat events."""
1422
+
1423
+ beat_in_bar = (pulse % (self.time_signature[0] * pulses_per_beat)) // pulses_per_beat
1424
+
1425
+ if beat_in_bar != self.current_beat:
1426
+ self.current_beat = beat_in_bar
1427
+ self._spawn(self.events.emit_async("beat", self.current_beat))
1428
+
1429
+
1430
+ async def _advance_pulse (self) -> None:
1431
+
1432
+ """Reschedule patterns, process events, and increment the pulse counter."""
1433
+
1434
+ if self._bpm_transition is not None:
1435
+ self._bpm_transition.elapsed_pulses += 1
1436
+
1437
+ if self._bpm_transition.elapsed_pulses >= self._bpm_transition.total_pulses:
1438
+ target = self._bpm_transition.target_bpm
1439
+ self._bpm_transition = None
1440
+ self.set_bpm(target)
1441
+ else:
1442
+ progress = self._bpm_transition.elapsed_pulses / self._bpm_transition.total_pulses
1443
+ eased = self._bpm_transition.easing_fn(progress)
1444
+ interpolated = (
1445
+ self._bpm_transition.start_bpm +
1446
+ (self._bpm_transition.target_bpm - self._bpm_transition.start_bpm) * eased
1447
+ )
1448
+ self.current_bpm = interpolated
1449
+ self.seconds_per_beat = 60.0 / self.current_bpm
1450
+ self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat
1451
+
1452
+ # Keep the recording's tempo map honest: without these events
1453
+ # a recorded ramp plays back at the pre-ramp tempo and jumps.
1454
+ if self.recording:
1455
+ self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(interpolated)))
1456
+
1457
+ # Accumulate simulated time and enforce the render time cap.
1458
+ if self.render_mode:
1459
+ self._render_elapsed_seconds += self.seconds_per_pulse
1460
+ if (
1461
+ self.render_max_seconds is not None
1462
+ and self._render_elapsed_seconds >= self.render_max_seconds
1463
+ ):
1464
+ max_min = self.render_max_seconds / 60.0
1465
+ logger.warning(
1466
+ f"Render stopped at {max_min:.1f}-minute safety limit. "
1467
+ f"Pass max_minutes=None with an explicit bars= count to remove this limit."
1468
+ )
1469
+ self.running = False
1470
+ return
1471
+
1472
+ # Drain buffered note input into the held-note tracker BEFORE patterns
1473
+ # rebuild, so a pattern's held_notes() snapshot reflects this pulse rather
1474
+ # than the previous one. deque.popleft() is GIL-atomic; the tracker is
1475
+ # only ever touched on this loop thread (here and during rebuild).
1476
+ if self._held_notes is not None:
1477
+ while self._note_input_buffer:
1478
+ is_on, pitch, velocity, when = self._note_input_buffer.popleft()
1479
+ if is_on:
1480
+ self._held_notes.note_on(pitch, velocity, when)
1481
+ else:
1482
+ self._held_notes.note_off(pitch, when)
1483
+
1484
+ await self._maybe_reschedule_patterns(self.pulse_count)
1485
+ await self._process_pulse(self.pulse_count)
1486
+ self.pulse_count += 1
1487
+
1488
+
1489
+ async def _run_loop_internal_clock (self, pulses_per_bar: int) -> None:
1490
+
1491
+ """Playback loop driven by the internal wall clock.
1492
+
1493
+ In normal mode the loop sleeps between pulses to maintain tempo.
1494
+ In render mode it runs as fast as possible (simulates time rather than
1495
+ waiting for the wall clock), stopping after *render_bars* bars.
1496
+ """
1497
+
1498
+ next_pulse_time = self.start_time
1499
+
1500
+ while self.running:
1501
+
1502
+ # In render mode, simulate time advancing one pulse at a time so
1503
+ # the inner loop always fires exactly once without spin-waiting.
1504
+ current_time = next_pulse_time if self.render_mode else time.perf_counter()
1505
+
1506
+ while current_time >= next_pulse_time:
1507
+ # Ordering within each pulse:
1508
+ # 1. _check_bar/beat_change() — update counters and queue "bar"/"beat"
1509
+ # event tasks (asyncio.create_task; not run yet).
1510
+ # 2. Send MIDI clock tick (if clock_output) so hardware receives it at
1511
+ # the same time as note events for tight sync.
1512
+ # 3. _advance_pulse() — fire callbacks, then send MIDI via _process_pulse().
1513
+ # 4. After the await returns, the event loop runs the queued event tasks,
1514
+ # which update the terminal display.
1515
+ #
1516
+ # Consequence: MIDI notes are sent *before* the display updates. The display
1517
+ # always trails the audio by roughly one pulse-processing cycle plus any
1518
+ # terminal rendering latency (~10-50 ms). This is expected and acceptable for
1519
+ # a visual status line — it cannot be tightened without restructuring the loop.
1520
+ self._check_bar_change(self.pulse_count, pulses_per_bar)
1521
+
1522
+ if not self.running:
1523
+ # A render bar-limit trips inside _check_bar_change; stop before
1524
+ # dispatching this pulse so the first beat of the next (unrendered)
1525
+ # bar never leaks into the recording.
1526
+ break # type: ignore[unreachable]
1527
+
1528
+ self._check_beat_change(self.pulse_count, self.pulses_per_beat)
1529
+ if self.clock_output:
1530
+ self._send_clock_message("clock")
1531
+ await self._advance_pulse()
1532
+ next_pulse_time += self.seconds_per_pulse
1533
+
1534
+ if not self.running:
1535
+ break # type: ignore[unreachable]
1536
+
1537
+ if not self.running:
1538
+ break # type: ignore[unreachable]
1539
+
1540
+ if not self.render_mode:
1541
+ # Check if queue is empty and we are past the last event.
1542
+ # Skipped in benchmark mode (_jitter_log set) so the clock
1543
+ # runs until explicitly cancelled via asyncio.
1544
+ if self._jitter_log is None:
1545
+ async with self.queue_lock:
1546
+ if (not self.event_queue and not self.active_notes
1547
+ and not self.reschedule_queue and not self.callback_queue
1548
+ and not self.callback_sequence_queue):
1549
+ logger.info("Sequence complete (no more events or active notes).")
1550
+ self.running = False
1551
+ break
1552
+
1553
+ sleep_time = next_pulse_time - time.perf_counter()
1554
+
1555
+ if sleep_time > 0:
1556
+ if self._spin_wait and sleep_time > self._spin_threshold:
1557
+ # Sleep to within _spin_threshold of the target, then busy-wait
1558
+ # for the remaining sub-millisecond. Trades ~1ms of CPU spin per
1559
+ # pulse for significantly tighter timing than asyncio.sleep alone.
1560
+ await asyncio.sleep(sleep_time - self._spin_threshold)
1561
+ while time.perf_counter() < next_pulse_time:
1562
+ pass
1563
+ else:
1564
+ await asyncio.sleep(sleep_time)
1565
+
1566
+ if self._jitter_log is not None:
1567
+ self._jitter_log.append(time.perf_counter() - next_pulse_time)
1568
+ else:
1569
+ # Yield to the event loop so queued tasks (pattern rescheduling,
1570
+ # asyncio.create_task callbacks) can run between pulses.
1571
+ await asyncio.sleep(0)
1572
+
1573
+
1574
+ async def _run_loop_external_clock (self, pulses_per_bar: int) -> None:
1575
+
1576
+ """Playback loop driven by incoming MIDI clock messages.
1577
+
1578
+ Each MIDI ``clock`` tick advances exactly one pulse (24 ppqn = internal ppqn).
1579
+ Transport messages (``start``, ``stop``, ``continue``) control sequencer state.
1580
+ The loop waits for a ``start`` or ``continue`` before advancing pulses,
1581
+ but still uses incoming ticks to estimate BPM for display.
1582
+ """
1583
+
1584
+ assert self._midi_input_queue is not None, "MIDI input queue must be initialized for external clock"
1585
+
1586
+ while self.running:
1587
+
1588
+ try:
1589
+ device_idx, message = await asyncio.wait_for(
1590
+ self._midi_input_queue.get(), timeout=2.0
1591
+ )
1592
+ except asyncio.TimeoutError:
1593
+ continue
1594
+
1595
+ if device_idx != self.clock_device_idx:
1596
+ continue
1597
+
1598
+ if message.type == "clock":
1599
+
1600
+ # Prime BPM estimation while waiting for start/continue,
1601
+ # but do not advance pulses or schedule events yet.
1602
+ if self._waiting_for_start:
1603
+ self._estimate_bpm(time.perf_counter())
1604
+ continue
1605
+
1606
+ self._estimate_bpm(time.perf_counter())
1607
+ self._check_bar_change(self.pulse_count, pulses_per_bar)
1608
+ self._check_beat_change(self.pulse_count, self.pulses_per_beat)
1609
+ await self._advance_pulse()
1610
+
1611
+ elif message.type == "start":
1612
+ logger.info("MIDI start received")
1613
+ self.pulse_count = 0
1614
+ self.current_bar = -1
1615
+ self.current_beat = -1
1616
+ self._waiting_for_start = False
1617
+
1618
+ elif message.type == "stop":
1619
+ logger.info("MIDI stop received")
1620
+ self.running = False
1621
+
1622
+ elif message.type == "continue":
1623
+ logger.info("MIDI continue received")
1624
+ self._waiting_for_start = False
1625
+
1626
+
1627
+ async def _run_loop_link_clock (self, link_clock: typing.Any, pulses_per_bar: int) -> None:
1628
+
1629
+ """Playback loop driven by Ableton Link beat clock.
1630
+
1631
+ Uses ``link_clock.sync(beat)`` as the timing primitive: for each pulse N
1632
+ we wait until the Link session beat reaches ``beat_origin + N / PPQN``.
1633
+ This gives accurate, phase-locked timing at 24 PPQN with typical jitter
1634
+ of ~0.3–0.5 ms (dominated by asyncio/OS scheduling overhead).
1635
+
1636
+ Starts on the next quantum boundary so that bar 0 aligns with all other
1637
+ Link participants in the session.
1638
+ """
1639
+
1640
+ logger.info("Ableton Link clock mode: waiting for bar boundary…")
1641
+
1642
+ # Wait for the next quantum boundary (bar start) for a clean, phase-locked start.
1643
+ beat_origin = await link_clock.wait_for_bar()
1644
+
1645
+ logger.info(f"Ableton Link: started at beat {beat_origin:.3f} (tempo={link_clock.tempo:.1f} BPM, peers={link_clock.num_peers})")
1646
+
1647
+ # Reset pulse counter here so bar/beat tracking starts from 0 at beat_origin.
1648
+ self.pulse_count = 0
1649
+ self.current_bar = -1
1650
+ self.current_beat = -1
1651
+
1652
+ while self.running:
1653
+
1654
+ # Compute the Link beat corresponding to the current pulse.
1655
+ target_beat = beat_origin + self.pulse_count / self.pulses_per_beat
1656
+
1657
+ # Wait for Link to reach that beat (this is the timing gate).
1658
+ await link_clock.sync(target_beat)
1659
+
1660
+ # Update local tempo from the Link session — propagates network BPM changes.
1661
+ link_bpm = link_clock.tempo
1662
+ if abs(link_bpm - self.current_bpm) > 0.01:
1663
+ # Record the change so a recorded session's .mid plays back
1664
+ # at the Link session tempo (mirrors the external-clock path).
1665
+ if self.recording:
1666
+ self._record_event(self.pulse_count, mido.MetaMessage('set_tempo', tempo=mido.bpm2tempo(link_bpm)))
1667
+ self.current_bpm = link_bpm
1668
+ self.seconds_per_beat = 60.0 / self.current_bpm
1669
+ self.seconds_per_pulse = self.seconds_per_beat / self.pulses_per_beat
1670
+ logger.debug(f"Link tempo update: {link_bpm:.2f} BPM")
1671
+
1672
+ self._check_bar_change(self.pulse_count, pulses_per_bar)
1673
+ self._check_beat_change(self.pulse_count, self.pulses_per_beat)
1674
+
1675
+ if self.clock_output:
1676
+ self._send_clock_message("clock")
1677
+
1678
+ await self._advance_pulse()
1679
+
1680
+ # Stop when all events are exhausted (same check as internal clock).
1681
+ async with self.queue_lock:
1682
+ if (not self.event_queue and not self.active_notes
1683
+ and not self.reschedule_queue and not self.callback_queue
1684
+ and not self.callback_sequence_queue):
1685
+ logger.info("Sequence complete (no more events or active notes).")
1686
+ self.running = False
1687
+ break
1688
+
1689
+
1690
+ async def _maybe_reschedule_patterns (self, pulse: int) -> None:
1691
+
1692
+ """
1693
+ Reschedule repeating callbacks and patterns when they reach their lookahead threshold.
1694
+ """
1695
+
1696
+ to_fire: typing.List[ScheduledCallback] = []
1697
+ to_reschedule: typing.List[ScheduledPattern] = []
1698
+
1699
+ async with self.callback_lock:
1700
+
1701
+ while self.callback_queue and self.callback_queue[0][0] <= pulse:
1702
+
1703
+ _, _, scheduled_callback = heapq.heappop(self.callback_queue)
1704
+
1705
+ next_start_pulse = scheduled_callback.cycle_start_pulse + scheduled_callback.interval_pulses
1706
+ scheduled_callback.cycle_start_pulse = next_start_pulse
1707
+ scheduled_callback.next_fire_pulse = next_start_pulse + scheduled_callback.interval_pulses - scheduled_callback.lookahead_pulses
1708
+
1709
+ to_fire.append(scheduled_callback)
1710
+
1711
+ if to_fire:
1712
+ # Decision path: composition-level callbacks fire before pattern rebuilds.
1713
+ for scheduled_callback in to_fire:
1714
+ try:
1715
+ result = scheduled_callback.callback(pulse)
1716
+
1717
+ if asyncio.iscoroutine(result):
1718
+ await result
1719
+
1720
+ except Exception:
1721
+ # Isolate a misbehaving callback so the rest still fire and
1722
+ # get rescheduled below — one bad callback must not stall the clock.
1723
+ logger.exception("Scheduled callback failed during reschedule (pulse %d) - continuing", pulse)
1724
+
1725
+ async with self.callback_lock:
1726
+ for scheduled_callback in to_fire:
1727
+ counter = next(self._callback_counter)
1728
+ heapq.heappush(self.callback_queue, (scheduled_callback.next_fire_pulse, counter, scheduled_callback))
1729
+
1730
+ # Variable-interval sequences fire after the fixed callbacks at the
1731
+ # same pulse (the form clock is fixed; the harmonic span clock is a
1732
+ # sequence — form-before-harmony ordering is preserved) and before
1733
+ # pattern rebuilds below.
1734
+ sequences_to_fire: typing.List[ScheduledCallbackSequence] = []
1735
+
1736
+ async with self.callback_lock:
1737
+
1738
+ while self.callback_sequence_queue and self.callback_sequence_queue[0][0] <= pulse:
1739
+ _, _, scheduled_sequence = heapq.heappop(self.callback_sequence_queue)
1740
+ sequences_to_fire.append(scheduled_sequence)
1741
+
1742
+ requeue: typing.List[ScheduledCallbackSequence] = []
1743
+
1744
+ for scheduled_sequence in sequences_to_fire:
1745
+
1746
+ try:
1747
+ result = scheduled_sequence.callback(scheduled_sequence.boundary_pulse)
1748
+
1749
+ if asyncio.iscoroutine(result):
1750
+ result = await result
1751
+
1752
+ except Exception:
1753
+ # Isolate a misbehaving callback so the clock survives; the
1754
+ # sequence is dropped — with no interval there is no next hop.
1755
+ logger.exception("Callback sequence failed (pulse %d) - sequence stopped", pulse)
1756
+ continue
1757
+
1758
+ if result is None:
1759
+ continue # the sequence chose to stop
1760
+
1761
+ interval_pulses = max(1, int(float(result) * self.pulses_per_beat))
1762
+ scheduled_sequence.boundary_pulse += interval_pulses
1763
+ scheduled_sequence.next_fire_pulse = max(
1764
+ pulse + 1,
1765
+ scheduled_sequence.boundary_pulse - scheduled_sequence.lookahead_pulses,
1766
+ )
1767
+ requeue.append(scheduled_sequence)
1768
+
1769
+ if requeue:
1770
+ async with self.callback_lock:
1771
+ for scheduled_sequence in requeue:
1772
+ counter = next(self._callback_counter)
1773
+ heapq.heappush(self.callback_sequence_queue, (scheduled_sequence.next_fire_pulse, counter, scheduled_sequence))
1774
+
1775
+ async with self.pattern_lock:
1776
+
1777
+ while self.reschedule_queue and self.reschedule_queue[0][0] <= pulse:
1778
+
1779
+ _, _, scheduled_pattern = heapq.heappop(self.reschedule_queue)
1780
+
1781
+ # Lazy pattern removal: if Composition.unregister() set the
1782
+ # ``_removed`` flag on this pattern, skip both the rebuild and
1783
+ # the re-push so it disappears from rotation. Already-queued
1784
+ # events in event_queue play out; sustaining notes were stopped
1785
+ # by unregister() via _stop_pattern_notes().
1786
+ if getattr(scheduled_pattern.pattern, '_removed', False):
1787
+ continue
1788
+
1789
+ next_start_pulse = scheduled_pattern.cycle_start_pulse + scheduled_pattern.length_pulses
1790
+ scheduled_pattern.cycle_start_pulse = next_start_pulse
1791
+
1792
+ # Anchor the upcoming cycle on the pattern itself, BEFORE
1793
+ # on_reschedule() below — rebuilds read it to place the cycle
1794
+ # on the absolute beat axis (the harmony window's axis).
1795
+ scheduled_pattern.pattern._cycle_start_pulse = next_start_pulse
1796
+
1797
+ to_reschedule.append(scheduled_pattern)
1798
+
1799
+ if to_reschedule:
1800
+ # Decision path: update shared composition state before pattern rebuilds.
1801
+ patterns = [scheduled_pattern.pattern for scheduled_pattern in to_reschedule]
1802
+
1803
+ try:
1804
+ await self.events.emit_async("reschedule_pulse", pulse, patterns)
1805
+ except Exception:
1806
+ logger.exception("reschedule_pulse listener failed (pulse %d) - continuing", pulse)
1807
+
1808
+ for scheduled_pattern in to_reschedule:
1809
+
1810
+ # Containment: a failing rebuild — or an invalid set_length() that
1811
+ # makes _get_pattern_timing raise — must cost this pattern its
1812
+ # cycle, never the clock. On failure the pattern keeps its
1813
+ # previous timing and is re-queued below for another try.
1814
+ try:
1815
+ scheduled_pattern.pattern.on_reschedule()
1816
+
1817
+ # Re-read length in case on_reschedule() changed it (e.g. via set_length).
1818
+ new_length_pulses, new_lookahead_pulses = self._get_pattern_timing(scheduled_pattern.pattern)
1819
+ scheduled_pattern.length_pulses = new_length_pulses
1820
+ scheduled_pattern.lookahead_pulses = new_lookahead_pulses
1821
+ scheduled_pattern.next_reschedule_pulse = scheduled_pattern.cycle_start_pulse + new_length_pulses - new_lookahead_pulses
1822
+
1823
+ await self.schedule_pattern(scheduled_pattern.pattern, scheduled_pattern.cycle_start_pulse)
1824
+
1825
+ except Exception:
1826
+ logger.exception("Pattern reschedule failed - pattern is silent this cycle and keeps its previous timing")
1827
+ scheduled_pattern.next_reschedule_pulse = scheduled_pattern.cycle_start_pulse + scheduled_pattern.length_pulses - scheduled_pattern.lookahead_pulses
1828
+
1829
+ self._spawn(self.events.emit_async("pattern_reschedule", scheduled_pattern.pattern, scheduled_pattern.cycle_start_pulse))
1830
+
1831
+ async with self.pattern_lock:
1832
+ for scheduled_pattern in to_reschedule:
1833
+ counter = next(self._reschedule_counter)
1834
+ heapq.heappush(self.reschedule_queue, (scheduled_pattern.next_reschedule_pulse, counter, scheduled_pattern))
1835
+
1836
+
1837
+ async def _process_pulse (self, pulse: int) -> None:
1838
+
1839
+ """
1840
+ Process and execute all events for a specific pulse.
1841
+ """
1842
+
1843
+ async with self.queue_lock:
1844
+
1845
+ # Drain queued CC forwards into the event heap.
1846
+ # deque.popleft() is GIL-atomic; safe to call from the event loop thread
1847
+ # while the callback thread calls append().
1848
+ while self._forward_buffer:
1849
+ fwd_pulse, fwd_msg, fwd_device = self._forward_buffer.popleft()
1850
+ self._push_event(MidiEvent.from_mido(fwd_pulse, fwd_msg, device=fwd_device))
1851
+
1852
+ while self.event_queue and self.event_queue[0].pulse <= pulse:
1853
+
1854
+ event = heapq.heappop(self.event_queue)
1855
+
1856
+ # Defensive: dispatching a single malformed event must not
1857
+ # crash the sequencer loop and stop the whole composition.
1858
+ # A bad event (e.g. a tuple stored on Note.velocity by a
1859
+ # misuse of a builder method) is logged with full context
1860
+ # and skipped; subsequent events continue normally.
1861
+ try:
1862
+
1863
+ # Track active notes (keyed by device, channel, note)
1864
+ if event.message_type == 'note_on' and event.velocity > 0:
1865
+ self.active_notes.add((event.device, event.channel, event.note))
1866
+ elif event.message_type == 'note_off' or (event.message_type == 'note_on' and event.velocity == 0):
1867
+ if (event.device, event.channel, event.note) in self.active_notes:
1868
+ self.active_notes.remove((event.device, event.channel, event.note))
1869
+
1870
+ # Send events at or before the current pulse (late events are sent
1871
+ # immediately). Latency compensation may defer the actual send by
1872
+ # the device's offset, but recording below always uses the LOGICAL
1873
+ # pulse — the .mid is the uncompensated score.
1874
+ self._dispatch_with_compensation(event)
1875
+
1876
+ if self.recording and event.message_type != 'osc':
1877
+
1878
+ mido_msg = event.to_mido()
1879
+ if mido_msg is not None:
1880
+ self._record_event(event.pulse, mido_msg)
1881
+
1882
+ except Exception:
1883
+
1884
+ logger.exception(
1885
+ "Failed to dispatch %s event at pulse %d (device=%s, channel=%s) — skipping",
1886
+ event.message_type, event.pulse, event.device, event.channel
1887
+ )
1888
+
1889
+
1890
+ async def _stop_all_active_notes (self) -> None:
1891
+
1892
+ """
1893
+ Send note_off for all currently tracked active notes.
1894
+ """
1895
+
1896
+ async with self.queue_lock:
1897
+ for dev, channel, note in list(self.active_notes):
1898
+ port = self._output_devices.get(dev)
1899
+ if port is not None:
1900
+ try:
1901
+ self._locked_send(port, mido.Message('note_off', channel=channel, note=note, velocity=0))
1902
+ except Exception:
1903
+ logger.exception("Failed to send note_off during stop")
1904
+ self.active_notes.clear()
1905
+
1906
+
1907
+ async def _stop_pattern_notes (self, pattern: PatternLike) -> None:
1908
+
1909
+ """Send note_off for active notes belonging to a single pattern.
1910
+
1911
+ Targets the pattern's primary ``(device, channel)`` plus every entry
1912
+ in ``pattern.mirrors``, so patterns with mirrors have their notes
1913
+ stopped on every output port they fan out to. Used by
1914
+ ``Composition.unregister()`` to flush drones and any sustaining
1915
+ notes when a pattern is being torn down.
1916
+ """
1917
+
1918
+ mirrors = getattr(pattern, 'mirrors', [])
1919
+ # Build the target set from each entry's (device, channel) prefix — a
1920
+ # 3-tuple mirror carries a dict (drum_note_map) and would be unhashable.
1921
+ targets: typing.Set[typing.Tuple[int, int]] = {(pattern.device, pattern.channel)} | {(e[0], e[1]) for e in mirrors}
1922
+
1923
+ async with self.queue_lock:
1924
+
1925
+ stranded = [t for t in self.active_notes if (t[0], t[1]) in targets]
1926
+
1927
+ for dev, channel, note in stranded:
1928
+ # Route through latency compensation, NOT straight to the
1929
+ # port: a note_on for this device may still be deferred in
1930
+ # _pending_sends, and an immediate note_off would overtake it,
1931
+ # leaving the note stuck ringing (drones have no later
1932
+ # note_off to rescue them). The shared per-device offset
1933
+ # preserves on→off order.
1934
+ try:
1935
+ self._dispatch_with_compensation(MidiEvent(
1936
+ pulse = self.pulse_count,
1937
+ message_type = 'note_off',
1938
+ channel = channel,
1939
+ note = note,
1940
+ velocity = 0,
1941
+ device = dev,
1942
+ ))
1943
+ except Exception:
1944
+ logger.exception(f"Failed to send note_off during unregister (dev={dev}, ch={channel}, note={note})")
1945
+ self.active_notes.discard((dev, channel, note))
1946
+
1947
+
1948
+ def _send_offset_seconds (self, device: int) -> float:
1949
+
1950
+ """Return the latency-compensation send offset (seconds) for *device*.
1951
+
1952
+ ``(max_latency − device_latency) / 1000``, clamped ≥ 0. The slowest
1953
+ device returns 0 (sent at logical time); faster devices return a
1954
+ positive delay so they sound together.
1955
+
1956
+ Invariant: the offset is **per-device**, so every event for one device
1957
+ shares it. That is what preserves same-pulse FIFO order through
1958
+ deferral — an NRPN burst (CC 99 → 98 → 6 → 38) on one device stays in
1959
+ order because all four are deferred by the same amount. A future
1960
+ per-channel/per-message latency would break that and must not be added
1961
+ without re-thinking burst ordering.
1962
+ """
1963
+
1964
+ offset_ms = self._max_device_latency_ms - self._output_devices.latency_of(device)
1965
+ if offset_ms <= 0.0:
1966
+ return 0.0
1967
+ return offset_ms / 1000.0
1968
+
1969
+ def _dispatch_with_compensation (self, event: MidiEvent) -> None:
1970
+
1971
+ """Send *event* now, or defer it by its device's latency offset.
1972
+
1973
+ Deferral is a wall-clock ``call_later`` so it is correct regardless of
1974
+ tempo or clock source. Skipped entirely in render mode (no real clock
1975
+ — deferring would drop events from the rendered file) and when no event
1976
+ loop is running (the synchronous test path).
1977
+ """
1978
+
1979
+ if self.render_mode or self._event_loop is None:
1980
+ self._send_midi(event)
1981
+ return
1982
+
1983
+ offset_s = self._send_offset_seconds(event.device)
1984
+ if offset_s <= 0.0:
1985
+ self._send_midi(event)
1986
+ return
1987
+
1988
+ # Defer the physical send. The one-element ``cell`` lets the callback
1989
+ # discard exactly its own handle from _pending_sends (the handle isn't
1990
+ # known until call_later returns, but _fire only runs after we append).
1991
+ cell: typing.List[asyncio.TimerHandle] = []
1992
+
1993
+ def _fire () -> None:
1994
+ self._pending_sends.discard(cell[0])
1995
+ self._send_midi(event)
1996
+
1997
+ handle = self._event_loop.call_later(offset_s, _fire)
1998
+ cell.append(handle)
1999
+ self._pending_sends.add(handle)
2000
+
2001
+ def _cancel_pending_sends (self) -> None:
2002
+
2003
+ """Cancel and forget all in-flight deferred sends. Idempotent.
2004
+
2005
+ Called during ``stop()`` before ports close so a pending ``call_later``
2006
+ can never fire ``port.send()`` on a closed port. Stranded notes are not
2007
+ a concern here: ``panic()`` runs after this and is the silence
2008
+ authority (``active_notes`` reflects logical time and can diverge from
2009
+ what physically fired, so it is not relied upon).
2010
+ """
2011
+
2012
+ for handle in self._pending_sends:
2013
+ handle.cancel()
2014
+ self._pending_sends.clear()
2015
+
2016
+ def _locked_send (self, port: typing.Any, message: typing.Any) -> None:
2017
+
2018
+ """Write one message to a port under the send lock.
2019
+
2020
+ The lock keeps the loop thread and the instant cc_forward callback
2021
+ thread from interleaving bytes on the same port.
2022
+ """
2023
+
2024
+ with self._port_send_lock:
2025
+ port.send(message)
2026
+
2027
+ def _send_midi (self, event: MidiEvent) -> None:
2028
+
2029
+ """
2030
+ Send a MIDI message to the appropriate output device.
2031
+ """
2032
+
2033
+ port = self._output_devices.get(event.device)
2034
+ if port is not None:
2035
+
2036
+ try:
2037
+
2038
+ if event.message_type == 'osc':
2039
+ if self.osc_server is not None:
2040
+ address, args = event.data
2041
+ self.osc_server.send(address, *args)
2042
+ return
2043
+
2044
+ msg = event.to_mido()
2045
+ if msg is None:
2046
+ return
2047
+
2048
+ self._locked_send(port, msg)
2049
+
2050
+ except Exception:
2051
+ logger.exception("MIDI send failed (device may be disconnected)")
2052
+
2053
+
2054
+ async def panic (self) -> None:
2055
+
2056
+ """
2057
+ Send a MIDI panic message to all channels.
2058
+ """
2059
+
2060
+ logger.info("Panic: sending all notes off.")
2061
+
2062
+ # 1. Stop all tracked active notes manually
2063
+ await self._stop_all_active_notes()
2064
+
2065
+ for port in self._output_devices:
2066
+
2067
+ try:
2068
+
2069
+ # Hold the send lock so an instant cc_forward on the callback
2070
+ # thread cannot interleave with the panic sweep.
2071
+ with self._port_send_lock:
2072
+
2073
+ # 2. Send "All Notes Off" (CC 123) and "All Sound Off" (CC 120) to all 16 channels
2074
+ for channel in range(16):
2075
+ port.send(mido.Message('control_change', channel=channel, control=123, value=0))
2076
+ port.send(mido.Message('control_change', channel=channel, control=120, value=0))
2077
+
2078
+ # 3. Use built-in panic and reset
2079
+ port.panic()
2080
+
2081
+ # Note: reset() might close/reopen ports or clear internal buffers depending on backend,
2082
+ # but mido docs say it sends "All Notes Off" and "Reset All Controllers".
2083
+ port.reset()
2084
+
2085
+ except Exception:
2086
+ logger.exception("MIDI panic failed (device may be disconnected)")