Source code for subsequence.progressions

"""Progressions — chord sequences laid out in time, as a governing value.

The one progression type: a frozen tuple of :class:`ChordSpan` — replacing the
old engine ``Progression`` (the ``freeze()`` capture) and ``ChordTimeline``
(the realised iterable) with a single value that is constructible, queryable,
transformable, and bindable to the harmonic clock.

Construction (the standard form — lists, parsed per element):

	subsequence.progression([1, 6, 3, 7])                    # diatonic degrees
	subsequence.progression([1, 6, 3, "bVII7"])              # romans where chromatic
	subsequence.progression(["Am", "F", "C", "G"])           # chord names
	subsequence.progression([("Am", 4), ("F", 2)])           # per-chord beats
	subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3)

Key-relative content (ints and romans) stays relative inside the value and
resolves at query time — change the key once, everything follows.  Spice
transforms (``extend``, ``inversions``, ``spread``, ``over``, ``borrow``)
decorate the spans, never the chords: the engine's currency stays the bare
``(root_pc, quality)`` triad, and decoration travels with the span to the
voicing layer.
"""

import dataclasses
import random
import re
import typing
import warnings

import subsequence.cadences
import subsequence.chords
import subsequence.harmonic_rhythm
import subsequence.harmonic_state
import subsequence.intervals
import subsequence.sequence_utils
import subsequence.voicings


# A progression source is either a built-in chord-graph style name (generated)
# or an explicit, ordered list of elements — ints, chord names, romans, Chord
# objects, or (element, beats) tuples.
ProgressionSource = typing.Union[str, "Progression", typing.Sequence[typing.Any]]

# A harmonic-rhythm spec is a single length (static), a list of lengths (a shaped
# rhythm, cycled per chord), or a between(...) range (bounded, optionally quantised).
HarmonicRhythmSpec = typing.Union[int, float, typing.List[float], subsequence.harmonic_rhythm.HarmonicRhythm]

# Voicing density: a fixed number of voices, or a (low, high) random range per chord.
VoicingSpec = typing.Union[int, typing.Tuple[int, int]]

# One bar per chord by default.  The value is context-free, so "a bar" is the
# common-time default; pass beats= for anything else.
DEFAULT_SPAN_BEATS: float = 4.0

