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
@@ -0,0 +1,1913 @@
1
+ """Progressions — chord sequences laid out in time, as a governing value.
2
+
3
+ The one progression type: a frozen tuple of :class:`ChordSpan` — replacing the
4
+ old engine ``Progression`` (the ``freeze()`` capture) and ``ChordTimeline``
5
+ (the realised iterable) with a single value that is constructible, queryable,
6
+ transformable, and bindable to the harmonic clock.
7
+
8
+ Construction (the standard form — lists, parsed per element):
9
+
10
+ subsequence.progression([1, 6, 3, 7]) # diatonic degrees
11
+ subsequence.progression([1, 6, 3, "bVII7"]) # romans where chromatic
12
+ subsequence.progression(["Am", "F", "C", "G"]) # chord names
13
+ subsequence.progression([("Am", 4), ("F", 2)]) # per-chord beats
14
+ subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3)
15
+
16
+ Key-relative content (ints and romans) stays relative inside the value and
17
+ resolves at query time — change the key once, everything follows. Spice
18
+ transforms (``extend``, ``inversions``, ``spread``, ``over``, ``borrow``)
19
+ decorate the spans, never the chords: the engine's currency stays the bare
20
+ ``(root_pc, quality)`` triad, and decoration travels with the span to the
21
+ voicing layer.
22
+ """
23
+
24
+ import dataclasses
25
+ import random
26
+ import re
27
+ import typing
28
+ import warnings
29
+
30
+ import subsequence.cadences
31
+ import subsequence.chords
32
+ import subsequence.harmonic_rhythm
33
+ import subsequence.harmonic_state
34
+ import subsequence.intervals
35
+ import subsequence.sequence_utils
36
+ import subsequence.voicings
37
+
38
+
39
+ # A progression source is either a built-in chord-graph style name (generated)
40
+ # or an explicit, ordered list of elements — ints, chord names, romans, Chord
41
+ # objects, or (element, beats) tuples.
42
+ ProgressionSource = typing.Union[str, "Progression", typing.Sequence[typing.Any]]
43
+
44
+ # A harmonic-rhythm spec is a single length (static), a list of lengths (a shaped
45
+ # rhythm, cycled per chord), or a between(...) range (bounded, optionally quantised).
46
+ HarmonicRhythmSpec = typing.Union[int, float, typing.List[float], subsequence.harmonic_rhythm.HarmonicRhythm]
47
+
48
+ # Voicing density: a fixed number of voices, or a (low, high) random range per chord.
49
+ VoicingSpec = typing.Union[int, typing.Tuple[int, int]]
50
+
51
+ # One bar per chord by default. The value is context-free, so "a bar" is the
52
+ # common-time default; pass beats= for anything else.
53
+ DEFAULT_SPAN_BEATS: float = 4.0
54
+
55
+ # Genre preset table — ``progression("name")`` looks a recognizable, genre-
56
+ # tagged loop up here. Elements are roman numerals (key-relative, quality
57
+ # explicit), so a preset resolves under whatever key it is bound to. Curated,
58
+ # not a catalogue (per the design's "curated small presets over catalogs"), and
59
+ # all-distinct. NOTE: minor/modal loops spell their non-major-diatonic chords
60
+ # with a FLAT accidental (bVI, bIII, bVII) — the scale-proof "major-relative"
61
+ # form, so they resolve to the same chords under any scale, NOT bare uppercase
62
+ # numerals (which would read the resolution scale's degree and come out wrong
63
+ # under the default ionian).
64
+ _PRESETS: typing.Dict[str, typing.List[typing.Any]] = {
65
+ # Pop / rock.
66
+ "pop_axis": ["I", "V", "vi", "IV"], # the "Axis of Awesome" four chords
67
+ "pop_axis_vi_start": ["vi", "IV", "I", "V"], # the axis rotated to a minor start
68
+ "doo_wop": ["I", "vi", "IV", "V"], # the 50s progression
69
+ "doo_wop_ii": ["I", "vi", "ii", "V"], # doo-wop with ii for IV
70
+ # EDM / trance / cinematic minor.
71
+ "trance_epic": ["i", "bVI", "bIII", "bVII"], # uplifting-trance minor loop
72
+ "trance_vamp": ["i", "bVII", "bVI", "bVII"], # hypnotic two-direction minor vamp
73
+ "minor_pop": ["i", "bVI", "bVII", "i"], # aeolian rise back to the tonic
74
+ # Minor / modal.
75
+ "andalusian": ["i", "bVII", "bVI", "V"], # the Andalusian cadence (descending tetrachord)
76
+ "phrygian_vamp": ["i", "bII", "i", "bII"], # the Spanish/Phrygian half-step
77
+ "dorian_vamp": ["i", "IV"], # minor tonic to the major (dorian) IV
78
+ "mixolydian_vamp": ["I", "bVII", "IV", "I"], # the classic-rock bVII
79
+ # Jazz.
80
+ "ii_v_i": ["ii7", "V7", "Imaj7"], # the major ii-V-I
81
+ "ii_v_i_minor": ["iiø7", "V7", "i"], # the minor ii-V-i
82
+ "rhythm_changes_a": ["I", "vi", "ii", "V", "I", "vi", "ii", "V", "I", "I7", "IV", "iv", "I", "V", "I", "I"],
83
+ # Blues.
84
+ "twelve_bar_blues": ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"],
85
+ "quick_change_blues": ["I7", "IV7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"],
86
+ # Classic / baroque loops.
87
+ "pachelbel": ["I", "V", "vi", "iii", "IV", "I", "IV", "V"], # Pachelbel's Canon
88
+ "pachelbel_minor": ["i", "v", "bVI", "bIII", "iv", "i", "iv", "V"], # its minor rendering
89
+ }
90
+
91
+
92
+ class ChordEvent (typing.NamedTuple):
93
+
94
+ """One chord on a realised timeline: which chord, when it starts, and how long
95
+ it lasts (in beats from the start of the part).
96
+
97
+ A ``NamedTuple``, so it unpacks positionally as ``(chord, start, length)`` — the
98
+ idiom for looping a progression — while also offering ``.chord`` / ``.start`` /
99
+ ``.length`` attribute access.
100
+ """
101
+
102
+ chord: typing.Any
103
+ start: float
104
+ length: float
105
+
106
+
107
+ # ---------------------------------------------------------------------------
108
+ # Leaf values: PitchSet and RomanChord
109
+ # ---------------------------------------------------------------------------
110
+
111
+
112
+ @dataclasses.dataclass(frozen=True)
113
+ class PitchSet:
114
+
115
+ """A nameless sonority — a frozen set of absolute MIDI pitches.
116
+
117
+ The escape hatch for chords with no root or quality: clusters, spectral
118
+ stacks, found objects. It duck-types ``.tones()`` so every placement verb
119
+ and the injected ``chord`` accept it unchanged. By design it is excluded
120
+ from generation and diatonic spice (there is nothing to transpose
121
+ diatonically), and a progression containing one loops on exhaustion
122
+ rather than falling through to live graph stepping.
123
+
124
+ Pitches are absolute: ``tones()`` ignores its ``root`` argument — you
125
+ chose the register when you chose the pitches.
126
+ """
127
+
128
+ pitches: typing.Tuple[int, ...]
129
+
130
+ def __init__ (self, pitches: typing.Iterable[int]) -> None:
131
+
132
+ """Normalise any iterable of MIDI pitches into a sorted frozen tuple."""
133
+
134
+ values = tuple(sorted(int(p) for p in pitches))
135
+
136
+ if not values:
137
+ raise ValueError("PitchSet needs at least one pitch")
138
+
139
+ object.__setattr__(self, "pitches", values)
140
+
141
+ def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
142
+
143
+ """Return the pitches (absolute — *root* is ignored by design).
144
+
145
+ ``inversion`` rotates pitches up an octave; ``count`` cycles the set
146
+ into higher octaves, matching the ``Chord.tones`` contract.
147
+ """
148
+
149
+ pitches = list(self.pitches)
150
+
151
+ if inversion != 0:
152
+ for _ in range(inversion % len(pitches)):
153
+ pitches.append(pitches.pop(0) + 12)
154
+
155
+ if count is not None:
156
+ n = len(pitches)
157
+ return [pitches[i % n] + 12 * (i // n) for i in range(count)]
158
+
159
+ return pitches
160
+
161
+ def intervals (self) -> typing.List[int]:
162
+
163
+ """Semitone offsets from the lowest pitch (the ``Chord`` protocol)."""
164
+
165
+ return [p - self.pitches[0] for p in self.pitches]
166
+
167
+ def name (self) -> str:
168
+
169
+ """A readable label for describe() output."""
170
+
171
+ return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")"
172
+
173
+
174
+ @dataclasses.dataclass(frozen=True)
175
+ class RomanChord:
176
+
177
+ """A key-relative chord — a scale degree with optional explicit quality.
178
+
179
+ Internal: users only ever meet it as an int or roman string element inside
180
+ a progression list. It stays relative inside the value and resolves to a
181
+ concrete :class:`~subsequence.chords.Chord` at query time against a key
182
+ and scale.
183
+
184
+ Attributes:
185
+ degree: 1-based scale degree.
186
+ accidental: -1 for a ``b`` prefix, +1 for ``#``. An accidental-
187
+ prefixed degree reads against the **major** scale, the universal
188
+ roman convention — ``bVII`` is always the whole step below the
189
+ tonic (Bb in C major, G in A minor); unprefixed degrees read the
190
+ current scale (``VII`` in A minor is already G).
191
+ quality: Explicit quality name, or ``None`` to infer diatonically
192
+ from the key and scale (the bare-int path).
193
+ of: Secondary-function target degree (``V/x`` — one level only).
194
+ The numeral resolves against the major scale on the target's
195
+ root, the common-practice reading.
196
+ borrowed: When True, the degree resolves against the parallel scale
197
+ (modal interchange) — set by :meth:`Progression.borrow`.
198
+ source_text: The element as written, for unbound ``describe()``.
199
+ major_relative: When True, the degree always reads the major scale
200
+ (with the accidental applied), whatever scale ``resolve()`` is
201
+ given — the scale-proof spelling :meth:`Progression.generate`
202
+ emits, where quality is always explicit and the resolve scale
203
+ must not re-interpret the root.
204
+ """
205
+
206
+ degree: int
207
+ accidental: int = 0
208
+ quality: typing.Optional[str] = None
209
+ of: typing.Optional[int] = None
210
+ borrowed: bool = False
211
+ source_text: str = ""
212
+ major_relative: bool = False
213
+
214
+ def __post_init__ (self) -> None:
215
+
216
+ """Validate the degree (1-based, like everything a musician counts)."""
217
+
218
+ if self.degree < 1:
219
+ raise ValueError(f"scale degree must be 1 or higher, got {self.degree}")
220
+
221
+ def label (self) -> str:
222
+
223
+ """The element as written (for unbound describe() output)."""
224
+
225
+ return self.source_text or str(self.degree)
226
+
227
+ def resolve (self, key_pc: int, scale: str = "ionian") -> subsequence.chords.Chord:
228
+
229
+ """Resolve to a concrete chord against a key and scale.
230
+
231
+ Raises:
232
+ ValueError: If the scale is unknown, the degree exceeds the
233
+ scale, or quality inference is needed but the scale has no
234
+ chord qualities registered.
235
+ """
236
+
237
+ mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale)
238
+
239
+ if mode not in subsequence.intervals.SCALE_MODE_MAP:
240
+ available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys()))
241
+ raise ValueError(f"Unknown scale: {mode!r}. Available: {available}")
242
+
243
+ if self.of is not None:
244
+ # Secondary function: resolve the target degree's root, then read
245
+ # this numeral against the major scale on that root.
246
+ target = RomanChord(degree=self.of)
247
+ target_chord = target.resolve(key_pc, scale)
248
+ return dataclasses.replace(self, of=None).resolve(target_chord.root_pc, "ionian")
249
+
250
+ pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode)
251
+
252
+ if self.accidental != 0 or self.major_relative:
253
+ # Accidental-prefixed degrees read against the major scale — the
254
+ # universal roman convention (bVII is the whole step below tonic
255
+ # in every key, major or minor). Generated spans set
256
+ # major_relative so their spelling is scale-proof — which is why
257
+ # the current scale's degree count must NOT be enforced here
258
+ # (bVII is a valid degree even under a five-note scale).
259
+ major_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian")
260
+ root_pc = (major_pcs[(self.degree - 1) % len(major_pcs)] + self.accidental) % 12
261
+ else:
262
+ if self.degree > len(pcs):
263
+ raise ValueError(
264
+ f"scale degree {self.degree} is out of range for {mode!r} "
265
+ f"({len(pcs)} degrees)"
266
+ )
267
+
268
+ root_pc = pcs[self.degree - 1] % 12
269
+
270
+ if self.quality is not None:
271
+ return subsequence.chords.Chord(root_pc=root_pc, quality=self.quality)
272
+
273
+ _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode]
274
+
275
+ if qualities is None:
276
+ raise ValueError(
277
+ f"Scale {mode!r} has no chord qualities defined, so degree "
278
+ f"{self.degree} cannot be inferred. Use register_scale(..., "
279
+ "qualities=[...]) or write the chord name explicitly."
280
+ )
281
+
282
+ if self.degree > len(qualities):
283
+ raise ValueError(
284
+ f"cannot infer a chord quality for degree {self.degree} under "
285
+ f"{mode!r} ({len(qualities)} degrees) — write the quality "
286
+ "explicitly (e.g. 'bVII' rather than a bare accidental degree)"
287
+ )
288
+
289
+ return subsequence.chords.Chord(root_pc=root_pc, quality=qualities[self.degree - 1])
290
+
291
+ def diatonic_extension_intervals (
292
+ self,
293
+ key_pc: int,
294
+ scale: str,
295
+ extensions: typing.Tuple[typing.Any, ...],
296
+ ) -> typing.Tuple[int, ...]:
297
+
298
+ """Stack diatonic thirds above the triad for numeric extensions.
299
+
300
+ Only meaningful for inferred-quality degrees (the bare-int path):
301
+ ``extend(7)`` on V in C major yields F natural (a dominant seventh),
302
+ where the colour rule on a concrete G chord would yield F#.
303
+
304
+ A 9/11/13 implies every seventh-family tone below it — ``extend(9)``
305
+ stacks the diatonic seventh AND the ninth, so a degree yields the
306
+ same chord class as the concrete-chord path (a ninth chord, not an
307
+ ``add9``).
308
+ """
309
+
310
+ mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale)
311
+ pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode)
312
+
313
+ if self.degree > len(pcs):
314
+ raise ValueError(
315
+ f"scale degree {self.degree} is out of range for {mode!r} "
316
+ f"({len(pcs)} degrees)"
317
+ )
318
+
319
+ root_pc = (pcs[self.degree - 1] + self.accidental) % 12
320
+
321
+ intervals: typing.List[int] = []
322
+
323
+ for extension in extensions:
324
+
325
+ if not isinstance(extension, int):
326
+ continue # sus/add forms are scale-independent — the colour path handles them
327
+
328
+ # 7 → six scale steps above the root; 9 → eight; 11 → ten; 13 → twelve.
329
+ top = {7: 6, 9: 8, 11: 10, 13: 12}.get(extension)
330
+
331
+ if top is None:
332
+ continue
333
+
334
+ # Stack every odd scale step from the seventh up to the extension.
335
+ for steps in range(6, top + 1, 2):
336
+ pc = pcs[(self.degree - 1 + steps) % len(pcs)]
337
+ octave = 0 if steps == 6 else 12 # 9ths/11ths/13ths live above the octave
338
+ intervals.append(((pc - root_pc) % 12) + octave)
339
+
340
+ return tuple(sorted(set(intervals)))
341
+
342
+
343
+ # Major-relative spelling of every pitch-class offset from the tonic — the
344
+ # pop/rock roman convention (b3, b6, b7; b2 and b5 for the rest). Used by
345
+ # generation to emit scale-proof RomanChords.
346
+ _OFFSET_SPELLING: typing.Dict[int, typing.Tuple[int, int]] = {
347
+ 0: (1, 0), 1: (2, -1), 2: (2, 0), 3: (3, -1), 4: (3, 0), 5: (4, 0),
348
+ 6: (5, -1), 7: (5, 0), 8: (6, -1), 9: (6, 0), 10: (7, -1), 11: (7, 0),
349
+ }
350
+
351
+ _ROMAN_NUMERALS: typing.Tuple[str, ...] = ("I", "II", "III", "IV", "V", "VI", "VII")
352
+
353
+ # The scale each built-in graph style implies — used to infer qualities for
354
+ # int constraints (end=1) and as generation's default resolve scale. Styles
355
+ # without diatonic quality rows fall back to ionian.
356
+ _STYLE_SCALES: typing.Dict[str, str] = {
357
+ "functional_major": "ionian",
358
+ "diatonic_major": "ionian",
359
+ "hooktheory_major": "ionian",
360
+ "pop_major": "ionian",
361
+ "turnaround": "ionian",
362
+ "turnaround_global": "ionian",
363
+ "aeolian_minor": "minor",
364
+ "phrygian_minor": "phrygian",
365
+ "lydian_major": "lydian",
366
+ "dorian_minor": "dorian",
367
+ "mixolydian": "mixolydian",
368
+ }
369
+
370
+ # Quality → (prints lowercase, printable suffix). Qualities outside the
371
+ # table print as an explicit parenthesised tail.
372
+ _ROMAN_QUALITY_TEXT: typing.Dict[str, typing.Tuple[bool, str]] = {
373
+ "major": (False, ""),
374
+ "minor": (True, ""),
375
+ "dominant_7th": (False, "7"),
376
+ "minor_7th": (True, "7"),
377
+ "major_7th": (False, "maj7"),
378
+ "diminished": (True, "°"),
379
+ "diminished_7th": (True, "°7"),
380
+ "half_diminished_7th": (True, "ø7"),
381
+ "augmented": (False, "+"),
382
+ }
383
+
384
+
385
+ def resolve_constraint (spec: typing.Any, key_pc: int, scale: str, what: str) -> subsequence.chords.Chord:
386
+
387
+ """Parse one hybrid-constraint spec (pin/end/avoid) into a concrete chord.
388
+
389
+ Specs follow the progression-element grammar: ints are diatonic degrees
390
+ (quality inferred from *scale*), strings are chord names or romans,
391
+ ``Chord`` objects pass through. ``PitchSet`` objects are rejected — generation
392
+ needs rooted chords.
393
+ """
394
+
395
+ parsed = parse_element(spec).chord
396
+
397
+ if isinstance(parsed, PitchSet):
398
+ raise ValueError(f"{what}: generation needs rooted chords — a PitchSet cannot constrain the walk")
399
+ if isinstance(parsed, RomanChord):
400
+ return parsed.resolve(key_pc, scale)
401
+
402
+ return typing.cast(subsequence.chords.Chord, parsed)
403
+
404
+
405
+ def cadence_pins (
406
+ name: str,
407
+ bars: int,
408
+ pins: typing.Optional[typing.Dict[int, typing.Any]],
409
+ end: typing.Optional[typing.Any],
410
+ ) -> typing.Dict[int, typing.Any]:
411
+
412
+ """Compile a cadence name into pins on the final bars of a walk.
413
+
414
+ The shared translation for ``Progression.generate(cadence=)`` and
415
+ ``Composition.freeze(cadence=)``: the formula occupies the last bars,
416
+ merged with the caller's own pins. Conflicts raise loudly — ``end=``
417
+ and a pin on a formula bar both name what the cadence already fixes.
418
+ """
419
+
420
+ spec = subsequence.cadences.cadence_formula(name)
421
+ count = len(spec.formula)
422
+
423
+ if end is not None:
424
+ raise ValueError(
425
+ f"cadence={name!r} already fixes the final bar — it conflicts with end={end!r} "
426
+ "(drop end=, or spell the tail yourself with pins=)"
427
+ )
428
+
429
+ if bars < count:
430
+ raise ValueError(f"cadence={name!r} needs {count} bars for its formula, but bars={bars}")
431
+
432
+ merged = dict(pins or {})
433
+
434
+ for offset, element in enumerate(spec.formula):
435
+ position = bars - count + 1 + offset
436
+ if position in merged:
437
+ raise ValueError(
438
+ f"cadence={name!r} fixes bar {position}, which conflicts with "
439
+ f"pins[{position}]={merged[position]!r}"
440
+ )
441
+ merged[position] = element
442
+
443
+ return merged
444
+
445
+
446
+ def _roman_from_chord (chord: subsequence.chords.Chord, tonic_pc: int) -> RomanChord:
447
+
448
+ """Spell a concrete chord relative to a tonic, scale-proof.
449
+
450
+ The inverse of resolution for generated values: the root becomes a
451
+ major-relative degree (accidentals for the chromatic offsets), the
452
+ quality stays explicit, and ``source_text`` carries a printable roman
453
+ for unbound ``describe()``.
454
+ """
455
+
456
+ offset = (chord.root_pc - tonic_pc) % 12
457
+ degree, accidental = _OFFSET_SPELLING[offset]
458
+
459
+ lowercase, suffix = _ROMAN_QUALITY_TEXT.get(chord.quality, (False, f"({chord.quality})"))
460
+ numeral = _ROMAN_NUMERALS[degree - 1]
461
+ text = ("b" if accidental < 0 else "#" if accidental > 0 else "") + (numeral.lower() if lowercase else numeral) + suffix
462
+
463
+ return RomanChord(
464
+ degree = degree,
465
+ accidental = accidental,
466
+ quality = chord.quality,
467
+ source_text = text,
468
+ major_relative = True,
469
+ )
470
+
471
+
472
+ # ---------------------------------------------------------------------------
473
+ # ChordSpan — the unit the clock walks
474
+ # ---------------------------------------------------------------------------
475
+
476
+
477
+ _EXTENSION_NAMES: typing.FrozenSet[str] = frozenset({"sus2", "sus4", "add9", "6"})
478
+ _NUMERIC_EXTENSIONS: typing.FrozenSet[int] = frozenset({7, 9, 11, 13})
479
+ _SPREAD_STYLES: typing.FrozenSet[str] = frozenset({"close", "open", "wide"})
480
+
481
+
482
+ @dataclasses.dataclass(frozen=True)
483
+ class ChordSpan:
484
+
485
+ """One chord with a duration and its decoration — the unit of harmonic time.
486
+
487
+ Decoration (extensions, slash bass, inversion, spread) lives HERE, never
488
+ on :class:`~subsequence.chords.Chord`: the engine's graph identity stays
489
+ the bare triad, and the decorated voicing is what patterns hear.
490
+
491
+ Attributes:
492
+ chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a
493
+ :class:`PitchSet`.
494
+ beats: Span length in beats.
495
+ extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``)
496
+ or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``).
497
+ bass: Slash/pedal bass — a pitch class int, a note name, or
498
+ ``"tonic"`` (resolved against the key at query time).
499
+ inversion: Chord inversion for the voicing (0 = root position).
500
+ spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2),
501
+ or ``"wide"`` (drop-2-and-4).
502
+ extension_intervals: Pre-computed semitone offsets for the
503
+ extensions, set by :meth:`Progression.resolve` for diatonic
504
+ degrees. ``None`` means "derive from the chord's own colour".
505
+ """
506
+
507
+ chord: typing.Any
508
+ beats: float
509
+ extensions: typing.Tuple[typing.Any, ...] = ()
510
+ bass: typing.Optional[typing.Union[int, str]] = None
511
+ inversion: int = 0
512
+ spread: typing.Optional[str] = None
513
+ extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None
514
+
515
+ def __post_init__ (self) -> None:
516
+
517
+ """Validate beats, extensions, and spread."""
518
+
519
+ if self.beats <= 0:
520
+ raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}")
521
+
522
+ for extension in self.extensions:
523
+ if isinstance(extension, bool) or not (
524
+ (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS)
525
+ or (isinstance(extension, str) and extension in _EXTENSION_NAMES)
526
+ ):
527
+ known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES))
528
+ raise ValueError(f"unknown extension {extension!r} — expected one of: {known}")
529
+
530
+ if self.spread is not None and self.spread not in _SPREAD_STYLES:
531
+ raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES)))
532
+
533
+ @property
534
+ def is_concrete (self) -> bool:
535
+
536
+ """True when the chord (and any pedal bass) needs no key context to sound.
537
+
538
+ A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not
539
+ concrete until :meth:`resolve` pins it to a key. Note-name basses are
540
+ resolved to a pitch class eagerly in :meth:`Progression.over`, so they
541
+ never linger here as strings.
542
+ """
543
+
544
+ return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str)
545
+
546
+ @property
547
+ def is_decorated (self) -> bool:
548
+
549
+ """True when the span carries any decoration beyond the bare chord."""
550
+
551
+ return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None
552
+
553
+ def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan":
554
+
555
+ """Return a concrete span: romans resolved, bass resolved to a pitch class."""
556
+
557
+ chord = self.chord
558
+ extension_intervals = self.extension_intervals
559
+
560
+ if isinstance(chord, RomanChord):
561
+ if chord.quality is None and any(isinstance(e, int) for e in self.extensions):
562
+ extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions)
563
+ chord = chord.resolve(key_pc, scale)
564
+
565
+ bass: typing.Optional[typing.Union[int, str]] = self.bass
566
+
567
+ if isinstance(bass, str):
568
+ if bass == "tonic":
569
+ bass = key_pc
570
+ else:
571
+ bass = subsequence.chords.key_name_to_pc(bass)
572
+
573
+ return dataclasses.replace(
574
+ self,
575
+ chord = chord,
576
+ bass = bass,
577
+ extension_intervals = extension_intervals,
578
+ )
579
+
580
+ def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str:
581
+
582
+ """A printable chord label: roman text when relative, decorated name when concrete."""
583
+
584
+ if isinstance(self.chord, RomanChord):
585
+ if key_pc is None:
586
+ text = self.chord.label()
587
+ return text + self._decoration_suffix(resolved=False)
588
+ return self.resolve(key_pc, scale).label()
589
+
590
+ base = str(self.chord.name())
591
+ return base + self._decoration_suffix(resolved=True)
592
+
593
+ def _decoration_suffix (self, resolved: bool) -> str:
594
+
595
+ """The printable decoration tail (extensions and slash bass)."""
596
+
597
+ parts = ""
598
+ numeric = sorted(e for e in self.extensions if isinstance(e, int))
599
+
600
+ # 9 implies 7 (and so on up): print only the highest stacked extension.
601
+ stacked = [e for e in numeric if e in (7, 9, 11, 13)]
602
+ if stacked:
603
+ parts += str(stacked[-1])
604
+
605
+ for name in (e for e in self.extensions if isinstance(e, str)):
606
+ parts += name
607
+
608
+ if self.bass is not None:
609
+ if isinstance(self.bass, int):
610
+ parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12]
611
+ else:
612
+ parts += "/" + str(self.bass)
613
+
614
+ return parts
615
+
616
+ def decorated_intervals (self) -> typing.List[int]:
617
+
618
+ """Semitone offsets of the decorated voicing (before inversion/spread/bass).
619
+
620
+ Numeric extensions deepen the chord in its own colour — a minor third
621
+ gets a minor seventh, a major third a major seventh, a diminished
622
+ triad a diminished seventh. Diatonic degrees extended with
623
+ ``extend(...)`` carry pre-computed scale-true intervals instead (so V
624
+ gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the
625
+ dominant colour on a concrete major chord.
626
+ """
627
+
628
+ if isinstance(self.chord, RomanChord):
629
+ raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
630
+
631
+ intervals = list(self.chord.intervals())
632
+
633
+ sus = [e for e in self.extensions if e in ("sus2", "sus4")]
634
+ if sus and len(intervals) >= 2:
635
+ intervals[1] = 2 if sus[0] == "sus2" else 5
636
+
637
+ numeric = sorted(e for e in self.extensions if isinstance(e, int))
638
+
639
+ if self.extension_intervals is not None:
640
+ added: typing.List[int] = list(self.extension_intervals)
641
+ else:
642
+ added = []
643
+ third = intervals[1] if len(intervals) >= 2 else None
644
+ has_seventh = any(i in (9, 10, 11) for i in intervals)
645
+ stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS]
646
+
647
+ if stacked and not has_seventh:
648
+ if third == 3 and len(intervals) >= 3 and intervals[2] == 6:
649
+ added.append(9) # diminished colour
650
+ elif third == 3:
651
+ added.append(10) # minor colour
652
+ elif third == 4:
653
+ added.append(11) # major colour
654
+ else:
655
+ added.append(10) # sus / no third: the dominant-leaning seventh
656
+
657
+ for extension in stacked:
658
+ if extension == 9:
659
+ added.append(14)
660
+ elif extension == 11:
661
+ added.append(17)
662
+ elif extension == 13:
663
+ added.append(21)
664
+
665
+ if "add9" in self.extensions:
666
+ added.append(14)
667
+ if "6" in self.extensions:
668
+ added.append(9)
669
+
670
+ return sorted(set(intervals) | set(added))
671
+
672
+ def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]:
673
+
674
+ """MIDI notes of the decorated voicing nearest *root* (concrete spans only).
675
+
676
+ Applies, in order: extensions, inversion, spread, then the slash/pedal
677
+ bass below the voicing. ``PitchSet`` spans return their absolute
678
+ pitches (decoration other than ``count`` does not apply).
679
+ """
680
+
681
+ if isinstance(self.chord, RomanChord):
682
+ raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
683
+
684
+ if isinstance(self.chord, PitchSet):
685
+ return self.chord.tones(root, inversion=self.inversion, count=count)
686
+
687
+ intervals = self.decorated_intervals()
688
+
689
+ if self.inversion != 0:
690
+ intervals = subsequence.voicings.invert_chord(intervals, self.inversion)
691
+
692
+ if self.spread == "open" and len(intervals) >= 3:
693
+ intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:])
694
+ elif self.spread == "wide" and len(intervals) >= 3:
695
+ dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)]
696
+ intervals = sorted(dropped)
697
+
698
+ offset = (self.chord.root_pc - root) % 12
699
+ if offset > 6:
700
+ offset -= 12
701
+ effective_root = root + offset
702
+
703
+ if count is not None:
704
+ n = len(intervals)
705
+ span_octave = max(12, ((max(intervals) // 12) + 1) * 12)
706
+ pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)]
707
+ else:
708
+ pitches = [effective_root + interval for interval in intervals]
709
+
710
+ if self.bass is not None and isinstance(self.bass, int):
711
+ lowest = min(pitches)
712
+ bass_note = lowest - ((lowest - self.bass) % 12)
713
+ if bass_note == lowest:
714
+ bass_note -= 12
715
+ pitches = [bass_note] + pitches
716
+
717
+ return pitches
718
+
719
+
720
+ class DecoratedChord:
721
+
722
+ """Duck-types the ``Chord`` voicing protocol over a decorated span.
723
+
724
+ What patterns and the injected ``chord`` receive when a span carries
725
+ decoration: ``tones()`` voices the extensions/inversion/spread/bass,
726
+ ``intervals()`` reports the decorated intervals (so per-pattern voice
727
+ leading works over them), and ``name()`` prints the decorated name
728
+ (``Am9``, ``C/G``). The engine itself never sees this — graph identity
729
+ stays the bare triad underneath (:attr:`base`).
730
+ """
731
+
732
+ def __init__ (self, span: ChordSpan) -> None:
733
+
734
+ """Wrap a concrete, decorated span."""
735
+
736
+ if not span.is_concrete:
737
+ raise ValueError("DecoratedChord needs a concrete span — resolve(key=...) first")
738
+
739
+ self._span = span
740
+
741
+ @property
742
+ def base (self) -> typing.Any:
743
+
744
+ """The undecorated chord (the engine's currency)."""
745
+
746
+ return self._span.chord
747
+
748
+ @property
749
+ def span (self) -> ChordSpan:
750
+
751
+ """The wrapped span (decoration and all)."""
752
+
753
+ return self._span
754
+
755
+ @property
756
+ def root_pc (self) -> int:
757
+
758
+ """The root pitch class of the underlying chord."""
759
+
760
+ return int(self._span.chord.root_pc) if hasattr(self._span.chord, "root_pc") else self._span.chord.pitches[0] % 12
761
+
762
+ @property
763
+ def quality (self) -> str:
764
+
765
+ """The quality of the underlying chord."""
766
+
767
+ return str(getattr(self._span.chord, "quality", "pitch_set"))
768
+
769
+ def intervals (self) -> typing.List[int]:
770
+
771
+ """Decorated semitone offsets from the root."""
772
+
773
+ if isinstance(self._span.chord, PitchSet):
774
+ return self._span.chord.intervals()
775
+
776
+ return self._span.decorated_intervals()
777
+
778
+ def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
779
+
780
+ """Decorated voicing nearest *root*; an explicit *inversion* overrides the span's."""
781
+
782
+ span = self._span if inversion == 0 else dataclasses.replace(self._span, inversion=inversion)
783
+
784
+ return span.tones(root, count=count)
785
+
786
+ def root_note (self, root_midi: int) -> int:
787
+
788
+ """The MIDI note of the (undecorated) chord root nearest *root_midi*."""
789
+
790
+ if isinstance(self._span.chord, PitchSet):
791
+ return self._span.chord.pitches[0]
792
+
793
+ return int(self._span.chord.root_note(root_midi))
794
+
795
+ def bass_note (self, root_midi: int, octave_offset: int = -1) -> int:
796
+
797
+ """The chord root shifted by octaves (the slash bass pc when one is set).
798
+
799
+ The slash bass uses the same register as the plain root bass — an octave
800
+ below the chord at the default ``octave_offset=-1`` — so a bass line over
801
+ a mix of plain and slash chords doesn't jump up an octave on the slash
802
+ ones.
803
+ """
804
+
805
+ if isinstance(self._span.bass, int):
806
+ lowest = self.root_note(root_midi)
807
+ bass = lowest - ((lowest - self._span.bass) % 12)
808
+ return bass + 12 * octave_offset
809
+
810
+ return self.root_note(root_midi) + (12 * octave_offset)
811
+
812
+ def name (self) -> str:
813
+
814
+ """The decorated chord name (``Am9``, ``C/G``)."""
815
+
816
+ return self._span.label()
817
+
818
+
819
+ # ---------------------------------------------------------------------------
820
+ # The roman / name / degree element parser
821
+ # ---------------------------------------------------------------------------
822
+
823
+ _ROMAN_VALUES: typing.Dict[str, int] = {
824
+ "i": 1, "ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7,
825
+ }
826
+
827
+ _ROMAN_RE = re.compile(
828
+ r"^(?P<accidental>[b#])?"
829
+ r"(?P<numeral>[ivIV]+)"
830
+ r"(?P<modifier>°|o|ø|\+|aug|dim)?"
831
+ r"(?P<maj7>maj7|M7)?"
832
+ r"(?P<figure>65|64|43|42|7|6|2)?"
833
+ r"(?:/(?P<of>.+))?$"
834
+ )
835
+
836
+ # Figure → (adds a seventh, inversion).
837
+ _FIGURES: typing.Dict[typing.Optional[str], typing.Tuple[bool, int]] = {
838
+ None: (False, 0),
839
+ "6": (False, 1),
840
+ "64": (False, 2),
841
+ "7": (True, 0),
842
+ "65": (True, 1),
843
+ "43": (True, 2),
844
+ "42": (True, 3),
845
+ "2": (True, 3),
846
+ }
847
+
848
+
849
+ def parse_roman (text: str) -> typing.Tuple[RomanChord, int]:
850
+
851
+ """Parse a roman numeral element into a (RomanChord, inversion) pair.
852
+
853
+ The ~music21 semantics grammar: case is quality (``IV`` major, ``iv``
854
+ minor), ``°``/``o``/``dim`` diminished, ``ø`` half-diminished, ``+``/
855
+ ``aug`` augmented; ``maj7`` forces the major seventh; figured-bass
856
+ suffixes give sevenths and inversions (``7``/``65``/``43``/``42``;
857
+ ``6``/``64`` for triads); ``b``/``#`` prefixes shift the degree; one
858
+ level of ``/x`` secondary function (``V7/IV``).
859
+
860
+ Raises ``ValueError`` for anything it can't read.
861
+ """
862
+
863
+ stripped = text.strip()
864
+ match = _ROMAN_RE.match(stripped)
865
+
866
+ if not match:
867
+ raise ValueError(f"Cannot parse roman numeral {text!r} — expected e.g. 'V7', 'bVII', 'ii65', 'V/V'")
868
+
869
+ numeral = match.group("numeral")
870
+ lowered = numeral.lower()
871
+
872
+ if lowered not in _ROMAN_VALUES or numeral not in (lowered, numeral.upper()):
873
+ raise ValueError(f"Cannot parse roman numeral {text!r} — {numeral!r} is not a degree numeral (I–VII)")
874
+
875
+ degree = _ROMAN_VALUES[lowered]
876
+ is_upper = numeral == numeral.upper()
877
+ accidental = {"b": -1, "#": 1}.get(match.group("accidental") or "", 0)
878
+
879
+ modifier = match.group("modifier")
880
+ has_maj7 = match.group("maj7") is not None
881
+ has_seventh, inversion = _FIGURES[match.group("figure")]
882
+
883
+ if modifier in ("°", "o", "dim"):
884
+ quality = "diminished_7th" if has_seventh else "diminished"
885
+ elif modifier == "ø":
886
+ quality = "half_diminished_7th"
887
+ elif modifier in ("+", "aug"):
888
+ quality = "augmented"
889
+ elif has_maj7:
890
+ if not is_upper:
891
+ raise ValueError(f"Cannot parse roman numeral {text!r} — maj7 needs an uppercase numeral")
892
+ quality = "major_7th"
893
+ elif has_seventh:
894
+ quality = "dominant_7th" if is_upper else "minor_7th"
895
+ else:
896
+ quality = "major" if is_upper else "minor"
897
+
898
+ of: typing.Optional[int] = None
899
+ of_text = match.group("of")
900
+
901
+ if of_text is not None:
902
+ if "/" in of_text:
903
+ raise ValueError(f"Cannot parse roman numeral {text!r} — only one level of secondary function (/x) is supported")
904
+ if of_text.lower() in _ROMAN_VALUES:
905
+ of = _ROMAN_VALUES[of_text.lower()]
906
+ elif of_text.isdigit():
907
+ of = int(of_text)
908
+ else:
909
+ raise ValueError(f"Cannot parse roman numeral {text!r} — secondary target {of_text!r} is not a degree")
910
+
911
+ roman = RomanChord(
912
+ degree = degree,
913
+ accidental = accidental,
914
+ quality = quality,
915
+ of = of,
916
+ source_text = stripped,
917
+ )
918
+
919
+ return roman, inversion
920
+
921
+
922
+ def parse_element (element: typing.Any, beats: float = DEFAULT_SPAN_BEATS) -> ChordSpan:
923
+
924
+ """Parse one progression-list element into a :class:`ChordSpan`.
925
+
926
+ Elements mix freely and are parsed per element (decision 16): ints are
927
+ diatonic degrees (1-based, quality inferred from key+scale at query
928
+ time); strings are chord names where they start with a note letter
929
+ (``"Am"``) and romans otherwise (``"VI"``, ``"bVII7"``); ``Chord``,
930
+ ``PitchSet``, and ``ChordSpan`` values pass through; an
931
+ ``(element, beats)`` tuple sets the span length.
932
+ """
933
+
934
+ if isinstance(element, ChordSpan):
935
+ return element
936
+
937
+ if isinstance(element, tuple):
938
+ if len(element) != 2:
939
+ raise ValueError(f"a progression tuple element must be (chord, beats), got {element!r}")
940
+ inner, span_beats = element
941
+ return parse_element(inner, beats=float(span_beats))
942
+
943
+ if isinstance(element, bool):
944
+ raise TypeError(f"cannot parse progression element {element!r} (bool)")
945
+
946
+ if isinstance(element, int):
947
+ return ChordSpan(chord=RomanChord(degree=element, source_text=str(element)), beats=beats)
948
+
949
+ if isinstance(element, (subsequence.chords.Chord, PitchSet, RomanChord)):
950
+ return ChordSpan(chord=element, beats=beats)
951
+
952
+ if isinstance(element, str):
953
+ stripped = element.strip()
954
+ if stripped and stripped[0] in "ABCDEFG":
955
+ return _parse_chord_name(stripped, beats)
956
+ roman, inversion = parse_roman(stripped)
957
+ return ChordSpan(chord=roman, beats=beats, inversion=inversion)
958
+
959
+ raise TypeError(
960
+ f"cannot parse progression element {element!r} — expected an int degree, "
961
+ "a chord name or roman string, a Chord, a PitchSet, or an (element, beats) tuple"
962
+ )
963
+
964
+
965
+ # ---------------------------------------------------------------------------
966
+ # The Progression value
967
+ # ---------------------------------------------------------------------------
968
+
969
+
970
+ def _parse_chord_name (name: str, beats: float) -> ChordSpan:
971
+
972
+ """Parse a chord-name element, splitting a trailing extension onto the span.
973
+
974
+ ``"Dm9"`` is D minor decorated with a 9 — the quality table holds bare
975
+ qualities, and the 9/11/13 ride the span as extensions (decoration lives
976
+ on spans, never chords). ``"Dm7"`` stays a plain quality (m7 is in the
977
+ table); the split only happens when the full name does not parse.
978
+ """
979
+
980
+ try:
981
+ return ChordSpan(chord = subsequence.chords.parse_chord(name), beats = beats)
982
+ except ValueError as original:
983
+ for extension in ("13", "11", "9"):
984
+ if name.endswith(extension) and len(name) > len(extension):
985
+ base = name[:-len(extension)]
986
+ try:
987
+ chord = subsequence.chords.parse_chord(base)
988
+ except ValueError:
989
+ continue
990
+ return ChordSpan(chord = chord, beats = beats, extensions = (int(extension),))
991
+ raise original
992
+
993
+
994
+ def _check_slot (slot: int, count: int) -> int:
995
+
996
+ """Validate a 1-based chord slot and return its 0-based index."""
997
+
998
+ if not isinstance(slot, int) or isinstance(slot, bool):
999
+ raise TypeError(f"chord slots are 1-based ints, got {slot!r}")
1000
+ if slot < 1 or slot > count:
1001
+ raise ValueError(f"chord slot {slot} is out of range (1–{count})")
1002
+
1003
+ return slot - 1
1004
+
1005
+
1006
+ @dataclasses.dataclass(frozen=True)
1007
+ class Progression:
1008
+
1009
+ """A frozen sequence of :class:`ChordSpan` — the governing harmony value.
1010
+
1011
+ Always a realised value: binding it to the clock freezes one realisation;
1012
+ ``p.progression()`` keeps its breathing behaviour by re-realising a fresh
1013
+ one each rebuild. Iterating yields ``(chord, start, length)``
1014
+ :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so
1015
+ placement loops keep working unchanged.
1016
+
1017
+ The governing family supports ``+`` (concatenate) and ``*`` (tile) but
1018
+ never ``&`` — there is one current chord (P1, the type law).
1019
+
1020
+ Attributes:
1021
+ spans: The chord spans, in order.
1022
+ trailing_history: Engine continuity metadata set by
1023
+ :meth:`Composition.freeze` — the NIR history at capture time,
1024
+ restored on each frozen replay. Empty for hand-built values.
1025
+ """
1026
+
1027
+ spans: typing.Tuple[ChordSpan, ...]
1028
+ trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = ()
1029
+
1030
+ def __post_init__ (self) -> None:
1031
+
1032
+ """Normalise span containers to tuples."""
1033
+
1034
+ object.__setattr__(self, "spans", tuple(self.spans))
1035
+ object.__setattr__(self, "trailing_history", tuple(self.trailing_history))
1036
+
1037
+ if not self.spans:
1038
+ raise ValueError("a Progression needs at least one chord span")
1039
+
1040
+ # -- queries ------------------------------------------------------------
1041
+
1042
+ @property
1043
+ def length (self) -> float:
1044
+
1045
+ """Total length in beats (the sum of span lengths)."""
1046
+
1047
+ return float(sum(span.beats for span in self.spans))
1048
+
1049
+ @property
1050
+ def is_concrete (self) -> bool:
1051
+
1052
+ """True when every span is key-independent (no romans/degrees)."""
1053
+
1054
+ return all(span.is_concrete for span in self.spans)
1055
+
1056
+ @property
1057
+ def chords (self) -> typing.Tuple[typing.Any, ...]:
1058
+
1059
+ """The bare chords, one per span (concrete progressions only)."""
1060
+
1061
+ self._require_concrete("read .chords")
1062
+
1063
+ return tuple(span.chord for span in self.spans)
1064
+
1065
+ @property
1066
+ def loops_on_exhaustion (self) -> bool:
1067
+
1068
+ """True when the clock must loop rather than fall through to live stepping."""
1069
+
1070
+ return any(isinstance(span.chord, PitchSet) for span in self.spans)
1071
+
1072
+ def _require_concrete (self, action: str) -> None:
1073
+
1074
+ """Raise with a resolution hint when key-relative spans remain."""
1075
+
1076
+ if not self.is_concrete:
1077
+ relative = ", ".join(span.label() for span in self.spans if not span.is_concrete)
1078
+ raise ValueError(
1079
+ f"cannot {action} on a key-relative progression (contains {relative}) — "
1080
+ "call .resolve(key=...) first, or bind it where a key is known"
1081
+ )
1082
+
1083
+ def __iter__ (self) -> typing.Iterator[ChordEvent]:
1084
+
1085
+ """Yield ``(chord, start, length)`` events — decorated chords where spiced."""
1086
+
1087
+ self._require_concrete("iterate")
1088
+
1089
+ cursor = 0.0
1090
+
1091
+ for span in self.spans:
1092
+ chord = DecoratedChord(span) if span.is_decorated else span.chord
1093
+ yield ChordEvent(chord=chord, start=cursor, length=span.beats)
1094
+ cursor += span.beats
1095
+
1096
+ def __len__ (self) -> int:
1097
+
1098
+ """The number of chord spans."""
1099
+
1100
+ return len(self.spans)
1101
+
1102
+ def events (self) -> typing.Tuple[ChordEvent, ...]:
1103
+
1104
+ """The realised timeline as a tuple (iteration, materialised)."""
1105
+
1106
+ return tuple(self)
1107
+
1108
+ def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]:
1109
+
1110
+ """Return ``(span, start, end)`` for the span sounding at *beat*.
1111
+
1112
+ *beat* wraps modulo the progression length, so the lookup also
1113
+ serves looped playback.
1114
+ """
1115
+
1116
+ position = beat % self.length
1117
+ cursor = 0.0
1118
+
1119
+ for span in self.spans:
1120
+ if cursor <= position < cursor + span.beats:
1121
+ return span, cursor, cursor + span.beats
1122
+ cursor += span.beats
1123
+
1124
+ final = self.spans[-1]
1125
+ return final, self.length - final.beats, self.length
1126
+
1127
+ def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression":
1128
+
1129
+ """Resolve every key-relative span against a key (name or pitch class)."""
1130
+
1131
+ key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)
1132
+
1133
+ return dataclasses.replace(
1134
+ self,
1135
+ spans = tuple(span.resolve(key_pc, scale) for span in self.spans),
1136
+ )
1137
+
1138
+ @classmethod
1139
+ def generate (
1140
+ cls,
1141
+ style: typing.Union[str, typing.Any] = "functional_major",
1142
+ bars: int = 8,
1143
+ beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS,
1144
+ *,
1145
+ key: typing.Optional[str] = None,
1146
+ scale: typing.Optional[str] = None,
1147
+ seed: typing.Optional[int] = None,
1148
+ rng: typing.Optional[random.Random] = None,
1149
+ pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1150
+ end: typing.Optional[typing.Any] = None,
1151
+ avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1152
+ cadence: typing.Optional[str] = None,
1153
+ dominant_7th: bool = True,
1154
+ gravity: float = 1.0,
1155
+ nir_strength: float = 0.5,
1156
+ minor_turnaround_weight: float = 0.0,
1157
+ root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1158
+ ) -> "Progression":
1159
+
1160
+ """Generate a progression from a chord-graph walk — the hybrid generator.
1161
+
1162
+ Full parameter pass-through to the engine (no more throwaway default
1163
+ engines), plus the hybrid constraints: ``pins`` fix chords at 1-based
1164
+ bars, ``end`` fixes the last bar, ``avoid`` excludes chords
1165
+ everywhere. Constraints compile into the walk — a backward
1166
+ feasibility pass guarantees satisfiability before any chord is
1167
+ drawn (unsatisfiable constraints raise immediately), then a forward
1168
+ walk samples through the engine's real history-dependent weights
1169
+ (NIR, gravity, diversity keep their character).
1170
+
1171
+ **Without** ``key=`` the result is key-relative — the walk runs
1172
+ against a reference tonic and the spans store scale-proof
1173
+ major-relative romans, so the value prints meaningfully unbound and
1174
+ resolves wherever it is bound (the walk itself is key-invariant).
1175
+ **With** ``key=`` the result is concrete.
1176
+
1177
+ Parameters:
1178
+ style: A chord-graph style name (or ``ChordGraph`` instance).
1179
+ bars: How many chords to generate.
1180
+ beats: Span length per chord — a scalar, or a list cycled.
1181
+ key: Key for a concrete result; omit for a key-relative value.
1182
+ scale: Scale for int constraints' quality inference (e.g.
1183
+ ``end=1``). Defaults from the style (aeolian_minor →
1184
+ minor); explicit strings (``"V"``, ``"bVII7"``) never
1185
+ need it.
1186
+ seed: Seed for the walk. A standalone generated value without
1187
+ a seed warns — module-level nondeterminism breaks live
1188
+ reload.
1189
+ rng: An explicit random stream (overrides ``seed``).
1190
+ pins: ``{bar: chord}`` — 1-based; values parse like progression
1191
+ elements (ints, romans, names, ``Chord``).
1192
+ end: The chord at the final bar — ``end="V"`` is the cadential
1193
+ major dominant in minor (a string because it is chromatic;
1194
+ no int can ask for it).
1195
+ avoid: Chords excluded from the walk. Naming a chord outside
1196
+ the style's vocabulary is allowed (trivially satisfied).
1197
+ cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/
1198
+ ``"fakeout"``, theory aliases accepted) — its formula
1199
+ becomes pins on the final bars, so the walk *approaches*
1200
+ the close. Conflicts with ``end=`` or pins on those bars.
1201
+ dominant_7th / gravity / nir_strength / minor_turnaround_weight /
1202
+ root_diversity: The engine parameters, exactly as
1203
+ :meth:`Composition.harmony` takes them.
1204
+
1205
+ Example:
1206
+ ```python
1207
+ chorus = subsequence.Progression.generate(
1208
+ style="aeolian_minor", bars=4, end="V", seed=7,
1209
+ )
1210
+ print(chorus) # romans until bound
1211
+ ```
1212
+ """
1213
+
1214
+ if bars < 1:
1215
+ raise ValueError("bars must be at least 1")
1216
+
1217
+ if cadence is not None:
1218
+ pins = cadence_pins(cadence, bars, pins, end)
1219
+ end = None
1220
+
1221
+ if rng is None:
1222
+ if seed is None:
1223
+ warnings.warn(
1224
+ "Progression.generate without seed= is nondeterministic — "
1225
+ "pass seed= so the value survives live reload",
1226
+ stacklevel = 2,
1227
+ )
1228
+ rng = random.Random()
1229
+ else:
1230
+ rng = random.Random(seed)
1231
+
1232
+ resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian")
1233
+ relative = key is None
1234
+ reference = key if key is not None else "C"
1235
+
1236
+ state = subsequence.harmonic_state.HarmonicState(
1237
+ key_name = reference,
1238
+ graph_style = style,
1239
+ include_dominant_7th = dominant_7th,
1240
+ key_gravity_blend = gravity,
1241
+ nir_strength = nir_strength,
1242
+ minor_turnaround_weight = minor_turnaround_weight,
1243
+ root_diversity = root_diversity,
1244
+ rng = rng,
1245
+ )
1246
+
1247
+ resolved_pins = {
1248
+ position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]")
1249
+ for position, spec in (pins or {}).items()
1250
+ }
1251
+ resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None
1252
+ resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])]
1253
+
1254
+ if 1 in resolved_pins:
1255
+ if resolved_pins[1] not in state.graph.nodes():
1256
+ raise ValueError(
1257
+ f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary"
1258
+ )
1259
+ state.current_chord = resolved_pins[1]
1260
+
1261
+ def commit (chosen: subsequence.chords.Chord) -> None:
1262
+ state.current_chord = chosen
1263
+
1264
+ walked = subsequence.sequence_utils.constrained_walk(
1265
+ state.graph,
1266
+ state.current_chord,
1267
+ bars,
1268
+ rng = state.rng,
1269
+ pins = resolved_pins,
1270
+ end = resolved_end,
1271
+ avoid = resolved_avoid,
1272
+ weight_modifier = state._transition_weight,
1273
+ before_choice = state._record_transition_source,
1274
+ after_choice = commit,
1275
+ )
1276
+
1277
+ lengths = _span_lengths(beats, bars)
1278
+
1279
+ if relative:
1280
+ return cls(spans = tuple(
1281
+ ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index])
1282
+ for index, chord in enumerate(walked)
1283
+ ))
1284
+
1285
+ return cls(spans = tuple(
1286
+ ChordSpan(chord = chord, beats = lengths[index])
1287
+ for index, chord in enumerate(walked)
1288
+ ))
1289
+
1290
+ # -- algebra ------------------------------------------------------------
1291
+
1292
+ def __add__ (self, other: "Progression") -> "Progression":
1293
+
1294
+ """Concatenate two progressions (the governing ``+``)."""
1295
+
1296
+ if not isinstance(other, Progression):
1297
+ return NotImplemented
1298
+
1299
+ return Progression(spans = self.spans + other.spans)
1300
+
1301
+ def __mul__ (self, count: int) -> "Progression":
1302
+
1303
+ """Tile the spans *count* times."""
1304
+
1305
+ if not isinstance(count, int) or isinstance(count, bool):
1306
+ return NotImplemented
1307
+ if count < 1:
1308
+ raise ValueError("a progression must repeat at least once (n >= 1)")
1309
+
1310
+ return Progression(spans = self.spans * count)
1311
+
1312
+ def __and__ (self, other: typing.Any) -> "Progression":
1313
+
1314
+ """Parallel merge is a type error for governing values — by design."""
1315
+
1316
+ raise TypeError(
1317
+ "Progressions cannot be merged with & — there is one current chord. "
1318
+ "Sequence them with +, or give a pattern its own part-level progression."
1319
+ )
1320
+
1321
+ # -- spice (the five operators) and editing ------------------------------
1322
+
1323
+ def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression":
1324
+
1325
+ """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span.
1326
+
1327
+ ``only=`` restricts the spice to the given 1-based chord slots.
1328
+ """
1329
+
1330
+ slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1331
+
1332
+ spans = tuple(
1333
+ dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions)))
1334
+ if index in slots else span
1335
+ for index, span in enumerate(self.spans)
1336
+ )
1337
+
1338
+ return dataclasses.replace(self, spans=spans)
1339
+
1340
+ def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression":
1341
+
1342
+ """Set chord inversions — a single int for all spans, or a list cycled per span."""
1343
+
1344
+ values = [spec] if isinstance(spec, int) else list(spec)
1345
+
1346
+ if not values:
1347
+ raise ValueError("inversions list is empty — pass at least one inversion")
1348
+
1349
+ spans = tuple(
1350
+ dataclasses.replace(span, inversion = int(values[index % len(values)]))
1351
+ for index, span in enumerate(self.spans)
1352
+ )
1353
+
1354
+ return dataclasses.replace(self, spans=spans)
1355
+
1356
+ def spread (self, style: str) -> "Progression":
1357
+
1358
+ """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``."""
1359
+
1360
+ spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans)
1361
+
1362
+ return dataclasses.replace(self, spans=spans)
1363
+
1364
+ def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression":
1365
+
1366
+ """Put the progression over a slash/pedal bass — *the* trance/techno move.
1367
+
1368
+ *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A
1369
+ note name is key-independent, so it resolves to its pitch class right
1370
+ here; ``"tonic"`` follows the key and stays relative until the
1371
+ progression is resolved. ``only=`` restricts it to the given 1-based
1372
+ slots (slash chords rather than a full pedal).
1373
+ """
1374
+
1375
+ if isinstance(bass, str) and bass != "tonic":
1376
+ bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now
1377
+ elif isinstance(bass, int) and not 0 <= bass <= 11:
1378
+ raise ValueError(f"a bass pitch class must be 0–11, got {bass}")
1379
+
1380
+ slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1381
+
1382
+ spans = tuple(
1383
+ dataclasses.replace(span, bass=bass) if index in slots else span
1384
+ for index, span in enumerate(self.spans)
1385
+ )
1386
+
1387
+ return dataclasses.replace(self, spans=spans)
1388
+
1389
+ def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression":
1390
+
1391
+ """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale.
1392
+
1393
+ Modal interchange for key-relative content: the degree re-resolves
1394
+ against the parallel mode (minor under a major scale and vice
1395
+ versa). Concrete chords raise — there is nothing relative to borrow.
1396
+ """
1397
+
1398
+ slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)}
1399
+
1400
+ spans = list(self.spans)
1401
+
1402
+ for index in slots:
1403
+ chord = spans[index].chord
1404
+ if not isinstance(chord, RomanChord):
1405
+ raise ValueError(
1406
+ f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — "
1407
+ "borrow() needs key-relative content (an int degree or roman)"
1408
+ )
1409
+ spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed))
1410
+
1411
+ return dataclasses.replace(self, spans=tuple(spans))
1412
+
1413
+ def replace (self, slot: int, chord: typing.Any) -> "Progression":
1414
+
1415
+ """Replace the chord at a 1-based slot (the span keeps its beats)."""
1416
+
1417
+ index = _check_slot(slot, len(self.spans))
1418
+ parsed = parse_element(chord, beats = self.spans[index].beats)
1419
+
1420
+ spans = self.spans[:index] + (parsed,) + self.spans[index + 1:]
1421
+
1422
+ return dataclasses.replace(self, spans=spans)
1423
+
1424
+ def cadence (self, name: str = "strong") -> "Progression":
1425
+
1426
+ """Substitute a cadence formula into the tail — the close, named.
1427
+
1428
+ The final spans take the formula's chords (``"strong"`` is V→I,
1429
+ ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names —
1430
+ authentic, plagal, half, deceptive — work as aliases). Each replaced
1431
+ span keeps its beats; its old chord and decorations go. Formula
1432
+ chords are key-relative (ints follow the bound scale's qualities,
1433
+ ``"V"`` is the major dominant by convention), so the tail resolves
1434
+ wherever the progression is bound — a concrete progression becomes
1435
+ mixed and resolves its tail at bind time, like any roman content.
1436
+
1437
+ Example::
1438
+
1439
+ verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open")
1440
+ # Bound in A minor: Am F Dm E — the half close, hanging on the dominant
1441
+
1442
+ Raises:
1443
+ ValueError: If the cadence name is unknown, or the progression
1444
+ has fewer spans than the formula.
1445
+ """
1446
+
1447
+ spec = subsequence.cadences.cadence_formula(name)
1448
+ count = len(spec.formula)
1449
+
1450
+ if len(self.spans) < count:
1451
+ raise ValueError(
1452
+ f"cadence({name!r}) substitutes the last {count} chords, but this "
1453
+ f"progression has only {len(self.spans)}"
1454
+ )
1455
+
1456
+ tail = tuple(
1457
+ parse_element(element, beats = span.beats)
1458
+ for element, span in zip(spec.formula, self.spans[-count:])
1459
+ )
1460
+
1461
+ return dataclasses.replace(self, spans = self.spans[:-count] + tail)
1462
+
1463
+ def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression":
1464
+
1465
+ """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span."""
1466
+
1467
+ if isinstance(beats, bool):
1468
+ raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}")
1469
+
1470
+ values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats]
1471
+
1472
+ if not values:
1473
+ raise ValueError("with_rhythm list is empty — pass at least one length")
1474
+
1475
+ spans = tuple(
1476
+ dataclasses.replace(span, beats = float(values[index % len(values)]))
1477
+ for index, span in enumerate(self.spans)
1478
+ )
1479
+
1480
+ return dataclasses.replace(self, spans=spans)
1481
+
1482
+ def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression":
1483
+
1484
+ """Steedman-inspired chord elaboration — approach each chord by fifths.
1485
+
1486
+ Implements the heart of Mark Steedman's generative grammar for
1487
+ jazz/blues chord sequences: every chord is **approached** by a chain
1488
+ of secondary dominants propagated backward around the cycle of fifths
1489
+ (Rule 3, "the perfect cadence propagated backward"), carved out of that
1490
+ chord's own span (Rule 1, metric subdivision). ``depth`` is literally
1491
+ how many fifth-steps back the chain extends:
1492
+
1493
+ - ``depth=0`` — identity (the bare progression).
1494
+ - ``depth=1`` — a secondary dominant before each chord: ``[X]`` →
1495
+ ``[V7/X, X]`` (e.g. a bar of C becomes G7 C).
1496
+ - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C).
1497
+ - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord
1498
+ is made minor — the ``ii`` of *its own local dominant* (the next
1499
+ link in the chain), forming a ii–V into that link, not the
1500
+ target's own ii — and dominants are recoloured by **tritone
1501
+ substitution** with even odds (Rule 4) for chromatic descents.
1502
+ This tritone choice is the only nondeterministic part, so ``seed``
1503
+ is taken (or warned) at depth ≥ 3.
1504
+
1505
+ Its flagship is the 12-bar blues with depth-per-chorus — elaborate a
1506
+ ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and
1507
+ tritone subs accumulate.
1508
+
1509
+ The progression must be **concrete** (resolved to rooted chords);
1510
+ the inserted dominants are computed by pitch-class arithmetic. Each
1511
+ chord keeps its decorations on the final (resolved) sub-span; the
1512
+ inserted approach chords are bare dominant/minor sevenths. Note that
1513
+ each span is divided into ``depth + 1`` equal sub-spans, so deep
1514
+ elaboration of a short harmonic rhythm can drop sub-spans below the
1515
+ harmony clock's lookahead floor — which raises at ``play()``/
1516
+ ``render()`` if the result is bound to the global clock (it is free
1517
+ of that floor at the part level, ``p.progression()``).
1518
+
1519
+ Parameters:
1520
+ depth: Elaboration depth (≥ 0).
1521
+ seed: Seed for the depth-≥3 tritone-substitution choices.
1522
+
1523
+ Returns:
1524
+ A new :class:`Progression` with the approach chords inserted.
1525
+
1526
+ Raises:
1527
+ ValueError: If *depth* is negative, the progression is
1528
+ key-relative, or any span is a rootless
1529
+ :class:`PitchSet`.
1530
+
1531
+ Example:
1532
+ ```python
1533
+ blues = subsequence.progression("twelve_bar_blues").resolve("C")
1534
+ chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout
1535
+ ```
1536
+ """
1537
+
1538
+ if depth < 0:
1539
+ raise ValueError("elaborate depth must be at least 0")
1540
+
1541
+ self._require_concrete("elaborate")
1542
+
1543
+ if depth == 0:
1544
+ return self
1545
+
1546
+ for span in self.spans:
1547
+ if isinstance(span.chord, PitchSet):
1548
+ raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths")
1549
+
1550
+ if depth >= 3 and seed is None:
1551
+ warnings.warn(
1552
+ "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the "
1553
+ "result survives live reload",
1554
+ stacklevel = 2,
1555
+ )
1556
+
1557
+ rng = random.Random(seed)
1558
+ new_spans: typing.List[ChordSpan] = []
1559
+
1560
+ for span in self.spans:
1561
+
1562
+ target_root = span.chord.root_pc
1563
+ sub_beats = span.beats / (depth + 1)
1564
+
1565
+ # The backward cycle-of-fifths chain, furthest-back first: chord j
1566
+ # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The
1567
+ # furthest-back (j == depth) is made minor — the ii of its OWN
1568
+ # local dominant (the next link), forming a ii–V into that link —
1569
+ # once the chain is long enough (depth >= 2) to spell one.
1570
+ for j in range(depth, 0, -1):
1571
+ root = (target_root + 7 * j) % 12
1572
+ quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th"
1573
+
1574
+ # Tritone substitution recolours a dominant to the dom7 a
1575
+ # tritone away (same guide tones, chromatic resolution).
1576
+ if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5:
1577
+ root = (root + 6) % 12
1578
+
1579
+ new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats))
1580
+
1581
+ # The target keeps its own chord and decorations, on its sub-span.
1582
+ new_spans.append(dataclasses.replace(span, beats = sub_beats))
1583
+
1584
+ return dataclasses.replace(self, spans = tuple(new_spans))
1585
+
1586
+ # -- description ----------------------------------------------------------
1587
+
1588
+ def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str:
1589
+
1590
+ """A readable, one-chord-per-line summary.
1591
+
1592
+ Key-relative spans print as written (romans/degrees) when unbound,
1593
+ and as concrete chord names under a *key*.
1594
+ """
1595
+
1596
+ key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key))
1597
+
1598
+ lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"]
1599
+ cursor = 0.0
1600
+
1601
+ for span in self.spans:
1602
+ lines.append(
1603
+ f" {cursor:6.2f} … {cursor + span.beats:6.2f} "
1604
+ f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)"
1605
+ )
1606
+ cursor += span.beats
1607
+
1608
+ return "\n".join(lines)
1609
+
1610
+ def __str__ (self) -> str:
1611
+
1612
+ """Same as :meth:`describe` with no key bound."""
1613
+
1614
+ return self.describe()
1615
+
1616
+
1617
+ # ---------------------------------------------------------------------------
1618
+ # Construction: the factory, generation, and the breathing realise() path
1619
+ # ---------------------------------------------------------------------------
1620
+
1621
+
1622
+ def _span_lengths (beats: typing.Union[float, typing.List[float]], count: int) -> typing.List[float]:
1623
+
1624
+ """Resolve a beats= spec into per-span lengths — a scalar for all, or a list cycled."""
1625
+
1626
+ if isinstance(beats, bool):
1627
+ raise TypeError(f"beats takes a number or a list of lengths, got bool: {beats!r}")
1628
+
1629
+ if isinstance(beats, (int, float)):
1630
+ return [float(beats)] * count
1631
+
1632
+ values = [float(b) for b in beats]
1633
+
1634
+ if not values:
1635
+ raise ValueError("beats list is empty — pass at least one length")
1636
+
1637
+ return [values[index % len(values)] for index in range(count)]
1638
+
1639
+
1640
+ def progression (
1641
+ source: typing.Optional[typing.Any] = None,
1642
+ beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS,
1643
+ *,
1644
+ style: typing.Optional[str] = None,
1645
+ bars: int = 8,
1646
+ key: typing.Optional[str] = None,
1647
+ scale: typing.Optional[str] = None,
1648
+ seed: typing.Optional[int] = None,
1649
+ rng: typing.Optional[random.Random] = None,
1650
+ pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1651
+ end: typing.Optional[typing.Any] = None,
1652
+ avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1653
+ cadence: typing.Optional[str] = None,
1654
+ dominant_7th: bool = True,
1655
+ gravity: float = 1.0,
1656
+ nir_strength: float = 0.5,
1657
+ minor_turnaround_weight: float = 0.0,
1658
+ root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1659
+ ) -> Progression:
1660
+
1661
+ """Build a :class:`Progression` — the lowercase factory.
1662
+
1663
+ Dispatch by argument type: a **list** parses per element (ints where
1664
+ diatonic, name/roman strings where nominal/chromatic,
1665
+ ``(element, beats)`` tuples for per-chord durations); a bare **string** names a
1666
+ preset from the curated table; ``style=`` generates *bars* chords from a
1667
+ chord-graph walk (requires ``key=``).
1668
+
1669
+ Parameters:
1670
+ source: The element list, preset name, or an existing Progression
1671
+ (returned unchanged).
1672
+ beats: Span length per chord — a scalar, or a list cycled per chord
1673
+ (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm).
1674
+ style: A chord-graph style name to generate from (e.g.
1675
+ ``"aeolian_minor"``).
1676
+ bars: How many chords to generate (style mode only).
1677
+ key: Key for style generation.
1678
+ seed: Seed for style generation. A standalone generated value
1679
+ without a seed warns — module-level nondeterminism breaks live
1680
+ reload.
1681
+ rng: An explicit random stream (overrides ``seed``; used by
1682
+ engine-mediated calls).
1683
+ dominant_7th / gravity / nir_strength: Graph-walk parameters,
1684
+ matching :meth:`Composition.harmony` (style mode only; full
1685
+ pass-through arrives with ``Progression.generate``).
1686
+
1687
+ Example:
1688
+ ```python
1689
+ verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor
1690
+ blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"])
1691
+ walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3)
1692
+ ```
1693
+ """
1694
+
1695
+ if style is not None:
1696
+ if source is not None:
1697
+ raise ValueError("pass either source or style=, not both")
1698
+ return Progression.generate(
1699
+ style = style,
1700
+ bars = bars,
1701
+ beats = beats,
1702
+ key = key,
1703
+ scale = scale,
1704
+ seed = seed,
1705
+ rng = rng,
1706
+ pins = pins,
1707
+ end = end,
1708
+ avoid = avoid,
1709
+ cadence = cadence,
1710
+ dominant_7th = dominant_7th,
1711
+ gravity = gravity,
1712
+ nir_strength = nir_strength,
1713
+ minor_turnaround_weight = minor_turnaround_weight,
1714
+ root_diversity = root_diversity,
1715
+ )
1716
+
1717
+ # Generation-only knobs are meaningless for a concrete source — reject
1718
+ # them so a musician asking for cadence= or key= on a list gets a usable
1719
+ # error instead of a silent no-op.
1720
+ generation_only = {
1721
+ "bars": bars != 8,
1722
+ "key": key is not None,
1723
+ "scale": scale is not None,
1724
+ "seed": seed is not None,
1725
+ "rng": rng is not None,
1726
+ "pins": pins is not None,
1727
+ "end": end is not None,
1728
+ "avoid": avoid is not None,
1729
+ "cadence": cadence is not None,
1730
+ "dominant_7th": dominant_7th is not True,
1731
+ "gravity": gravity != 1.0,
1732
+ "nir_strength": nir_strength != 0.5,
1733
+ "minor_turnaround_weight": minor_turnaround_weight != 0.0,
1734
+ "root_diversity": root_diversity != subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1735
+ }
1736
+ passed = [name for name, was_set in generation_only.items() if was_set]
1737
+
1738
+ if passed:
1739
+ raise ValueError(
1740
+ f"{', '.join(sorted(passed))} only apply when generating with style=. "
1741
+ "A concrete progression takes these as methods instead — e.g. "
1742
+ ".cadence('strong') for the close, and the key binds at "
1743
+ "composition.harmony() / resolve() time."
1744
+ )
1745
+
1746
+ if isinstance(source, Progression):
1747
+ return source
1748
+
1749
+ if isinstance(source, str):
1750
+ if source in _PRESETS:
1751
+ return progression(_PRESETS[source], beats=beats)
1752
+ known = ", ".join(sorted(_PRESETS))
1753
+ raise ValueError(
1754
+ f"Unknown progression preset {source!r}. Known presets: {known}. "
1755
+ "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])."
1756
+ )
1757
+
1758
+ if source is None:
1759
+ raise ValueError("progression() needs a source list (or style=...)")
1760
+
1761
+ elements = list(source)
1762
+
1763
+ if not elements:
1764
+ raise ValueError("progression list is empty — pass at least one chord")
1765
+
1766
+ lengths = _span_lengths(beats, len(elements))
1767
+
1768
+ return Progression(spans = tuple(
1769
+ parse_element(element, beats=lengths[index])
1770
+ for index, element in enumerate(elements)
1771
+ ))
1772
+
1773
+
1774
+ def _chord_source (source: ProgressionSource, key: typing.Optional[str], rng: random.Random, scale: str = "ionian") -> typing.Iterator[typing.Any]:
1775
+
1776
+ """Yield chords indefinitely — generated from a graph style, or cycled from a list."""
1777
+
1778
+ if isinstance(source, str):
1779
+ if not key:
1780
+ raise ValueError(f"progression style {source!r} needs a key — pass key= or set the Composition key")
1781
+ state = subsequence.harmonic_state.HarmonicState(key_name=key, graph_style=source, rng=rng)
1782
+ yield state.current_chord
1783
+ while True:
1784
+ yield state.step()
1785
+
1786
+ elif isinstance(source, Progression):
1787
+ resolved = source if source.is_concrete else source.resolve(
1788
+ subsequence.chords.key_name_to_pc(_require_key(source, key)), scale
1789
+ )
1790
+ index = 0
1791
+ while True:
1792
+ span = resolved.spans[index % len(resolved.spans)]
1793
+ yield DecoratedChord(span) if span.is_decorated else span.chord
1794
+ index += 1
1795
+
1796
+ else:
1797
+ spans = [parse_element(item) for item in source]
1798
+ if not spans:
1799
+ raise ValueError("progression list is empty — pass at least one chord")
1800
+ chords = []
1801
+ for span in spans:
1802
+ if not span.is_concrete:
1803
+ span = span.resolve(subsequence.chords.key_name_to_pc(_require_key(span, key)), scale)
1804
+ chords.append(DecoratedChord(span) if span.is_decorated else span.chord)
1805
+ index = 0
1806
+ while True:
1807
+ yield chords[index % len(chords)]
1808
+ index += 1
1809
+
1810
+
1811
+ def _require_key (what: typing.Any, key: typing.Optional[str]) -> str:
1812
+
1813
+ """Return the key or raise the standard relative-content error."""
1814
+
1815
+ if not key:
1816
+ raise ValueError(
1817
+ "this progression contains key-relative content (degrees, romans, or a "
1818
+ "'tonic' pedal bass) — pass key= or set the Composition key"
1819
+ )
1820
+
1821
+ return key
1822
+
1823
+
1824
+ def _resolve_length (spec: HarmonicRhythmSpec, index: int, rng: random.Random) -> float:
1825
+
1826
+ """Resolve one chord length in beats from a harmonic-rhythm spec.
1827
+
1828
+ Accepts a scalar (static), a list/tuple of lengths (a shaped rhythm, cycled by
1829
+ ``index``), or a :class:`~subsequence.harmonic_rhythm.HarmonicRhythm` from
1830
+ ``between(...)``. Mirrors the ``(low, high)``-tuple house idiom used for
1831
+ velocity — except a range here is spelled ``between(...)`` so a bare list can
1832
+ mean a *sequence* of lengths.
1833
+ """
1834
+
1835
+ if isinstance(spec, subsequence.harmonic_rhythm.HarmonicRhythm):
1836
+ return spec.resolve(rng)
1837
+ if isinstance(spec, tuple): # type: ignore # the hint says list, but users pass a tuple anyway — guard for it
1838
+ # A (low, high) tuple means a random range everywhere else in the API (e.g.
1839
+ # velocity); here it would silently cycle. Reject it so the intent is explicit.
1840
+ raise ValueError(
1841
+ f"harmonic_rhythm tuple {spec!r} is ambiguous — use between{spec!r} for a random "
1842
+ f"range, or a list {list(spec)!r} for a repeating sequence of lengths"
1843
+ )
1844
+ if isinstance(spec, list):
1845
+ if not spec:
1846
+ raise ValueError("harmonic_rhythm sequence is empty — pass at least one length")
1847
+ return float(spec[index % len(spec)])
1848
+ if isinstance(spec, bool):
1849
+ raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got bool: {spec!r}")
1850
+ if isinstance(spec, (int, float)):
1851
+ return float(spec)
1852
+ raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got {type(spec).__name__}")
1853
+
1854
+
1855
+ def resolve_voices (voicing: VoicingSpec, rng: random.Random) -> int:
1856
+
1857
+ """Resolve the voice count for one chord — a fixed int, or a ``(low, high)`` draw."""
1858
+
1859
+ if isinstance(voicing, bool):
1860
+ raise TypeError(f"voicing must be an int or a (low, high) tuple, got bool: {voicing!r}")
1861
+ if isinstance(voicing, int):
1862
+ count = voicing
1863
+ elif isinstance(voicing, tuple):
1864
+ if len(voicing) != 2:
1865
+ raise ValueError(f"voicing tuple must be (low, high), got {voicing!r}")
1866
+ count = rng.randint(int(voicing[0]), int(voicing[1]))
1867
+ else:
1868
+ raise TypeError(f"voicing must be an int or a (low, high) tuple, got {type(voicing).__name__}")
1869
+ if count < 1:
1870
+ raise ValueError(f"voicing must be at least 1 voice, got {count}")
1871
+ return count
1872
+
1873
+
1874
+ def realize (
1875
+ source: ProgressionSource,
1876
+ harmonic_rhythm: HarmonicRhythmSpec,
1877
+ key: typing.Optional[str],
1878
+ length: float,
1879
+ rng: random.Random,
1880
+ scale: str = "ionian",
1881
+ ) -> Progression:
1882
+
1883
+ """Lay a progression out across *length* beats and return a frozen value.
1884
+
1885
+ Walks the chord source, giving each chord a harmonic-rhythm length, until the
1886
+ part is full. The final chord is trimmed so the timeline ends exactly on
1887
+ *length* and therefore loops cleanly. Voicing and articulation are not decided
1888
+ here — they belong to whatever places the chords (the verb you call in the loop,
1889
+ or :meth:`Composition.chords`).
1890
+ """
1891
+
1892
+ if length <= 0:
1893
+ raise ValueError(f"progression length ({length:g}) must be positive")
1894
+
1895
+ stream = _chord_source(source, key, rng, scale)
1896
+ spans: typing.List[ChordSpan] = []
1897
+ cursor = 0.0
1898
+ index = 0
1899
+
1900
+ while cursor < length - 1e-9:
1901
+ chord = next(stream)
1902
+ duration = _resolve_length(harmonic_rhythm, index, rng)
1903
+ if duration <= 0:
1904
+ raise ValueError(f"harmonic_rhythm produced a non-positive length ({duration:g}) — lengths are in beats and must be > 0")
1905
+ duration = min(duration, length - cursor)
1906
+ if isinstance(chord, DecoratedChord):
1907
+ spans.append(dataclasses.replace(chord.span, beats=duration))
1908
+ else:
1909
+ spans.append(ChordSpan(chord=chord, beats=duration))
1910
+ cursor += duration
1911
+ index += 1
1912
+
1913
+ return Progression(spans = tuple(spans))