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