subsequence 0.6.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subsequence/__init__.py +231 -0
- subsequence/__main__.py +24 -0
- subsequence/assets/web/index.html +345 -0
- subsequence/cadences.py +113 -0
- subsequence/chord_graphs/__init__.py +100 -0
- subsequence/chord_graphs/aeolian_minor.py +158 -0
- subsequence/chord_graphs/chromatic_mediant.py +113 -0
- subsequence/chord_graphs/diminished.py +97 -0
- subsequence/chord_graphs/dorian_minor.py +127 -0
- subsequence/chord_graphs/functional_major.py +102 -0
- subsequence/chord_graphs/hooktheory_major.py +88 -0
- subsequence/chord_graphs/lydian_major.py +130 -0
- subsequence/chord_graphs/mixolydian.py +98 -0
- subsequence/chord_graphs/phrygian_minor.py +76 -0
- subsequence/chord_graphs/suspended.py +109 -0
- subsequence/chord_graphs/turnaround_global.py +157 -0
- subsequence/chord_graphs/whole_tone.py +77 -0
- subsequence/chords.py +419 -0
- subsequence/composition.py +6099 -0
- subsequence/conductor.py +238 -0
- subsequence/constants/__init__.py +24 -0
- subsequence/constants/durations.py +37 -0
- subsequence/constants/instruments/__init__.py +13 -0
- subsequence/constants/instruments/gm_cc.py +46 -0
- subsequence/constants/instruments/gm_drums.py +53 -0
- subsequence/constants/instruments/gm_instruments.py +32 -0
- subsequence/constants/instruments/roland_tr8s.py +320 -0
- subsequence/constants/instruments/vermona_drm1_drums.py +87 -0
- subsequence/constants/midi_notes.py +29 -0
- subsequence/constants/pulses.py +17 -0
- subsequence/constants/velocity.py +22 -0
- subsequence/definitions.py +232 -0
- subsequence/display.py +617 -0
- subsequence/easing.py +347 -0
- subsequence/event_emitter.py +109 -0
- subsequence/form_state.py +665 -0
- subsequence/forms.py +257 -0
- subsequence/groove.py +323 -0
- subsequence/harmonic_rhythm.py +83 -0
- subsequence/harmonic_state.py +352 -0
- subsequence/harmony.py +197 -0
- subsequence/held_notes.py +91 -0
- subsequence/helpers/__init__.py +0 -0
- subsequence/helpers/network.py +58 -0
- subsequence/helpers/wing.py +430 -0
- subsequence/intervals.py +436 -0
- subsequence/keystroke.py +249 -0
- subsequence/link_clock.py +128 -0
- subsequence/live_client.py +187 -0
- subsequence/live_reloader.py +298 -0
- subsequence/live_server.py +161 -0
- subsequence/melodic_state.py +483 -0
- subsequence/midi.py +97 -0
- subsequence/midi_utils.py +329 -0
- subsequence/mini_notation.py +164 -0
- subsequence/motifs.py +2356 -0
- subsequence/osc.py +194 -0
- subsequence/pattern.py +363 -0
- subsequence/pattern_algorithmic.py +2010 -0
- subsequence/pattern_builder.py +2589 -0
- subsequence/pattern_midi.py +1208 -0
- subsequence/progressions.py +1913 -0
- subsequence/py.typed +0 -0
- subsequence/roles.py +63 -0
- subsequence/sequence_utils.py +3123 -0
- subsequence/sequencer.py +2086 -0
- subsequence/tuning.py +453 -0
- subsequence/voicings.py +151 -0
- subsequence/web_ui.py +337 -0
- subsequence/weighted_graph.py +156 -0
- subsequence-0.6.4.dist-info/METADATA +208 -0
- subsequence-0.6.4.dist-info/RECORD +78 -0
- subsequence-0.6.4.dist-info/WHEEL +5 -0
- subsequence-0.6.4.dist-info/entry_points.txt +2 -0
- subsequence-0.6.4.dist-info/licenses/LICENSE +661 -0
- subsequence-0.6.4.dist-info/scm_file_list.json +193 -0
- subsequence-0.6.4.dist-info/scm_version.json +8 -0
- subsequence-0.6.4.dist-info/top_level.txt +1 -0
|
@@ -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))
|