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