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/forms.py
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Forms — the governing values for compositional structure.
|
|
2
|
+
|
|
3
|
+
:class:`Section` is the payload home: a frozen ``(name, bars, energy, key)``
|
|
4
|
+
leaf. :class:`Form` is a frozen tuple of Sections — constructible from
|
|
5
|
+
Sections or plain ``(name, bars)`` tuples (lists coerce), editable by slot
|
|
6
|
+
(:meth:`Form.replace` / :meth:`Form.insert` / :meth:`Form.with_energy`),
|
|
7
|
+
and concatenable with ``+``. Repetition is Python:
|
|
8
|
+
``[verse, verse, chorus] == [verse] * 2 + [chorus]`` — no letters DSL.
|
|
9
|
+
|
|
10
|
+
Bind a Form with ``composition.form(form, at_end="stop"|"hold"|"loop")``;
|
|
11
|
+
generate one from a graph form with ``composition.form_freeze()``.
|
|
12
|
+
|
|
13
|
+
Sections compose as plain Python lists that Form coerces — ``Section`` is a
|
|
14
|
+
leaf, not an operator algebra.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import dataclasses
|
|
18
|
+
import typing
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclasses.dataclass(frozen=True)
|
|
22
|
+
class Section:
|
|
23
|
+
|
|
24
|
+
"""One section of a form — the payload home.
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
name: The section name (``"verse"``).
|
|
28
|
+
bars: Length in bars (≥ 1).
|
|
29
|
+
energy: The section's energy level (0.0–1.0; the arranging dial).
|
|
30
|
+
Read by ``p.energy`` and ``min_energy=`` gating; a
|
|
31
|
+
``composition.energy()`` dict overrides it (the dict is the
|
|
32
|
+
later, performance-level dial).
|
|
33
|
+
key: Optional key override — re-anchors *key-relative* content
|
|
34
|
+
(degrees, romans, generated material, and key-relative section
|
|
35
|
+
progressions bound with ``section_chords``) to this section's
|
|
36
|
+
tonic. *Absolute* content (note names, MIDI pitches, frozen
|
|
37
|
+
chords) is never moved, and *chord-relative* content
|
|
38
|
+
(``ChordTone``, ``Approach``) tracks the sounding chord rather
|
|
39
|
+
than the key — see the three-intent model in the docs. The
|
|
40
|
+
live graph engine (``harmony(style=...)``) stays in the
|
|
41
|
+
composition key by design (a stateful walk does not transpose
|
|
42
|
+
mid-stream).
|
|
43
|
+
scale: Optional scale/mode override (e.g. ``"minor"``) — moves the
|
|
44
|
+
mode as well as the tonic, so a section can genuinely change
|
|
45
|
+
to the relative or parallel minor. Falls back to the form's
|
|
46
|
+
scale, then the composition's.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
bars: int
|
|
51
|
+
energy: float = 0.5
|
|
52
|
+
key: typing.Optional[str] = None
|
|
53
|
+
scale: typing.Optional[str] = None
|
|
54
|
+
|
|
55
|
+
def __post_init__ (self) -> None:
|
|
56
|
+
|
|
57
|
+
"""Validate the payload loudly."""
|
|
58
|
+
|
|
59
|
+
if not isinstance(self.name, str) or not self.name:
|
|
60
|
+
raise ValueError(f"a section needs a non-empty string name, got {self.name!r}")
|
|
61
|
+
|
|
62
|
+
if not isinstance(self.bars, int) or isinstance(self.bars, bool) or self.bars < 1:
|
|
63
|
+
raise ValueError(f"Section {self.name!r} must last at least 1 bar, got {self.bars!r}")
|
|
64
|
+
|
|
65
|
+
if not 0.0 <= float(self.energy) <= 1.0:
|
|
66
|
+
raise ValueError(f"Section {self.name!r} energy must be 0.0–1.0, got {self.energy!r}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _coerce_section (element: typing.Any) -> Section:
|
|
70
|
+
|
|
71
|
+
"""Coerce a Form element — a Section passes through, a (name, bars) tuple converts."""
|
|
72
|
+
|
|
73
|
+
if isinstance(element, Section):
|
|
74
|
+
return element
|
|
75
|
+
|
|
76
|
+
if isinstance(element, tuple) and len(element) == 2:
|
|
77
|
+
name, bars = element
|
|
78
|
+
return Section(name = name, bars = bars)
|
|
79
|
+
|
|
80
|
+
raise TypeError(
|
|
81
|
+
f"Form elements are Sections or (name, bars) tuples — got {element!r}"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclasses.dataclass(frozen=True)
|
|
86
|
+
class Form:
|
|
87
|
+
|
|
88
|
+
"""A frozen sequence of Sections — the editable, bindable form value.
|
|
89
|
+
|
|
90
|
+
List-friendly: the constructor coerces ``("name", bars)`` tuples, so
|
|
91
|
+
``Form([("verse", 8), ("chorus", 8)])`` and
|
|
92
|
+
``Form([Section("verse", 8), Section("chorus", 8)])`` are the same value.
|
|
93
|
+
Repetition is Python list arithmetic before construction.
|
|
94
|
+
|
|
95
|
+
A form may carry its own ``key``/``scale`` — the **form tier** of the
|
|
96
|
+
key-source chain (``Section.key`` overrides it; it overrides the
|
|
97
|
+
composition key). A whole AABA in one key with one section borrowing
|
|
98
|
+
another is ``Form([...], key="A")`` plus a ``Section(..., key="F")``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
sections: typing.Tuple[Section, ...]
|
|
102
|
+
key: typing.Optional[str] = None
|
|
103
|
+
scale: typing.Optional[str] = None
|
|
104
|
+
|
|
105
|
+
def __init__ (
|
|
106
|
+
self,
|
|
107
|
+
sections: typing.Iterable[typing.Any],
|
|
108
|
+
key: typing.Optional[str] = None,
|
|
109
|
+
scale: typing.Optional[str] = None,
|
|
110
|
+
) -> None:
|
|
111
|
+
|
|
112
|
+
"""Coerce any iterable of Sections / (name, bars) tuples."""
|
|
113
|
+
|
|
114
|
+
coerced = tuple(_coerce_section(element) for element in sections)
|
|
115
|
+
|
|
116
|
+
if not coerced:
|
|
117
|
+
raise ValueError("a Form needs at least one section")
|
|
118
|
+
|
|
119
|
+
object.__setattr__(self, "sections", coerced)
|
|
120
|
+
object.__setattr__(self, "key", key)
|
|
121
|
+
object.__setattr__(self, "scale", scale)
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def bars (self) -> int:
|
|
125
|
+
|
|
126
|
+
"""Total length in bars."""
|
|
127
|
+
|
|
128
|
+
return sum(section.bars for section in self.sections)
|
|
129
|
+
|
|
130
|
+
def __len__ (self) -> int:
|
|
131
|
+
|
|
132
|
+
"""Number of sections."""
|
|
133
|
+
|
|
134
|
+
return len(self.sections)
|
|
135
|
+
|
|
136
|
+
def __iter__ (self) -> typing.Iterator[Section]:
|
|
137
|
+
|
|
138
|
+
"""Iterate the sections in order."""
|
|
139
|
+
|
|
140
|
+
return iter(self.sections)
|
|
141
|
+
|
|
142
|
+
def __add__ (self, other: "Form") -> "Form":
|
|
143
|
+
|
|
144
|
+
"""Sequential concatenation: ``intro_form + body_form``.
|
|
145
|
+
|
|
146
|
+
The **left** operand's form-tier ``key``/``scale`` survives (a single
|
|
147
|
+
value cannot hold two form keys); the right form's form-tier key is
|
|
148
|
+
dropped. Per-section ``Section.key``/``scale`` on either side is
|
|
149
|
+
preserved — the sections concatenate intact.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
if not isinstance(other, Form):
|
|
153
|
+
return NotImplemented
|
|
154
|
+
|
|
155
|
+
return Form(self.sections + other.sections, key = self.key, scale = self.scale)
|
|
156
|
+
|
|
157
|
+
def replace (
|
|
158
|
+
self,
|
|
159
|
+
slot: int,
|
|
160
|
+
section: typing.Optional[Section] = None,
|
|
161
|
+
**changes: typing.Any,
|
|
162
|
+
) -> "Form":
|
|
163
|
+
|
|
164
|
+
"""Replace the section at a 1-based slot — whole, or by field.
|
|
165
|
+
|
|
166
|
+
``form.replace(3, bars=16)`` stretches slot 3;
|
|
167
|
+
``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
index = _check_slot(slot, len(self.sections))
|
|
171
|
+
|
|
172
|
+
if section is not None and changes:
|
|
173
|
+
raise ValueError("pass either a Section or field changes, not both")
|
|
174
|
+
|
|
175
|
+
if section is None:
|
|
176
|
+
if not changes:
|
|
177
|
+
raise ValueError("replace() needs a Section or field changes (bars=, energy=, key=, name=)")
|
|
178
|
+
section = dataclasses.replace(self.sections[index], **changes)
|
|
179
|
+
|
|
180
|
+
return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index + 1:], key = self.key, scale = self.scale)
|
|
181
|
+
|
|
182
|
+
def insert (self, slot: int, section: typing.Any) -> "Form":
|
|
183
|
+
|
|
184
|
+
"""Insert a section *at* a 1-based slot (existing sections shift right).
|
|
185
|
+
|
|
186
|
+
``slot`` may be ``len(form) + 1`` to append.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= len(self.sections) + 1:
|
|
190
|
+
raise ValueError(f"slot {slot!r} is out of range (1–{len(self.sections) + 1})")
|
|
191
|
+
|
|
192
|
+
index = slot - 1
|
|
193
|
+
|
|
194
|
+
return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index:], key = self.key, scale = self.scale)
|
|
195
|
+
|
|
196
|
+
def with_energy (self, energies: typing.Dict[str, float]) -> "Form":
|
|
197
|
+
|
|
198
|
+
"""Set the energy payload on named sections — ``{"chorus": 0.9}``.
|
|
199
|
+
|
|
200
|
+
Every section whose name appears in the mapping takes the new value;
|
|
201
|
+
naming a section the form does not contain raises. Energy *ramps*
|
|
202
|
+
(``(start, end)`` tuples) live in ``composition.energy()``, not in
|
|
203
|
+
the payload — a Section carries one number.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
names = {section.name for section in self.sections}
|
|
207
|
+
|
|
208
|
+
for name in energies:
|
|
209
|
+
if name not in names:
|
|
210
|
+
known = ", ".join(sorted(names))
|
|
211
|
+
raise ValueError(f"with_energy: no section named {name!r} in this form (sections: {known})")
|
|
212
|
+
|
|
213
|
+
return Form(tuple(
|
|
214
|
+
dataclasses.replace(section, energy = energies[section.name])
|
|
215
|
+
if section.name in energies else section
|
|
216
|
+
for section in self.sections
|
|
217
|
+
), key = self.key, scale = self.scale)
|
|
218
|
+
|
|
219
|
+
def describe (self) -> str:
|
|
220
|
+
|
|
221
|
+
"""A readable one-section-per-line summary."""
|
|
222
|
+
|
|
223
|
+
lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"]
|
|
224
|
+
|
|
225
|
+
if self.key is not None or self.scale is not None:
|
|
226
|
+
lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})")
|
|
227
|
+
|
|
228
|
+
bar = 1
|
|
229
|
+
|
|
230
|
+
for slot, section in enumerate(self.sections, start = 1):
|
|
231
|
+
extras = f" energy={section.energy:g}"
|
|
232
|
+
if section.key is not None:
|
|
233
|
+
extras += f" key={section.key}"
|
|
234
|
+
if section.scale is not None:
|
|
235
|
+
extras += f" scale={section.scale}"
|
|
236
|
+
lines.append(f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}")
|
|
237
|
+
bar += section.bars
|
|
238
|
+
|
|
239
|
+
return "\n".join(lines)
|
|
240
|
+
|
|
241
|
+
def __str__ (self) -> str:
|
|
242
|
+
|
|
243
|
+
"""Same as :meth:`describe`."""
|
|
244
|
+
|
|
245
|
+
return self.describe()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _check_slot (slot: int, count: int) -> int:
|
|
249
|
+
|
|
250
|
+
"""Validate a 1-based section slot and return its 0-based index."""
|
|
251
|
+
|
|
252
|
+
if not isinstance(slot, int) or isinstance(slot, bool):
|
|
253
|
+
raise TypeError(f"section slots are 1-based ints, got {slot!r}")
|
|
254
|
+
if slot < 1 or slot > count:
|
|
255
|
+
raise ValueError(f"section slot {slot} is out of range (1–{count})")
|
|
256
|
+
|
|
257
|
+
return slot - 1
|
subsequence/groove.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Groove templates — repeating timing and velocity feels applied to quantized patterns.
|
|
3
|
+
|
|
4
|
+
Exports the public Groove class: build one by hand, from a swing percentage,
|
|
5
|
+
or from an Ableton ``.agr`` file, then apply it with ``p.groove(template)``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import typing
|
|
12
|
+
import xml.etree.ElementTree
|
|
13
|
+
|
|
14
|
+
import subsequence.constants
|
|
15
|
+
|
|
16
|
+
if typing.TYPE_CHECKING:
|
|
17
|
+
import subsequence.pattern
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclasses.dataclass
|
|
21
|
+
class Groove:
|
|
22
|
+
|
|
23
|
+
"""
|
|
24
|
+
A timing/velocity template applied to quantized grid positions.
|
|
25
|
+
|
|
26
|
+
A groove is a repeating pattern of per-step timing offsets and optional
|
|
27
|
+
velocity adjustments aligned to a rhythmic grid. Apply it as a post-build
|
|
28
|
+
transform with ``p.groove(template)`` to give a pattern its characteristic
|
|
29
|
+
feel — swing, shuffle, MPC-style pocket, or anything extracted from an
|
|
30
|
+
Ableton ``.agr`` file.
|
|
31
|
+
|
|
32
|
+
Parameters:
|
|
33
|
+
offsets: Timing offset per grid slot, in beats. Repeats cyclically.
|
|
34
|
+
Positive values delay the note; negative values push it earlier.
|
|
35
|
+
grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes).
|
|
36
|
+
velocities: Optional velocity scale per grid slot (1.0 = unchanged).
|
|
37
|
+
Repeats cyclically alongside offsets.
|
|
38
|
+
|
|
39
|
+
Example::
|
|
40
|
+
|
|
41
|
+
# Ableton-style 57% swing on 16th notes
|
|
42
|
+
groove = Groove.swing(percent=57)
|
|
43
|
+
|
|
44
|
+
# Custom groove with timing and velocity
|
|
45
|
+
groove = Groove(
|
|
46
|
+
grid=0.25,
|
|
47
|
+
offsets=[0.0, +0.02, 0.0, -0.01],
|
|
48
|
+
velocities=[1.0, 0.7, 0.9, 0.6],
|
|
49
|
+
)
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
offsets: typing.List[float]
|
|
53
|
+
grid: float = 0.25
|
|
54
|
+
velocities: typing.Optional[typing.List[float]] = None
|
|
55
|
+
|
|
56
|
+
def __post_init__ (self) -> None:
|
|
57
|
+
if not self.offsets:
|
|
58
|
+
raise ValueError("offsets must not be empty")
|
|
59
|
+
if self.grid <= 0:
|
|
60
|
+
raise ValueError("grid must be positive")
|
|
61
|
+
if self.velocities is not None and not self.velocities:
|
|
62
|
+
raise ValueError("velocities must not be empty (use None for no velocity adjustment)")
|
|
63
|
+
|
|
64
|
+
@staticmethod
|
|
65
|
+
def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove":
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
Create a swing groove from a percentage.
|
|
69
|
+
|
|
70
|
+
50% is straight (no swing). 67% is approximately triplet swing.
|
|
71
|
+
57% is a moderate shuffle — the Ableton default.
|
|
72
|
+
|
|
73
|
+
Parameters:
|
|
74
|
+
percent: Swing amount (50–75 is the useful range).
|
|
75
|
+
grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths).
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
if percent < 50.0 or percent > 99.0:
|
|
79
|
+
raise ValueError("swing percent must be between 50 and 99")
|
|
80
|
+
pair_duration = grid * 2
|
|
81
|
+
offset = (percent / 100.0 - 0.5) * pair_duration
|
|
82
|
+
return Groove(offsets=[0.0, offset], grid=grid)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def from_agr (path: str, grid: typing.Optional[float] = None) -> "Groove":
|
|
86
|
+
|
|
87
|
+
"""
|
|
88
|
+
Import timing and velocity data from an Ableton .agr groove file.
|
|
89
|
+
|
|
90
|
+
An ``.agr`` file is an XML document containing a MIDI clip whose
|
|
91
|
+
note positions encode the groove's rhythmic feel. This method reads
|
|
92
|
+
those note start times and velocities and converts them into the
|
|
93
|
+
``Groove`` dataclass format (per-step offsets and velocity scales).
|
|
94
|
+
|
|
95
|
+
Without ``grid=``, the grid is inferred as ``clip length / note
|
|
96
|
+
count`` — which assumes the clip plays **exactly one note per grid
|
|
97
|
+
cell** (the standard shape for a groove clip). A clip with rests or
|
|
98
|
+
chords breaks that assumption: pass ``grid=`` explicitly (e.g.
|
|
99
|
+
``grid=0.25`` for a 16th-note groove) and empty cells keep a neutral
|
|
100
|
+
offset. A clip whose notes cannot be assigned one-per-cell raises
|
|
101
|
+
rather than importing a wrong feel.
|
|
102
|
+
|
|
103
|
+
**What is extracted:**
|
|
104
|
+
|
|
105
|
+
- ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets
|
|
106
|
+
relative to ideal grid positions.
|
|
107
|
+
- ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity
|
|
108
|
+
scaling (normalised to the highest velocity in the file).
|
|
109
|
+
- ``TimingAmount`` from the Groove element → pre-scales the timing
|
|
110
|
+
offsets (100 = full, 70 = 70% of the groove's timing).
|
|
111
|
+
- ``VelocityAmount`` from the Groove element → pre-scales velocity
|
|
112
|
+
deviation (100 = full groove velocity, 0 = no velocity changes).
|
|
113
|
+
|
|
114
|
+
The resulting ``Groove`` reflects the file author's intended
|
|
115
|
+
strength. Use ``strength=`` when applying to further adjust.
|
|
116
|
+
|
|
117
|
+
**What is NOT imported:**
|
|
118
|
+
|
|
119
|
+
``RandomAmount`` (use ``p.randomize()`` separately for random
|
|
120
|
+
jitter) and ``QuantizationAmount`` (not applicable - Subsequence
|
|
121
|
+
notes are already grid-quantized by construction).
|
|
122
|
+
|
|
123
|
+
Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``,
|
|
124
|
+
``OffVelocity``, ``Probability``) are also ignored.
|
|
125
|
+
|
|
126
|
+
Parameters:
|
|
127
|
+
path: Path to the .agr file.
|
|
128
|
+
grid: Grid size in beats (0.25 = 16th notes). ``None`` (default)
|
|
129
|
+
infers it from the clip, assuming one note per cell.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
tree = xml.etree.ElementTree.parse(path)
|
|
133
|
+
root = tree.getroot()
|
|
134
|
+
|
|
135
|
+
# Find the MIDI clip
|
|
136
|
+
clip = root.find(".//MidiClip")
|
|
137
|
+
if clip is None:
|
|
138
|
+
raise ValueError(f"No MidiClip found in {path}")
|
|
139
|
+
|
|
140
|
+
# Get clip length
|
|
141
|
+
current_end = clip.find("CurrentEnd")
|
|
142
|
+
if current_end is None:
|
|
143
|
+
raise ValueError(f"No CurrentEnd found in {path}")
|
|
144
|
+
clip_length = float(current_end.get("Value", "4"))
|
|
145
|
+
|
|
146
|
+
# Read Groove Pool blend parameters
|
|
147
|
+
groove_elem = root.find(".//Groove")
|
|
148
|
+
timing_amount = 100.0
|
|
149
|
+
velocity_amount = 100.0
|
|
150
|
+
if groove_elem is not None:
|
|
151
|
+
timing_el = groove_elem.find("TimingAmount")
|
|
152
|
+
if timing_el is not None:
|
|
153
|
+
timing_amount = float(timing_el.get("Value", "100"))
|
|
154
|
+
velocity_el = groove_elem.find("VelocityAmount")
|
|
155
|
+
if velocity_el is not None:
|
|
156
|
+
velocity_amount = float(velocity_el.get("Value", "100"))
|
|
157
|
+
|
|
158
|
+
timing_scale = timing_amount / 100.0
|
|
159
|
+
velocity_scale = velocity_amount / 100.0
|
|
160
|
+
|
|
161
|
+
# Extract note events sorted by time
|
|
162
|
+
events = clip.findall(".//MidiNoteEvent")
|
|
163
|
+
if not events:
|
|
164
|
+
raise ValueError(f"No MidiNoteEvent elements found in {path}")
|
|
165
|
+
|
|
166
|
+
times: typing.List[float] = []
|
|
167
|
+
velocities_raw: typing.List[float] = []
|
|
168
|
+
for event in events:
|
|
169
|
+
times.append(float(event.get("Time", "0")))
|
|
170
|
+
velocities_raw.append(float(event.get("Velocity", "127")))
|
|
171
|
+
|
|
172
|
+
# Sort as PAIRS - sorting times alone desynced each offset from its
|
|
173
|
+
# note's velocity whenever the XML listed events out of time order.
|
|
174
|
+
paired = sorted(zip(times, velocities_raw))
|
|
175
|
+
times = [t for t, _ in paired]
|
|
176
|
+
velocities_raw = [v for _, v in paired]
|
|
177
|
+
|
|
178
|
+
note_count = len(times)
|
|
179
|
+
|
|
180
|
+
# Infer grid from clip length and note count — valid only for the
|
|
181
|
+
# one-note-per-cell clip shape (see docstring); grid= overrides.
|
|
182
|
+
if grid is None:
|
|
183
|
+
grid = clip_length / note_count
|
|
184
|
+
|
|
185
|
+
if grid <= 0:
|
|
186
|
+
raise ValueError(f"grid must be positive — got {grid}")
|
|
187
|
+
|
|
188
|
+
slot_count = max(1, int(round(clip_length / grid)))
|
|
189
|
+
|
|
190
|
+
# Bind each note to its NEAREST grid line (robust to rests under an
|
|
191
|
+
# explicit grid — empty cells keep a neutral offset), refusing
|
|
192
|
+
# ambiguous clips instead of importing a garbage feel.
|
|
193
|
+
slot_offsets = [0.0] * slot_count
|
|
194
|
+
slot_velocities: typing.List[typing.Optional[float]] = [None] * slot_count
|
|
195
|
+
|
|
196
|
+
for time, velocity in zip(times, velocities_raw):
|
|
197
|
+
|
|
198
|
+
slot = int(round(time / grid))
|
|
199
|
+
|
|
200
|
+
if not 0 <= slot < slot_count:
|
|
201
|
+
raise ValueError(
|
|
202
|
+
f"{path}: note at beat {time:g} falls outside the {slot_count}-cell "
|
|
203
|
+
f"grid (grid={grid:g}, clip length {clip_length:g}) — pass grid= "
|
|
204
|
+
"matching the clip's note spacing"
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if slot_velocities[slot] is not None:
|
|
208
|
+
raise ValueError(
|
|
209
|
+
f"{path}: two notes share grid cell {slot} (a chord, or a grid "
|
|
210
|
+
"coarser than the clip's note spacing) — pass grid= matching the "
|
|
211
|
+
"clip (e.g. grid=0.25 for 16ths)"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
slot_offsets[slot] = (time - slot * grid) * timing_scale
|
|
215
|
+
slot_velocities[slot] = velocity
|
|
216
|
+
|
|
217
|
+
# Calculate velocity scales (relative to max velocity in the file),
|
|
218
|
+
# blended toward 1.0 by VelocityAmount; empty cells stay neutral (1.0).
|
|
219
|
+
filled = [v for v in slot_velocities if v is not None]
|
|
220
|
+
max_vel = max(filled)
|
|
221
|
+
has_velocity_variation = any(v != max_vel for v in filled)
|
|
222
|
+
groove_velocities: typing.Optional[typing.List[float]] = None
|
|
223
|
+
if has_velocity_variation and max_vel > 0:
|
|
224
|
+
raw_scales = [(v / max_vel) if v is not None else 1.0 for v in slot_velocities]
|
|
225
|
+
# velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change)
|
|
226
|
+
groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales]
|
|
227
|
+
# If blending has removed all variation, set to None
|
|
228
|
+
if all(abs(v - 1.0) < 1e-9 for v in groove_velocities):
|
|
229
|
+
groove_velocities = None
|
|
230
|
+
|
|
231
|
+
return Groove(offsets=slot_offsets, grid=grid, velocities=groove_velocities)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def apply_groove (
|
|
235
|
+
steps: typing.Dict[int, "subsequence.pattern.Step"],
|
|
236
|
+
groove: Groove,
|
|
237
|
+
pulses_per_quarter: int = subsequence.constants.MIDI_QUARTER_NOTE,
|
|
238
|
+
strength: float = 1.0,
|
|
239
|
+
) -> typing.Dict[int, "subsequence.pattern.Step"]:
|
|
240
|
+
|
|
241
|
+
"""
|
|
242
|
+
Apply a groove template to a step dictionary keyed by pulse positions.
|
|
243
|
+
|
|
244
|
+
Notes close to a grid position are shifted by the groove's offset for
|
|
245
|
+
that slot. Notes between grid positions are left untouched.
|
|
246
|
+
|
|
247
|
+
Parameters:
|
|
248
|
+
steps: Step dictionary (pulse → Step).
|
|
249
|
+
groove: The groove template to apply.
|
|
250
|
+
pulses_per_quarter: Internal MIDI clock resolution (default 24).
|
|
251
|
+
strength: How much of the groove to apply (0.0–1.0).
|
|
252
|
+
0.0 leaves all timing and velocity unchanged; 1.0 applies
|
|
253
|
+
the full groove. Intermediate values blend between the two,
|
|
254
|
+
equivalent to Ableton’s TimingAmount / VelocityAmount dials.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
if not 0.0 <= strength <= 1.0:
|
|
258
|
+
raise ValueError("strength must be between 0.0 and 1.0")
|
|
259
|
+
|
|
260
|
+
grid_pulses = groove.grid * pulses_per_quarter
|
|
261
|
+
if grid_pulses <= 0:
|
|
262
|
+
return dict(steps)
|
|
263
|
+
|
|
264
|
+
half_grid = grid_pulses / 2.0
|
|
265
|
+
num_offsets = len(groove.offsets)
|
|
266
|
+
num_velocities = len(groove.velocities) if groove.velocities else 0
|
|
267
|
+
|
|
268
|
+
new_steps: typing.Dict[int, subsequence.pattern.Step] = {}
|
|
269
|
+
|
|
270
|
+
for old_pulse, step in steps.items():
|
|
271
|
+
|
|
272
|
+
# Find nearest grid position
|
|
273
|
+
grid_index = round(old_pulse / grid_pulses)
|
|
274
|
+
ideal_pulse = grid_index * grid_pulses
|
|
275
|
+
|
|
276
|
+
# Only groove notes that sit close to a grid position; notes deliberately
|
|
277
|
+
# placed between grid lines (flams, pushes) keep both their timing AND
|
|
278
|
+
# velocity. The window is ±25% of a cell (half_grid * 0.5) — narrow on
|
|
279
|
+
# purpose, so off-grid expression survives a quantised groove.
|
|
280
|
+
if abs(old_pulse - ideal_pulse) > half_grid * 0.5:
|
|
281
|
+
new_pulse = old_pulse
|
|
282
|
+
else:
|
|
283
|
+
slot = grid_index % num_offsets
|
|
284
|
+
|
|
285
|
+
# Blend from the note's OWN pulse toward the groove target so
|
|
286
|
+
# strength=0.0 truly leaves timing untouched. (Blending from
|
|
287
|
+
# ideal_pulse quantised away in-window micro-timing — e.g. from
|
|
288
|
+
# randomize() — at every strength, including 0.)
|
|
289
|
+
groove_target = ideal_pulse + groove.offsets[slot] * pulses_per_quarter
|
|
290
|
+
new_pulse = int(round(old_pulse + (groove_target - old_pulse) * strength))
|
|
291
|
+
new_pulse = max(0, new_pulse)
|
|
292
|
+
|
|
293
|
+
# Velocity scaling applies only to grooved (on-grid) notes, for the
|
|
294
|
+
# same reason — an off-grid note shouldn't pick up a slot's accent.
|
|
295
|
+
if groove.velocities and num_velocities > 0:
|
|
296
|
+
vel_slot = grid_index % num_velocities
|
|
297
|
+
# Blend between 1.0 (no effect) and the groove's scale (full effect)
|
|
298
|
+
vel_scale = 1.0 + (groove.velocities[vel_slot] - 1.0) * strength
|
|
299
|
+
step = _scale_step_velocity(step, vel_scale)
|
|
300
|
+
|
|
301
|
+
if new_pulse not in new_steps:
|
|
302
|
+
new_steps[new_pulse] = subsequence.pattern.Step()
|
|
303
|
+
|
|
304
|
+
new_steps[new_pulse].notes.extend(step.notes)
|
|
305
|
+
|
|
306
|
+
return new_steps
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _scale_step_velocity (step: "subsequence.pattern.Step", scale: float) -> "subsequence.pattern.Step":
|
|
310
|
+
|
|
311
|
+
"""
|
|
312
|
+
Return a copy of the step with scaled velocities.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
import subsequence.pattern
|
|
316
|
+
|
|
317
|
+
new_notes = []
|
|
318
|
+
for note in step.notes:
|
|
319
|
+
new_notes.append(dataclasses.replace(
|
|
320
|
+
note,
|
|
321
|
+
velocity=max(1, min(127, int(round(note.velocity * scale))))
|
|
322
|
+
))
|
|
323
|
+
return subsequence.pattern.Step(notes=new_notes)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import dataclasses
|
|
2
|
+
import math
|
|
3
|
+
import random
|
|
4
|
+
import typing
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclasses.dataclass(frozen=True)
|
|
8
|
+
class HarmonicRhythm:
|
|
9
|
+
|
|
10
|
+
"""A bounded, optionally-quantised *harmonic rhythm* — how long each chord lasts.
|
|
11
|
+
|
|
12
|
+
Harmonic rhythm is the rate at which the chords change. It can be regular,
|
|
13
|
+
irregular, or static; this spec describes the **irregular** case — each chord
|
|
14
|
+
lasts a fresh random length somewhere between ``low`` and ``high`` beats. When
|
|
15
|
+
``step`` is given, those lengths snap to whole multiples of it, so the result
|
|
16
|
+
is irregular but still lands on a musical grid (e.g. always a whole-note
|
|
17
|
+
boundary).
|
|
18
|
+
|
|
19
|
+
Build one with :func:`between` rather than constructing it directly::
|
|
20
|
+
|
|
21
|
+
harmonic_rhythm = between(WHOLE, 3 * WHOLE, step=WHOLE) # 1, 2, or 3 whole notes
|
|
22
|
+
|
|
23
|
+
The other two harmonic-rhythm shapes are expressed without this class:
|
|
24
|
+
a single ``float`` (static — every chord the same length) and a ``list`` of
|
|
25
|
+
floats (a *shaped* rhythm such as ``[WHOLE, HALF, HALF]``, cycled per chord).
|
|
26
|
+
``p.progression()`` / ``comp.chords()`` accept all three.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
low: float
|
|
30
|
+
high: float
|
|
31
|
+
step: typing.Optional[float] = None
|
|
32
|
+
|
|
33
|
+
def __post_init__ (self) -> None:
|
|
34
|
+
|
|
35
|
+
"""Validate the bounds at construction so a typo surfaces at the call site."""
|
|
36
|
+
|
|
37
|
+
if self.low <= 0:
|
|
38
|
+
raise ValueError(f"harmonic rhythm low ({self.low:g}) must be positive — lengths are in beats")
|
|
39
|
+
if self.high < self.low:
|
|
40
|
+
raise ValueError(f"harmonic rhythm high ({self.high:g}) must be at least low ({self.low:g})")
|
|
41
|
+
if self.step is not None and self.step <= 0:
|
|
42
|
+
raise ValueError(f"harmonic rhythm step ({self.step:g}) must be positive")
|
|
43
|
+
|
|
44
|
+
def resolve (self, rng: random.Random) -> float:
|
|
45
|
+
|
|
46
|
+
"""Draw one chord length in beats from this spec.
|
|
47
|
+
|
|
48
|
+
With a ``step``, the length is a whole multiple of it snapped *inside*
|
|
49
|
+
``[low, high]`` (so a quantised rhythm never strays past its bounds). If
|
|
50
|
+
no whole multiple fits within the bounds, the nearest in-range length is
|
|
51
|
+
used. Without a ``step``, the draw is continuous and uniform.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
if self.step:
|
|
55
|
+
# Smallest/largest step-multiples that still sit within the bounds.
|
|
56
|
+
# The epsilon absorbs float dust so e.g. 12 / 4 floors to 3, not 2.
|
|
57
|
+
lo = max(1, math.ceil(self.low / self.step - 1e-9))
|
|
58
|
+
hi = math.floor(self.high / self.step + 1e-9)
|
|
59
|
+
if hi < lo:
|
|
60
|
+
hi = lo
|
|
61
|
+
length = rng.randint(lo, hi) * self.step
|
|
62
|
+
|
|
63
|
+
# Honour the bounds over the grid: when no whole multiple fits inside
|
|
64
|
+
# [low, high] (e.g. between(2, 3, step=4)), clamp to the nearest edge so
|
|
65
|
+
# the result never strays past the bounds the musician asked for.
|
|
66
|
+
return float(min(self.high, max(self.low, length)))
|
|
67
|
+
|
|
68
|
+
return float(rng.uniform(self.low, self.high))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def between (low: float, high: float, step: typing.Optional[float] = None) -> HarmonicRhythm:
|
|
72
|
+
|
|
73
|
+
"""A harmonic rhythm that varies *between* two lengths (in beats).
|
|
74
|
+
|
|
75
|
+
Each chord lasts a random length in ``[low, high]``. Pass ``step`` to snap
|
|
76
|
+
those lengths to a grid — e.g. ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` gives
|
|
77
|
+
one, two, or three whole notes, never anything in between.
|
|
78
|
+
|
|
79
|
+
Reads aloud the way you'd describe it: "between one and three whole notes,
|
|
80
|
+
in whole-note steps."
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
return HarmonicRhythm(low=low, high=high, step=step)
|