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/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)