# Genre preset table — ``progression("name")`` looks a recognizable, genre-
# tagged loop up here.  Elements are roman numerals (key-relative, quality
# explicit), so a preset resolves under whatever key it is bound to.  Curated,
# not a catalogue (per the design's "curated small presets over catalogs"), and
# all-distinct.  NOTE: minor/modal loops spell their non-major-diatonic chords
# with a FLAT accidental (bVI, bIII, bVII) — the scale-proof "major-relative"
# form, so they resolve to the same chords under any scale, NOT bare uppercase
# numerals (which would read the resolution scale's degree and come out wrong
# under the default ionian).
_PRESETS: typing.Dict[str, typing.List[typing.Any]] = {
	# Pop / rock.
	"pop_axis":           ["I", "V", "vi", "IV"],            # the "Axis of Awesome" four chords
	"pop_axis_vi_start":  ["vi", "IV", "I", "V"],            # the axis rotated to a minor start
	"doo_wop":            ["I", "vi", "IV", "V"],            # the 50s progression
	"doo_wop_ii":         ["I", "vi", "ii", "V"],            # doo-wop with ii for IV
	# EDM / trance / cinematic minor.
	"trance_epic":        ["i", "bVI", "bIII", "bVII"],      # uplifting-trance minor loop
	"trance_vamp":        ["i", "bVII", "bVI", "bVII"],      # hypnotic two-direction minor vamp
	"minor_pop":          ["i", "bVI", "bVII", "i"],         # aeolian rise back to the tonic
	# Minor / modal.
	"andalusian":         ["i", "bVII", "bVI", "V"],         # the Andalusian cadence (descending tetrachord)
	"phrygian_vamp":      ["i", "bII", "i", "bII"],          # the Spanish/Phrygian half-step
	"dorian_vamp":        ["i", "IV"],                       # minor tonic to the major (dorian) IV
	"mixolydian_vamp":    ["I", "bVII", "IV", "I"],          # the classic-rock bVII
	# Jazz.
	"ii_v_i":             ["ii7", "V7", "Imaj7"],            # the major ii-V-I
	"ii_v_i_minor":       ["iiø7", "V7", "i"],               # the minor ii-V-i
	"rhythm_changes_a":   ["I", "vi", "ii", "V", "I", "vi", "ii", "V", "I", "I7", "IV", "iv", "I", "V", "I", "I"],
	# Blues.
	"twelve_bar_blues":   ["I7", "I7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"],
	"quick_change_blues": ["I7", "IV7", "I7", "I7", "IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "V7"],
	# Classic / baroque loops.
	"pachelbel":          ["I", "V", "vi", "iii", "IV", "I", "IV", "V"],          # Pachelbel's Canon
	"pachelbel_minor":    ["i", "v", "bVI", "bIII", "iv", "i", "iv", "V"],        # its minor rendering
}


[docs] class ChordEvent (typing.NamedTuple): """One chord on a realised timeline: which chord, when it starts, and how long it lasts (in beats from the start of the part). A ``NamedTuple``, so it unpacks positionally as ``(chord, start, length)`` — the idiom for looping a progression — while also offering ``.chord`` / ``.start`` / ``.length`` attribute access. """ chord: typing.Any start: float length: float
# --------------------------------------------------------------------------- # Leaf values: PitchSet and RomanChord # --------------------------------------------------------------------------- @dataclasses.dataclass(frozen=True)
[docs] class PitchSet: """A nameless sonority — a frozen set of absolute MIDI pitches. The escape hatch for chords with no root or quality: clusters, spectral stacks, found objects. It duck-types ``.tones()`` so every placement verb and the injected ``chord`` accept it unchanged. By design it is excluded from generation and diatonic spice (there is nothing to transpose diatonically), and a progression containing one loops on exhaustion rather than falling through to live graph stepping. Pitches are absolute: ``tones()`` ignores its ``root`` argument — you chose the register when you chose the pitches. """ pitches: typing.Tuple[int, ...] def __init__ (self, pitches: typing.Iterable[int]) -> None: """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" values = tuple(sorted(int(p) for p in pitches)) if not values: raise ValueError("PitchSet needs at least one pitch") object.__setattr__(self, "pitches", values)
[docs] def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: """Return the pitches (absolute — *root* is ignored by design). ``inversion`` rotates pitches up an octave; ``count`` cycles the set into higher octaves, matching the ``Chord.tones`` contract. """ pitches = list(self.pitches) if inversion != 0: for _ in range(inversion % len(pitches)): pitches.append(pitches.pop(0) + 12) if count is not None: n = len(pitches) return [pitches[i % n] + 12 * (i // n) for i in range(count)] return pitches
[docs] def intervals (self) -> typing.List[int]: """Semitone offsets from the lowest pitch (the ``Chord`` protocol).""" return [p - self.pitches[0] for p in self.pitches]
[docs] def name (self) -> str: """A readable label for describe() output.""" return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")"
@dataclasses.dataclass(frozen=True)
[docs] class RomanChord: """A key-relative chord — a scale degree with optional explicit quality. Internal: users only ever meet it as an int or roman string element inside a progression list. It stays relative inside the value and resolves to a concrete :class:`~subsequence.chords.Chord` at query time against a key and scale. Attributes: degree: 1-based scale degree. accidental: -1 for a ``b`` prefix, +1 for ``#``. An accidental- prefixed degree reads against the **major** scale, the universal roman convention — ``bVII`` is always the whole step below the tonic (Bb in C major, G in A minor); unprefixed degrees read the current scale (``VII`` in A minor is already G). quality: Explicit quality name, or ``None`` to infer diatonically from the key and scale (the bare-int path). of: Secondary-function target degree (``V/x`` — one level only). The numeral resolves against the major scale on the target's root, the common-practice reading. borrowed: When True, the degree resolves against the parallel scale (modal interchange) — set by :meth:`Progression.borrow`. source_text: The element as written, for unbound ``describe()``. major_relative: When True, the degree always reads the major scale (with the accidental applied), whatever scale ``resolve()`` is given — the scale-proof spelling :meth:`Progression.generate` emits, where quality is always explicit and the resolve scale must not re-interpret the root. """
[docs] degree: int
[docs] accidental: int = 0
[docs] quality: typing.Optional[str] = None
[docs] of: typing.Optional[int] = None
[docs] borrowed: bool = False
[docs] source_text: str = ""
[docs] major_relative: bool = False
def __post_init__ (self) -> None: """Validate the degree (1-based, like everything a musician counts).""" if self.degree < 1: raise ValueError(f"scale degree must be 1 or higher, got {self.degree}")
[docs] def label (self) -> str: """The element as written (for unbound describe() output).""" return self.source_text or str(self.degree)
[docs] def resolve (self, key_pc: int, scale: str = "ionian") -> subsequence.chords.Chord: """Resolve to a concrete chord against a key and scale. Raises: ValueError: If the scale is unknown, the degree exceeds the scale, or quality inference is needed but the scale has no chord qualities registered. """ mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale) if mode not in subsequence.intervals.SCALE_MODE_MAP: available = ", ".join(sorted(subsequence.intervals.SCALE_MODE_MAP.keys())) raise ValueError(f"Unknown scale: {mode!r}. Available: {available}") if self.of is not None: # Secondary function: resolve the target degree's root, then read # this numeral against the major scale on that root. target = RomanChord(degree=self.of) target_chord = target.resolve(key_pc, scale) return dataclasses.replace(self, of=None).resolve(target_chord.root_pc, "ionian") pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) if self.degree > len(pcs): raise ValueError( f"scale degree {self.degree} is out of range for {mode!r} " f"({len(pcs)} degrees)" ) if self.accidental != 0 or self.major_relative: # Accidental-prefixed degrees read against the major scale — the # universal roman convention (bVII is the whole step below tonic # in every key, major or minor). Generated spans set # major_relative so their spelling is scale-proof. major_pcs = subsequence.intervals.scale_pitch_classes(key_pc, "ionian") root_pc = (major_pcs[(self.degree - 1) % len(major_pcs)] + self.accidental) % 12 else: root_pc = pcs[self.degree - 1] % 12 if self.quality is not None: return subsequence.chords.Chord(root_pc=root_pc, quality=self.quality) _, qualities = subsequence.intervals.SCALE_MODE_MAP[mode] if qualities is None: raise ValueError( f"Scale {mode!r} has no chord qualities defined, so degree " f"{self.degree} cannot be inferred. Use register_scale(..., " "qualities=[...]) or write the chord name explicitly." ) return subsequence.chords.Chord(root_pc=root_pc, quality=qualities[self.degree - 1])
[docs] def diatonic_extension_intervals ( self, key_pc: int, scale: str, extensions: typing.Tuple[typing.Any, ...], ) -> typing.Tuple[int, ...]: """Stack diatonic thirds above the triad for numeric extensions. Only meaningful for inferred-quality degrees (the bare-int path): ``extend(7)`` on V in C major yields F natural (a dominant seventh), where the colour rule on a concrete G chord would yield F#. """ mode = "minor" if self.borrowed and scale != "minor" else ("ionian" if self.borrowed else scale) pcs = subsequence.intervals.scale_pitch_classes(key_pc, mode) root_pc = (pcs[self.degree - 1] + self.accidental) % 12 intervals: typing.List[int] = [] for extension in extensions: if not isinstance(extension, int): continue # sus/add forms are scale-independent — the colour path handles them # 7 → six scale steps above the root; 9 → eight; 11 → ten; 13 → twelve. steps = {7: 6, 9: 8, 11: 10, 13: 12}.get(extension) if steps is None: continue pc = pcs[(self.degree - 1 + steps) % len(pcs)] octave = 0 if extension == 7 else 12 # 9ths/11ths/13ths live above the octave intervals.append(((pc - root_pc) % 12) + octave) return tuple(sorted(set(intervals)))
# Major-relative spelling of every pitch-class offset from the tonic — the # pop/rock roman convention (b3, b6, b7; b2 and b5 for the rest). Used by # generation to emit scale-proof RomanChords. _OFFSET_SPELLING: typing.Dict[int, typing.Tuple[int, int]] = { 0: (1, 0), 1: (2, -1), 2: (2, 0), 3: (3, -1), 4: (3, 0), 5: (4, 0), 6: (5, -1), 7: (5, 0), 8: (6, -1), 9: (6, 0), 10: (7, -1), 11: (7, 0), } _ROMAN_NUMERALS: typing.Tuple[str, ...] = ("I", "II", "III", "IV", "V", "VI", "VII") # The scale each built-in graph style implies — used to infer qualities for # int constraints (end=1) and as generation's default resolve scale. Styles # without diatonic quality rows fall back to ionian. _STYLE_SCALES: typing.Dict[str, str] = { "functional_major": "ionian", "diatonic_major": "ionian", "hooktheory_major": "ionian", "pop_major": "ionian", "turnaround": "ionian", "turnaround_global": "ionian", "aeolian_minor": "minor", "phrygian_minor": "phrygian", "lydian_major": "lydian", "dorian_minor": "dorian", "mixolydian": "mixolydian", } # Quality → (prints lowercase, printable suffix). Qualities outside the # table print as an explicit parenthesised tail. _ROMAN_QUALITY_TEXT: typing.Dict[str, typing.Tuple[bool, str]] = { "major": (False, ""), "minor": (True, ""), "dominant_7th": (False, "7"), "minor_7th": (True, "7"), "major_7th": (False, "maj7"), "diminished": (True, "°"), "diminished_7th": (True, "°7"), "half_diminished_7th": (True, "ø7"), "augmented": (False, "+"), }
[docs] def resolve_constraint (spec: typing.Any, key_pc: int, scale: str, what: str) -> subsequence.chords.Chord: """Parse one hybrid-constraint spec (pin/end/avoid) into a concrete chord. Specs follow the progression-element grammar: ints are diatonic degrees (quality inferred from *scale*), strings are chord names or romans, ``Chord`` objects pass through. ``PitchSet`` objects are rejected — generation needs rooted chords. """ parsed = parse_element(spec).chord if isinstance(parsed, PitchSet): raise ValueError(f"{what}: generation needs rooted chords — a PitchSet cannot constrain the walk") if isinstance(parsed, RomanChord): return parsed.resolve(key_pc, scale) return typing.cast(subsequence.chords.Chord, parsed)
[docs] def cadence_pins ( name: str, bars: int, pins: typing.Optional[typing.Dict[int, typing.Any]], end: typing.Optional[typing.Any], ) -> typing.Dict[int, typing.Any]: """Compile a cadence name into pins on the final bars of a walk. The shared translation for ``Progression.generate(cadence=)`` and ``Composition.freeze(cadence=)``: the formula occupies the last bars, merged with the caller's own pins. Conflicts raise loudly — ``end=`` and a pin on a formula bar both name what the cadence already fixes. """ spec = subsequence.cadences.cadence_formula(name) count = len(spec.formula) if end is not None: raise ValueError( f"cadence={name!r} already fixes the final bar — it conflicts with end={end!r} " "(drop end=, or spell the tail yourself with pins=)" ) if bars < count: raise ValueError(f"cadence={name!r} needs {count} bars for its formula, but bars={bars}") merged = dict(pins or {}) for offset, element in enumerate(spec.formula): position = bars - count + 1 + offset if position in merged: raise ValueError( f"cadence={name!r} fixes bar {position}, which conflicts with " f"pins[{position}]={merged[position]!r}" ) merged[position] = element return merged
def _roman_from_chord (chord: subsequence.chords.Chord, tonic_pc: int) -> RomanChord: """Spell a concrete chord relative to a tonic, scale-proof. The inverse of resolution for generated values: the root becomes a major-relative degree (accidentals for the chromatic offsets), the quality stays explicit, and ``source_text`` carries a printable roman for unbound ``describe()``. """ offset = (chord.root_pc - tonic_pc) % 12 degree, accidental = _OFFSET_SPELLING[offset] lowercase, suffix = _ROMAN_QUALITY_TEXT.get(chord.quality, (False, f"({chord.quality})")) numeral = _ROMAN_NUMERALS[degree - 1] text = ("b" if accidental < 0 else "#" if accidental > 0 else "") + (numeral.lower() if lowercase else numeral) + suffix return RomanChord( degree = degree, accidental = accidental, quality = chord.quality, source_text = text, major_relative = True, ) # --------------------------------------------------------------------------- # ChordSpan — the unit the clock walks # --------------------------------------------------------------------------- _EXTENSION_NAMES: typing.FrozenSet[str] = frozenset({"sus2", "sus4", "add9", "6"}) _NUMERIC_EXTENSIONS: typing.FrozenSet[int] = frozenset({7, 9, 11, 13}) _SPREAD_STYLES: typing.FrozenSet[str] = frozenset({"close", "open", "wide"}) @dataclasses.dataclass(frozen=True)
[docs] class ChordSpan: """One chord with a duration and its decoration — the unit of harmonic time. Decoration (extensions, slash bass, inversion, spread) lives HERE, never on :class:`~subsequence.chords.Chord`: the engine's graph identity stays the bare triad, and the decorated voicing is what patterns hear. Attributes: chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a :class:`PitchSet`. beats: Span length in beats. extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). bass: Slash/pedal bass — a pitch class int, a note name, or ``"tonic"`` (resolved against the key at query time). inversion: Chord inversion for the voicing (0 = root position). spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), or ``"wide"`` (drop-2-and-4). extension_intervals: Pre-computed semitone offsets for the extensions, set by :meth:`Progression.resolve` for diatonic degrees. ``None`` means "derive from the chord's own colour". """
[docs] chord: typing.Any
[docs] beats: float
[docs] extensions: typing.Tuple[typing.Any, ...] = ()
[docs] bass: typing.Optional[typing.Union[int, str]] = None
[docs] inversion: int = 0
[docs] spread: typing.Optional[str] = None
[docs] extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None
def __post_init__ (self) -> None: """Validate beats, extensions, and spread.""" if self.beats <= 0: raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}") for extension in self.extensions: if isinstance(extension, bool) or not ( (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) or (isinstance(extension, str) and extension in _EXTENSION_NAMES) ): known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) raise ValueError(f"unknown extension {extension!r} — expected one of: {known}") if self.spread is not None and self.spread not in _SPREAD_STYLES: raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES))) @property
[docs] def is_concrete (self) -> bool: """True when the chord (and any pedal bass) needs no key context to sound. A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not concrete until :meth:`resolve` pins it to a key. Note-name basses are resolved to a pitch class eagerly in :meth:`Progression.over`, so they never linger here as strings. """ return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str)
@property
[docs] def is_decorated (self) -> bool: """True when the span carries any decoration beyond the bare chord.""" return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None
[docs] def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": """Return a concrete span: romans resolved, bass resolved to a pitch class.""" chord = self.chord extension_intervals = self.extension_intervals if isinstance(chord, RomanChord): if chord.quality is None and any(isinstance(e, int) for e in self.extensions): extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) chord = chord.resolve(key_pc, scale) bass: typing.Optional[typing.Union[int, str]] = self.bass if isinstance(bass, str): if bass == "tonic": bass = key_pc else: bass = subsequence.chords.key_name_to_pc(bass) return dataclasses.replace( self, chord = chord, bass = bass, extension_intervals = extension_intervals, )
[docs] def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: """A printable chord label: roman text when relative, decorated name when concrete.""" if isinstance(self.chord, RomanChord): if key_pc is None: text = self.chord.label() return text + self._decoration_suffix(resolved=False) return self.resolve(key_pc, scale).label() base = str(self.chord.name()) return base + self._decoration_suffix(resolved=True)
def _decoration_suffix (self, resolved: bool) -> str: """The printable decoration tail (extensions and slash bass).""" parts = "" numeric = sorted(e for e in self.extensions if isinstance(e, int)) # 9 implies 7 (and so on up): print only the highest stacked extension. stacked = [e for e in numeric if e in (7, 9, 11, 13)] if stacked: parts += str(stacked[-1]) for name in (e for e in self.extensions if isinstance(e, str)): parts += name if self.bass is not None: if isinstance(self.bass, int): parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] else: parts += "/" + str(self.bass) return parts
[docs] def decorated_intervals (self) -> typing.List[int]: """Semitone offsets of the decorated voicing (before inversion/spread/bass). Numeric extensions deepen the chord in its own colour — a minor third gets a minor seventh, a major third a major seventh, a diminished triad a diminished seventh. Diatonic degrees extended with ``extend(...)`` carry pre-computed scale-true intervals instead (so V gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the dominant colour on a concrete major chord. """ if isinstance(self.chord, RomanChord): raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") intervals = list(self.chord.intervals()) sus = [e for e in self.extensions if e in ("sus2", "sus4")] if sus and len(intervals) >= 2: intervals[1] = 2 if sus[0] == "sus2" else 5 numeric = sorted(e for e in self.extensions if isinstance(e, int)) if self.extension_intervals is not None: added: typing.List[int] = list(self.extension_intervals) else: added = [] third = intervals[1] if len(intervals) >= 2 else None has_seventh = any(i in (9, 10, 11) for i in intervals) stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] if stacked and not has_seventh: if third == 3 and len(intervals) >= 3 and intervals[2] == 6: added.append(9) # diminished colour elif third == 3: added.append(10) # minor colour elif third == 4: added.append(11) # major colour else: added.append(10) # sus / no third: the dominant-leaning seventh for extension in stacked: if extension == 9: added.append(14) elif extension == 11: added.append(17) elif extension == 13: added.append(21) if "add9" in self.extensions: added.append(14) if "6" in self.extensions: added.append(9) return sorted(set(intervals) | set(added))
[docs] def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: """MIDI notes of the decorated voicing nearest *root* (concrete spans only). Applies, in order: extensions, inversion, spread, then the slash/pedal bass below the voicing. ``PitchSet`` spans return their absolute pitches (decoration other than ``count`` does not apply). """ if isinstance(self.chord, RomanChord): raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") if isinstance(self.chord, PitchSet): return self.chord.tones(root, inversion=self.inversion, count=count) intervals = self.decorated_intervals() if self.inversion != 0: intervals = subsequence.voicings.invert_chord(intervals, self.inversion) if self.spread == "open" and len(intervals) >= 3: intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) elif self.spread == "wide" and len(intervals) >= 3: dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] intervals = sorted(dropped) offset = (self.chord.root_pc - root) % 12 if offset > 6: offset -= 12 effective_root = root + offset if count is not None: n = len(intervals) span_octave = max(12, ((max(intervals) // 12) + 1) * 12) pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] else: pitches = [effective_root + interval for interval in intervals] if self.bass is not None and isinstance(self.bass, int): lowest = min(pitches) bass_note = lowest - ((lowest - self.bass) % 12) if bass_note == lowest: bass_note -= 12 pitches = [bass_note] + pitches return pitches
[docs] class DecoratedChord: """Duck-types the ``Chord`` voicing protocol over a decorated span. What patterns and the injected ``chord`` receive when a span carries decoration: ``tones()`` voices the extensions/inversion/spread/bass, ``intervals()`` reports the decorated intervals (so per-pattern voice leading works over them), and ``name()`` prints the decorated name (``Am9``, ``C/G``). The engine itself never sees this — graph identity stays the bare triad underneath (:attr:`base`). """ def __init__ (self, span: ChordSpan) -> None: """Wrap a concrete, decorated span.""" if not span.is_concrete: raise ValueError("DecoratedChord needs a concrete span — resolve(key=...) first") self._span = span @property
[docs] def base (self) -> typing.Any: """The undecorated chord (the engine's currency).""" return self._span.chord
@property
[docs] def span (self) -> ChordSpan: """The wrapped span (decoration and all).""" return self._span
@property
[docs] def root_pc (self) -> int: """The root pitch class of the underlying chord.""" return int(self._span.chord.root_pc) if hasattr(self._span.chord, "root_pc") else self._span.chord.pitches[0] % 12
@property
[docs] def quality (self) -> str: """The quality of the underlying chord.""" return str(getattr(self._span.chord, "quality", "pitch_set"))
[docs] def intervals (self) -> typing.List[int]: """Decorated semitone offsets from the root.""" if isinstance(self._span.chord, PitchSet): return self._span.chord.intervals() return self._span.decorated_intervals()
[docs] def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: """Decorated voicing nearest *root*; an explicit *inversion* overrides the span's.""" span = self._span if inversion == 0 else dataclasses.replace(self._span, inversion=inversion) return span.tones(root, count=count)
[docs] def root_note (self, root_midi: int) -> int: """The MIDI note of the (undecorated) chord root nearest *root_midi*.""" if isinstance(self._span.chord, PitchSet): return self._span.chord.pitches[0] return int(self._span.chord.root_note(root_midi))
[docs] def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: """The chord root shifted by octaves (the slash bass pc when one is set). The slash bass uses the same register as the plain root bass — an octave below the chord at the default ``octave_offset=-1`` — so a bass line over a mix of plain and slash chords doesn't jump up an octave on the slash ones. """ if isinstance(self._span.bass, int): lowest = self.root_note(root_midi) bass = lowest - ((lowest - self._span.bass) % 12) return bass + 12 * octave_offset return self.root_note(root_midi) + (12 * octave_offset)
[docs] def name (self) -> str: """The decorated chord name (``Am9``, ``C/G``).""" return self._span.label()
# --------------------------------------------------------------------------- # The roman / name / degree element parser # --------------------------------------------------------------------------- _ROMAN_VALUES: typing.Dict[str, int] = { "i": 1, "ii": 2, "iii": 3, "iv": 4, "v": 5, "vi": 6, "vii": 7, } _ROMAN_RE = re.compile( r"^(?P<accidental>[b#])?" r"(?P<numeral>[ivIV]+)" r"(?P<modifier>°|o|ø|\+|aug|dim)?" r"(?P<maj7>maj7|M7)?" r"(?P<figure>65|64|43|42|7|6|2)?" r"(?:/(?P<of>.+))?$" ) # Figure → (adds a seventh, inversion). _FIGURES: typing.Dict[typing.Optional[str], typing.Tuple[bool, int]] = { None: (False, 0), "6": (False, 1), "64": (False, 2), "7": (True, 0), "65": (True, 1), "43": (True, 2), "42": (True, 3), "2": (True, 3), }
[docs] def parse_roman (text: str) -> typing.Tuple[RomanChord, int]: """Parse a roman numeral element into a (RomanChord, inversion) pair. The ~music21 semantics grammar: case is quality (``IV`` major, ``iv`` minor), ``°``/``o``/``dim`` diminished, ``ø`` half-diminished, ``+``/ ``aug`` augmented; ``maj7`` forces the major seventh; figured-bass suffixes give sevenths and inversions (``7``/``65``/``43``/``42``; ``6``/``64`` for triads); ``b``/``#`` prefixes shift the degree; one level of ``/x`` secondary function (``V7/IV``). Raises ``ValueError`` for anything it can't read. """ stripped = text.strip() match = _ROMAN_RE.match(stripped) if not match: raise ValueError(f"Cannot parse roman numeral {text!r} — expected e.g. 'V7', 'bVII', 'ii65', 'V/V'") numeral = match.group("numeral") lowered = numeral.lower() if lowered not in _ROMAN_VALUES or numeral not in (lowered, numeral.upper()): raise ValueError(f"Cannot parse roman numeral {text!r}{numeral!r} is not a degree numeral (I–VII)") degree = _ROMAN_VALUES[lowered] is_upper = numeral == numeral.upper() accidental = {"b": -1, "#": 1}.get(match.group("accidental") or "", 0) modifier = match.group("modifier") has_maj7 = match.group("maj7") is not None has_seventh, inversion = _FIGURES[match.group("figure")] if modifier in ("°", "o", "dim"): quality = "diminished_7th" if has_seventh else "diminished" elif modifier == "ø": quality = "half_diminished_7th" elif modifier in ("+", "aug"): quality = "augmented" elif has_maj7: if not is_upper: raise ValueError(f"Cannot parse roman numeral {text!r} — maj7 needs an uppercase numeral") quality = "major_7th" elif has_seventh: quality = "dominant_7th" if is_upper else "minor_7th" else: quality = "major" if is_upper else "minor" of: typing.Optional[int] = None of_text = match.group("of") if of_text is not None: if "/" in of_text: raise ValueError(f"Cannot parse roman numeral {text!r} — only one level of secondary function (/x) is supported") if of_text.lower() in _ROMAN_VALUES: of = _ROMAN_VALUES[of_text.lower()] elif of_text.isdigit(): of = int(of_text) else: raise ValueError(f"Cannot parse roman numeral {text!r} — secondary target {of_text!r} is not a degree") roman = RomanChord( degree = degree, accidental = accidental, quality = quality, of = of, source_text = stripped, ) return roman, inversion
[docs] def parse_element (element: typing.Any, beats: float = DEFAULT_SPAN_BEATS) -> ChordSpan: """Parse one progression-list element into a :class:`ChordSpan`. Elements mix freely and are parsed per element (decision 16): ints are diatonic degrees (1-based, quality inferred from key+scale at query time); strings are chord names where they start with a note letter (``"Am"``) and romans otherwise (``"VI"``, ``"bVII7"``); ``Chord``, ``PitchSet``, and ``ChordSpan`` values pass through; an ``(element, beats)`` tuple sets the span length. """ if isinstance(element, ChordSpan): return element if isinstance(element, tuple): if len(element) != 2: raise ValueError(f"a progression tuple element must be (chord, beats), got {element!r}") inner, span_beats = element return parse_element(inner, beats=float(span_beats)) if isinstance(element, bool): raise TypeError(f"cannot parse progression element {element!r} (bool)") if isinstance(element, int): return ChordSpan(chord=RomanChord(degree=element, source_text=str(element)), beats=beats) if isinstance(element, (subsequence.chords.Chord, PitchSet, RomanChord)): return ChordSpan(chord=element, beats=beats) if isinstance(element, str): stripped = element.strip() if stripped and stripped[0] in "ABCDEFG": return _parse_chord_name(stripped, beats) roman, inversion = parse_roman(stripped) return ChordSpan(chord=roman, beats=beats, inversion=inversion) raise TypeError( f"cannot parse progression element {element!r} — expected an int degree, " "a chord name or roman string, a Chord, a PitchSet, or an (element, beats) tuple" )
# --------------------------------------------------------------------------- # The Progression value # --------------------------------------------------------------------------- def _parse_chord_name (name: str, beats: float) -> ChordSpan: """Parse a chord-name element, splitting a trailing extension onto the span. ``"Dm9"`` is D minor decorated with a 9 — the quality table holds bare qualities, and the 9/11/13 ride the span as extensions (decoration lives on spans, never chords). ``"Dm7"`` stays a plain quality (m7 is in the table); the split only happens when the full name does not parse. """ try: return ChordSpan(chord = subsequence.chords.parse_chord(name), beats = beats) except ValueError as original: for extension in ("13", "11", "9"): if name.endswith(extension) and len(name) > len(extension): base = name[:-len(extension)] try: chord = subsequence.chords.parse_chord(base) except ValueError: continue return ChordSpan(chord = chord, beats = beats, extensions = (int(extension),)) raise original def _check_slot (slot: int, count: int) -> int: """Validate a 1-based chord slot and return its 0-based index.""" if not isinstance(slot, int) or isinstance(slot, bool): raise TypeError(f"chord slots are 1-based ints, got {slot!r}") if slot < 1 or slot > count: raise ValueError(f"chord slot {slot} is out of range (1–{count})") return slot - 1 @dataclasses.dataclass(frozen=True)
[docs] class Progression: """A frozen sequence of :class:`ChordSpan` — the governing harmony value. Always a realised value: binding it to the clock freezes one realisation; ``p.progression()`` keeps its breathing behaviour by re-realising a fresh one each rebuild. Iterating yields ``(chord, start, length)`` :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so placement loops keep working unchanged. The governing family supports ``+`` (concatenate) and ``*`` (tile) but never ``&`` — there is one current chord (P1, the type law). Attributes: spans: The chord spans, in order. trailing_history: Engine continuity metadata set by :meth:`Composition.freeze` — the NIR history at capture time, restored on each frozen replay. Empty for hand-built values. """
[docs] spans: typing.Tuple[ChordSpan, ...]
[docs] trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = ()
def __post_init__ (self) -> None: """Normalise span containers to tuples.""" object.__setattr__(self, "spans", tuple(self.spans)) object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) if not self.spans: raise ValueError("a Progression needs at least one chord span") # -- queries ------------------------------------------------------------ @property
[docs] def length (self) -> float: """Total length in beats (the sum of span lengths).""" return float(sum(span.beats for span in self.spans))
@property
[docs] def is_concrete (self) -> bool: """True when every span is key-independent (no romans/degrees).""" return all(span.is_concrete for span in self.spans)
@property
[docs] def chords (self) -> typing.Tuple[typing.Any, ...]: """The bare chords, one per span (concrete progressions only).""" self._require_concrete("read .chords") return tuple(span.chord for span in self.spans)
@property
[docs] def loops_on_exhaustion (self) -> bool: """True when the clock must loop rather than fall through to live stepping.""" return any(isinstance(span.chord, PitchSet) for span in self.spans)
def _require_concrete (self, action: str) -> None: """Raise with a resolution hint when key-relative spans remain.""" if not self.is_concrete: relative = ", ".join(span.label() for span in self.spans if not span.is_concrete) raise ValueError( f"cannot {action} on a key-relative progression (contains {relative}) — " "call .resolve(key=...) first, or bind it where a key is known" ) def __iter__ (self) -> typing.Iterator[ChordEvent]: """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" self._require_concrete("iterate") cursor = 0.0 for span in self.spans: chord = DecoratedChord(span) if span.is_decorated else span.chord yield ChordEvent(chord=chord, start=cursor, length=span.beats) cursor += span.beats def __len__ (self) -> int: """The number of chord spans.""" return len(self.spans)
[docs] def events (self) -> typing.Tuple[ChordEvent, ...]: """The realised timeline as a tuple (iteration, materialised).""" return tuple(self)
[docs] def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: """Return ``(span, start, end)`` for the span sounding at *beat*. *beat* wraps modulo the progression length, so the lookup also serves looped playback. """ position = beat % self.length cursor = 0.0 for span in self.spans: if cursor <= position < cursor + span.beats: return span, cursor, cursor + span.beats cursor += span.beats final = self.spans[-1] return final, self.length - final.beats, self.length
[docs] def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": """Resolve every key-relative span against a key (name or pitch class).""" key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) return dataclasses.replace( self, spans = tuple(span.resolve(key_pc, scale) for span in self.spans), )
@classmethod
[docs] def generate ( cls, style: typing.Union[str, typing.Any] = "functional_major", bars: int = 8, beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, *, key: typing.Optional[str] = None, scale: typing.Optional[str] = None, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None, pins: typing.Optional[typing.Dict[int, typing.Any]] = None, end: typing.Optional[typing.Any] = None, avoid: typing.Optional[typing.Sequence[typing.Any]] = None, cadence: typing.Optional[str] = None, dominant_7th: bool = True, gravity: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, ) -> "Progression": """Generate a progression from a chord-graph walk — the hybrid generator. Full parameter pass-through to the engine (no more throwaway default engines), plus the hybrid constraints: ``pins`` fix chords at 1-based bars, ``end`` fixes the last bar, ``avoid`` excludes chords everywhere. Constraints compile into the walk — a backward feasibility pass guarantees satisfiability before any chord is drawn (unsatisfiable constraints raise immediately), then a forward walk samples through the engine's real history-dependent weights (NIR, gravity, diversity keep their character). **Without** ``key=`` the result is key-relative — the walk runs against a reference tonic and the spans store scale-proof major-relative romans, so the value prints meaningfully unbound and resolves wherever it is bound (the walk itself is key-invariant). **With** ``key=`` the result is concrete. Parameters: style: A chord-graph style name (or ``ChordGraph`` instance). bars: How many chords to generate. beats: Span length per chord — a scalar, or a list cycled. key: Key for a concrete result; omit for a key-relative value. scale: Scale for int constraints' quality inference (e.g. ``end=1``). Defaults from the style (aeolian_minor → minor); explicit strings (``"V"``, ``"bVII7"``) never need it. seed: Seed for the walk. A standalone generated value without a seed warns — module-level nondeterminism breaks live reload. rng: An explicit random stream (overrides ``seed``). pins: ``{bar: chord}`` — 1-based; values parse like progression elements (ints, romans, names, ``Chord``). end: The chord at the final bar — ``end="V"`` is the cadential major dominant in minor (a string because it is chromatic; no int can ask for it). avoid: Chords excluded from the walk. Naming a chord outside the style's vocabulary is allowed (trivially satisfied). cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ ``"fakeout"``, theory aliases accepted) — its formula becomes pins on the final bars, so the walk *approaches* the close. Conflicts with ``end=`` or pins on those bars. dominant_7th / gravity / nir_strength / minor_turnaround_weight / root_diversity: The engine parameters, exactly as :meth:`Composition.harmony` takes them. Example: ```python chorus = subsequence.Progression.generate( style="aeolian_minor", bars=4, end="V", seed=7, ) print(chorus) # romans until bound ``` """ if bars < 1: raise ValueError("bars must be at least 1") if cadence is not None: pins = cadence_pins(cadence, bars, pins, end) end = None if rng is None: if seed is None: warnings.warn( "Progression.generate without seed= is nondeterministic — " "pass seed= so the value survives live reload", stacklevel = 2, ) rng = random.Random() else: rng = random.Random(seed) resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") relative = key is None reference = key if key is not None else "C" state = subsequence.harmonic_state.HarmonicState( key_name = reference, graph_style = style, include_dominant_7th = dominant_7th, key_gravity_blend = gravity, nir_strength = nir_strength, minor_turnaround_weight = minor_turnaround_weight, root_diversity = root_diversity, rng = rng, ) resolved_pins = { position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") for position, spec in (pins or {}).items() } resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] if 1 in resolved_pins: if resolved_pins[1] not in state.graph.nodes(): raise ValueError( f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" ) state.current_chord = resolved_pins[1] def commit (chosen: subsequence.chords.Chord) -> None: state.current_chord = chosen walked = subsequence.sequence_utils.constrained_walk( state.graph, state.current_chord, bars, rng = state.rng, pins = resolved_pins, end = resolved_end, avoid = resolved_avoid, weight_modifier = state._transition_weight, before_choice = state._record_transition_source, after_choice = commit, ) lengths = _span_lengths(beats, bars) if relative: return cls(spans = tuple( ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) for index, chord in enumerate(walked) )) return cls(spans = tuple( ChordSpan(chord = chord, beats = lengths[index]) for index, chord in enumerate(walked) ))
# -- algebra ------------------------------------------------------------ def __add__ (self, other: "Progression") -> "Progression": """Concatenate two progressions (the governing ``+``).""" if not isinstance(other, Progression): return NotImplemented return Progression(spans = self.spans + other.spans) def __mul__ (self, count: int) -> "Progression": """Tile the spans *count* times.""" if not isinstance(count, int) or isinstance(count, bool): return NotImplemented if count < 1: raise ValueError("a progression must repeat at least once (n >= 1)") return Progression(spans = self.spans * count) def __and__ (self, other: typing.Any) -> "Progression": """Parallel merge is a type error for governing values — by design.""" raise TypeError( "Progressions cannot be merged with & — there is one current chord. " "Sequence them with +, or give a pattern its own part-level progression." ) # -- spice (the five operators) and editing ------------------------------
[docs] def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. ``only=`` restricts the spice to the given 1-based chord slots. """ slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} spans = tuple( dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) if index in slots else span for index, span in enumerate(self.spans) ) return dataclasses.replace(self, spans=spans)
[docs] def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": """Set chord inversions — a single int for all spans, or a list cycled per span.""" values = [spec] if isinstance(spec, int) else list(spec) if not values: raise ValueError("inversions list is empty — pass at least one inversion") spans = tuple( dataclasses.replace(span, inversion = int(values[index % len(values)])) for index, span in enumerate(self.spans) ) return dataclasses.replace(self, spans=spans)
[docs] def spread (self, style: str) -> "Progression": """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) return dataclasses.replace(self, spans=spans)
[docs] def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": """Put the progression over a slash/pedal bass — *the* trance/techno move. *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A note name is key-independent, so it resolves to its pitch class right here; ``"tonic"`` follows the key and stays relative until the progression is resolved. ``only=`` restricts it to the given 1-based slots (slash chords rather than a full pedal). """ if isinstance(bass, str) and bass != "tonic": bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now elif isinstance(bass, int) and not 0 <= bass <= 11: raise ValueError(f"a bass pitch class must be 0–11, got {bass}") slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} spans = tuple( dataclasses.replace(span, bass=bass) if index in slots else span for index, span in enumerate(self.spans) ) return dataclasses.replace(self, spans=spans)
[docs] def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. Modal interchange for key-relative content: the degree re-resolves against the parallel mode (minor under a major scale and vice versa). Concrete chords raise — there is nothing relative to borrow. """ slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} spans = list(self.spans) for index in slots: chord = spans[index].chord if not isinstance(chord, RomanChord): raise ValueError( f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " "borrow() needs key-relative content (an int degree or roman)" ) spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) return dataclasses.replace(self, spans=tuple(spans))
[docs] def replace (self, slot: int, chord: typing.Any) -> "Progression": """Replace the chord at a 1-based slot (the span keeps its beats).""" index = _check_slot(slot, len(self.spans)) parsed = parse_element(chord, beats = self.spans[index].beats) spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] return dataclasses.replace(self, spans=spans)
[docs] def cadence (self, name: str = "strong") -> "Progression": """Substitute a cadence formula into the tail — the close, named. The final spans take the formula's chords (``"strong"`` is V→I, ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — authentic, plagal, half, deceptive — work as aliases). Each replaced span keeps its beats; its old chord and decorations go. Formula chords are key-relative (ints follow the bound scale's qualities, ``"V"`` is the major dominant by convention), so the tail resolves wherever the progression is bound — a concrete progression becomes mixed and resolves its tail at bind time, like any roman content. Example:: verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") # Am F F E — the half close, hanging on the dominant Raises: ValueError: If the cadence name is unknown, or the progression has fewer spans than the formula. """ spec = subsequence.cadences.cadence_formula(name) count = len(spec.formula) if len(self.spans) < count: raise ValueError( f"cadence({name!r}) substitutes the last {count} chords, but this " f"progression has only {len(self.spans)}" ) tail = tuple( parse_element(element, beats = span.beats) for element, span in zip(spec.formula, self.spans[-count:]) ) return dataclasses.replace(self, spans = self.spans[:-count] + tail)
[docs] def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" if isinstance(beats, bool): raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] if not values: raise ValueError("with_rhythm list is empty — pass at least one length") spans = tuple( dataclasses.replace(span, beats = float(values[index % len(values)])) for index, span in enumerate(self.spans) ) return dataclasses.replace(self, spans=spans)
[docs] def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": """Steedman-inspired chord elaboration — approach each chord by fifths. Implements the heart of Mark Steedman's generative grammar for jazz/blues chord sequences: every chord is **approached** by a chain of secondary dominants propagated backward around the cycle of fifths (Rule 3, "the perfect cadence propagated backward"), carved out of that chord's own span (Rule 1, metric subdivision). ``depth`` is literally how many fifth-steps back the chain extends: - ``depth=0`` — identity (the bare progression). - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord is made minor — the ``ii`` of *its own local dominant* (the next link in the chain), forming a ii–V into that link, not the target's own ii — and dominants are recoloured by **tritone substitution** with even odds (Rule 4) for chromatic descents. This tritone choice is the only nondeterministic part, so ``seed`` is taken (or warned) at depth ≥ 3. Its flagship is the 12-bar blues with depth-per-chorus — elaborate a ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and tritone subs accumulate. The progression must be **concrete** (resolved to rooted chords); the inserted dominants are computed by pitch-class arithmetic. Each chord keeps its decorations on the final (resolved) sub-span; the inserted approach chords are bare dominant/minor sevenths. Note that each span is divided into ``depth + 1`` equal sub-spans, so deep elaboration of a short harmonic rhythm can drop sub-spans below the harmony clock's lookahead floor — which raises at ``play()``/ ``render()`` if the result is bound to the global clock (it is free of that floor at the part level, ``p.progression()``). Parameters: depth: Elaboration depth (≥ 0). seed: Seed for the depth-≥3 tritone-substitution choices. Returns: A new :class:`Progression` with the approach chords inserted. Raises: ValueError: If *depth* is negative, the progression is key-relative, or any span is a rootless :class:`PitchSet`. Example: ```python blues = subsequence.progression("twelve_bar_blues").resolve("C") chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout ``` """ if depth < 0: raise ValueError("elaborate depth must be at least 0") self._require_concrete("elaborate") if depth == 0: return self for span in self.spans: if isinstance(span.chord, PitchSet): raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") if depth >= 3 and seed is None: warnings.warn( "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " "result survives live reload", stacklevel = 2, ) rng = random.Random(seed) new_spans: typing.List[ChordSpan] = [] for span in self.spans: target_root = span.chord.root_pc sub_beats = span.beats / (depth + 1) # The backward cycle-of-fifths chain, furthest-back first: chord j # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The # furthest-back (j == depth) is made minor — the ii of its OWN # local dominant (the next link), forming a ii–V into that link — # once the chain is long enough (depth >= 2) to spell one. for j in range(depth, 0, -1): root = (target_root + 7 * j) % 12 quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" # Tritone substitution recolours a dominant to the dom7 a # tritone away (same guide tones, chromatic resolution). if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: root = (root + 6) % 12 new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) # The target keeps its own chord and decorations, on its sub-span. new_spans.append(dataclasses.replace(span, beats = sub_beats)) return dataclasses.replace(self, spans = tuple(new_spans))
# -- description ----------------------------------------------------------
[docs] def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: """A readable, one-chord-per-line summary. Key-relative spans print as written (romans/degrees) when unbound, and as concrete chord names under a *key*. """ key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] cursor = 0.0 for span in self.spans: lines.append( f" {cursor:6.2f}{cursor + span.beats:6.2f} " f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" ) cursor += span.beats return "\n".join(lines)
def __str__ (self) -> str: """Same as :meth:`describe` with no key bound.""" return self.describe()
# --------------------------------------------------------------------------- # Construction: the factory, generation, and the breathing realise() path # --------------------------------------------------------------------------- def _span_lengths (beats: typing.Union[float, typing.List[float]], count: int) -> typing.List[float]: """Resolve a beats= spec into per-span lengths — a scalar for all, or a list cycled.""" if isinstance(beats, bool): raise TypeError(f"beats takes a number or a list of lengths, got bool: {beats!r}") if isinstance(beats, (int, float)): return [float(beats)] * count values = [float(b) for b in beats] if not values: raise ValueError("beats list is empty — pass at least one length") return [values[index % len(values)] for index in range(count)]
[docs] def progression ( source: typing.Optional[typing.Any] = None, beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, *, style: typing.Optional[str] = None, bars: int = 8, key: typing.Optional[str] = None, scale: typing.Optional[str] = None, seed: typing.Optional[int] = None, rng: typing.Optional[random.Random] = None, pins: typing.Optional[typing.Dict[int, typing.Any]] = None, end: typing.Optional[typing.Any] = None, avoid: typing.Optional[typing.Sequence[typing.Any]] = None, cadence: typing.Optional[str] = None, dominant_7th: bool = True, gravity: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, ) -> Progression: """Build a :class:`Progression` — the lowercase factory. Dispatch by argument type: a **list** parses per element (ints where diatonic, name/roman strings where nominal/chromatic, ``(element, beats)`` tuples for per-chord durations); a bare **string** names a preset from the curated table; ``style=`` generates *bars* chords from a chord-graph walk (requires ``key=``). Parameters: source: The element list, preset name, or an existing Progression (returned unchanged). beats: Span length per chord — a scalar, or a list cycled per chord (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). style: A chord-graph style name to generate from (e.g. ``"aeolian_minor"``). bars: How many chords to generate (style mode only). key: Key for style generation. seed: Seed for style generation. A standalone generated value without a seed warns — module-level nondeterminism breaks live reload. rng: An explicit random stream (overrides ``seed``; used by engine-mediated calls). dominant_7th / gravity / nir_strength: Graph-walk parameters, matching :meth:`Composition.harmony` (style mode only; full pass-through arrives with ``Progression.generate``). Example: ```python verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) ``` """ if style is not None: if source is not None: raise ValueError("pass either source or style=, not both") return Progression.generate( style = style, bars = bars, beats = beats, key = key, scale = scale, seed = seed, rng = rng, pins = pins, end = end, avoid = avoid, cadence = cadence, dominant_7th = dominant_7th, gravity = gravity, nir_strength = nir_strength, minor_turnaround_weight = minor_turnaround_weight, root_diversity = root_diversity, ) if isinstance(source, Progression): return source if isinstance(source, str): if source in _PRESETS: return progression(_PRESETS[source], beats=beats) known = ", ".join(sorted(_PRESETS)) raise ValueError( f"Unknown progression preset {source!r}. Known presets: {known}. " "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])." ) if source is None: raise ValueError("progression() needs a source list (or style=...)") elements = list(source) if not elements: raise ValueError("progression list is empty — pass at least one chord") lengths = _span_lengths(beats, len(elements)) return Progression(spans = tuple( parse_element(element, beats=lengths[index]) for index, element in enumerate(elements) ))
def _chord_source (source: ProgressionSource, key: typing.Optional[str], rng: random.Random, scale: str = "ionian") -> typing.Iterator[typing.Any]: """Yield chords indefinitely — generated from a graph style, or cycled from a list.""" if isinstance(source, str): if not key: raise ValueError(f"progression style {source!r} needs a key — pass key= or set the Composition key") state = subsequence.harmonic_state.HarmonicState(key_name=key, graph_style=source, rng=rng) yield state.current_chord while True: yield state.step() elif isinstance(source, Progression): resolved = source if source.is_concrete else source.resolve( subsequence.chords.key_name_to_pc(_require_key(source, key)), scale ) index = 0 while True: span = resolved.spans[index % len(resolved.spans)] yield DecoratedChord(span) if span.is_decorated else span.chord index += 1 else: spans = [parse_element(item) for item in source] if not spans: raise ValueError("progression list is empty — pass at least one chord") chords = [] for span in spans: if not span.is_concrete: span = span.resolve(subsequence.chords.key_name_to_pc(_require_key(span, key)), scale) chords.append(DecoratedChord(span) if span.is_decorated else span.chord) index = 0 while True: yield chords[index % len(chords)] index += 1 def _require_key (what: typing.Any, key: typing.Optional[str]) -> str: """Return the key or raise the standard relative-content error.""" if not key: raise ValueError( "this progression contains key-relative content (degrees, romans, or a " "'tonic' pedal bass) — pass key= or set the Composition key" ) return key def _resolve_length (spec: HarmonicRhythmSpec, index: int, rng: random.Random) -> float: """Resolve one chord length in beats from a harmonic-rhythm spec. Accepts a scalar (static), a list/tuple of lengths (a shaped rhythm, cycled by ``index``), or a :class:`~subsequence.harmonic_rhythm.HarmonicRhythm` from ``between(...)``. Mirrors the ``(low, high)``-tuple house idiom used for velocity — except a range here is spelled ``between(...)`` so a bare list can mean a *sequence* of lengths. """ if isinstance(spec, subsequence.harmonic_rhythm.HarmonicRhythm): return spec.resolve(rng) if isinstance(spec, tuple): # type: ignore # the hint says list, but users pass a tuple anyway — guard for it # A (low, high) tuple means a random range everywhere else in the API (e.g. # velocity); here it would silently cycle. Reject it so the intent is explicit. raise ValueError( f"harmonic_rhythm tuple {spec!r} is ambiguous — use between{spec!r} for a random " f"range, or a list {list(spec)!r} for a repeating sequence of lengths" ) if isinstance(spec, list): if not spec: raise ValueError("harmonic_rhythm sequence is empty — pass at least one length") return float(spec[index % len(spec)]) if isinstance(spec, bool): raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got bool: {spec!r}") if isinstance(spec, (int, float)): return float(spec) raise TypeError(f"harmonic_rhythm must be a number, a list of lengths, or between(...), got {type(spec).__name__}")
[docs] def resolve_voices (voicing: VoicingSpec, rng: random.Random) -> int: """Resolve the voice count for one chord — a fixed int, or a ``(low, high)`` draw.""" if isinstance(voicing, bool): raise TypeError(f"voicing must be an int or a (low, high) tuple, got bool: {voicing!r}") if isinstance(voicing, int): count = voicing elif isinstance(voicing, tuple): if len(voicing) != 2: raise ValueError(f"voicing tuple must be (low, high), got {voicing!r}") count = rng.randint(int(voicing[0]), int(voicing[1])) else: raise TypeError(f"voicing must be an int or a (low, high) tuple, got {type(voicing).__name__}") if count < 1: raise ValueError(f"voicing must be at least 1 voice, got {count}") return count
[docs] def realize ( source: ProgressionSource, harmonic_rhythm: HarmonicRhythmSpec, key: typing.Optional[str], length: float, rng: random.Random, scale: str = "ionian", ) -> Progression: """Lay a progression out across *length* beats and return a frozen value. Walks the chord source, giving each chord a harmonic-rhythm length, until the part is full. The final chord is trimmed so the timeline ends exactly on *length* and therefore loops cleanly. Voicing and articulation are not decided here — they belong to whatever places the chords (the verb you call in the loop, or :meth:`Composition.chords`). """ if length <= 0: raise ValueError(f"progression length ({length:g}) must be positive") stream = _chord_source(source, key, rng, scale) spans: typing.List[ChordSpan] = [] cursor = 0.0 index = 0 while cursor < length - 1e-9: chord = next(stream) duration = _resolve_length(harmonic_rhythm, index, rng) if duration <= 0: raise ValueError(f"harmonic_rhythm produced a non-positive length ({duration:g}) — lengths are in beats and must be > 0") duration = min(duration, length - cursor) if isinstance(chord, DecoratedChord): spans.append(dataclasses.replace(chord.span, beats=duration)) else: spans.append(ChordSpan(chord=chord, beats=duration)) cursor += duration index += 1 return Progression(spans = tuple(spans))