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/tuning.py
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
"""Microtonal tuning support for Subsequence.
|
|
2
|
+
|
|
3
|
+
Provides the ``Tuning`` class for specifying alternative tuning systems,
|
|
4
|
+
a parser for Scala ``.scl`` files, and ``apply_tuning_to_pattern()`` which
|
|
5
|
+
injects per-note pitch bend events so that any MIDI-compatible synthesiser
|
|
6
|
+
can play the tuning without MPE or special hardware support.
|
|
7
|
+
|
|
8
|
+
Pitch bend is injected automatically:
|
|
9
|
+
|
|
10
|
+
- **Monophonic patterns** (no overlapping notes): a single pitch bend event
|
|
11
|
+
precedes each note on the pattern's own channel.
|
|
12
|
+
- **Polyphonic patterns** (overlapping notes): notes are spread across an
|
|
13
|
+
explicit channel pool via ``ChannelAllocator``. Each channel receives an
|
|
14
|
+
independent pitch bend, so simultaneous notes can carry different tuning
|
|
15
|
+
offsets. The channel pool must be supplied by the caller.
|
|
16
|
+
|
|
17
|
+
Typical usage via ``Composition.tuning()`` (applies globally, automatically):
|
|
18
|
+
|
|
19
|
+
comp.tuning("meanquar.scl", bend_range=2.0)
|
|
20
|
+
|
|
21
|
+
Per-pattern override via the ``PatternBuilder.apply_tuning()`` post-build
|
|
22
|
+
transform:
|
|
23
|
+
|
|
24
|
+
p.apply_tuning(Tuning.equal(19), bend_range=2.0)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
import dataclasses
|
|
28
|
+
import logging
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import typing
|
|
32
|
+
|
|
33
|
+
import subsequence.pattern
|
|
34
|
+
|
|
35
|
+
logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# ── Tuning class ─────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
@dataclasses.dataclass
|
|
41
|
+
class Tuning:
|
|
42
|
+
|
|
43
|
+
"""A microtonal tuning system expressed as cent offsets from the unison.
|
|
44
|
+
|
|
45
|
+
The ``cents`` list contains the cent values for scale degrees 1 through N.
|
|
46
|
+
Degree 0 (the unison, 0.0 cents) is always implicit and not stored.
|
|
47
|
+
The last entry is typically 1200.0 cents (the octave) for octave-repeating
|
|
48
|
+
scales, but any period is supported.
|
|
49
|
+
|
|
50
|
+
Create a ``Tuning`` from a file or programmatically:
|
|
51
|
+
|
|
52
|
+
Tuning.from_scl("meanquar.scl") # Scala .scl file
|
|
53
|
+
Tuning.from_cents([100, 200, ..., 1200]) # explicit cents
|
|
54
|
+
Tuning.from_ratios([9/8, 5/4, ..., 2]) # frequency ratios
|
|
55
|
+
Tuning.equal(19) # 19-tone equal temperament
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
cents: typing.List[float]
|
|
59
|
+
description: str = ""
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def size (self) -> int:
|
|
63
|
+
"""Number of scale degrees per period (the .scl ``count`` line)."""
|
|
64
|
+
return len(self.cents)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def period_cents (self) -> float:
|
|
68
|
+
"""Cent span of one period (typically 1200.0 for octave-repeating scales)."""
|
|
69
|
+
return self.cents[-1] if self.cents else 1200.0
|
|
70
|
+
|
|
71
|
+
# ── Factory methods ───────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
@classmethod
|
|
74
|
+
def from_scl (cls, source: typing.Union[str, os.PathLike]) -> "Tuning":
|
|
75
|
+
"""Parse a Scala .scl file.
|
|
76
|
+
|
|
77
|
+
``source`` is a file path. Lines beginning with ``!`` are comments.
|
|
78
|
+
The first non-comment line is the description. The second is the
|
|
79
|
+
integer count of pitch values. Each subsequent line is a pitch:
|
|
80
|
+
|
|
81
|
+
- Contains ``.`` → cents (float).
|
|
82
|
+
- Contains ``/`` or is a bare integer → ratio; converted to cents via
|
|
83
|
+
``1200 × log₂(ratio)``.
|
|
84
|
+
|
|
85
|
+
Raises ``ValueError`` for malformed files.
|
|
86
|
+
"""
|
|
87
|
+
with open(source, "r", encoding="utf-8") as fh:
|
|
88
|
+
text = fh.read()
|
|
89
|
+
return cls._parse_scl_text(text)
|
|
90
|
+
|
|
91
|
+
@classmethod
|
|
92
|
+
def from_scl_string (cls, text: str) -> "Tuning":
|
|
93
|
+
"""Parse a Scala .scl file from a string (useful for testing)."""
|
|
94
|
+
return cls._parse_scl_text(text)
|
|
95
|
+
|
|
96
|
+
@classmethod
|
|
97
|
+
def _parse_scl_text (cls, text: str) -> "Tuning":
|
|
98
|
+
lines = [line.rstrip() for line in text.splitlines()]
|
|
99
|
+
non_comment: typing.List[str] = [l for l in lines if not l.lstrip().startswith("!")]
|
|
100
|
+
|
|
101
|
+
if len(non_comment) < 2:
|
|
102
|
+
raise ValueError("Malformed .scl: need description + count lines")
|
|
103
|
+
|
|
104
|
+
description = non_comment[0].strip()
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
count = int(non_comment[1].strip())
|
|
108
|
+
except ValueError:
|
|
109
|
+
raise ValueError(f"Malformed .scl: expected integer count, got {non_comment[1]!r}")
|
|
110
|
+
|
|
111
|
+
pitch_lines = non_comment[2:2 + count]
|
|
112
|
+
|
|
113
|
+
if len(pitch_lines) < count:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
f"Malformed .scl: expected {count} pitch values, got {len(pitch_lines)}"
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
cents_list: typing.List[float] = []
|
|
119
|
+
for raw in pitch_lines:
|
|
120
|
+
# Text after the pitch value is ignored (Scala spec)
|
|
121
|
+
token = raw.split()[0] if raw.split() else ""
|
|
122
|
+
cents_list.append(cls._parse_pitch_token(token))
|
|
123
|
+
|
|
124
|
+
return cls(cents=cents_list, description=description)
|
|
125
|
+
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _parse_pitch_token (token: str) -> float:
|
|
128
|
+
"""Convert a single .scl pitch token to cents."""
|
|
129
|
+
if not token:
|
|
130
|
+
raise ValueError("Empty pitch token in .scl file")
|
|
131
|
+
if "." in token:
|
|
132
|
+
# Cents value
|
|
133
|
+
return float(token)
|
|
134
|
+
if "/" in token:
|
|
135
|
+
# Ratio like 3/2
|
|
136
|
+
num_str, den_str = token.split("/", 1)
|
|
137
|
+
ratio = int(num_str) / int(den_str)
|
|
138
|
+
else:
|
|
139
|
+
# Bare integer like 2 (interpreted as 2/1)
|
|
140
|
+
ratio = float(token)
|
|
141
|
+
if ratio <= 0:
|
|
142
|
+
raise ValueError(f"Non-positive ratio in .scl: {token!r}")
|
|
143
|
+
return 1200.0 * math.log2(ratio)
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_cents (cls, cents: typing.List[float], description: str = "") -> "Tuning":
|
|
147
|
+
"""Construct a tuning from a list of cent values for degrees 1..N.
|
|
148
|
+
|
|
149
|
+
The implicit degree 0 (unison, 0.0 cents) is not included in ``cents``.
|
|
150
|
+
The last value is typically 1200.0 for an octave-repeating scale.
|
|
151
|
+
"""
|
|
152
|
+
return cls(cents=list(cents), description=description)
|
|
153
|
+
|
|
154
|
+
@classmethod
|
|
155
|
+
def from_ratios (cls, ratios: typing.List[float], description: str = "") -> "Tuning":
|
|
156
|
+
"""Construct a tuning from frequency ratios relative to 1/1.
|
|
157
|
+
|
|
158
|
+
Each ratio is converted to cents via ``1200 × log₂(ratio)``.
|
|
159
|
+
Pass ``2`` or ``2.0`` for the octave (1200 cents).
|
|
160
|
+
"""
|
|
161
|
+
cents = [1200.0 * math.log2(r) for r in ratios]
|
|
162
|
+
return cls(cents=cents, description=description)
|
|
163
|
+
|
|
164
|
+
@classmethod
|
|
165
|
+
def equal (cls, divisions: int = 12, period: float = 1200.0) -> "Tuning":
|
|
166
|
+
"""Construct an equal-tempered tuning with ``divisions`` equal steps per period.
|
|
167
|
+
|
|
168
|
+
``Tuning.equal(12)`` is standard 12-TET (no pitch bend needed).
|
|
169
|
+
``Tuning.equal(19)`` gives 19-tone equal temperament.
|
|
170
|
+
"""
|
|
171
|
+
step = period / divisions
|
|
172
|
+
cents = [step * i for i in range(1, divisions + 1)]
|
|
173
|
+
return cls(
|
|
174
|
+
cents=cents,
|
|
175
|
+
description=f"{divisions}-tone equal temperament",
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# ── Core calculation ──────────────────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
def pitch_bend_for_note (
|
|
181
|
+
self,
|
|
182
|
+
midi_note: int,
|
|
183
|
+
reference_note: int = 60,
|
|
184
|
+
bend_range: float = 2.0,
|
|
185
|
+
) -> typing.Tuple[int, float]:
|
|
186
|
+
"""Return ``(nearest_12tet_note, bend_normalized)`` for a MIDI note number.
|
|
187
|
+
|
|
188
|
+
The MIDI note number is interpreted as a scale degree relative to
|
|
189
|
+
``reference_note`` (default 60 = C4, degree 0 of the scale). The
|
|
190
|
+
tuning's cent table determines the exact frequency, and the nearest
|
|
191
|
+
12-TET MIDI note plus a fractional pitch bend corrects the remainder.
|
|
192
|
+
|
|
193
|
+
Parameters:
|
|
194
|
+
midi_note: The MIDI note to tune (0–127).
|
|
195
|
+
reference_note: MIDI note number that maps to degree 0 of the scale.
|
|
196
|
+
bend_range: Pitch wheel range in semitones (must match the synth's
|
|
197
|
+
pitch-bend range setting). Default ±2 semitones.
|
|
198
|
+
|
|
199
|
+
Returns:
|
|
200
|
+
A tuple ``(nearest_note, bend_normalized)`` where ``nearest_note``
|
|
201
|
+
is the integer MIDI note to send and ``bend_normalized`` is the
|
|
202
|
+
normalised pitch bend value (-1.0 to +1.0).
|
|
203
|
+
"""
|
|
204
|
+
if self.size == 0:
|
|
205
|
+
return midi_note, 0.0
|
|
206
|
+
|
|
207
|
+
steps_from_root = midi_note - reference_note
|
|
208
|
+
degree = steps_from_root % self.size
|
|
209
|
+
octave = steps_from_root // self.size
|
|
210
|
+
|
|
211
|
+
# Cent value for this degree (degree 0 = 0.0, degree k = cents[k-1])
|
|
212
|
+
degree_cents = 0.0 if degree == 0 else self.cents[degree - 1]
|
|
213
|
+
|
|
214
|
+
# Total cents from the root
|
|
215
|
+
total_cents = octave * self.period_cents + degree_cents
|
|
216
|
+
|
|
217
|
+
# Equivalent continuous 12-TET note number (100 cents per semitone)
|
|
218
|
+
continuous = reference_note + total_cents / 100.0
|
|
219
|
+
|
|
220
|
+
nearest = int(round(continuous))
|
|
221
|
+
nearest = max(0, min(127, nearest))
|
|
222
|
+
|
|
223
|
+
offset_semitones = continuous - nearest # signed, in semitones
|
|
224
|
+
|
|
225
|
+
if bend_range <= 0:
|
|
226
|
+
bend_normalized = 0.0
|
|
227
|
+
else:
|
|
228
|
+
bend_normalized = max(-1.0, min(1.0, offset_semitones / bend_range))
|
|
229
|
+
|
|
230
|
+
return nearest, bend_normalized
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ── Channel allocator ─────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
class ChannelAllocator:
|
|
236
|
+
|
|
237
|
+
"""Assign MIDI channels from a pool for polyphonic channel rotation.
|
|
238
|
+
|
|
239
|
+
Tracks which channels are busy (a note is sounding) and which are free.
|
|
240
|
+
Channels are reclaimed once a note ends (pulse ≥ release_pulse).
|
|
241
|
+
|
|
242
|
+
A simple round-robin fallback is used when all channels are busy
|
|
243
|
+
(simultaneous voices exceed pool size) — accompanied by a warning log.
|
|
244
|
+
"""
|
|
245
|
+
|
|
246
|
+
def __init__ (self, channels: typing.List[int]) -> None:
|
|
247
|
+
if not channels:
|
|
248
|
+
raise ValueError("ChannelAllocator requires at least one channel")
|
|
249
|
+
self._channels = list(channels)
|
|
250
|
+
# Map channel -> pulse at which it becomes free again
|
|
251
|
+
self._release: typing.Dict[int, int] = {ch: 0 for ch in channels}
|
|
252
|
+
self._rr_index = 0
|
|
253
|
+
|
|
254
|
+
def allocate (self, pulse: int, duration: int) -> int:
|
|
255
|
+
"""Return a free channel for a note starting at ``pulse`` lasting ``duration`` pulses."""
|
|
256
|
+
# Find a channel that is free at this pulse
|
|
257
|
+
for ch in self._channels:
|
|
258
|
+
if self._release[ch] <= pulse:
|
|
259
|
+
self._release[ch] = pulse + duration
|
|
260
|
+
return ch
|
|
261
|
+
|
|
262
|
+
# All channels busy — round-robin with a warning
|
|
263
|
+
ch = self._channels[self._rr_index % len(self._channels)]
|
|
264
|
+
self._rr_index += 1
|
|
265
|
+
logger.warning(
|
|
266
|
+
"ChannelAllocator: pool exhausted (%d channels, all busy at pulse %d). "
|
|
267
|
+
"Simultaneous voices exceed pool size. Some pitch bends may conflict.",
|
|
268
|
+
len(self._channels), pulse,
|
|
269
|
+
)
|
|
270
|
+
self._release[ch] = pulse + duration
|
|
271
|
+
return ch
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
# ── Pattern transform ─────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
def apply_tuning_to_pattern (
|
|
277
|
+
pattern: "subsequence.pattern.Pattern",
|
|
278
|
+
tuning: Tuning,
|
|
279
|
+
bend_range: float = 2.0,
|
|
280
|
+
channels: typing.Optional[typing.List[int]] = None,
|
|
281
|
+
reference_note: int = 60,
|
|
282
|
+
) -> None:
|
|
283
|
+
"""Apply a microtonal tuning to all notes in a pattern in place.
|
|
284
|
+
|
|
285
|
+
For each note:
|
|
286
|
+
|
|
287
|
+
1. The nearest 12-TET MIDI note is computed and replaces ``note.pitch``.
|
|
288
|
+
2. A pitchwheel ``CcEvent`` is injected at the note's onset with the
|
|
289
|
+
fractional bend that corrects from the nearest 12-TET pitch to the
|
|
290
|
+
exact tuned frequency.
|
|
291
|
+
3. If ``channels`` is provided and the pattern has overlapping notes,
|
|
292
|
+
notes are spread across the channel pool (``ChannelAllocator``).
|
|
293
|
+
|
|
294
|
+
Existing pitchwheel events (e.g., from ``p.portamento()`` or
|
|
295
|
+
``p.slide()``) are shifted additively by the tuning offset of the note
|
|
296
|
+
sounding at each pulse. Bend-reset-to-zero events are replaced with
|
|
297
|
+
bend-reset-to-tuning-offset events.
|
|
298
|
+
|
|
299
|
+
Parameters:
|
|
300
|
+
pattern: The pattern to transform in place.
|
|
301
|
+
tuning: The ``Tuning`` object specifying cent offsets.
|
|
302
|
+
bend_range: Must match the MIDI synth's pitch-bend range setting
|
|
303
|
+
(default ±2 semitones).
|
|
304
|
+
channels: Optional explicit channel pool for polyphonic parts.
|
|
305
|
+
When ``None``, all notes stay on ``pattern.channel``. Under
|
|
306
|
+
polyphonic rotation, expression events created BEFORE this call
|
|
307
|
+
(``portamento()``/``slide()`` bends) are not re-routed per note —
|
|
308
|
+
apply tuning last, or avoid combining note-correlated bends with
|
|
309
|
+
a channel pool.
|
|
310
|
+
reference_note: MIDI note number mapped to scale degree 0.
|
|
311
|
+
"""
|
|
312
|
+
if not pattern.steps:
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# ── Step 1: determine if polyphony requires channel rotation ─────────────
|
|
316
|
+
allocator: typing.Optional[ChannelAllocator] = None
|
|
317
|
+
if channels is not None:
|
|
318
|
+
# Check whether the pattern actually has overlapping notes
|
|
319
|
+
if _has_overlapping_notes(pattern):
|
|
320
|
+
allocator = ChannelAllocator(channels)
|
|
321
|
+
# Even if monophonic, use the first channel from the pool
|
|
322
|
+
elif channels:
|
|
323
|
+
# Re-assign the pattern's notes to the first pool channel
|
|
324
|
+
for step in pattern.steps.values():
|
|
325
|
+
for note in step.notes:
|
|
326
|
+
note.channel = channels[0]
|
|
327
|
+
|
|
328
|
+
# Pre-existing expression events (portamento/slide bends, CCs)
|
|
329
|
+
# with no explicit channel would otherwise stay on
|
|
330
|
+
# pattern.channel — targeting a channel where nothing sounds and
|
|
331
|
+
# escaping the additive-shift pass below.
|
|
332
|
+
for ev in pattern.cc_events:
|
|
333
|
+
if ev.channel is None:
|
|
334
|
+
ev.channel = channels[0]
|
|
335
|
+
|
|
336
|
+
# ── Step 2: build a pulse→(tuning_bend_normalized, note_channel) map ─────
|
|
337
|
+
# We need this for two things:
|
|
338
|
+
# - Injecting onset pitch bend events
|
|
339
|
+
# - Shifting existing pitchwheel events additively
|
|
340
|
+
#
|
|
341
|
+
# tuning_map: pulse → list of (tuning_bend_raw_int, channel)
|
|
342
|
+
# For overlapping notes on the same pulse, each gets its own channel.
|
|
343
|
+
|
|
344
|
+
tuning_map: typing.Dict[int, typing.List[typing.Tuple[int, int]]] = {}
|
|
345
|
+
|
|
346
|
+
for pulse, step in sorted(pattern.steps.items()):
|
|
347
|
+
for note in step.notes:
|
|
348
|
+
nearest, bend_norm = tuning.pitch_bend_for_note(
|
|
349
|
+
note.pitch, reference_note=reference_note, bend_range=bend_range
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Assign channel (rotation or single channel)
|
|
353
|
+
if allocator is not None:
|
|
354
|
+
note.channel = allocator.allocate(pulse, note.duration)
|
|
355
|
+
# (else note.channel stays as set above or unchanged)
|
|
356
|
+
|
|
357
|
+
# Replace pitch with nearest 12-TET note
|
|
358
|
+
note.pitch = nearest
|
|
359
|
+
|
|
360
|
+
bend_raw = _norm_to_raw(bend_norm)
|
|
361
|
+
|
|
362
|
+
if pulse not in tuning_map:
|
|
363
|
+
tuning_map[pulse] = []
|
|
364
|
+
tuning_map[pulse].append((bend_raw, note.channel))
|
|
365
|
+
|
|
366
|
+
# ── Step 3: shift existing pitchwheel events additively ──────────────────
|
|
367
|
+
# Build a timeline of (pulse, note_end_pulse, bend_raw, channel) for
|
|
368
|
+
# all tuning bends, so we can look up which tuning offset is active
|
|
369
|
+
# at any given pulse/channel.
|
|
370
|
+
|
|
371
|
+
# Sorted list of (onset_pulse, end_pulse, bend_raw, channel)
|
|
372
|
+
timeline: typing.List[typing.Tuple[int, int, int, int]] = []
|
|
373
|
+
for pulse, step in sorted(pattern.steps.items()):
|
|
374
|
+
for note in step.notes:
|
|
375
|
+
# Find the bend_raw that was computed for this note/pulse/channel
|
|
376
|
+
# (use the first matching entry for this channel)
|
|
377
|
+
for br, ch in tuning_map.get(pulse, []):
|
|
378
|
+
if ch == note.channel:
|
|
379
|
+
timeline.append((pulse, pulse + note.duration, br, ch))
|
|
380
|
+
break
|
|
381
|
+
|
|
382
|
+
def _active_bend_at (pulse: int, channel: int) -> int:
|
|
383
|
+
"""Return the tuning bend_raw active for (pulse, channel), or 0."""
|
|
384
|
+
result = 0
|
|
385
|
+
for onset, end, br, ch in timeline:
|
|
386
|
+
if ch == channel and onset <= pulse < end:
|
|
387
|
+
result = br
|
|
388
|
+
break
|
|
389
|
+
return result
|
|
390
|
+
|
|
391
|
+
# Shift existing pitchwheel events
|
|
392
|
+
new_cc: typing.List["subsequence.pattern.CcEvent"] = []
|
|
393
|
+
for ev in pattern.cc_events:
|
|
394
|
+
if ev.message_type != "pitchwheel":
|
|
395
|
+
new_cc.append(ev)
|
|
396
|
+
continue
|
|
397
|
+
|
|
398
|
+
ch = ev.channel if ev.channel is not None else pattern.channel
|
|
399
|
+
active = _active_bend_at(ev.pulse, ch)
|
|
400
|
+
|
|
401
|
+
if ev.value == 0:
|
|
402
|
+
# Bend-reset: replace with tuning offset (so glides land correctly)
|
|
403
|
+
shifted = active
|
|
404
|
+
else:
|
|
405
|
+
# Additive shift
|
|
406
|
+
shifted = max(-8192, min(8191, ev.value + active))
|
|
407
|
+
|
|
408
|
+
new_cc.append(dataclasses.replace(ev, value=shifted))
|
|
409
|
+
|
|
410
|
+
pattern.cc_events = new_cc
|
|
411
|
+
|
|
412
|
+
# ── Step 4: inject onset tuning bend events ───────────────────────────────
|
|
413
|
+
# priority=-1 makes the bend dispatch BEFORE the note_on that shares its
|
|
414
|
+
# pulse — the scheduler pushes all notes before cc events, so list order
|
|
415
|
+
# alone cannot deliver the bend-first requirement; the priority field on
|
|
416
|
+
# MidiEvent outranks the push-order tie-breaker.
|
|
417
|
+
onset_events: typing.List["subsequence.pattern.CcEvent"] = []
|
|
418
|
+
for pulse, entries in sorted(tuning_map.items()):
|
|
419
|
+
for bend_raw, channel in entries:
|
|
420
|
+
onset_events.append(
|
|
421
|
+
subsequence.pattern.CcEvent(
|
|
422
|
+
pulse=pulse,
|
|
423
|
+
message_type="pitchwheel",
|
|
424
|
+
value=bend_raw,
|
|
425
|
+
channel=channel,
|
|
426
|
+
priority=-1,
|
|
427
|
+
)
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
# Prepend onset bends (kept first in the list for readability; the
|
|
431
|
+
# dispatch order guarantee comes from priority above)
|
|
432
|
+
pattern.cc_events = onset_events + pattern.cc_events
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _has_overlapping_notes (pattern: "subsequence.pattern.Pattern") -> bool:
|
|
436
|
+
"""Return True if any notes in the pattern overlap in time."""
|
|
437
|
+
# Build a list of (onset, offset) across all notes
|
|
438
|
+
intervals: typing.List[typing.Tuple[int, int]] = []
|
|
439
|
+
for pulse, step in pattern.steps.items():
|
|
440
|
+
for note in step.notes:
|
|
441
|
+
intervals.append((pulse, pulse + note.duration))
|
|
442
|
+
|
|
443
|
+
# Sort by onset; check if any start before the previous one ends
|
|
444
|
+
intervals.sort()
|
|
445
|
+
for i in range(1, len(intervals)):
|
|
446
|
+
if intervals[i][0] < intervals[i - 1][1]:
|
|
447
|
+
return True
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _norm_to_raw (bend_normalized: float) -> int:
|
|
452
|
+
"""Convert a normalised pitch bend (-1.0 to +1.0) to a raw MIDI value (-8192 to +8191)."""
|
|
453
|
+
return max(-8192, min(8191, int(round(bend_normalized * 8192))))
|
subsequence/voicings.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Chord inversions and voice leading.
|
|
2
|
+
|
|
3
|
+
Provides functions for rotating chord intervals into different inversions and
|
|
4
|
+
choosing the smoothest voicing between consecutive chords. Voice leading
|
|
5
|
+
minimises the total semitone movement so that chord pads sound connected
|
|
6
|
+
rather than jumping around the keyboard.
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
```python
|
|
10
|
+
# Manual inversion
|
|
11
|
+
first_inv = subsequence.voicings.invert_chord([0, 4, 7], inversion=1) # [4, 7, 12]
|
|
12
|
+
|
|
13
|
+
# Automatic voice leading across a pattern
|
|
14
|
+
@composition.pattern(channel=0, length=4, voice_leading=True)
|
|
15
|
+
def chords (p, chord):
|
|
16
|
+
p.chord(chord, root=52, velocity=90, sustain=True)
|
|
17
|
+
```
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import typing
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def invert_chord (intervals: typing.List[int], inversion: int) -> typing.List[int]:
|
|
24
|
+
|
|
25
|
+
"""Rotate chord intervals to produce an inversion.
|
|
26
|
+
|
|
27
|
+
Inversion 0 is root position. Inversion 1 raises the bottom note by an
|
|
28
|
+
octave (first inversion). Wraps around for inversions >= the number of
|
|
29
|
+
notes.
|
|
30
|
+
|
|
31
|
+
Parameters:
|
|
32
|
+
intervals: Chord intervals in semitones from root (e.g., ``[0, 4, 7]``)
|
|
33
|
+
inversion: Which inversion to produce (0 = root position)
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Rotated interval list, still measured from the original chord root,
|
|
37
|
+
so adding any root yields the same chord with a different bass note.
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
```python
|
|
41
|
+
invert_chord([0, 4, 7], 0) # [0, 4, 7] - root position
|
|
42
|
+
invert_chord([0, 4, 7], 1) # [4, 7, 12] - first inversion (E-G-C)
|
|
43
|
+
invert_chord([0, 4, 7], 2) # [7, 12, 16] - second inversion (G-C-E)
|
|
44
|
+
```
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
n = len(intervals)
|
|
48
|
+
|
|
49
|
+
if n == 0:
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
inversion = inversion % n
|
|
53
|
+
|
|
54
|
+
if inversion == 0:
|
|
55
|
+
return list(intervals)
|
|
56
|
+
|
|
57
|
+
# Keep the intervals anchored at the original root: re-zeroing to the new
|
|
58
|
+
# bass note would change the chord's pitch classes once a caller adds the
|
|
59
|
+
# root back (the pre-2026-06 bug — [0, 3, 8] is an Ab-major shape, not
|
|
60
|
+
# C major first inversion).
|
|
61
|
+
return intervals[inversion:] + [i + 12 for i in intervals[:inversion]]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def voice_lead (intervals: typing.List[int], root_midi: int, previous_voicing: typing.Optional[typing.List[int]]) -> typing.List[int]:
|
|
65
|
+
|
|
66
|
+
"""Find the inversion closest to a previous voicing.
|
|
67
|
+
|
|
68
|
+
Tries every inversion, in the nearest octaves, and picks the candidate
|
|
69
|
+
with the smallest total semitone movement from ``previous_voicing``.
|
|
70
|
+
Voices are compared *positionally* (voice ``i`` to voice ``i``), so this
|
|
71
|
+
picks the best inversion rather than the globally optimal voice
|
|
72
|
+
reassignment. If ``previous_voicing`` is ``None`` or the chord sizes
|
|
73
|
+
differ, returns root position.
|
|
74
|
+
|
|
75
|
+
Parameters:
|
|
76
|
+
intervals: Chord intervals in semitones from root (e.g., ``[0, 4, 7]``)
|
|
77
|
+
root_midi: MIDI note number for the chord root
|
|
78
|
+
previous_voicing: MIDI note numbers of the previous chord, or ``None``
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
MIDI note numbers for the best voicing
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
n = len(intervals)
|
|
85
|
+
|
|
86
|
+
if n == 0:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
# No previous voicing or size mismatch - return root position.
|
|
90
|
+
if previous_voicing is None or len(previous_voicing) != n:
|
|
91
|
+
return [root_midi + i for i in intervals]
|
|
92
|
+
|
|
93
|
+
best_voicing: typing.Optional[typing.List[int]] = None
|
|
94
|
+
best_cost = float("inf")
|
|
95
|
+
|
|
96
|
+
for inv in range(n):
|
|
97
|
+
inv_intervals = invert_chord(intervals, inv)
|
|
98
|
+
|
|
99
|
+
# Inversions are anchored upward from the root, so also try each one
|
|
100
|
+
# an octave down (and up) — the smoothest voicing often sits below
|
|
101
|
+
# the nominal root (e.g. C-F-A for F major approached from C major).
|
|
102
|
+
for octave_offset in (0, -12, 12):
|
|
103
|
+
candidate = [root_midi + i + octave_offset for i in inv_intervals]
|
|
104
|
+
|
|
105
|
+
cost = sum(abs(candidate[i] - previous_voicing[i]) for i in range(n))
|
|
106
|
+
|
|
107
|
+
if cost < best_cost:
|
|
108
|
+
best_cost = cost
|
|
109
|
+
best_voicing = candidate
|
|
110
|
+
|
|
111
|
+
assert best_voicing is not None
|
|
112
|
+
return best_voicing
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class VoiceLeadingState:
|
|
116
|
+
|
|
117
|
+
"""Track the previous voicing across chord changes.
|
|
118
|
+
|
|
119
|
+
Each pattern that uses voice leading gets its own instance so that a bass
|
|
120
|
+
line and a pad can voice-lead independently.
|
|
121
|
+
|
|
122
|
+
Example:
|
|
123
|
+
```python
|
|
124
|
+
state = VoiceLeadingState()
|
|
125
|
+
voicing1 = state.next([0, 4, 7], 60) # root position (no previous)
|
|
126
|
+
voicing2 = state.next([0, 3, 7], 60) # picks closest inversion to voicing1
|
|
127
|
+
```
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__ (self) -> None:
|
|
131
|
+
|
|
132
|
+
"""Start with no previous voicing."""
|
|
133
|
+
|
|
134
|
+
self.previous_voicing: typing.Optional[typing.List[int]] = None
|
|
135
|
+
|
|
136
|
+
def next (self, intervals: typing.List[int], root_midi: int) -> typing.List[int]:
|
|
137
|
+
|
|
138
|
+
"""Choose the smoothest voicing and update state.
|
|
139
|
+
|
|
140
|
+
Parameters:
|
|
141
|
+
intervals: Chord intervals in semitones from root
|
|
142
|
+
root_midi: MIDI note number for the chord root
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
MIDI note numbers for the chosen voicing
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
result = voice_lead(intervals, root_midi, self.previous_voicing)
|
|
149
|
+
self.previous_voicing = result
|
|
150
|
+
|
|
151
|
+
return result
|