subsequence 0.6.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. subsequence/__init__.py +231 -0
  2. subsequence/__main__.py +24 -0
  3. subsequence/assets/web/index.html +345 -0
  4. subsequence/cadences.py +113 -0
  5. subsequence/chord_graphs/__init__.py +100 -0
  6. subsequence/chord_graphs/aeolian_minor.py +158 -0
  7. subsequence/chord_graphs/chromatic_mediant.py +113 -0
  8. subsequence/chord_graphs/diminished.py +97 -0
  9. subsequence/chord_graphs/dorian_minor.py +127 -0
  10. subsequence/chord_graphs/functional_major.py +102 -0
  11. subsequence/chord_graphs/hooktheory_major.py +88 -0
  12. subsequence/chord_graphs/lydian_major.py +130 -0
  13. subsequence/chord_graphs/mixolydian.py +98 -0
  14. subsequence/chord_graphs/phrygian_minor.py +76 -0
  15. subsequence/chord_graphs/suspended.py +109 -0
  16. subsequence/chord_graphs/turnaround_global.py +157 -0
  17. subsequence/chord_graphs/whole_tone.py +77 -0
  18. subsequence/chords.py +419 -0
  19. subsequence/composition.py +6099 -0
  20. subsequence/conductor.py +238 -0
  21. subsequence/constants/__init__.py +24 -0
  22. subsequence/constants/durations.py +37 -0
  23. subsequence/constants/instruments/__init__.py +13 -0
  24. subsequence/constants/instruments/gm_cc.py +46 -0
  25. subsequence/constants/instruments/gm_drums.py +53 -0
  26. subsequence/constants/instruments/gm_instruments.py +32 -0
  27. subsequence/constants/instruments/roland_tr8s.py +320 -0
  28. subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
  29. subsequence/constants/midi_notes.py +29 -0
  30. subsequence/constants/pulses.py +17 -0
  31. subsequence/constants/velocity.py +22 -0
  32. subsequence/definitions.py +232 -0
  33. subsequence/display.py +617 -0
  34. subsequence/easing.py +347 -0
  35. subsequence/event_emitter.py +109 -0
  36. subsequence/form_state.py +665 -0
  37. subsequence/forms.py +257 -0
  38. subsequence/groove.py +323 -0
  39. subsequence/harmonic_rhythm.py +83 -0
  40. subsequence/harmonic_state.py +352 -0
  41. subsequence/harmony.py +197 -0
  42. subsequence/held_notes.py +91 -0
  43. subsequence/helpers/__init__.py +0 -0
  44. subsequence/helpers/network.py +58 -0
  45. subsequence/helpers/wing.py +430 -0
  46. subsequence/intervals.py +436 -0
  47. subsequence/keystroke.py +249 -0
  48. subsequence/link_clock.py +128 -0
  49. subsequence/live_client.py +187 -0
  50. subsequence/live_reloader.py +298 -0
  51. subsequence/live_server.py +161 -0
  52. subsequence/melodic_state.py +483 -0
  53. subsequence/midi.py +97 -0
  54. subsequence/midi_utils.py +329 -0
  55. subsequence/mini_notation.py +164 -0
  56. subsequence/motifs.py +2356 -0
  57. subsequence/osc.py +194 -0
  58. subsequence/pattern.py +363 -0
  59. subsequence/pattern_algorithmic.py +2010 -0
  60. subsequence/pattern_builder.py +2589 -0
  61. subsequence/pattern_midi.py +1208 -0
  62. subsequence/progressions.py +1913 -0
  63. subsequence/py.typed +0 -0
  64. subsequence/roles.py +63 -0
  65. subsequence/sequence_utils.py +3123 -0
  66. subsequence/sequencer.py +2086 -0
  67. subsequence/tuning.py +453 -0
  68. subsequence/voicings.py +151 -0
  69. subsequence/web_ui.py +337 -0
  70. subsequence/weighted_graph.py +156 -0
  71. subsequence-0.6.4.dist-info/METADATA +208 -0
  72. subsequence-0.6.4.dist-info/RECORD +78 -0
  73. subsequence-0.6.4.dist-info/WHEEL +5 -0
  74. subsequence-0.6.4.dist-info/entry_points.txt +2 -0
  75. subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
  76. subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
  77. subsequence-0.6.4.dist-info/scm_version.json +8 -0
  78. subsequence-0.6.4.dist-info/top_level.txt +1 -0
