subsequence 0.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
subsequence/sequencer.py
ADDED
|
@@ -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)")
|