subsequence/midi.py ADDED
@@ -0,0 +1,97 @@
1
+ """
2
+ Lightweight MIDI message constructors.
3
+
4
+ Provides factory functions that return ``mido.Message`` objects without
5
+ requiring users to import or interact with mido directly. Intended for use
6
+ with ``composition.cc_forward()`` callable transforms and any other context
7
+ where a ``mido.Message`` is needed as a return value.
8
+
9
+ Example::
10
+
11
+ import subsequence.midi as midi
12
+
13
+ composition.cc_forward(1,
14
+ lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)
15
+ )
16
+ """
17
+
18
+ import typing
19
+ import mido
20
+
21
+
22
+ def cc (
23
+ control: int,
24
+ value: int,
25
+ channel: int = 0,
26
+ ) -> mido.Message:
27
+
28
+ """Create a MIDI Control Change message.
29
+
30
+ Parameters:
31
+ control: CC number (0–127).
32
+ value: CC value (0–127).
33
+ channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
34
+
35
+ Returns:
36
+ A ``mido.Message`` of type ``control_change``.
37
+
38
+ Example:
39
+ ```python
40
+ import subsequence.midi as midi
41
+
42
+ # Forward CC 1 to CC 74, scaling range to 40–100
43
+ composition.cc_forward(1,
44
+ lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)
45
+ )
46
+ ```
47
+ """
48
+
49
+ return mido.Message('control_change', channel=channel, control=control, value=value)
50
+
51
+
52
+ def pitchwheel (
53
+ pitch: int,
54
+ channel: int = 0,
55
+ ) -> mido.Message:
56
+
57
+ """Create a MIDI Pitch Wheel message.
58
+
59
+ Parameters:
60
+ pitch: Pitch bend value (-8192 to 8191). 0 is centre (no bend).
61
+ Out-of-range values are clamped to the valid range.
62
+ channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
63
+
64
+ Returns:
65
+ A ``mido.Message`` of type ``pitchwheel``.
66
+
67
+ Example:
68
+ ```python
69
+ import subsequence.midi as midi
70
+
71
+ # Forward CC 1 as pitch bend, scaled to upper half only (0 to +8191)
72
+ composition.cc_forward(1,
73
+ lambda v, ch: midi.pitchwheel(int(v / 127 * 8191), channel=ch)
74
+ )
75
+ ```
76
+ """
77
+
78
+ pitch = max(-8192, min(8191, pitch))
79
+ return mido.Message('pitchwheel', channel=channel, pitch=pitch)
80
+
81
+
82
+ def program_change (
83
+ program: int,
84
+ channel: int = 0,
85
+ ) -> mido.Message:
86
+
87
+ """Create a MIDI Program Change message.
88
+
89
+ Parameters:
90
+ program: Program number (0–127).
91
+ channel: MIDI channel (0-indexed, 0–15). Defaults to 0.
92
+
93
+ Returns:
94
+ A ``mido.Message`` of type ``program_change``.
95
+ """
96
+
97
+ return mido.Message('program_change', channel=channel, program=program)
@@ -0,0 +1,329 @@
1
+ """
2
+ MIDI device plumbing — discovering, opening, and registering hardware ports.
3
+
4
+ Provides interactive/automatic output and input device selection, the
5
+ multi-device registry used by the sequencer, and the ``bank_select()``
6
+ helper for addressing synth banks beyond the first 128 programs.
7
+ """
8
+
9
+ import logging
10
+ import typing
11
+ import mido
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # Type alias for device identifiers: index (int), name (str), or None (device 0).
17
+ DeviceId = typing.Union[int, str, None]
18
+
19
+
20
+ class MidiDeviceRegistry:
21
+
22
+ """Ordered registry of named MIDI ports (output or input).
23
+
24
+ Devices are stored in insertion order. Index 0 is always the first
25
+ (or only) device — the default for all APIs that do not specify a device.
26
+ Devices can be looked up by integer index or by name string.
27
+ ``None`` always resolves to index 0.
28
+
29
+ The registry is intended to be append-only once playback has started.
30
+ All registered port objects must already be open.
31
+ """
32
+
33
+ def __init__ (self) -> None:
34
+
35
+ """
36
+ Create an empty registry; populate it with add().
37
+ """
38
+
39
+ self._ports: typing.List[typing.Tuple[str, typing.Any]] = []
40
+ self._name_to_index: typing.Dict[str, int] = {}
41
+ # Per-device physical output latency in milliseconds, parallel to
42
+ # self._ports by index. Kept separate from the (name, port) tuple so
43
+ # replace() can swap the port object without disturbing latency.
44
+ self._latencies: typing.List[float] = []
45
+
46
+ def add (self, name: str, port: typing.Any, latency_ms: float = 0.0) -> int:
47
+
48
+ """Register a port under *name*. Returns the assigned integer index.
49
+
50
+ *latency_ms* is the device's physical output latency (non-negative);
51
+ see :meth:`set_latency`.
52
+ """
53
+
54
+ idx = len(self._ports)
55
+ self._ports.append((name, port))
56
+ self._latencies.append(max(0.0, float(latency_ms)))
57
+ # First registration wins for name collisions.
58
+ if name not in self._name_to_index:
59
+ self._name_to_index[name] = idx
60
+ return idx
61
+
62
+ def get (self, device: DeviceId = None) -> typing.Optional[typing.Any]:
63
+
64
+ """Return the port for *device*, or ``None`` if the registry is empty.
65
+
66
+ ``None`` → index 0. ``int`` → direct index. ``str`` → name lookup.
67
+ Returns ``None`` if the device cannot be resolved (empty registry,
68
+ out-of-range index, unknown name).
69
+ """
70
+
71
+ if not self._ports:
72
+ return None
73
+ idx = self.index_of(device)
74
+ if idx < 0 or idx >= len(self._ports):
75
+ return None
76
+ return self._ports[idx][1]
77
+
78
+ def index_of (self, device: DeviceId = None) -> int:
79
+
80
+ """Resolve *device* to an integer index. Returns 0 for ``None``.
81
+ Returns -1 if the name is unknown or the index is out of range."""
82
+
83
+ if device is None:
84
+ return 0
85
+ if isinstance(device, int):
86
+ if 0 <= device < len(self._ports):
87
+ return device
88
+ return -1
89
+ # str
90
+ return self._name_to_index.get(device, -1)
91
+
92
+ def replace (self, index: int, port: typing.Any) -> None:
93
+
94
+ """Replace the port object at *index* without changing the name or index mapping.
95
+
96
+ Used by the backward-compat ``midi_out``/``midi_in`` setters to allow
97
+ test code to inject a fake port after the registry has been populated.
98
+ Raises ``IndexError`` if *index* is out of range.
99
+ """
100
+
101
+ if index < 0 or index >= len(self._ports):
102
+ raise IndexError(f"MidiDeviceRegistry: index {index} out of range (size {len(self._ports)})")
103
+ name = self._ports[index][0]
104
+ self._ports[index] = (name, port)
105
+ # Latency is intentionally preserved — replace() is a pure port swap.
106
+
107
+ def set_latency (self, device: DeviceId, latency_ms: float) -> None:
108
+
109
+ """Set the physical output latency (milliseconds) for *device*.
110
+
111
+ *latency_ms* must be non-negative — a device cannot sound before it is
112
+ triggered, so a negative output latency is meaningless. Raises
113
+ ``ValueError`` for a negative value or an unknown device.
114
+ """
115
+
116
+ if latency_ms < 0:
117
+ raise ValueError(f"latency_ms must be non-negative — got {latency_ms}")
118
+ idx = self.index_of(device)
119
+ if idx < 0:
120
+ raise ValueError(f"Unknown output device: {device!r}")
121
+ self._latencies[idx] = float(latency_ms)
122
+
123
+ def latency_of (self, device: DeviceId = None) -> float:
124
+
125
+ """Return the latency (ms) for *device*, or 0.0 if it cannot be resolved.
126
+
127
+ Defensive on the hot dispatch path: an unknown device yields 0.0 rather
128
+ than raising, so a stray event can never crash the send loop.
129
+ """
130
+
131
+ idx = self.index_of(device)
132
+ if idx < 0 or idx >= len(self._latencies):
133
+ return 0.0
134
+ return self._latencies[idx]
135
+
136
+ def max_latency (self) -> float:
137
+
138
+ """Return the largest latency across all registered devices (0.0 if empty)."""
139
+
140
+ return max(self._latencies, default=0.0)
141
+
142
+ def close_all (self) -> None:
143
+
144
+ """Close every registered port and clear the registry."""
145
+
146
+ for name, port in self._ports:
147
+ try:
148
+ port.close()
149
+ except (OSError, RuntimeError, AttributeError):
150
+ # Shutdown path: a failure on one port must not prevent closing the rest.
151
+ logger.exception(f"Error closing MIDI port '{name}'")
152
+ self._ports.clear()
153
+ self._name_to_index.clear()
154
+ self._latencies.clear()
155
+
156
+ def __len__ (self) -> int:
157
+
158
+ """
159
+ Number of registered devices.
160
+ """
161
+
162
+ return len(self._ports)
163
+
164
+ def __iter__ (self) -> typing.Iterator[typing.Any]:
165
+ """Iterate over port objects (not names)."""
166
+ return (port for _, port in self._ports)
167
+
168
+ def __bool__ (self) -> bool:
169
+
170
+ """
171
+ True if at least one device is registered.
172
+ """
173
+
174
+ return bool(self._ports)
175
+
176
+
177
+ def bank_select (bank: int) -> typing.Tuple[int, int]:
178
+
179
+ """
180
+ Convert a 14-bit MIDI bank number to (MSB, LSB) for use with
181
+ ``p.program_change()``.
182
+
183
+ MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and
184
+ CC 32 (Bank LSB). Together they encode a 14-bit bank number in the
185
+ range 0–16,383:
186
+
187
+ MSB = bank // 128 (upper 7 bits, sent on CC 0)
188
+ LSB = bank % 128 (lower 7 bits, sent on CC 32)
189
+
190
+ Args:
191
+ bank: Integer bank number, 0–16,383. Values outside this range are
192
+ clamped.
193
+
194
+ Returns:
195
+ ``(msb, lsb)`` tuple, each value in 0–127.
196
+
197
+ Example:
198
+ ```python
199
+ msb, lsb = subsequence.bank_select(128) # → (1, 0)
200
+ p.program_change(48, bank_msb=msb, bank_lsb=lsb)
201
+ ```
202
+ """
203
+
204
+ bank = max(0, min(16383, bank))
205
+ return bank >> 7, bank & 0x7F
206
+
207
+ def select_output_device (device_name: typing.Optional[str] = None) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]:
208
+
209
+ """
210
+ Select and open a MIDI output device.
211
+
212
+ If ``device_name`` is provided, attempts to open that specific device.
213
+ If ``device_name`` is None, auto-discovers available devices:
214
+
215
+ - If exactly one device exists, it is selected automatically.
216
+ - If multiple devices exist, prompts the user to choose one from the console.
217
+ - If no devices exist, logs an error and returns None.
218
+
219
+ Returns:
220
+ A tuple of (device_name, midi_out_object) or (None, None) on failure.
221
+ """
222
+
223
+ try:
224
+ outputs = mido.get_output_names()
225
+ logger.info(f"Available MIDI outputs: {outputs}")
226
+
227
+ if not outputs:
228
+ logger.error("No MIDI output devices found.")
229
+ return None, None
230
+
231
+ # Explicit device requested
232
+ if device_name is not None:
233
+ if device_name in outputs:
234
+ midi_out = mido.open_output(device_name)
235
+ logger.info(f"Opened MIDI output: {device_name}")
236
+ return device_name, midi_out
237
+ else:
238
+ logger.error(
239
+ f"MIDI output device '{device_name}' not found. "
240
+ f"Available devices: {outputs}"
241
+ )
242
+ return None, None
243
+
244
+ # Auto-discover: one device - use it
245
+ if len(outputs) == 1:
246
+ selected_name = outputs[0]
247
+ midi_out = mido.open_output(selected_name)
248
+ logger.info(f"One MIDI output found - using '{selected_name}'")
249
+ return selected_name, midi_out
250
+
251
+ # Auto-discover: multiple devices - prompt user
252
+ print("\nAvailable MIDI output devices:\n")
253
+ for i, name in enumerate(outputs, 1):
254
+ print(f" {i}. {name}")
255
+ print()
256
+
257
+ while True:
258
+ try:
259
+ choice = int(input(f"Select a device (1-{len(outputs)}): "))
260
+ if 1 <= choice <= len(outputs):
261
+ break
262
+ except ValueError:
263
+ pass
264
+ except EOFError:
265
+ # stdin is closed (headless/redirected run) — the prompt can
266
+ # never be answered, so retrying would spin forever.
267
+ raise RuntimeError(
268
+ "No interactive terminal to choose between multiple MIDI outputs — "
269
+ f"pass output_device= with one of: {outputs}"
270
+ ) from None
271
+ print(f"Enter a number between 1 and {len(outputs)}.")
272
+
273
+ selected_name = outputs[choice - 1]
274
+ midi_out = mido.open_output(selected_name)
275
+ logger.info(f"Opened MIDI output: {selected_name}")
276
+
277
+ print(f"\nTip: To skip this prompt, pass the device name directly:\n")
278
+ print(f" Sequencer(output_device_name=\"{selected_name}\")")
279
+ print(f" Composition(output_device=\"{selected_name}\")\n")
280
+
281
+ return selected_name, midi_out
282
+
283
+ except (OSError, RuntimeError) as e:
284
+ logger.error(f"Failed to open MIDI output: {e}")
285
+ return None, None
286
+
287
+
288
+ def select_input_device (device_name: typing.Optional[str] = None, callback: typing.Optional[typing.Callable] = None) -> typing.Tuple[typing.Optional[str], typing.Optional[typing.Any]]:
289
+
290
+ """
291
+ Select and open a MIDI input device.
292
+
293
+ If ``device_name`` is provided, attempts to open exactly that device.
294
+ If ``device_name`` is None, returns None without prompting (input is optional/advanced).
295
+ To enforce input, the caller should check the return value.
296
+
297
+ A named device that is not present raises ValueError rather than falling
298
+ back to another input: MIDI input drives clock-follow and live note
299
+ capture, so silently listening to the wrong device would desynchronise
300
+ or mis-record a performance.
301
+
302
+ Returns:
303
+ A tuple of (device_name, midi_in_object), or (None, None) when no
304
+ name was given or the device failed to open.
305
+
306
+ Raises:
307
+ ValueError: If *device_name* is not among the available inputs.
308
+ """
309
+
310
+ if device_name is None:
311
+ return None, None
312
+
313
+ try:
314
+ inputs = mido.get_input_names()
315
+ logger.info(f"Available MIDI inputs: {inputs}")
316
+
317
+ if device_name not in inputs:
318
+ raise ValueError(
319
+ f"MIDI input device '{device_name}' not found. "
320
+ f"Available devices: {inputs}"
321
+ )
322
+
323
+ midi_in = mido.open_input(device_name, callback=callback)
324
+ logger.info(f"Opened MIDI input: {device_name}")
325
+ return device_name, midi_in
326
+
327
+ except (OSError, RuntimeError) as e:
328
+ logger.error(f"Failed to open MIDI input: {e}")
329
+ return None, None
@@ -0,0 +1,164 @@
1
+ import dataclasses
2
+ import typing
3
+
4
+
5
+ @dataclasses.dataclass
6
+ class ParsedEvent:
7
+
8
+ """
9
+ Represents a single event parsed from mini-notation.
10
+ """
11
+
12
+ time: float
13
+ duration: float
14
+ symbol: str
15
+ probability: float = 1.0
16
+
17
+
18
+ class MiniNotationError(Exception):
19
+ pass
20
+
21
+
22
+ def parse (notation: str, total_duration: float = 4.0) -> typing.List[ParsedEvent]:
23
+
24
+ """
25
+ Parse a mini-notation string into a list of timed events.
26
+
27
+ Mini-notation is a concise way to express rhythmic and melodic phrases.
28
+ It distributes events evenly across the specified duration.
29
+
30
+ **Syntax:**
31
+
32
+ - ``x y z``: Items separated by spaces are distributed across the total duration.
33
+ - ``[a b]``: Groups items into a single subdivided step.
34
+ - ``~`` or ``.``: A rest.
35
+ - ``_``: Extends the previous note (sustain).
36
+ - ``x?0.6``: Probability suffix — fires with the given probability (0.0–1.0).
37
+
38
+ Parameters:
39
+ notation: The string to parse.
40
+ total_duration: The duration (in beats) to distribute the
41
+ events over (default 4.0).
42
+
43
+ Returns:
44
+ A list of ``ParsedEvent`` objects with calculated times and durations.
45
+
46
+ Example:
47
+ ```python
48
+ # Distributes kick on beats 1 and 3, snare on 2 and 4
49
+ parse("kick snare kick snare", 4.0)
50
+
51
+ # Subdivisions: kick on 1, snare on 2.1 and 2.2
52
+ parse("kick [snare snare]", 2.0)
53
+ ```
54
+ """
55
+
56
+ if total_duration <= 0:
57
+ raise ValueError("total_duration must be positive")
58
+
59
+ tokens = _tokenize(notation)
60
+
61
+ events = _parse_recursive(tokens, 0.0, total_duration)
62
+
63
+ return _post_process_sustains(events)
64
+
65
+
66
+ def _tokenize (text: str) -> typing.List[typing.Union[str, list]]:
67
+
68
+ """
69
+ Convert string into nested lists of tokens.
70
+ "a [b c]" -> ["a", ["b", "c"]]
71
+ """
72
+
73
+ # Add spaces around brackets to make splitting easier
74
+ text = text.replace("[", " [ ").replace("]", " ] ")
75
+ raw_tokens = text.split()
76
+
77
+ stack: typing.List[list] = [[]]
78
+
79
+ for token in raw_tokens:
80
+
81
+ if token == "[":
82
+ new_group: typing.List[typing.Any] = []
83
+ stack[-1].append(new_group)
84
+ stack.append(new_group)
85
+
86
+ elif token == "]":
87
+ if len(stack) <= 1:
88
+ raise MiniNotationError("Unexpected closing bracket")
89
+ stack.pop()
90
+
91
+ else:
92
+ stack[-1].append(token)
93
+
94
+ if len(stack) > 1:
95
+ raise MiniNotationError("Missing closing bracket")
96
+
97
+ return stack[0]
98
+
99
+
100
+ def _parse_recursive (tokens: list, start_time: float, duration: float) -> typing.List[ParsedEvent]:
101
+
102
+ """
103
+ Recursively distribute tokens over the given duration.
104
+ """
105
+
106
+ events: typing.List[ParsedEvent] = []
107
+ step_duration = duration / len(tokens) if tokens else 0
108
+
109
+ for i, token in enumerate(tokens):
110
+
111
+ current_time = start_time + (i * step_duration)
112
+
113
+ if isinstance(token, list):
114
+ # Recursively parse sub-group
115
+ events.extend(_parse_recursive(token, current_time, step_duration))
116
+
117
+ elif isinstance(token, str):
118
+ # Parse optional probability suffix: "kick?0.6" → symbol="kick", probability=0.6
119
+ if "?" in token and token[0] != "?":
120
+ symbol, prob_str = token.rsplit("?", 1)
121
+ try:
122
+ probability = float(prob_str)
123
+ except ValueError:
124
+ raise MiniNotationError(f"Invalid probability suffix in {token!r} - expected a number after '?'")
125
+
126
+ if not 0.0 <= probability <= 1.0:
127
+ raise MiniNotationError(f"Probability in {token!r} must be between 0.0 and 1.0")
128
+ else:
129
+ symbol = token
130
+ probability = 1.0
131
+
132
+ if symbol in ("~", "."):
133
+ continue
134
+ events.append(ParsedEvent(current_time, step_duration, symbol, probability))
135
+
136
+ return events
137
+
138
+
139
+ def _post_process_sustains (events: typing.List[ParsedEvent]) -> typing.List[ParsedEvent]:
140
+
141
+ """
142
+ Merge ``_`` events into the previous event's duration.
143
+ """
144
+
145
+ if not events:
146
+ return []
147
+
148
+ processed: typing.List[ParsedEvent] = []
149
+ last_event = None
150
+
151
+ for event in events:
152
+
153
+ if event.symbol == "_":
154
+ # Extend only when this sustain slot is contiguous with the end
155
+ # of the previous note: after a rest, `_` keeps the silence
156
+ # instead of stretching the note through the gap.
157
+ if last_event and abs((last_event.time + last_event.duration) - event.time) < 1e-9:
158
+ last_event.duration += event.duration
159
+
160
+ else:
161
+ processed.append(event)
162
+ last_event = event
163
+
164
+ return processed