subsequence

Subsequence - an algorithmic composition framework for Python.

Subsequence gives you a palette of mathematical building blocks - Euclidean rhythms, cellular automata, L-systems, Markov chains, cognitive melody generation - and a stateful engine that lets them interact and evolve over time. Unlike tools that loop a fixed pattern forever, Subsequence rebuilds every pattern fresh before each cycle with full context, so algorithms feed into each other and compositions emerge that no single technique could produce alone. It generates pure MIDI (no audio engine) to control hardware synths, modular systems, drum machines, or software VSTs/DAWs.

What makes it different:

  • A rich algorithmic palette. Euclidean and Bresenham rhythm generators, cellular automata (1D and 2D), L-system string rewriting, Markov chains, cognitive melody via the Narmour model, probability- weighted ghost notes, position-aware thinning, drones and continuous notes, Perlin and pink noise, logistic chaos maps - plus groove templates, velocity shaping, and pitch-bend automation to shape how they sound.
  • Stateful patterns that evolve. Each pattern is a Python function rebuilt fresh every cycle with full context - current chord, section, cycle count, shared data from other patterns. A Euclidean rhythm can thin itself as tension builds, a cellular automaton can seed from the harmony, and a Markov chain can shift behaviour between sections.
  • Optional chord graph. Define weighted chord and key transitions via probability graphs, with gravity and automatic voice leading. Eleven built-in palettes and frozen progressions to lock some sections while others evolve freely. Layer on cognitive harmony for Narmour-based melodic inertia.
  • Sub-microsecond clock. Hybrid sleep+spin timing achieves typical pulse jitter of < 5 us on Linux, with zero long-term drift.
  • Turn anything into music. composition.schedule() runs any Python function on a beat cycle - APIs, sensors, files. Anything Python can reach becomes a musical parameter.
  • Pure MIDI, zero sound engine. No audio synthesis, no heavyweight dependencies. Route to hardware synths, drum machines, Eurorack, or software instruments.

Composition tools:

  • Rhythm and feel. Euclidean and Bresenham generators, multi-voice weighted Bresenham distribution (bresenham_poly()), ghost note layers (ghost_fill()), position-aware note removal (thin() - the musical inverse of ghost_fill), evolving cellular-automaton rhythms (cellular_1d(), cellular_2d()), smooth Perlin noise (perlin_1d(), perlin_2d(), perlin_1d_sequence(), perlin_2d_grid()), deterministic chaos sequences (logistic_map()), pink 1/f noise (pink_noise()), L-system string rewriting (p.lsystem()), Markov-chain generation (p.markov()), aperiodic binary rhythms (p.thue_morse()), golden-ratio beat placement (p.fibonacci()), Gray-Scott reaction-diffusion patterns (p.reaction_diffusion()), Lorenz strange-attractor generation (p.lorenz()), exhaustive pitch-subsequence melodies (p.de_bruijn()), step-wise melodies with guaranteed pitch diversity (p.self_avoiding_walk()), drones and explicit note on/off events (p.drone(), p.drone_off(), p.silence()), groove templates (Groove.swing(), Groove.from_agr()), swing via p.swing() (a shortcut for Groove.swing()), randomize, velocity shaping and ramps (p.build_velocity_ramp()), dropout, per-step probability, and polyrhythms via independent pattern lengths.
  • Melody generation. p.melody() with MelodicState applies the Narmour Implication-Realization model to single-note lines: continuation after small steps, reversal after large leaps, chord-tone weighting, range gravity, and pitch-diversity penalty. History persists across bar rebuilds for natural phrase continuity.
  • Chord parts. comp.chords() and p.progression() play a chord progression — generated from a chord-graph style or given explicitly — at a declared harmonic rhythm: a fixed length, a shaped [WHOLE, HALF, HALF] sequence, or between(WHOLE, 3 * WHOLE, step=WHOLE) for chords of varying, quantized length. Voicing density, detached articulation, and a seed for a fixed phrase are all declarative.
  • Expression. CC messages/ramps, pitch bend, note-correlated bend/portamento/slide, program changes, SysEx, and OSC output - all from within patterns.
  • Form and structure. Musical form as a weighted graph, ordered list, or generator. Patterns read p.section to adapt. Conductor signals (LFOs, ramps) shape intensity over time.
  • Mini-notation. p.seq("x x [x x] x", pitch="kick") - concise string syntax for rhythms, subdivisions, and per-step probability.
  • Scales. p.snap_to_scale() snaps notes to any scale. scale_notes() generates a list of MIDI note numbers from a key, mode, and range or note count - useful for arpeggios, Markov chains, and melodic walks. Built-in western and non-western modes, plus register_scale() for your own.
  • Microtonal tuning. composition.tuning() applies a tuning system globally; p.apply_tuning() overrides per-pattern. Supports Scala .scl files, explicit cent lists, frequency ratios, and N-TET equal temperaments. Polyphonic parts use explicit channel rotation so simultaneous notes can carry independent pitch bends without MPE. Compatible with any standard MIDI synthesiser.
  • Randomness tools. Weighted choice, no-repeat shuffle, random walk, probability gates. Deterministic seeding makes every decision repeatable: set it composition-wide (seed=42) or per generator (seed= on any generator, with rng= for an explicit instance — precedence rng > seed > the pattern's p.rng). See the README "Conventions" section for the API's shared vocabulary.
  • Pattern transforms. Legato, detached, fixed gate (p.duration()), reverse, time-stretch, rotate, transpose, invert, randomize, and conditional p.every().

Integration:

  • MIDI clock. Master (clock_output()) or follower (clock_follow=True). When multiple inputs are connected, only one may be designated as the master clock source; messages from other inputs are filtered to prevent sync interference. Sync to a DAW or drive hardware.
  • Latency compensation. Declare each output device's physical latency (latency_ms=); Subsequence delays the faster devices so a mix of hardware and slower software instruments sound together.
  • MIDI mirroring with per-device drum maps. Fan a pattern out to extra (device, channel) destinations; an entry can carry its own drum_note_map so one named drum hit re-resolves to the right voice on each device — a DRM1 and a General MIDI sampler alike.
  • Hardware control. CC input mapping from knobs/faders to composition.data; patterns read and write the same dict via p.data for both external data access and cross-pattern communication. OSC for bidirectional communication with mixers, lighting, visuals.
  • Live held-note arpeggiator. composition.note_input() tracks the notes you hold on a keyboard; a pattern reads them with p.held_notes() and arpeggiates them (p.arpeggio(p.held_notes())), with release_ms debounce and latch. A performance layer over the deterministic composition - empty when rendering headlessly.
  • Live coding. Hot-swap patterns, change tempo, mute/unmute, and tweak parameters during playback via a built-in TCP eval server.
  • Hotkeys. Single keystrokes to jump sections, toggle mutes, or fire any action - with optional bar-boundary quantization.
  • Real-time pattern triggering. composition.trigger() generates one-shot patterns in response to sensors, OSC, or any event.
  • Terminal display. Live status line (BPM, bar, section, chord). Add grid=True for an ASCII pattern grid showing velocity and sustain - makes legato, detached, and staccato articulations visually distinct at a glance. Add grid_scale=2 to zoom in horizontally, revealing swing and groove micro-timing.
  • Web UI Dashboard (Beta). Enable with composition.web_ui() to broadcast live composition metadata and visualize piano-roll pattern grids in a reactive HTTP/WebSocket browser dashboard.
  • Ableton Link. Industry-standard wireless tempo/phase sync (comp.link(); requires pip install subsequence[link]). Any Link-enabled app on the same LAN — Ableton Live, iOS synths, other Subsequence instances — stays in time automatically.
  • Recording. Record to standard MIDI file. Render to file without waiting for real-time playback.
Minimal example:
import subsequence
import subsequence.constants.instruments.gm_drums as gm_drums

comp = subsequence.Composition(bpm=120)

@comp.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
def drums (p):
    (p.hit_steps("kick_1",        [0, 4, 8, 12], velocity=100)
      .hit_steps("snare_1",       [4, 12],        velocity=90)
      .hit_steps("hi_hat_closed", range(16),      velocity=70))

comp.play()

Community and Feedback:

Package-level exports: Composition, Chord, Groove, MelodicState, Tuning, Motif, Phrase, motif, Degree, ChordTone, Approach, MotifEvent, ControlEvent, Progression, ChordSpan, PitchSet, progression, between, parse_chord, register_chord_quality, register_scale, scale_notes, bank_select.

  1"""
  2Subsequence - an algorithmic composition framework for Python.
  3
  4Subsequence gives you a palette of mathematical building blocks -
  5Euclidean rhythms, cellular automata, L-systems, Markov chains,
  6cognitive melody generation - and a stateful engine that lets them
  7interact and evolve over time. Unlike tools that loop a fixed pattern
  8forever, Subsequence rebuilds every pattern fresh before each cycle
  9with full context, so algorithms feed into each other and compositions
 10emerge that no single technique could produce alone. It generates pure
 11MIDI (no audio engine) to control hardware synths, modular systems,
 12drum machines, or software VSTs/DAWs.
 13
 14What makes it different:
 15
 16- **A rich algorithmic palette.** Euclidean and Bresenham rhythm
 17  generators, cellular automata (1D and 2D), L-system string rewriting,
 18  Markov chains, cognitive melody via the Narmour model, probability-
 19  weighted ghost notes, position-aware thinning, drones and continuous
 20  notes, Perlin and pink noise, logistic chaos maps - plus groove
 21  templates, velocity shaping, and pitch-bend automation to shape
 22  how they sound.
 23- **Stateful patterns that evolve.** Each pattern is a Python function
 24  rebuilt fresh every cycle with full context - current chord, section,
 25  cycle count, shared data from other patterns. A Euclidean rhythm can
 26  thin itself as tension builds, a cellular automaton can seed from the
 27  harmony, and a Markov chain can shift behaviour between sections.
 28- **Optional chord graph.** Define weighted chord and key transitions
 29  via probability graphs, with gravity and automatic voice leading.
 30  Eleven built-in palettes and frozen progressions to lock some sections
 31  while others evolve freely. Layer on cognitive harmony for
 32  Narmour-based melodic inertia.
 33- **Sub-microsecond clock.** Hybrid sleep+spin timing achieves typical
 34  pulse jitter of < 5 us on Linux, with zero long-term drift.
 35- **Turn anything into music.** ``composition.schedule()`` runs any
 36  Python function on a beat cycle - APIs, sensors, files. Anything
 37  Python can reach becomes a musical parameter.
 38- **Pure MIDI, zero sound engine.** No audio synthesis, no heavyweight
 39  dependencies. Route to hardware synths, drum machines, Eurorack, or
 40  software instruments.
 41
 42Composition tools:
 43
 44- **Rhythm and feel.** Euclidean and Bresenham generators, multi-voice
 45  weighted Bresenham distribution (``bresenham_poly()``), ghost note
 46  layers (``ghost_fill()``), position-aware note removal (``thin()`` -
 47  the musical inverse of ``ghost_fill``), evolving cellular-automaton
 48  rhythms (``cellular_1d()``, ``cellular_2d()``), smooth Perlin noise (``perlin_1d()``,
 49  ``perlin_2d()``, ``perlin_1d_sequence()``, ``perlin_2d_grid()``),
 50  deterministic chaos sequences (``logistic_map()``), pink 1/f noise
 51  (``pink_noise()``), L-system string rewriting (``p.lsystem()``),
 52  Markov-chain generation (``p.markov()``), aperiodic binary rhythms
 53  (``p.thue_morse()``), golden-ratio beat placement (``p.fibonacci()``),
 54  Gray-Scott reaction-diffusion patterns (``p.reaction_diffusion()``),
 55  Lorenz strange-attractor generation (``p.lorenz()``), exhaustive
 56  pitch-subsequence melodies (``p.de_bruijn()``), step-wise melodies
 57  with guaranteed pitch diversity (``p.self_avoiding_walk()``), drones
 58  and explicit note on/off events (``p.drone()``, ``p.drone_off()``,
 59  ``p.silence()``),
 60  groove templates (``Groove.swing()``, ``Groove.from_agr()``), swing via
 61  ``p.swing()`` (a shortcut for ``Groove.swing()``), randomize,
 62  velocity shaping and ramps (``p.build_velocity_ramp()``), dropout, per-step
 63  probability, and polyrhythms via independent pattern lengths.
 64- **Melody generation.** ``p.melody()`` with ``MelodicState`` applies
 65  the Narmour Implication-Realization model to single-note lines:
 66  continuation after small steps, reversal after large leaps, chord-tone
 67  weighting, range gravity, and pitch-diversity penalty.  History persists
 68  across bar rebuilds for natural phrase continuity.
 69- **Chord parts.** ``comp.chords()`` and ``p.progression()`` play a chord
 70  progression — generated from a chord-graph style or given explicitly — at a
 71  declared *harmonic rhythm*: a fixed length, a shaped ``[WHOLE, HALF, HALF]``
 72  sequence, or ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` for chords of varying,
 73  quantized length.  Voicing density, ``detached`` articulation, and a seed for
 74  a fixed phrase are all declarative.
 75- **Expression.** CC messages/ramps, pitch bend, note-correlated
 76  bend/portamento/slide, program changes, SysEx, and OSC output - all
 77  from within patterns.
 78- **Form and structure.** Musical form as a weighted graph, ordered list,
 79  or generator. Patterns read ``p.section`` to adapt. Conductor signals
 80  (LFOs, ramps) shape intensity over time.
 81- **Mini-notation.** ``p.seq("x x [x x] x", pitch="kick")`` - concise
 82  string syntax for rhythms, subdivisions, and per-step probability.
 83- **Scales.** ``p.snap_to_scale()`` snaps notes to any
 84  scale. ``scale_notes()`` generates a list of MIDI note numbers from
 85  a key, mode, and range or note count - useful for arpeggios, Markov
 86  chains, and melodic walks. Built-in western and non-western modes,
 87  plus ``register_scale()`` for your own.
 88- **Microtonal tuning.** ``composition.tuning()`` applies a tuning
 89  system globally; ``p.apply_tuning()`` overrides per-pattern.
 90  Supports Scala ``.scl`` files, explicit cent lists, frequency ratios,
 91  and N-TET equal temperaments. Polyphonic parts use explicit channel
 92  rotation so simultaneous notes can carry independent pitch bends
 93  without MPE. Compatible with any standard MIDI synthesiser.
 94- **Randomness tools.** Weighted choice, no-repeat shuffle, random
 95  walk, probability gates. Deterministic seeding makes every decision
 96  repeatable: set it composition-wide (``seed=42``) or per generator
 97  (``seed=`` on any generator, with ``rng=`` for an explicit instance —
 98  precedence ``rng`` > ``seed`` > the pattern's ``p.rng``). See the
 99  README "Conventions" section for the API's shared vocabulary.
100- **Pattern transforms.** Legato, detached, fixed gate (``p.duration()``),
101  reverse, time-stretch, rotate, transpose, invert, randomize, and
102  conditional ``p.every()``.
103
104Integration:
105
106- **MIDI clock.** Master (``clock_output()``) or follower
107  (``clock_follow=True``). When multiple inputs are connected, only
108  one may be designated as the master clock source; messages from
109  other inputs are filtered to prevent sync interference. Sync to a
110  DAW or drive hardware.
111- **Latency compensation.** Declare each output device's physical
112  latency (``latency_ms=``); Subsequence delays the faster devices so
113  a mix of hardware and slower software instruments sound together.
114- **MIDI mirroring with per-device drum maps.** Fan a pattern out to
115  extra ``(device, channel)`` destinations; an entry can carry its own
116  ``drum_note_map`` so one named drum hit re-resolves to the right voice
117  on each device — a DRM1 and a General MIDI sampler alike.
118- **Hardware control.** CC input mapping from knobs/faders to
119  ``composition.data``; patterns read and write the same dict via
120  ``p.data`` for both external data access and cross-pattern
121  communication. OSC for bidirectional communication with mixers,
122  lighting, visuals.
123- **Live held-note arpeggiator.** ``composition.note_input()`` tracks the
124  notes you hold on a keyboard; a pattern reads them with ``p.held_notes()``
125  and arpeggiates them (``p.arpeggio(p.held_notes())``), with ``release_ms``
126  debounce and ``latch``. A performance layer over the deterministic
127  composition - empty when rendering headlessly.
128- **Live coding.** Hot-swap patterns, change tempo, mute/unmute, and
129  tweak parameters during playback via a built-in TCP eval server.
130- **Hotkeys.** Single keystrokes to jump sections, toggle mutes, or
131  fire any action - with optional bar-boundary quantization.
132- **Real-time pattern triggering.** ``composition.trigger()`` generates
133  one-shot patterns in response to sensors, OSC, or any event.
134- **Terminal display.** Live status line (BPM, bar, section, chord).
135  Add ``grid=True`` for an ASCII pattern grid showing velocity and
136  sustain - makes legato, detached, and staccato articulations visually
137  distinct at a glance.
138  Add ``grid_scale=2`` to zoom in horizontally, revealing swing and
139  groove micro-timing.
140- **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to 
141  broadcast live composition metadata and visualize piano-roll pattern 
142  grids in a reactive HTTP/WebSocket browser dashboard.
143- **Ableton Link.** Industry-standard wireless tempo/phase sync
144  (``comp.link()``; requires ``pip install subsequence[link]``).
145  Any Link-enabled app on the same LAN — Ableton Live, iOS synths,
146  other Subsequence instances — stays in time automatically.
147- **Recording.** Record to standard MIDI file. Render to file without
148  waiting for real-time playback.
149
150Minimal example:
151
152    ```python
153    import subsequence
154    import subsequence.constants.instruments.gm_drums as gm_drums
155
156    comp = subsequence.Composition(bpm=120)
157
158    @comp.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP)
159    def drums (p):
160        (p.hit_steps("kick_1",        [0, 4, 8, 12], velocity=100)
161          .hit_steps("snare_1",       [4, 12],        velocity=90)
162          .hit_steps("hi_hat_closed", range(16),      velocity=70))
163
164    comp.play()
165    ```
166
167Community and Feedback:
168
169- **Discussions:** Chat and ask questions at https://github.com/simonholliday/subsequence/discussions
170- **Issues:** Report bugs and request features at https://github.com/simonholliday/subsequence/issues
171
172Package-level exports: ``Composition``, ``Chord``, ``Groove``, ``MelodicState``, ``Tuning``, ``Motif``, ``Phrase``, ``motif``, ``Degree``, ``ChordTone``, ``Approach``, ``MotifEvent``, ``ControlEvent``, ``Progression``, ``ChordSpan``, ``PitchSet``, ``progression``, ``between``, ``parse_chord``, ``register_chord_quality``, ``register_scale``, ``scale_notes``, ``bank_select``.
173"""
174
175import subsequence.chords
176import subsequence.composition
177import subsequence.groove
178import subsequence.harmonic_rhythm
179import subsequence.intervals
180import subsequence.melodic_state
181import subsequence.midi_utils
182import subsequence.motifs
183import subsequence.progressions
184import subsequence.tuning
185
186
187Composition = subsequence.composition.Composition
188Motif = subsequence.motifs.Motif
189Phrase = subsequence.motifs.Phrase
190motif = subsequence.motifs.motif
191Degree = subsequence.motifs.Degree
192ChordTone = subsequence.motifs.ChordTone
193Approach = subsequence.motifs.Approach
194MotifEvent = subsequence.motifs.MotifEvent
195ControlEvent = subsequence.motifs.ControlEvent
196Progression = subsequence.progressions.Progression
197ChordSpan = subsequence.progressions.ChordSpan
198PitchSet = subsequence.progressions.PitchSet
199progression = subsequence.progressions.progression
200Chord = subsequence.chords.Chord
201Groove = subsequence.groove.Groove
202MelodicState = subsequence.melodic_state.MelodicState
203Tuning = subsequence.tuning.Tuning
204between = subsequence.harmonic_rhythm.between
205parse_chord = subsequence.chords.parse_chord
206register_chord_quality = subsequence.chords.register_chord_quality
207register_scale = subsequence.intervals.register_scale
208scale_notes = subsequence.intervals.scale_notes
209bank_select = subsequence.midi_utils.bank_select
class Composition:
1023class Composition:
1024
1025	"""
1026	The top-level controller for a musical piece.
1027	
1028	The `Composition` object manages the global clock (Sequencer), the harmonic
1029	progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns.
1030	It serves as the main entry point for defining your music.
1031	
1032	Typical workflow:
1033	1. Initialize `Composition` with BPM and Key.
1034	2. Define harmony and form (optional).
1035	3. Register patterns using the `@composition.pattern` decorator.
1036	4. Call `composition.play()` to start the music.
1037	"""
1038
1039	def __init__ (
1040		self,
1041		output_device: typing.Optional[str] = None,
1042		bpm: float = 120,
1043		time_signature: typing.Tuple[int, int] = (4, 4),
1044		key: typing.Optional[str] = None,
1045		scale: typing.Optional[str] = None,
1046		seed: typing.Optional[int] = None,
1047		record: bool = False,
1048		record_filename: typing.Optional[str] = None,
1049		zero_indexed_channels: bool = False,
1050		latency_ms: float = 0.0
1051	) -> None:
1052
1053		"""
1054		Initialize a new composition.
1055
1056		Parameters:
1057			output_device: The exact name of the MIDI output port to use,
1058				as reported by ``mido.get_output_names()``. Matching is
1059				strict — the string must equal an entry in that list
1060				verbatim. On Linux/ALSA, names include the client and
1061				port IDs (e.g.
1062				``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the
1063				trailing ``:client:port`` digits are assigned in
1064				connection order and can change between reboots or when
1065				a virtual port is recreated. To look up the current
1066				names::
1067
1068				    import mido
1069				    for n in mido.get_output_names(): print(n)
1070
1071				If `None`, Subsequence auto-discovers — uses the only
1072				available device, or prompts to choose if several exist.
1073			bpm: Initial tempo in beats per minute (default 120).
1074			key: The root key of the piece (e.g., "C", "F#", "Bb").
1075				Required if you plan to use `harmony()`.
1076			scale: The scale/mode of the piece (e.g. "minor", "dorian",
1077				or any registered scale name).  Used to resolve scale
1078				degrees in motifs; defaults to major (ionian) when unset.
1079			seed: An optional integer for deterministic randomness. When set,
1080				every random decision (chord choices, drum probability, etc.)
1081				will be identical on every run.
1082			record: When True, record all MIDI events to a file.
1083			record_filename: Optional filename for the recording (defaults to timestamp).
1084			zero_indexed_channels: When False (default), MIDI channels use
1085				1-based numbering (1-16) matching instrument labelling.
1086				Channel 10 is drums, the way musicians and hardware panels
1087				show it. When True, channels use 0-based numbering (0-15)
1088				matching the raw MIDI protocol.
1089			latency_ms: Physical output latency of the primary device in
1090				milliseconds, for delay compensation (default 0.0, must be
1091				non-negative). Set this when the primary output sounds late
1092				(e.g. a software sampler) so Subsequence delays faster
1093				devices to line everything up. See ``midi_output()`` for
1094				additional devices.
1095
1096		Example:
1097			```python
1098			comp = subsequence.Composition(bpm=128, key="Eb", seed=123)
1099			```
1100		"""
1101
1102		if latency_ms < 0:
1103			raise ValueError(f"latency_ms must be non-negative — got {latency_ms}")
1104
1105		self.output_device = output_device
1106		self.bpm = bpm
1107		self.time_signature = time_signature
1108		self.key = key
1109		self.scale = scale
1110		self._seed: typing.Optional[int] = seed
1111		self._zero_indexed_channels: bool = zero_indexed_channels
1112		self._output_latency_ms: float = latency_ms
1113
1114		# Determinism plumbing: named-stream derivation state.  Build-time
1115		# consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so
1116		# adding one call never shifts another's stream; play-time pattern
1117		# streams are name-keyed in _build_pattern_from_pending.
1118		self._freeze_count: int = 0
1119		self._harmony_count: int = 0
1120		self._form_count: int = 0
1121		self._reroll_nonces: typing.Dict[str, int] = {}
1122		self._locked_names: typing.Set[str] = set()
1123
1124		self._sequencer = subsequence.sequencer.Sequencer(
1125			output_device_name = output_device,
1126			initial_bpm = bpm,
1127			time_signature = time_signature,
1128			record = record,
1129			record_filename = record_filename
1130		)
1131
1132		self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None
1133		self._harmony_cycle_beats: typing.Optional[int] = None
1134		self._harmony_reschedule_lookahead: float = 1
1135		self._section_progressions: typing.Dict[str, Progression] = {}
1136		self._bound_progression: typing.Optional[Progression] = None
1137		self._pinned_chords: typing.Dict[int, typing.Any] = {}
1138		self._harmony_horizon = _HarmonyHorizon()
1139		self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {}
1140		self._pending_patterns: typing.List[_PendingPattern] = []
1141		# Names of patterns declared by the most recent live-reload exec (added by
1142		# pattern()/layer() as they run); the deletion diff in _apply_source_async
1143		# tears down any running pattern absent from this set.
1144		self._declared_names: typing.Set[str] = set()
1145		self._pending_scheduled: typing.List[_PendingScheduled] = []
1146		self._form_state: typing.Optional[subsequence.form_state.FormState] = None
1147		self._builder_bar: int = 0
1148		self._display: typing.Optional[subsequence.display.Display] = None
1149		self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None
1150		self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None
1151		self._is_live: bool = False
1152		self._running_patterns: typing.Dict[str, typing.Any] = {}
1153		self._input_device: typing.Optional[str] = None
1154		self._input_device_alias: typing.Optional[str] = None
1155		self._clock_follow: bool = False
1156		self._clock_output: bool = False
1157		self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = []
1158		self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = []
1159		# Held-note input config from note_input() (None = not declared).
1160		self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None
1161		# Additional output devices registered with midi_output() after construction.
1162		self._additional_outputs: typing.List[_AdditionalOutput] = []
1163		# Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool)
1164		self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = []
1165		# Maps alias/name → output device index (populated in _run after all devices are opened).
1166		self._output_device_names: typing.Dict[str, int] = {}
1167		# Maps alias/name → input device index (populated in _run after all input devices are opened).
1168		self._input_device_names: typing.Dict[str, int] = {}
1169		self.data: typing.Dict[str, typing.Any] = {}
1170		self._osc_server: typing.Optional[subsequence.osc.OscServer] = None
1171		self.conductor = subsequence.conductor.Conductor()
1172		self._web_ui_enabled: bool = False
1173		self._web_ui_http_host: str = "127.0.0.1"
1174		self._web_ui_ws_host: str = "127.0.0.1"
1175		self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None
1176		self._link_quantum: typing.Optional[float] = None
1177
1178		# Hotkey state — populated by hotkeys() and hotkey().
1179		self._hotkeys_enabled: bool = False
1180		self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {}
1181		self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = []
1182		self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None
1183
1184		# Tuning state — populated by tuning().
1185		self._tuning: typing.Optional[typing.Any] = None       # subsequence.tuning.Tuning
1186		self._tuning_bend_range: float = 2.0
1187		self._tuning_channels: typing.Optional[typing.List[int]] = None
1188		self._tuning_reference_note: int = 60
1189		self._tuning_exclude_drums: bool = True
1190
1191	def _resolve_device_id (self, device: subsequence.midi_utils.DeviceId) -> int:
1192		"""Resolve an output device id (None/int/str) to an integer index.
1193
1194		``None`` → 0 (primary device).  ``int`` → returned as-is.
1195		``str`` → looked up in ``_output_device_names``; logs a warning and
1196		returns 0 if the name is unknown (called after all devices are opened
1197		in ``_run()``).
1198		"""
1199		if device is None:
1200			return 0
1201		if isinstance(device, int):
1202			return device
1203		idx = self._output_device_names.get(device)
1204		if idx is None:
1205			logger.warning(
1206				f"Unknown output device name '{device}' — routing to device 0. "
1207				f"Available names: {list(self._output_device_names.keys())}"
1208			)
1209			return 0
1210		return idx
1211
1212	def _resolve_input_device_id (self, device: subsequence.midi_utils.DeviceId) -> typing.Optional[int]:
1213		"""Resolve an input device id (None/int/str) to an integer index.
1214
1215		``None`` → ``None`` (matches any input device — existing behaviour).
1216		``int`` → returned as-is.  ``str`` → looked up in ``_input_device_names``;
1217		logs a warning and returns ``None`` if the name is unknown.
1218		Called after all input devices are opened in ``_run()``.
1219		"""
1220		if device is None:
1221			return None
1222		if isinstance(device, int):
1223			return device
1224		idx = self._input_device_names.get(device)
1225		if idx is None:
1226			logger.warning(
1227				f"Unknown input device name '{device}' — mapping will be ignored. "
1228				f"Available names: {list(self._input_device_names.keys())}"
1229			)
1230			return None
1231		return idx
1232
1233	def _resolve_pending_devices (self) -> None:
1234		"""Resolve name-based device ids on pending patterns now that all output devices are open."""
1235		for pending in self._pending_patterns:
1236			if isinstance(pending.raw_device, str):
1237				pending.device = self._resolve_device_id(pending.raw_device)
1238
1239	async def _activate_new_pending_patterns (self) -> None:
1240
1241		"""Build and schedule any pending patterns whose names are not yet running.
1242
1243		Used by ``LiveReloader._reload_async`` to bring NEW patterns added
1244		in a live reload into rotation mid-flight.  Existing patterns
1245		hot-swap via the decorator (their ``_builder_fn`` is replaced in
1246		place); only patterns whose names are not yet in ``_running_patterns``
1247		need this graduation step.
1248
1249		Newly-scheduled patterns start at the current sequencer pulse —
1250		they'll generate events from now onward, and the next reschedule
1251		will fire at the same offset as their primary cycle.
1252		"""
1253
1254		# Resolve any deferred string-device names against the now-open
1255		# device registry (no-op for int/None devices).
1256		self._resolve_pending_devices()
1257
1258		# Dedupe by name, last declaration wins — re-declaring a pattern in a
1259		# reloaded source must not schedule two copies.
1260		new_by_name: typing.Dict[str, _PendingPattern] = {}
1261
1262		for pending in self._pending_patterns:
1263			if pending.builder_fn.__name__ not in self._running_patterns:
1264				new_by_name[pending.builder_fn.__name__] = pending
1265
1266		new_pending = list(new_by_name.values())
1267
1268		if not new_pending:
1269			return
1270
1271		current_pulse = self._sequencer.pulse_count
1272
1273		for pending in new_pending:
1274
1275			pattern = self._build_pattern_from_pending(pending, start_pulse = current_pulse)
1276			await self._sequencer.schedule_pattern_repeating(pattern, start_pulse = current_pulse)
1277			self._running_patterns[pending.builder_fn.__name__] = pattern
1278
1279			logger.info(f"Live-reload: scheduled new pattern '{pending.builder_fn.__name__}'")
1280
1281		# Prune graduated (and stale duplicate) declarations: leaving them in
1282		# _pending_patterns resurrected deleted patterns on every later reload.
1283		self._pending_patterns = [
1284			pending for pending in self._pending_patterns
1285			if pending.builder_fn.__name__ not in self._running_patterns
1286		]
1287
1288	def _resolve_channel (self, channel: int) -> int:
1289
1290		"""
1291		Convert a user-supplied MIDI channel to the 0-indexed value used internally.
1292
1293		When ``zero_indexed_channels`` is False (default), the channel is
1294		validated as 1-16 and decremented by one. When True (0-indexed), the
1295		channel is validated as 0-15 and returned unchanged.
1296		"""
1297
1298		if self._zero_indexed_channels:
1299			if not 0 <= channel <= 15:
1300				raise ValueError(f"MIDI channel must be 0-15 (zero_indexed_channels=True), got {channel}")
1301			return channel
1302		else:
1303			if not 1 <= channel <= 16:
1304				raise ValueError(f"MIDI channel must be 1-16, got {channel}")
1305			return channel - 1
1306
1307	def _resolve_mirrors (
1308		self,
1309		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]],
1310		primary: typing.Optional[typing.Tuple[int, int]] = None,
1311	) -> typing.List[subsequence.pattern.MirrorSpec]:
1312
1313		"""
1314		Validate and normalise a list of mirror destinations.
1315
1316		Each entry is a 2- or 3-element sequence — ``(device_idx, channel)`` or
1317		``(device_idx, channel, drum_note_map)`` — as a tuple, list, or any such
1318		iterable.  ``channel`` is expressed in the user's channel-numbering
1319		convention (1-16 by default, 0-15 when ``zero_indexed_channels=True``);
1320		this method converts it to canonical 0-indexed form and rejects
1321		malformed entries.  The optional ``drum_note_map`` is preserved verbatim
1322		so the sequencer can re-resolve mirrored drum names per device.
1323
1324		String device names are NOT supported here; users wanting a named
1325		device should pass the integer index returned from ``midi_output()``.
1326
1327		If ``primary=(device, channel)`` is supplied (canonical 0-indexed
1328		form), a mirror entry whose ``(device, channel)`` matches it triggers a
1329		``logger.warning`` — this is almost always a user error (every event
1330		would double-fire on the same destination).  The optional map is ignored
1331		for this comparison.  Skipped when ``primary`` is ``None``, since the
1332		runtime API call site supplies its own check.
1333		"""
1334
1335		if mirrors is None:
1336			return []
1337
1338		resolved: typing.List[subsequence.pattern.MirrorSpec] = []
1339
1340		for entry in mirrors:
1341
1342			# Accept any 2- or 3-element iterable (tuple, list, etc.) — config
1343			# files and JSON sources naturally produce lists.  Validate shape at
1344			# decoration time so bad inputs surface here instead of producing
1345			# inscrutable failures inside the sequencer.
1346			try:
1347				items = list(entry)
1348			except TypeError:
1349				raise ValueError(f"Mirror entry must be a (device, channel[, drum_note_map]) tuple — got {entry!r}")
1350
1351			if len(items) not in (2, 3):
1352				raise ValueError(f"Mirror entry must have 2 or 3 elements (device, channel[, drum_note_map]) — got {entry!r}")
1353
1354			device = items[0]
1355			channel = items[1]
1356			drum_map = items[2] if len(items) == 3 else None
1357
1358			if not isinstance(device, int) or isinstance(device, bool):
1359				raise ValueError(f"Mirror device must be an integer index — got {type(device).__name__} ({device!r})")
1360
1361			if not isinstance(channel, int) or isinstance(channel, bool):
1362				raise ValueError(f"Mirror channel must be an integer — got {type(channel).__name__} ({channel!r})")
1363
1364			if drum_map is not None and not isinstance(drum_map, dict):
1365				raise ValueError(f"Mirror drum_note_map must be a dict or None — got {type(drum_map).__name__} ({drum_map!r})")
1366
1367			resolved_channel = self._resolve_channel(channel)
1368
1369			if primary is not None and (device, resolved_channel) == primary:
1370				logger.warning(
1371					f"Mirror destination {(device, resolved_channel)} matches the pattern's primary destination "
1372					f"— every event will double-fire on this (device, channel).  This is almost "
1373					f"certainly unintended."
1374				)
1375
1376			resolved_entry: subsequence.pattern.MirrorSpec = (
1377				(device, resolved_channel)
1378				if drum_map is None
1379				else (device, resolved_channel, drum_map)
1380			)
1381			resolved.append(resolved_entry)
1382
1383		return resolved
1384
1385	@property
1386	def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]:
1387		"""The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called."""
1388		return self._harmonic_state
1389
1390	def current_chord (self) -> typing.Optional[typing.Any]:
1391
1392		"""The chord sounding at the playhead, or ``None`` without harmony.
1393
1394		Reads the harmony window at the current pulse, so it stays accurate
1395		under variable harmonic rhythm and clock lookahead (the engine's
1396		``current_chord`` flips *lookahead* beats early — this does not).
1397		Falls back to the engine's chord before playback starts.  The chord
1398		may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span
1399		is spiced; it duck-types the ``Chord`` voicing protocol either way.
1400		"""
1401
1402		if not self._harmony_horizon.is_empty:
1403			beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat
1404			chord = self._harmony_horizon.chord_at(beat)
1405			if chord is not None:
1406				return chord
1407
1408		if self._harmonic_state is not None:
1409			return self._harmonic_state.get_current_chord()
1410
1411		return None
1412
1413	@property
1414	def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]:
1415		"""The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called."""
1416		return self._form_state
1417
1418	@property
1419	def sequencer (self) -> subsequence.sequencer.Sequencer:
1420		"""The underlying ``Sequencer`` instance."""
1421		return self._sequencer
1422
1423	@property
1424	def running_patterns (self) -> typing.Dict[str, typing.Any]:
1425		"""The currently active patterns, keyed by name."""
1426		return self._running_patterns
1427
1428	@property
1429	def builder_bar (self) -> int:
1430		"""Current bar index used by pattern builders."""
1431		return self._builder_bar
1432
1433	def _require_harmonic_state (self) -> subsequence.harmonic_state.HarmonicState:
1434		"""Return the active HarmonicState, raising ValueError if none is configured."""
1435		if self._harmonic_state is None:
1436			raise ValueError(
1437				"harmony() must be called before this action — "
1438				"no harmonic state has been configured."
1439			)
1440		return self._harmonic_state
1441
1442	def _coerce_progression (self, source: typing.Any, what: str) -> Progression:
1443
1444		"""Coerce a Progression / element list / preset name and resolve it against the key.
1445
1446		Binding freezes one realisation (the value type's identity), so
1447		key-relative content resolves here, at bind time, against the
1448		composition's key and scale.
1449		"""
1450
1451		value = source if isinstance(source, Progression) else subsequence.progressions.progression(source)
1452
1453		if not value.is_concrete:
1454			if self.key is None:
1455				raise ValueError(
1456					f"{what} contains key-relative chords (degrees/romans) — "
1457					"set key= on the Composition so they can resolve"
1458				)
1459			value = value.resolve(self.key, self.scale or "ionian")
1460
1461		return value
1462
1463	def harmony (
1464		self,
1465		style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None,
1466		cycle_beats: int = 4,
1467		dominant_7th: bool = True,
1468		gravity: float = 1.0,
1469		nir_strength: float = 0.5,
1470		minor_turnaround_weight: float = 0.0,
1471		root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1472		reschedule_lookahead: float = 1,
1473		progression: typing.Optional[typing.Any] = None,
1474	) -> None:
1475
1476		"""
1477		Configure the harmonic logic and chord change intervals.
1478
1479		Two sources, combinable: a **bound progression** (``progression=`` — a
1480		:class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``,
1481		or chord names) walked span by span on the global clock; and/or a
1482		**graph style** stepping live chords.  With only a progression bound,
1483		it loops on exhaustion; with a style configured too, exhaustion falls
1484		through to live stepping (the frozen-replay bridge).  Calling with
1485		neither argument keeps today's default live engine
1486		(``style="functional_major"``).
1487
1488		Parameters:
1489			style: The harmonic style to use. Built-in: "functional_major"
1490				(alias "diatonic_major"), "turnaround", "aeolian_minor",
1491				"phrygian_minor", "lydian_major", "dorian_minor",
1492				"chromatic_mediant", "suspended", "mixolydian", "whole_tone",
1493				"diminished". See README for full descriptions.
1494			cycle_beats: How many beats each live chord lasts (default 4).
1495				Bound progressions carry their own harmonic rhythm in their
1496				spans, so this applies to live stepping only.
1497			dominant_7th: Whether to include V7 chords (default True).
1498			gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord.
1499			nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement
1500				expectations.
1501			minor_turnaround_weight: For "turnaround" style, influences major vs minor feel.
1502			root_diversity: Root-repetition damping (0.0 to 1.0). Each recent
1503				chord sharing a candidate's root reduces the weight to 40% at
1504				the default (0.4). Set to 1.0 to disable.
1505			reschedule_lookahead: How many beats in advance to calculate the
1506				next chord.
1507			progression: A progression to bind to the global clock.  Key-
1508				relative content resolves now, against the composition key
1509				and scale (binding freezes one realisation).
1510
1511		Example:
1512			```python
1513			# A moody minor progression that changes every 8 beats
1514			comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4)
1515
1516			# Manual harmony driving everything — loops forever
1517			comp.harmony(progression=subsequence.progression([1, 6, 3, 7]))
1518			```
1519		"""
1520
1521		if style is None and progression is None:
1522			style = "functional_major"
1523
1524		if style is not None:
1525
1526			if self.key is None:
1527				raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor")
1528
1529			preserved_history: typing.List[subsequence.chords.Chord] = []
1530			preserved_current: typing.Optional[subsequence.chords.Chord] = None
1531
1532			if self._harmonic_state is not None:
1533				preserved_history = self._harmonic_state.history.copy()
1534				preserved_current = self._harmonic_state.current_chord
1535
1536			# Per-call salted build stream (harmony:1, harmony:2, ...): a re-call
1537			# gets its own deterministic stream while history and current chord
1538			# are preserved above, and adding a re-call never shifts any other
1539			# consumer's stream.
1540			self._harmony_count += 1
1541
1542			self._harmonic_state = subsequence.harmonic_state.HarmonicState(
1543				key_name = self.key,
1544				graph_style = style,
1545				include_dominant_7th = dominant_7th,
1546				key_gravity_blend = gravity,
1547				nir_strength = nir_strength,
1548				minor_turnaround_weight = minor_turnaround_weight,
1549				root_diversity = root_diversity,
1550				rng = self._stream(f"harmony:{self._harmony_count}")
1551			)
1552
1553			if preserved_history:
1554				self._harmonic_state.history = preserved_history
1555			if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current):
1556				self._harmonic_state.current_chord = preserved_current
1557
1558		if progression is not None:
1559			self._bound_progression = self._coerce_progression(progression, "harmony(progression=)")
1560
1561		self._harmony_cycle_beats = cycle_beats
1562		self._harmony_reschedule_lookahead = reschedule_lookahead
1563
1564		# A re-call invalidates whatever the horizon had planned.
1565		self._harmony_horizon.invalidate_future()
1566
1567	def freeze (
1568		self,
1569		bars: int,
1570		end: typing.Optional[typing.Any] = None,
1571		pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1572		avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1573	) -> "Progression":
1574
1575		"""Capture a chord progression from the live harmony engine.
1576
1577		Runs the harmony engine forward by *bars* chord changes, records each
1578		chord, and returns it as a :class:`Progression` that can be bound to a
1579		form section with :meth:`section_chords`.
1580
1581		The engine state **advances** — successive ``freeze()`` calls produce a
1582		continuing compositional journey so section progressions feel like parts
1583		of a whole rather than isolated islands.
1584
1585		The hybrid constraints compile into the walk: ``end=`` fixes the last
1586		bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=``
1587		excludes chords throughout.  Specs follow the progression-element
1588		grammar (ints where diatonic, roman/name strings where chromatic) and
1589		resolve against the composition key and scale.  A backward
1590		feasibility pass guarantees satisfiability before any chord is drawn;
1591		the forward walk keeps the engine's real history-dependent weighting.
1592		Bar 1 is always the engine's current chord — the journey continues —
1593		so ``pins={1: ...}`` may only name it redundantly.
1594
1595		Parameters:
1596			bars: Number of chords to capture (one per harmony cycle).
1597			end: The chord at the final bar — ``end="V"`` is the cadential
1598				major dominant in minor.
1599			pins: ``{bar: chord}`` — 1-based fiat positions.
1600			avoid: Chords excluded from the walk.
1601
1602		Returns:
1603			A :class:`Progression` with the captured chords and trailing
1604			history for NIR continuity.
1605
1606		Raises:
1607			ValueError: If :meth:`harmony` has not been called first, or the
1608				constraints are contradictory or unsatisfiable.
1609
1610		Example::
1611
1612			composition.harmony(style="functional_major", cycle_beats=4)
1613			verse  = composition.freeze(8, end="V")   # the verse sets up the chorus
1614			chorus = composition.freeze(4)            # next 4 chords, continuing on
1615			composition.section_chords("verse",  verse)
1616			composition.section_chords("chorus", chorus)
1617		"""
1618
1619		hs = self._require_harmonic_state()
1620
1621		if bars < 1:
1622			raise ValueError("bars must be at least 1")
1623
1624		scale = self.scale or "ionian"
1625		key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc
1626
1627		resolved_pins = {
1628			position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]")
1629			for position, spec in (pins or {}).items()
1630		}
1631		resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None
1632		resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])]
1633
1634		if 1 in resolved_pins and resolved_pins[1] != hs.current_chord:
1635			raise ValueError(
1636				f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord "
1637				f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; "
1638				"pin a later bar, or use pin_chord() for playback fiat"
1639			)
1640
1641		# Per-call salted stream (freeze:1, freeze:2, ...): each call's draws
1642		# are independent of every other consumer, so frozen progressions are
1643		# reproducible WITHOUT play() and adding a call cannot shift a
1644		# neighbour's output.  Engine state still advances normally — chord
1645		# continuity comes from current_chord/history, randomness from the
1646		# salted stream (swap-and-restore keeps hs.rng for play untouched).
1647		self._freeze_count += 1
1648		stream = self._stream(f"freeze:{self._freeze_count}")
1649		saved_rng = hs.rng
1650
1651		if stream is not None:
1652			hs.rng = stream
1653
1654		try:
1655			# The kernel with the engine's own hooks is draw-for-draw the old
1656			# step() loop when unconstrained — one walk path for both.
1657			def _commit (chosen: subsequence.chords.Chord) -> None:
1658				hs.current_chord = chosen
1659
1660			collected = subsequence.sequence_utils.constrained_walk(
1661				hs.graph,
1662				hs.current_chord,
1663				bars,
1664				rng = hs.rng,
1665				pins = resolved_pins,
1666				end = resolved_end,
1667				avoid = resolved_avoid,
1668				weight_modifier = hs._transition_weight,
1669				before_choice = hs._record_transition_source,
1670				after_choice = _commit,
1671			)
1672
1673			# Advance past the last captured chord so the next freeze() call or
1674			# live playback does not duplicate it.
1675			hs.step()
1676
1677		finally:
1678			hs.rng = saved_rng
1679
1680		span_beats = float(self._harmony_cycle_beats or 4)
1681
1682		return Progression(
1683			spans = tuple(
1684				subsequence.progressions.ChordSpan(chord = chord, beats = span_beats)
1685				for chord in collected
1686			),
1687			trailing_history = tuple(hs.history),
1688		)
1689
1690	def section_chords (self, section_name: str, progression: typing.Any) -> None:
1691
1692		"""Bind a :class:`Progression` to a named form section.
1693
1694		Every time *section_name* plays, the harmonic clock walks the
1695		progression's spans instead of calling the live engine.  Sections
1696		without a bound progression continue generating live chords.
1697
1698		Accepts a :class:`Progression` value (from :meth:`freeze`, the
1699		``progression()`` factory, or hand-built) or anything the factory
1700		accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord
1701		names.  Key-relative content resolves now, against the composition
1702		key and scale.
1703
1704		On exhaustion mid-section the progression loops when no graph style
1705		is configured (and always when it contains a
1706		:class:`~subsequence.progressions.PitchSet`); with a live engine,
1707		exhaustion falls through to live stepping until the section changes.
1708
1709		Parameters:
1710			section_name: Name of the section as defined in :meth:`form`.
1711			progression: The progression to bind.
1712
1713		Raises:
1714			ValueError: If a graph-based form has been configured and
1715				*section_name* is not one of its sections.  List and generator
1716				forms yield names lazily, so they cannot be validated here.
1717
1718		Example::
1719
1720			composition.section_chords("verse",  verse_progression)
1721			composition.section_chords("chorus", [1, 6, 3, 7])
1722			# "bridge" is not bound — it generates live chords
1723		"""
1724
1725		if (
1726			self._form_state is not None
1727			and self._form_state._section_bars is not None
1728			and section_name not in self._form_state._section_bars
1729		):
1730			known = ", ".join(sorted(self._form_state._section_bars))
1731			raise ValueError(
1732				f"Section '{section_name}' not found in form. "
1733				f"Known sections: {known}"
1734			)
1735
1736		self._section_progressions[section_name] = self._coerce_progression(
1737			progression, f"section_chords({section_name!r})"
1738		)
1739		self._harmony_horizon.invalidate_future()
1740
1741	def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None:
1742
1743		"""Force the chord sounding at a bar — fiat over live generation.
1744
1745		Whatever the harmonic source (live walk, bound progression, section
1746		progression) produces for *bar*, the pinned chord overrides it.
1747		Pass ``None`` to remove a pin.
1748
1749		Parameters:
1750			bar: 1-based bar number (the musician count).
1751			chord: A chord name, int degree, roman string, ``Chord``,
1752				``PitchSet``, or ``None`` to unpin.  Key-relative specs
1753				resolve now, against the composition key and scale.
1754
1755		Example::
1756
1757			composition.pin_chord(8, "E7")    # the turnaround lands on E7
1758			composition.pin_chord(8, None)    # let it walk again
1759		"""
1760
1761		if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1:
1762			raise ValueError(f"bars are 1-based ints, got {bar!r}")
1763
1764		if chord is None:
1765			self._pinned_chords.pop(bar, None)
1766		else:
1767			span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0]))
1768
1769			if not span.is_concrete:
1770				if self.key is None:
1771					raise ValueError("pin_chord with a key-relative spec needs key= on the Composition")
1772				span = span.resolve(subsequence.chords.key_name_to_pc(self.key), self.scale or "ionian")
1773
1774			self._pinned_chords[bar] = _span_chord(span)
1775
1776		self._harmony_horizon.invalidate_future()
1777
1778	def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None:
1779
1780		"""Bind a Motif or Phrase to a named form section (per optional part).
1781
1782		Patterns read the binding back with ``p.section_motif(part)`` (or use
1783		the one-call :meth:`phrase_part`); a section with no binding for the
1784		part is silent for that part — bind material or don't, no fallback
1785		guessing.  Re-binding is idempotent, so the call is safe in a live
1786		file: re-executing on save is the desired rebind.
1787
1788		Parameters:
1789			section_name: Name of the section as defined in :meth:`form`.
1790			value: A ``Motif`` or ``Phrase`` (anything exposing
1791				``.length``/``.slice`` places).
1792			part: Optional part label, so one section can carry several
1793				bindings (``"lead"``, ``"bass"``, ...).
1794
1795		Raises:
1796			ValueError: If a graph-based form has been configured and
1797				*section_name* is not one of its sections.
1798
1799		Example::
1800
1801			composition.section_motifs("verse",  verse_line,  part="lead")
1802			composition.section_motifs("chorus", chorus_line, part="lead")
1803		"""
1804
1805		if not hasattr(value, "length") or not hasattr(value, "slice"):
1806			raise TypeError(
1807				f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}"
1808			)
1809
1810		if (
1811			self._form_state is not None
1812			and self._form_state._section_bars is not None
1813			and section_name not in self._form_state._section_bars
1814		):
1815			known = ", ".join(sorted(self._form_state._section_bars))
1816			raise ValueError(
1817				f"Section '{section_name}' not found in form. "
1818				f"Known sections: {known}"
1819			)
1820
1821		self._section_motifs[(section_name, part)] = value
1822
1823	def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None:
1824
1825		"""
1826		Register a callback for a sequencer event (e.g., "bar", "start", "stop").
1827		"""
1828
1829		self._sequencer.on_event(event_name, callback)
1830
1831
1832	# -----------------------------------------------------------------------
1833	# Hotkey API
1834	# -----------------------------------------------------------------------
1835
1836	def hotkeys (self, enabled: bool = True) -> None:
1837
1838		"""Enable or disable the global hotkey listener.
1839
1840		Must be called **before** :meth:`play` to take effect.  When enabled, a
1841		background thread reads single keystrokes from stdin without requiring
1842		Enter.  The ``?`` key is always reserved and lists all active bindings.
1843
1844		Hotkeys have zero impact on playback when disabled — the listener
1845		thread is never started.
1846
1847		Args:
1848		    enabled: ``True`` (default) to enable hotkeys; ``False`` to disable.
1849
1850		Example::
1851
1852		    composition.hotkeys()
1853		    composition.hotkey("a", lambda: composition.form_jump("chorus"))
1854		    composition.play()
1855		"""
1856
1857		self._hotkeys_enabled = enabled
1858
1859
1860	def hotkey (
1861		self,
1862		key:      str,
1863		action:   typing.Callable[[], None],
1864		quantize: int = 0,
1865		label:    typing.Optional[str] = None,
1866	) -> None:
1867
1868		"""Register a single-key shortcut that fires during playback.
1869
1870		The listener must be enabled first with :meth:`hotkeys`.
1871
1872		Most actions — form jumps, ``composition.data`` writes, and
1873		:meth:`tweak` calls — should use ``quantize=0`` (the default).  Their
1874		musical effect is naturally delayed to the next pattern rebuild cycle,
1875		which provides automatic musical quantization without extra configuration.
1876
1877		Use ``quantize=N`` for actions where you want an explicit bar-boundary
1878		guarantee, such as :meth:`mute` / :meth:`unmute`.
1879
1880		The ``?`` key is reserved and cannot be overridden.
1881
1882		Args:
1883		    key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``).
1884		    action: Zero-argument callable to execute.
1885		    quantize: ``0`` = execute immediately (default).  ``N`` = execute
1886		        on the next global bar number divisible by *N*.
1887		    label: Display name for the ``?`` help listing.  Auto-derived from
1888		        the function name or lambda body if omitted.
1889
1890		Raises:
1891		    ValueError: If ``key`` is the reserved ``?`` character, or if
1892		        ``key`` is not exactly one character.
1893
1894		Example::
1895
1896		    composition.hotkeys()
1897
1898		    # Immediate — musical effect happens at next pattern rebuild
1899		    composition.hotkey("a", lambda: composition.form_jump("chorus"))
1900		    composition.hotkey("1", lambda: composition.data.update({"mode": "chill"}))
1901
1902		    # Explicit 4-bar phrase boundary
1903		    composition.hotkey("s", lambda: composition.mute("drums"), quantize=4)
1904
1905		    # Named function — label is derived automatically
1906		    def drop_to_breakdown ():
1907		        composition.form_jump("breakdown")
1908		        composition.mute("lead")
1909
1910		    composition.hotkey("d", drop_to_breakdown)
1911
1912		    composition.play()
1913		"""
1914
1915		if len(key) != 1:
1916			raise ValueError(f"hotkey key must be a single character, got {key!r}")
1917
1918		if key == _HOTKEY_RESERVED:
1919			raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.")
1920
1921		derived = label if label is not None else _derive_label(action)
1922
1923		self._hotkey_bindings[key] = HotkeyBinding(
1924			key      = key,
1925			action   = action,
1926			quantize = quantize,
1927			label    = derived,
1928		)
1929
1930
1931	def form_jump (self, section_name: str) -> None:
1932
1933		"""Jump the form to a named section immediately.
1934
1935		Delegates to :meth:`subsequence.form_state.FormState.jump_to`.  Only works when the
1936		composition uses graph-mode form (a dict passed to :meth:`form`).
1937
1938		The musical effect is heard at the *next pattern rebuild cycle* — already-
1939		queued MIDI notes are unaffected.  This natural delay means ``form_jump``
1940		is effective without needing explicit quantization.
1941
1942		Args:
1943		    section_name: The section to jump to.
1944
1945		Raises:
1946		    ValueError: If no form is configured, or the form is not in graph
1947		        mode, or *section_name* is unknown.
1948
1949		Example::
1950
1951		    composition.hotkey("c", lambda: composition.form_jump("chorus"))
1952		"""
1953
1954		if self._form_state is None:
1955			raise ValueError("form_jump() requires a form to be configured via composition.form().")
1956
1957		self._form_state.jump_to(section_name)
1958
1959		# The harmony horizon planned against the old section — revoke it.
1960		self._harmony_horizon.invalidate_future()
1961
1962
1963	def form_next (self, section_name: str) -> None:
1964
1965		"""Queue the next section — takes effect when the current section ends.
1966
1967		Unlike :meth:`form_jump`, this does not interrupt the current section.
1968		The queued section replaces the automatically pre-decided next section
1969		and takes effect at the natural section boundary.  The performer can
1970		change their mind by calling ``form_next`` again before the boundary.
1971
1972		Delegates to :meth:`subsequence.form_state.FormState.queue_next`.  Only works when the
1973		composition uses graph-mode form (a dict passed to :meth:`form`).
1974
1975		Args:
1976		    section_name: The section to queue.
1977
1978		Raises:
1979		    ValueError: If no form is configured, or the form is not in graph
1980		        mode, or *section_name* is unknown.
1981
1982		Example::
1983
1984		    composition.hotkey("c", lambda: composition.form_next("chorus"))
1985		"""
1986
1987		if self._form_state is None:
1988			raise ValueError("form_next() requires a form to be configured via composition.form().")
1989
1990		self._form_state.queue_next(section_name)
1991
1992		# The harmony horizon planned against the old continuation — revoke it.
1993		self._harmony_horizon.invalidate_future()
1994
1995
1996	def _list_hotkeys (self) -> None:
1997
1998		"""Log all active hotkey bindings (triggered by the ``?`` key).
1999
2000		Output appears via the standard logger so it scrolls cleanly above
2001		the :class:`~subsequence.display.Display` status line.
2002		"""
2003
2004		lines = ["Active hotkeys:"]
2005		for key in sorted(self._hotkey_bindings):
2006			b = self._hotkey_bindings[key]
2007			quant_str = "immediate" if b.quantize == 0 else f"quantize={b.quantize}"
2008			lines.append(f"  {key}  \u2192  {b.label}  ({quant_str})")
2009		lines.append(f"  ?  \u2192  list hotkeys")
2010		logger.info("\n".join(lines))
2011
2012
2013	def _process_hotkeys (self, bar: int) -> None:
2014
2015		"""Drain pending keystrokes and execute due actions.
2016
2017		Called on every ``"bar"`` event by the sequencer when hotkeys are
2018		enabled.  Handles both immediate (``quantize=0``) and quantized actions.
2019
2020		Both kinds run here, on the bar-event callback (the event loop): the
2021		keystroke listener thread only enqueues keypresses (``drain()``), it
2022		never executes actions.  Immediate (``quantize=0``) bindings fire as soon
2023		as the key is drained; quantized ones wait for their next boundary.
2024
2025		Args:
2026		    bar: The current global bar number from the sequencer.
2027		"""
2028
2029		if self._keystroke_listener is None:
2030			return
2031
2032		# Process newly arrived keys.
2033		for key in self._keystroke_listener.drain():
2034
2035			if key == _HOTKEY_RESERVED:
2036				self._list_hotkeys()
2037				continue
2038
2039			binding = self._hotkey_bindings.get(key)
2040			if binding is None:
2041				continue
2042
2043			if binding.quantize == 0:
2044				# Immediate — execute now (we're on the bar-event callback,
2045				# which is safe for all mutation methods).
2046				try:
2047					binding.action()
2048					logger.info(f"Hotkey '{key}' \u2192 {binding.label}")
2049				except Exception as exc:
2050					logger.warning(f"Hotkey '{key}' action raised: {exc}")
2051			else:
2052				# Defer until the next quantize boundary.
2053				self._pending_hotkey_actions.append(
2054					_PendingHotkeyAction(binding=binding)
2055				)
2056
2057		# Fire any pending actions whose bar boundary has arrived.
2058		still_pending: typing.List[_PendingHotkeyAction] = []
2059
2060		for pending in self._pending_hotkey_actions:
2061			if bar % pending.binding.quantize == 0:
2062				try:
2063					pending.binding.action()
2064					logger.info(
2065						f"Hotkey '{pending.binding.key}' \u2192 {pending.binding.label} "
2066						f"(bar {bar})"
2067					)
2068				except Exception as exc:
2069					logger.warning(
2070						f"Hotkey '{pending.binding.key}' action raised: {exc}"
2071					)
2072			else:
2073				still_pending.append(pending)
2074
2075		self._pending_hotkey_actions = still_pending
2076
2077	@property
2078	def seed (self) -> typing.Optional[int]:
2079
2080		"""
2081		The composition's random seed, or None when unseeded.
2082
2083		When set, every random decision derives deterministically from this
2084		value through named streams (see ``seed_for()``), so the same script
2085		produces the same music on every run.  Assign to set it::
2086
2087			comp.seed = 42
2088
2089		(Formerly the method ``comp.seed(42)`` — the call form is a hard
2090		break per the pre-1.0 rename policy.)
2091		"""
2092
2093		return self._seed
2094
2095	@seed.setter
2096	def seed (self, value: typing.Optional[int]) -> None:
2097
2098		"""Set the composition seed (``comp.seed = 42``)."""
2099
2100		self._seed = value
2101
2102	def _stream_seed (self, name: str) -> typing.Optional[int]:
2103
2104		"""
2105		Derive the effective integer seed for a named random stream.
2106
2107		The derivation is ``zlib.crc32(f"{seed}:{name}")`` — crc32 rather
2108		than ``hash()`` because it is stable across processes — plus the
2109		per-name nonce when ``reroll()`` has been called.  Returns None when
2110		the composition is unseeded.
2111		"""
2112
2113		if self._seed is None:
2114			return None
2115
2116		nonce = self._reroll_nonces.get(name, 0)
2117		key = f"{self._seed}:{name}" if nonce == 0 else f"{self._seed}:{name}:{nonce}"
2118		return zlib.crc32(key.encode())
2119
2120	def _stream (self, name: str) -> typing.Optional[random.Random]:
2121
2122		"""A fresh ``random.Random`` for a named stream, or None when unseeded."""
2123
2124		stream_seed = self._stream_seed(name)
2125		return None if stream_seed is None else random.Random(stream_seed)
2126
2127	def seed_for (self, name: str) -> typing.Optional[int]:
2128
2129		"""
2130		Surface the effective derived seed for a named stream.
2131
2132		Works for pattern names and equally for any name you invent for a
2133		standalone value generator (``seed=composition.seed_for("hook")``),
2134		so its randomness keys off the composition seed without sharing any
2135		other consumer's stream.  Reflects ``reroll()`` nonces.  Returns None
2136		when the composition is unseeded.
2137
2138		Example:
2139			```python
2140			hook_seed = composition.seed_for("hook")
2141			```
2142		"""
2143
2144		return self._stream_seed(name)
2145
2146	def reroll (self, name: str) -> None:
2147
2148		"""
2149		Deal a named stream a fresh deterministic seed — try a new variation.
2150
2151		Bumps the per-name nonce and prints the new effective seed.  The
2152		nonce lives only in this process, so the printed seed is what lets a
2153		variation you like survive a restart: note it down, or ``lock()`` the
2154		name to pin it for the session.  Refuses on locked names.
2155
2156		Parameters:
2157			name: The stream name — usually a pattern name.
2158
2159		Example:
2160			```python
2161			comp.reroll("lead")    # prints: reroll('lead') -> effective seed ...
2162			```
2163		"""
2164
2165		if name in self._locked_names:
2166			print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first")
2167			return
2168
2169		self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1
2170		effective = self._stream_seed(name)
2171
2172		if effective is None:
2173			print(f"reroll('{name}'): composition has no seed - randomness is unseeded")
2174			return
2175
2176		running = self._running_patterns.get(name)
2177
2178		if running is not None and hasattr(running, "_rng"):
2179			running._rng = random.Random(effective)
2180
2181		print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})")
2182
2183	def lock (self, name: str) -> None:
2184
2185		"""
2186		Pin a named stream: keep its current effective seed and realization.
2187
2188		Engine-side state, so it survives live reload (it is never a builder
2189		swap): a locked pattern re-deals its stream from the same effective
2190		seed on every rebuild, so every cycle realizes identically, and
2191		``reroll()`` refuses with a message until ``unlock()``.
2192
2193		Parameters:
2194			name: The stream name — usually a pattern name.
2195		"""
2196
2197		self._locked_names.add(name)
2198
2199	def unlock (self, name: str) -> None:
2200
2201		"""Release a ``lock()``: the stream runs free and ``reroll()`` works again."""
2202
2203		self._locked_names.discard(name)
2204
2205	def tuning (
2206		self,
2207		source: typing.Optional[typing.Union[str, "os.PathLike"]] = None,
2208		*,
2209		cents: typing.Optional[typing.List[float]] = None,
2210		ratios: typing.Optional[typing.List[float]] = None,
2211		equal: typing.Optional[int] = None,
2212		bend_range: float = 2.0,
2213		channels: typing.Optional[typing.List[int]] = None,
2214		reference_note: int = 60,
2215		exclude_drums: bool = True,
2216	) -> None:
2217
2218		"""Set a global microtonal tuning for the composition.
2219
2220		The tuning is applied automatically after each pattern rebuild (before
2221		the pattern is scheduled).  Drum patterns (those registered with a
2222		``drum_note_map``) are excluded by default.
2223
2224		Supply exactly one of the source parameters:
2225
2226		- ``source``: path to a Scala ``.scl`` file.
2227		- ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit).
2228		- ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``).
2229		- ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``).
2230
2231		For polyphonic parts, supply a ``channels`` pool.  Notes are spread
2232		across those MIDI channels so each can carry an independent pitch bend.
2233		The synth must be configured to match ``bend_range`` (its pitch-bend range
2234		setting in semitones).
2235
2236		Parameters:
2237			source: Path to a ``.scl`` file.
2238			cents: Cent offsets for scale degrees 1..N.
2239			ratios: Frequency ratios for scale degrees 1..N.
2240			equal: Number of equal divisions of the period.
2241			bend_range: Synth pitch-bend range in semitones (default ±2).
2242			channels: Channel pool for polyphonic rotation.
2243			reference_note: MIDI note mapped to scale degree 0 (default 60 = C4).
2244			exclude_drums: When True (default), skip patterns that have a
2245			    ``drum_note_map`` (they use fixed GM pitches, not tuned ones).
2246
2247		Example:
2248			```python
2249			# Quarter-comma meantone from a Scala file
2250			comp.tuning("meanquar.scl")
2251
2252			# Just intonation from ratios
2253			comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2])
2254
2255			# 19-TET, monophonic
2256			comp.tuning(equal=19, bend_range=2.0)
2257
2258			# 31-TET with channel rotation for polyphony (channels 1-6)
2259			comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5])
2260			```
2261		"""
2262		import subsequence.tuning as _tuning_mod
2263
2264		given = sum(x is not None for x in [source, cents, ratios, equal])
2265		if given == 0:
2266			raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal")
2267		if given > 1:
2268			raise ValueError("composition.tuning() accepts only one source parameter")
2269
2270		if source is not None:
2271			t = _tuning_mod.Tuning.from_scl(source)
2272		elif cents is not None:
2273			t = _tuning_mod.Tuning.from_cents(cents)
2274		elif ratios is not None:
2275			t = _tuning_mod.Tuning.from_ratios(ratios)
2276		else:
2277			t = _tuning_mod.Tuning.equal(equal)  # type: ignore[arg-type]
2278
2279		self._tuning = t
2280		self._tuning_bend_range = bend_range
2281		self._tuning_channels = channels
2282		self._tuning_reference_note = reference_note
2283		self._tuning_exclude_drums = exclude_drums
2284
2285	def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None:
2286
2287		"""
2288		Enable or disable the live terminal dashboard.
2289
2290		When enabled, Subsequence uses a safe logging handler that allows a
2291		persistent status line (BPM, Key, Bar, Section, Chord) to stay at
2292		the bottom of the terminal while logs scroll above it.
2293
2294		Parameters:
2295			enabled: Whether to show the display (default True).
2296			grid: When True, render an ASCII grid visualisation of all
2297				running patterns above the status line. The grid updates
2298				once per bar, showing which steps have notes and at what
2299				velocity.
2300			grid_scale: Horizontal zoom factor for the grid (default
2301				``1.0``).  Higher values add visual columns between
2302				grid steps, revealing micro-timing from swing and groove.
2303				Snapped to the nearest integer internally for uniform
2304				marker spacing.
2305		"""
2306
2307		if enabled:
2308			self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale)
2309		else:
2310			self._display = None
2311
2312	def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None:
2313
2314		"""
2315		Enable the realtime Web UI Dashboard.
2316
2317		When enabled, Subsequence instantiates a WebSocket server that broadcasts
2318		the current state, signals, and active patterns (with high-res timing and
2319		note data) to any connected browser clients.
2320
2321		Both servers bind to localhost by default.  Pass ``http_host`` / ``ws_host``
2322		(e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but
2323		broadcasts full composition state, so only do so on a trusted network.
2324		"""
2325
2326		self._web_ui_enabled = True
2327		self._web_ui_http_host = http_host
2328		self._web_ui_ws_host = ws_host
2329
2330	def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None:
2331
2332		"""
2333		Configure a MIDI input device for external sync and MIDI messages.
2334
2335		May be called multiple times to register additional input devices.
2336		The first call sets the primary input (device 0).  Subsequent calls
2337		add additional input devices (device 1, 2, …).  Only one device may
2338		have ``clock_follow=True``.
2339
2340		Parameters:
2341			device: The name of the MIDI input port.
2342			clock_follow: If True, Subsequence will slave its clock to incoming
2343				MIDI Ticks. It will also follow MIDI Start/Stop/Continue
2344				commands. Only one device can have this enabled at a time.
2345			name: Optional alias for use with ``cc_map(input_device=…)`` and
2346				``cc_forward(input_device=…)``.  When omitted, the raw device
2347				name is used.
2348
2349		Example:
2350			```python
2351			# Single controller (unchanged usage)
2352			comp.midi_input("Scarlett 2i4", clock_follow=True)
2353
2354			# Multiple controllers
2355			comp.midi_input("Arturia KeyStep", name="keys")
2356			comp.midi_input("Faderfox EC4", name="faders")
2357			```
2358		"""
2359
2360		if clock_follow:
2361			if self.is_clock_following:
2362				raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)")
2363
2364		if self._input_device is None:
2365			# First call: set primary input device (device 0)
2366			self._input_device = device
2367			self._input_device_alias = name
2368			self._clock_follow = clock_follow
2369		else:
2370			# Subsequent calls: register additional input devices
2371			self._additional_inputs.append((device, name, clock_follow))
2372
2373	def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int:
2374
2375		"""
2376		Register an additional MIDI output device.
2377
2378		The first output device is always the one passed to
2379		``Composition(output_device=…)`` — that is device 0.
2380		Each call to ``midi_output()`` adds the next device (1, 2, …).
2381
2382		Parameters:
2383			device: The exact name of the MIDI output port, as reported
2384				by ``mido.get_output_names()``. Matching is strict —
2385				partial names and substrings are rejected. See
2386				``Composition.__init__`` for the lookup snippet and a
2387				note on ALSA name stability on Linux.
2388			name: Optional alias for use with ``pattern(device=…)``,
2389				``cc_forward(output_device=…)``, etc.  When omitted, the raw
2390				device name is used.
2391			latency_ms: Physical output latency of this device in
2392				milliseconds, for delay compensation (default 0.0, must be
2393				non-negative). Set this when the device sounds late (e.g. a
2394				software sampler) so Subsequence delays faster devices to
2395				line everything up.
2396
2397		Returns:
2398			The integer device index assigned (1, 2, 3, …).
2399
2400		Example:
2401			```python
2402			comp = subsequence.Composition(bpm=120, output_device="MOTU Express")
2403
2404			# Returns 1 — use as device=1 or device="integra"
2405			comp.midi_output("Roland Integra", name="integra")
2406
2407			# A software sampler that sounds 20ms late
2408			comp.midi_output("Subsample", name="sampler", latency_ms=20)
2409
2410			@comp.pattern(channel=1, beats=4, device="integra")
2411			def strings (p):
2412				p.note(60, beat=0)
2413			```
2414		"""
2415
2416		if latency_ms < 0:
2417			raise ValueError(f"latency_ms must be non-negative — got {latency_ms}")
2418
2419		idx = 1 + len(self._additional_outputs)  # device 0 is always the primary
2420		self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms))
2421		return idx
2422
2423	def _warn_if_high_latency (self) -> None:
2424
2425		"""Warn if delay compensation adds a large whole-rig latency.
2426
2427		The slowest device defines the alignment point — every faster device is
2428		delayed up to that amount — so a large maximum means the whole rig
2429		responds late to live input.  Emitted once at startup.
2430		"""
2431
2432		candidates: typing.List[typing.Tuple[str, float]] = [("primary output", self._output_latency_ms)]
2433		candidates += [(out.alias or out.device, out.latency_ms) for out in self._additional_outputs]
2434
2435		slow_name, max_ms = max(candidates, key=lambda c: c[1])
2436
2437		if max_ms > _LATENCY_WARN_THRESHOLD_MS:
2438			logger.warning(
2439				"Device latency compensation: '%s' is the slowest at %.0fms, so faster "
2440				"devices are delayed up to %.0fms to stay aligned — live-input feel may suffer.",
2441				slow_name, max_ms, max_ms,
2442			)
2443
2444	def clock_output (self, enabled: bool = True) -> None:
2445
2446		"""
2447		Send MIDI timing clock to connected hardware.
2448
2449		When enabled, Subsequence acts as a MIDI clock master and sends
2450		standard clock messages on the output port: a Start message (0xFA)
2451		when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN),
2452		and a Stop message (0xFC) when playback ends.
2453
2454		This allows hardware synthesizers, drum machines, and effect units to
2455		slave their tempo to Subsequence automatically.
2456
2457		**Note:** Clock output is automatically disabled when ``midi_input()``
2458		is called with ``clock_follow=True``, to prevent a clock feedback loop.
2459
2460		Parameters:
2461			enabled: Whether to send MIDI clock (default True).
2462
2463		Example:
2464			```python
2465			comp = subsequence.Composition(bpm=120, output_device="...")
2466			comp.clock_output()   # hardware will follow Subsequence tempo
2467			```
2468		"""
2469
2470		self._clock_output = enabled
2471
2472
2473	def link (self, quantum: float = 4.0) -> "Composition":
2474
2475		"""
2476		Enable Ableton Link tempo and phase synchronisation.
2477
2478		When enabled, Subsequence joins the local Link session and slaves its
2479		clock to the shared network tempo and beat phase.  All other Link-enabled
2480		apps on the same LAN — Ableton Live, iOS synths, other Subsequence
2481		instances — will automatically stay in time.
2482
2483		Playback starts on the next bar boundary aligned to the Link quantum,
2484		so downbeats stay in sync across all participants.
2485
2486		Requires the ``link`` optional extra::
2487
2488		    pip install subsequence[link]
2489
2490		Parameters:
2491			quantum: Beat cycle length.  ``4.0`` (default) = one bar in 4/4 time.
2492			         Change this if your composition uses a different meter.
2493
2494		Example::
2495
2496		    comp = subsequence.Composition(bpm=120, key="C")
2497		    comp.link()          # join the Link session
2498		    comp.play()
2499
2500		    # On another machine / instance:
2501		    comp2 = subsequence.Composition(bpm=120)
2502		    comp2.link()         # tempo and phase will lock to comp
2503		    comp2.play()
2504
2505		Note:
2506		    ``set_bpm()`` proposes the new tempo to the Link network when Link
2507		    is active.  The network-authoritative tempo is applied on the next
2508		    pulse, so there may be a brief lag before the change is visible.
2509		"""
2510
2511		# Eagerly check that aalink is installed — fail early with a clear message.
2512		subsequence.link_clock._require_aalink()
2513
2514		self._link_quantum = quantum
2515		return self
2516
2517
2518	def cc_map (
2519		self,
2520		cc: int,
2521		data_key: str,
2522		channel: typing.Optional[int] = None,
2523		min_val: float = 0.0,
2524		max_val: float = 1.0,
2525		input_device: subsequence.midi_utils.DeviceId = None,
2526	) -> None:
2527
2528		"""
2529		Map an incoming MIDI CC to a ``composition.data`` key.
2530
2531		When the composition receives a CC message on the configured MIDI
2532		input port, the value is scaled from the CC range (0–127) to
2533		*[min_val, max_val]* and stored in ``composition.data[data_key]``.
2534
2535		This lets hardware knobs, faders, and expression pedals control live
2536		parameters without writing any callback code.
2537
2538		**Requires** ``midi_input()`` to be called first to open an input port.
2539
2540		Parameters:
2541			cc: MIDI Control Change number (0–127).
2542			data_key: The ``composition.data`` key to write.
2543			channel: If given, only respond to CC messages on this channel.
2544				Uses the same numbering convention as ``pattern()`` (1-16
2545				by default, or 0-15 with ``zero_indexed_channels=True``).
2546				``None`` matches any channel (default).
2547			min_val: Scaled minimum — written when CC value is 0 (default 0.0).
2548			max_val: Scaled maximum — written when CC value is 127 (default 1.0).
2549			input_device: Only respond to CC messages from this input device
2550				(index or name).  ``None`` responds to any input device (default).
2551
2552		Example:
2553			```python
2554			comp.midi_input("Arturia KeyStep")
2555			comp.cc_map(74, "filter_cutoff")           # knob → 0.0–1.0
2556			comp.cc_map(7, "volume", min_val=0, max_val=127)  # volume fader
2557
2558			# Multi-device: only listen to CC 74 from the "faders" controller
2559			comp.cc_map(74, "filter", input_device="faders")
2560			```
2561		"""
2562
2563		resolved_channel = self._resolve_channel(channel) if channel is not None else None
2564
2565		self._cc_mappings.append({
2566			'cc': cc,
2567			'data_key': data_key,
2568			'channel': resolved_channel,
2569			'min_val': min_val,
2570			'max_val': max_val,
2571			'input_device': input_device,  # resolved to int index in _run()
2572		})
2573
2574
2575	def note_input (
2576		self,
2577		channel: typing.Optional[int] = None,
2578		release_ms: float = 30.0,
2579		latch: bool = False,
2580		input_device: subsequence.midi_utils.DeviceId = None,
2581	) -> None:
2582
2583		"""Track notes held on a MIDI keyboard for live arpeggiation.
2584
2585		Incoming note-on/note-off messages build a live "currently held" set
2586		that any pattern reads via ``p.held_notes()`` — typically fed straight
2587		to ``p.arpeggio()``.  The composition still authors the rhythm and
2588		motion; the player's hands supply the pitch set.  This is a live
2589		*performance* layer over the deterministic, seeded composition: when
2590		rendering headlessly there is no input, so ``p.held_notes()`` is empty
2591		and seeded output is unchanged.
2592
2593		**Requires** ``midi_input()`` to be called first to open an input port.
2594
2595		Parameters:
2596			channel: If given, only track notes on this channel.  Uses the same
2597				numbering convention as ``pattern()`` (1-16 by default, or 0-15
2598				with ``zero_indexed_channels=True``).  ``None`` tracks any
2599				channel (default).
2600			release_ms: How long (milliseconds) a released note keeps counting
2601				as held.  This smooths the momentary all-keys-up gap during a
2602				hand-position change so the arp does not drop to silence.
2603				Default 30.0; set 0.0 to release instantly.  Ignored when
2604				``latch`` is True.
2605			latch: When True, the held set persists after you lift your hands
2606				until you play a new chord (the first key after every key is up
2607				replaces it) — like a hardware arp's latch.
2608			input_device: Only track notes from this input device (index or
2609				name).  ``None`` tracks any input device (default).
2610
2611		Example:
2612			```python
2613			comp.midi_input("Arturia KeyStep")
2614			comp.note_input(channel=1, release_ms=30)
2615
2616			@comp.pattern(channel=6, beats=4)
2617			def arp (p):
2618			    p.arpeggio(p.held_notes(), direction="up")  # rests when silent
2619			```
2620		"""
2621
2622		if self._note_input is not None:
2623			raise RuntimeError("only one note_input source is supported — named multi-source is not yet available")
2624
2625		resolved_channel = self._resolve_channel(channel) if channel is not None else None
2626
2627		self._note_input = {
2628			'channel': resolved_channel,
2629			'release_ms': release_ms,
2630			'latch': latch,
2631			'input_device': input_device,  # resolved to int index in _run()
2632		}
2633
2634
2635	@staticmethod
2636	def _make_cc_forward_transform (
2637		output: typing.Union[str, typing.Callable],
2638		cc: int,
2639		output_channel: typing.Optional[int],
2640	) -> typing.Callable:
2641
2642		"""Build a transform callable from a preset string or user-supplied callable.
2643
2644		The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]``
2645		where ``channel`` is the 0-indexed incoming channel.
2646		"""
2647
2648		import mido as _mido
2649
2650		def _out_ch (incoming: int) -> int:
2651			return output_channel if output_channel is not None else incoming
2652
2653		if callable(output):
2654			if output_channel is None:
2655				return output
2656			def _wrapped (value: int, channel: int) -> typing.Optional[typing.Any]:
2657				msg = output(value, channel)
2658
2659				if msg is None:
2660					return None
2661
2662				# copy() re-channels without rebuilding: reconstructing from
2663				# __dict__ passed 'type' twice and raised TypeError on every
2664				# message, so callable+output_channel never forwarded anything.
2665				return msg.copy(channel=output_channel)
2666			return _wrapped
2667
2668		if output == 'cc':
2669			def _cc_identity (value: int, channel: int) -> typing.Any:
2670				return _mido.Message('control_change', channel=_out_ch(channel), control=cc, value=value)
2671			return _cc_identity
2672
2673		if output.startswith('cc:'):
2674			try:
2675				target_cc = int(output[3:])
2676			except ValueError:
2677				raise ValueError(f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127")
2678			if not 0 <= target_cc <= 127:
2679				raise ValueError(f"cc_forward(): CC number {target_cc} out of range 0–127")
2680			def _cc_remap (value: int, channel: int) -> typing.Any:
2681				return _mido.Message('control_change', channel=_out_ch(channel), control=target_cc, value=value)
2682			return _cc_remap
2683
2684		if output == 'pitchwheel':
2685			def _pitchwheel (value: int, channel: int) -> typing.Any:
2686				pitch = int(value / 127 * 16383) - 8192
2687				return _mido.Message('pitchwheel', channel=_out_ch(channel), pitch=pitch)
2688			return _pitchwheel
2689
2690		raise ValueError(
2691			f"cc_forward(): unknown preset '{output}'. "
2692			"Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable."
2693		)
2694
2695
2696	def cc_forward (
2697		self,
2698		cc: int,
2699		output: typing.Union[str, typing.Callable],
2700		*,
2701		channel: typing.Optional[int] = None,
2702		output_channel: typing.Optional[int] = None,
2703		mode: str = "instant",
2704		input_device: subsequence.midi_utils.DeviceId = None,
2705		output_device: subsequence.midi_utils.DeviceId = None,
2706	) -> None:
2707
2708		"""
2709		Forward an incoming MIDI CC to the MIDI output in real-time.
2710
2711		Unlike ``cc_map()`` which writes incoming CC values to ``composition.data``
2712		for use at pattern rebuild time, ``cc_forward()`` routes the signal
2713		directly to the MIDI output — bypassing the pattern cycle entirely.
2714
2715		Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC
2716		number; they operate independently.
2717
2718		Parameters:
2719			cc: Incoming CC number to listen for (0–127).
2720			output: What to send. Either a **preset string**:
2721
2722				- ``"cc"`` — identity forward, same CC number and value.
2723				- ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``).
2724				- ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend.
2725
2726				Or a **callable** with signature
2727				``(value: int, channel: int) -> Optional[mido.Message]``.
2728				Return a fully formed ``mido.Message`` to send, or ``None`` to suppress.
2729				``channel`` is 0-indexed (the incoming channel).
2730			channel: If given, only respond to CC messages on this channel.
2731				Uses the same numbering convention as ``cc_map()``.
2732				``None`` matches any channel (default).
2733			output_channel: Override the output channel. ``None`` uses the
2734				incoming channel. Uses the same numbering convention as ``pattern()``.
2735			mode: Dispatch mode:
2736
2737				- ``"instant"`` *(default)* — send immediately on the MIDI input
2738				  callback thread. Lowest latency (~1–5 ms). Instant forwards are
2739				  **not** recorded when recording is enabled.
2740				- ``"queued"`` — inject into the sequencer event queue and send at
2741				  the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards
2742				  **are** recorded when recording is enabled.
2743
2744		Example:
2745			```python
2746			comp.midi_input("Arturia KeyStep")
2747
2748			# CC 1 → CC 1 (identity, instant)
2749			comp.cc_forward(1, "cc")
2750
2751			# CC 1 → pitch bend on channel 1, queued (recordable)
2752			comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued")
2753
2754			# CC 1 → CC 74, custom channel
2755			comp.cc_forward(1, "cc:74", output_channel=2)
2756
2757			# Custom transform — remap CC range 0–127 to CC 74 range 40–100
2758			import subsequence.midi as midi
2759			comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch))
2760
2761			# Forward AND map to data simultaneously — both active on the same CC
2762			comp.cc_map(1, "mod_wheel")
2763			comp.cc_forward(1, "cc:74")
2764			```
2765		"""
2766
2767		if not 0 <= cc <= 127:
2768			raise ValueError(f"cc_forward(): cc {cc} out of range 0–127")
2769
2770		if mode not in ('instant', 'queued'):
2771			raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'")
2772
2773		resolved_in_channel = self._resolve_channel(channel) if channel is not None else None
2774		resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None
2775
2776		transform = self._make_cc_forward_transform(output, cc, resolved_out_channel)
2777
2778		self._cc_forwards.append({
2779			'cc': cc,
2780			'channel': resolved_in_channel,
2781			'output_channel': resolved_out_channel,
2782			'mode': mode,
2783			'transform': transform,
2784			'input_device': input_device,   # resolved to int index in _run()
2785			'output_device': output_device, # resolved to int index in _run()
2786		})
2787
2788
2789	def live (self, port: int = 5555) -> None:
2790
2791		"""
2792		Enable the live coding eval server.
2793
2794		This allows you to connect to a running composition using the
2795		`subsequence.live_client` REPL and hot-swap pattern code or
2796		modify variables in real-time.
2797
2798		Security:
2799			The server executes arbitrary Python in this process — it is **not** a
2800			sandbox.  It binds to localhost only and is opt-in, but any process on
2801			the same machine that can reach the port gains full code execution here.
2802			Do not enable it on shared or multi-user hosts, and never expose the
2803			port to a network.
2804
2805		Parameters:
2806			port: The TCP port to listen on (default 5555).
2807		"""
2808
2809		self._live_server = subsequence.live_server.LiveServer(self, port=port)
2810		self._is_live = True
2811
2812	def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None:
2813
2814		"""Watch a Python file and reload it into the composition on every save.
2815
2816		The watched file is exec'd into a namespace with ``composition`` and
2817		``subsequence`` available.  ``@composition.pattern`` decorators inside
2818		the file hot-swap their corresponding running patterns in place;
2819		patterns whose function bodies have been deleted from the file are
2820		unregistered automatically on the next reload (notes stopped,
2821		removed from the running-pattern set).
2822
2823		An **initial synchronous load** happens here — if the file has a
2824		``SyntaxError`` or doesn't exist at this moment, the exception
2825		propagates so the user knows immediately.  Subsequent reloads
2826		happen on the composition's event loop and tolerate transient
2827		errors (logged, skipped).
2828
2829		Call BEFORE ``composition.play()``.  Reloads happen on the
2830		composition's event loop, so all mutations are thread-safe.
2831
2832		See the "Live coding via file watching" section of the README for
2833		the recommended wrapper-script + live-file split.
2834
2835		Parameters:
2836			path: Path to the Python file to watch.
2837			poll_interval: Seconds between ``mtime`` polls (default 0.25 s).
2838
2839		Example::
2840
2841			# live_init.py — runs once
2842			composition = subsequence.Composition(bpm=120, key="E")
2843			composition.harmony(style="aeolian_minor")
2844			composition.watch("live_patterns.py")
2845			composition.play()
2846		"""
2847
2848		# Required for the decorator hot-swap path to fire on re-decoration.
2849		self._is_live = True
2850
2851		# Detect the single-file workflow: if watch() is called from inside
2852		# the very file being watched, the outer Python script execution will
2853		# already register the patterns (the decorators sit at module level
2854		# below ``watch(__file__)``).  In that case, _load_initial's re-exec
2855		# would double-register every pattern, so skip it.  For the two-file
2856		# workflow (path != caller's __file__) the initial exec is essential
2857		# — it's the only way the watched file's patterns ever reach the
2858		# composition.
2859		caller_file = self._caller_module_file()
2860		self_watch = False
2861		if caller_file is not None:
2862			try:
2863				self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve()
2864			except OSError:
2865				self_watch = False
2866
2867		self._live_reloader = subsequence.live_reloader.LiveReloader(
2868			composition = self,
2869			path = path,
2870			poll_interval = poll_interval,
2871			skip_initial_exec = self_watch,
2872		)
2873		self._live_reloader.start()
2874
2875	@staticmethod
2876	def _caller_module_file () -> typing.Optional[str]:
2877
2878		"""Return ``__file__`` of the module that invoked the caller, if available.
2879
2880		Walks one frame up the call stack — the immediate caller is
2881		``watch()``, so ``f_back`` is the user's code.  Returns the
2882		module-level ``__file__`` of that frame's globals; ``None`` when
2883		the caller has no ``__file__`` (REPL, exec'd context, etc.).
2884		"""
2885
2886		frame = inspect.currentframe()
2887		if frame is None or frame.f_back is None or frame.f_back.f_back is None:
2888			return None
2889		# f_back = watch(); f_back.f_back = user code calling watch().
2890		return frame.f_back.f_back.f_globals.get("__file__")
2891
2892	def load_patterns (
2893		self,
2894		source:       str,
2895		source_label: str = "<string>",
2896	) -> None:
2897
2898		"""Compile and apply a pattern-source string to the composition.
2899
2900		Equivalent to one ``watch()`` reload triggered by save, but with the
2901		source presented in-memory rather than on disk.  Useful for web /
2902		REST handlers that accept pattern uploads from a trusted contributor,
2903		or for one-shot session loads with no file backing.
2904
2905		Behaviour mirrors ``watch()``:
2906		* The source is exec'd into a fresh namespace with ``composition``
2907		  and ``subsequence`` in scope.
2908		* ``@composition.pattern`` decorators in the source hot-swap their
2909		  corresponding running patterns in place.
2910		* Patterns currently running but **not** declared in the source are
2911		  unregistered — the source is treated as the full new truth.
2912		* If the composition is already playing, the swap happens on the
2913		  event loop thread; the call blocks until it completes.
2914		* If the composition has not yet called ``play()``, the source runs
2915		  on the caller's thread; decorators populate ``_pending_patterns``
2916		  and ``play()`` picks them up in the usual way.
2917
2918		Errors are raised so the caller can act on them:
2919		* ``SyntaxError`` if ``source`` fails to compile.
2920		* The exception raised inside ``exec()`` for any runtime error.
2921		* ``RuntimeError`` if called from inside the composition's own
2922		  event loop thread (would deadlock — see Threading below).
2923
2924		In either failure case, existing composition state is preserved —
2925		the diff-and-unregister phase is skipped if exec raised, so a
2926		half-broken upload cannot tear down working patterns.
2927
2928		Threading:
2929			Designed to be called from a thread DIFFERENT from the
2930			composition's event loop — typically a web-handler worker.
2931			Cannot be called from inside the loop itself (a pattern
2932			callback, an asyncio task spawned by the composition).  From
2933			there, ``await composition._apply_source_async(...)`` directly.
2934
2935		SECURITY WARNING: ``exec()`` is not sandboxed.  The source has full
2936		Python access in this process.  Only pass source from trusted
2937		senders.  The built-in blocklist (``help``, ``input``, ``breakpoint``,
2938		``exit``, ``quit``) prevents calls that would stall the event loop;
2939		it is not a security boundary.
2940
2941		Parameters:
2942			source:       Python source declaring ``@composition.pattern``
2943				functions.
2944			source_label: Identifier used in compile errors and tracebacks
2945				(appears as the filename in ``SyntaxError`` and ``__file__``-
2946				style traceback lines).  Default ``"<string>"``.
2947		"""
2948
2949		# Required for the decorator hot-swap path to fire on re-decoration.
2950		self._is_live = True
2951
2952		# Compile on the caller's thread so SyntaxError comes back fast,
2953		# before any cross-thread scheduling.
2954		compiled = compile(source, source_label, "exec")
2955		namespace = self._build_live_namespace(source_label = source_label)
2956
2957		loop = self._sequencer._event_loop
2958
2959		if loop is not None and loop.is_running():
2960
2961			# Refuse to deadlock: calling load_patterns() from inside the
2962			# composition's own event loop (e.g. from a pattern callback or
2963			# an asyncio task spawned by the composition) would have us
2964			# block waiting for a coroutine that can only run when this
2965			# thread yields.  Tell the caller exactly what to do instead.
2966			try:
2967				current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
2968			except RuntimeError:
2969				current_loop = None
2970
2971			if current_loop is loop:
2972				raise RuntimeError(
2973					"load_patterns() cannot be called from inside the composition's "
2974					"event loop thread — it would deadlock waiting for the "
2975					"scheduled coroutine to run on the very thread that's blocked. "
2976					"From a worker thread, call it normally.  From an async "
2977					"coroutine already on the loop, "
2978					"`await composition._apply_source_async(compile(source, label, 'exec'), "
2979					"composition._build_live_namespace())` instead."
2980				)
2981
2982			# Composition is playing — mutation must happen on the loop thread.
2983			# future.result() blocks the caller until the coroutine finishes
2984			# and re-raises any exception it threw.
2985			future = asyncio.run_coroutine_threadsafe(
2986				self._apply_source_async(compiled, namespace),
2987				loop = loop,
2988			)
2989			future.result()
2990
2991		else:
2992			# Pre-play: no event loop yet.  Decorators populate
2993			# _pending_patterns; play() graduates them in the usual way.
2994			# Diff-and-unregister is unnecessary here — nothing is running.
2995			exec(compiled, namespace)
2996
2997	async def _apply_source_async (
2998		self,
2999		compiled:  types.CodeType,
3000		namespace: typing.Dict[str, typing.Any],
3001	) -> None:
3002
3003		"""Execute pre-compiled live source against the running composition.
3004
3005		Runs on the event loop thread.  Performs ``exec()``, graduates any
3006		newly-decorated patterns into ``_running_patterns``, then unregisters
3007		any patterns that were running but absent from the source.
3008
3009		Raises whatever ``exec()`` raises.  When that happens, the diff-and-
3010		unregister phase is skipped — the namespace is incomplete, so any
3011		patterns the source failed to reach would be misinterpreted as
3012		deletions and torn down.
3013
3014		Called from two places:
3015		* ``Composition.load_patterns()`` via ``run_coroutine_threadsafe``.
3016		* ``LiveReloader._reload_async`` directly (already on the loop).
3017		"""
3018
3019		# Track which patterns the source declares this exec.  pattern() and
3020		# layer() add their (resolved) names to _declared_names as they run, so
3021		# this covers decorated patterns AND layer()/merged patterns — the latter
3022		# have no module-level callable to match against by name, which is why the
3023		# old namespace-based diff tore layers down on every reload.
3024		self._declared_names = set()
3025
3026		# Bail before any state mutation if exec raises — propagates to
3027		# the caller (load_patterns re-raises; LiveReloader catches + logs).
3028		exec(compiled, namespace)
3029
3030		# Graduate newly-decorated patterns from _pending_patterns into
3031		# _running_patterns so they start firing on the next reschedule.
3032		# Patterns that hot-swapped via the decorator/layer path don't appear
3033		# in _pending_patterns and don't need this step.
3034		await self._activate_new_pending_patterns()
3035
3036		# Detect deletions: anything currently running but NOT declared by the
3037		# just-exec'd source has been removed by the user and should be torn
3038		# down.  Decorators/layer() do NOT remove from _running_patterns when a
3039		# definition disappears from the source.
3040		for name in list(self._running_patterns.keys()):
3041			if name not in self._declared_names:
3042				self.unregister(name)
3043
3044	def _build_live_namespace (self, source_label: str = "<live>") -> typing.Dict[str, typing.Any]:
3045
3046		"""Build a fresh namespace dict for exec'ing live source.
3047
3048		Provides ``composition`` (this Composition), ``subsequence`` (the
3049		package), and a safe builtins set with ``help``, ``input``,
3050		``breakpoint``, ``exit``, ``quit`` blocked.
3051
3052		Also injects two dunder globals that make the single-file live-coding
3053		workflow ergonomic:
3054
3055		* ``__name__ = "__live_reload__"`` — so ``if __name__ == "__main__":``
3056		  blocks in the watched file are *skipped* during live reload.  The
3057		  same file run directly with ``python my_session.py`` sees
3058		  ``__name__ == "__main__"`` and runs setup; saves trigger reload
3059		  with ``__name__ == "__live_reload__"``, skipping setup and only
3060		  re-running pattern definitions.
3061		* ``__file__ = source_label`` — so ``composition.watch(__file__)``
3062		  and any user code referencing ``__file__`` works inside the live
3063		  namespace.  Set to the file path for ``LiveReloader``, the
3064		  user-supplied ``source_label`` for ``Composition.load_patterns``,
3065		  and ``"<live>"`` for ``LiveServer``.
3066
3067		Single source of truth: ``live_reloader`` (file watching),
3068		``live_server`` (TCP REPL), and ``load_patterns`` (string source)
3069		all call this so live source sees the same environment from any
3070		entry point.
3071
3072		The blocklist prevents calls that would stall the async event loop
3073		running the sequencer.  It is **not** a security sandbox — exec'd
3074		code can still do anything Python allows.
3075
3076		Parameters:
3077			source_label: Value to bind to ``__file__`` in the namespace.
3078				Defaults to ``"<live>"``.
3079		"""
3080
3081		import subsequence  # local import: this module is imported during subsequence init
3082
3083		safe_builtins = {name: getattr(builtins, name) for name in dir(builtins)}
3084
3085		blocked = {"help", "input", "breakpoint", "exit", "quit"}
3086
3087		for name in blocked:
3088			safe_builtins[name] = _live_blocked(name)
3089
3090		return {
3091			"__builtins__": safe_builtins,
3092			"__name__":     "__live_reload__",
3093			"__file__":     source_label,
3094			"composition":  self,
3095			"subsequence":  subsequence,
3096		}
3097
3098	def osc (self, receive_port: int = 9000, send_port: int = 9001, send_host: str = "127.0.0.1", receive_host: str = "0.0.0.0") -> None:
3099
3100		"""
3101		Enable bi-directional Open Sound Control (OSC).
3102
3103		Subsequence will listen for commands (like `/bpm` or `/mute`) and
3104		broadcast its internal state (like `/chord` or `/bar`) over UDP.
3105
3106		Parameters:
3107			receive_port: Port to listen for incoming OSC messages (default 9000).
3108			send_port: Port to send state updates to (default 9001).
3109			send_host: The IP address to send updates to (default "127.0.0.1").
3110			receive_host: Interface to listen on (default "0.0.0.0" — all
3111				interfaces, so external OSC controllers on the LAN can reach it).
3112				The listener can change tempo, mute patterns, and write data, so on
3113				an untrusted network restrict it with ``receive_host="127.0.0.1"``.
3114		"""
3115
3116		self._osc_server = subsequence.osc.OscServer(
3117			self,
3118			receive_port = receive_port,
3119			send_port = send_port,
3120			send_host = send_host,
3121			receive_host = receive_host
3122		)
3123
3124	def osc_map (self, address: str, handler: typing.Callable) -> None:
3125
3126		"""
3127		Register a custom OSC handler.
3128
3129		Must be called after :meth:`osc` has been configured.
3130
3131		Parameters:
3132			address: OSC address pattern to match (e.g. ``"/my/param"``).
3133			handler: Callable invoked with ``(address, *args)`` when a
3134				matching message arrives.
3135
3136		Example::
3137
3138			composition.osc()
3139
3140			def on_intensity (address, value):
3141				composition.data["intensity"] = float(value)
3142
3143			composition.osc_map("/intensity", on_intensity)
3144		"""
3145
3146		if self._osc_server is None:
3147			raise RuntimeError("Call composition.osc() before composition.osc_map()")
3148
3149		self._osc_server.map(address, handler)
3150
3151	def set_bpm (self, bpm: float) -> None:
3152
3153		"""
3154		Instantly change the tempo.
3155
3156		Parameters:
3157			bpm: The new tempo in beats per minute.
3158
3159		When Ableton Link is active, this proposes the new tempo to the Link
3160		network instead of applying it locally.  The network-authoritative tempo
3161		is picked up on the next pulse.
3162		"""
3163
3164		self._sequencer.set_bpm(bpm)
3165
3166		if not self.is_clock_following and self._link_quantum is None:
3167			self.bpm = bpm
3168
3169	def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None:
3170
3171		"""
3172		Smoothly ramp the tempo to a target value over a number of bars.
3173
3174		Parameters:
3175			bpm: Target tempo in beats per minute.
3176			bars: Duration of the transition in bars.
3177			shape: Easing curve name.  Defaults to ``"linear"``.
3178			       ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural-
3179			       sounding tempo changes.  See :mod:`subsequence.easing` for all
3180			       available shapes.
3181
3182		Example:
3183			```python
3184			# Accelerate to 140 BPM over the next 8 bars with a smooth S-curve
3185			comp.target_bpm(140, bars=8, shape="ease_in_out")
3186			```
3187
3188		Note:
3189			Ignored while Ableton Link is active — the shared session tempo is
3190			authoritative.  Use ``set_bpm()`` to propose a tempo to the Link network.
3191		"""
3192
3193		self._sequencer.set_target_bpm(bpm, bars, shape)
3194
3195	def live_info (self) -> typing.Dict[str, typing.Any]:
3196
3197		"""
3198		Return a dictionary containing the current state of the composition.
3199		
3200		Includes BPM, key, current bar, active section, current chord, 
3201		running patterns, and custom data.
3202		"""
3203
3204		section_info = None
3205		if self._form_state is not None:
3206			section = self._form_state.get_section_info()
3207			if section is not None:
3208				section_info = {
3209					"name": section.name,
3210					"bar": section.bar,
3211					"bars": section.bars,
3212					"progress": section.progress
3213				}
3214
3215		chord_name = None
3216		sounding_chord = self.current_chord()
3217		if sounding_chord is not None:
3218			chord_name = sounding_chord.name()
3219
3220		pattern_list = []
3221		channel_offset = 0 if self._zero_indexed_channels else 1
3222		for name, pat in self._running_patterns.items():
3223			pattern_list.append({
3224				"name": name,
3225				"channel": pat.channel + channel_offset,
3226				"length": pat.length,
3227				"cycle": pat._cycle_count,
3228				"muted": pat._muted,
3229				"tweaks": dict(pat._tweaks)
3230			})
3231
3232		return {
3233			"bpm": self._sequencer.current_bpm,
3234			"key": self.key,
3235			"bar": self._builder_bar,
3236			"section": section_info,
3237			"chord": chord_name,
3238			"patterns": pattern_list,
3239			"input_device": self._input_device,
3240			"clock_follow": self.is_clock_following,
3241			"data": self.data
3242		}
3243
3244	def mute (self, name: str) -> None:
3245
3246		"""
3247		Mute a running pattern by name.
3248		
3249		The pattern continues to 'run' and increment its cycle count in 
3250		the background, but it will not produce any MIDI notes until unmuted.
3251
3252		Parameters:
3253			name: The function name of the pattern to mute.
3254		"""
3255
3256		if name not in self._running_patterns:
3257			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3258
3259		self._running_patterns[name]._muted = True
3260		logger.info(f"Muted pattern: {name}")
3261
3262	def unmute (self, name: str) -> None:
3263
3264		"""
3265		Unmute a previously muted pattern.
3266		"""
3267
3268		if name not in self._running_patterns:
3269			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3270
3271		self._running_patterns[name]._muted = False
3272		logger.info(f"Unmuted pattern: {name}")
3273
3274	def unregister (self, name: str) -> None:
3275
3276		"""Fully remove a running pattern from rotation.
3277
3278		Unlike ``mute()`` (which keeps the pattern alive but silent),
3279		``unregister()`` tears the pattern down entirely.  It sets
3280		``pattern._removed = True`` so the sequencer's reschedule loop
3281		skips re-adding it on the next pulse; sends ``note_off`` for any
3282		of the pattern's currently-sounding notes on the primary
3283		destination AND on every mirror destination (so drones and
3284		sustaining notes stop immediately); and removes the entry from
3285		``_running_patterns`` so it no longer appears in ``live_info()``,
3286		the terminal grid, or any other consumer that enumerates running
3287		patterns.
3288
3289		Already-queued events in the sequencer's event queue play out —
3290		note_offs are paired with their note_ons at queue time, so notes
3291		end at their natural duration; only drones rely on the targeted
3292		``_stop_pattern_notes`` pass.
3293
3294		Idempotent: silently logs a ``debug`` and returns if the pattern
3295		is already absent.  Useful from both the live REPL
3296		(``composition.live()``) and the file watcher
3297		(``composition.watch()``), which calls this for any pattern
3298		removed from the watched file between reloads.
3299
3300		Parameters:
3301			name: Function name of the pattern to remove.
3302		"""
3303
3304		if name not in self._running_patterns:
3305			logger.debug(f"unregister() no-op: pattern '{name}' not running")
3306			return
3307
3308		pattern = self._running_patterns[name]
3309
3310		# Mark for removal first so the reschedule loop sees the flag even if
3311		# it fires concurrently with the note-off pass below.
3312		pattern._removed = True
3313
3314		# Stop sustaining notes (including drones) on every destination this
3315		# pattern outputs to.  Fire-and-forget across threads via the event
3316		# loop; ``_stop_pattern_notes`` acquires the queue lock internally.
3317		if self._sequencer._event_loop is not None:
3318			asyncio.run_coroutine_threadsafe(
3319				self._sequencer._stop_pattern_notes(pattern),
3320				loop = self._sequencer._event_loop,
3321			)
3322
3323		def _finalise_removal () -> None:
3324			self._running_patterns.pop(name, None)
3325
3326			# Forget any pending (not-yet-graduated) declaration too, so a
3327			# later live reload cannot resurrect the pattern.
3328			self._pending_patterns = [
3329				pending for pending in self._pending_patterns
3330				if pending.builder_fn.__name__ != name
3331			]
3332
3333			logger.info(f"Unregistered pattern: {name}")
3334
3335		# The running-patterns dict is iterated by the display, web UI, and
3336		# reschedule loop on the event loop thread — mutate it there when this
3337		# call arrives from another thread (e.g. the live TCP server).
3338		loop = self._sequencer._event_loop
3339
3340		try:
3341			on_loop = loop is not None and asyncio.get_running_loop() is loop
3342		except RuntimeError:
3343			on_loop = False
3344
3345		if loop is not None and loop.is_running() and not on_loop:
3346			loop.call_soon_threadsafe(_finalise_removal)
3347		else:
3348			_finalise_removal()
3349
3350	def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None:
3351
3352		"""
3353		Add a mirror destination to a running pattern.
3354
3355		Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone
3356		event the pattern emits will also be sent to ``(device, channel)``,
3357		starting from the next cycle rebuild.  Idempotent on ``(device, channel)``
3358		— calling with the same destination twice does not double-fan; calling
3359		again with a different ``drum_note_map`` re-points it in place.
3360
3361		Parameters:
3362			name: Function name of the pattern to mirror.
3363			device: Output device index (the integer returned from
3364				``midi_output()``; 0 = primary device).
3365			channel: MIDI channel using this composition's numbering convention
3366				(1-16 by default; 0-15 if ``zero_indexed_channels=True``).
3367			drum_note_map: Optional per-destination drum map.  When set, mirrored
3368				drum hits are re-resolved by name through it, so a named voice
3369				lands on this device's own note number — see the README
3370				"MIDI mirroring" section.
3371
3372		Bandwidth: each mirror adds another full copy of the pattern's events.
3373		See the README "MIDI mirroring" section for the tradeoffs.
3374		"""
3375
3376		if name not in self._running_patterns:
3377			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3378
3379		resolved_channel = self._resolve_channel(channel)
3380		prefix = (device, resolved_channel)
3381		entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map)
3382
3383		pattern = self._running_patterns[name]
3384
3385		# Mirror-to-self check: comparing the (device, channel) prefix against the
3386		# live pattern's resolved destination.  Unlike the decorator path this is
3387		# always concrete.
3388		if prefix == (pattern.device, pattern.channel):
3389			logger.warning(
3390				f"Mirror destination {prefix} matches '{name}'s primary destination "
3391				f"— every event will double-fire on this (device, channel).  This is almost "
3392				f"certainly unintended."
3393			)
3394
3395		# Idempotent on (device, channel): replace any existing entry for the same
3396		# destination (so its map can be re-pointed), else append.
3397		existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None)
3398		if existing_index is None:
3399			pattern.mirrors.append(entry)
3400			logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}")
3401		elif pattern.mirrors[existing_index] != entry:
3402			pattern.mirrors[existing_index] = entry
3403			logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}")
3404		else:
3405			logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}")
3406
3407	def unmirror (self, name: str, device: int, channel: int) -> None:
3408
3409		"""
3410		Remove a single mirror destination from a running pattern.
3411
3412		Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is
3413		ignored.  Idempotent: silently does nothing if the destination is not
3414		currently mirrored.  The change applies on the next cycle rebuild.
3415		"""
3416
3417		if name not in self._running_patterns:
3418			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3419
3420		resolved_channel = self._resolve_channel(channel)
3421		prefix = (device, resolved_channel)
3422
3423		pattern = self._running_patterns[name]
3424
3425		filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix]
3426		if len(filtered) != len(pattern.mirrors):
3427			pattern.mirrors[:] = filtered
3428			logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}")
3429		else:
3430			logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors")
3431
3432	def unmirror_all (self, name: str) -> None:
3433
3434		"""
3435		Remove every mirror destination from a running pattern.
3436		"""
3437
3438		if name not in self._running_patterns:
3439			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3440
3441		pattern = self._running_patterns[name]
3442
3443		if pattern.mirrors:
3444			pattern.mirrors.clear()
3445			logger.info(f"All mirrors cleared on pattern: {name}")
3446
3447	def tweak (self, name: str, **kwargs: typing.Any) -> None:
3448
3449		"""Override parameters for a running pattern.
3450
3451		Values set here are available inside the pattern's builder
3452		function via ``p.param()``.  They persist across rebuilds
3453		until explicitly changed or cleared.  Changes take effect
3454		on the next rebuild cycle.
3455
3456		Parameters:
3457			name: The function name of the pattern.
3458			**kwargs: Parameter names and their new values.
3459
3460		Example (from the live REPL)::
3461
3462			composition.tweak("bass", pitches=[48, 52, 55, 60])
3463		"""
3464
3465		if name not in self._running_patterns:
3466			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3467
3468		self._running_patterns[name]._tweaks.update(kwargs)
3469		logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}")
3470
3471	def clear_tweak (self, name: str, *param_names: str) -> None:
3472
3473		"""Remove tweaked parameters from a running pattern.
3474
3475		If no parameter names are given, all tweaks for the pattern
3476		are cleared and every ``p.param()`` call reverts to its
3477		default.
3478
3479		Parameters:
3480			name: The function name of the pattern.
3481			*param_names: Specific parameter names to clear.  If
3482				omitted, all tweaks are removed.
3483		"""
3484
3485		if name not in self._running_patterns:
3486			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3487
3488		if not param_names:
3489			self._running_patterns[name]._tweaks.clear()
3490			logger.info(f"Cleared all tweaks for pattern '{name}'")
3491		else:
3492			for param_name in param_names:
3493				self._running_patterns[name]._tweaks.pop(param_name, None)
3494			logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}")
3495
3496	def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]:
3497
3498		"""Return a copy of the current tweaks for a running pattern.
3499
3500		Parameters:
3501			name: The function name of the pattern.
3502		"""
3503
3504		if name not in self._running_patterns:
3505			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3506
3507		return dict(self._running_patterns[name]._tweaks)
3508
3509	def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None:
3510
3511		"""
3512		Register a custom function to run on a repeating beat-based cycle.
3513
3514		Subsequence automatically runs synchronous functions in a thread pool
3515		so they don't block the timing-critical MIDI clock. Async functions
3516		are run directly on the event loop.
3517
3518		Parameters:
3519			fn: The function to call.
3520			cycle_beats: How often to call it (e.g., 4 = every bar).
3521			reschedule_lookahead: How far in advance to schedule the next call.
3522			wait_for_initial: If True, run the function once during startup
3523				and wait for it to complete before playback begins. This
3524				ensures ``composition.data`` is populated before patterns
3525				first build. Implies ``defer=True`` for the repeating
3526				schedule.
3527			defer: If True, skip the pulse-0 fire and defer the first
3528				repeating call to just before the second cycle boundary.
3529
3530		Raises:
3531			RuntimeError: If called after ``play()`` has started — scheduled
3532				tasks register at startup, so a late registration would be
3533				silently ignored otherwise.
3534		"""
3535
3536		if self._sequencer.running:
3537			raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup")
3538
3539		self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer))
3540
3541	def form (
3542		self,
3543		sections: typing.Union[
3544			typing.List[typing.Tuple[str, int]],
3545			typing.Iterator[typing.Tuple[str, int]],
3546			typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]]
3547		],
3548		loop: bool = False,
3549		start: typing.Optional[str] = None
3550	) -> None:
3551
3552		"""
3553		Define the structure (sections) of the composition.
3554
3555		You can define form in three ways:
3556		1. **Graph (Dict)**: Dynamic transitions based on weights.
3557		2. **Sequence (List)**: A fixed order of sections.
3558		3. **Generator**: A Python generator that yields `(name, bars)` pairs.
3559
3560		Parameters:
3561			sections: The form definition (Dict, List, or Generator).
3562			loop: Whether to cycle back to the start (List mode only).
3563			start: The section to start with (Graph mode only).
3564
3565		Example:
3566			```python
3567			# A simple pop structure
3568			comp.form([
3569				("verse", 8),
3570				("chorus", 8),
3571				("verse", 8),
3572				("chorus", 16)
3573			])
3574			```
3575		"""
3576
3577		# Seed FormState at form() time (per-call salt) so build-time walks —
3578		# the frozen clones form_freeze will take — are deterministic without
3579		# play(); the play-time stream is re-dealt name-keyed in _run().
3580		self._form_count += 1
3581
3582		self._form_state = subsequence.form_state.FormState(
3583			sections,
3584			loop = loop,
3585			start = start,
3586			rng = self._stream(f"form:{self._form_count}")
3587		)
3588
3589	@staticmethod
3590	def _resolve_length (
3591		beats: typing.Optional[float],
3592		bars: typing.Optional[float],
3593		steps: typing.Optional[float],
3594		step_duration: typing.Optional[float],
3595		default: float = 4.0,
3596		beats_per_bar: int = 4,
3597	) -> typing.Tuple[float, int]:
3598
3599		"""
3600		Resolve the beat_length and default_grid from the duration parameters.
3601
3602		Two modes:
3603		- **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``.
3604		  ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats.
3605		- **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``.
3606		  ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats.
3607
3608		Constraints:
3609		- ``beats`` and ``bars`` are mutually exclusive.
3610		- ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``.
3611		- ``steps`` cannot be combined with ``beats`` or ``bars``.
3612
3613		Returns:
3614			(beat_length, default_grid) — beat_length in beats (quarter notes);
3615			default_grid the number of grid steps (16th-notes in beat mode, or the
3616			explicit ``steps`` value directly in step mode).
3617		"""
3618
3619		if beats is not None and bars is not None:
3620			raise ValueError("Specify only one of beats= or bars=")
3621
3622		if steps is not None and (beats is not None or bars is not None):
3623			raise ValueError("steps= cannot be combined with beats= or bars=")
3624
3625		if step_duration is not None and steps is None:
3626			raise ValueError("step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)")
3627
3628		if steps is not None:
3629			if step_duration is None:
3630				raise ValueError("steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)")
3631			return steps * step_duration, int(steps)
3632
3633		if bars is not None:
3634			raw = bars * beats_per_bar
3635		elif beats is not None:
3636			raw = beats
3637		else:
3638			raw = default
3639
3640		return raw, round(raw / subsequence.constants.durations.SIXTEENTH)
3641
3642	def pattern (
3643		self,
3644		channel: int,
3645		beats: typing.Optional[float] = None,
3646		bars: typing.Optional[float] = None,
3647		steps: typing.Optional[float] = None,
3648		step_duration: typing.Optional[float] = None,
3649		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
3650		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
3651		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
3652		reschedule_lookahead: float = 1,
3653		voice_leading: bool = False,
3654		device: subsequence.midi_utils.DeviceId = None,
3655		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3656	) -> typing.Callable:
3657
3658		"""
3659		Register a function as a repeating MIDI pattern.
3660
3661		The decorated function will be called once per cycle to 'rebuild' its
3662		content. This allows for generative logic that evolves over time.
3663
3664		Two ways to specify pattern length:
3665
3666		- **Duration mode** (default): use ``beats=`` or ``bars=``.
3667		  The grid defaults to sixteenth-note resolution.
3668		- **Step mode**: use ``steps=`` paired with ``step_duration=``.
3669		  The grid equals the step count, so ``p.hit_steps()`` indices map
3670		  directly to steps.
3671
3672		Parameters:
3673			channel: MIDI channel. By default uses 1-based numbering (1-16).
3674				Set ``zero_indexed_channels=True`` on the ``Composition`` to use
3675				0-based numbering (0-15), matching the raw MIDI protocol, instead.
3676			beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar.
3677			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats.
3678			steps: Step count for step mode. Requires ``step_duration=``.
3679			step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``).
3680				Requires ``steps=``.
3681			drum_note_map: Optional mapping for drum instruments.
3682			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
3683				Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``.
3684			nrpn_name_map: Optional mapping of NRPN parameter names (strings) to
3685				14-bit parameter numbers (0–16383).  Enables string-based names
3686				in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a
3687				device-specific dictionary (e.g. Sequential Take 5's
3688				``Osc1FreqFine`` → 9).
3689			reschedule_lookahead: Beats in advance to compute the next cycle.
3690			voice_leading: If True, chords in this pattern will automatically
3691				use inversions that minimize voice movement.
3692			mirrors: Optional list of additional ``(device, channel)`` destinations
3693				to duplicate every event from this pattern onto.  Notes, CCs, pitch
3694				bend, NRPN/RPN bursts, program changes, SysEx, and drone events are
3695				all mirrored; OSC events are not (OSC is not bound to a MIDI port).
3696				``device`` is the integer index returned by ``midi_output()`` (0 =
3697				primary).  ``channel`` follows this composition's channel-numbering
3698				convention.  See also ``mirror()`` / ``unmirror()`` for live toggling.
3699
3700		Example:
3701			```python
3702			@comp.pattern(channel=1, beats=4)
3703			def chords (p):
3704				p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9)
3705
3706			@comp.pattern(channel=1, bars=2)
3707			def long_phrase (p):
3708				...
3709
3710			@comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH)
3711			def riff (p):
3712				p.sequence(steps=[0, 1, 3, 5], pitches=60)
3713			```
3714		"""
3715
3716		channel = self._resolve_channel(channel)
3717
3718		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0])
3719
3720		# Resolve device string name to index if possible now; otherwise store
3721		# the raw DeviceId and resolve it in _run() once all devices are open.
3722		resolved_device: subsequence.midi_utils.DeviceId = device
3723
3724		# Mirror-to-self check is only reliable when the primary device is a
3725		# concrete integer at decoration time.  ``None`` resolves to device 0
3726		# downstream, so we treat it as 0 here too.  Strings are deferred to
3727		# ``_run()`` and we skip the check for them.
3728		primary: typing.Optional[typing.Tuple[int, int]]
3729		if isinstance(resolved_device, str):
3730			primary = None
3731		else:
3732			primary = (resolved_device if resolved_device is not None else 0, channel)
3733		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
3734
3735		def decorator (fn: typing.Callable) -> typing.Callable:
3736
3737			"""
3738			Wrap the builder function and register it as a pending pattern.
3739			During live sessions, hot-swap an existing pattern's builder instead.
3740			"""
3741
3742			# Record this declaration so the live-reload deletion diff knows the
3743			# pattern is still present in the source (see _apply_source_async).
3744			self._declared_names.add(fn.__name__)
3745
3746			# Hot-swap: if we're live and a pattern with this name exists, replace its builder.
3747			if self._is_live and fn.__name__ in self._running_patterns:
3748				running = self._running_patterns[fn.__name__]
3749				running._builder_fn = fn
3750				running._wants_chord = _fn_has_parameter(fn, "chord")
3751				logger.info(f"Hot-swapped pattern: {fn.__name__}")
3752				return fn
3753
3754			# Names key the seeded stream, mutes, tweaks, and reroll/lock — a
3755			# duplicate means two scheduled copies sharing one stream with
3756			# only one reachable by name.  Warn loudly at registration.
3757			if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns):
3758				logger.warning(
3759					f"Duplicate pattern name '{fn.__name__}': both copies will be "
3760					f"scheduled, they share one seeded stream, and only one is "
3761					f"reachable by name — rename one of them."
3762				)
3763
3764			pending = _PendingPattern(
3765				builder_fn = fn,
3766				channel = channel,  # already resolved to 0-indexed
3767				length = beat_length,
3768				default_grid = default_grid,
3769				drum_note_map = drum_note_map,
3770				cc_name_map = cc_name_map,
3771				nrpn_name_map = nrpn_name_map,
3772				reschedule_lookahead = reschedule_lookahead,
3773				voice_leading = voice_leading,
3774				# For int/None: resolve immediately.  For str: store 0 as
3775				# placeholder; _resolve_pending_devices() fixes it in _run().
3776				device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device,
3777				raw_device = resolved_device,
3778				mirrors = resolved_mirrors,
3779			)
3780
3781			self._pending_patterns.append(pending)
3782
3783			return fn
3784
3785		return decorator
3786
3787	def layer (
3788		self,
3789		*builder_fns: typing.Callable,
3790		channel: int,
3791		beats: typing.Optional[float] = None,
3792		bars: typing.Optional[float] = None,
3793		steps: typing.Optional[float] = None,
3794		step_duration: typing.Optional[float] = None,
3795		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
3796		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
3797		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
3798		reschedule_lookahead: float = 1,
3799		voice_leading: bool = False,
3800		device: subsequence.midi_utils.DeviceId = None,
3801		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3802	) -> None:
3803
3804		"""
3805		Combine multiple functions into a single MIDI pattern.
3806
3807		This is useful for composing complex patterns out of reusable
3808		building blocks (e.g., a 'kick' function and a 'snare' function).
3809
3810		See ``pattern()`` for the full description of ``beats``, ``bars``,
3811		``steps``, and ``step_duration``.
3812
3813		Parameters:
3814			builder_fns: One or more pattern builder functions.
3815			channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``).
3816			beats: Duration in beats (quarter notes).
3817			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
3818			steps: Step count for step mode. Requires ``step_duration=``.
3819			step_duration: Duration of one step in beats. Requires ``steps=``.
3820			drum_note_map: Optional mapping for drum instruments.
3821			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
3822			nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit
3823				parameter numbers.
3824			reschedule_lookahead: Beats in advance to compute the next cycle.
3825			voice_leading: If True, chords use smooth voice leading.
3826			mirrors: Optional list of additional ``(device, channel)`` destinations
3827				to duplicate every event onto.  See ``pattern()`` for details.
3828		"""
3829
3830		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0])
3831
3832		# Resolve channel up-front so the mirror-to-self check has the canonical
3833		# primary form to compare against.
3834		resolved_channel = self._resolve_channel(channel)
3835
3836		# See pattern() for the same comment about None / str handling.
3837		primary: typing.Optional[typing.Tuple[int, int]]
3838		if isinstance(device, str):
3839			primary = None
3840		else:
3841			primary = (device if device is not None else 0, resolved_channel)
3842		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
3843
3844		wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns)
3845
3846		if wants_chord:
3847
3848			def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None:
3849
3850				for fn in builder_fns:
3851					if _fn_has_parameter(fn, "chord"):
3852						fn(p, chord)
3853					else:
3854						fn(p)
3855
3856		else:
3857
3858			def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:  # type: ignore[misc]
3859
3860				for fn in builder_fns:
3861					fn(p)
3862
3863		# Give the merged builder a stable, unique name derived from its
3864		# components so multiple layer() calls don't all register under
3865		# "merged_builder" and collide in _running_patterns (which made
3866		# mute/tweak/unregister/live_info reach only the LAST layer).  "+" can't
3867		# appear in a Python identifier, so this never clashes with a real
3868		# pattern function's name.
3869		base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}"
3870		merged_name = base_name
3871		suffix = 2
3872
3873		# Two layers with the same components (e.g. on different saves of a
3874		# live file) must map to the same names pass-over-pass, while two
3875		# DIFFERENT layers sharing components in one pass must not collide.
3876		while merged_name in self._declared_names:
3877			merged_name = f"{base_name}#{suffix}"
3878			suffix += 1
3879
3880		merged_builder.__name__ = merged_name
3881
3882		# Record the declaration for the live-reload deletion diff, and hot-swap
3883		# in place when this layer is already running so a reload picks up edits
3884		# to the component functions without losing the pattern's cycle count,
3885		# tweaks, or mirrors (mirrors the pattern() decorator's hot-swap).
3886		self._declared_names.add(merged_builder.__name__)
3887
3888		if self._is_live and merged_builder.__name__ in self._running_patterns:
3889			running = self._running_patterns[merged_builder.__name__]
3890			running._builder_fn = merged_builder
3891			running._wants_chord = wants_chord
3892			logger.info(f"Hot-swapped layer: {merged_builder.__name__}")
3893			return
3894
3895		pending = _PendingPattern(
3896			builder_fn = merged_builder,
3897			channel = resolved_channel,  # already resolved to 0-indexed above
3898			length = beat_length,
3899			default_grid = default_grid,
3900			drum_note_map = drum_note_map,
3901			cc_name_map = cc_name_map,
3902			nrpn_name_map = nrpn_name_map,
3903			reschedule_lookahead = reschedule_lookahead,
3904			voice_leading = voice_leading,
3905			mirrors = resolved_mirrors,
3906			device = 0 if (device is None or isinstance(device, str)) else device,
3907			raw_device = device,
3908		)
3909
3910		self._pending_patterns.append(pending)
3911
3912	def chords (
3913		self,
3914		*,
3915		channel: int,
3916		progression: subsequence.progressions.ProgressionSource,
3917		harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec,
3918		bars: typing.Optional[float] = None,
3919		beats: typing.Optional[float] = None,
3920		voicing: subsequence.progressions.VoicingSpec = (3, 4),
3921		velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY,
3922		detached: typing.Optional[float] = None,
3923		root: int = 60,
3924		key: typing.Optional[str] = None,
3925		seed: typing.Optional[int] = None,
3926		device: subsequence.midi_utils.DeviceId = None,
3927		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3928	) -> subsequence.progressions.Progression:
3929
3930		"""Declare a self-contained chord part: a progression at a chosen harmonic rhythm.
3931
3932		The one-call form of ``p.progression()`` — it registers a pattern on
3933		*channel* that plays *progression* across *bars* (or *beats*), each chord
3934		lasting a length drawn from *harmonic_rhythm* (the musical term for how often
3935		the chords change).  It needs no ``composition.harmony()`` call and, with an
3936		explicit chord list or a ``key=``, no composition key either — so a
3937		drums-plus-one-chord-part sketch stays simple.
3938
3939		The progression is realised once, up front, and the same timeline plays every
3940		cycle (a stable phrase).  That timeline is returned so you can see exactly what
3941		was chosen — ``print(comp.chords(...))``.
3942
3943		Parameters:
3944			channel: MIDI channel for the chord part.
3945			progression: A chord-graph style name to generate from, or an explicit list
3946				of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``).
3947			harmonic_rhythm: How long each chord lasts — a number, a list of lengths,
3948				or ``between(low, high, step=...)``.  See ``p.progression()``.
3949			bars / beats: Length of the part (defaults to 4 beats if neither is given).  ``bars`` uses the
3950				composition's time signature.
3951			voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``).
3952			velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation.
3953			detached: Beats of silence before each next chord (``duration = length - detached``).
3954			root: MIDI root the voicings are centred on (e.g. 48 = C3).
3955			key: Key for a generated progression; defaults to the composition key.
3956			seed: Seed for the (otherwise fixed) realisation; defaults to the
3957				composition seed, so the part is reproducible.
3958			device: Optional output-device override.
3959			mirrors: Optional additional ``(device, channel)`` destinations.
3960
3961		Returns:
3962			The realised :class:`~subsequence.progressions.Progression`.
3963		"""
3964
3965		beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0])
3966		resolved_channel = self._resolve_channel(channel)
3967		resolved_key = key if key is not None else self.key
3968
3969		rng = random.Random(seed if seed is not None else self._seed)
3970		timeline = subsequence.progressions.realize(
3971			source = progression,
3972			harmonic_rhythm = harmonic_rhythm,
3973			key = resolved_key,
3974			length = beat_length,
3975			rng = rng,
3976			scale = self.scale or "ionian",
3977		)
3978
3979		captured_root = root
3980		captured_velocity = velocity
3981		captured_detached = detached
3982		captured_voicing = voicing
3983
3984		def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:
3985
3986			"""Replay the realised timeline as block chords each cycle (voicing per chord)."""
3987
3988			for chord, start, length in timeline:
3989				ring = length - captured_detached if (captured_detached and captured_detached < length) else length
3990				voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng)
3991				p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity)
3992
3993		# Unique, stable name so multiple chord parts don't collide in
3994		# _running_patterns — including two parts on the SAME channel, which
3995		# get a deterministic #2/#3 suffix in declaration order.
3996		base_name = f"chords@ch{resolved_channel}"
3997		chords_name = base_name
3998		suffix = 2
3999
4000		while chords_name in self._declared_names:
4001			chords_name = f"{base_name}#{suffix}"
4002			suffix += 1
4003
4004		chords_builder.__name__ = chords_name
4005		self._declared_names.add(chords_name)
4006
4007		primary: typing.Optional[typing.Tuple[int, int]]
4008		if isinstance(device, str):
4009			primary = None
4010		else:
4011			primary = (device if device is not None else 0, resolved_channel)
4012		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
4013
4014		self._declared_names.add(chords_builder.__name__)
4015
4016		if self._is_live and chords_builder.__name__ in self._running_patterns:
4017			running = self._running_patterns[chords_builder.__name__]
4018			running._builder_fn = chords_builder
4019			running._wants_chord = False
4020			logger.info(f"Hot-swapped chords: {chords_builder.__name__}")
4021			return timeline
4022
4023		pending = _PendingPattern(
4024			builder_fn = chords_builder,
4025			channel = resolved_channel,
4026			length = beat_length,
4027			default_grid = default_grid,
4028			drum_note_map = None,
4029			reschedule_lookahead = 1,
4030			voice_leading = False,
4031			mirrors = resolved_mirrors,
4032			device = 0 if (device is None or isinstance(device, str)) else device,
4033			raw_device = device,
4034		)
4035		self._pending_patterns.append(pending)
4036		return timeline
4037
4038	def phrase_part (
4039		self,
4040		*,
4041		channel: int,
4042		part: typing.Optional[str] = None,
4043		root: int = 60,
4044		bars: typing.Optional[float] = None,
4045		beats: typing.Optional[float] = None,
4046		velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None,
4047		fit: typing.Optional[float] = None,
4048		device: subsequence.midi_utils.DeviceId = None,
4049		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
4050	) -> None:
4051
4052		"""Declare a part that plays each section's bound Motif/Phrase.
4053
4054		The one-call consumer for :meth:`section_motifs` — it registers a
4055		pattern on *channel* that walks whatever value is bound to the
4056		current section for *part* (stateless position from the cycle
4057		counter, via ``p.phrase()``).  A section with no binding for the
4058		part is **silent** for that part — bind material or don't; no
4059		fallback guessing.
4060
4061		Parameters:
4062			channel: MIDI channel for the part.
4063			part: The part label to read from the registry (``None`` = the
4064				unlabelled binding).
4065			root: Register anchor for degree resolution.
4066			bars / beats: Cycle length of the part (defaults to 4 beats);
4067				the phrase is sliced one cycle window at a time.
4068			velocity: Optional override applied to every note.
4069			fit: Passed through (active with the melody engine stage).
4070			device: Optional output-device override.
4071			mirrors: Optional additional ``(device, channel)`` destinations.
4072
4073		Example::
4074
4075			composition.section_motifs("verse",  verse_line,  part="lead")
4076			composition.section_motifs("chorus", chorus_line, part="lead")
4077			composition.phrase_part(channel=4, part="lead", root=72, bars=2)
4078		"""
4079
4080		beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0])
4081		resolved_channel = self._resolve_channel(channel)
4082
4083		captured_part = part
4084		captured_root = root
4085		captured_velocity = velocity
4086		captured_fit = fit
4087
4088		def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:
4089
4090			"""Walk the current section's bound value (silent when unbound)."""
4091
4092			value = p.section_motif(captured_part)
4093
4094			if value is None:
4095				return	# unbound section: silence for this part, by design
4096
4097			p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit)
4098
4099		# Unique, stable name so multiple phrase parts don't collide —
4100		# including two parts on the SAME channel (deterministic #2/#3
4101		# suffixes in declaration order, the chords() convention).
4102		base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}"
4103		phrase_name = base_name
4104		suffix = 2
4105
4106		while phrase_name in self._declared_names:
4107			phrase_name = f"{base_name}#{suffix}"
4108			suffix += 1
4109
4110		phrase_builder.__name__ = phrase_name
4111		self._declared_names.add(phrase_name)
4112
4113		primary: typing.Optional[typing.Tuple[int, int]]
4114		if isinstance(device, str):
4115			primary = None
4116		else:
4117			primary = (device if device is not None else 0, resolved_channel)
4118		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
4119
4120		if self._is_live and phrase_builder.__name__ in self._running_patterns:
4121			running = self._running_patterns[phrase_builder.__name__]
4122			running._builder_fn = phrase_builder
4123			running._wants_chord = False
4124			logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}")
4125			return
4126
4127		pending = _PendingPattern(
4128			builder_fn = phrase_builder,
4129			channel = resolved_channel,
4130			length = beat_length,
4131			default_grid = default_grid,
4132			drum_note_map = None,
4133			reschedule_lookahead = 1,
4134			voice_leading = False,
4135			mirrors = resolved_mirrors,
4136			device = 0 if (device is None or isinstance(device, str)) else device,
4137			raw_device = device,
4138		)
4139		self._pending_patterns.append(pending)
4140
4141	def trigger (
4142		self,
4143		fn: typing.Callable,
4144		channel: int,
4145		beats: typing.Optional[float] = None,
4146		bars: typing.Optional[float] = None,
4147		steps: typing.Optional[float] = None,
4148		step_duration: typing.Optional[float] = None,
4149		quantize: float = 0,
4150		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
4151		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
4152		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
4153		chord: bool = False,
4154		device: subsequence.midi_utils.DeviceId = None,
4155		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
4156	) -> None:
4157
4158		"""
4159		Trigger a one-shot pattern immediately or on a quantized boundary.
4160
4161		This is useful for real-time response to sensors, OSC messages, or other
4162		external events. The builder function is called immediately with a fresh
4163		PatternBuilder, and the generated events are injected into the queue at
4164		the specified quantize boundary.
4165
4166		The builder function has the same API as a ``@composition.pattern``
4167		decorated function and can use all PatternBuilder methods: ``p.note()``,
4168		``p.euclidean()``, ``p.arpeggio()``, and so on.
4169
4170		See ``pattern()`` for the full description of ``beats``, ``bars``,
4171		``steps``, and ``step_duration``. Default is 1 beat.
4172
4173		Parameters:
4174			fn: The pattern builder function (same signature as ``@comp.pattern``).
4175			channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``).
4176			beats: Duration in beats (quarter notes, default 1).
4177			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
4178			steps: Step count for step mode. Requires ``step_duration=``.
4179			step_duration: Duration of one step in beats. Requires ``steps=``.
4180			quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default),
4181				``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*``
4182				constants from ``subsequence.constants.durations``.
4183			drum_note_map: Optional drum name mapping for this pattern.
4184			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
4185			nrpn_name_map: Optional mapping of NRPN parameter names to
4186				14-bit parameter numbers.
4187			chord: If ``True``, the builder function receives the current chord as
4188				a second parameter (same as ``@composition.pattern``).
4189			mirrors: Optional list of additional ``(device, channel)`` destinations
4190				to fire this one-shot onto in parallel with the primary destination.
4191
4192		Example:
4193			```python
4194			# Immediate single note (channels are 1-16 by default)
4195			composition.trigger(
4196				lambda p: p.note(60, beat=0, velocity=100, duration=0.5),
4197				channel=1
4198			)
4199
4200			# Quantized fill (next bar) — channel 10 is the GM drum channel
4201			import subsequence.constants.durations as dur
4202			composition.trigger(
4203				lambda p: p.euclidean("snare", pulses=7, velocity=90),
4204				channel=10,
4205				drum_note_map=gm_drums.GM_DRUM_MAP,
4206				quantize=dur.WHOLE
4207			)
4208
4209			# With chord context — the builder receives the chord as a second
4210			# argument when chord=True.
4211			composition.trigger(
4212				lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH),
4213				channel=1,
4214				quantize=dur.QUARTER,
4215				chord=True
4216			)
4217			```
4218		"""
4219
4220		# Resolve channel numbering
4221		resolved_channel = self._resolve_channel(channel)
4222
4223		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0])
4224
4225		# Resolve device index — for trigger() this is always concrete by call time,
4226		# so the mirror-to-self check has the full primary tuple available.
4227		resolved_device_idx = self._resolve_device_id(device)
4228		resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel))
4229
4230		# Create a temporary Pattern
4231		pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors)
4232
4233		# Create a PatternBuilder
4234		builder = subsequence.pattern_builder.PatternBuilder(
4235			pattern=pattern,
4236			cycle=0,  # One-shot patterns don't rebuild, so cycle is always 0
4237			drum_note_map=drum_note_map,
4238			cc_name_map=cc_name_map,
4239			nrpn_name_map=nrpn_name_map,
4240			section=self._form_state.get_section_info() if self._form_state else None,
4241			bar=self._builder_bar,
4242			conductor=self.conductor,
4243			rng=random.Random(),  # Fresh random state for each trigger
4244			tweaks={},
4245			default_grid=default_grid,
4246			data=self.data,
4247			held_notes=self._sequencer._held_notes
4248		)
4249
4250		# Call the builder function
4251		try:
4252
4253			current_chord = self.current_chord() if chord else None
4254
4255			if current_chord is not None:
4256				injected = _InjectedChord(current_chord, None)  # No voice leading for one-shots
4257				fn(builder, injected)
4258
4259			else:
4260				fn(builder)
4261
4262		except Exception:
4263			logger.exception("Error in trigger builder — pattern will be silent")
4264			return
4265
4266		# Calculate the start pulse based on quantize
4267		current_pulse = self._sequencer.pulse_count
4268		pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE
4269
4270		if quantize == 0:
4271			# Immediate: use current pulse
4272			start_pulse = current_pulse
4273
4274		else:
4275			# Quantize to the next multiple of (quantize * pulses_per_beat)
4276			quantize_pulses = int(quantize * pulses_per_beat)
4277			start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses
4278
4279		# Schedule the pattern for one-shot execution
4280		try:
4281			# Probe only: raises RuntimeError when not on the event loop.
4282			asyncio.get_running_loop()
4283			asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse))
4284
4285		except RuntimeError:
4286			# Not on the event loop — hand the coroutine to the loop thread.
4287			if self._sequencer._event_loop is not None:
4288				asyncio.run_coroutine_threadsafe(
4289					self._sequencer.schedule_pattern(pattern, start_pulse),
4290					loop=self._sequencer._event_loop
4291				)
4292			else:
4293				logger.warning("trigger() called before playback started; pattern ignored")
4294
4295	@property
4296	def is_clock_following (self) -> bool:
4297
4298		"""True if either the primary or any additional device is following external clock."""
4299
4300		return self._clock_follow or any(cf for _, _, cf in self._additional_inputs)
4301
4302
4303	def play (self) -> None:
4304
4305		"""
4306		Start the composition.
4307
4308		This call blocks until the program is interrupted (e.g., via Ctrl+C).
4309		It initializes the MIDI hardware, launches the background sequencer,
4310		and begins playback.
4311		"""
4312
4313		try:
4314			asyncio.run(self._run())
4315
4316		except KeyboardInterrupt:
4317			pass
4318
4319
4320	def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None:
4321
4322		"""Render the composition to a MIDI file without real-time playback.
4323
4324		Runs the sequencer as fast as possible (no timing delays) and stops
4325		when the first active limit is reached.  The result is saved as a
4326		standard MIDI file that can be imported into any DAW.
4327
4328		All patterns, scheduled callbacks, and harmony logic run exactly as
4329		they would during live playback — BPM transitions, generative fills,
4330		and probabilistic gates all work in render mode.  The only difference
4331		is that time is simulated rather than wall-clock driven.
4332
4333		Parameters:
4334			bars: Number of bars to render, or ``None`` for no bar limit
4335			      (default ``None``).  When both *bars* and *max_minutes* are
4336			      active, playback stops at whichever limit is reached first.
4337			filename: Output MIDI filename (default ``"render.mid"``).
4338			max_minutes: Safety cap on the length of rendered MIDI in minutes
4339			             (default ``60.0``).  Pass ``None`` to disable the time
4340			             cap — you must then provide an explicit *bars* value.
4341
4342		Raises:
4343			ValueError: If both *bars* and *max_minutes* are ``None``, which
4344			            would produce an infinite render.
4345
4346		Examples:
4347			```python
4348			# Default: renders up to 60 minutes of MIDI content.
4349			composition.render()
4350
4351			# Render exactly 64 bars (time cap still active as backstop).
4352			composition.render(bars=64, filename="demo.mid")
4353
4354			# Render up to 5 minutes of an infinite generative composition.
4355			composition.render(max_minutes=5, filename="five_min.mid")
4356
4357			# Remove the time cap — must supply bars instead.
4358			composition.render(bars=128, max_minutes=None, filename="long.mid")
4359			```
4360		"""
4361
4362		if bars is None and max_minutes is None:
4363			raise ValueError(
4364				"render() requires at least one limit: provide bars=, max_minutes=, or both. "
4365				"Passing both as None would produce an infinite render."
4366			)
4367
4368		self._sequencer.recording = True
4369		self._sequencer.record_filename = filename
4370		self._sequencer.render_mode = True
4371		self._sequencer.render_bars = bars if bars is not None else 0
4372		self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None
4373		asyncio.run(self._run())
4374
4375	def _broadcast_osc_status (self, bar: int) -> None:
4376
4377		"""
4378		Send the per-bar OSC status snapshot: bar number, current tempo,
4379		and (when active) the current chord name and form section.
4380		"""
4381
4382		if self._osc_server:
4383			self._osc_server.send("/bar", bar)
4384			self._osc_server.send("/bpm", self._sequencer.current_bpm)
4385
4386			sounding = self.current_chord()
4387			if sounding is not None:
4388				self._osc_server.send("/chord", sounding.name())
4389
4390			if self._form_state:
4391				info = self._form_state.get_section_info()
4392				if info:
4393					self._osc_server.send("/section", info.name)
4394
4395	async def _run (self) -> None:
4396
4397		"""
4398		Async entry point that schedules all patterns and runs the sequencer.
4399		"""
4400
4401		# 1. Pre-calculate MIDI input indices and configure sequencer clock follow.
4402		if self._input_device is not None:
4403			self._sequencer.input_device_name = self._input_device
4404			self._sequencer.clock_follow = self._clock_follow
4405			self._sequencer.clock_device_idx = 0
4406
4407			if not self._clock_follow:
4408				# Find first additional input that wants to be the clock master.
4409				for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1):
4410					if cf:
4411						self._sequencer.clock_follow = True
4412						self._sequencer.clock_device_idx = idx
4413						break
4414
4415		# Populate input device name mapping early (before opening ports) so we can
4416		# resolve CC mappings to integer device indices immediately.
4417		if self._sequencer.input_device_name:
4418			self._input_device_names[self._sequencer.input_device_name] = 0
4419			if self._input_device_alias is not None:
4420				self._input_device_names[self._input_device_alias] = 0
4421
4422		for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1):
4423			self._input_device_names[dev_name] = idx
4424			if alias:
4425				self._input_device_names[alias] = idx
4426
4427		# 2. Pre-calculate output device names.
4428		if self._sequencer.output_device_name:
4429			self._output_device_names[self._sequencer.output_device_name] = 0
4430			# Primary device (index 0) is open by now (_init_midi_output ran in
4431			# the Sequencer constructor), so its latency can be set safely here.
4432			if self._output_latency_ms:
4433				self._sequencer.set_device_latency(0, self._output_latency_ms)
4434
4435		# 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the
4436		# input-names map is fully populated above, and the callback thread needs
4437		# integer indices as soon as ports open.  OUTPUT names (cc_forward
4438		# output_device=, pattern device=) resolve after the additional outputs
4439		# are opened below; resolving them here matched against a map containing
4440		# only the primary and silently routed everything to device 0.
4441		for mapping in self._cc_mappings:
4442			raw = mapping.get('input_device')
4443			if isinstance(raw, str):
4444				mapping['input_device'] = self._resolve_input_device_id(raw)
4445		for fwd in self._cc_forwards:
4446			raw_in = fwd.get('input_device')
4447			if isinstance(raw_in, str):
4448				fwd['input_device'] = self._resolve_input_device_id(raw_in)
4449
4450		# 4. Share CC input mappings, forwards, and a reference to composition.data
4451		# with the sequencer BEFORE opening the ports. This ensures that any initial
4452		# messages in the OS buffer are correctly mapped as soon as the port opens.
4453		self._sequencer.cc_mappings = self._cc_mappings
4454		self._sequencer.cc_forwards = self._cc_forwards
4455		self._sequencer._composition_data = self.data
4456
4457		# Held-note input: create the tracker and resolve its channel/device
4458		# filter so the callback thread can buffer matching note events.
4459		if self._note_input is not None:
4460			if self._input_device is None and not self._additional_inputs:
4461				raise RuntimeError("note_input() requires a MIDI input — call composition.midi_input(device) first")
4462			raw_dev = self._note_input.get('input_device')
4463			if isinstance(raw_dev, str):
4464				raw_dev = self._resolve_input_device_id(raw_dev)
4465			self._sequencer._note_input_channel = self._note_input['channel']
4466			self._sequencer._note_input_device = raw_dev
4467			self._sequencer._held_notes = subsequence.held_notes.HeldNotes(
4468				release_ms = self._note_input['release_ms'],
4469				latch = self._note_input['latch'],
4470			)
4471
4472		# 5. Open MIDI input ports early. Even without a deliberate sleep, opening
4473		# them before pattern building minimizes the window for missed messages.
4474		# Primary input
4475		self._sequencer._open_midi_inputs()
4476
4477		# Additional inputs
4478		for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1):
4479			# Use the pre-calculated index
4480			callback = self._sequencer._make_input_callback(idx)
4481			open_name, port = subsequence.midi_utils.select_input_device(dev_name, callback)
4482			if open_name and port is not None:
4483				self._sequencer.add_input_device(open_name, port)
4484			else:
4485				logger.warning(f"Could not open additional input device '{dev_name}'")
4486
4487		# 6. Open additional MIDI output devices.
4488		for out in self._additional_outputs:
4489			open_name, port = subsequence.midi_utils.select_output_device(out.device)
4490			if open_name and port is not None:
4491				idx = self._sequencer.add_output_device(open_name, port, out.latency_ms)
4492				self._output_device_names[open_name] = idx
4493				if out.alias is not None:
4494					self._output_device_names[out.alias] = idx
4495			else:
4496				logger.warning(f"Could not open additional output device '{out.device}'")
4497
4498		# Warn if latency compensation adds noticeable whole-rig delay: the
4499		# slowest device defines the alignment point, so every faster device is
4500		# delayed up to that amount and live-input feel suffers.
4501		self._warn_if_high_latency()
4502
4503		# Resolve any name-based output device IDs on patterns that may have been added
4504		# for additional output devices.
4505		self._resolve_pending_devices()
4506
4507		# Resolve cc_forward output-device names now that every output port and
4508		# alias is registered (resolving earlier silently routed to device 0).
4509		for fwd in self._cc_forwards:
4510			raw_out = fwd.get('output_device')
4511			if isinstance(raw_out, str):
4512				fwd['output_device'] = self._resolve_device_id(raw_out)
4513
4514		# Pass clock output flag (suppressed automatically when clock_follow=True).
4515		self._sequencer.clock_output = self._clock_output and not self.is_clock_following
4516
4517		# Create Ableton Link clock if comp.link() was called.
4518		if self._link_quantum is not None:
4519			self._sequencer._link_clock = subsequence.link_clock.LinkClock(
4520				bpm = self.bpm,
4521				quantum = self._link_quantum,
4522				loop = asyncio.get_running_loop(),
4523			)
4524
4525		# Deal play-time streams.  Every stream is NAME-keyed (crc32 of
4526		# "seed:name", see _stream_seed) rather than dealt from one master in
4527		# registration order: adding or removing one consumer can never shift
4528		# another's stream, and patterns added live derive identically in
4529		# _build_pattern_from_pending.  When no seed is set, components keep
4530		# their own unseeded RNGs (existing behaviour).
4531		if self._seed is not None:
4532
4533			harmony_stream = self._stream("play:harmony")
4534			if self._harmonic_state is not None and harmony_stream is not None:
4535				self._harmonic_state.rng = harmony_stream
4536
4537			form_stream = self._stream("play:form")
4538			if self._form_state is not None and form_stream is not None:
4539				self._form_state._rng = form_stream
4540
4541		# The clocks fire BEFORE pattern rebuilds at the same pulse, and their
4542		# lookahead is RAISED to the maximum pattern lookahead (never patterns
4543		# clamped down): when a pattern rebuilds for its next cycle, the form
4544		# state and the harmony window already describe that cycle.
4545		bar_beats = float(self.time_signature[0])
4546
4547		pattern_lookaheads = [pending.reschedule_lookahead for pending in self._pending_patterns]
4548		pattern_lookaheads += [pattern.reschedule_lookahead for pattern in self._running_patterns.values()]
4549		max_pattern_lookahead = max(pattern_lookaheads, default = 1)
4550
4551		clock_lookahead = max(1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead))
4552
4553		if clock_lookahead > bar_beats:
4554			logger.warning(
4555				"A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — "
4556				"the harmony/form clocks fire at most one bar ahead, so that pattern may "
4557				"rebuild before the window covers its cycle start.",
4558				clock_lookahead, bar_beats,
4559			)
4560			clock_lookahead = bar_beats
4561
4562		# Minimum span >= maximum lookahead: the clock cannot prepare a chord
4563		# boundary that arrives sooner than it fires.  Harmonic motion faster
4564		# than this floor stays available at the part level (p.progression),
4565		# where placement is not clock-bound.
4566		def _check_span_floor (progression: typing.Optional[Progression], label: str) -> None:
4567			if progression is None:
4568				return
4569			shortest = min(span.beats for span in progression.spans)
4570			if shortest < clock_lookahead - 1e-9:
4571				raise ValueError(
4572					f"{label}: shortest chord span ({shortest:g} beats) is below the clock "
4573					f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). "
4574					"Lengthen the span, lower the pattern lookaheads, or place fast harmony "
4575					"at the part level with p.progression()."
4576				)
4577
4578		_check_span_floor(self._bound_progression, "harmony(progression=)")
4579		for section_name, section_progression in self._section_progressions.items():
4580			_check_span_floor(section_progression, f"section_chords({section_name!r})")
4581
4582		# The form clock MUST be registered before the harmonic clock: same-pulse
4583		# fixed callbacks fire in registration order (and all fixed callbacks fire
4584		# before callback sequences), and on a section-boundary bar the harmonic
4585		# clock reads the current section (via _get_section_progression) to decide
4586		# whether to walk that section's chords.  Registering harmony first would
4587		# make it read the OLD section on every boundary, shifting section_chords()
4588		# replays by one bar and bleeding them across sections.
4589		if self._form_state is not None:
4590
4591			await schedule_form(
4592				sequencer = self._sequencer,
4593				form_state = self._form_state,
4594				reschedule_lookahead = clock_lookahead
4595			)
4596
4597		self._harmony_horizon.reset()
4598
4599		if self._harmonic_state is not None or self._bound_progression is not None or self._section_progressions:
4600
4601			def _get_section_progression () -> typing.Optional[typing.Tuple[str, int, typing.Optional[Progression]]]:
4602				"""Return (section_name, section_index, Progression|None) for the current section, or None."""
4603				if self._form_state is None:
4604					return None
4605				info = self._form_state.get_section_info()
4606				if info is None:
4607					return None
4608				prog = self._section_progressions.get(info.name)
4609				return (info.name, info.index, prog)
4610
4611			await schedule_harmonic_clock(
4612				sequencer = self._sequencer,
4613				get_harmonic_state = lambda: self._harmonic_state,
4614				horizon = self._harmony_horizon,
4615				bar_beats = bar_beats,
4616				cycle_beats = self._harmony_cycle_beats or 4,
4617				get_bound_progression = lambda: self._bound_progression,
4618				get_section_progression = _get_section_progression,
4619				get_pinned = self._pinned_chords.get,
4620				reschedule_lookahead = clock_lookahead,
4621			)
4622
4623		# Bar counter - always active so p.bar is available to all builders.
4624		def _advance_builder_bar (pulse: int) -> None:
4625			self._builder_bar += 1
4626
4627		first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat)
4628
4629		await self._sequencer.schedule_callback_repeating(
4630			callback = _advance_builder_bar,
4631			interval_beats = self.time_signature[0],
4632			start_pulse = first_bar_pulse,
4633			reschedule_lookahead = 1
4634		)
4635
4636		# Run wait_for_initial=True scheduled functions and block until all complete.
4637		# This ensures composition.data is populated before patterns build.
4638		initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial]
4639
4640		if initial_tasks:
4641
4642			names = ", ".join(getattr(t.fn, '__name__', repr(t.fn)) for t in initial_tasks)
4643			logger.info(f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}")
4644
4645			async def _run_initial (fn: typing.Callable) -> None:
4646
4647				accepts_ctx = _fn_has_parameter(fn, "p")
4648				ctx = ScheduleContext(cycle=0)
4649
4650				try:
4651					if inspect.iscoroutinefunction(fn):
4652						await (fn(ctx) if accepts_ctx else fn())
4653					else:
4654						loop = asyncio.get_running_loop()
4655						call = (lambda: fn(ctx)) if accepts_ctx else fn
4656						await loop.run_in_executor(None, call)
4657				except Exception as exc:
4658					logger.warning(f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}")
4659
4660			await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks])
4661
4662		for pending_task in self._pending_scheduled:
4663
4664			accepts_ctx = _fn_has_parameter(pending_task.fn, "p")
4665
4666			# A wait_for_initial task already ran once as cycle 0 (the blocking
4667			# pre-roll above), so its repeating wrapper starts at cycle 1 — keeping
4668			# ScheduleContext.cycle monotonic across the initial and repeating runs.
4669			wrapped = _make_safe_callback(
4670				pending_task.fn,
4671				accepts_context = accepts_ctx,
4672				start_cycle = 1 if pending_task.wait_for_initial else 0,
4673			)
4674
4675			# wait_for_initial=True implies defer — no point firing at pulse 0
4676			# after the blocking run just completed.  defer=True skips the
4677			# backshift fire so the first repeating call happens one full cycle
4678			# later.
4679			if pending_task.wait_for_initial or pending_task.defer:
4680				start_pulse = int(pending_task.cycle_beats * self._sequencer.pulses_per_beat)
4681			else:
4682				start_pulse = 0
4683
4684			await self._sequencer.schedule_callback_repeating(
4685				callback = wrapped,
4686				interval_beats = pending_task.cycle_beats,
4687				start_pulse = start_pulse,
4688				reschedule_lookahead = pending_task.reschedule_lookahead
4689			)
4690
4691		# Build Pattern objects from pending registrations.
4692		patterns: typing.List[subsequence.pattern.Pattern] = []
4693
4694		for i, pending in enumerate(self._pending_patterns):
4695
4696			pattern = self._build_pattern_from_pending(pending)
4697			patterns.append(pattern)
4698
4699		await schedule_patterns(
4700			sequencer = self._sequencer,
4701			patterns = patterns,
4702			start_pulse = 0
4703		)
4704
4705		# Populate the running patterns dict for live hot-swap and mute/unmute.
4706		for i, pending in enumerate(self._pending_patterns):
4707			name = pending.builder_fn.__name__
4708			self._running_patterns[name] = patterns[i]
4709
4710		# Everything pending is running now; drop the declarations so a later
4711		# live reload cannot graduate stale copies.
4712		self._pending_patterns = []
4713
4714		if self._display is not None and not self._sequencer.render_mode:
4715			self._display.start()
4716			self._sequencer.on_event("bar",  self._display.update)
4717			self._sequencer.on_event("beat", self._display.update)
4718
4719		if self._live_server is not None:
4720			await self._live_server.start()
4721
4722		if self._osc_server is not None:
4723			await self._osc_server.start()
4724			self._sequencer.osc_server = self._osc_server
4725			self._sequencer.on_event("bar", self._broadcast_osc_status)
4726
4727		# Start keystroke listener if hotkeys are enabled and not in render mode.
4728		if self._hotkeys_enabled and not self._sequencer.render_mode:
4729			self._keystroke_listener = subsequence.keystroke.KeystrokeListener()
4730			self._keystroke_listener.start()
4731
4732			if self._keystroke_listener.active:
4733				# Listener started successfully — register the bar handler
4734				# and show all bindings so the user knows what's available.
4735				self._sequencer.on_event("bar", self._process_hotkeys)
4736				self._list_hotkeys()
4737			# If not active, KeystrokeListener.start() already logged a warning.
4738
4739		if self._web_ui_enabled and not self._sequencer.render_mode:
4740			self._web_ui_server = subsequence.web_ui.WebUI(self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host)
4741			self._web_ui_server.start()
4742
4743		try:
4744			await run_until_stopped(self._sequencer)
4745		finally:
4746			# Tear down every service even if run_until_stopped (or an earlier
4747			# stop) raised, and guard each individually, so one failure can't
4748			# strand the rest — most importantly the keystroke listener's
4749			# terminal restore.
4750			if self._web_ui_server is not None:
4751				try:
4752					self._web_ui_server.stop()
4753				except Exception:
4754					logger.exception("Error stopping web UI")
4755
4756			if self._live_server is not None:
4757				try:
4758					await self._live_server.stop()
4759				except Exception:
4760					logger.exception("Error stopping live server")
4761
4762			if self._live_reloader is not None:
4763				try:
4764					self._live_reloader.stop()
4765				except Exception:
4766					logger.exception("Error stopping live reloader")
4767
4768			if self._osc_server is not None:
4769				try:
4770					await self._osc_server.stop()
4771				except Exception:
4772					logger.exception("Error stopping OSC server")
4773				self._sequencer.osc_server = None
4774
4775			if self._display is not None:
4776				try:
4777					self._display.stop()
4778				except Exception:
4779					logger.exception("Error stopping display")
4780
4781			if self._keystroke_listener is not None:
4782				try:
4783					self._keystroke_listener.stop()
4784				except Exception:
4785					logger.exception("Error stopping keystroke listener")
4786				self._keystroke_listener = None
4787
4788	def _build_pattern_from_pending (self, pending: _PendingPattern, start_pulse: int = 0) -> subsequence.pattern.Pattern:
4789
4790		"""
4791		Create a Pattern from a pending registration using a temporary subclass.
4792
4793		The pattern's play stream is dealt here, keyed by NAME (crc32 of
4794		"seed:name" plus any reroll nonce), so registration order is
4795		irrelevant and a pattern added live gets exactly the stream it would
4796		have had at startup.  ``start_pulse`` anchors the first cycle on the
4797		beat axis so the initial build reads the harmony window at the right
4798		place (the sequencer keeps the anchor current on every reschedule).
4799		"""
4800
4801		composition_ref = self
4802		rng = self._stream(pending.builder_fn.__name__)
4803
4804		class _DecoratorPattern (subsequence.pattern.Pattern):
4805
4806			"""
4807			Pattern subclass that delegates to a builder function on each reschedule.
4808			"""
4809
4810			def __init__ (self, pending: _PendingPattern, pattern_rng: typing.Optional[random.Random] = None) -> None:
4811
4812				"""
4813				Initialize the decorator pattern from pending registration details.
4814				"""
4815
4816				super().__init__(
4817					channel = pending.channel,
4818					length = pending.length,
4819					reschedule_lookahead = pending.reschedule_lookahead,
4820					device = pending.device,
4821					mirrors = pending.mirrors,
4822				)
4823
4824				self._builder_fn = pending.builder_fn
4825				self._drum_note_map = pending.drum_note_map
4826				self._cc_name_map = pending.cc_name_map
4827				self._nrpn_name_map = pending.nrpn_name_map
4828				self._default_grid: int = pending.default_grid
4829				self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord")
4830				self._cycle_count = 0
4831				self._rng = pattern_rng
4832				self._muted = False
4833				self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = (
4834					subsequence.voicings.VoiceLeadingState() if pending.voice_leading else None
4835				)
4836				self._tweaks: typing.Dict[str, typing.Any] = {}
4837
4838				# Anchor of the cycle being built, on the absolute pulse axis.
4839				# The sequencer updates this on every reschedule; the initial
4840				# value is the pattern's first scheduled start.
4841				self._cycle_start_pulse = start_pulse
4842
4843				self._rebuild()
4844
4845			def _rebuild (self) -> None:
4846
4847				"""
4848				Clear steps and call the builder function to repopulate.
4849				"""
4850
4851				self.steps = {}
4852				self.cc_events = []
4853				self.osc_events = []
4854				self.raw_note_events = []
4855				current_cycle = self._cycle_count
4856				self._cycle_count += 1
4857
4858				# lock(): re-deal the stream from its effective seed every
4859				# rebuild so a locked pattern realizes identically each cycle.
4860				# Checked here (engine-side) so it survives live reload.
4861				if self._builder_fn.__name__ in composition_ref._locked_names:
4862					locked_seed = composition_ref._stream_seed(self._builder_fn.__name__)
4863					if locked_seed is not None:
4864						self._rng = random.Random(locked_seed)
4865
4866				if self._muted:
4867					return
4868
4869				# The harmony view for this cycle, anchored at its start beat —
4870				# under variable harmonic rhythm the window, not the engine's
4871				# mutating singleton, is the source of truth.
4872				harmony_view: typing.Optional[HarmonyView] = None
4873
4874				if not composition_ref._harmony_horizon.is_empty:
4875					origin_beat = self._cycle_start_pulse / composition_ref._sequencer.pulses_per_beat
4876					harmony_view = HarmonyView(composition_ref._harmony_horizon, origin_beat)
4877
4878				builder = subsequence.pattern_builder.PatternBuilder(
4879					pattern = self,
4880					cycle = current_cycle,
4881					drum_note_map = self._drum_note_map,
4882					cc_name_map = self._cc_name_map,
4883					nrpn_name_map = self._nrpn_name_map,
4884					section = composition_ref._form_state.get_section_info() if composition_ref._form_state else None,
4885					bar = composition_ref._builder_bar,
4886					conductor = composition_ref.conductor,
4887					rng = self._rng,
4888					tweaks = self._tweaks,
4889					default_grid = self._default_grid,
4890					data = composition_ref.data,
4891					key = composition_ref.key,
4892					scale = composition_ref.scale,
4893					time_signature = composition_ref.time_signature,
4894					held_notes = composition_ref._sequencer._held_notes,
4895					harmony = harmony_view,
4896					section_motifs = composition_ref._section_motifs
4897				)
4898
4899				try:
4900
4901					if self._wants_chord:
4902
4903						# The two-parameter convention: the injected chord is
4904						# the cycle-start snapshot from the window (falling
4905						# back to the engine before the clock has run).
4906						chord = harmony_view.chord if harmony_view is not None else (
4907							composition_ref._harmonic_state.get_current_chord()
4908							if composition_ref._harmonic_state is not None else None
4909						)
4910
4911						if chord is not None:
4912							injected = _InjectedChord(
4913								chord,
4914								self._voice_leading_state,
4915								next_chord = harmony_view.next_chord if harmony_view is not None else None,
4916								beats_remaining = harmony_view.until_change if harmony_view is not None else None,
4917							)
4918							self._builder_fn(builder, injected)
4919						else:
4920							self._builder_fn(builder)
4921
4922					else:
4923						self._builder_fn(builder)
4924
4925				except Exception:
4926					logger.exception("Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", self._builder_fn.__name__, current_cycle)
4927
4928				# Auto-apply global tuning if set and not already applied by the builder.
4929				if (
4930					composition_ref._tuning is not None
4931					and not builder._tuning_applied
4932					and not (composition_ref._tuning_exclude_drums and self._drum_note_map)
4933				):
4934					import subsequence.tuning as _tuning_mod
4935					_tuning_mod.apply_tuning_to_pattern(
4936						self,
4937						composition_ref._tuning,
4938						bend_range=composition_ref._tuning_bend_range,
4939						channels=composition_ref._tuning_channels,
4940						reference_note=composition_ref._tuning_reference_note,
4941					)
4942
4943			def on_reschedule (self) -> None:
4944
4945				"""
4946				Rebuild the pattern from the builder function before the next cycle.
4947				"""
4948
4949				self._rebuild()
4950
4951		return _DecoratorPattern(pending, rng)

The top-level controller for a musical piece.

The Composition object manages the global clock (Sequencer), the harmonic progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns. It serves as the main entry point for defining your music.

Typical workflow:

  1. Initialize Composition with BPM and Key.
  2. Define harmony and form (optional).
  3. Register patterns using the @composition.pattern decorator.
  4. Call composition.play() to start the music.
Composition( output_device: Optional[str] = None, bpm: float = 120, time_signature: Tuple[int, int] = (4, 4), key: Optional[str] = None, scale: Optional[str] = None, seed: Optional[int] = None, record: bool = False, record_filename: Optional[str] = None, zero_indexed_channels: bool = False, latency_ms: float = 0.0)
1039	def __init__ (
1040		self,
1041		output_device: typing.Optional[str] = None,
1042		bpm: float = 120,
1043		time_signature: typing.Tuple[int, int] = (4, 4),
1044		key: typing.Optional[str] = None,
1045		scale: typing.Optional[str] = None,
1046		seed: typing.Optional[int] = None,
1047		record: bool = False,
1048		record_filename: typing.Optional[str] = None,
1049		zero_indexed_channels: bool = False,
1050		latency_ms: float = 0.0
1051	) -> None:
1052
1053		"""
1054		Initialize a new composition.
1055
1056		Parameters:
1057			output_device: The exact name of the MIDI output port to use,
1058				as reported by ``mido.get_output_names()``. Matching is
1059				strict — the string must equal an entry in that list
1060				verbatim. On Linux/ALSA, names include the client and
1061				port IDs (e.g.
1062				``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``); the
1063				trailing ``:client:port`` digits are assigned in
1064				connection order and can change between reboots or when
1065				a virtual port is recreated. To look up the current
1066				names::
1067
1068				    import mido
1069				    for n in mido.get_output_names(): print(n)
1070
1071				If `None`, Subsequence auto-discovers — uses the only
1072				available device, or prompts to choose if several exist.
1073			bpm: Initial tempo in beats per minute (default 120).
1074			key: The root key of the piece (e.g., "C", "F#", "Bb").
1075				Required if you plan to use `harmony()`.
1076			scale: The scale/mode of the piece (e.g. "minor", "dorian",
1077				or any registered scale name).  Used to resolve scale
1078				degrees in motifs; defaults to major (ionian) when unset.
1079			seed: An optional integer for deterministic randomness. When set,
1080				every random decision (chord choices, drum probability, etc.)
1081				will be identical on every run.
1082			record: When True, record all MIDI events to a file.
1083			record_filename: Optional filename for the recording (defaults to timestamp).
1084			zero_indexed_channels: When False (default), MIDI channels use
1085				1-based numbering (1-16) matching instrument labelling.
1086				Channel 10 is drums, the way musicians and hardware panels
1087				show it. When True, channels use 0-based numbering (0-15)
1088				matching the raw MIDI protocol.
1089			latency_ms: Physical output latency of the primary device in
1090				milliseconds, for delay compensation (default 0.0, must be
1091				non-negative). Set this when the primary output sounds late
1092				(e.g. a software sampler) so Subsequence delays faster
1093				devices to line everything up. See ``midi_output()`` for
1094				additional devices.
1095
1096		Example:
1097			```python
1098			comp = subsequence.Composition(bpm=128, key="Eb", seed=123)
1099			```
1100		"""
1101
1102		if latency_ms < 0:
1103			raise ValueError(f"latency_ms must be non-negative — got {latency_ms}")
1104
1105		self.output_device = output_device
1106		self.bpm = bpm
1107		self.time_signature = time_signature
1108		self.key = key
1109		self.scale = scale
1110		self._seed: typing.Optional[int] = seed
1111		self._zero_indexed_channels: bool = zero_indexed_channels
1112		self._output_latency_ms: float = latency_ms
1113
1114		# Determinism plumbing: named-stream derivation state.  Build-time
1115		# consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so
1116		# adding one call never shifts another's stream; play-time pattern
1117		# streams are name-keyed in _build_pattern_from_pending.
1118		self._freeze_count: int = 0
1119		self._harmony_count: int = 0
1120		self._form_count: int = 0
1121		self._reroll_nonces: typing.Dict[str, int] = {}
1122		self._locked_names: typing.Set[str] = set()
1123
1124		self._sequencer = subsequence.sequencer.Sequencer(
1125			output_device_name = output_device,
1126			initial_bpm = bpm,
1127			time_signature = time_signature,
1128			record = record,
1129			record_filename = record_filename
1130		)
1131
1132		self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None
1133		self._harmony_cycle_beats: typing.Optional[int] = None
1134		self._harmony_reschedule_lookahead: float = 1
1135		self._section_progressions: typing.Dict[str, Progression] = {}
1136		self._bound_progression: typing.Optional[Progression] = None
1137		self._pinned_chords: typing.Dict[int, typing.Any] = {}
1138		self._harmony_horizon = _HarmonyHorizon()
1139		self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {}
1140		self._pending_patterns: typing.List[_PendingPattern] = []
1141		# Names of patterns declared by the most recent live-reload exec (added by
1142		# pattern()/layer() as they run); the deletion diff in _apply_source_async
1143		# tears down any running pattern absent from this set.
1144		self._declared_names: typing.Set[str] = set()
1145		self._pending_scheduled: typing.List[_PendingScheduled] = []
1146		self._form_state: typing.Optional[subsequence.form_state.FormState] = None
1147		self._builder_bar: int = 0
1148		self._display: typing.Optional[subsequence.display.Display] = None
1149		self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None
1150		self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None
1151		self._is_live: bool = False
1152		self._running_patterns: typing.Dict[str, typing.Any] = {}
1153		self._input_device: typing.Optional[str] = None
1154		self._input_device_alias: typing.Optional[str] = None
1155		self._clock_follow: bool = False
1156		self._clock_output: bool = False
1157		self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = []
1158		self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = []
1159		# Held-note input config from note_input() (None = not declared).
1160		self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None
1161		# Additional output devices registered with midi_output() after construction.
1162		self._additional_outputs: typing.List[_AdditionalOutput] = []
1163		# Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool)
1164		self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = []
1165		# Maps alias/name → output device index (populated in _run after all devices are opened).
1166		self._output_device_names: typing.Dict[str, int] = {}
1167		# Maps alias/name → input device index (populated in _run after all input devices are opened).
1168		self._input_device_names: typing.Dict[str, int] = {}
1169		self.data: typing.Dict[str, typing.Any] = {}
1170		self._osc_server: typing.Optional[subsequence.osc.OscServer] = None
1171		self.conductor = subsequence.conductor.Conductor()
1172		self._web_ui_enabled: bool = False
1173		self._web_ui_http_host: str = "127.0.0.1"
1174		self._web_ui_ws_host: str = "127.0.0.1"
1175		self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None
1176		self._link_quantum: typing.Optional[float] = None
1177
1178		# Hotkey state — populated by hotkeys() and hotkey().
1179		self._hotkeys_enabled: bool = False
1180		self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {}
1181		self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = []
1182		self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None
1183
1184		# Tuning state — populated by tuning().
1185		self._tuning: typing.Optional[typing.Any] = None       # subsequence.tuning.Tuning
1186		self._tuning_bend_range: float = 2.0
1187		self._tuning_channels: typing.Optional[typing.List[int]] = None
1188		self._tuning_reference_note: int = 60
1189		self._tuning_exclude_drums: bool = True

Initialize a new composition.

Arguments:
  • output_device: The exact name of the MIDI output port to use, as reported by mido.get_output_names(). Matching is strict — the string must equal an entry in that list verbatim. On Linux/ALSA, names include the client and port IDs (e.g. "Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"); the trailing :client:port digits are assigned in connection order and can change between reboots or when a virtual port is recreated. To look up the current names::

    import mido
    for n in mido.get_output_names(): print(n)
    

    If None, Subsequence auto-discovers — uses the only available device, or prompts to choose if several exist.

  • bpm: Initial tempo in beats per minute (default 120).
  • key: The root key of the piece (e.g., "C", "F#", "Bb"). Required if you plan to use harmony().
  • scale: The scale/mode of the piece (e.g. "minor", "dorian", or any registered scale name). Used to resolve scale degrees in motifs; defaults to major (ionian) when unset.
  • seed: An optional integer for deterministic randomness. When set, every random decision (chord choices, drum probability, etc.) will be identical on every run.
  • record: When True, record all MIDI events to a file.
  • record_filename: Optional filename for the recording (defaults to timestamp).
  • zero_indexed_channels: When False (default), MIDI channels use 1-based numbering (1-16) matching instrument labelling. Channel 10 is drums, the way musicians and hardware panels show it. When True, channels use 0-based numbering (0-15) matching the raw MIDI protocol.
  • latency_ms: Physical output latency of the primary device in milliseconds, for delay compensation (default 0.0, must be non-negative). Set this when the primary output sounds late (e.g. a software sampler) so Subsequence delays faster devices to line everything up. See midi_output() for additional devices.
Example:
comp = subsequence.Composition(bpm=128, key="Eb", seed=123)
output_device
bpm
time_signature
key
scale
data: Dict[str, Any]
conductor
harmonic_state: Optional[subsequence.harmonic_state.HarmonicState]
1385	@property
1386	def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]:
1387		"""The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called."""
1388		return self._harmonic_state

The active HarmonicState, or None if harmony() has not been called.

def current_chord(self) -> Optional[Any]:
1390	def current_chord (self) -> typing.Optional[typing.Any]:
1391
1392		"""The chord sounding at the playhead, or ``None`` without harmony.
1393
1394		Reads the harmony window at the current pulse, so it stays accurate
1395		under variable harmonic rhythm and clock lookahead (the engine's
1396		``current_chord`` flips *lookahead* beats early — this does not).
1397		Falls back to the engine's chord before playback starts.  The chord
1398		may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span
1399		is spiced; it duck-types the ``Chord`` voicing protocol either way.
1400		"""
1401
1402		if not self._harmony_horizon.is_empty:
1403			beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat
1404			chord = self._harmony_horizon.chord_at(beat)
1405			if chord is not None:
1406				return chord
1407
1408		if self._harmonic_state is not None:
1409			return self._harmonic_state.get_current_chord()
1410
1411		return None

The chord sounding at the playhead, or None without harmony.

Reads the harmony window at the current pulse, so it stays accurate under variable harmonic rhythm and clock lookahead (the engine's current_chord flips lookahead beats early — this does not). Falls back to the engine's chord before playback starts. The chord may be a decorated wrapper (Am9, C/G) when the sounding span is spiced; it duck-types the Chord voicing protocol either way.

form_state: Optional[subsequence.form_state.FormState]
1413	@property
1414	def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]:
1415		"""The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called."""
1416		return self._form_state

The active subsequence.form_state.FormState, or None if form() has not been called.

sequencer: subsequence.sequencer.Sequencer
1418	@property
1419	def sequencer (self) -> subsequence.sequencer.Sequencer:
1420		"""The underlying ``Sequencer`` instance."""
1421		return self._sequencer

The underlying Sequencer instance.

running_patterns: Dict[str, Any]
1423	@property
1424	def running_patterns (self) -> typing.Dict[str, typing.Any]:
1425		"""The currently active patterns, keyed by name."""
1426		return self._running_patterns

The currently active patterns, keyed by name.

builder_bar: int
1428	@property
1429	def builder_bar (self) -> int:
1430		"""Current bar index used by pattern builders."""
1431		return self._builder_bar

Current bar index used by pattern builders.

def harmony( self, style: Union[str, subsequence.chord_graphs.ChordGraph, NoneType] = None, cycle_beats: int = 4, dominant_7th: bool = True, gravity: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = 0.4, reschedule_lookahead: float = 1, progression: Optional[Any] = None) -> None:
1463	def harmony (
1464		self,
1465		style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None,
1466		cycle_beats: int = 4,
1467		dominant_7th: bool = True,
1468		gravity: float = 1.0,
1469		nir_strength: float = 0.5,
1470		minor_turnaround_weight: float = 0.0,
1471		root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1472		reschedule_lookahead: float = 1,
1473		progression: typing.Optional[typing.Any] = None,
1474	) -> None:
1475
1476		"""
1477		Configure the harmonic logic and chord change intervals.
1478
1479		Two sources, combinable: a **bound progression** (``progression=`` — a
1480		:class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``,
1481		or chord names) walked span by span on the global clock; and/or a
1482		**graph style** stepping live chords.  With only a progression bound,
1483		it loops on exhaustion; with a style configured too, exhaustion falls
1484		through to live stepping (the frozen-replay bridge).  Calling with
1485		neither argument keeps today's default live engine
1486		(``style="functional_major"``).
1487
1488		Parameters:
1489			style: The harmonic style to use. Built-in: "functional_major"
1490				(alias "diatonic_major"), "turnaround", "aeolian_minor",
1491				"phrygian_minor", "lydian_major", "dorian_minor",
1492				"chromatic_mediant", "suspended", "mixolydian", "whole_tone",
1493				"diminished". See README for full descriptions.
1494			cycle_beats: How many beats each live chord lasts (default 4).
1495				Bound progressions carry their own harmonic rhythm in their
1496				spans, so this applies to live stepping only.
1497			dominant_7th: Whether to include V7 chords (default True).
1498			gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord.
1499			nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement
1500				expectations.
1501			minor_turnaround_weight: For "turnaround" style, influences major vs minor feel.
1502			root_diversity: Root-repetition damping (0.0 to 1.0). Each recent
1503				chord sharing a candidate's root reduces the weight to 40% at
1504				the default (0.4). Set to 1.0 to disable.
1505			reschedule_lookahead: How many beats in advance to calculate the
1506				next chord.
1507			progression: A progression to bind to the global clock.  Key-
1508				relative content resolves now, against the composition key
1509				and scale (binding freezes one realisation).
1510
1511		Example:
1512			```python
1513			# A moody minor progression that changes every 8 beats
1514			comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4)
1515
1516			# Manual harmony driving everything — loops forever
1517			comp.harmony(progression=subsequence.progression([1, 6, 3, 7]))
1518			```
1519		"""
1520
1521		if style is None and progression is None:
1522			style = "functional_major"
1523
1524		if style is not None:
1525
1526			if self.key is None:
1527				raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor")
1528
1529			preserved_history: typing.List[subsequence.chords.Chord] = []
1530			preserved_current: typing.Optional[subsequence.chords.Chord] = None
1531
1532			if self._harmonic_state is not None:
1533				preserved_history = self._harmonic_state.history.copy()
1534				preserved_current = self._harmonic_state.current_chord
1535
1536			# Per-call salted build stream (harmony:1, harmony:2, ...): a re-call
1537			# gets its own deterministic stream while history and current chord
1538			# are preserved above, and adding a re-call never shifts any other
1539			# consumer's stream.
1540			self._harmony_count += 1
1541
1542			self._harmonic_state = subsequence.harmonic_state.HarmonicState(
1543				key_name = self.key,
1544				graph_style = style,
1545				include_dominant_7th = dominant_7th,
1546				key_gravity_blend = gravity,
1547				nir_strength = nir_strength,
1548				minor_turnaround_weight = minor_turnaround_weight,
1549				root_diversity = root_diversity,
1550				rng = self._stream(f"harmony:{self._harmony_count}")
1551			)
1552
1553			if preserved_history:
1554				self._harmonic_state.history = preserved_history
1555			if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current):
1556				self._harmonic_state.current_chord = preserved_current
1557
1558		if progression is not None:
1559			self._bound_progression = self._coerce_progression(progression, "harmony(progression=)")
1560
1561		self._harmony_cycle_beats = cycle_beats
1562		self._harmony_reschedule_lookahead = reschedule_lookahead
1563
1564		# A re-call invalidates whatever the horizon had planned.
1565		self._harmony_horizon.invalidate_future()

Configure the harmonic logic and chord change intervals.

Two sources, combinable: a bound progression (progression= — a Progression value, an element list like [1, 6, 3, "bVII7"], or chord names) walked span by span on the global clock; and/or a graph style stepping live chords. With only a progression bound, it loops on exhaustion; with a style configured too, exhaustion falls through to live stepping (the frozen-replay bridge). Calling with neither argument keeps today's default live engine (style="functional_major").

Arguments:
  • style: The harmonic style to use. Built-in: "functional_major" (alias "diatonic_major"), "turnaround", "aeolian_minor", "phrygian_minor", "lydian_major", "dorian_minor", "chromatic_mediant", "suspended", "mixolydian", "whole_tone", "diminished". See README for full descriptions.
  • cycle_beats: How many beats each live chord lasts (default 4). Bound progressions carry their own harmonic rhythm in their spans, so this applies to live stepping only.
  • dominant_7th: Whether to include V7 chords (default True).
  • gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord.
  • nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement expectations.
  • minor_turnaround_weight: For "turnaround" style, influences major vs minor feel.
  • root_diversity: Root-repetition damping (0.0 to 1.0). Each recent chord sharing a candidate's root reduces the weight to 40% at the default (0.4). Set to 1.0 to disable.
  • reschedule_lookahead: How many beats in advance to calculate the next chord.
  • progression: A progression to bind to the global clock. Key- relative content resolves now, against the composition key and scale (binding freezes one realisation).
Example:
# A moody minor progression that changes every 8 beats
comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4)

# Manual harmony driving everything — loops forever
comp.harmony(progression=subsequence.progression([1, 6, 3, 7]))
def freeze( self, bars: int, end: Optional[Any] = None, pins: Optional[Dict[int, Any]] = None, avoid: Optional[Sequence[Any]] = None) -> Progression:
1567	def freeze (
1568		self,
1569		bars: int,
1570		end: typing.Optional[typing.Any] = None,
1571		pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1572		avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1573	) -> "Progression":
1574
1575		"""Capture a chord progression from the live harmony engine.
1576
1577		Runs the harmony engine forward by *bars* chord changes, records each
1578		chord, and returns it as a :class:`Progression` that can be bound to a
1579		form section with :meth:`section_chords`.
1580
1581		The engine state **advances** — successive ``freeze()`` calls produce a
1582		continuing compositional journey so section progressions feel like parts
1583		of a whole rather than isolated islands.
1584
1585		The hybrid constraints compile into the walk: ``end=`` fixes the last
1586		bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=``
1587		excludes chords throughout.  Specs follow the progression-element
1588		grammar (ints where diatonic, roman/name strings where chromatic) and
1589		resolve against the composition key and scale.  A backward
1590		feasibility pass guarantees satisfiability before any chord is drawn;
1591		the forward walk keeps the engine's real history-dependent weighting.
1592		Bar 1 is always the engine's current chord — the journey continues —
1593		so ``pins={1: ...}`` may only name it redundantly.
1594
1595		Parameters:
1596			bars: Number of chords to capture (one per harmony cycle).
1597			end: The chord at the final bar — ``end="V"`` is the cadential
1598				major dominant in minor.
1599			pins: ``{bar: chord}`` — 1-based fiat positions.
1600			avoid: Chords excluded from the walk.
1601
1602		Returns:
1603			A :class:`Progression` with the captured chords and trailing
1604			history for NIR continuity.
1605
1606		Raises:
1607			ValueError: If :meth:`harmony` has not been called first, or the
1608				constraints are contradictory or unsatisfiable.
1609
1610		Example::
1611
1612			composition.harmony(style="functional_major", cycle_beats=4)
1613			verse  = composition.freeze(8, end="V")   # the verse sets up the chorus
1614			chorus = composition.freeze(4)            # next 4 chords, continuing on
1615			composition.section_chords("verse",  verse)
1616			composition.section_chords("chorus", chorus)
1617		"""
1618
1619		hs = self._require_harmonic_state()
1620
1621		if bars < 1:
1622			raise ValueError("bars must be at least 1")
1623
1624		scale = self.scale or "ionian"
1625		key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc
1626
1627		resolved_pins = {
1628			position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]")
1629			for position, spec in (pins or {}).items()
1630		}
1631		resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None
1632		resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])]
1633
1634		if 1 in resolved_pins and resolved_pins[1] != hs.current_chord:
1635			raise ValueError(
1636				f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord "
1637				f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; "
1638				"pin a later bar, or use pin_chord() for playback fiat"
1639			)
1640
1641		# Per-call salted stream (freeze:1, freeze:2, ...): each call's draws
1642		# are independent of every other consumer, so frozen progressions are
1643		# reproducible WITHOUT play() and adding a call cannot shift a
1644		# neighbour's output.  Engine state still advances normally — chord
1645		# continuity comes from current_chord/history, randomness from the
1646		# salted stream (swap-and-restore keeps hs.rng for play untouched).
1647		self._freeze_count += 1
1648		stream = self._stream(f"freeze:{self._freeze_count}")
1649		saved_rng = hs.rng
1650
1651		if stream is not None:
1652			hs.rng = stream
1653
1654		try:
1655			# The kernel with the engine's own hooks is draw-for-draw the old
1656			# step() loop when unconstrained — one walk path for both.
1657			def _commit (chosen: subsequence.chords.Chord) -> None:
1658				hs.current_chord = chosen
1659
1660			collected = subsequence.sequence_utils.constrained_walk(
1661				hs.graph,
1662				hs.current_chord,
1663				bars,
1664				rng = hs.rng,
1665				pins = resolved_pins,
1666				end = resolved_end,
1667				avoid = resolved_avoid,
1668				weight_modifier = hs._transition_weight,
1669				before_choice = hs._record_transition_source,
1670				after_choice = _commit,
1671			)
1672
1673			# Advance past the last captured chord so the next freeze() call or
1674			# live playback does not duplicate it.
1675			hs.step()
1676
1677		finally:
1678			hs.rng = saved_rng
1679
1680		span_beats = float(self._harmony_cycle_beats or 4)
1681
1682		return Progression(
1683			spans = tuple(
1684				subsequence.progressions.ChordSpan(chord = chord, beats = span_beats)
1685				for chord in collected
1686			),
1687			trailing_history = tuple(hs.history),
1688		)

Capture a chord progression from the live harmony engine.

Runs the harmony engine forward by bars chord changes, records each chord, and returns it as a Progression that can be bound to a form section with section_chords().

The engine state advances — successive freeze() calls produce a continuing compositional journey so section progressions feel like parts of a whole rather than isolated islands.

The hybrid constraints compile into the walk: end= fixes the last bar ("end on V at bar 8"), pins= fix any 1-based bar, avoid= excludes chords throughout. Specs follow the progression-element grammar (ints where diatonic, roman/name strings where chromatic) and resolve against the composition key and scale. A backward feasibility pass guarantees satisfiability before any chord is drawn; the forward walk keeps the engine's real history-dependent weighting. Bar 1 is always the engine's current chord — the journey continues — so pins={1: ...} may only name it redundantly.

Arguments:
  • bars: Number of chords to capture (one per harmony cycle).
  • end: The chord at the final bar — end="V" is the cadential major dominant in minor.
  • pins: {bar: chord} — 1-based fiat positions.
  • avoid: Chords excluded from the walk.
Returns:

A Progression with the captured chords and trailing history for NIR continuity.

Raises:
  • ValueError: If harmony() has not been called first, or the constraints are contradictory or unsatisfiable.

Example::

    composition.harmony(style="functional_major", cycle_beats=4)
    verse  = composition.freeze(8, end="V")   # the verse sets up the chorus
    chorus = composition.freeze(4)            # next 4 chords, continuing on
    composition.section_chords("verse",  verse)
    composition.section_chords("chorus", chorus)
def section_chords(self, section_name: str, progression: Any) -> None:
1690	def section_chords (self, section_name: str, progression: typing.Any) -> None:
1691
1692		"""Bind a :class:`Progression` to a named form section.
1693
1694		Every time *section_name* plays, the harmonic clock walks the
1695		progression's spans instead of calling the live engine.  Sections
1696		without a bound progression continue generating live chords.
1697
1698		Accepts a :class:`Progression` value (from :meth:`freeze`, the
1699		``progression()`` factory, or hand-built) or anything the factory
1700		accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord
1701		names.  Key-relative content resolves now, against the composition
1702		key and scale.
1703
1704		On exhaustion mid-section the progression loops when no graph style
1705		is configured (and always when it contains a
1706		:class:`~subsequence.progressions.PitchSet`); with a live engine,
1707		exhaustion falls through to live stepping until the section changes.
1708
1709		Parameters:
1710			section_name: Name of the section as defined in :meth:`form`.
1711			progression: The progression to bind.
1712
1713		Raises:
1714			ValueError: If a graph-based form has been configured and
1715				*section_name* is not one of its sections.  List and generator
1716				forms yield names lazily, so they cannot be validated here.
1717
1718		Example::
1719
1720			composition.section_chords("verse",  verse_progression)
1721			composition.section_chords("chorus", [1, 6, 3, 7])
1722			# "bridge" is not bound — it generates live chords
1723		"""
1724
1725		if (
1726			self._form_state is not None
1727			and self._form_state._section_bars is not None
1728			and section_name not in self._form_state._section_bars
1729		):
1730			known = ", ".join(sorted(self._form_state._section_bars))
1731			raise ValueError(
1732				f"Section '{section_name}' not found in form. "
1733				f"Known sections: {known}"
1734			)
1735
1736		self._section_progressions[section_name] = self._coerce_progression(
1737			progression, f"section_chords({section_name!r})"
1738		)
1739		self._harmony_horizon.invalidate_future()

Bind a Progression to a named form section.

Every time section_name plays, the harmonic clock walks the progression's spans instead of calling the live engine. Sections without a bound progression continue generating live chords.

Accepts a Progression value (from freeze(), the progression() factory, or hand-built) or anything the factory accepts — an element list like [1, 6, 3, "bVII7"] or chord names. Key-relative content resolves now, against the composition key and scale.

On exhaustion mid-section the progression loops when no graph style is configured (and always when it contains a ~subsequence.progressions.PitchSet); with a live engine, exhaustion falls through to live stepping until the section changes.

Arguments:
  • section_name: Name of the section as defined in form().
  • progression: The progression to bind.
Raises:
  • ValueError: If a graph-based form has been configured and section_name is not one of its sections. List and generator forms yield names lazily, so they cannot be validated here.

Example::

    composition.section_chords("verse",  verse_progression)
    composition.section_chords("chorus", [1, 6, 3, 7])
    # "bridge" is not bound — it generates live chords
def pin_chord(self, bar: int, chord: Optional[Any]) -> None:
1741	def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None:
1742
1743		"""Force the chord sounding at a bar — fiat over live generation.
1744
1745		Whatever the harmonic source (live walk, bound progression, section
1746		progression) produces for *bar*, the pinned chord overrides it.
1747		Pass ``None`` to remove a pin.
1748
1749		Parameters:
1750			bar: 1-based bar number (the musician count).
1751			chord: A chord name, int degree, roman string, ``Chord``,
1752				``PitchSet``, or ``None`` to unpin.  Key-relative specs
1753				resolve now, against the composition key and scale.
1754
1755		Example::
1756
1757			composition.pin_chord(8, "E7")    # the turnaround lands on E7
1758			composition.pin_chord(8, None)    # let it walk again
1759		"""
1760
1761		if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1:
1762			raise ValueError(f"bars are 1-based ints, got {bar!r}")
1763
1764		if chord is None:
1765			self._pinned_chords.pop(bar, None)
1766		else:
1767			span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0]))
1768
1769			if not span.is_concrete:
1770				if self.key is None:
1771					raise ValueError("pin_chord with a key-relative spec needs key= on the Composition")
1772				span = span.resolve(subsequence.chords.key_name_to_pc(self.key), self.scale or "ionian")
1773
1774			self._pinned_chords[bar] = _span_chord(span)
1775
1776		self._harmony_horizon.invalidate_future()

Force the chord sounding at a bar — fiat over live generation.

Whatever the harmonic source (live walk, bound progression, section progression) produces for bar, the pinned chord overrides it. Pass None to remove a pin.

Arguments:
  • bar: 1-based bar number (the musician count).
  • chord: A chord name, int degree, roman string, Chord, PitchSet, or None to unpin. Key-relative specs resolve now, against the composition key and scale.

Example::

    composition.pin_chord(8, "E7")    # the turnaround lands on E7
    composition.pin_chord(8, None)    # let it walk again
def section_motifs(self, section_name: str, value: Any, part: Optional[str] = None) -> None:
1778	def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None:
1779
1780		"""Bind a Motif or Phrase to a named form section (per optional part).
1781
1782		Patterns read the binding back with ``p.section_motif(part)`` (or use
1783		the one-call :meth:`phrase_part`); a section with no binding for the
1784		part is silent for that part — bind material or don't, no fallback
1785		guessing.  Re-binding is idempotent, so the call is safe in a live
1786		file: re-executing on save is the desired rebind.
1787
1788		Parameters:
1789			section_name: Name of the section as defined in :meth:`form`.
1790			value: A ``Motif`` or ``Phrase`` (anything exposing
1791				``.length``/``.slice`` places).
1792			part: Optional part label, so one section can carry several
1793				bindings (``"lead"``, ``"bass"``, ...).
1794
1795		Raises:
1796			ValueError: If a graph-based form has been configured and
1797				*section_name* is not one of its sections.
1798
1799		Example::
1800
1801			composition.section_motifs("verse",  verse_line,  part="lead")
1802			composition.section_motifs("chorus", chorus_line, part="lead")
1803		"""
1804
1805		if not hasattr(value, "length") or not hasattr(value, "slice"):
1806			raise TypeError(
1807				f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}"
1808			)
1809
1810		if (
1811			self._form_state is not None
1812			and self._form_state._section_bars is not None
1813			and section_name not in self._form_state._section_bars
1814		):
1815			known = ", ".join(sorted(self._form_state._section_bars))
1816			raise ValueError(
1817				f"Section '{section_name}' not found in form. "
1818				f"Known sections: {known}"
1819			)
1820
1821		self._section_motifs[(section_name, part)] = value

Bind a Motif or Phrase to a named form section (per optional part).

Patterns read the binding back with p.section_motif(part) (or use the one-call phrase_part()); a section with no binding for the part is silent for that part — bind material or don't, no fallback guessing. Re-binding is idempotent, so the call is safe in a live file: re-executing on save is the desired rebind.

Arguments:
  • section_name: Name of the section as defined in form().
  • value: A Motif or Phrase (anything exposing .length/.slice places).
  • part: Optional part label, so one section can carry several bindings ("lead", "bass", ...).
Raises:
  • ValueError: If a graph-based form has been configured and section_name is not one of its sections.

Example::

    composition.section_motifs("verse",  verse_line,  part="lead")
    composition.section_motifs("chorus", chorus_line, part="lead")
def on_event(self, event_name: str, callback: Callable[..., Any]) -> None:
1823	def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None:
1824
1825		"""
1826		Register a callback for a sequencer event (e.g., "bar", "start", "stop").
1827		"""
1828
1829		self._sequencer.on_event(event_name, callback)

Register a callback for a sequencer event (e.g., "bar", "start", "stop").

def hotkeys(self, enabled: bool = True) -> None:
1836	def hotkeys (self, enabled: bool = True) -> None:
1837
1838		"""Enable or disable the global hotkey listener.
1839
1840		Must be called **before** :meth:`play` to take effect.  When enabled, a
1841		background thread reads single keystrokes from stdin without requiring
1842		Enter.  The ``?`` key is always reserved and lists all active bindings.
1843
1844		Hotkeys have zero impact on playback when disabled — the listener
1845		thread is never started.
1846
1847		Args:
1848		    enabled: ``True`` (default) to enable hotkeys; ``False`` to disable.
1849
1850		Example::
1851
1852		    composition.hotkeys()
1853		    composition.hotkey("a", lambda: composition.form_jump("chorus"))
1854		    composition.play()
1855		"""
1856
1857		self._hotkeys_enabled = enabled

Enable or disable the global hotkey listener.

Must be called before play() to take effect. When enabled, a background thread reads single keystrokes from stdin without requiring Enter. The ? key is always reserved and lists all active bindings.

Hotkeys have zero impact on playback when disabled — the listener thread is never started.

Arguments:
  • enabled: True (default) to enable hotkeys; False to disable.

Example::

composition.hotkeys()
composition.hotkey("a", lambda: composition.form_jump("chorus"))
composition.play()
def hotkey( self, key: str, action: Callable[[], NoneType], quantize: int = 0, label: Optional[str] = None) -> None:
1860	def hotkey (
1861		self,
1862		key:      str,
1863		action:   typing.Callable[[], None],
1864		quantize: int = 0,
1865		label:    typing.Optional[str] = None,
1866	) -> None:
1867
1868		"""Register a single-key shortcut that fires during playback.
1869
1870		The listener must be enabled first with :meth:`hotkeys`.
1871
1872		Most actions — form jumps, ``composition.data`` writes, and
1873		:meth:`tweak` calls — should use ``quantize=0`` (the default).  Their
1874		musical effect is naturally delayed to the next pattern rebuild cycle,
1875		which provides automatic musical quantization without extra configuration.
1876
1877		Use ``quantize=N`` for actions where you want an explicit bar-boundary
1878		guarantee, such as :meth:`mute` / :meth:`unmute`.
1879
1880		The ``?`` key is reserved and cannot be overridden.
1881
1882		Args:
1883		    key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``).
1884		    action: Zero-argument callable to execute.
1885		    quantize: ``0`` = execute immediately (default).  ``N`` = execute
1886		        on the next global bar number divisible by *N*.
1887		    label: Display name for the ``?`` help listing.  Auto-derived from
1888		        the function name or lambda body if omitted.
1889
1890		Raises:
1891		    ValueError: If ``key`` is the reserved ``?`` character, or if
1892		        ``key`` is not exactly one character.
1893
1894		Example::
1895
1896		    composition.hotkeys()
1897
1898		    # Immediate — musical effect happens at next pattern rebuild
1899		    composition.hotkey("a", lambda: composition.form_jump("chorus"))
1900		    composition.hotkey("1", lambda: composition.data.update({"mode": "chill"}))
1901
1902		    # Explicit 4-bar phrase boundary
1903		    composition.hotkey("s", lambda: composition.mute("drums"), quantize=4)
1904
1905		    # Named function — label is derived automatically
1906		    def drop_to_breakdown ():
1907		        composition.form_jump("breakdown")
1908		        composition.mute("lead")
1909
1910		    composition.hotkey("d", drop_to_breakdown)
1911
1912		    composition.play()
1913		"""
1914
1915		if len(key) != 1:
1916			raise ValueError(f"hotkey key must be a single character, got {key!r}")
1917
1918		if key == _HOTKEY_RESERVED:
1919			raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.")
1920
1921		derived = label if label is not None else _derive_label(action)
1922
1923		self._hotkey_bindings[key] = HotkeyBinding(
1924			key      = key,
1925			action   = action,
1926			quantize = quantize,
1927			label    = derived,
1928		)

Register a single-key shortcut that fires during playback.

The listener must be enabled first with hotkeys().

Most actions — form jumps, composition.data writes, and tweak() calls — should use quantize=0 (the default). Their musical effect is naturally delayed to the next pattern rebuild cycle, which provides automatic musical quantization without extra configuration.

Use quantize=N for actions where you want an explicit bar-boundary guarantee, such as mute() / unmute().

The ? key is reserved and cannot be overridden.

Arguments:
  • key: A single character trigger (e.g. "a", "1", " ").
  • action: Zero-argument callable to execute.
  • quantize: 0 = execute immediately (default). N = execute on the next global bar number divisible by N.
  • label: Display name for the ? help listing. Auto-derived from the function name or lambda body if omitted.
Raises:
  • ValueError: If key is the reserved ? character, or if key is not exactly one character.

Example::

composition.hotkeys()

# Immediate — musical effect happens at next pattern rebuild
composition.hotkey("a", lambda: composition.form_jump("chorus"))
composition.hotkey("1", lambda: composition.data.update({"mode": "chill"}))

# Explicit 4-bar phrase boundary
composition.hotkey("s", lambda: composition.mute("drums"), quantize=4)

# Named function — label is derived automatically
def drop_to_breakdown ():
    composition.form_jump("breakdown")
    composition.mute("lead")

composition.hotkey("d", drop_to_breakdown)

composition.play()
def form_jump(self, section_name: str) -> None:
1931	def form_jump (self, section_name: str) -> None:
1932
1933		"""Jump the form to a named section immediately.
1934
1935		Delegates to :meth:`subsequence.form_state.FormState.jump_to`.  Only works when the
1936		composition uses graph-mode form (a dict passed to :meth:`form`).
1937
1938		The musical effect is heard at the *next pattern rebuild cycle* — already-
1939		queued MIDI notes are unaffected.  This natural delay means ``form_jump``
1940		is effective without needing explicit quantization.
1941
1942		Args:
1943		    section_name: The section to jump to.
1944
1945		Raises:
1946		    ValueError: If no form is configured, or the form is not in graph
1947		        mode, or *section_name* is unknown.
1948
1949		Example::
1950
1951		    composition.hotkey("c", lambda: composition.form_jump("chorus"))
1952		"""
1953
1954		if self._form_state is None:
1955			raise ValueError("form_jump() requires a form to be configured via composition.form().")
1956
1957		self._form_state.jump_to(section_name)
1958
1959		# The harmony horizon planned against the old section — revoke it.
1960		self._harmony_horizon.invalidate_future()

Jump the form to a named section immediately.

Delegates to subsequence.form_state.FormState.jump_to(). Only works when the composition uses graph-mode form (a dict passed to form()).

The musical effect is heard at the next pattern rebuild cycle — already- queued MIDI notes are unaffected. This natural delay means form_jump is effective without needing explicit quantization.

Arguments:
  • section_name: The section to jump to.
Raises:
  • ValueError: If no form is configured, or the form is not in graph mode, or section_name is unknown.

Example::

composition.hotkey("c", lambda: composition.form_jump("chorus"))
def form_next(self, section_name: str) -> None:
1963	def form_next (self, section_name: str) -> None:
1964
1965		"""Queue the next section — takes effect when the current section ends.
1966
1967		Unlike :meth:`form_jump`, this does not interrupt the current section.
1968		The queued section replaces the automatically pre-decided next section
1969		and takes effect at the natural section boundary.  The performer can
1970		change their mind by calling ``form_next`` again before the boundary.
1971
1972		Delegates to :meth:`subsequence.form_state.FormState.queue_next`.  Only works when the
1973		composition uses graph-mode form (a dict passed to :meth:`form`).
1974
1975		Args:
1976		    section_name: The section to queue.
1977
1978		Raises:
1979		    ValueError: If no form is configured, or the form is not in graph
1980		        mode, or *section_name* is unknown.
1981
1982		Example::
1983
1984		    composition.hotkey("c", lambda: composition.form_next("chorus"))
1985		"""
1986
1987		if self._form_state is None:
1988			raise ValueError("form_next() requires a form to be configured via composition.form().")
1989
1990		self._form_state.queue_next(section_name)
1991
1992		# The harmony horizon planned against the old continuation — revoke it.
1993		self._harmony_horizon.invalidate_future()

Queue the next section — takes effect when the current section ends.

Unlike form_jump(), this does not interrupt the current section. The queued section replaces the automatically pre-decided next section and takes effect at the natural section boundary. The performer can change their mind by calling form_next again before the boundary.

Delegates to subsequence.form_state.FormState.queue_next(). Only works when the composition uses graph-mode form (a dict passed to form()).

Arguments:
  • section_name: The section to queue.
Raises:
  • ValueError: If no form is configured, or the form is not in graph mode, or section_name is unknown.

Example::

composition.hotkey("c", lambda: composition.form_next("chorus"))
seed: Optional[int]
2077	@property
2078	def seed (self) -> typing.Optional[int]:
2079
2080		"""
2081		The composition's random seed, or None when unseeded.
2082
2083		When set, every random decision derives deterministically from this
2084		value through named streams (see ``seed_for()``), so the same script
2085		produces the same music on every run.  Assign to set it::
2086
2087			comp.seed = 42
2088
2089		(Formerly the method ``comp.seed(42)`` — the call form is a hard
2090		break per the pre-1.0 rename policy.)
2091		"""
2092
2093		return self._seed

The composition's random seed, or None when unseeded.

When set, every random decision derives deterministically from this value through named streams (see seed_for()), so the same script produces the same music on every run. Assign to set it::

    comp.seed = 42

(Formerly the method comp.seed(42) — the call form is a hard break per the pre-1.0 rename policy.)

def seed_for(self, name: str) -> Optional[int]:
2127	def seed_for (self, name: str) -> typing.Optional[int]:
2128
2129		"""
2130		Surface the effective derived seed for a named stream.
2131
2132		Works for pattern names and equally for any name you invent for a
2133		standalone value generator (``seed=composition.seed_for("hook")``),
2134		so its randomness keys off the composition seed without sharing any
2135		other consumer's stream.  Reflects ``reroll()`` nonces.  Returns None
2136		when the composition is unseeded.
2137
2138		Example:
2139			```python
2140			hook_seed = composition.seed_for("hook")
2141			```
2142		"""
2143
2144		return self._stream_seed(name)

Surface the effective derived seed for a named stream.

Works for pattern names and equally for any name you invent for a standalone value generator (seed=composition.seed_for("hook")), so its randomness keys off the composition seed without sharing any other consumer's stream. Reflects reroll() nonces. Returns None when the composition is unseeded.

Example:
hook_seed = composition.seed_for("hook")
def reroll(self, name: str) -> None:
2146	def reroll (self, name: str) -> None:
2147
2148		"""
2149		Deal a named stream a fresh deterministic seed — try a new variation.
2150
2151		Bumps the per-name nonce and prints the new effective seed.  The
2152		nonce lives only in this process, so the printed seed is what lets a
2153		variation you like survive a restart: note it down, or ``lock()`` the
2154		name to pin it for the session.  Refuses on locked names.
2155
2156		Parameters:
2157			name: The stream name — usually a pattern name.
2158
2159		Example:
2160			```python
2161			comp.reroll("lead")    # prints: reroll('lead') -> effective seed ...
2162			```
2163		"""
2164
2165		if name in self._locked_names:
2166			print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first")
2167			return
2168
2169		self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1
2170		effective = self._stream_seed(name)
2171
2172		if effective is None:
2173			print(f"reroll('{name}'): composition has no seed - randomness is unseeded")
2174			return
2175
2176		running = self._running_patterns.get(name)
2177
2178		if running is not None and hasattr(running, "_rng"):
2179			running._rng = random.Random(effective)
2180
2181		print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})")

Deal a named stream a fresh deterministic seed — try a new variation.

Bumps the per-name nonce and prints the new effective seed. The nonce lives only in this process, so the printed seed is what lets a variation you like survive a restart: note it down, or lock() the name to pin it for the session. Refuses on locked names.

Arguments:
  • name: The stream name — usually a pattern name.
Example:
comp.reroll("lead")    # prints: reroll('lead') -> effective seed ...
def lock(self, name: str) -> None:
2183	def lock (self, name: str) -> None:
2184
2185		"""
2186		Pin a named stream: keep its current effective seed and realization.
2187
2188		Engine-side state, so it survives live reload (it is never a builder
2189		swap): a locked pattern re-deals its stream from the same effective
2190		seed on every rebuild, so every cycle realizes identically, and
2191		``reroll()`` refuses with a message until ``unlock()``.
2192
2193		Parameters:
2194			name: The stream name — usually a pattern name.
2195		"""
2196
2197		self._locked_names.add(name)

Pin a named stream: keep its current effective seed and realization.

Engine-side state, so it survives live reload (it is never a builder swap): a locked pattern re-deals its stream from the same effective seed on every rebuild, so every cycle realizes identically, and reroll() refuses with a message until unlock().

Arguments:
  • name: The stream name — usually a pattern name.
def unlock(self, name: str) -> None:
2199	def unlock (self, name: str) -> None:
2200
2201		"""Release a ``lock()``: the stream runs free and ``reroll()`` works again."""
2202
2203		self._locked_names.discard(name)

Release a lock(): the stream runs free and reroll() works again.

def tuning( self, source: Union[str, os.PathLike, NoneType] = None, *, cents: Optional[List[float]] = None, ratios: Optional[List[float]] = None, equal: Optional[int] = None, bend_range: float = 2.0, channels: Optional[List[int]] = None, reference_note: int = 60, exclude_drums: bool = True) -> None:
2205	def tuning (
2206		self,
2207		source: typing.Optional[typing.Union[str, "os.PathLike"]] = None,
2208		*,
2209		cents: typing.Optional[typing.List[float]] = None,
2210		ratios: typing.Optional[typing.List[float]] = None,
2211		equal: typing.Optional[int] = None,
2212		bend_range: float = 2.0,
2213		channels: typing.Optional[typing.List[int]] = None,
2214		reference_note: int = 60,
2215		exclude_drums: bool = True,
2216	) -> None:
2217
2218		"""Set a global microtonal tuning for the composition.
2219
2220		The tuning is applied automatically after each pattern rebuild (before
2221		the pattern is scheduled).  Drum patterns (those registered with a
2222		``drum_note_map``) are excluded by default.
2223
2224		Supply exactly one of the source parameters:
2225
2226		- ``source``: path to a Scala ``.scl`` file.
2227		- ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit).
2228		- ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``).
2229		- ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``).
2230
2231		For polyphonic parts, supply a ``channels`` pool.  Notes are spread
2232		across those MIDI channels so each can carry an independent pitch bend.
2233		The synth must be configured to match ``bend_range`` (its pitch-bend range
2234		setting in semitones).
2235
2236		Parameters:
2237			source: Path to a ``.scl`` file.
2238			cents: Cent offsets for scale degrees 1..N.
2239			ratios: Frequency ratios for scale degrees 1..N.
2240			equal: Number of equal divisions of the period.
2241			bend_range: Synth pitch-bend range in semitones (default ±2).
2242			channels: Channel pool for polyphonic rotation.
2243			reference_note: MIDI note mapped to scale degree 0 (default 60 = C4).
2244			exclude_drums: When True (default), skip patterns that have a
2245			    ``drum_note_map`` (they use fixed GM pitches, not tuned ones).
2246
2247		Example:
2248			```python
2249			# Quarter-comma meantone from a Scala file
2250			comp.tuning("meanquar.scl")
2251
2252			# Just intonation from ratios
2253			comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2])
2254
2255			# 19-TET, monophonic
2256			comp.tuning(equal=19, bend_range=2.0)
2257
2258			# 31-TET with channel rotation for polyphony (channels 1-6)
2259			comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5])
2260			```
2261		"""
2262		import subsequence.tuning as _tuning_mod
2263
2264		given = sum(x is not None for x in [source, cents, ratios, equal])
2265		if given == 0:
2266			raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal")
2267		if given > 1:
2268			raise ValueError("composition.tuning() accepts only one source parameter")
2269
2270		if source is not None:
2271			t = _tuning_mod.Tuning.from_scl(source)
2272		elif cents is not None:
2273			t = _tuning_mod.Tuning.from_cents(cents)
2274		elif ratios is not None:
2275			t = _tuning_mod.Tuning.from_ratios(ratios)
2276		else:
2277			t = _tuning_mod.Tuning.equal(equal)  # type: ignore[arg-type]
2278
2279		self._tuning = t
2280		self._tuning_bend_range = bend_range
2281		self._tuning_channels = channels
2282		self._tuning_reference_note = reference_note
2283		self._tuning_exclude_drums = exclude_drums

Set a global microtonal tuning for the composition.

The tuning is applied automatically after each pattern rebuild (before the pattern is scheduled). Drum patterns (those registered with a drum_note_map) are excluded by default.

Supply exactly one of the source parameters:

  • source: path to a Scala .scl file.
  • cents: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit).
  • ratios: list of frequency ratios (e.g., [9/8, 5/4, 4/3, 3/2, 2]).
  • equal: integer for N-tone equal temperament (e.g., equal=19).

For polyphonic parts, supply a channels pool. Notes are spread across those MIDI channels so each can carry an independent pitch bend. The synth must be configured to match bend_range (its pitch-bend range setting in semitones).

Arguments:
  • source: Path to a .scl file.
  • cents: Cent offsets for scale degrees 1..N.
  • ratios: Frequency ratios for scale degrees 1..N.
  • equal: Number of equal divisions of the period.
  • bend_range: Synth pitch-bend range in semitones (default ±2).
  • channels: Channel pool for polyphonic rotation.
  • reference_note: MIDI note mapped to scale degree 0 (default 60 = C4).
  • exclude_drums: When True (default), skip patterns that have a drum_note_map (they use fixed GM pitches, not tuned ones).
Example:
# Quarter-comma meantone from a Scala file
comp.tuning("meanquar.scl")

# Just intonation from ratios
comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2])

# 19-TET, monophonic
comp.tuning(equal=19, bend_range=2.0)

# 31-TET with channel rotation for polyphony (channels 1-6)
comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5])
def display( self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None:
2285	def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None:
2286
2287		"""
2288		Enable or disable the live terminal dashboard.
2289
2290		When enabled, Subsequence uses a safe logging handler that allows a
2291		persistent status line (BPM, Key, Bar, Section, Chord) to stay at
2292		the bottom of the terminal while logs scroll above it.
2293
2294		Parameters:
2295			enabled: Whether to show the display (default True).
2296			grid: When True, render an ASCII grid visualisation of all
2297				running patterns above the status line. The grid updates
2298				once per bar, showing which steps have notes and at what
2299				velocity.
2300			grid_scale: Horizontal zoom factor for the grid (default
2301				``1.0``).  Higher values add visual columns between
2302				grid steps, revealing micro-timing from swing and groove.
2303				Snapped to the nearest integer internally for uniform
2304				marker spacing.
2305		"""
2306
2307		if enabled:
2308			self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale)
2309		else:
2310			self._display = None

Enable or disable the live terminal dashboard.

When enabled, Subsequence uses a safe logging handler that allows a persistent status line (BPM, Key, Bar, Section, Chord) to stay at the bottom of the terminal while logs scroll above it.

Arguments:
  • enabled: Whether to show the display (default True).
  • grid: When True, render an ASCII grid visualisation of all running patterns above the status line. The grid updates once per bar, showing which steps have notes and at what velocity.
  • grid_scale: Horizontal zoom factor for the grid (default 1.0). Higher values add visual columns between grid steps, revealing micro-timing from swing and groove. Snapped to the nearest integer internally for uniform marker spacing.
def web_ui(self, http_host: str = '127.0.0.1', ws_host: str = '127.0.0.1') -> None:
2312	def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None:
2313
2314		"""
2315		Enable the realtime Web UI Dashboard.
2316
2317		When enabled, Subsequence instantiates a WebSocket server that broadcasts
2318		the current state, signals, and active patterns (with high-res timing and
2319		note data) to any connected browser clients.
2320
2321		Both servers bind to localhost by default.  Pass ``http_host`` / ``ws_host``
2322		(e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but
2323		broadcasts full composition state, so only do so on a trusted network.
2324		"""
2325
2326		self._web_ui_enabled = True
2327		self._web_ui_http_host = http_host
2328		self._web_ui_ws_host = ws_host

Enable the realtime Web UI Dashboard.

When enabled, Subsequence instantiates a WebSocket server that broadcasts the current state, signals, and active patterns (with high-res timing and note data) to any connected browser clients.

Both servers bind to localhost by default. Pass http_host / ws_host (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but broadcasts full composition state, so only do so on a trusted network.

def midi_input( self, device: str, clock_follow: bool = False, name: Optional[str] = None) -> None:
2330	def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None:
2331
2332		"""
2333		Configure a MIDI input device for external sync and MIDI messages.
2334
2335		May be called multiple times to register additional input devices.
2336		The first call sets the primary input (device 0).  Subsequent calls
2337		add additional input devices (device 1, 2, …).  Only one device may
2338		have ``clock_follow=True``.
2339
2340		Parameters:
2341			device: The name of the MIDI input port.
2342			clock_follow: If True, Subsequence will slave its clock to incoming
2343				MIDI Ticks. It will also follow MIDI Start/Stop/Continue
2344				commands. Only one device can have this enabled at a time.
2345			name: Optional alias for use with ``cc_map(input_device=…)`` and
2346				``cc_forward(input_device=…)``.  When omitted, the raw device
2347				name is used.
2348
2349		Example:
2350			```python
2351			# Single controller (unchanged usage)
2352			comp.midi_input("Scarlett 2i4", clock_follow=True)
2353
2354			# Multiple controllers
2355			comp.midi_input("Arturia KeyStep", name="keys")
2356			comp.midi_input("Faderfox EC4", name="faders")
2357			```
2358		"""
2359
2360		if clock_follow:
2361			if self.is_clock_following:
2362				raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)")
2363
2364		if self._input_device is None:
2365			# First call: set primary input device (device 0)
2366			self._input_device = device
2367			self._input_device_alias = name
2368			self._clock_follow = clock_follow
2369		else:
2370			# Subsequent calls: register additional input devices
2371			self._additional_inputs.append((device, name, clock_follow))

Configure a MIDI input device for external sync and MIDI messages.

May be called multiple times to register additional input devices. The first call sets the primary input (device 0). Subsequent calls add additional input devices (device 1, 2, …). Only one device may have clock_follow=True.

Arguments:
  • device: The name of the MIDI input port.
  • clock_follow: If True, Subsequence will slave its clock to incoming MIDI Ticks. It will also follow MIDI Start/Stop/Continue commands. Only one device can have this enabled at a time.
  • name: Optional alias for use with cc_map(input_device=…) and cc_forward(input_device=…). When omitted, the raw device name is used.
Example:
# Single controller (unchanged usage)
comp.midi_input("Scarlett 2i4", clock_follow=True)

# Multiple controllers
comp.midi_input("Arturia KeyStep", name="keys")
comp.midi_input("Faderfox EC4", name="faders")
def midi_output( self, device: str, name: Optional[str] = None, latency_ms: float = 0.0) -> int:
2373	def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int:
2374
2375		"""
2376		Register an additional MIDI output device.
2377
2378		The first output device is always the one passed to
2379		``Composition(output_device=…)`` — that is device 0.
2380		Each call to ``midi_output()`` adds the next device (1, 2, …).
2381
2382		Parameters:
2383			device: The exact name of the MIDI output port, as reported
2384				by ``mido.get_output_names()``. Matching is strict —
2385				partial names and substrings are rejected. See
2386				``Composition.__init__`` for the lookup snippet and a
2387				note on ALSA name stability on Linux.
2388			name: Optional alias for use with ``pattern(device=…)``,
2389				``cc_forward(output_device=…)``, etc.  When omitted, the raw
2390				device name is used.
2391			latency_ms: Physical output latency of this device in
2392				milliseconds, for delay compensation (default 0.0, must be
2393				non-negative). Set this when the device sounds late (e.g. a
2394				software sampler) so Subsequence delays faster devices to
2395				line everything up.
2396
2397		Returns:
2398			The integer device index assigned (1, 2, 3, …).
2399
2400		Example:
2401			```python
2402			comp = subsequence.Composition(bpm=120, output_device="MOTU Express")
2403
2404			# Returns 1 — use as device=1 or device="integra"
2405			comp.midi_output("Roland Integra", name="integra")
2406
2407			# A software sampler that sounds 20ms late
2408			comp.midi_output("Subsample", name="sampler", latency_ms=20)
2409
2410			@comp.pattern(channel=1, beats=4, device="integra")
2411			def strings (p):
2412				p.note(60, beat=0)
2413			```
2414		"""
2415
2416		if latency_ms < 0:
2417			raise ValueError(f"latency_ms must be non-negative — got {latency_ms}")
2418
2419		idx = 1 + len(self._additional_outputs)  # device 0 is always the primary
2420		self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms))
2421		return idx

Register an additional MIDI output device.

The first output device is always the one passed to Composition(output_device=…) — that is device 0. Each call to midi_output() adds the next device (1, 2, …).

Arguments:
  • device: The exact name of the MIDI output port, as reported by mido.get_output_names(). Matching is strict — partial names and substrings are rejected. See Composition.__init__ for the lookup snippet and a note on ALSA name stability on Linux.
  • name: Optional alias for use with pattern(device=…), cc_forward(output_device=…), etc. When omitted, the raw device name is used.
  • latency_ms: Physical output latency of this device in milliseconds, for delay compensation (default 0.0, must be non-negative). Set this when the device sounds late (e.g. a software sampler) so Subsequence delays faster devices to line everything up.
Returns:

The integer device index assigned (1, 2, 3, …).

Example:
comp = subsequence.Composition(bpm=120, output_device="MOTU Express")

# Returns 1 — use as device=1 or device="integra"
comp.midi_output("Roland Integra", name="integra")

# A software sampler that sounds 20ms late
comp.midi_output("Subsample", name="sampler", latency_ms=20)

@comp.pattern(channel=1, beats=4, device="integra")
def strings (p):
        p.note(60, beat=0)
def clock_output(self, enabled: bool = True) -> None:
2444	def clock_output (self, enabled: bool = True) -> None:
2445
2446		"""
2447		Send MIDI timing clock to connected hardware.
2448
2449		When enabled, Subsequence acts as a MIDI clock master and sends
2450		standard clock messages on the output port: a Start message (0xFA)
2451		when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN),
2452		and a Stop message (0xFC) when playback ends.
2453
2454		This allows hardware synthesizers, drum machines, and effect units to
2455		slave their tempo to Subsequence automatically.
2456
2457		**Note:** Clock output is automatically disabled when ``midi_input()``
2458		is called with ``clock_follow=True``, to prevent a clock feedback loop.
2459
2460		Parameters:
2461			enabled: Whether to send MIDI clock (default True).
2462
2463		Example:
2464			```python
2465			comp = subsequence.Composition(bpm=120, output_device="...")
2466			comp.clock_output()   # hardware will follow Subsequence tempo
2467			```
2468		"""
2469
2470		self._clock_output = enabled

Send MIDI timing clock to connected hardware.

When enabled, Subsequence acts as a MIDI clock master and sends standard clock messages on the output port: a Start message (0xFA) when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), and a Stop message (0xFC) when playback ends.

This allows hardware synthesizers, drum machines, and effect units to slave their tempo to Subsequence automatically.

Note: Clock output is automatically disabled when midi_input() is called with clock_follow=True, to prevent a clock feedback loop.

Arguments:
  • enabled: Whether to send MIDI clock (default True).
Example:
comp = subsequence.Composition(bpm=120, output_device="...")
comp.clock_output()   # hardware will follow Subsequence tempo
def cc_map( self, cc: int, data_key: str, channel: Optional[int] = None, min_val: float = 0.0, max_val: float = 1.0, input_device: Union[int, str, NoneType] = None) -> None:
2518	def cc_map (
2519		self,
2520		cc: int,
2521		data_key: str,
2522		channel: typing.Optional[int] = None,
2523		min_val: float = 0.0,
2524		max_val: float = 1.0,
2525		input_device: subsequence.midi_utils.DeviceId = None,
2526	) -> None:
2527
2528		"""
2529		Map an incoming MIDI CC to a ``composition.data`` key.
2530
2531		When the composition receives a CC message on the configured MIDI
2532		input port, the value is scaled from the CC range (0–127) to
2533		*[min_val, max_val]* and stored in ``composition.data[data_key]``.
2534
2535		This lets hardware knobs, faders, and expression pedals control live
2536		parameters without writing any callback code.
2537
2538		**Requires** ``midi_input()`` to be called first to open an input port.
2539
2540		Parameters:
2541			cc: MIDI Control Change number (0–127).
2542			data_key: The ``composition.data`` key to write.
2543			channel: If given, only respond to CC messages on this channel.
2544				Uses the same numbering convention as ``pattern()`` (1-16
2545				by default, or 0-15 with ``zero_indexed_channels=True``).
2546				``None`` matches any channel (default).
2547			min_val: Scaled minimum — written when CC value is 0 (default 0.0).
2548			max_val: Scaled maximum — written when CC value is 127 (default 1.0).
2549			input_device: Only respond to CC messages from this input device
2550				(index or name).  ``None`` responds to any input device (default).
2551
2552		Example:
2553			```python
2554			comp.midi_input("Arturia KeyStep")
2555			comp.cc_map(74, "filter_cutoff")           # knob → 0.0–1.0
2556			comp.cc_map(7, "volume", min_val=0, max_val=127)  # volume fader
2557
2558			# Multi-device: only listen to CC 74 from the "faders" controller
2559			comp.cc_map(74, "filter", input_device="faders")
2560			```
2561		"""
2562
2563		resolved_channel = self._resolve_channel(channel) if channel is not None else None
2564
2565		self._cc_mappings.append({
2566			'cc': cc,
2567			'data_key': data_key,
2568			'channel': resolved_channel,
2569			'min_val': min_val,
2570			'max_val': max_val,
2571			'input_device': input_device,  # resolved to int index in _run()
2572		})

Map an incoming MIDI CC to a composition.data key.

When the composition receives a CC message on the configured MIDI input port, the value is scaled from the CC range (0–127) to [min_val, max_val] and stored in composition.data[data_key].

This lets hardware knobs, faders, and expression pedals control live parameters without writing any callback code.

Requires midi_input() to be called first to open an input port.

Arguments:
  • cc: MIDI Control Change number (0–127).
  • data_key: The composition.data key to write.
  • channel: If given, only respond to CC messages on this channel. Uses the same numbering convention as pattern() (1-16 by default, or 0-15 with zero_indexed_channels=True). None matches any channel (default).
  • min_val: Scaled minimum — written when CC value is 0 (default 0.0).
  • max_val: Scaled maximum — written when CC value is 127 (default 1.0).
  • input_device: Only respond to CC messages from this input device (index or name). None responds to any input device (default).
Example:
comp.midi_input("Arturia KeyStep")
comp.cc_map(74, "filter_cutoff")           # knob → 0.0–1.0
comp.cc_map(7, "volume", min_val=0, max_val=127)  # volume fader

# Multi-device: only listen to CC 74 from the "faders" controller
comp.cc_map(74, "filter", input_device="faders")
def note_input( self, channel: Optional[int] = None, release_ms: float = 30.0, latch: bool = False, input_device: Union[int, str, NoneType] = None) -> None:
2575	def note_input (
2576		self,
2577		channel: typing.Optional[int] = None,
2578		release_ms: float = 30.0,
2579		latch: bool = False,
2580		input_device: subsequence.midi_utils.DeviceId = None,
2581	) -> None:
2582
2583		"""Track notes held on a MIDI keyboard for live arpeggiation.
2584
2585		Incoming note-on/note-off messages build a live "currently held" set
2586		that any pattern reads via ``p.held_notes()`` — typically fed straight
2587		to ``p.arpeggio()``.  The composition still authors the rhythm and
2588		motion; the player's hands supply the pitch set.  This is a live
2589		*performance* layer over the deterministic, seeded composition: when
2590		rendering headlessly there is no input, so ``p.held_notes()`` is empty
2591		and seeded output is unchanged.
2592
2593		**Requires** ``midi_input()`` to be called first to open an input port.
2594
2595		Parameters:
2596			channel: If given, only track notes on this channel.  Uses the same
2597				numbering convention as ``pattern()`` (1-16 by default, or 0-15
2598				with ``zero_indexed_channels=True``).  ``None`` tracks any
2599				channel (default).
2600			release_ms: How long (milliseconds) a released note keeps counting
2601				as held.  This smooths the momentary all-keys-up gap during a
2602				hand-position change so the arp does not drop to silence.
2603				Default 30.0; set 0.0 to release instantly.  Ignored when
2604				``latch`` is True.
2605			latch: When True, the held set persists after you lift your hands
2606				until you play a new chord (the first key after every key is up
2607				replaces it) — like a hardware arp's latch.
2608			input_device: Only track notes from this input device (index or
2609				name).  ``None`` tracks any input device (default).
2610
2611		Example:
2612			```python
2613			comp.midi_input("Arturia KeyStep")
2614			comp.note_input(channel=1, release_ms=30)
2615
2616			@comp.pattern(channel=6, beats=4)
2617			def arp (p):
2618			    p.arpeggio(p.held_notes(), direction="up")  # rests when silent
2619			```
2620		"""
2621
2622		if self._note_input is not None:
2623			raise RuntimeError("only one note_input source is supported — named multi-source is not yet available")
2624
2625		resolved_channel = self._resolve_channel(channel) if channel is not None else None
2626
2627		self._note_input = {
2628			'channel': resolved_channel,
2629			'release_ms': release_ms,
2630			'latch': latch,
2631			'input_device': input_device,  # resolved to int index in _run()
2632		}

Track notes held on a MIDI keyboard for live arpeggiation.

Incoming note-on/note-off messages build a live "currently held" set that any pattern reads via p.held_notes() — typically fed straight to p.arpeggio(). The composition still authors the rhythm and motion; the player's hands supply the pitch set. This is a live performance layer over the deterministic, seeded composition: when rendering headlessly there is no input, so p.held_notes() is empty and seeded output is unchanged.

Requires midi_input() to be called first to open an input port.

Arguments:
  • channel: If given, only track notes on this channel. Uses the same numbering convention as pattern() (1-16 by default, or 0-15 with zero_indexed_channels=True). None tracks any channel (default).
  • release_ms: How long (milliseconds) a released note keeps counting as held. This smooths the momentary all-keys-up gap during a hand-position change so the arp does not drop to silence. Default 30.0; set 0.0 to release instantly. Ignored when latch is True.
  • latch: When True, the held set persists after you lift your hands until you play a new chord (the first key after every key is up replaces it) — like a hardware arp's latch.
  • input_device: Only track notes from this input device (index or name). None tracks any input device (default).
Example:
comp.midi_input("Arturia KeyStep")
comp.note_input(channel=1, release_ms=30)

@comp.pattern(channel=6, beats=4)
def arp (p):
    p.arpeggio(p.held_notes(), direction="up")  # rests when silent
def cc_forward( self, cc: int, output: Union[str, Callable], *, channel: Optional[int] = None, output_channel: Optional[int] = None, mode: str = 'instant', input_device: Union[int, str, NoneType] = None, output_device: Union[int, str, NoneType] = None) -> None:
2696	def cc_forward (
2697		self,
2698		cc: int,
2699		output: typing.Union[str, typing.Callable],
2700		*,
2701		channel: typing.Optional[int] = None,
2702		output_channel: typing.Optional[int] = None,
2703		mode: str = "instant",
2704		input_device: subsequence.midi_utils.DeviceId = None,
2705		output_device: subsequence.midi_utils.DeviceId = None,
2706	) -> None:
2707
2708		"""
2709		Forward an incoming MIDI CC to the MIDI output in real-time.
2710
2711		Unlike ``cc_map()`` which writes incoming CC values to ``composition.data``
2712		for use at pattern rebuild time, ``cc_forward()`` routes the signal
2713		directly to the MIDI output — bypassing the pattern cycle entirely.
2714
2715		Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC
2716		number; they operate independently.
2717
2718		Parameters:
2719			cc: Incoming CC number to listen for (0–127).
2720			output: What to send. Either a **preset string**:
2721
2722				- ``"cc"`` — identity forward, same CC number and value.
2723				- ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``).
2724				- ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend.
2725
2726				Or a **callable** with signature
2727				``(value: int, channel: int) -> Optional[mido.Message]``.
2728				Return a fully formed ``mido.Message`` to send, or ``None`` to suppress.
2729				``channel`` is 0-indexed (the incoming channel).
2730			channel: If given, only respond to CC messages on this channel.
2731				Uses the same numbering convention as ``cc_map()``.
2732				``None`` matches any channel (default).
2733			output_channel: Override the output channel. ``None`` uses the
2734				incoming channel. Uses the same numbering convention as ``pattern()``.
2735			mode: Dispatch mode:
2736
2737				- ``"instant"`` *(default)* — send immediately on the MIDI input
2738				  callback thread. Lowest latency (~1–5 ms). Instant forwards are
2739				  **not** recorded when recording is enabled.
2740				- ``"queued"`` — inject into the sequencer event queue and send at
2741				  the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards
2742				  **are** recorded when recording is enabled.
2743
2744		Example:
2745			```python
2746			comp.midi_input("Arturia KeyStep")
2747
2748			# CC 1 → CC 1 (identity, instant)
2749			comp.cc_forward(1, "cc")
2750
2751			# CC 1 → pitch bend on channel 1, queued (recordable)
2752			comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued")
2753
2754			# CC 1 → CC 74, custom channel
2755			comp.cc_forward(1, "cc:74", output_channel=2)
2756
2757			# Custom transform — remap CC range 0–127 to CC 74 range 40–100
2758			import subsequence.midi as midi
2759			comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch))
2760
2761			# Forward AND map to data simultaneously — both active on the same CC
2762			comp.cc_map(1, "mod_wheel")
2763			comp.cc_forward(1, "cc:74")
2764			```
2765		"""
2766
2767		if not 0 <= cc <= 127:
2768			raise ValueError(f"cc_forward(): cc {cc} out of range 0–127")
2769
2770		if mode not in ('instant', 'queued'):
2771			raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'")
2772
2773		resolved_in_channel = self._resolve_channel(channel) if channel is not None else None
2774		resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None
2775
2776		transform = self._make_cc_forward_transform(output, cc, resolved_out_channel)
2777
2778		self._cc_forwards.append({
2779			'cc': cc,
2780			'channel': resolved_in_channel,
2781			'output_channel': resolved_out_channel,
2782			'mode': mode,
2783			'transform': transform,
2784			'input_device': input_device,   # resolved to int index in _run()
2785			'output_device': output_device, # resolved to int index in _run()
2786		})

Forward an incoming MIDI CC to the MIDI output in real-time.

Unlike cc_map() which writes incoming CC values to composition.data for use at pattern rebuild time, cc_forward() routes the signal directly to the MIDI output — bypassing the pattern cycle entirely.

Both cc_map() and cc_forward() may be registered for the same CC number; they operate independently.

Arguments:
  • cc: Incoming CC number to listen for (0–127).
  • output: What to send. Either a preset string:

    • "cc" — identity forward, same CC number and value.
    • "cc:N" — forward as CC number N (e.g. "cc:74").
    • "pitchwheel" — scale 0–127 to -8192..8191 and send as pitch bend.

    Or a callable with signature (value: int, channel: int) -> Optional[mido.Message]. Return a fully formed mido.Message to send, or None to suppress. channel is 0-indexed (the incoming channel).

  • channel: If given, only respond to CC messages on this channel. Uses the same numbering convention as cc_map(). None matches any channel (default).
  • output_channel: Override the output channel. None uses the incoming channel. Uses the same numbering convention as pattern().
  • mode: Dispatch mode:

    • "instant" (default) — send immediately on the MIDI input callback thread. Lowest latency (~1–5 ms). Instant forwards are not recorded when recording is enabled.
    • "queued" — inject into the sequencer event queue and send at the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards are recorded when recording is enabled.
Example:
comp.midi_input("Arturia KeyStep")

# CC 1 → CC 1 (identity, instant)
comp.cc_forward(1, "cc")

# CC 1 → pitch bend on channel 1, queued (recordable)
comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued")

# CC 1 → CC 74, custom channel
comp.cc_forward(1, "cc:74", output_channel=2)

# Custom transform — remap CC range 0–127 to CC 74 range 40–100
import subsequence.midi as midi
comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch))

# Forward AND map to data simultaneously — both active on the same CC
comp.cc_map(1, "mod_wheel")
comp.cc_forward(1, "cc:74")
def live(self, port: int = 5555) -> None:
2789	def live (self, port: int = 5555) -> None:
2790
2791		"""
2792		Enable the live coding eval server.
2793
2794		This allows you to connect to a running composition using the
2795		`subsequence.live_client` REPL and hot-swap pattern code or
2796		modify variables in real-time.
2797
2798		Security:
2799			The server executes arbitrary Python in this process — it is **not** a
2800			sandbox.  It binds to localhost only and is opt-in, but any process on
2801			the same machine that can reach the port gains full code execution here.
2802			Do not enable it on shared or multi-user hosts, and never expose the
2803			port to a network.
2804
2805		Parameters:
2806			port: The TCP port to listen on (default 5555).
2807		"""
2808
2809		self._live_server = subsequence.live_server.LiveServer(self, port=port)
2810		self._is_live = True

Enable the live coding eval server.

This allows you to connect to a running composition using the subsequence.live_client REPL and hot-swap pattern code or modify variables in real-time.

Security:

The server executes arbitrary Python in this process — it is not a sandbox. It binds to localhost only and is opt-in, but any process on the same machine that can reach the port gains full code execution here. Do not enable it on shared or multi-user hosts, and never expose the port to a network.

Arguments:
  • port: The TCP port to listen on (default 5555).
def watch( self, path: Union[str, pathlib.Path], poll_interval: float = 0.25) -> None:
2812	def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None:
2813
2814		"""Watch a Python file and reload it into the composition on every save.
2815
2816		The watched file is exec'd into a namespace with ``composition`` and
2817		``subsequence`` available.  ``@composition.pattern`` decorators inside
2818		the file hot-swap their corresponding running patterns in place;
2819		patterns whose function bodies have been deleted from the file are
2820		unregistered automatically on the next reload (notes stopped,
2821		removed from the running-pattern set).
2822
2823		An **initial synchronous load** happens here — if the file has a
2824		``SyntaxError`` or doesn't exist at this moment, the exception
2825		propagates so the user knows immediately.  Subsequent reloads
2826		happen on the composition's event loop and tolerate transient
2827		errors (logged, skipped).
2828
2829		Call BEFORE ``composition.play()``.  Reloads happen on the
2830		composition's event loop, so all mutations are thread-safe.
2831
2832		See the "Live coding via file watching" section of the README for
2833		the recommended wrapper-script + live-file split.
2834
2835		Parameters:
2836			path: Path to the Python file to watch.
2837			poll_interval: Seconds between ``mtime`` polls (default 0.25 s).
2838
2839		Example::
2840
2841			# live_init.py — runs once
2842			composition = subsequence.Composition(bpm=120, key="E")
2843			composition.harmony(style="aeolian_minor")
2844			composition.watch("live_patterns.py")
2845			composition.play()
2846		"""
2847
2848		# Required for the decorator hot-swap path to fire on re-decoration.
2849		self._is_live = True
2850
2851		# Detect the single-file workflow: if watch() is called from inside
2852		# the very file being watched, the outer Python script execution will
2853		# already register the patterns (the decorators sit at module level
2854		# below ``watch(__file__)``).  In that case, _load_initial's re-exec
2855		# would double-register every pattern, so skip it.  For the two-file
2856		# workflow (path != caller's __file__) the initial exec is essential
2857		# — it's the only way the watched file's patterns ever reach the
2858		# composition.
2859		caller_file = self._caller_module_file()
2860		self_watch = False
2861		if caller_file is not None:
2862			try:
2863				self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve()
2864			except OSError:
2865				self_watch = False
2866
2867		self._live_reloader = subsequence.live_reloader.LiveReloader(
2868			composition = self,
2869			path = path,
2870			poll_interval = poll_interval,
2871			skip_initial_exec = self_watch,
2872		)
2873		self._live_reloader.start()

Watch a Python file and reload it into the composition on every save.

The watched file is exec'd into a namespace with composition and subsequence available. @composition.pattern decorators inside the file hot-swap their corresponding running patterns in place; patterns whose function bodies have been deleted from the file are unregistered automatically on the next reload (notes stopped, removed from the running-pattern set).

An initial synchronous load happens here — if the file has a SyntaxError or doesn't exist at this moment, the exception propagates so the user knows immediately. Subsequent reloads happen on the composition's event loop and tolerate transient errors (logged, skipped).

Call BEFORE composition.play(). Reloads happen on the composition's event loop, so all mutations are thread-safe.

See the "Live coding via file watching" section of the README for the recommended wrapper-script + live-file split.

Arguments:
  • path: Path to the Python file to watch.
  • poll_interval: Seconds between mtime polls (default 0.25 s).

Example::

    # live_init.py — runs once
    composition = subsequence.Composition(bpm=120, key="E")
    composition.harmony(style="aeolian_minor")
    composition.watch("live_patterns.py")
    composition.play()
def load_patterns(self, source: str, source_label: str = '<string>') -> None:
2892	def load_patterns (
2893		self,
2894		source:       str,
2895		source_label: str = "<string>",
2896	) -> None:
2897
2898		"""Compile and apply a pattern-source string to the composition.
2899
2900		Equivalent to one ``watch()`` reload triggered by save, but with the
2901		source presented in-memory rather than on disk.  Useful for web /
2902		REST handlers that accept pattern uploads from a trusted contributor,
2903		or for one-shot session loads with no file backing.
2904
2905		Behaviour mirrors ``watch()``:
2906		* The source is exec'd into a fresh namespace with ``composition``
2907		  and ``subsequence`` in scope.
2908		* ``@composition.pattern`` decorators in the source hot-swap their
2909		  corresponding running patterns in place.
2910		* Patterns currently running but **not** declared in the source are
2911		  unregistered — the source is treated as the full new truth.
2912		* If the composition is already playing, the swap happens on the
2913		  event loop thread; the call blocks until it completes.
2914		* If the composition has not yet called ``play()``, the source runs
2915		  on the caller's thread; decorators populate ``_pending_patterns``
2916		  and ``play()`` picks them up in the usual way.
2917
2918		Errors are raised so the caller can act on them:
2919		* ``SyntaxError`` if ``source`` fails to compile.
2920		* The exception raised inside ``exec()`` for any runtime error.
2921		* ``RuntimeError`` if called from inside the composition's own
2922		  event loop thread (would deadlock — see Threading below).
2923
2924		In either failure case, existing composition state is preserved —
2925		the diff-and-unregister phase is skipped if exec raised, so a
2926		half-broken upload cannot tear down working patterns.
2927
2928		Threading:
2929			Designed to be called from a thread DIFFERENT from the
2930			composition's event loop — typically a web-handler worker.
2931			Cannot be called from inside the loop itself (a pattern
2932			callback, an asyncio task spawned by the composition).  From
2933			there, ``await composition._apply_source_async(...)`` directly.
2934
2935		SECURITY WARNING: ``exec()`` is not sandboxed.  The source has full
2936		Python access in this process.  Only pass source from trusted
2937		senders.  The built-in blocklist (``help``, ``input``, ``breakpoint``,
2938		``exit``, ``quit``) prevents calls that would stall the event loop;
2939		it is not a security boundary.
2940
2941		Parameters:
2942			source:       Python source declaring ``@composition.pattern``
2943				functions.
2944			source_label: Identifier used in compile errors and tracebacks
2945				(appears as the filename in ``SyntaxError`` and ``__file__``-
2946				style traceback lines).  Default ``"<string>"``.
2947		"""
2948
2949		# Required for the decorator hot-swap path to fire on re-decoration.
2950		self._is_live = True
2951
2952		# Compile on the caller's thread so SyntaxError comes back fast,
2953		# before any cross-thread scheduling.
2954		compiled = compile(source, source_label, "exec")
2955		namespace = self._build_live_namespace(source_label = source_label)
2956
2957		loop = self._sequencer._event_loop
2958
2959		if loop is not None and loop.is_running():
2960
2961			# Refuse to deadlock: calling load_patterns() from inside the
2962			# composition's own event loop (e.g. from a pattern callback or
2963			# an asyncio task spawned by the composition) would have us
2964			# block waiting for a coroutine that can only run when this
2965			# thread yields.  Tell the caller exactly what to do instead.
2966			try:
2967				current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop()
2968			except RuntimeError:
2969				current_loop = None
2970
2971			if current_loop is loop:
2972				raise RuntimeError(
2973					"load_patterns() cannot be called from inside the composition's "
2974					"event loop thread — it would deadlock waiting for the "
2975					"scheduled coroutine to run on the very thread that's blocked. "
2976					"From a worker thread, call it normally.  From an async "
2977					"coroutine already on the loop, "
2978					"`await composition._apply_source_async(compile(source, label, 'exec'), "
2979					"composition._build_live_namespace())` instead."
2980				)
2981
2982			# Composition is playing — mutation must happen on the loop thread.
2983			# future.result() blocks the caller until the coroutine finishes
2984			# and re-raises any exception it threw.
2985			future = asyncio.run_coroutine_threadsafe(
2986				self._apply_source_async(compiled, namespace),
2987				loop = loop,
2988			)
2989			future.result()
2990
2991		else:
2992			# Pre-play: no event loop yet.  Decorators populate
2993			# _pending_patterns; play() graduates them in the usual way.
2994			# Diff-and-unregister is unnecessary here — nothing is running.
2995			exec(compiled, namespace)

Compile and apply a pattern-source string to the composition.

Equivalent to one watch() reload triggered by save, but with the source presented in-memory rather than on disk. Useful for web / REST handlers that accept pattern uploads from a trusted contributor, or for one-shot session loads with no file backing.

Behaviour mirrors watch():

  • The source is exec'd into a fresh namespace with composition and subsequence in scope.
  • @composition.pattern decorators in the source hot-swap their corresponding running patterns in place.
  • Patterns currently running but not declared in the source are unregistered — the source is treated as the full new truth.
  • If the composition is already playing, the swap happens on the event loop thread; the call blocks until it completes.
  • If the composition has not yet called play(), the source runs on the caller's thread; decorators populate _pending_patterns and play() picks them up in the usual way.

Errors are raised so the caller can act on them:

  • SyntaxError if source fails to compile.
  • The exception raised inside exec() for any runtime error.
  • RuntimeError if called from inside the composition's own event loop thread (would deadlock — see Threading below).

In either failure case, existing composition state is preserved — the diff-and-unregister phase is skipped if exec raised, so a half-broken upload cannot tear down working patterns.

Threading:

Designed to be called from a thread DIFFERENT from the composition's event loop — typically a web-handler worker. Cannot be called from inside the loop itself (a pattern callback, an asyncio task spawned by the composition). From there, await composition._apply_source_async(...) directly.

SECURITY WARNING: exec() is not sandboxed. The source has full Python access in this process. Only pass source from trusted senders. The built-in blocklist (help, input, breakpoint, exit, quit) prevents calls that would stall the event loop; it is not a security boundary.

Arguments:
  • source: Python source declaring @composition.pattern functions.
  • source_label: Identifier used in compile errors and tracebacks (appears as the filename in SyntaxError and __file__- style traceback lines). Default "<string>".
def osc( self, receive_port: int = 9000, send_port: int = 9001, send_host: str = '127.0.0.1', receive_host: str = '0.0.0.0') -> None:
3098	def osc (self, receive_port: int = 9000, send_port: int = 9001, send_host: str = "127.0.0.1", receive_host: str = "0.0.0.0") -> None:
3099
3100		"""
3101		Enable bi-directional Open Sound Control (OSC).
3102
3103		Subsequence will listen for commands (like `/bpm` or `/mute`) and
3104		broadcast its internal state (like `/chord` or `/bar`) over UDP.
3105
3106		Parameters:
3107			receive_port: Port to listen for incoming OSC messages (default 9000).
3108			send_port: Port to send state updates to (default 9001).
3109			send_host: The IP address to send updates to (default "127.0.0.1").
3110			receive_host: Interface to listen on (default "0.0.0.0" — all
3111				interfaces, so external OSC controllers on the LAN can reach it).
3112				The listener can change tempo, mute patterns, and write data, so on
3113				an untrusted network restrict it with ``receive_host="127.0.0.1"``.
3114		"""
3115
3116		self._osc_server = subsequence.osc.OscServer(
3117			self,
3118			receive_port = receive_port,
3119			send_port = send_port,
3120			send_host = send_host,
3121			receive_host = receive_host
3122		)

Enable bi-directional Open Sound Control (OSC).

Subsequence will listen for commands (like /bpm or /mute) and broadcast its internal state (like /chord or /bar) over UDP.

Arguments:
  • receive_port: Port to listen for incoming OSC messages (default 9000).
  • send_port: Port to send state updates to (default 9001).
  • send_host: The IP address to send updates to (default "127.0.0.1").
  • receive_host: Interface to listen on (default "0.0.0.0" — all interfaces, so external OSC controllers on the LAN can reach it). The listener can change tempo, mute patterns, and write data, so on an untrusted network restrict it with receive_host="127.0.0.1".
def osc_map(self, address: str, handler: Callable) -> None:
3124	def osc_map (self, address: str, handler: typing.Callable) -> None:
3125
3126		"""
3127		Register a custom OSC handler.
3128
3129		Must be called after :meth:`osc` has been configured.
3130
3131		Parameters:
3132			address: OSC address pattern to match (e.g. ``"/my/param"``).
3133			handler: Callable invoked with ``(address, *args)`` when a
3134				matching message arrives.
3135
3136		Example::
3137
3138			composition.osc()
3139
3140			def on_intensity (address, value):
3141				composition.data["intensity"] = float(value)
3142
3143			composition.osc_map("/intensity", on_intensity)
3144		"""
3145
3146		if self._osc_server is None:
3147			raise RuntimeError("Call composition.osc() before composition.osc_map()")
3148
3149		self._osc_server.map(address, handler)

Register a custom OSC handler.

Must be called after osc() has been configured.

Arguments:
  • address: OSC address pattern to match (e.g. "/my/param").
  • handler: Callable invoked with (address, *args) when a matching message arrives.

Example::

    composition.osc()

    def on_intensity (address, value):
            composition.data["intensity"] = float(value)

    composition.osc_map("/intensity", on_intensity)
def set_bpm(self, bpm: float) -> None:
3151	def set_bpm (self, bpm: float) -> None:
3152
3153		"""
3154		Instantly change the tempo.
3155
3156		Parameters:
3157			bpm: The new tempo in beats per minute.
3158
3159		When Ableton Link is active, this proposes the new tempo to the Link
3160		network instead of applying it locally.  The network-authoritative tempo
3161		is picked up on the next pulse.
3162		"""
3163
3164		self._sequencer.set_bpm(bpm)
3165
3166		if not self.is_clock_following and self._link_quantum is None:
3167			self.bpm = bpm

Instantly change the tempo.

Arguments:
  • bpm: The new tempo in beats per minute.

When Ableton Link is active, this proposes the new tempo to the Link network instead of applying it locally. The network-authoritative tempo is picked up on the next pulse.

def target_bpm(self, bpm: float, bars: int, shape: str = 'linear') -> None:
3169	def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None:
3170
3171		"""
3172		Smoothly ramp the tempo to a target value over a number of bars.
3173
3174		Parameters:
3175			bpm: Target tempo in beats per minute.
3176			bars: Duration of the transition in bars.
3177			shape: Easing curve name.  Defaults to ``"linear"``.
3178			       ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural-
3179			       sounding tempo changes.  See :mod:`subsequence.easing` for all
3180			       available shapes.
3181
3182		Example:
3183			```python
3184			# Accelerate to 140 BPM over the next 8 bars with a smooth S-curve
3185			comp.target_bpm(140, bars=8, shape="ease_in_out")
3186			```
3187
3188		Note:
3189			Ignored while Ableton Link is active — the shared session tempo is
3190			authoritative.  Use ``set_bpm()`` to propose a tempo to the Link network.
3191		"""
3192
3193		self._sequencer.set_target_bpm(bpm, bars, shape)

Smoothly ramp the tempo to a target value over a number of bars.

Arguments:
  • bpm: Target tempo in beats per minute.
  • bars: Duration of the transition in bars.
  • shape: Easing curve name. Defaults to "linear". "ease_in_out" or "s_curve" are recommended for natural- sounding tempo changes. See subsequence.easing for all available shapes.
Example:
# Accelerate to 140 BPM over the next 8 bars with a smooth S-curve
comp.target_bpm(140, bars=8, shape="ease_in_out")
Note:

Ignored while Ableton Link is active — the shared session tempo is authoritative. Use set_bpm() to propose a tempo to the Link network.

def live_info(self) -> Dict[str, Any]:
3195	def live_info (self) -> typing.Dict[str, typing.Any]:
3196
3197		"""
3198		Return a dictionary containing the current state of the composition.
3199		
3200		Includes BPM, key, current bar, active section, current chord, 
3201		running patterns, and custom data.
3202		"""
3203
3204		section_info = None
3205		if self._form_state is not None:
3206			section = self._form_state.get_section_info()
3207			if section is not None:
3208				section_info = {
3209					"name": section.name,
3210					"bar": section.bar,
3211					"bars": section.bars,
3212					"progress": section.progress
3213				}
3214
3215		chord_name = None
3216		sounding_chord = self.current_chord()
3217		if sounding_chord is not None:
3218			chord_name = sounding_chord.name()
3219
3220		pattern_list = []
3221		channel_offset = 0 if self._zero_indexed_channels else 1
3222		for name, pat in self._running_patterns.items():
3223			pattern_list.append({
3224				"name": name,
3225				"channel": pat.channel + channel_offset,
3226				"length": pat.length,
3227				"cycle": pat._cycle_count,
3228				"muted": pat._muted,
3229				"tweaks": dict(pat._tweaks)
3230			})
3231
3232		return {
3233			"bpm": self._sequencer.current_bpm,
3234			"key": self.key,
3235			"bar": self._builder_bar,
3236			"section": section_info,
3237			"chord": chord_name,
3238			"patterns": pattern_list,
3239			"input_device": self._input_device,
3240			"clock_follow": self.is_clock_following,
3241			"data": self.data
3242		}

Return a dictionary containing the current state of the composition.

Includes BPM, key, current bar, active section, current chord, running patterns, and custom data.

def mute(self, name: str) -> None:
3244	def mute (self, name: str) -> None:
3245
3246		"""
3247		Mute a running pattern by name.
3248		
3249		The pattern continues to 'run' and increment its cycle count in 
3250		the background, but it will not produce any MIDI notes until unmuted.
3251
3252		Parameters:
3253			name: The function name of the pattern to mute.
3254		"""
3255
3256		if name not in self._running_patterns:
3257			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3258
3259		self._running_patterns[name]._muted = True
3260		logger.info(f"Muted pattern: {name}")

Mute a running pattern by name.

The pattern continues to 'run' and increment its cycle count in the background, but it will not produce any MIDI notes until unmuted.

Arguments:
  • name: The function name of the pattern to mute.
def unmute(self, name: str) -> None:
3262	def unmute (self, name: str) -> None:
3263
3264		"""
3265		Unmute a previously muted pattern.
3266		"""
3267
3268		if name not in self._running_patterns:
3269			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3270
3271		self._running_patterns[name]._muted = False
3272		logger.info(f"Unmuted pattern: {name}")

Unmute a previously muted pattern.

def unregister(self, name: str) -> None:
3274	def unregister (self, name: str) -> None:
3275
3276		"""Fully remove a running pattern from rotation.
3277
3278		Unlike ``mute()`` (which keeps the pattern alive but silent),
3279		``unregister()`` tears the pattern down entirely.  It sets
3280		``pattern._removed = True`` so the sequencer's reschedule loop
3281		skips re-adding it on the next pulse; sends ``note_off`` for any
3282		of the pattern's currently-sounding notes on the primary
3283		destination AND on every mirror destination (so drones and
3284		sustaining notes stop immediately); and removes the entry from
3285		``_running_patterns`` so it no longer appears in ``live_info()``,
3286		the terminal grid, or any other consumer that enumerates running
3287		patterns.
3288
3289		Already-queued events in the sequencer's event queue play out —
3290		note_offs are paired with their note_ons at queue time, so notes
3291		end at their natural duration; only drones rely on the targeted
3292		``_stop_pattern_notes`` pass.
3293
3294		Idempotent: silently logs a ``debug`` and returns if the pattern
3295		is already absent.  Useful from both the live REPL
3296		(``composition.live()``) and the file watcher
3297		(``composition.watch()``), which calls this for any pattern
3298		removed from the watched file between reloads.
3299
3300		Parameters:
3301			name: Function name of the pattern to remove.
3302		"""
3303
3304		if name not in self._running_patterns:
3305			logger.debug(f"unregister() no-op: pattern '{name}' not running")
3306			return
3307
3308		pattern = self._running_patterns[name]
3309
3310		# Mark for removal first so the reschedule loop sees the flag even if
3311		# it fires concurrently with the note-off pass below.
3312		pattern._removed = True
3313
3314		# Stop sustaining notes (including drones) on every destination this
3315		# pattern outputs to.  Fire-and-forget across threads via the event
3316		# loop; ``_stop_pattern_notes`` acquires the queue lock internally.
3317		if self._sequencer._event_loop is not None:
3318			asyncio.run_coroutine_threadsafe(
3319				self._sequencer._stop_pattern_notes(pattern),
3320				loop = self._sequencer._event_loop,
3321			)
3322
3323		def _finalise_removal () -> None:
3324			self._running_patterns.pop(name, None)
3325
3326			# Forget any pending (not-yet-graduated) declaration too, so a
3327			# later live reload cannot resurrect the pattern.
3328			self._pending_patterns = [
3329				pending for pending in self._pending_patterns
3330				if pending.builder_fn.__name__ != name
3331			]
3332
3333			logger.info(f"Unregistered pattern: {name}")
3334
3335		# The running-patterns dict is iterated by the display, web UI, and
3336		# reschedule loop on the event loop thread — mutate it there when this
3337		# call arrives from another thread (e.g. the live TCP server).
3338		loop = self._sequencer._event_loop
3339
3340		try:
3341			on_loop = loop is not None and asyncio.get_running_loop() is loop
3342		except RuntimeError:
3343			on_loop = False
3344
3345		if loop is not None and loop.is_running() and not on_loop:
3346			loop.call_soon_threadsafe(_finalise_removal)
3347		else:
3348			_finalise_removal()

Fully remove a running pattern from rotation.

Unlike mute() (which keeps the pattern alive but silent), unregister() tears the pattern down entirely. It sets pattern._removed = True so the sequencer's reschedule loop skips re-adding it on the next pulse; sends note_off for any of the pattern's currently-sounding notes on the primary destination AND on every mirror destination (so drones and sustaining notes stop immediately); and removes the entry from _running_patterns so it no longer appears in live_info(), the terminal grid, or any other consumer that enumerates running patterns.

Already-queued events in the sequencer's event queue play out — note_offs are paired with their note_ons at queue time, so notes end at their natural duration; only drones rely on the targeted _stop_pattern_notes pass.

Idempotent: silently logs a debug and returns if the pattern is already absent. Useful from both the live REPL (composition.live()) and the file watcher (composition.watch()), which calls this for any pattern removed from the watched file between reloads.

Arguments:
  • name: Function name of the pattern to remove.
def mirror( self, name: str, device: int, channel: int, drum_note_map: Optional[Dict[str, int]] = None) -> None:
3350	def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None:
3351
3352		"""
3353		Add a mirror destination to a running pattern.
3354
3355		Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone
3356		event the pattern emits will also be sent to ``(device, channel)``,
3357		starting from the next cycle rebuild.  Idempotent on ``(device, channel)``
3358		— calling with the same destination twice does not double-fan; calling
3359		again with a different ``drum_note_map`` re-points it in place.
3360
3361		Parameters:
3362			name: Function name of the pattern to mirror.
3363			device: Output device index (the integer returned from
3364				``midi_output()``; 0 = primary device).
3365			channel: MIDI channel using this composition's numbering convention
3366				(1-16 by default; 0-15 if ``zero_indexed_channels=True``).
3367			drum_note_map: Optional per-destination drum map.  When set, mirrored
3368				drum hits are re-resolved by name through it, so a named voice
3369				lands on this device's own note number — see the README
3370				"MIDI mirroring" section.
3371
3372		Bandwidth: each mirror adds another full copy of the pattern's events.
3373		See the README "MIDI mirroring" section for the tradeoffs.
3374		"""
3375
3376		if name not in self._running_patterns:
3377			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3378
3379		resolved_channel = self._resolve_channel(channel)
3380		prefix = (device, resolved_channel)
3381		entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map)
3382
3383		pattern = self._running_patterns[name]
3384
3385		# Mirror-to-self check: comparing the (device, channel) prefix against the
3386		# live pattern's resolved destination.  Unlike the decorator path this is
3387		# always concrete.
3388		if prefix == (pattern.device, pattern.channel):
3389			logger.warning(
3390				f"Mirror destination {prefix} matches '{name}'s primary destination "
3391				f"— every event will double-fire on this (device, channel).  This is almost "
3392				f"certainly unintended."
3393			)
3394
3395		# Idempotent on (device, channel): replace any existing entry for the same
3396		# destination (so its map can be re-pointed), else append.
3397		existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None)
3398		if existing_index is None:
3399			pattern.mirrors.append(entry)
3400			logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}")
3401		elif pattern.mirrors[existing_index] != entry:
3402			pattern.mirrors[existing_index] = entry
3403			logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}")
3404		else:
3405			logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}")

Add a mirror destination to a running pattern.

Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone event the pattern emits will also be sent to (device, channel), starting from the next cycle rebuild. Idempotent on (device, channel) — calling with the same destination twice does not double-fan; calling again with a different drum_note_map re-points it in place.

Arguments:
  • name: Function name of the pattern to mirror.
  • device: Output device index (the integer returned from midi_output(); 0 = primary device).
  • channel: MIDI channel using this composition's numbering convention (1-16 by default; 0-15 if zero_indexed_channels=True).
  • drum_note_map: Optional per-destination drum map. When set, mirrored drum hits are re-resolved by name through it, so a named voice lands on this device's own note number — see the README "MIDI mirroring" section.

Bandwidth: each mirror adds another full copy of the pattern's events. See the README "MIDI mirroring" section for the tradeoffs.

def unmirror(self, name: str, device: int, channel: int) -> None:
3407	def unmirror (self, name: str, device: int, channel: int) -> None:
3408
3409		"""
3410		Remove a single mirror destination from a running pattern.
3411
3412		Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is
3413		ignored.  Idempotent: silently does nothing if the destination is not
3414		currently mirrored.  The change applies on the next cycle rebuild.
3415		"""
3416
3417		if name not in self._running_patterns:
3418			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3419
3420		resolved_channel = self._resolve_channel(channel)
3421		prefix = (device, resolved_channel)
3422
3423		pattern = self._running_patterns[name]
3424
3425		filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix]
3426		if len(filtered) != len(pattern.mirrors):
3427			pattern.mirrors[:] = filtered
3428			logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}")
3429		else:
3430			logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors")

Remove a single mirror destination from a running pattern.

Matches on (device, channel) only — any attached drum_note_map is ignored. Idempotent: silently does nothing if the destination is not currently mirrored. The change applies on the next cycle rebuild.

def unmirror_all(self, name: str) -> None:
3432	def unmirror_all (self, name: str) -> None:
3433
3434		"""
3435		Remove every mirror destination from a running pattern.
3436		"""
3437
3438		if name not in self._running_patterns:
3439			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3440
3441		pattern = self._running_patterns[name]
3442
3443		if pattern.mirrors:
3444			pattern.mirrors.clear()
3445			logger.info(f"All mirrors cleared on pattern: {name}")

Remove every mirror destination from a running pattern.

def tweak(self, name: str, **kwargs: Any) -> None:
3447	def tweak (self, name: str, **kwargs: typing.Any) -> None:
3448
3449		"""Override parameters for a running pattern.
3450
3451		Values set here are available inside the pattern's builder
3452		function via ``p.param()``.  They persist across rebuilds
3453		until explicitly changed or cleared.  Changes take effect
3454		on the next rebuild cycle.
3455
3456		Parameters:
3457			name: The function name of the pattern.
3458			**kwargs: Parameter names and their new values.
3459
3460		Example (from the live REPL)::
3461
3462			composition.tweak("bass", pitches=[48, 52, 55, 60])
3463		"""
3464
3465		if name not in self._running_patterns:
3466			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3467
3468		self._running_patterns[name]._tweaks.update(kwargs)
3469		logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}")

Override parameters for a running pattern.

Values set here are available inside the pattern's builder function via p.param(). They persist across rebuilds until explicitly changed or cleared. Changes take effect on the next rebuild cycle.

Arguments:
  • name: The function name of the pattern.
  • **kwargs: Parameter names and their new values.

Example (from the live REPL)::

    composition.tweak("bass", pitches=[48, 52, 55, 60])
def clear_tweak(self, name: str, *param_names: str) -> None:
3471	def clear_tweak (self, name: str, *param_names: str) -> None:
3472
3473		"""Remove tweaked parameters from a running pattern.
3474
3475		If no parameter names are given, all tweaks for the pattern
3476		are cleared and every ``p.param()`` call reverts to its
3477		default.
3478
3479		Parameters:
3480			name: The function name of the pattern.
3481			*param_names: Specific parameter names to clear.  If
3482				omitted, all tweaks are removed.
3483		"""
3484
3485		if name not in self._running_patterns:
3486			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3487
3488		if not param_names:
3489			self._running_patterns[name]._tweaks.clear()
3490			logger.info(f"Cleared all tweaks for pattern '{name}'")
3491		else:
3492			for param_name in param_names:
3493				self._running_patterns[name]._tweaks.pop(param_name, None)
3494			logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}")

Remove tweaked parameters from a running pattern.

If no parameter names are given, all tweaks for the pattern are cleared and every p.param() call reverts to its default.

Arguments:
  • name: The function name of the pattern.
  • *param_names: Specific parameter names to clear. If omitted, all tweaks are removed.
def get_tweaks(self, name: str) -> Dict[str, Any]:
3496	def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]:
3497
3498		"""Return a copy of the current tweaks for a running pattern.
3499
3500		Parameters:
3501			name: The function name of the pattern.
3502		"""
3503
3504		if name not in self._running_patterns:
3505			raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}")
3506
3507		return dict(self._running_patterns[name]._tweaks)

Return a copy of the current tweaks for a running pattern.

Arguments:
  • name: The function name of the pattern.
def schedule( self, fn: Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None:
3509	def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None:
3510
3511		"""
3512		Register a custom function to run on a repeating beat-based cycle.
3513
3514		Subsequence automatically runs synchronous functions in a thread pool
3515		so they don't block the timing-critical MIDI clock. Async functions
3516		are run directly on the event loop.
3517
3518		Parameters:
3519			fn: The function to call.
3520			cycle_beats: How often to call it (e.g., 4 = every bar).
3521			reschedule_lookahead: How far in advance to schedule the next call.
3522			wait_for_initial: If True, run the function once during startup
3523				and wait for it to complete before playback begins. This
3524				ensures ``composition.data`` is populated before patterns
3525				first build. Implies ``defer=True`` for the repeating
3526				schedule.
3527			defer: If True, skip the pulse-0 fire and defer the first
3528				repeating call to just before the second cycle boundary.
3529
3530		Raises:
3531			RuntimeError: If called after ``play()`` has started — scheduled
3532				tasks register at startup, so a late registration would be
3533				silently ignored otherwise.
3534		"""
3535
3536		if self._sequencer.running:
3537			raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup")
3538
3539		self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer))

Register a custom function to run on a repeating beat-based cycle.

Subsequence automatically runs synchronous functions in a thread pool so they don't block the timing-critical MIDI clock. Async functions are run directly on the event loop.

Arguments:
  • fn: The function to call.
  • cycle_beats: How often to call it (e.g., 4 = every bar).
  • reschedule_lookahead: How far in advance to schedule the next call.
  • wait_for_initial: If True, run the function once during startup and wait for it to complete before playback begins. This ensures composition.data is populated before patterns first build. Implies defer=True for the repeating schedule.
  • defer: If True, skip the pulse-0 fire and defer the first repeating call to just before the second cycle boundary.
Raises:
  • RuntimeError: If called after play() has started — scheduled tasks register at startup, so a late registration would be silently ignored otherwise.
def form( self, sections: Union[List[Tuple[str, int]], Iterator[Tuple[str, int]], Dict[str, Tuple[int, Optional[List[Tuple[str, int]]]]]], loop: bool = False, start: Optional[str] = None) -> None:
3541	def form (
3542		self,
3543		sections: typing.Union[
3544			typing.List[typing.Tuple[str, int]],
3545			typing.Iterator[typing.Tuple[str, int]],
3546			typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]]
3547		],
3548		loop: bool = False,
3549		start: typing.Optional[str] = None
3550	) -> None:
3551
3552		"""
3553		Define the structure (sections) of the composition.
3554
3555		You can define form in three ways:
3556		1. **Graph (Dict)**: Dynamic transitions based on weights.
3557		2. **Sequence (List)**: A fixed order of sections.
3558		3. **Generator**: A Python generator that yields `(name, bars)` pairs.
3559
3560		Parameters:
3561			sections: The form definition (Dict, List, or Generator).
3562			loop: Whether to cycle back to the start (List mode only).
3563			start: The section to start with (Graph mode only).
3564
3565		Example:
3566			```python
3567			# A simple pop structure
3568			comp.form([
3569				("verse", 8),
3570				("chorus", 8),
3571				("verse", 8),
3572				("chorus", 16)
3573			])
3574			```
3575		"""
3576
3577		# Seed FormState at form() time (per-call salt) so build-time walks —
3578		# the frozen clones form_freeze will take — are deterministic without
3579		# play(); the play-time stream is re-dealt name-keyed in _run().
3580		self._form_count += 1
3581
3582		self._form_state = subsequence.form_state.FormState(
3583			sections,
3584			loop = loop,
3585			start = start,
3586			rng = self._stream(f"form:{self._form_count}")
3587		)

Define the structure (sections) of the composition.

You can define form in three ways:

  1. Graph (Dict): Dynamic transitions based on weights.
  2. Sequence (List): A fixed order of sections.
  3. Generator: A Python generator that yields (name, bars) pairs.
Arguments:
  • sections: The form definition (Dict, List, or Generator).
  • loop: Whether to cycle back to the start (List mode only).
  • start: The section to start with (Graph mode only).
Example:
# A simple pop structure
comp.form([
        ("verse", 8),
        ("chorus", 8),
        ("verse", 8),
        ("chorus", 16)
])
def pattern( self, channel: int, beats: Optional[float] = None, bars: Optional[float] = None, steps: Optional[float] = None, step_duration: Optional[float] = None, drum_note_map: Optional[Dict[str, int]] = None, cc_name_map: Optional[Dict[str, int]] = None, nrpn_name_map: Optional[Dict[str, int]] = None, reschedule_lookahead: float = 1, voice_leading: bool = False, device: Union[int, str, NoneType] = None, mirrors: Optional[Iterable[Union[Tuple[int, int], Tuple[int, int, Optional[Dict[str, int]]]]]] = None) -> Callable:
3642	def pattern (
3643		self,
3644		channel: int,
3645		beats: typing.Optional[float] = None,
3646		bars: typing.Optional[float] = None,
3647		steps: typing.Optional[float] = None,
3648		step_duration: typing.Optional[float] = None,
3649		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
3650		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
3651		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
3652		reschedule_lookahead: float = 1,
3653		voice_leading: bool = False,
3654		device: subsequence.midi_utils.DeviceId = None,
3655		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3656	) -> typing.Callable:
3657
3658		"""
3659		Register a function as a repeating MIDI pattern.
3660
3661		The decorated function will be called once per cycle to 'rebuild' its
3662		content. This allows for generative logic that evolves over time.
3663
3664		Two ways to specify pattern length:
3665
3666		- **Duration mode** (default): use ``beats=`` or ``bars=``.
3667		  The grid defaults to sixteenth-note resolution.
3668		- **Step mode**: use ``steps=`` paired with ``step_duration=``.
3669		  The grid equals the step count, so ``p.hit_steps()`` indices map
3670		  directly to steps.
3671
3672		Parameters:
3673			channel: MIDI channel. By default uses 1-based numbering (1-16).
3674				Set ``zero_indexed_channels=True`` on the ``Composition`` to use
3675				0-based numbering (0-15), matching the raw MIDI protocol, instead.
3676			beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar.
3677			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats.
3678			steps: Step count for step mode. Requires ``step_duration=``.
3679			step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``).
3680				Requires ``steps=``.
3681			drum_note_map: Optional mapping for drum instruments.
3682			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
3683				Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``.
3684			nrpn_name_map: Optional mapping of NRPN parameter names (strings) to
3685				14-bit parameter numbers (0–16383).  Enables string-based names
3686				in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a
3687				device-specific dictionary (e.g. Sequential Take 5's
3688				``Osc1FreqFine`` → 9).
3689			reschedule_lookahead: Beats in advance to compute the next cycle.
3690			voice_leading: If True, chords in this pattern will automatically
3691				use inversions that minimize voice movement.
3692			mirrors: Optional list of additional ``(device, channel)`` destinations
3693				to duplicate every event from this pattern onto.  Notes, CCs, pitch
3694				bend, NRPN/RPN bursts, program changes, SysEx, and drone events are
3695				all mirrored; OSC events are not (OSC is not bound to a MIDI port).
3696				``device`` is the integer index returned by ``midi_output()`` (0 =
3697				primary).  ``channel`` follows this composition's channel-numbering
3698				convention.  See also ``mirror()`` / ``unmirror()`` for live toggling.
3699
3700		Example:
3701			```python
3702			@comp.pattern(channel=1, beats=4)
3703			def chords (p):
3704				p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9)
3705
3706			@comp.pattern(channel=1, bars=2)
3707			def long_phrase (p):
3708				...
3709
3710			@comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH)
3711			def riff (p):
3712				p.sequence(steps=[0, 1, 3, 5], pitches=60)
3713			```
3714		"""
3715
3716		channel = self._resolve_channel(channel)
3717
3718		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0])
3719
3720		# Resolve device string name to index if possible now; otherwise store
3721		# the raw DeviceId and resolve it in _run() once all devices are open.
3722		resolved_device: subsequence.midi_utils.DeviceId = device
3723
3724		# Mirror-to-self check is only reliable when the primary device is a
3725		# concrete integer at decoration time.  ``None`` resolves to device 0
3726		# downstream, so we treat it as 0 here too.  Strings are deferred to
3727		# ``_run()`` and we skip the check for them.
3728		primary: typing.Optional[typing.Tuple[int, int]]
3729		if isinstance(resolved_device, str):
3730			primary = None
3731		else:
3732			primary = (resolved_device if resolved_device is not None else 0, channel)
3733		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
3734
3735		def decorator (fn: typing.Callable) -> typing.Callable:
3736
3737			"""
3738			Wrap the builder function and register it as a pending pattern.
3739			During live sessions, hot-swap an existing pattern's builder instead.
3740			"""
3741
3742			# Record this declaration so the live-reload deletion diff knows the
3743			# pattern is still present in the source (see _apply_source_async).
3744			self._declared_names.add(fn.__name__)
3745
3746			# Hot-swap: if we're live and a pattern with this name exists, replace its builder.
3747			if self._is_live and fn.__name__ in self._running_patterns:
3748				running = self._running_patterns[fn.__name__]
3749				running._builder_fn = fn
3750				running._wants_chord = _fn_has_parameter(fn, "chord")
3751				logger.info(f"Hot-swapped pattern: {fn.__name__}")
3752				return fn
3753
3754			# Names key the seeded stream, mutes, tweaks, and reroll/lock — a
3755			# duplicate means two scheduled copies sharing one stream with
3756			# only one reachable by name.  Warn loudly at registration.
3757			if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns):
3758				logger.warning(
3759					f"Duplicate pattern name '{fn.__name__}': both copies will be "
3760					f"scheduled, they share one seeded stream, and only one is "
3761					f"reachable by name — rename one of them."
3762				)
3763
3764			pending = _PendingPattern(
3765				builder_fn = fn,
3766				channel = channel,  # already resolved to 0-indexed
3767				length = beat_length,
3768				default_grid = default_grid,
3769				drum_note_map = drum_note_map,
3770				cc_name_map = cc_name_map,
3771				nrpn_name_map = nrpn_name_map,
3772				reschedule_lookahead = reschedule_lookahead,
3773				voice_leading = voice_leading,
3774				# For int/None: resolve immediately.  For str: store 0 as
3775				# placeholder; _resolve_pending_devices() fixes it in _run().
3776				device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device,
3777				raw_device = resolved_device,
3778				mirrors = resolved_mirrors,
3779			)
3780
3781			self._pending_patterns.append(pending)
3782
3783			return fn
3784
3785		return decorator

Register a function as a repeating MIDI pattern.

The decorated function will be called once per cycle to 'rebuild' its content. This allows for generative logic that evolves over time.

Two ways to specify pattern length:

  • Duration mode (default): use beats= or bars=. The grid defaults to sixteenth-note resolution.
  • Step mode: use steps= paired with step_duration=. The grid equals the step count, so p.hit_steps() indices map directly to steps.
Arguments:
  • channel: MIDI channel. By default uses 1-based numbering (1-16). Set zero_indexed_channels=True on the Composition to use 0-based numbering (0-15), matching the raw MIDI protocol, instead.
  • beats: Duration in beats (quarter notes). beats=4 = 1 bar.
  • bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). bars=2 = 8 beats.
  • steps: Step count for step mode. Requires step_duration=.
  • step_duration: Duration of one step in beats (e.g. dur.SIXTEENTH). Requires steps=.
  • drum_note_map: Optional mapping for drum instruments.
  • cc_name_map: Optional mapping of CC names to MIDI CC numbers. Enables string-based CC names in p.cc() and p.cc_ramp().
  • nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 14-bit parameter numbers (0–16383). Enables string-based names in p.nrpn() and p.nrpn_ramp() — typically a device-specific dictionary (e.g. Sequential Take 5's Osc1FreqFine → 9).
  • reschedule_lookahead: Beats in advance to compute the next cycle.
  • voice_leading: If True, chords in this pattern will automatically use inversions that minimize voice movement.
  • mirrors: Optional list of additional (device, channel) destinations to duplicate every event from this pattern onto. Notes, CCs, pitch bend, NRPN/RPN bursts, program changes, SysEx, and drone events are all mirrored; OSC events are not (OSC is not bound to a MIDI port). device is the integer index returned by midi_output() (0 = primary). channel follows this composition's channel-numbering convention. See also mirror() / unmirror() for live toggling.
Example:
@comp.pattern(channel=1, beats=4)
def chords (p):
        p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9)

@comp.pattern(channel=1, bars=2)
def long_phrase (p):
        ...

@comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH)
def riff (p):
        p.sequence(steps=[0, 1, 3, 5], pitches=60)
def layer( self, *builder_fns: Callable, channel: int, beats: Optional[float] = None, bars: Optional[float] = None, steps: Optional[float] = None, step_duration: Optional[float] = None, drum_note_map: Optional[Dict[str, int]] = None, cc_name_map: Optional[Dict[str, int]] = None, nrpn_name_map: Optional[Dict[str, int]] = None, reschedule_lookahead: float = 1, voice_leading: bool = False, device: Union[int, str, NoneType] = None, mirrors: Optional[Iterable[Union[Tuple[int, int], Tuple[int, int, Optional[Dict[str, int]]]]]] = None) -> None:
3787	def layer (
3788		self,
3789		*builder_fns: typing.Callable,
3790		channel: int,
3791		beats: typing.Optional[float] = None,
3792		bars: typing.Optional[float] = None,
3793		steps: typing.Optional[float] = None,
3794		step_duration: typing.Optional[float] = None,
3795		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
3796		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
3797		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
3798		reschedule_lookahead: float = 1,
3799		voice_leading: bool = False,
3800		device: subsequence.midi_utils.DeviceId = None,
3801		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3802	) -> None:
3803
3804		"""
3805		Combine multiple functions into a single MIDI pattern.
3806
3807		This is useful for composing complex patterns out of reusable
3808		building blocks (e.g., a 'kick' function and a 'snare' function).
3809
3810		See ``pattern()`` for the full description of ``beats``, ``bars``,
3811		``steps``, and ``step_duration``.
3812
3813		Parameters:
3814			builder_fns: One or more pattern builder functions.
3815			channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``).
3816			beats: Duration in beats (quarter notes).
3817			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
3818			steps: Step count for step mode. Requires ``step_duration=``.
3819			step_duration: Duration of one step in beats. Requires ``steps=``.
3820			drum_note_map: Optional mapping for drum instruments.
3821			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
3822			nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit
3823				parameter numbers.
3824			reschedule_lookahead: Beats in advance to compute the next cycle.
3825			voice_leading: If True, chords use smooth voice leading.
3826			mirrors: Optional list of additional ``(device, channel)`` destinations
3827				to duplicate every event onto.  See ``pattern()`` for details.
3828		"""
3829
3830		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0])
3831
3832		# Resolve channel up-front so the mirror-to-self check has the canonical
3833		# primary form to compare against.
3834		resolved_channel = self._resolve_channel(channel)
3835
3836		# See pattern() for the same comment about None / str handling.
3837		primary: typing.Optional[typing.Tuple[int, int]]
3838		if isinstance(device, str):
3839			primary = None
3840		else:
3841			primary = (device if device is not None else 0, resolved_channel)
3842		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
3843
3844		wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns)
3845
3846		if wants_chord:
3847
3848			def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None:
3849
3850				for fn in builder_fns:
3851					if _fn_has_parameter(fn, "chord"):
3852						fn(p, chord)
3853					else:
3854						fn(p)
3855
3856		else:
3857
3858			def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:  # type: ignore[misc]
3859
3860				for fn in builder_fns:
3861					fn(p)
3862
3863		# Give the merged builder a stable, unique name derived from its
3864		# components so multiple layer() calls don't all register under
3865		# "merged_builder" and collide in _running_patterns (which made
3866		# mute/tweak/unregister/live_info reach only the LAST layer).  "+" can't
3867		# appear in a Python identifier, so this never clashes with a real
3868		# pattern function's name.
3869		base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}"
3870		merged_name = base_name
3871		suffix = 2
3872
3873		# Two layers with the same components (e.g. on different saves of a
3874		# live file) must map to the same names pass-over-pass, while two
3875		# DIFFERENT layers sharing components in one pass must not collide.
3876		while merged_name in self._declared_names:
3877			merged_name = f"{base_name}#{suffix}"
3878			suffix += 1
3879
3880		merged_builder.__name__ = merged_name
3881
3882		# Record the declaration for the live-reload deletion diff, and hot-swap
3883		# in place when this layer is already running so a reload picks up edits
3884		# to the component functions without losing the pattern's cycle count,
3885		# tweaks, or mirrors (mirrors the pattern() decorator's hot-swap).
3886		self._declared_names.add(merged_builder.__name__)
3887
3888		if self._is_live and merged_builder.__name__ in self._running_patterns:
3889			running = self._running_patterns[merged_builder.__name__]
3890			running._builder_fn = merged_builder
3891			running._wants_chord = wants_chord
3892			logger.info(f"Hot-swapped layer: {merged_builder.__name__}")
3893			return
3894
3895		pending = _PendingPattern(
3896			builder_fn = merged_builder,
3897			channel = resolved_channel,  # already resolved to 0-indexed above
3898			length = beat_length,
3899			default_grid = default_grid,
3900			drum_note_map = drum_note_map,
3901			cc_name_map = cc_name_map,
3902			nrpn_name_map = nrpn_name_map,
3903			reschedule_lookahead = reschedule_lookahead,
3904			voice_leading = voice_leading,
3905			mirrors = resolved_mirrors,
3906			device = 0 if (device is None or isinstance(device, str)) else device,
3907			raw_device = device,
3908		)
3909
3910		self._pending_patterns.append(pending)

Combine multiple functions into a single MIDI pattern.

This is useful for composing complex patterns out of reusable building blocks (e.g., a 'kick' function and a 'snare' function).

See pattern() for the full description of beats, bars, steps, and step_duration.

Arguments:
  • builder_fns: One or more pattern builder functions.
  • channel: MIDI channel (1-16, or 0-15 with zero_indexed_channels=True).
  • beats: Duration in beats (quarter notes).
  • bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
  • steps: Step count for step mode. Requires step_duration=.
  • step_duration: Duration of one step in beats. Requires steps=.
  • drum_note_map: Optional mapping for drum instruments.
  • cc_name_map: Optional mapping of CC names to MIDI CC numbers.
  • nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit parameter numbers.
  • reschedule_lookahead: Beats in advance to compute the next cycle.
  • voice_leading: If True, chords use smooth voice leading.
  • mirrors: Optional list of additional (device, channel) destinations to duplicate every event onto. See pattern() for details.
def chords( self, *, channel: int, progression: Union[str, Progression, Sequence[Any]], harmonic_rhythm: Union[int, float, List[float], subsequence.harmonic_rhythm.HarmonicRhythm], bars: Optional[float] = None, beats: Optional[float] = None, voicing: Union[int, Tuple[int, int]] = (3, 4), velocity: Union[int, Tuple[int, int]] = 90, detached: Optional[float] = None, root: int = 60, key: Optional[str] = None, seed: Optional[int] = None, device: Union[int, str, NoneType] = None, mirrors: Optional[Iterable[Union[Tuple[int, int], Tuple[int, int, Optional[Dict[str, int]]]]]] = None) -> Progression:
3912	def chords (
3913		self,
3914		*,
3915		channel: int,
3916		progression: subsequence.progressions.ProgressionSource,
3917		harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec,
3918		bars: typing.Optional[float] = None,
3919		beats: typing.Optional[float] = None,
3920		voicing: subsequence.progressions.VoicingSpec = (3, 4),
3921		velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY,
3922		detached: typing.Optional[float] = None,
3923		root: int = 60,
3924		key: typing.Optional[str] = None,
3925		seed: typing.Optional[int] = None,
3926		device: subsequence.midi_utils.DeviceId = None,
3927		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
3928	) -> subsequence.progressions.Progression:
3929
3930		"""Declare a self-contained chord part: a progression at a chosen harmonic rhythm.
3931
3932		The one-call form of ``p.progression()`` — it registers a pattern on
3933		*channel* that plays *progression* across *bars* (or *beats*), each chord
3934		lasting a length drawn from *harmonic_rhythm* (the musical term for how often
3935		the chords change).  It needs no ``composition.harmony()`` call and, with an
3936		explicit chord list or a ``key=``, no composition key either — so a
3937		drums-plus-one-chord-part sketch stays simple.
3938
3939		The progression is realised once, up front, and the same timeline plays every
3940		cycle (a stable phrase).  That timeline is returned so you can see exactly what
3941		was chosen — ``print(comp.chords(...))``.
3942
3943		Parameters:
3944			channel: MIDI channel for the chord part.
3945			progression: A chord-graph style name to generate from, or an explicit list
3946				of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``).
3947			harmonic_rhythm: How long each chord lasts — a number, a list of lengths,
3948				or ``between(low, high, step=...)``.  See ``p.progression()``.
3949			bars / beats: Length of the part (defaults to 4 beats if neither is given).  ``bars`` uses the
3950				composition's time signature.
3951			voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``).
3952			velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation.
3953			detached: Beats of silence before each next chord (``duration = length - detached``).
3954			root: MIDI root the voicings are centred on (e.g. 48 = C3).
3955			key: Key for a generated progression; defaults to the composition key.
3956			seed: Seed for the (otherwise fixed) realisation; defaults to the
3957				composition seed, so the part is reproducible.
3958			device: Optional output-device override.
3959			mirrors: Optional additional ``(device, channel)`` destinations.
3960
3961		Returns:
3962			The realised :class:`~subsequence.progressions.Progression`.
3963		"""
3964
3965		beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0])
3966		resolved_channel = self._resolve_channel(channel)
3967		resolved_key = key if key is not None else self.key
3968
3969		rng = random.Random(seed if seed is not None else self._seed)
3970		timeline = subsequence.progressions.realize(
3971			source = progression,
3972			harmonic_rhythm = harmonic_rhythm,
3973			key = resolved_key,
3974			length = beat_length,
3975			rng = rng,
3976			scale = self.scale or "ionian",
3977		)
3978
3979		captured_root = root
3980		captured_velocity = velocity
3981		captured_detached = detached
3982		captured_voicing = voicing
3983
3984		def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:
3985
3986			"""Replay the realised timeline as block chords each cycle (voicing per chord)."""
3987
3988			for chord, start, length in timeline:
3989				ring = length - captured_detached if (captured_detached and captured_detached < length) else length
3990				voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng)
3991				p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity)
3992
3993		# Unique, stable name so multiple chord parts don't collide in
3994		# _running_patterns — including two parts on the SAME channel, which
3995		# get a deterministic #2/#3 suffix in declaration order.
3996		base_name = f"chords@ch{resolved_channel}"
3997		chords_name = base_name
3998		suffix = 2
3999
4000		while chords_name in self._declared_names:
4001			chords_name = f"{base_name}#{suffix}"
4002			suffix += 1
4003
4004		chords_builder.__name__ = chords_name
4005		self._declared_names.add(chords_name)
4006
4007		primary: typing.Optional[typing.Tuple[int, int]]
4008		if isinstance(device, str):
4009			primary = None
4010		else:
4011			primary = (device if device is not None else 0, resolved_channel)
4012		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
4013
4014		self._declared_names.add(chords_builder.__name__)
4015
4016		if self._is_live and chords_builder.__name__ in self._running_patterns:
4017			running = self._running_patterns[chords_builder.__name__]
4018			running._builder_fn = chords_builder
4019			running._wants_chord = False
4020			logger.info(f"Hot-swapped chords: {chords_builder.__name__}")
4021			return timeline
4022
4023		pending = _PendingPattern(
4024			builder_fn = chords_builder,
4025			channel = resolved_channel,
4026			length = beat_length,
4027			default_grid = default_grid,
4028			drum_note_map = None,
4029			reschedule_lookahead = 1,
4030			voice_leading = False,
4031			mirrors = resolved_mirrors,
4032			device = 0 if (device is None or isinstance(device, str)) else device,
4033			raw_device = device,
4034		)
4035		self._pending_patterns.append(pending)
4036		return timeline

Declare a self-contained chord part: a progression at a chosen harmonic rhythm.

The one-call form of p.progression() — it registers a pattern on channel that plays progression across bars (or beats), each chord lasting a length drawn from harmonic_rhythm (the musical term for how often the chords change). It needs no composition.harmony() call and, with an explicit chord list or a key=, no composition key either — so a drums-plus-one-chord-part sketch stays simple.

The progression is realised once, up front, and the same timeline plays every cycle (a stable phrase). That timeline is returned so you can see exactly what was chosen — print(comp.chords(...)).

Arguments:
  • channel: MIDI channel for the chord part.
  • progression: A chord-graph style name to generate from, or an explicit list of chords (Chord objects or names like ["Cm7", "Dbmaj7"]).
  • harmonic_rhythm: How long each chord lasts — a number, a list of lengths, or between(low, high, step=...). See p.progression().
  • bars / beats: Length of the part (defaults to 4 beats if neither is given). bars uses the composition's time signature.
  • voicing: Notes per chord — an int, or a (low, high) range (e.g. (3, 4)).
  • velocity: MIDI velocity, or a (low, high) tuple for per-voice humanisation.
  • detached: Beats of silence before each next chord (duration = length - detached).
  • root: MIDI root the voicings are centred on (e.g. 48 = C3).
  • key: Key for a generated progression; defaults to the composition key.
  • seed: Seed for the (otherwise fixed) realisation; defaults to the composition seed, so the part is reproducible.
  • device: Optional output-device override.
  • mirrors: Optional additional (device, channel) destinations.
Returns:

The realised ~subsequence.progressions.Progression.

def phrase_part( self, *, channel: int, part: Optional[str] = None, root: int = 60, bars: Optional[float] = None, beats: Optional[float] = None, velocity: Union[int, Tuple[int, int], NoneType] = None, fit: Optional[float] = None, device: Union[int, str, NoneType] = None, mirrors: Optional[Iterable[Union[Tuple[int, int], Tuple[int, int, Optional[Dict[str, int]]]]]] = None) -> None:
4038	def phrase_part (
4039		self,
4040		*,
4041		channel: int,
4042		part: typing.Optional[str] = None,
4043		root: int = 60,
4044		bars: typing.Optional[float] = None,
4045		beats: typing.Optional[float] = None,
4046		velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None,
4047		fit: typing.Optional[float] = None,
4048		device: subsequence.midi_utils.DeviceId = None,
4049		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
4050	) -> None:
4051
4052		"""Declare a part that plays each section's bound Motif/Phrase.
4053
4054		The one-call consumer for :meth:`section_motifs` — it registers a
4055		pattern on *channel* that walks whatever value is bound to the
4056		current section for *part* (stateless position from the cycle
4057		counter, via ``p.phrase()``).  A section with no binding for the
4058		part is **silent** for that part — bind material or don't; no
4059		fallback guessing.
4060
4061		Parameters:
4062			channel: MIDI channel for the part.
4063			part: The part label to read from the registry (``None`` = the
4064				unlabelled binding).
4065			root: Register anchor for degree resolution.
4066			bars / beats: Cycle length of the part (defaults to 4 beats);
4067				the phrase is sliced one cycle window at a time.
4068			velocity: Optional override applied to every note.
4069			fit: Passed through (active with the melody engine stage).
4070			device: Optional output-device override.
4071			mirrors: Optional additional ``(device, channel)`` destinations.
4072
4073		Example::
4074
4075			composition.section_motifs("verse",  verse_line,  part="lead")
4076			composition.section_motifs("chorus", chorus_line, part="lead")
4077			composition.phrase_part(channel=4, part="lead", root=72, bars=2)
4078		"""
4079
4080		beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0])
4081		resolved_channel = self._resolve_channel(channel)
4082
4083		captured_part = part
4084		captured_root = root
4085		captured_velocity = velocity
4086		captured_fit = fit
4087
4088		def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None:
4089
4090			"""Walk the current section's bound value (silent when unbound)."""
4091
4092			value = p.section_motif(captured_part)
4093
4094			if value is None:
4095				return	# unbound section: silence for this part, by design
4096
4097			p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit)
4098
4099		# Unique, stable name so multiple phrase parts don't collide —
4100		# including two parts on the SAME channel (deterministic #2/#3
4101		# suffixes in declaration order, the chords() convention).
4102		base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}"
4103		phrase_name = base_name
4104		suffix = 2
4105
4106		while phrase_name in self._declared_names:
4107			phrase_name = f"{base_name}#{suffix}"
4108			suffix += 1
4109
4110		phrase_builder.__name__ = phrase_name
4111		self._declared_names.add(phrase_name)
4112
4113		primary: typing.Optional[typing.Tuple[int, int]]
4114		if isinstance(device, str):
4115			primary = None
4116		else:
4117			primary = (device if device is not None else 0, resolved_channel)
4118		resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary)
4119
4120		if self._is_live and phrase_builder.__name__ in self._running_patterns:
4121			running = self._running_patterns[phrase_builder.__name__]
4122			running._builder_fn = phrase_builder
4123			running._wants_chord = False
4124			logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}")
4125			return
4126
4127		pending = _PendingPattern(
4128			builder_fn = phrase_builder,
4129			channel = resolved_channel,
4130			length = beat_length,
4131			default_grid = default_grid,
4132			drum_note_map = None,
4133			reschedule_lookahead = 1,
4134			voice_leading = False,
4135			mirrors = resolved_mirrors,
4136			device = 0 if (device is None or isinstance(device, str)) else device,
4137			raw_device = device,
4138		)
4139		self._pending_patterns.append(pending)

Declare a part that plays each section's bound Motif/Phrase.

The one-call consumer for section_motifs() — it registers a pattern on channel that walks whatever value is bound to the current section for part (stateless position from the cycle counter, via p.phrase()). A section with no binding for the part is silent for that part — bind material or don't; no fallback guessing.

Arguments:
  • channel: MIDI channel for the part.
  • part: The part label to read from the registry (None = the unlabelled binding).
  • root: Register anchor for degree resolution.
  • bars / beats: Cycle length of the part (defaults to 4 beats); the phrase is sliced one cycle window at a time.
  • velocity: Optional override applied to every note.
  • fit: Passed through (active with the melody engine stage).
  • device: Optional output-device override.
  • mirrors: Optional additional (device, channel) destinations.

Example::

    composition.section_motifs("verse",  verse_line,  part="lead")
    composition.section_motifs("chorus", chorus_line, part="lead")
    composition.phrase_part(channel=4, part="lead", root=72, bars=2)
def trigger( self, fn: Callable, channel: int, beats: Optional[float] = None, bars: Optional[float] = None, steps: Optional[float] = None, step_duration: Optional[float] = None, quantize: float = 0, drum_note_map: Optional[Dict[str, int]] = None, cc_name_map: Optional[Dict[str, int]] = None, nrpn_name_map: Optional[Dict[str, int]] = None, chord: bool = False, device: Union[int, str, NoneType] = None, mirrors: Optional[Iterable[Union[Tuple[int, int], Tuple[int, int, Optional[Dict[str, int]]]]]] = None) -> None:
4141	def trigger (
4142		self,
4143		fn: typing.Callable,
4144		channel: int,
4145		beats: typing.Optional[float] = None,
4146		bars: typing.Optional[float] = None,
4147		steps: typing.Optional[float] = None,
4148		step_duration: typing.Optional[float] = None,
4149		quantize: float = 0,
4150		drum_note_map: typing.Optional[typing.Dict[str, int]] = None,
4151		cc_name_map: typing.Optional[typing.Dict[str, int]] = None,
4152		nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None,
4153		chord: bool = False,
4154		device: subsequence.midi_utils.DeviceId = None,
4155		mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None,
4156	) -> None:
4157
4158		"""
4159		Trigger a one-shot pattern immediately or on a quantized boundary.
4160
4161		This is useful for real-time response to sensors, OSC messages, or other
4162		external events. The builder function is called immediately with a fresh
4163		PatternBuilder, and the generated events are injected into the queue at
4164		the specified quantize boundary.
4165
4166		The builder function has the same API as a ``@composition.pattern``
4167		decorated function and can use all PatternBuilder methods: ``p.note()``,
4168		``p.euclidean()``, ``p.arpeggio()``, and so on.
4169
4170		See ``pattern()`` for the full description of ``beats``, ``bars``,
4171		``steps``, and ``step_duration``. Default is 1 beat.
4172
4173		Parameters:
4174			fn: The pattern builder function (same signature as ``@comp.pattern``).
4175			channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``).
4176			beats: Duration in beats (quarter notes, default 1).
4177			bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
4178			steps: Step count for step mode. Requires ``step_duration=``.
4179			step_duration: Duration of one step in beats. Requires ``steps=``.
4180			quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default),
4181				``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*``
4182				constants from ``subsequence.constants.durations``.
4183			drum_note_map: Optional drum name mapping for this pattern.
4184			cc_name_map: Optional mapping of CC names to MIDI CC numbers.
4185			nrpn_name_map: Optional mapping of NRPN parameter names to
4186				14-bit parameter numbers.
4187			chord: If ``True``, the builder function receives the current chord as
4188				a second parameter (same as ``@composition.pattern``).
4189			mirrors: Optional list of additional ``(device, channel)`` destinations
4190				to fire this one-shot onto in parallel with the primary destination.
4191
4192		Example:
4193			```python
4194			# Immediate single note (channels are 1-16 by default)
4195			composition.trigger(
4196				lambda p: p.note(60, beat=0, velocity=100, duration=0.5),
4197				channel=1
4198			)
4199
4200			# Quantized fill (next bar) — channel 10 is the GM drum channel
4201			import subsequence.constants.durations as dur
4202			composition.trigger(
4203				lambda p: p.euclidean("snare", pulses=7, velocity=90),
4204				channel=10,
4205				drum_note_map=gm_drums.GM_DRUM_MAP,
4206				quantize=dur.WHOLE
4207			)
4208
4209			# With chord context — the builder receives the chord as a second
4210			# argument when chord=True.
4211			composition.trigger(
4212				lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH),
4213				channel=1,
4214				quantize=dur.QUARTER,
4215				chord=True
4216			)
4217			```
4218		"""
4219
4220		# Resolve channel numbering
4221		resolved_channel = self._resolve_channel(channel)
4222
4223		beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0])
4224
4225		# Resolve device index — for trigger() this is always concrete by call time,
4226		# so the mirror-to-self check has the full primary tuple available.
4227		resolved_device_idx = self._resolve_device_id(device)
4228		resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel))
4229
4230		# Create a temporary Pattern
4231		pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors)
4232
4233		# Create a PatternBuilder
4234		builder = subsequence.pattern_builder.PatternBuilder(
4235			pattern=pattern,
4236			cycle=0,  # One-shot patterns don't rebuild, so cycle is always 0
4237			drum_note_map=drum_note_map,
4238			cc_name_map=cc_name_map,
4239			nrpn_name_map=nrpn_name_map,
4240			section=self._form_state.get_section_info() if self._form_state else None,
4241			bar=self._builder_bar,
4242			conductor=self.conductor,
4243			rng=random.Random(),  # Fresh random state for each trigger
4244			tweaks={},
4245			default_grid=default_grid,
4246			data=self.data,
4247			held_notes=self._sequencer._held_notes
4248		)
4249
4250		# Call the builder function
4251		try:
4252
4253			current_chord = self.current_chord() if chord else None
4254
4255			if current_chord is not None:
4256				injected = _InjectedChord(current_chord, None)  # No voice leading for one-shots
4257				fn(builder, injected)
4258
4259			else:
4260				fn(builder)
4261
4262		except Exception:
4263			logger.exception("Error in trigger builder — pattern will be silent")
4264			return
4265
4266		# Calculate the start pulse based on quantize
4267		current_pulse = self._sequencer.pulse_count
4268		pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE
4269
4270		if quantize == 0:
4271			# Immediate: use current pulse
4272			start_pulse = current_pulse
4273
4274		else:
4275			# Quantize to the next multiple of (quantize * pulses_per_beat)
4276			quantize_pulses = int(quantize * pulses_per_beat)
4277			start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses
4278
4279		# Schedule the pattern for one-shot execution
4280		try:
4281			# Probe only: raises RuntimeError when not on the event loop.
4282			asyncio.get_running_loop()
4283			asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse))
4284
4285		except RuntimeError:
4286			# Not on the event loop — hand the coroutine to the loop thread.
4287			if self._sequencer._event_loop is not None:
4288				asyncio.run_coroutine_threadsafe(
4289					self._sequencer.schedule_pattern(pattern, start_pulse),
4290					loop=self._sequencer._event_loop
4291				)
4292			else:
4293				logger.warning("trigger() called before playback started; pattern ignored")

Trigger a one-shot pattern immediately or on a quantized boundary.

This is useful for real-time response to sensors, OSC messages, or other external events. The builder function is called immediately with a fresh PatternBuilder, and the generated events are injected into the queue at the specified quantize boundary.

The builder function has the same API as a @composition.pattern decorated function and can use all PatternBuilder methods: p.note(), p.euclidean(), p.arpeggio(), and so on.

See pattern() for the full description of beats, bars, steps, and step_duration. Default is 1 beat.

Arguments:
  • fn: The pattern builder function (same signature as @comp.pattern).
  • channel: MIDI channel (1-16, or 0-15 with zero_indexed_channels=True).
  • beats: Duration in beats (quarter notes, default 1).
  • bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4).
  • steps: Step count for step mode. Requires step_duration=.
  • step_duration: Duration of one step in beats. Requires steps=.
  • quantize: Snap the trigger to a beat boundary: 0 = immediate (default), 1 = next beat (quarter note), 4 = next bar. Use dur.* constants from subsequence.constants.durations.
  • drum_note_map: Optional drum name mapping for this pattern.
  • cc_name_map: Optional mapping of CC names to MIDI CC numbers.
  • nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit parameter numbers.
  • chord: If True, the builder function receives the current chord as a second parameter (same as @composition.pattern).
  • mirrors: Optional list of additional (device, channel) destinations to fire this one-shot onto in parallel with the primary destination.
Example:
# Immediate single note (channels are 1-16 by default)
composition.trigger(
        lambda p: p.note(60, beat=0, velocity=100, duration=0.5),
        channel=1
)

# Quantized fill (next bar) — channel 10 is the GM drum channel
import subsequence.constants.durations as dur
composition.trigger(
        lambda p: p.euclidean("snare", pulses=7, velocity=90),
        channel=10,
        drum_note_map=gm_drums.GM_DRUM_MAP,
        quantize=dur.WHOLE
)

# With chord context — the builder receives the chord as a second
# argument when chord=True.
composition.trigger(
        lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH),
        channel=1,
        quantize=dur.QUARTER,
        chord=True
)
is_clock_following: bool
4295	@property
4296	def is_clock_following (self) -> bool:
4297
4298		"""True if either the primary or any additional device is following external clock."""
4299
4300		return self._clock_follow or any(cf for _, _, cf in self._additional_inputs)

True if either the primary or any additional device is following external clock.

def play(self) -> None:
4303	def play (self) -> None:
4304
4305		"""
4306		Start the composition.
4307
4308		This call blocks until the program is interrupted (e.g., via Ctrl+C).
4309		It initializes the MIDI hardware, launches the background sequencer,
4310		and begins playback.
4311		"""
4312
4313		try:
4314			asyncio.run(self._run())
4315
4316		except KeyboardInterrupt:
4317			pass

Start the composition.

This call blocks until the program is interrupted (e.g., via Ctrl+C). It initializes the MIDI hardware, launches the background sequencer, and begins playback.

def render( self, bars: Optional[int] = None, filename: str = 'render.mid', max_minutes: Optional[float] = 60.0) -> None:
4320	def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None:
4321
4322		"""Render the composition to a MIDI file without real-time playback.
4323
4324		Runs the sequencer as fast as possible (no timing delays) and stops
4325		when the first active limit is reached.  The result is saved as a
4326		standard MIDI file that can be imported into any DAW.
4327
4328		All patterns, scheduled callbacks, and harmony logic run exactly as
4329		they would during live playback — BPM transitions, generative fills,
4330		and probabilistic gates all work in render mode.  The only difference
4331		is that time is simulated rather than wall-clock driven.
4332
4333		Parameters:
4334			bars: Number of bars to render, or ``None`` for no bar limit
4335			      (default ``None``).  When both *bars* and *max_minutes* are
4336			      active, playback stops at whichever limit is reached first.
4337			filename: Output MIDI filename (default ``"render.mid"``).
4338			max_minutes: Safety cap on the length of rendered MIDI in minutes
4339			             (default ``60.0``).  Pass ``None`` to disable the time
4340			             cap — you must then provide an explicit *bars* value.
4341
4342		Raises:
4343			ValueError: If both *bars* and *max_minutes* are ``None``, which
4344			            would produce an infinite render.
4345
4346		Examples:
4347			```python
4348			# Default: renders up to 60 minutes of MIDI content.
4349			composition.render()
4350
4351			# Render exactly 64 bars (time cap still active as backstop).
4352			composition.render(bars=64, filename="demo.mid")
4353
4354			# Render up to 5 minutes of an infinite generative composition.
4355			composition.render(max_minutes=5, filename="five_min.mid")
4356
4357			# Remove the time cap — must supply bars instead.
4358			composition.render(bars=128, max_minutes=None, filename="long.mid")
4359			```
4360		"""
4361
4362		if bars is None and max_minutes is None:
4363			raise ValueError(
4364				"render() requires at least one limit: provide bars=, max_minutes=, or both. "
4365				"Passing both as None would produce an infinite render."
4366			)
4367
4368		self._sequencer.recording = True
4369		self._sequencer.record_filename = filename
4370		self._sequencer.render_mode = True
4371		self._sequencer.render_bars = bars if bars is not None else 0
4372		self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None
4373		asyncio.run(self._run())

Render the composition to a MIDI file without real-time playback.

Runs the sequencer as fast as possible (no timing delays) and stops when the first active limit is reached. The result is saved as a standard MIDI file that can be imported into any DAW.

All patterns, scheduled callbacks, and harmony logic run exactly as they would during live playback — BPM transitions, generative fills, and probabilistic gates all work in render mode. The only difference is that time is simulated rather than wall-clock driven.

Arguments:
  • bars: Number of bars to render, or None for no bar limit (default None). When both bars and max_minutes are active, playback stops at whichever limit is reached first.
  • filename: Output MIDI filename (default "render.mid").
  • max_minutes: Safety cap on the length of rendered MIDI in minutes (default 60.0). Pass None to disable the time cap — you must then provide an explicit bars value.
Raises:
  • ValueError: If both bars and max_minutes are None, which would produce an infinite render.
Examples:
# Default: renders up to 60 minutes of MIDI content.
composition.render()

# Render exactly 64 bars (time cap still active as backstop).
composition.render(bars=64, filename="demo.mid")

# Render up to 5 minutes of an infinite generative composition.
composition.render(max_minutes=5, filename="five_min.mid")

# Remove the time cap — must supply bars instead.
composition.render(bars=128, max_minutes=None, filename="long.mid")
@dataclasses.dataclass(frozen=True)
class Motif:
 337@dataclasses.dataclass(frozen=True)
 338class Motif:
 339
 340	"""
 341	An immutable musical figure: timed note events + control gestures + a length in beats.
 342
 343	Construct via the classmethods (:meth:`degrees`, :meth:`notes`,
 344	:meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture
 345	constructors, or :meth:`from_events`) rather than positionally.
 346	``length`` is explicit — a trailing rest is meaningful.
 347	"""
 348
 349	events: typing.Tuple[MotifEvent, ...]
 350	length: float
 351	controls: typing.Tuple[ControlEvent, ...] = ()
 352
 353	def __post_init__ (self) -> None:
 354
 355		"""Validate, and normalise both streams to canonical order."""
 356
 357		if self.length < 0:
 358			raise ValueError(f"Motif length must be non-negative — got {self.length}")
 359
 360		object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key)))
 361		object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key)))
 362
 363	# ── constructors ────────────────────────────────────────────────────
 364
 365	@classmethod
 366	def empty (cls) -> "Motif":
 367
 368		"""The empty motif (zero events, zero length) — the identity for ``then``."""
 369
 370		return cls(events=(), length=0.0)
 371
 372	@classmethod
 373	def from_events (
 374		cls,
 375		events: typing.Iterable[MotifEvent],
 376		length: typing.Optional[float] = None,
 377		controls: typing.Iterable[ControlEvent] = (),
 378	) -> "Motif":
 379
 380		"""Build a motif from explicit events (power use; length defaults to the next whole beat)."""
 381
 382		events = tuple(events)
 383		controls = tuple(controls)
 384
 385		return cls(
 386			events = events,
 387			length = _computed_length(events, controls) if length is None else length,
 388			controls = controls,
 389		)
 390
 391	@classmethod
 392	def _from_sequence (
 393		cls,
 394		pitches: typing.List[PitchSpec],
 395		beats: typing.Optional[typing.List[float]],
 396		velocities: typing.Any,
 397		durations: typing.Any,
 398		probabilities: typing.Any,
 399		length: typing.Optional[float],
 400	) -> "Motif":
 401
 402		"""Shared core: one event per element, None = rest (slot still advances)."""
 403
 404		n = len(pitches)
 405		onsets = list(beats) if beats is not None else [float(i) for i in range(n)]
 406
 407		if len(onsets) != n:
 408			raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match")
 409
 410		velocity_list = _expand("velocities", velocities, n)
 411		duration_list = _expand("durations", durations, n)
 412		probability_list = _expand("probabilities", probabilities, n)
 413
 414		events = tuple(
 415			MotifEvent(
 416				beat = float(onsets[i]),
 417				pitch = pitches[i],
 418				velocity = velocity_list[i],
 419				duration = float(duration_list[i]),
 420				probability = float(probability_list[i]),
 421			)
 422			for i in range(n)
 423			if pitches[i] is not None
 424		)
 425
 426		return cls(
 427			events = events,
 428			length = _computed_length(events, ()) if length is None else float(length),
 429		)
 430
 431	@classmethod
 432	def degrees (
 433		cls,
 434		degrees: typing.List[typing.Union[int, Degree, None]],
 435		beats: typing.Optional[typing.List[float]] = None,
 436		velocities: typing.Any = _DEFAULT_VELOCITY,
 437		durations: typing.Any = 1.0,
 438		probabilities: typing.Any = 1.0,
 439		length: typing.Optional[float] = None,
 440	) -> "Motif":
 441
 442		"""
 443		A melody written as 1-based scale degrees, one per beat by default.
 444
 445		Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a
 446		rest (the beat slot still advances), or :class:`Degree` for octave/
 447		chromatic detail.  Resolved against key + scale at placement.
 448		Durations default to a full beat (each note holds its slot).
 449		"""
 450
 451		converted: typing.List[PitchSpec] = []
 452
 453		for element in degrees:
 454			if isinstance(element, int):
 455				if element > _MAX_PLAUSIBLE_DEGREE:
 456					raise ValueError(
 457						f"Degree {element} is implausibly large — scale degrees are 1-based "
 458						f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()."
 459					)
 460				converted.append(Degree(element))
 461			elif isinstance(element, Degree) or element is None:
 462				converted.append(element)
 463			else:
 464				raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}")
 465
 466		return cls._from_sequence(converted, beats, velocities, durations, probabilities, length)
 467
 468	@classmethod
 469	def notes (
 470		cls,
 471		notes: typing.List[typing.Union[int, None]],
 472		beats: typing.Optional[typing.List[float]] = None,
 473		velocities: typing.Any = _DEFAULT_VELOCITY,
 474		durations: typing.Any = 1.0,
 475		probabilities: typing.Any = 1.0,
 476		length: typing.Optional[float] = None,
 477	) -> "Motif":
 478
 479		"""A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest."""
 480
 481		for element in notes:
 482			if not (isinstance(element, int) or element is None):
 483				raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}")
 484
 485		return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
 486
 487	@classmethod
 488	def hits (
 489		cls,
 490		pitch: typing.Union[int, str],
 491		beats: typing.List[float],
 492		length: typing.Optional[float] = None,
 493		velocities: typing.Any = _DEFAULT_VELOCITY,
 494		durations: typing.Any = 0.1,
 495		probabilities: typing.Any = 1.0,
 496	) -> "Motif":
 497
 498		"""One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention."""
 499
 500		return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length)
 501
 502	@classmethod
 503	def steps (
 504		cls,
 505		steps: typing.List[int],
 506		pitches: typing.Any,
 507		velocities: typing.Any = _DEFAULT_VELOCITY,
 508		durations: typing.Any = 0.1,
 509		probabilities: typing.Any = 1.0,
 510		step_duration: float = 0.25,
 511		length: typing.Optional[float] = None,
 512	) -> "Motif":
 513
 514		"""
 515		Grid placement — the ``sequence()`` convention: ``steps`` are 0-based
 516		grid indices (sixteenths by default), ``pitches`` a scalar or
 517		parallel list of MIDI ints or drum names.
 518		"""
 519
 520		n = len(steps)
 521		pitch_list = _expand("pitches", pitches, n)
 522		onsets = [s * step_duration for s in steps]
 523
 524		if length is None and n:
 525			length = float(math.ceil((max(steps) + 1) * step_duration))
 526
 527		return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length)
 528
 529	@classmethod
 530	def euclidean (
 531		cls,
 532		pulses: int,
 533		steps: int,
 534		pitch: typing.Union[int, str],
 535		length: float = 4.0,
 536		velocities: typing.Any = _DEFAULT_VELOCITY,
 537		durations: typing.Any = 0.1,
 538		probabilities: typing.Any = 1.0,
 539	) -> "Motif":
 540
 541		"""A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats."""
 542
 543		# The kernel returns one 0/1 flag per grid step; onsets are the 1s.
 544		flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses)
 545		step_duration = length / steps
 546		onsets = [i * step_duration for i, flag in enumerate(flags) if flag]
 547
 548		return cls._from_sequence(
 549			[pitch] * len(onsets),
 550			onsets,
 551			velocities, durations, probabilities, length,
 552		)
 553
 554	# ── control-gesture constructors (mirror the pattern_midi verbs) ────
 555
 556	@classmethod
 557	def _control_writes (
 558		cls,
 559		signal: ControlSignal,
 560		values: typing.List[float],
 561		beats: typing.List[float],
 562		length: typing.Optional[float],
 563		probabilities: typing.Any = 1.0,
 564	) -> "Motif":
 565
 566		"""Shared core for discrete control writes."""
 567
 568		if len(values) != len(beats):
 569			raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match")
 570
 571		probability_list = _expand("probabilities", probabilities, len(values))
 572
 573		controls = tuple(
 574			ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i]))
 575			for i in range(len(values))
 576		)
 577
 578		return cls(
 579			events = (),
 580			length = _computed_length((), controls) if length is None else float(length),
 581			controls = controls,
 582		)
 583
 584	@classmethod
 585	def _control_ramp (
 586		cls,
 587		signal: ControlSignal,
 588		start: float,
 589		end: float,
 590		beat_start: float,
 591		beat_end: typing.Optional[float],
 592		shape: typing.Union[str, "subsequence.easing.EasingFn"],
 593		length: typing.Optional[float],
 594		probability: float = 1.0,
 595	) -> "Motif":
 596
 597		"""Shared core for shaped control ramps."""
 598
 599		if beat_end is None:
 600			if length is None:
 601				raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)")
 602			beat_end = float(length)
 603
 604		if beat_end <= beat_start:
 605			raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})")
 606
 607		controls = (
 608			ControlEvent(
 609				beat = float(beat_start),
 610				signal = signal,
 611				start = float(start),
 612				end = float(end),
 613				span = float(beat_end) - float(beat_start),
 614				shape = shape,
 615				probability = probability,
 616			),
 617		)
 618
 619		return cls(
 620			events = (),
 621			length = float(math.ceil(beat_end)) if length is None else float(length),
 622			controls = controls,
 623		)
 624
 625	@classmethod
 626	def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
 627
 628		"""Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement."""
 629
 630		return cls._control_writes(CC(control), list(values), list(beats), length, probabilities)
 631
 632	@classmethod
 633	def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
 634
 635		"""A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``."""
 636
 637		return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)
 638
 639	@classmethod
 640	def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
 641
 642		"""Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``."""
 643
 644		return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities)
 645
 646	@classmethod
 647	def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
 648
 649		"""Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``."""
 650
 651		return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)
 652
 653	@classmethod
 654	def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
 655
 656		"""Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``."""
 657
 658		return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
 659
 660	@classmethod
 661	def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
 662
 663		"""An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``."""
 664
 665		return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
 666
 667	@classmethod
 668	def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
 669
 670		"""Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``."""
 671
 672		return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)
 673
 674	@classmethod
 675	def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
 676
 677		"""An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``."""
 678
 679		return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)
 680
 681	@classmethod
 682	def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
 683
 684		"""Discrete OSC float sends at beat positions — mirrors ``p.osc()``."""
 685
 686		return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
 687
 688	@classmethod
 689	def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
 690
 691		"""An OSC float swept over a beat range — mirrors ``p.osc_ramp()``."""
 692
 693		return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability)
 694
 695	# ── the algebra ─────────────────────────────────────────────────────
 696
 697	def then (self, other: "Motif") -> "Motif":
 698
 699		"""Closed sequential concat: glue *other* after this motif into ONE longer motif."""
 700
 701		if not isinstance(other, Motif):
 702			raise TypeError(f"then() takes a Motif — got {type(other).__name__}")
 703
 704		return Motif(
 705			events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events),
 706			length = self.length + other.length,
 707			controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls),
 708		)
 709
 710	@classmethod
 711	def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif":
 712
 713		"""Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``)."""
 714
 715		result = cls.empty()
 716
 717		for m in motifs:
 718			result = result.then(m)
 719
 720		return result
 721
 722	def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif":
 723
 724		"""
 725		Parallel merge (the spelled form of ``&``): event union, length = max.
 726
 727		No implicit tiling — a short gesture stacked under a long figure
 728		plays once.  Phrase operands flatten first.
 729		"""
 730
 731		if isinstance(other, Phrase):
 732			merged = other.flatten()
 733		elif isinstance(other, Motif):
 734			merged = other
 735		else:
 736			raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}")
 737
 738		return Motif(
 739			events = self.events + merged.events,
 740			length = max(self.length, merged.length),
 741			controls = self.controls + merged.controls,
 742		)
 743
 744	def slice (self, start: float, end: float) -> "Motif":
 745
 746		"""
 747		A window onto the motif, on its own authority: events starting outside
 748		are dropped; durations and ramp spans truncate at the cut (a truncated
 749		ramp ends at its interpolated cut value).  Beats shift so the window
 750		starts at 0.
 751		"""
 752
 753		if end <= start:
 754			raise ValueError(f"slice end ({end}) must be after start ({start})")
 755
 756		events = tuple(
 757			dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat))
 758			for e in self.events
 759			if start <= e.beat < end
 760		)
 761
 762		controls = []
 763
 764		for c in self.controls:
 765			if not (start <= c.beat < end):
 766				continue
 767			if c.end is not None and c.beat + c.span > end:
 768				kept = end - c.beat
 769				controls.append(dataclasses.replace(
 770					c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span),
 771				))
 772			else:
 773				controls.append(dataclasses.replace(c, beat=c.beat - start))
 774
 775		return Motif(events=events, length=end - start, controls=tuple(controls))
 776
 777	def __add__ (self, other: typing.Any) -> "Phrase":
 778
 779		"""``a + b`` — sequential: a two-segment Phrase (segmentation preserved)."""
 780
 781		if isinstance(other, Motif):
 782			return Phrase((self, other))
 783
 784		return NotImplemented
 785
 786	def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]:
 787
 788		"""``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty."""
 789
 790		if not isinstance(count, int):
 791			return NotImplemented
 792		if count < 0:
 793			raise ValueError(f"Repetition count must be non-negative — got {count}")
 794		if count == 0:
 795			return Motif.empty()
 796		if count == 1:
 797			return self
 798
 799		return Phrase((self,) * count)
 800
 801	__rmul__ = __mul__
 802
 803	def __and__ (self, other: typing.Any) -> "Motif":
 804
 805		"""``a & b`` — parallel merge; the spelled form is :meth:`stack`."""
 806
 807		if isinstance(other, (Motif, Phrase)):
 808			return self.stack(other)
 809
 810		return NotImplemented
 811
 812	# ── transforms (pure; control gestures ride time, ignore pitch) ─────
 813
 814	def reverse (self) -> "Motif":
 815
 816		"""Mirror the figure in time; ramps swap direction (a rising sweep falls)."""
 817
 818		events = tuple(
 819			dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration))
 820			for e in self.events
 821		)
 822		controls = tuple(
 823			dataclasses.replace(
 824				c,
 825				beat = max(0.0, self.length - c.beat - c.span),
 826				start = c.start if c.end is None else c.end,
 827				end = c.end if c.end is None else c.start,
 828			)
 829			for c in self.controls
 830		)
 831
 832		return Motif(events=events, length=self.length, controls=controls)
 833
 834	def rotate (self, beats: float) -> "Motif":
 835
 836		"""Shift every onset by *beats*, wrapping modulo the length (spans ride along)."""
 837
 838		if self.length == 0:
 839			return self
 840
 841		events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events)
 842		controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls)
 843
 844		return Motif(events=events, length=self.length, controls=controls)
 845
 846	def stretch (self, factor: float) -> "Motif":
 847
 848		"""Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length."""
 849
 850		if factor <= 0:
 851			raise ValueError(f"Stretch factor must be positive — got {factor}")
 852
 853		events = tuple(
 854			dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor)
 855			for e in self.events
 856		)
 857		controls = tuple(
 858			dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor)
 859			for c in self.controls
 860		)
 861
 862		return Motif(events=events, length=self.length * factor, controls=controls)
 863
 864	def quantize (self, grid: float) -> "Motif":
 865
 866		"""Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched)."""
 867
 868		if grid <= 0:
 869			raise ValueError(f"Quantize grid must be positive — got {grid}")
 870
 871		events = tuple(
 872			dataclasses.replace(e, beat=round(e.beat / grid) * grid)
 873			for e in self.events
 874		)
 875
 876		return Motif(events=events, length=self.length, controls=self.controls)
 877
 878	def accent (self, beat: float, amount: int = 20) -> "Motif":
 879
 880		"""Add *amount* velocity to every note at the given beat position (0-based beats)."""
 881
 882		def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]:
 883			if isinstance(velocity, tuple):
 884				return (min(127, velocity[0] + amount), min(127, velocity[1] + amount))
 885			return min(127, velocity + amount)
 886
 887		events = tuple(
 888			dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e
 889			for e in self.events
 890		)
 891
 892		return Motif(events=events, length=self.length, controls=self.controls)
 893
 894	def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif":
 895
 896		"""Replace every note's velocity (an int, or a ``(low, high)`` random range)."""
 897
 898		events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events)
 899
 900		return Motif(events=events, length=self.length, controls=self.controls)
 901
 902	def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec:
 903
 904		"""One varied pitch: a small melodic nudge that always changes the note.
 905
 906		Degrees move by scale steps, MIDI ints by semitones, chord tones by
 907		index; an Approach's target is nudged.  Drum names raise — a varied
 908		drum is a different instrument, not a variation.
 909		"""
 910
 911		if isinstance(pitch, Degree):
 912			steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1]
 913			return dataclasses.replace(pitch, step = rng.choice(steps))
 914		if isinstance(pitch, ChordTone):
 915			indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1]
 916			return ChordTone(rng.choice(indices), octave = pitch.octave)
 917		if isinstance(pitch, Approach):
 918			nudged = self._nudged_pitch(pitch.target, rng)
 919			if not isinstance(nudged, (int, Degree, ChordTone)):
 920				raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content")
 921			return Approach(nudged)
 922		if isinstance(pitch, int):
 923			return pitch + rng.choice((-2, -1, 1, 2))
 924
 925		raise TypeError(
 926			f"vary() moves pitches — {type(pitch).__name__} content cannot vary "
 927			"(a varied drum is a different instrument)"
 928		)
 929
 930	def vary (
 931		self,
 932		notes: int = 1,
 933		position: str = "end",
 934		seed: typing.Optional[int] = None,
 935		rng: typing.Optional[random.Random] = None,
 936	) -> "Motif":
 937
 938		"""Replace a few pitches, preserving the rhythm — the smallest variation.
 939
 940		Rhythm, velocities, durations, rests, and control gestures are
 941		untouched; only the chosen notes' pitches move (by a small melodic
 942		nudge: scale steps for degrees, semitones for MIDI ints).
 943
 944		Parameters:
 945			notes: How many pitched notes to vary (clamped to what exists).
 946			position: Which notes — ``"end"`` (the tail, the default),
 947				``"start"``, or ``"anywhere"`` (drawn from the stream).
 948			seed: Seed for the variation.  A standalone vary without a seed
 949				warns — module-level nondeterminism breaks live reload.
 950			rng: An explicit random stream (overrides ``seed``; used by
 951				recipe machinery).
 952
 953		Example:
 954			```python
 955			answer = call.vary(notes=1, seed=4)     # same figure, new tail note
 956			```
 957		"""
 958
 959		if notes < 0:
 960			raise ValueError(f"notes must be at least 0, got {notes}")
 961		if position not in ("end", "start", "anywhere"):
 962			raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}')
 963
 964		if rng is None:
 965			if seed is None:
 966				warnings.warn(
 967					"vary() without seed= is nondeterministic — pass seed= so the "
 968					"value survives live reload",
 969					stacklevel = 2,
 970				)
 971				rng = random.Random()
 972			else:
 973				rng = random.Random(seed)
 974
 975		pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
 976		count = min(notes, len(pitched_indices))
 977
 978		if count == 0:
 979			return self
 980
 981		if position == "end":
 982			chosen = pitched_indices[-count:]
 983		elif position == "start":
 984			chosen = pitched_indices[:count]
 985		else:
 986			chosen = sorted(rng.sample(pitched_indices, count))
 987
 988		events = list(self.events)
 989
 990		for index in chosen:
 991			events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng))
 992
 993		return Motif(events = tuple(events), length = self.length, controls = self.controls)
 994
 995	def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif":
 996
 997		"""Call → response: re-aim the tail to a stable degree.
 998
 999		The classic consequent move — the figure repeats but its last pitched
1000		note lands home (degree 1 by default; pass ``to=5`` for a half-close,
1001		or a full ``Degree`` for register control).  Everything else —
1002		rhythm, the other pitches, velocities, controls — is untouched.
1003
1004		Degree content only: absolute MIDI has no degrees to re-aim (build
1005		the call with ``motif([...])``), and drums raise.
1006		"""
1007
1008		target = to if isinstance(to, Degree) else Degree(int(to))
1009
1010		pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
1011
1012		if not pitched_indices:
1013			return self
1014
1015		last = self.events[pitched_indices[-1]]
1016
1017		if not isinstance(last.pitch, Degree):
1018			raise TypeError(
1019				f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} "
1020				"content (build the call with motif([...]) for degree content)"
1021			)
1022
1023		if isinstance(to, int):
1024			# Keep the call's register: only the step is re-aimed.
1025			target = dataclasses.replace(last.pitch, step = int(to), chroma = 0)
1026
1027		events = list(self.events)
1028		events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target)
1029
1030		return Motif(events = tuple(events), length = self.length, controls = self.controls)
1031
1032	def pitched (self, spec: PitchSpec) -> "Motif":
1033
1034		"""
1035		Replace every pitch with one spec — a kick rhythm becomes a bass line.
1036
1037		``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord
1038		tones; any other string is a drum name; ints are MIDI; Degree /
1039		ChordTone / Approach pass through.
1040		"""
1041
1042		if isinstance(spec, str) and spec in _CHORD_TONE_NAMES:
1043			spec = ChordTone(spec)
1044
1045		events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events)
1046
1047		return Motif(events=events, length=self.length, controls=self.controls)
1048
1049	def rhythm (self) -> "Motif":
1050
1051		"""
1052		Strip pitches (and control gestures): a reusable rhythmic skeleton.
1053
1054		Timing, velocities, durations, and probabilities survive; re-pitch
1055		with :meth:`pitched` before placement (placing a skeleton raises).
1056		"""
1057
1058		events = tuple(dataclasses.replace(e, pitch=None) for e in self.events)
1059
1060		return Motif(events=events, length=self.length)
1061
1062	def onsets (self) -> typing.List[float]:
1063
1064		"""The note onset beats, in order — ready for rhythm-first generation."""
1065
1066		return [e.beat for e in self.events]
1067
1068	def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif":
1069
1070		"""
1071		Transpose pitched content; the keyword names the unit.
1072
1073		``steps=`` moves scale degrees diatonically (the sequencing move) and
1074		raises on absolute-MIDI or drum content; ``semitones=`` is the
1075		literal chromatic form for MIDI ints and degrees.  Drum motifs raise
1076		on both — a transposed drum name is a different instrument, not a
1077		transposition.
1078		"""
1079
1080		if (steps is None) == (semitones is None):
1081			raise ValueError("transpose() takes exactly one of steps= or semitones=")
1082
1083		def move (pitch: PitchSpec) -> PitchSpec:
1084
1085			if pitch is None:
1086				return None
1087
1088			if isinstance(pitch, Approach):
1089				moved = move(pitch.target)
1090				if not isinstance(moved, (int, Degree, ChordTone)):
1091					raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content")
1092				return Approach(moved)
1093
1094			if steps is not None:
1095				if isinstance(pitch, Degree):
1096					return dataclasses.replace(pitch, step=pitch.step + steps)
1097				raise TypeError(
1098					f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content "
1099					f"has no degrees (use semitones= for MIDI ints)"
1100				)
1101
1102			assert semitones is not None	# exactly one of steps/semitones is set (validated above)
1103
1104			if isinstance(pitch, int):
1105				return pitch + semitones
1106			if isinstance(pitch, Degree):
1107				return dataclasses.replace(pitch, chroma=pitch.chroma + semitones)
1108			raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content")
1109
1110		events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events)
1111
1112		return Motif(events=events, length=self.length, controls=self.controls)
1113
1114	def invert (self, pivot: typing.Optional[int] = None) -> "Motif":
1115
1116		"""
1117		Mirror pitches around a pivot: MIDI content around a MIDI pivot,
1118		degree content around a degree pivot (default: the first note's pitch).
1119		Drum motifs raise.
1120		"""
1121
1122		pitched_events = [e for e in self.events if e.pitch is not None]
1123
1124		if not pitched_events:
1125			return self
1126
1127		first = pitched_events[0].pitch
1128
1129		if pivot is None:
1130			if isinstance(first, int):
1131				pivot = first
1132			elif isinstance(first, Degree):
1133				pivot = first.step
1134			else:
1135				raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content")
1136
1137		def mirror (pitch: PitchSpec) -> PitchSpec:
1138
1139			if pitch is None:
1140				return None
1141			if isinstance(pitch, int):
1142				return 2 * pivot - pitch
1143			if isinstance(pitch, Degree):
1144				mirrored = 2 * pivot - pitch.step
1145				if mirrored < 1:
1146					raise ValueError(
1147						f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — "
1148						f"raise the pivot or use Degree octaves"
1149					)
1150				return dataclasses.replace(pitch, step=mirrored, chroma=-pitch.chroma)
1151			raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content")
1152
1153		events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events)
1154
1155		return Motif(events=events, length=self.length, controls=self.controls)
1156
1157	# ── description ─────────────────────────────────────────────────────
1158
1159	def describe (self) -> str:
1160
1161		"""A readable one-line summary: length, notes (pitch@beat), and control gestures."""
1162
1163		notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events)
1164		parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"]
1165
1166		if self.controls:
1167			gestures = ", ".join(_control_label(c) for c in self.controls)
1168			parts.append(f"controls [{gestures}]")
1169
1170		return " ".join(parts)
1171
1172	def __str__ (self) -> str:
1173
1174		"""Printable form (same as :meth:`describe`)."""
1175
1176		return self.describe()

An immutable musical figure: timed note events + control gestures + a length in beats.

Construct via the classmethods (degrees(), notes(), hits(), steps(), euclidean(), the control-gesture constructors, or from_events()) rather than positionally. length is explicit — a trailing rest is meaningful.

Motif( events: Tuple[MotifEvent, ...], length: float, controls: Tuple[ControlEvent, ...] = ())
events: Tuple[MotifEvent, ...]
length: float
controls: Tuple[ControlEvent, ...] = ()
@classmethod
def empty(cls) -> Motif:
365	@classmethod
366	def empty (cls) -> "Motif":
367
368		"""The empty motif (zero events, zero length) — the identity for ``then``."""
369
370		return cls(events=(), length=0.0)

The empty motif (zero events, zero length) — the identity for then.

@classmethod
def from_events( cls, events: Iterable[MotifEvent], length: Optional[float] = None, controls: Iterable[ControlEvent] = ()) -> Motif:
372	@classmethod
373	def from_events (
374		cls,
375		events: typing.Iterable[MotifEvent],
376		length: typing.Optional[float] = None,
377		controls: typing.Iterable[ControlEvent] = (),
378	) -> "Motif":
379
380		"""Build a motif from explicit events (power use; length defaults to the next whole beat)."""
381
382		events = tuple(events)
383		controls = tuple(controls)
384
385		return cls(
386			events = events,
387			length = _computed_length(events, controls) if length is None else length,
388			controls = controls,
389		)

Build a motif from explicit events (power use; length defaults to the next whole beat).

@classmethod
def degrees( cls, degrees: List[Union[int, Degree, NoneType]], beats: Optional[List[float]] = None, velocities: Any = 100, durations: Any = 1.0, probabilities: Any = 1.0, length: Optional[float] = None) -> Motif:
431	@classmethod
432	def degrees (
433		cls,
434		degrees: typing.List[typing.Union[int, Degree, None]],
435		beats: typing.Optional[typing.List[float]] = None,
436		velocities: typing.Any = _DEFAULT_VELOCITY,
437		durations: typing.Any = 1.0,
438		probabilities: typing.Any = 1.0,
439		length: typing.Optional[float] = None,
440	) -> "Motif":
441
442		"""
443		A melody written as 1-based scale degrees, one per beat by default.
444
445		Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a
446		rest (the beat slot still advances), or :class:`Degree` for octave/
447		chromatic detail.  Resolved against key + scale at placement.
448		Durations default to a full beat (each note holds its slot).
449		"""
450
451		converted: typing.List[PitchSpec] = []
452
453		for element in degrees:
454			if isinstance(element, int):
455				if element > _MAX_PLAUSIBLE_DEGREE:
456					raise ValueError(
457						f"Degree {element} is implausibly large — scale degrees are 1-based "
458						f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()."
459					)
460				converted.append(Degree(element))
461			elif isinstance(element, Degree) or element is None:
462				converted.append(element)
463			else:
464				raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}")
465
466		return cls._from_sequence(converted, beats, velocities, durations, probabilities, length)

A melody written as 1-based scale degrees, one per beat by default.

Elements are ints (1 = tonic, 8 = tonic an octave up), None for a rest (the beat slot still advances), or Degree for octave/ chromatic detail. Resolved against key + scale at placement. Durations default to a full beat (each note holds its slot).

@classmethod
def notes( cls, notes: List[Optional[int]], beats: Optional[List[float]] = None, velocities: Any = 100, durations: Any = 1.0, probabilities: Any = 1.0, length: Optional[float] = None) -> Motif:
468	@classmethod
469	def notes (
470		cls,
471		notes: typing.List[typing.Union[int, None]],
472		beats: typing.Optional[typing.List[float]] = None,
473		velocities: typing.Any = _DEFAULT_VELOCITY,
474		durations: typing.Any = 1.0,
475		probabilities: typing.Any = 1.0,
476		length: typing.Optional[float] = None,
477	) -> "Motif":
478
479		"""A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest."""
480
481		for element in notes:
482			if not (isinstance(element, int) or element is None):
483				raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}")
484
485		return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)

A melody written as absolute MIDI note numbers (60 = middle C); None = rest.

@classmethod
def hits( cls, pitch: Union[int, str], beats: List[float], length: Optional[float] = None, velocities: Any = 100, durations: Any = 0.1, probabilities: Any = 1.0) -> Motif:
487	@classmethod
488	def hits (
489		cls,
490		pitch: typing.Union[int, str],
491		beats: typing.List[float],
492		length: typing.Optional[float] = None,
493		velocities: typing.Any = _DEFAULT_VELOCITY,
494		durations: typing.Any = 0.1,
495		probabilities: typing.Any = 1.0,
496	) -> "Motif":
497
498		"""One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention."""
499
500		return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length)

One pitch (usually a drum name) at a list of beat positions — the hit() convention.

@classmethod
def steps( cls, steps: List[int], pitches: Any, velocities: Any = 100, durations: Any = 0.1, probabilities: Any = 1.0, step_duration: float = 0.25, length: Optional[float] = None) -> Motif:
502	@classmethod
503	def steps (
504		cls,
505		steps: typing.List[int],
506		pitches: typing.Any,
507		velocities: typing.Any = _DEFAULT_VELOCITY,
508		durations: typing.Any = 0.1,
509		probabilities: typing.Any = 1.0,
510		step_duration: float = 0.25,
511		length: typing.Optional[float] = None,
512	) -> "Motif":
513
514		"""
515		Grid placement — the ``sequence()`` convention: ``steps`` are 0-based
516		grid indices (sixteenths by default), ``pitches`` a scalar or
517		parallel list of MIDI ints or drum names.
518		"""
519
520		n = len(steps)
521		pitch_list = _expand("pitches", pitches, n)
522		onsets = [s * step_duration for s in steps]
523
524		if length is None and n:
525			length = float(math.ceil((max(steps) + 1) * step_duration))
526
527		return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length)

Grid placement — the sequence() convention: steps are 0-based grid indices (sixteenths by default), pitches a scalar or parallel list of MIDI ints or drum names.

@classmethod
def euclidean( cls, pulses: int, steps: int, pitch: Union[int, str], length: float = 4.0, velocities: Any = 100, durations: Any = 0.1, probabilities: Any = 1.0) -> Motif:
529	@classmethod
530	def euclidean (
531		cls,
532		pulses: int,
533		steps: int,
534		pitch: typing.Union[int, str],
535		length: float = 4.0,
536		velocities: typing.Any = _DEFAULT_VELOCITY,
537		durations: typing.Any = 0.1,
538		probabilities: typing.Any = 1.0,
539	) -> "Motif":
540
541		"""A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats."""
542
543		# The kernel returns one 0/1 flag per grid step; onsets are the 1s.
544		flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses)
545		step_duration = length / steps
546		onsets = [i * step_duration for i, flag in enumerate(flags) if flag]
547
548		return cls._from_sequence(
549			[pitch] * len(onsets),
550			onsets,
551			velocities, durations, probabilities, length,
552		)

A euclidean rhythm as a value: pulses spread evenly across steps over length beats.

@classmethod
def cc( cls, control: Union[int, str], values: List[int], beats: List[float], length: Optional[float] = None, probabilities: Any = 1.0) -> Motif:
625	@classmethod
626	def cc (cls, control: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
627
628		"""Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement."""
629
630		return cls._control_writes(CC(control), list(values), list(beats), length, probabilities)

Discrete CC writes at beat positions — mirrors p.cc(); names resolve at placement.

@classmethod
def cc_ramp( cls, control: Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: Optional[float] = None, shape: Union[str, Callable[[float], float]] = 'linear', length: Optional[float] = None, probability: float = 1.0) -> Motif:
632	@classmethod
633	def cc_ramp (cls, control: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
634
635		"""A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``."""
636
637		return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)

A CC value swept startend over a beat range — mirrors p.cc_ramp().

@classmethod
def pitch_bend( cls, values: List[float], beats: List[float], length: Optional[float] = None, probabilities: Any = 1.0) -> Motif:
639	@classmethod
640	def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
641
642		"""Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``."""
643
644		return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities)

Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors p.pitch_bend().

@classmethod
def pitch_bend_ramp( cls, start: float, end: float, beat_start: float = 0.0, beat_end: Optional[float] = None, shape: Union[str, Callable[[float], float]] = 'linear', length: Optional[float] = None, probability: float = 1.0) -> Motif:
646	@classmethod
647	def pitch_bend_ramp (cls, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
648
649		"""Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``."""
650
651		return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)

Pitch bend swept startend (-1.0 to 1.0) over a beat range — mirrors p.pitch_bend_ramp().

@classmethod
def nrpn( cls, parameter: Union[int, str], values: List[int], beats: List[float], fine: bool = False, null_reset: bool = True, length: Optional[float] = None, probabilities: Any = 1.0) -> Motif:
653	@classmethod
654	def nrpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
655
656		"""Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``."""
657
658		return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)

Discrete NRPN parameter writes at beat positions — mirrors p.nrpn().

@classmethod
def nrpn_ramp( cls, parameter: Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: Optional[float] = None, shape: Union[str, Callable[[float], float]] = 'linear', fine: bool = True, null_reset: bool = True, length: Optional[float] = None, probability: float = 1.0) -> Motif:
660	@classmethod
661	def nrpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
662
663		"""An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``."""
664
665		return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)

An NRPN value swept over a beat range — mirrors p.nrpn_ramp().

@classmethod
def rpn( cls, parameter: Union[int, str], values: List[int], beats: List[float], fine: bool = False, null_reset: bool = True, length: Optional[float] = None, probabilities: Any = 1.0) -> Motif:
667	@classmethod
668	def rpn (cls, parameter: typing.Union[int, str], values: typing.List[int], beats: typing.List[float], fine: bool = False, null_reset: bool = True, length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
669
670		"""Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``."""
671
672		return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities)

Discrete RPN parameter writes at beat positions — mirrors p.rpn().

@classmethod
def rpn_ramp( cls, parameter: Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: Optional[float] = None, shape: Union[str, Callable[[float], float]] = 'linear', fine: bool = True, null_reset: bool = True, length: Optional[float] = None, probability: float = 1.0) -> Motif:
674	@classmethod
675	def rpn_ramp (cls, parameter: typing.Union[int, str], start: int, end: int, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", fine: bool = True, null_reset: bool = True, length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
676
677		"""An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``."""
678
679		return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability)

An RPN value swept over a beat range — mirrors p.rpn_ramp().

@classmethod
def osc( cls, address: str, values: List[float], beats: List[float], length: Optional[float] = None, probabilities: Any = 1.0) -> Motif:
681	@classmethod
682	def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif":
683
684		"""Discrete OSC float sends at beat positions — mirrors ``p.osc()``."""
685
686		return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)

Discrete OSC float sends at beat positions — mirrors p.osc().

@classmethod
def osc_ramp( cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: Optional[float] = None, shape: Union[str, Callable[[float], float]] = 'linear', length: Optional[float] = None, probability: float = 1.0) -> Motif:
688	@classmethod
689	def osc_ramp (cls, address: str, start: float, end: float, beat_start: float = 0.0, beat_end: typing.Optional[float] = None, shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear", length: typing.Optional[float] = None, probability: float = 1.0) -> "Motif":
690
691		"""An OSC float swept over a beat range — mirrors ``p.osc_ramp()``."""
692
693		return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability)

An OSC float swept over a beat range — mirrors p.osc_ramp().

def then(self, other: Motif) -> Motif:
697	def then (self, other: "Motif") -> "Motif":
698
699		"""Closed sequential concat: glue *other* after this motif into ONE longer motif."""
700
701		if not isinstance(other, Motif):
702			raise TypeError(f"then() takes a Motif — got {type(other).__name__}")
703
704		return Motif(
705			events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events),
706			length = self.length + other.length,
707			controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls),
708		)

Closed sequential concat: glue other after this motif into ONE longer motif.

@classmethod
def join( cls, motifs: Iterable[Motif]) -> Motif:
710	@classmethod
711	def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif":
712
713		"""Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``)."""
714
715		result = cls.empty()
716
717		for m in motifs:
718			result = result.then(m)
719
720		return result

Fold a list of motifs into one with then (empty list → Motif.empty()).

def stack( self, other: Union[Motif, Phrase]) -> Motif:
722	def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif":
723
724		"""
725		Parallel merge (the spelled form of ``&``): event union, length = max.
726
727		No implicit tiling — a short gesture stacked under a long figure
728		plays once.  Phrase operands flatten first.
729		"""
730
731		if isinstance(other, Phrase):
732			merged = other.flatten()
733		elif isinstance(other, Motif):
734			merged = other
735		else:
736			raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}")
737
738		return Motif(
739			events = self.events + merged.events,
740			length = max(self.length, merged.length),
741			controls = self.controls + merged.controls,
742		)

Parallel merge (the spelled form of &): event union, length = max.

No implicit tiling — a short gesture stacked under a long figure plays once. Phrase operands flatten first.

def slice(self, start: float, end: float) -> Motif:
744	def slice (self, start: float, end: float) -> "Motif":
745
746		"""
747		A window onto the motif, on its own authority: events starting outside
748		are dropped; durations and ramp spans truncate at the cut (a truncated
749		ramp ends at its interpolated cut value).  Beats shift so the window
750		starts at 0.
751		"""
752
753		if end <= start:
754			raise ValueError(f"slice end ({end}) must be after start ({start})")
755
756		events = tuple(
757			dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat))
758			for e in self.events
759			if start <= e.beat < end
760		)
761
762		controls = []
763
764		for c in self.controls:
765			if not (start <= c.beat < end):
766				continue
767			if c.end is not None and c.beat + c.span > end:
768				kept = end - c.beat
769				controls.append(dataclasses.replace(
770					c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span),
771				))
772			else:
773				controls.append(dataclasses.replace(c, beat=c.beat - start))
774
775		return Motif(events=events, length=end - start, controls=tuple(controls))

A window onto the motif, on its own authority: events starting outside are dropped; durations and ramp spans truncate at the cut (a truncated ramp ends at its interpolated cut value). Beats shift so the window starts at 0.

def reverse(self) -> Motif:
814	def reverse (self) -> "Motif":
815
816		"""Mirror the figure in time; ramps swap direction (a rising sweep falls)."""
817
818		events = tuple(
819			dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration))
820			for e in self.events
821		)
822		controls = tuple(
823			dataclasses.replace(
824				c,
825				beat = max(0.0, self.length - c.beat - c.span),
826				start = c.start if c.end is None else c.end,
827				end = c.end if c.end is None else c.start,
828			)
829			for c in self.controls
830		)
831
832		return Motif(events=events, length=self.length, controls=controls)

Mirror the figure in time; ramps swap direction (a rising sweep falls).

def rotate(self, beats: float) -> Motif:
834	def rotate (self, beats: float) -> "Motif":
835
836		"""Shift every onset by *beats*, wrapping modulo the length (spans ride along)."""
837
838		if self.length == 0:
839			return self
840
841		events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events)
842		controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls)
843
844		return Motif(events=events, length=self.length, controls=controls)

Shift every onset by beats, wrapping modulo the length (spans ride along).

def stretch(self, factor: float) -> Motif:
846	def stretch (self, factor: float) -> "Motif":
847
848		"""Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length."""
849
850		if factor <= 0:
851			raise ValueError(f"Stretch factor must be positive — got {factor}")
852
853		events = tuple(
854			dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor)
855			for e in self.events
856		)
857		controls = tuple(
858			dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor)
859			for c in self.controls
860		)
861
862		return Motif(events=events, length=self.length * factor, controls=controls)

Scale time by factor (2.0 = half-time feel): beats, durations, spans, and length.

def quantize(self, grid: float) -> Motif:
864	def quantize (self, grid: float) -> "Motif":
865
866		"""Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched)."""
867
868		if grid <= 0:
869			raise ValueError(f"Quantize grid must be positive — got {grid}")
870
871		events = tuple(
872			dataclasses.replace(e, beat=round(e.beat / grid) * grid)
873			for e in self.events
874		)
875
876		return Motif(events=events, length=self.length, controls=self.controls)

Snap note onsets to the nearest multiple of grid beats (control gestures untouched).

def accent(self, beat: float, amount: int = 20) -> Motif:
878	def accent (self, beat: float, amount: int = 20) -> "Motif":
879
880		"""Add *amount* velocity to every note at the given beat position (0-based beats)."""
881
882		def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]:
883			if isinstance(velocity, tuple):
884				return (min(127, velocity[0] + amount), min(127, velocity[1] + amount))
885			return min(127, velocity + amount)
886
887		events = tuple(
888			dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e
889			for e in self.events
890		)
891
892		return Motif(events=events, length=self.length, controls=self.controls)

Add amount velocity to every note at the given beat position (0-based beats).

def with_velocity(self, velocity: Union[int, Tuple[int, int]]) -> Motif:
894	def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif":
895
896		"""Replace every note's velocity (an int, or a ``(low, high)`` random range)."""
897
898		events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events)
899
900		return Motif(events=events, length=self.length, controls=self.controls)

Replace every note's velocity (an int, or a (low, high) random range).

def vary( self, notes: int = 1, position: str = 'end', seed: Optional[int] = None, rng: Optional[random.Random] = None) -> Motif:
930	def vary (
931		self,
932		notes: int = 1,
933		position: str = "end",
934		seed: typing.Optional[int] = None,
935		rng: typing.Optional[random.Random] = None,
936	) -> "Motif":
937
938		"""Replace a few pitches, preserving the rhythm — the smallest variation.
939
940		Rhythm, velocities, durations, rests, and control gestures are
941		untouched; only the chosen notes' pitches move (by a small melodic
942		nudge: scale steps for degrees, semitones for MIDI ints).
943
944		Parameters:
945			notes: How many pitched notes to vary (clamped to what exists).
946			position: Which notes — ``"end"`` (the tail, the default),
947				``"start"``, or ``"anywhere"`` (drawn from the stream).
948			seed: Seed for the variation.  A standalone vary without a seed
949				warns — module-level nondeterminism breaks live reload.
950			rng: An explicit random stream (overrides ``seed``; used by
951				recipe machinery).
952
953		Example:
954			```python
955			answer = call.vary(notes=1, seed=4)     # same figure, new tail note
956			```
957		"""
958
959		if notes < 0:
960			raise ValueError(f"notes must be at least 0, got {notes}")
961		if position not in ("end", "start", "anywhere"):
962			raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}')
963
964		if rng is None:
965			if seed is None:
966				warnings.warn(
967					"vary() without seed= is nondeterministic — pass seed= so the "
968					"value survives live reload",
969					stacklevel = 2,
970				)
971				rng = random.Random()
972			else:
973				rng = random.Random(seed)
974
975		pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
976		count = min(notes, len(pitched_indices))
977
978		if count == 0:
979			return self
980
981		if position == "end":
982			chosen = pitched_indices[-count:]
983		elif position == "start":
984			chosen = pitched_indices[:count]
985		else:
986			chosen = sorted(rng.sample(pitched_indices, count))
987
988		events = list(self.events)
989
990		for index in chosen:
991			events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng))
992
993		return Motif(events = tuple(events), length = self.length, controls = self.controls)

Replace a few pitches, preserving the rhythm — the smallest variation.

Rhythm, velocities, durations, rests, and control gestures are untouched; only the chosen notes' pitches move (by a small melodic nudge: scale steps for degrees, semitones for MIDI ints).

Arguments:
  • notes: How many pitched notes to vary (clamped to what exists).
  • position: Which notes — "end" (the tail, the default), "start", or "anywhere" (drawn from the stream).
  • seed: Seed for the variation. A standalone vary without a seed warns — module-level nondeterminism breaks live reload.
  • rng: An explicit random stream (overrides seed; used by recipe machinery).
Example:
answer = call.vary(notes=1, seed=4)     # same figure, new tail note
def answer( self, to: Union[int, Degree] = 1) -> Motif:
 995	def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif":
 996
 997		"""Call → response: re-aim the tail to a stable degree.
 998
 999		The classic consequent move — the figure repeats but its last pitched
1000		note lands home (degree 1 by default; pass ``to=5`` for a half-close,
1001		or a full ``Degree`` for register control).  Everything else —
1002		rhythm, the other pitches, velocities, controls — is untouched.
1003
1004		Degree content only: absolute MIDI has no degrees to re-aim (build
1005		the call with ``motif([...])``), and drums raise.
1006		"""
1007
1008		target = to if isinstance(to, Degree) else Degree(int(to))
1009
1010		pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None]
1011
1012		if not pitched_indices:
1013			return self
1014
1015		last = self.events[pitched_indices[-1]]
1016
1017		if not isinstance(last.pitch, Degree):
1018			raise TypeError(
1019				f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} "
1020				"content (build the call with motif([...]) for degree content)"
1021			)
1022
1023		if isinstance(to, int):
1024			# Keep the call's register: only the step is re-aimed.
1025			target = dataclasses.replace(last.pitch, step = int(to), chroma = 0)
1026
1027		events = list(self.events)
1028		events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target)
1029
1030		return Motif(events = tuple(events), length = self.length, controls = self.controls)

Call → response: re-aim the tail to a stable degree.

The classic consequent move — the figure repeats but its last pitched note lands home (degree 1 by default; pass to=5 for a half-close, or a full Degree for register control). Everything else — rhythm, the other pitches, velocities, controls — is untouched.

Degree content only: absolute MIDI has no degrees to re-aim (build the call with motif([...])), and drums raise.

def pitched( self, spec: Union[int, str, Degree, ChordTone, Approach, NoneType]) -> Motif:
1032	def pitched (self, spec: PitchSpec) -> "Motif":
1033
1034		"""
1035		Replace every pitch with one spec — a kick rhythm becomes a bass line.
1036
1037		``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord
1038		tones; any other string is a drum name; ints are MIDI; Degree /
1039		ChordTone / Approach pass through.
1040		"""
1041
1042		if isinstance(spec, str) and spec in _CHORD_TONE_NAMES:
1043			spec = ChordTone(spec)
1044
1045		events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events)
1046
1047		return Motif(events=events, length=self.length, controls=self.controls)

Replace every pitch with one spec — a kick rhythm becomes a bass line.

"root" / "third" / "fifth" / "seventh" become chord tones; any other string is a drum name; ints are MIDI; Degree / ChordTone / Approach pass through.

def rhythm(self) -> Motif:
1049	def rhythm (self) -> "Motif":
1050
1051		"""
1052		Strip pitches (and control gestures): a reusable rhythmic skeleton.
1053
1054		Timing, velocities, durations, and probabilities survive; re-pitch
1055		with :meth:`pitched` before placement (placing a skeleton raises).
1056		"""
1057
1058		events = tuple(dataclasses.replace(e, pitch=None) for e in self.events)
1059
1060		return Motif(events=events, length=self.length)

Strip pitches (and control gestures): a reusable rhythmic skeleton.

Timing, velocities, durations, and probabilities survive; re-pitch with pitched() before placement (placing a skeleton raises).

def onsets(self) -> List[float]:
1062	def onsets (self) -> typing.List[float]:
1063
1064		"""The note onset beats, in order — ready for rhythm-first generation."""
1065
1066		return [e.beat for e in self.events]

The note onset beats, in order — ready for rhythm-first generation.

def transpose( self, steps: Optional[int] = None, semitones: Optional[int] = None) -> Motif:
1068	def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif":
1069
1070		"""
1071		Transpose pitched content; the keyword names the unit.
1072
1073		``steps=`` moves scale degrees diatonically (the sequencing move) and
1074		raises on absolute-MIDI or drum content; ``semitones=`` is the
1075		literal chromatic form for MIDI ints and degrees.  Drum motifs raise
1076		on both — a transposed drum name is a different instrument, not a
1077		transposition.
1078		"""
1079
1080		if (steps is None) == (semitones is None):
1081			raise ValueError("transpose() takes exactly one of steps= or semitones=")
1082
1083		def move (pitch: PitchSpec) -> PitchSpec:
1084
1085			if pitch is None:
1086				return None
1087
1088			if isinstance(pitch, Approach):
1089				moved = move(pitch.target)
1090				if not isinstance(moved, (int, Degree, ChordTone)):
1091					raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content")
1092				return Approach(moved)
1093
1094			if steps is not None:
1095				if isinstance(pitch, Degree):
1096					return dataclasses.replace(pitch, step=pitch.step + steps)
1097				raise TypeError(
1098					f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content "
1099					f"has no degrees (use semitones= for MIDI ints)"
1100				)
1101
1102			assert semitones is not None	# exactly one of steps/semitones is set (validated above)
1103
1104			if isinstance(pitch, int):
1105				return pitch + semitones
1106			if isinstance(pitch, Degree):
1107				return dataclasses.replace(pitch, chroma=pitch.chroma + semitones)
1108			raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content")
1109
1110		events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events)
1111
1112		return Motif(events=events, length=self.length, controls=self.controls)

Transpose pitched content; the keyword names the unit.

steps= moves scale degrees diatonically (the sequencing move) and raises on absolute-MIDI or drum content; semitones= is the literal chromatic form for MIDI ints and degrees. Drum motifs raise on both — a transposed drum name is a different instrument, not a transposition.

def invert(self, pivot: Optional[int] = None) -> Motif:
1114	def invert (self, pivot: typing.Optional[int] = None) -> "Motif":
1115
1116		"""
1117		Mirror pitches around a pivot: MIDI content around a MIDI pivot,
1118		degree content around a degree pivot (default: the first note's pitch).
1119		Drum motifs raise.
1120		"""
1121
1122		pitched_events = [e for e in self.events if e.pitch is not None]
1123
1124		if not pitched_events:
1125			return self
1126
1127		first = pitched_events[0].pitch
1128
1129		if pivot is None:
1130			if isinstance(first, int):
1131				pivot = first
1132			elif isinstance(first, Degree):
1133				pivot = first.step
1134			else:
1135				raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content")
1136
1137		def mirror (pitch: PitchSpec) -> PitchSpec:
1138
1139			if pitch is None:
1140				return None
1141			if isinstance(pitch, int):
1142				return 2 * pivot - pitch
1143			if isinstance(pitch, Degree):
1144				mirrored = 2 * pivot - pitch.step
1145				if mirrored < 1:
1146					raise ValueError(
1147						f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — "
1148						f"raise the pivot or use Degree octaves"
1149					)
1150				return dataclasses.replace(pitch, step=mirrored, chroma=-pitch.chroma)
1151			raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content")
1152
1153		events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events)
1154
1155		return Motif(events=events, length=self.length, controls=self.controls)

Mirror pitches around a pivot: MIDI content around a MIDI pivot, degree content around a degree pivot (default: the first note's pitch). Drum motifs raise.

def describe(self) -> str:
1159	def describe (self) -> str:
1160
1161		"""A readable one-line summary: length, notes (pitch@beat), and control gestures."""
1162
1163		notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events)
1164		parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"]
1165
1166		if self.controls:
1167			gestures = ", ".join(_control_label(c) for c in self.controls)
1168			parts.append(f"controls [{gestures}]")
1169
1170		return " ".join(parts)

A readable one-line summary: length, notes (pitch@beat), and control gestures.

@dataclasses.dataclass(frozen=True)
class Phrase:
1275@dataclasses.dataclass(frozen=True)
1276class Phrase:
1277
1278	"""
1279	A sequence of Motifs with segmentation preserved.
1280
1281	Segmentation is the unit of editing — it is what development and
1282	per-region regeneration operate on.  ``flatten()`` erases it into one
1283	long Motif.  Length is the sum of segment lengths.
1284
1285	A phrase made by :meth:`develop` carries its recipe, so
1286	:meth:`reroll` can regenerate a region; transforms and hand edits
1287	return recipe-less phrases (their notes no longer come from the
1288	recipe, so there is nothing honest to regenerate from).
1289	"""
1290
1291	segments: typing.Tuple[Motif, ...]
1292	recipe: typing.Optional[_PhraseRecipe]
1293
1294	def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None:
1295
1296		"""Coerce any iterable of Motifs."""
1297
1298		segments = tuple(segments)
1299
1300		for segment in segments:
1301			if not isinstance(segment, Motif):
1302				raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}")
1303
1304		object.__setattr__(self, "segments", segments)
1305		object.__setattr__(self, "recipe", recipe)
1306
1307	@property
1308	def length (self) -> float:
1309
1310		"""Total length in beats (sum of segment lengths)."""
1311
1312		return sum(segment.length for segment in self.segments)
1313
1314	@classmethod
1315	def develop (
1316		cls,
1317		motif: Motif,
1318		bars: int = 8,
1319		plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None,
1320		seed: typing.Optional[int] = None,
1321		beats_per_bar: float = 4.0,
1322	) -> "Phrase":
1323
1324		"""Grow a motif into a phrase by a plan — the phrase generator.
1325
1326		``plan`` follows the standard form.  The literal form is a **list of
1327		unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently
1328		``["a"] * 3 + ["b"]``: the first label is the given motif, each new
1329		label is a generated contrast unit (the source's rhythm, freshly
1330		re-pitched), a repeated label is a restatement, and *bars* spreads
1331		evenly across the units.  A bare string is a **recipe name** from
1332		the curated table — ``plan="call_response"`` (call, answer, call,
1333		varied answer) — reserved for plans whose semantics exceed a label
1334		skeleton.  A letter string is not a plan: a sequence of labels is a
1335		sequence, so it is a list.
1336
1337		The result carries its recipe, so :meth:`reroll` can regenerate a
1338		region later.
1339
1340		Parameters:
1341			motif: The source unit (its length must be ``bars / len(units)``
1342				bars — the plan's units tile the phrase exactly).
1343			bars: Phrase length in bars (must divide evenly by the unit
1344				count).
1345			plan: A list of unit labels, or a recipe name.
1346			seed: Seed for the generated units.  Without one, develop()
1347				warns — module-level nondeterminism breaks live reload.
1348			beats_per_bar: Bar size in beats (the value is context-free;
1349				4 is the common-time default).
1350
1351		Example:
1352			```python
1353			call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
1354			lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11)
1355			```
1356		"""
1357
1358		if plan is None:
1359			raise ValueError(
1360				'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) '
1361				'or a recipe name (plan="call_response")'
1362			)
1363
1364		if seed is None:
1365			warnings.warn(
1366				"develop() without seed= is nondeterministic — pass seed= so the "
1367				"value survives live reload",
1368				stacklevel = 2,
1369			)
1370
1371		if isinstance(plan, str):
1372
1373			if plan not in _PHRASE_RECIPES:
1374				known = ", ".join(sorted(_PHRASE_RECIPES))
1375				hint = ""
1376				if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan):
1377					spelled = ", ".join(repr(c) for c in plan)
1378					hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]."
1379				raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}")
1380
1381			units = _PHRASE_RECIPES[plan](motif, seed)
1382			stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan
1383
1384		else:
1385
1386			labels = list(plan)
1387
1388			if not labels or not all(isinstance(label, str) and label for label in labels):
1389				raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']")
1390
1391			generated: typing.Dict[str, Motif] = {labels[0]: motif}
1392
1393			for label in labels:
1394				if label not in generated:
1395					generated[label] = _contrast_unit(motif, random.Random(f"{seed}:unit:{label}"))
1396
1397			units = [generated[label] for label in labels]
1398			stored_plan = tuple(labels)
1399
1400		if bars % len(units) != 0:
1401			raise ValueError(
1402				f"bars={bars} does not divide evenly across {len(units)} plan units — "
1403				"each unit must fill a whole number of bars"
1404			)
1405
1406		unit_beats = bars * beats_per_bar / len(units)
1407
1408		if abs(motif.length - unit_beats) > 1e-9:
1409			raise ValueError(
1410				f"the motif is {motif.length:g} beats but each of the {len(units)} plan units "
1411				f"spans {unit_beats:g} beats ({bars} bars / {len(units)} units) — "
1412				"adjust bars, the plan, or the motif's length"
1413			)
1414
1415		return cls(units, recipe = _PhraseRecipe(
1416			source = motif,
1417			plan = stored_plan,
1418			bars = bars,
1419			seed = seed,
1420			beats_per_bar = beats_per_bar,
1421		))
1422
1423	def reroll (
1424		self,
1425		bar: typing.Optional[int] = None,
1426		bars: typing.Optional[typing.Sequence[int]] = None,
1427		seed: typing.Optional[int] = None,
1428	) -> "Phrase":
1429
1430		"""Regenerate only the named bars — rhythm and boundary pitches kept.
1431
1432		Within each named bar, the first and last pitched notes stay (the
1433		boundary pins) and the interior pitches re-roll from the recipe's
1434		stream; onsets, durations, velocities, rests, drums, and control
1435		gestures are untouched.  Segmentation and the recipe survive, so
1436		rerolls compose.
1437
1438		Only a phrase that carries a recipe can reroll — a hand-written or
1439		transformed phrase raises loudly (its notes no longer come from a
1440		generator, so regenerating them would invent music).
1441
1442		Parameters:
1443			bar: A single 1-based bar to reroll.
1444			bars: A list of 1-based bars (the paired plural spelling).
1445			seed: Seed for the new pitches (salted per bar).  Without one,
1446				reroll() warns.
1447
1448		Example:
1449			```python
1450			lead = lead.reroll(bar=7, seed=4)    # only bar 7; rhythm + boundaries kept
1451			```
1452		"""
1453
1454		if self.recipe is None:
1455			raise ValueError(
1456				"this phrase carries no recipe (it was written by hand, or transformed "
1457				"since generation) — reroll() regenerates from a recipe; edit segments "
1458				"with replace(), or rebuild with Phrase.develop()"
1459			)
1460
1461		if (bar is None) == (bars is None):
1462			raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)")
1463
1464		region = [bar] if bar is not None else list(bars or [])
1465		beats_per_bar = self.recipe.beats_per_bar
1466		total_bars = int(round(self.length / beats_per_bar))
1467
1468		for number in region:
1469			if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars:
1470				raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})")
1471
1472		if seed is None:
1473			warnings.warn(
1474				"reroll() without seed= is nondeterministic — pass seed= so the "
1475				"value survives live reload",
1476				stacklevel = 2,
1477			)
1478
1479		windows = [
1480			((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{seed}:reroll:{number}"))
1481			for number in sorted(set(region))
1482		]
1483
1484		new_segments: typing.List[Motif] = []
1485		offset = 0.0
1486
1487		for segment in self.segments:
1488
1489			events = list(segment.events)
1490
1491			for window_start, window_end, rng in windows:
1492
1493				inside = [
1494					index for index, event in enumerate(events)
1495					if window_start <= offset + event.beat < window_end
1496					and event.pitch is not None and not isinstance(event.pitch, str)
1497				]
1498
1499				# Boundary pins: the first and last pitched notes of the bar
1500				# stay; only the interior re-rolls.
1501				for index in inside[1:-1]:
1502					events[index] = dataclasses.replace(
1503						events[index],
1504						pitch = segment._nudged_pitch(events[index].pitch, rng),
1505					)
1506
1507			new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls))
1508			offset += segment.length
1509
1510		return Phrase(new_segments, recipe = self.recipe)
1511
1512	def flatten (self) -> Motif:
1513
1514		"""Erase segmentation: one long Motif (the monoid homomorphism onto ``then``)."""
1515
1516		return Motif.join(self.segments)
1517
1518	# ── algebra ─────────────────────────────────────────────────────────
1519
1520	def __add__ (self, other: typing.Any) -> "Phrase":
1521
1522		"""Append a Motif segment, or concatenate another Phrase's segments."""
1523
1524		if isinstance(other, Motif):
1525			return Phrase(self.segments + (other,))
1526		if isinstance(other, Phrase):
1527			return Phrase(self.segments + other.segments)
1528
1529		return NotImplemented
1530
1531	def __radd__ (self, other: typing.Any) -> "Phrase":
1532
1533		"""A Motif on the left prepends as a segment."""
1534
1535		if isinstance(other, Motif):
1536			return Phrase((other,) + self.segments)
1537
1538		return NotImplemented
1539
1540	def __mul__ (self, count: int) -> "Phrase":
1541
1542		"""Tile the segments *count* times."""
1543
1544		if not isinstance(count, int):
1545			return NotImplemented
1546		if count < 0:
1547			raise ValueError(f"Repetition count must be non-negative — got {count}")
1548
1549		return Phrase(self.segments * count)
1550
1551	__rmul__ = __mul__
1552
1553	def __and__ (self, other: typing.Any) -> Motif:
1554
1555		"""Parallel merge is vertical: Phrase operands flatten to Motif first."""
1556
1557		if isinstance(other, (Motif, Phrase)):
1558			return self.flatten().stack(other)
1559
1560		return NotImplemented
1561
1562	def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif:
1563
1564		"""The spelled form of ``&`` — flattens, then merges."""
1565
1566		return self.flatten().stack(other)
1567
1568	def slice (self, start: float, end: float) -> "Phrase":
1569
1570		"""A window; re-segments at the cut points (partial segments are sliced)."""
1571
1572		segments = []
1573		offset = 0.0
1574
1575		for segment in self.segments:
1576			seg_start, seg_end = offset, offset + segment.length
1577			lo, hi = max(start, seg_start), min(end, seg_end)
1578			if lo < hi:
1579				segments.append(segment.slice(lo - seg_start, hi - seg_start))
1580			offset = seg_end
1581
1582		return Phrase(segments)
1583
1584	def replace (self, position: int, motif: Motif) -> "Phrase":
1585
1586		"""Replace the segment at a 1-based position (musicians count from one)."""
1587
1588		if not 1 <= position <= len(self.segments):
1589			raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)")
1590
1591		segments = list(self.segments)
1592		segments[position - 1] = motif
1593
1594		return Phrase(segments)
1595
1596	# ── transforms: lifted segment-wise, except time-reordering ─────────
1597
1598	def reverse (self) -> "Phrase":
1599
1600		"""Reverse the whole timeline: segments reverse order AND each reverses internally."""
1601
1602		return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
1603
1604	def rotate (self, beats: float) -> "Phrase":
1605
1606		"""Rotate the whole timeline modulo the total length, then re-segment at the original boundaries."""
1607
1608		flat = self.flatten().rotate(beats)
1609		segments = []
1610		offset = 0.0
1611
1612		# Re-segment by onset (events keep their full durations — a note may
1613		# ring past its new segment, exactly as it does on the flat timeline).
1614		for segment in self.segments:
1615			lo, hi = offset, offset + segment.length
1616			segments.append(Motif(
1617				events = tuple(
1618					dataclasses.replace(e, beat=e.beat - lo)
1619					for e in flat.events if lo <= e.beat < hi
1620				),
1621				length = segment.length,
1622				controls = tuple(
1623					dataclasses.replace(c, beat=c.beat - lo)
1624					for c in flat.controls if lo <= c.beat < hi
1625				),
1626			))
1627			offset = hi
1628
1629		return Phrase(segments)
1630
1631	def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase":
1632
1633		"""Apply a Motif transform to every segment."""
1634
1635		return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments))
1636
1637	def stretch (self, factor: float) -> "Phrase":
1638
1639		"""Scale time in every segment (lengths scale with them)."""
1640
1641		return self._lift("stretch", factor)
1642
1643	def quantize (self, grid: float) -> "Phrase":
1644
1645		"""Snap note onsets segment-wise."""
1646
1647		return self._lift("quantize", grid)
1648
1649	def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase":
1650
1651		"""Replace every note's velocity, segment-wise."""
1652
1653		return self._lift("with_velocity", velocity)
1654
1655	def pitched (self, spec: PitchSpec) -> "Phrase":
1656
1657		"""Replace every pitch, segment-wise."""
1658
1659		return self._lift("pitched", spec)
1660
1661	def rhythm (self) -> "Phrase":
1662
1663		"""Strip pitches segment-wise: a phrase-shaped skeleton."""
1664
1665		return self._lift("rhythm")
1666
1667	def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase":
1668
1669		"""Transpose every segment (see :meth:`Motif.transpose`)."""
1670
1671		return self._lift("transpose", steps=steps, semitones=semitones)
1672
1673	def invert (self, pivot: typing.Optional[int] = None) -> "Phrase":
1674
1675		"""Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`)."""
1676
1677		if pivot is None:
1678			for segment in self.segments:
1679				for event in segment.events:
1680					if event.pitch is not None:
1681						if isinstance(event.pitch, int):
1682							pivot = event.pitch
1683						elif isinstance(event.pitch, Degree):
1684							pivot = event.pitch.step
1685						break
1686				if pivot is not None:
1687					break
1688
1689		return self._lift("invert", pivot=pivot)
1690
1691	def describe (self) -> str:
1692
1693		"""A readable summary: total length and each segment on its own line."""
1694
1695		header = f"Phrase {self.length:g} beats, {len(self.segments)} segments"
1696		lines = [f"  {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)]
1697
1698		return "\n".join([header] + lines)
1699
1700	def __str__ (self) -> str:
1701
1702		"""Printable form (same as :meth:`describe`)."""
1703
1704		return self.describe()

A sequence of Motifs with segmentation preserved.

Segmentation is the unit of editing — it is what development and per-region regeneration operate on. flatten() erases it into one long Motif. Length is the sum of segment lengths.

A phrase made by develop() carries its recipe, so reroll() can regenerate a region; transforms and hand edits return recipe-less phrases (their notes no longer come from the recipe, so there is nothing honest to regenerate from).

Phrase( segments: Iterable[Motif], recipe: Optional[subsequence.motifs._PhraseRecipe] = None)
1294	def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None:
1295
1296		"""Coerce any iterable of Motifs."""
1297
1298		segments = tuple(segments)
1299
1300		for segment in segments:
1301			if not isinstance(segment, Motif):
1302				raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}")
1303
1304		object.__setattr__(self, "segments", segments)
1305		object.__setattr__(self, "recipe", recipe)

Coerce any iterable of Motifs.

segments: Tuple[Motif, ...]
recipe: Optional[subsequence.motifs._PhraseRecipe]
length: float
1307	@property
1308	def length (self) -> float:
1309
1310		"""Total length in beats (sum of segment lengths)."""
1311
1312		return sum(segment.length for segment in self.segments)

Total length in beats (sum of segment lengths).

@classmethod
def develop( cls, motif: Motif, bars: int = 8, plan: Union[Sequence[str], str, NoneType] = None, seed: Optional[int] = None, beats_per_bar: float = 4.0) -> Phrase:
1314	@classmethod
1315	def develop (
1316		cls,
1317		motif: Motif,
1318		bars: int = 8,
1319		plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None,
1320		seed: typing.Optional[int] = None,
1321		beats_per_bar: float = 4.0,
1322	) -> "Phrase":
1323
1324		"""Grow a motif into a phrase by a plan — the phrase generator.
1325
1326		``plan`` follows the standard form.  The literal form is a **list of
1327		unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently
1328		``["a"] * 3 + ["b"]``: the first label is the given motif, each new
1329		label is a generated contrast unit (the source's rhythm, freshly
1330		re-pitched), a repeated label is a restatement, and *bars* spreads
1331		evenly across the units.  A bare string is a **recipe name** from
1332		the curated table — ``plan="call_response"`` (call, answer, call,
1333		varied answer) — reserved for plans whose semantics exceed a label
1334		skeleton.  A letter string is not a plan: a sequence of labels is a
1335		sequence, so it is a list.
1336
1337		The result carries its recipe, so :meth:`reroll` can regenerate a
1338		region later.
1339
1340		Parameters:
1341			motif: The source unit (its length must be ``bars / len(units)``
1342				bars — the plan's units tile the phrase exactly).
1343			bars: Phrase length in bars (must divide evenly by the unit
1344				count).
1345			plan: A list of unit labels, or a recipe name.
1346			seed: Seed for the generated units.  Without one, develop()
1347				warns — module-level nondeterminism breaks live reload.
1348			beats_per_bar: Bar size in beats (the value is context-free;
1349				4 is the common-time default).
1350
1351		Example:
1352			```python
1353			call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
1354			lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11)
1355			```
1356		"""
1357
1358		if plan is None:
1359			raise ValueError(
1360				'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) '
1361				'or a recipe name (plan="call_response")'
1362			)
1363
1364		if seed is None:
1365			warnings.warn(
1366				"develop() without seed= is nondeterministic — pass seed= so the "
1367				"value survives live reload",
1368				stacklevel = 2,
1369			)
1370
1371		if isinstance(plan, str):
1372
1373			if plan not in _PHRASE_RECIPES:
1374				known = ", ".join(sorted(_PHRASE_RECIPES))
1375				hint = ""
1376				if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan):
1377					spelled = ", ".join(repr(c) for c in plan)
1378					hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]."
1379				raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}")
1380
1381			units = _PHRASE_RECIPES[plan](motif, seed)
1382			stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan
1383
1384		else:
1385
1386			labels = list(plan)
1387
1388			if not labels or not all(isinstance(label, str) and label for label in labels):
1389				raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']")
1390
1391			generated: typing.Dict[str, Motif] = {labels[0]: motif}
1392
1393			for label in labels:
1394				if label not in generated:
1395					generated[label] = _contrast_unit(motif, random.Random(f"{seed}:unit:{label}"))
1396
1397			units = [generated[label] for label in labels]
1398			stored_plan = tuple(labels)
1399
1400		if bars % len(units) != 0:
1401			raise ValueError(
1402				f"bars={bars} does not divide evenly across {len(units)} plan units — "
1403				"each unit must fill a whole number of bars"
1404			)
1405
1406		unit_beats = bars * beats_per_bar / len(units)
1407
1408		if abs(motif.length - unit_beats) > 1e-9:
1409			raise ValueError(
1410				f"the motif is {motif.length:g} beats but each of the {len(units)} plan units "
1411				f"spans {unit_beats:g} beats ({bars} bars / {len(units)} units) — "
1412				"adjust bars, the plan, or the motif's length"
1413			)
1414
1415		return cls(units, recipe = _PhraseRecipe(
1416			source = motif,
1417			plan = stored_plan,
1418			bars = bars,
1419			seed = seed,
1420			beats_per_bar = beats_per_bar,
1421		))

Grow a motif into a phrase by a plan — the phrase generator.

plan follows the standard form. The literal form is a list of unit labelsplan=["a", "a", "a", "b"], equivalently ["a"] * 3 + ["b"]: the first label is the given motif, each new label is a generated contrast unit (the source's rhythm, freshly re-pitched), a repeated label is a restatement, and bars spreads evenly across the units. A bare string is a recipe name from the curated table — plan="call_response" (call, answer, call, varied answer) — reserved for plans whose semantics exceed a label skeleton. A letter string is not a plan: a sequence of labels is a sequence, so it is a list.

The result carries its recipe, so reroll() can regenerate a region later.

Arguments:
  • motif: The source unit (its length must be bars / len(units) bars — the plan's units tile the phrase exactly).
  • bars: Phrase length in bars (must divide evenly by the unit count).
  • plan: A list of unit labels, or a recipe name.
  • seed: Seed for the generated units. Without one, develop() warns — module-level nondeterminism breaks live reload.
  • beats_per_bar: Bar size in beats (the value is context-free; 4 is the common-time default).
Example:
call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3])
lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11)
def reroll( self, bar: Optional[int] = None, bars: Optional[Sequence[int]] = None, seed: Optional[int] = None) -> Phrase:
1423	def reroll (
1424		self,
1425		bar: typing.Optional[int] = None,
1426		bars: typing.Optional[typing.Sequence[int]] = None,
1427		seed: typing.Optional[int] = None,
1428	) -> "Phrase":
1429
1430		"""Regenerate only the named bars — rhythm and boundary pitches kept.
1431
1432		Within each named bar, the first and last pitched notes stay (the
1433		boundary pins) and the interior pitches re-roll from the recipe's
1434		stream; onsets, durations, velocities, rests, drums, and control
1435		gestures are untouched.  Segmentation and the recipe survive, so
1436		rerolls compose.
1437
1438		Only a phrase that carries a recipe can reroll — a hand-written or
1439		transformed phrase raises loudly (its notes no longer come from a
1440		generator, so regenerating them would invent music).
1441
1442		Parameters:
1443			bar: A single 1-based bar to reroll.
1444			bars: A list of 1-based bars (the paired plural spelling).
1445			seed: Seed for the new pitches (salted per bar).  Without one,
1446				reroll() warns.
1447
1448		Example:
1449			```python
1450			lead = lead.reroll(bar=7, seed=4)    # only bar 7; rhythm + boundaries kept
1451			```
1452		"""
1453
1454		if self.recipe is None:
1455			raise ValueError(
1456				"this phrase carries no recipe (it was written by hand, or transformed "
1457				"since generation) — reroll() regenerates from a recipe; edit segments "
1458				"with replace(), or rebuild with Phrase.develop()"
1459			)
1460
1461		if (bar is None) == (bars is None):
1462			raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)")
1463
1464		region = [bar] if bar is not None else list(bars or [])
1465		beats_per_bar = self.recipe.beats_per_bar
1466		total_bars = int(round(self.length / beats_per_bar))
1467
1468		for number in region:
1469			if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars:
1470				raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})")
1471
1472		if seed is None:
1473			warnings.warn(
1474				"reroll() without seed= is nondeterministic — pass seed= so the "
1475				"value survives live reload",
1476				stacklevel = 2,
1477			)
1478
1479		windows = [
1480			((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{seed}:reroll:{number}"))
1481			for number in sorted(set(region))
1482		]
1483
1484		new_segments: typing.List[Motif] = []
1485		offset = 0.0
1486
1487		for segment in self.segments:
1488
1489			events = list(segment.events)
1490
1491			for window_start, window_end, rng in windows:
1492
1493				inside = [
1494					index for index, event in enumerate(events)
1495					if window_start <= offset + event.beat < window_end
1496					and event.pitch is not None and not isinstance(event.pitch, str)
1497				]
1498
1499				# Boundary pins: the first and last pitched notes of the bar
1500				# stay; only the interior re-rolls.
1501				for index in inside[1:-1]:
1502					events[index] = dataclasses.replace(
1503						events[index],
1504						pitch = segment._nudged_pitch(events[index].pitch, rng),
1505					)
1506
1507			new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls))
1508			offset += segment.length
1509
1510		return Phrase(new_segments, recipe = self.recipe)

Regenerate only the named bars — rhythm and boundary pitches kept.

Within each named bar, the first and last pitched notes stay (the boundary pins) and the interior pitches re-roll from the recipe's stream; onsets, durations, velocities, rests, drums, and control gestures are untouched. Segmentation and the recipe survive, so rerolls compose.

Only a phrase that carries a recipe can reroll — a hand-written or transformed phrase raises loudly (its notes no longer come from a generator, so regenerating them would invent music).

Arguments:
  • bar: A single 1-based bar to reroll.
  • bars: A list of 1-based bars (the paired plural spelling).
  • seed: Seed for the new pitches (salted per bar). Without one, reroll() warns.
Example:
lead = lead.reroll(bar=7, seed=4)    # only bar 7; rhythm + boundaries kept
def flatten(self) -> Motif:
1512	def flatten (self) -> Motif:
1513
1514		"""Erase segmentation: one long Motif (the monoid homomorphism onto ``then``)."""
1515
1516		return Motif.join(self.segments)

Erase segmentation: one long Motif (the monoid homomorphism onto then).

def stack( self, other: Union[Motif, Phrase]) -> Motif:
1562	def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif:
1563
1564		"""The spelled form of ``&`` — flattens, then merges."""
1565
1566		return self.flatten().stack(other)

The spelled form of & — flattens, then merges.

def slice(self, start: float, end: float) -> Phrase:
1568	def slice (self, start: float, end: float) -> "Phrase":
1569
1570		"""A window; re-segments at the cut points (partial segments are sliced)."""
1571
1572		segments = []
1573		offset = 0.0
1574
1575		for segment in self.segments:
1576			seg_start, seg_end = offset, offset + segment.length
1577			lo, hi = max(start, seg_start), min(end, seg_end)
1578			if lo < hi:
1579				segments.append(segment.slice(lo - seg_start, hi - seg_start))
1580			offset = seg_end
1581
1582		return Phrase(segments)

A window; re-segments at the cut points (partial segments are sliced).

def replace( self, position: int, motif: Motif) -> Phrase:
1584	def replace (self, position: int, motif: Motif) -> "Phrase":
1585
1586		"""Replace the segment at a 1-based position (musicians count from one)."""
1587
1588		if not 1 <= position <= len(self.segments):
1589			raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)")
1590
1591		segments = list(self.segments)
1592		segments[position - 1] = motif
1593
1594		return Phrase(segments)

Replace the segment at a 1-based position (musicians count from one).

def reverse(self) -> Phrase:
1598	def reverse (self) -> "Phrase":
1599
1600		"""Reverse the whole timeline: segments reverse order AND each reverses internally."""
1601
1602		return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))

Reverse the whole timeline: segments reverse order AND each reverses internally.

def rotate(self, beats: float) -> Phrase:
1604	def rotate (self, beats: float) -> "Phrase":
1605
1606		"""Rotate the whole timeline modulo the total length, then re-segment at the original boundaries."""
1607
1608		flat = self.flatten().rotate(beats)
1609		segments = []
1610		offset = 0.0
1611
1612		# Re-segment by onset (events keep their full durations — a note may
1613		# ring past its new segment, exactly as it does on the flat timeline).
1614		for segment in self.segments:
1615			lo, hi = offset, offset + segment.length
1616			segments.append(Motif(
1617				events = tuple(
1618					dataclasses.replace(e, beat=e.beat - lo)
1619					for e in flat.events if lo <= e.beat < hi
1620				),
1621				length = segment.length,
1622				controls = tuple(
1623					dataclasses.replace(c, beat=c.beat - lo)
1624					for c in flat.controls if lo <= c.beat < hi
1625				),
1626			))
1627			offset = hi
1628
1629		return Phrase(segments)

Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.

def stretch(self, factor: float) -> Phrase:
1637	def stretch (self, factor: float) -> "Phrase":
1638
1639		"""Scale time in every segment (lengths scale with them)."""
1640
1641		return self._lift("stretch", factor)

Scale time in every segment (lengths scale with them).

def quantize(self, grid: float) -> Phrase:
1643	def quantize (self, grid: float) -> "Phrase":
1644
1645		"""Snap note onsets segment-wise."""
1646
1647		return self._lift("quantize", grid)

Snap note onsets segment-wise.

def with_velocity(self, velocity: Union[int, Tuple[int, int]]) -> Phrase:
1649	def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase":
1650
1651		"""Replace every note's velocity, segment-wise."""
1652
1653		return self._lift("with_velocity", velocity)

Replace every note's velocity, segment-wise.

def pitched( self, spec: Union[int, str, Degree, ChordTone, Approach, NoneType]) -> Phrase:
1655	def pitched (self, spec: PitchSpec) -> "Phrase":
1656
1657		"""Replace every pitch, segment-wise."""
1658
1659		return self._lift("pitched", spec)

Replace every pitch, segment-wise.

def rhythm(self) -> Phrase:
1661	def rhythm (self) -> "Phrase":
1662
1663		"""Strip pitches segment-wise: a phrase-shaped skeleton."""
1664
1665		return self._lift("rhythm")

Strip pitches segment-wise: a phrase-shaped skeleton.

def transpose( self, steps: Optional[int] = None, semitones: Optional[int] = None) -> Phrase:
1667	def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase":
1668
1669		"""Transpose every segment (see :meth:`Motif.transpose`)."""
1670
1671		return self._lift("transpose", steps=steps, semitones=semitones)

Transpose every segment (see Motif.transpose()).

def invert(self, pivot: Optional[int] = None) -> Phrase:
1673	def invert (self, pivot: typing.Optional[int] = None) -> "Phrase":
1674
1675		"""Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`)."""
1676
1677		if pivot is None:
1678			for segment in self.segments:
1679				for event in segment.events:
1680					if event.pitch is not None:
1681						if isinstance(event.pitch, int):
1682							pivot = event.pitch
1683						elif isinstance(event.pitch, Degree):
1684							pivot = event.pitch.step
1685						break
1686				if pivot is not None:
1687					break
1688
1689		return self._lift("invert", pivot=pivot)

Mirror pitches in every segment around one pivot (see Motif.invert()).

def describe(self) -> str:
1691	def describe (self) -> str:
1692
1693		"""A readable summary: total length and each segment on its own line."""
1694
1695		header = f"Phrase {self.length:g} beats, {len(self.segments)} segments"
1696		lines = [f"  {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)]
1697
1698		return "\n".join([header] + lines)

A readable summary: total length and each segment on its own line.

def motif( degrees: List[Union[int, Degree, NoneType]], beats: Optional[List[float]] = None, velocities: Any = 100, durations: Any = 1.0, probabilities: Any = 1.0, length: Optional[float] = None) -> Motif:
1707def motif (
1708	degrees: typing.List[typing.Union[int, Degree, None]],
1709	beats: typing.Optional[typing.List[float]] = None,
1710	velocities: typing.Any = _DEFAULT_VELOCITY,
1711	durations: typing.Any = 1.0,
1712	probabilities: typing.Any = 1.0,
1713	length: typing.Optional[float] = None,
1714) -> Motif:
1715
1716	"""
1717	The lowercase shortcut: a melody as 1-based scale degrees.
1718
1719	``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` —
1720	relative pitch is the primary form.  For absolute MIDI note numbers use
1721	``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so
1722	a pasted MIDI list fails loud instead of squealing octaves up.
1723	"""
1724
1725	return Motif.degrees(
1726		degrees,
1727		beats = beats,
1728		velocities = velocities,
1729		durations = durations,
1730		probabilities = probabilities,
1731		length = length,
1732	)

The lowercase shortcut: a melody as 1-based scale degrees.

subsequence.motif([5, 6, 5, 3]) is Motif.degrees([5, 6, 5, 3]) — relative pitch is the primary form. For absolute MIDI note numbers use Motif.notes([64, 65, 64, 60]); implausibly large ints here raise so a pasted MIDI list fails loud instead of squealing octaves up.

@dataclasses.dataclass(frozen=True)
class Degree:
60@dataclasses.dataclass(frozen=True)
61class Degree:
62
63	"""
64	A scale degree — 1-based, resolved against key + scale at placement.
65
66	Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the
67	scale length and resolve into higher octaves).  ``octave`` shifts whole
68	octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened).
69	"""
70
71	step: int
72	octave: int = 0
73	chroma: int = 0
74
75	def __post_init__ (self) -> None:
76
77		"""Validate that the degree is 1-based and plausibly a degree."""
78
79		if self.step < 1:
80			raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")

A scale degree — 1-based, resolved against key + scale at placement.

Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the scale length and resolve into higher octaves). octave shifts whole octaves; chroma is a chromatic offset in semitones (+1 = sharpened).

Degree(step: int, octave: int = 0, chroma: int = 0)
step: int
octave: int = 0
chroma: int = 0
@dataclasses.dataclass(frozen=True)
class ChordTone:
 83@dataclasses.dataclass(frozen=True)
 84class ChordTone:
 85
 86	"""
 87	An index into the current chord's tones — 1-based, resolved at placement.
 88
 89	Accepts an int (1 = root, 2 = third, ...) or one of the names
 90	``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``.  ``octave``
 91	shifts whole octaves.
 92	"""
 93
 94	index: int
 95	octave: int = 0
 96
 97	def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None:
 98
 99		"""Normalize a tone name to its 1-based index."""
100
101		if isinstance(index_or_name, str):
102			if index_or_name not in _CHORD_TONE_NAMES:
103				raise ValueError(
104					f"Unknown chord tone name '{index_or_name}' — "
105					f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index"
106				)
107			index = _CHORD_TONE_NAMES[index_or_name]
108		else:
109			index = index_or_name
110
111		if index < 1:
112			raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}")
113
114		object.__setattr__(self, "index", index)
115		object.__setattr__(self, "octave", octave)

An index into the current chord's tones — 1-based, resolved at placement.

Accepts an int (1 = root, 2 = third, ...) or one of the names "root" / "third" / "fifth" / "seventh". octave shifts whole octaves.

ChordTone(index_or_name: Union[int, str], octave: int = 0)
 97	def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None:
 98
 99		"""Normalize a tone name to its 1-based index."""
100
101		if isinstance(index_or_name, str):
102			if index_or_name not in _CHORD_TONE_NAMES:
103				raise ValueError(
104					f"Unknown chord tone name '{index_or_name}' — "
105					f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index"
106				)
107			index = _CHORD_TONE_NAMES[index_or_name]
108		else:
109			index = index_or_name
110
111		if index < 1:
112			raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}")
113
114		object.__setattr__(self, "index", index)
115		object.__setattr__(self, "octave", octave)

Normalize a tone name to its 1-based index.

index: int
octave: int = 0
@dataclasses.dataclass(frozen=True)
class Approach:
118@dataclasses.dataclass(frozen=True)
119class Approach:
120
121	"""
122	A half-step approach into a target pitch at the next chord boundary.
123
124	Parses today; resolution requires the harmony window and is not yet
125	available — placing a motif containing one raises with a clear message.
126	"""
127
128	target: typing.Union[int, Degree, ChordTone]

A half-step approach into a target pitch at the next chord boundary.

Parses today; resolution requires the harmony window and is not yet available — placing a motif containing one raises with a clear message.

Approach( target: Union[int, Degree, ChordTone])
target: Union[int, Degree, ChordTone]
@dataclasses.dataclass(frozen=True)
class MotifEvent:
228@dataclasses.dataclass(frozen=True)
229class MotifEvent:
230
231	"""
232	One timed note event inside a Motif.
233
234	``pitch`` is a specification: an absolute MIDI int, a drum name string,
235	a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None
236	for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which
237	must be re-pitched via :meth:`Motif.pitched` before placement.
238	``velocity`` is an int or a ``(low, high)`` random-range tuple.
239	"""
240
241	beat: float
242	pitch: PitchSpec
243	velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY
244	duration: float = 0.25
245	probability: float = 1.0
246
247	def __post_init__ (self) -> None:
248
249		"""Validate ranges that are wrong at any placement."""
250
251		if self.duration <= 0:
252			raise ValueError(f"Event duration must be positive — got {self.duration}")
253		if not 0.0 <= self.probability <= 1.0:
254			raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
255
256	def _sort_key (self) -> tuple:
257
258		"""Canonical ordering key — makes parallel merge order-independent."""
259
260		return (self.beat, _pitch_sort_key(self.pitch), _velocity_key(self.velocity), self.duration, self.probability)

One timed note event inside a Motif.

pitch is a specification: an absolute MIDI int, a drum name string, a Degree, ChordTone, or Approach — or None for a pitch-stripped skeleton event (see Motif.rhythm()), which must be re-pitched via Motif.pitched() before placement. velocity is an int or a (low, high) random-range tuple.

MotifEvent( beat: float, pitch: Union[int, str, Degree, ChordTone, Approach, NoneType], velocity: Union[int, Tuple[int, int]] = 100, duration: float = 0.25, probability: float = 1.0)
beat: float
pitch: Union[int, str, Degree, ChordTone, Approach, NoneType]
velocity: Union[int, Tuple[int, int]] = 100
duration: float = 0.25
probability: float = 1.0
@dataclasses.dataclass(frozen=True)
class ControlEvent:
263@dataclasses.dataclass(frozen=True)
264class ControlEvent:
265
266	"""
267	One timed control gesture inside a Motif: a discrete write or a shaped ramp.
268
269	A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates
270	``start`` → ``end`` over ``span`` beats through the easing ``shape``.
271	Pulse density (``resolution=``) is deliberately not stored here — beats
272	and shapes are music; MIDI traffic density is set at the placement call.
273	"""
274
275	beat: float
276	signal: ControlSignal
277	start: float
278	end: typing.Optional[float] = None
279	span: float = 0.0
280	shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear"
281	probability: float = 1.0
282
283	def __post_init__ (self) -> None:
284
285		"""Validate the discrete/ramp invariants."""
286
287		if (self.end is None) != (self.span == 0.0):
288			raise ValueError("A ramp needs both end= and span= (a discrete write has neither)")
289		if self.span < 0:
290			raise ValueError(f"Ramp span must be non-negative — got {self.span}")
291		if not 0.0 <= self.probability <= 1.0:
292			raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}")
293
294	def _sort_key (self) -> tuple:
295
296		"""Canonical ordering key — makes parallel merge order-independent."""
297
298		end = self.start if self.end is None else self.end
299		return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability)
300
301	def _value_at (self, fraction: float) -> float:
302
303		"""The interpolated value at a 0–1 fraction through the ramp."""
304
305		if self.end is None:
306			return self.start
307
308		easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape)
309		return self.start + (self.end - self.start) * easing_fn(max(0.0, min(1.0, fraction)))

One timed control gesture inside a Motif: a discrete write or a shaped ramp.

A discrete write has end=None and span=0.0; a ramp interpolates startend over span beats through the easing shape. Pulse density (resolution=) is deliberately not stored here — beats and shapes are music; MIDI traffic density is set at the placement call.

ControlEvent( beat: float, signal: Union[subsequence.motifs.CC, subsequence.motifs.PitchBend, subsequence.motifs.NRPN, subsequence.motifs.RPN, subsequence.motifs.OSC], start: float, end: Optional[float] = None, span: float = 0.0, shape: Union[str, Callable[[float], float]] = 'linear', probability: float = 1.0)
beat: float
start: float
end: Optional[float] = None
span: float = 0.0
shape: Union[str, Callable[[float], float]] = 'linear'
probability: float = 1.0
@dataclasses.dataclass(frozen=True)
class Progression:
 872@dataclasses.dataclass(frozen=True)
 873class Progression:
 874
 875	"""A frozen sequence of :class:`ChordSpan` — the governing harmony value.
 876
 877	Always a realised value: binding it to the clock freezes one realisation;
 878	``p.progression()`` keeps its breathing behaviour by re-realising a fresh
 879	one each rebuild.  Iterating yields ``(chord, start, length)``
 880	:class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so
 881	placement loops keep working unchanged.
 882
 883	The governing family supports ``+`` (concatenate) and ``*`` (tile) but
 884	never ``&`` — there is one current chord (P1, the type law).
 885
 886	Attributes:
 887		spans: The chord spans, in order.
 888		trailing_history: Engine continuity metadata set by
 889			:meth:`Composition.freeze` — the NIR history at capture time,
 890			restored on each frozen replay.  Empty for hand-built values.
 891	"""
 892
 893	spans: typing.Tuple[ChordSpan, ...]
 894	trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = ()
 895
 896	def __post_init__ (self) -> None:
 897
 898		"""Normalise span containers to tuples."""
 899
 900		object.__setattr__(self, "spans", tuple(self.spans))
 901		object.__setattr__(self, "trailing_history", tuple(self.trailing_history))
 902
 903		if not self.spans:
 904			raise ValueError("a Progression needs at least one chord span")
 905
 906	# -- queries ------------------------------------------------------------
 907
 908	@property
 909	def length (self) -> float:
 910
 911		"""Total length in beats (the sum of span lengths)."""
 912
 913		return float(sum(span.beats for span in self.spans))
 914
 915	@property
 916	def is_concrete (self) -> bool:
 917
 918		"""True when every span is key-independent (no romans/degrees)."""
 919
 920		return all(span.is_concrete for span in self.spans)
 921
 922	@property
 923	def chords (self) -> typing.Tuple[typing.Any, ...]:
 924
 925		"""The bare chords, one per span (concrete progressions only)."""
 926
 927		self._require_concrete("read .chords")
 928
 929		return tuple(span.chord for span in self.spans)
 930
 931	@property
 932	def loops_on_exhaustion (self) -> bool:
 933
 934		"""True when the clock must loop rather than fall through to live stepping."""
 935
 936		return any(isinstance(span.chord, PitchSet) for span in self.spans)
 937
 938	def _require_concrete (self, action: str) -> None:
 939
 940		"""Raise with a resolution hint when key-relative spans remain."""
 941
 942		if not self.is_concrete:
 943			relative = ", ".join(span.label() for span in self.spans if not span.is_concrete)
 944			raise ValueError(
 945				f"cannot {action} on a key-relative progression (contains {relative}) — "
 946				"call .resolve(key=...) first, or bind it where a key is known"
 947			)
 948
 949	def __iter__ (self) -> typing.Iterator[ChordEvent]:
 950
 951		"""Yield ``(chord, start, length)`` events — decorated chords where spiced."""
 952
 953		self._require_concrete("iterate")
 954
 955		cursor = 0.0
 956
 957		for span in self.spans:
 958			chord = DecoratedChord(span) if span.is_decorated else span.chord
 959			yield ChordEvent(chord=chord, start=cursor, length=span.beats)
 960			cursor += span.beats
 961
 962	def __len__ (self) -> int:
 963
 964		"""The number of chord spans."""
 965
 966		return len(self.spans)
 967
 968	def events (self) -> typing.Tuple[ChordEvent, ...]:
 969
 970		"""The realised timeline as a tuple (iteration, materialised)."""
 971
 972		return tuple(self)
 973
 974	def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]:
 975
 976		"""Return ``(span, start, end)`` for the span sounding at *beat*.
 977
 978		*beat* wraps modulo the progression length, so the lookup also
 979		serves looped playback.
 980		"""
 981
 982		position = beat % self.length
 983		cursor = 0.0
 984
 985		for span in self.spans:
 986			if cursor <= position < cursor + span.beats:
 987				return span, cursor, cursor + span.beats
 988			cursor += span.beats
 989
 990		final = self.spans[-1]
 991		return final, self.length - final.beats, self.length
 992
 993	def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression":
 994
 995		"""Resolve every key-relative span against a key (name or pitch class)."""
 996
 997		key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)
 998
 999		return dataclasses.replace(
1000			self,
1001			spans = tuple(span.resolve(key_pc, scale) for span in self.spans),
1002		)
1003
1004	@classmethod
1005	def generate (
1006		cls,
1007		style: typing.Union[str, typing.Any] = "functional_major",
1008		bars: int = 8,
1009		beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS,
1010		*,
1011		key: typing.Optional[str] = None,
1012		scale: typing.Optional[str] = None,
1013		seed: typing.Optional[int] = None,
1014		rng: typing.Optional[random.Random] = None,
1015		pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1016		end: typing.Optional[typing.Any] = None,
1017		avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1018		dominant_7th: bool = True,
1019		gravity: float = 1.0,
1020		nir_strength: float = 0.5,
1021		minor_turnaround_weight: float = 0.0,
1022		root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1023	) -> "Progression":
1024
1025		"""Generate a progression from a chord-graph walk — the hybrid generator.
1026
1027		Full parameter pass-through to the engine (no more throwaway default
1028		engines), plus the hybrid constraints: ``pins`` fix chords at 1-based
1029		bars, ``end`` fixes the last bar, ``avoid`` excludes chords
1030		everywhere.  Constraints compile into the walk — a backward
1031		feasibility pass guarantees satisfiability before any chord is
1032		drawn (unsatisfiable constraints raise immediately), then a forward
1033		walk samples through the engine's real history-dependent weights
1034		(NIR, gravity, diversity keep their character).
1035
1036		**Without** ``key=`` the result is key-relative — the walk runs
1037		against a reference tonic and the spans store scale-proof
1038		major-relative romans, so the value prints meaningfully unbound and
1039		resolves wherever it is bound (the walk itself is key-invariant).
1040		**With** ``key=`` the result is concrete.
1041
1042		Parameters:
1043			style: A chord-graph style name (or ``ChordGraph`` instance).
1044			bars: How many chords to generate.
1045			beats: Span length per chord — a scalar, or a list cycled.
1046			key: Key for a concrete result; omit for a key-relative value.
1047			scale: Scale for int constraints' quality inference (e.g.
1048				``end=1``).  Defaults from the style (aeolian_minor →
1049				minor); explicit strings (``"V"``, ``"bVII7"``) never
1050				need it.
1051			seed: Seed for the walk.  A standalone generated value without
1052				a seed warns — module-level nondeterminism breaks live
1053				reload.
1054			rng: An explicit random stream (overrides ``seed``).
1055			pins: ``{bar: chord}`` — 1-based; values parse like progression
1056				elements (ints, romans, names, ``Chord``).
1057			end: The chord at the final bar — ``end="V"`` is the cadential
1058				major dominant in minor (a string because it is chromatic;
1059				no int can ask for it).
1060			avoid: Chords excluded from the walk.  Naming a chord outside
1061				the style's vocabulary is allowed (trivially satisfied).
1062			dominant_7th / gravity / nir_strength / minor_turnaround_weight /
1063				root_diversity: The engine parameters, exactly as
1064				:meth:`Composition.harmony` takes them.
1065
1066		Example:
1067			```python
1068			chorus = subsequence.Progression.generate(
1069				style="aeolian_minor", bars=4, end="V", seed=7,
1070			)
1071			print(chorus)        # romans until bound
1072			```
1073		"""
1074
1075		if bars < 1:
1076			raise ValueError("bars must be at least 1")
1077
1078		if rng is None:
1079			if seed is None:
1080				warnings.warn(
1081					"Progression.generate without seed= is nondeterministic — "
1082					"pass seed= so the value survives live reload",
1083					stacklevel = 2,
1084				)
1085				rng = random.Random()
1086			else:
1087				rng = random.Random(seed)
1088
1089		resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian")
1090		relative = key is None
1091		reference = key if key is not None else "C"
1092
1093		state = subsequence.harmonic_state.HarmonicState(
1094			key_name = reference,
1095			graph_style = style,
1096			include_dominant_7th = dominant_7th,
1097			key_gravity_blend = gravity,
1098			nir_strength = nir_strength,
1099			minor_turnaround_weight = minor_turnaround_weight,
1100			root_diversity = root_diversity,
1101			rng = rng,
1102		)
1103
1104		resolved_pins = {
1105			position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]")
1106			for position, spec in (pins or {}).items()
1107		}
1108		resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None
1109		resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])]
1110
1111		if 1 in resolved_pins:
1112			if resolved_pins[1] not in state.graph.nodes():
1113				raise ValueError(
1114					f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary"
1115				)
1116			state.current_chord = resolved_pins[1]
1117
1118		def commit (chosen: subsequence.chords.Chord) -> None:
1119			state.current_chord = chosen
1120
1121		walked = subsequence.sequence_utils.constrained_walk(
1122			state.graph,
1123			state.current_chord,
1124			bars,
1125			rng = state.rng,
1126			pins = resolved_pins,
1127			end = resolved_end,
1128			avoid = resolved_avoid,
1129			weight_modifier = state._transition_weight,
1130			before_choice = state._record_transition_source,
1131			after_choice = commit,
1132		)
1133
1134		lengths = _span_lengths(beats, bars)
1135
1136		if relative:
1137			return cls(spans = tuple(
1138				ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index])
1139				for index, chord in enumerate(walked)
1140			))
1141
1142		return cls(spans = tuple(
1143			ChordSpan(chord = chord, beats = lengths[index])
1144			for index, chord in enumerate(walked)
1145		))
1146
1147	# -- algebra ------------------------------------------------------------
1148
1149	def __add__ (self, other: "Progression") -> "Progression":
1150
1151		"""Concatenate two progressions (the governing ``+``)."""
1152
1153		if not isinstance(other, Progression):
1154			return NotImplemented
1155
1156		return Progression(spans = self.spans + other.spans)
1157
1158	def __mul__ (self, count: int) -> "Progression":
1159
1160		"""Tile the spans *count* times."""
1161
1162		if not isinstance(count, int) or isinstance(count, bool):
1163			return NotImplemented
1164		if count < 1:
1165			raise ValueError("a progression must repeat at least once (n >= 1)")
1166
1167		return Progression(spans = self.spans * count)
1168
1169	def __and__ (self, other: typing.Any) -> "Progression":
1170
1171		"""Parallel merge is a type error for governing values — by design."""
1172
1173		raise TypeError(
1174			"Progressions cannot be merged with & — there is one current chord. "
1175			"Sequence them with +, or give a pattern its own part-level progression."
1176		)
1177
1178	# -- spice (the five operators) and editing ------------------------------
1179
1180	def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression":
1181
1182		"""Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span.
1183
1184		``only=`` restricts the spice to the given 1-based chord slots.
1185		"""
1186
1187		slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1188
1189		spans = tuple(
1190			dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions)))
1191			if index in slots else span
1192			for index, span in enumerate(self.spans)
1193		)
1194
1195		return dataclasses.replace(self, spans=spans)
1196
1197	def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression":
1198
1199		"""Set chord inversions — a single int for all spans, or a list cycled per span."""
1200
1201		values = [spec] if isinstance(spec, int) else list(spec)
1202
1203		if not values:
1204			raise ValueError("inversions list is empty — pass at least one inversion")
1205
1206		spans = tuple(
1207			dataclasses.replace(span, inversion = int(values[index % len(values)]))
1208			for index, span in enumerate(self.spans)
1209		)
1210
1211		return dataclasses.replace(self, spans=spans)
1212
1213	def spread (self, style: str) -> "Progression":
1214
1215		"""Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``."""
1216
1217		spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans)
1218
1219		return dataclasses.replace(self, spans=spans)
1220
1221	def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression":
1222
1223		"""Put the progression over a slash/pedal bass — *the* trance/techno move.
1224
1225		*bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``
1226		(resolved against the key at query time).  ``only=`` restricts it to
1227		the given 1-based slots (slash chords rather than a full pedal).
1228		"""
1229
1230		if isinstance(bass, str) and bass != "tonic":
1231			subsequence.chords.key_name_to_pc(bass)	# validate early; resolution stays late
1232		elif isinstance(bass, int) and not 0 <= bass <= 11:
1233			raise ValueError(f"a bass pitch class must be 0–11, got {bass}")
1234
1235		slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1236
1237		spans = tuple(
1238			dataclasses.replace(span, bass=bass) if index in slots else span
1239			for index, span in enumerate(self.spans)
1240		)
1241
1242		return dataclasses.replace(self, spans=spans)
1243
1244	def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression":
1245
1246		"""Borrow the chord(s) at the given 1-based slot(s) from the parallel scale.
1247
1248		Modal interchange for key-relative content: the degree re-resolves
1249		against the parallel mode (minor under a major scale and vice
1250		versa).  Concrete chords raise — there is nothing relative to borrow.
1251		"""
1252
1253		slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)}
1254
1255		spans = list(self.spans)
1256
1257		for index in slots:
1258			chord = spans[index].chord
1259			if not isinstance(chord, RomanChord):
1260				raise ValueError(
1261					f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — "
1262					"borrow() needs key-relative content (an int degree or roman)"
1263				)
1264			spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed))
1265
1266		return dataclasses.replace(self, spans=tuple(spans))
1267
1268	def replace (self, slot: int, chord: typing.Any) -> "Progression":
1269
1270		"""Replace the chord at a 1-based slot (the span keeps its beats)."""
1271
1272		index = _check_slot(slot, len(self.spans))
1273		parsed = parse_element(chord, beats = self.spans[index].beats)
1274
1275		spans = self.spans[:index] + (parsed,) + self.spans[index + 1:]
1276
1277		return dataclasses.replace(self, spans=spans)
1278
1279	def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression":
1280
1281		"""Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span."""
1282
1283		if isinstance(beats, bool):
1284			raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}")
1285
1286		values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats]
1287
1288		if not values:
1289			raise ValueError("with_rhythm list is empty — pass at least one length")
1290
1291		spans = tuple(
1292			dataclasses.replace(span, beats = float(values[index % len(values)]))
1293			for index, span in enumerate(self.spans)
1294		)
1295
1296		return dataclasses.replace(self, spans=spans)
1297
1298	# -- description ----------------------------------------------------------
1299
1300	def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str:
1301
1302		"""A readable, one-chord-per-line summary.
1303
1304		Key-relative spans print as written (romans/degrees) when unbound,
1305		and as concrete chord names under a *key*.
1306		"""
1307
1308		key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key))
1309
1310		lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"]
1311		cursor = 0.0
1312
1313		for span in self.spans:
1314			lines.append(
1315				f"  {cursor:6.2f}{cursor + span.beats:6.2f}   "
1316				f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)"
1317			)
1318			cursor += span.beats
1319
1320		return "\n".join(lines)
1321
1322	def __str__ (self) -> str:
1323
1324		"""Same as :meth:`describe` with no key bound."""
1325
1326		return self.describe()

A frozen sequence of 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) 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 Composition.freeze() — the NIR history at capture time, restored on each frozen replay. Empty for hand-built values.
Progression( spans: Tuple[ChordSpan, ...], trailing_history: Tuple[Chord, ...] = ())
spans: Tuple[ChordSpan, ...]
trailing_history: Tuple[Chord, ...] = ()
length: float
908	@property
909	def length (self) -> float:
910
911		"""Total length in beats (the sum of span lengths)."""
912
913		return float(sum(span.beats for span in self.spans))

Total length in beats (the sum of span lengths).

is_concrete: bool
915	@property
916	def is_concrete (self) -> bool:
917
918		"""True when every span is key-independent (no romans/degrees)."""
919
920		return all(span.is_concrete for span in self.spans)

True when every span is key-independent (no romans/degrees).

chords: Tuple[Any, ...]
922	@property
923	def chords (self) -> typing.Tuple[typing.Any, ...]:
924
925		"""The bare chords, one per span (concrete progressions only)."""
926
927		self._require_concrete("read .chords")
928
929		return tuple(span.chord for span in self.spans)

The bare chords, one per span (concrete progressions only).

loops_on_exhaustion: bool
931	@property
932	def loops_on_exhaustion (self) -> bool:
933
934		"""True when the clock must loop rather than fall through to live stepping."""
935
936		return any(isinstance(span.chord, PitchSet) for span in self.spans)

True when the clock must loop rather than fall through to live stepping.

def events(self) -> Tuple[subsequence.progressions.ChordEvent, ...]:
968	def events (self) -> typing.Tuple[ChordEvent, ...]:
969
970		"""The realised timeline as a tuple (iteration, materialised)."""
971
972		return tuple(self)

The realised timeline as a tuple (iteration, materialised).

def span_at( self, beat: float) -> Tuple[ChordSpan, float, float]:
974	def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]:
975
976		"""Return ``(span, start, end)`` for the span sounding at *beat*.
977
978		*beat* wraps modulo the progression length, so the lookup also
979		serves looped playback.
980		"""
981
982		position = beat % self.length
983		cursor = 0.0
984
985		for span in self.spans:
986			if cursor <= position < cursor + span.beats:
987				return span, cursor, cursor + span.beats
988			cursor += span.beats
989
990		final = self.spans[-1]
991		return final, self.length - final.beats, self.length

Return (span, start, end) for the span sounding at beat.

beat wraps modulo the progression length, so the lookup also serves looped playback.

def resolve( self, key: Union[str, int], scale: str = 'ionian') -> Progression:
 993	def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression":
 994
 995		"""Resolve every key-relative span against a key (name or pitch class)."""
 996
 997		key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)
 998
 999		return dataclasses.replace(
1000			self,
1001			spans = tuple(span.resolve(key_pc, scale) for span in self.spans),
1002		)

Resolve every key-relative span against a key (name or pitch class).

@classmethod
def generate( cls, style: Union[str, Any] = 'functional_major', bars: int = 8, beats: Union[float, List[float]] = 4.0, *, key: Optional[str] = None, scale: Optional[str] = None, seed: Optional[int] = None, rng: Optional[random.Random] = None, pins: Optional[Dict[int, Any]] = None, end: Optional[Any] = None, avoid: Optional[Sequence[Any]] = None, dominant_7th: bool = True, gravity: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = 0.4) -> Progression:
1004	@classmethod
1005	def generate (
1006		cls,
1007		style: typing.Union[str, typing.Any] = "functional_major",
1008		bars: int = 8,
1009		beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS,
1010		*,
1011		key: typing.Optional[str] = None,
1012		scale: typing.Optional[str] = None,
1013		seed: typing.Optional[int] = None,
1014		rng: typing.Optional[random.Random] = None,
1015		pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1016		end: typing.Optional[typing.Any] = None,
1017		avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1018		dominant_7th: bool = True,
1019		gravity: float = 1.0,
1020		nir_strength: float = 0.5,
1021		minor_turnaround_weight: float = 0.0,
1022		root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1023	) -> "Progression":
1024
1025		"""Generate a progression from a chord-graph walk — the hybrid generator.
1026
1027		Full parameter pass-through to the engine (no more throwaway default
1028		engines), plus the hybrid constraints: ``pins`` fix chords at 1-based
1029		bars, ``end`` fixes the last bar, ``avoid`` excludes chords
1030		everywhere.  Constraints compile into the walk — a backward
1031		feasibility pass guarantees satisfiability before any chord is
1032		drawn (unsatisfiable constraints raise immediately), then a forward
1033		walk samples through the engine's real history-dependent weights
1034		(NIR, gravity, diversity keep their character).
1035
1036		**Without** ``key=`` the result is key-relative — the walk runs
1037		against a reference tonic and the spans store scale-proof
1038		major-relative romans, so the value prints meaningfully unbound and
1039		resolves wherever it is bound (the walk itself is key-invariant).
1040		**With** ``key=`` the result is concrete.
1041
1042		Parameters:
1043			style: A chord-graph style name (or ``ChordGraph`` instance).
1044			bars: How many chords to generate.
1045			beats: Span length per chord — a scalar, or a list cycled.
1046			key: Key for a concrete result; omit for a key-relative value.
1047			scale: Scale for int constraints' quality inference (e.g.
1048				``end=1``).  Defaults from the style (aeolian_minor →
1049				minor); explicit strings (``"V"``, ``"bVII7"``) never
1050				need it.
1051			seed: Seed for the walk.  A standalone generated value without
1052				a seed warns — module-level nondeterminism breaks live
1053				reload.
1054			rng: An explicit random stream (overrides ``seed``).
1055			pins: ``{bar: chord}`` — 1-based; values parse like progression
1056				elements (ints, romans, names, ``Chord``).
1057			end: The chord at the final bar — ``end="V"`` is the cadential
1058				major dominant in minor (a string because it is chromatic;
1059				no int can ask for it).
1060			avoid: Chords excluded from the walk.  Naming a chord outside
1061				the style's vocabulary is allowed (trivially satisfied).
1062			dominant_7th / gravity / nir_strength / minor_turnaround_weight /
1063				root_diversity: The engine parameters, exactly as
1064				:meth:`Composition.harmony` takes them.
1065
1066		Example:
1067			```python
1068			chorus = subsequence.Progression.generate(
1069				style="aeolian_minor", bars=4, end="V", seed=7,
1070			)
1071			print(chorus)        # romans until bound
1072			```
1073		"""
1074
1075		if bars < 1:
1076			raise ValueError("bars must be at least 1")
1077
1078		if rng is None:
1079			if seed is None:
1080				warnings.warn(
1081					"Progression.generate without seed= is nondeterministic — "
1082					"pass seed= so the value survives live reload",
1083					stacklevel = 2,
1084				)
1085				rng = random.Random()
1086			else:
1087				rng = random.Random(seed)
1088
1089		resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian")
1090		relative = key is None
1091		reference = key if key is not None else "C"
1092
1093		state = subsequence.harmonic_state.HarmonicState(
1094			key_name = reference,
1095			graph_style = style,
1096			include_dominant_7th = dominant_7th,
1097			key_gravity_blend = gravity,
1098			nir_strength = nir_strength,
1099			minor_turnaround_weight = minor_turnaround_weight,
1100			root_diversity = root_diversity,
1101			rng = rng,
1102		)
1103
1104		resolved_pins = {
1105			position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]")
1106			for position, spec in (pins or {}).items()
1107		}
1108		resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None
1109		resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])]
1110
1111		if 1 in resolved_pins:
1112			if resolved_pins[1] not in state.graph.nodes():
1113				raise ValueError(
1114					f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary"
1115				)
1116			state.current_chord = resolved_pins[1]
1117
1118		def commit (chosen: subsequence.chords.Chord) -> None:
1119			state.current_chord = chosen
1120
1121		walked = subsequence.sequence_utils.constrained_walk(
1122			state.graph,
1123			state.current_chord,
1124			bars,
1125			rng = state.rng,
1126			pins = resolved_pins,
1127			end = resolved_end,
1128			avoid = resolved_avoid,
1129			weight_modifier = state._transition_weight,
1130			before_choice = state._record_transition_source,
1131			after_choice = commit,
1132		)
1133
1134		lengths = _span_lengths(beats, bars)
1135
1136		if relative:
1137			return cls(spans = tuple(
1138				ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index])
1139				for index, chord in enumerate(walked)
1140			))
1141
1142		return cls(spans = tuple(
1143			ChordSpan(chord = chord, beats = lengths[index])
1144			for index, chord in enumerate(walked)
1145		))

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.

Arguments:
  • 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).
  • dominant_7th / gravity / nir_strength / minor_turnaround_weight / root_diversity: The engine parameters, exactly as Composition.harmony() takes them.
Example:
chorus = subsequence.Progression.generate(
        style="aeolian_minor", bars=4, end="V", seed=7,
)
print(chorus)        # romans until bound
def extend( self, *extensions: Any, only: Optional[List[int]] = None) -> Progression:
1180	def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression":
1181
1182		"""Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span.
1183
1184		``only=`` restricts the spice to the given 1-based chord slots.
1185		"""
1186
1187		slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1188
1189		spans = tuple(
1190			dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions)))
1191			if index in slots else span
1192			for index, span in enumerate(self.spans)
1193		)
1194
1195		return dataclasses.replace(self, spans=spans)

Add chord extensions (7/9/11/13/"sus4"/...) to every span.

only= restricts the spice to the given 1-based chord slots.

def inversions( self, spec: Union[int, List[int]]) -> Progression:
1197	def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression":
1198
1199		"""Set chord inversions — a single int for all spans, or a list cycled per span."""
1200
1201		values = [spec] if isinstance(spec, int) else list(spec)
1202
1203		if not values:
1204			raise ValueError("inversions list is empty — pass at least one inversion")
1205
1206		spans = tuple(
1207			dataclasses.replace(span, inversion = int(values[index % len(values)]))
1208			for index, span in enumerate(self.spans)
1209		)
1210
1211		return dataclasses.replace(self, spans=spans)

Set chord inversions — a single int for all spans, or a list cycled per span.

def spread(self, style: str) -> Progression:
1213	def spread (self, style: str) -> "Progression":
1214
1215		"""Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``."""
1216
1217		spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans)
1218
1219		return dataclasses.replace(self, spans=spans)

Set the voicing spread: "close", "open" (drop-2), or "wide".

def over( self, bass: Union[int, str], only: Optional[List[int]] = None) -> Progression:
1221	def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression":
1222
1223		"""Put the progression over a slash/pedal bass — *the* trance/techno move.
1224
1225		*bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``
1226		(resolved against the key at query time).  ``only=`` restricts it to
1227		the given 1-based slots (slash chords rather than a full pedal).
1228		"""
1229
1230		if isinstance(bass, str) and bass != "tonic":
1231			subsequence.chords.key_name_to_pc(bass)	# validate early; resolution stays late
1232		elif isinstance(bass, int) and not 0 <= bass <= 11:
1233			raise ValueError(f"a bass pitch class must be 0–11, got {bass}")
1234
1235		slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only}
1236
1237		spans = tuple(
1238			dataclasses.replace(span, bass=bass) if index in slots else span
1239			for index, span in enumerate(self.spans)
1240		)
1241
1242		return dataclasses.replace(self, spans=spans)

Put the progression over a slash/pedal bass — the trance/techno move.

bass is a pitch class int, a note name ("G"), or "tonic" (resolved against the key at query time). only= restricts it to the given 1-based slots (slash chords rather than a full pedal).

def borrow( self, slot: Union[int, List[int]]) -> Progression:
1244	def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression":
1245
1246		"""Borrow the chord(s) at the given 1-based slot(s) from the parallel scale.
1247
1248		Modal interchange for key-relative content: the degree re-resolves
1249		against the parallel mode (minor under a major scale and vice
1250		versa).  Concrete chords raise — there is nothing relative to borrow.
1251		"""
1252
1253		slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)}
1254
1255		spans = list(self.spans)
1256
1257		for index in slots:
1258			chord = spans[index].chord
1259			if not isinstance(chord, RomanChord):
1260				raise ValueError(
1261					f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — "
1262					"borrow() needs key-relative content (an int degree or roman)"
1263				)
1264			spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed))
1265
1266		return dataclasses.replace(self, spans=tuple(spans))

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.

def replace(self, slot: int, chord: Any) -> Progression:
1268	def replace (self, slot: int, chord: typing.Any) -> "Progression":
1269
1270		"""Replace the chord at a 1-based slot (the span keeps its beats)."""
1271
1272		index = _check_slot(slot, len(self.spans))
1273		parsed = parse_element(chord, beats = self.spans[index].beats)
1274
1275		spans = self.spans[:index] + (parsed,) + self.spans[index + 1:]
1276
1277		return dataclasses.replace(self, spans=spans)

Replace the chord at a 1-based slot (the span keeps its beats).

def with_rhythm( self, beats: Union[float, List[float]]) -> Progression:
1279	def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression":
1280
1281		"""Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span."""
1282
1283		if isinstance(beats, bool):
1284			raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}")
1285
1286		values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats]
1287
1288		if not values:
1289			raise ValueError("with_rhythm list is empty — pass at least one length")
1290
1291		spans = tuple(
1292			dataclasses.replace(span, beats = float(values[index % len(values)]))
1293			for index, span in enumerate(self.spans)
1294		)
1295
1296		return dataclasses.replace(self, spans=spans)

Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.

def describe( self, key: Union[int, str, NoneType] = None, scale: str = 'ionian') -> str:
1300	def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str:
1301
1302		"""A readable, one-chord-per-line summary.
1303
1304		Key-relative spans print as written (romans/degrees) when unbound,
1305		and as concrete chord names under a *key*.
1306		"""
1307
1308		key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key))
1309
1310		lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"]
1311		cursor = 0.0
1312
1313		for span in self.spans:
1314			lines.append(
1315				f"  {cursor:6.2f}{cursor + span.beats:6.2f}   "
1316				f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)"
1317			)
1318			cursor += span.beats
1319
1320		return "\n".join(lines)

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.

@dataclasses.dataclass(frozen=True)
class ChordSpan:
384@dataclasses.dataclass(frozen=True)
385class ChordSpan:
386
387	"""One chord with a duration and its decoration — the unit of harmonic time.
388
389	Decoration (extensions, slash bass, inversion, spread) lives HERE, never
390	on :class:`~subsequence.chords.Chord`: the engine's graph identity stays
391	the bare triad, and the decorated voicing is what patterns hear.
392
393	Attributes:
394		chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a
395			:class:`PitchSet`.
396		beats: Span length in beats.
397		extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``)
398			or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``).
399		bass: Slash/pedal bass — a pitch class int, a note name, or
400			``"tonic"`` (resolved against the key at query time).
401		inversion: Chord inversion for the voicing (0 = root position).
402		spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2),
403			or ``"wide"`` (drop-2-and-4).
404		extension_intervals: Pre-computed semitone offsets for the
405			extensions, set by :meth:`Progression.resolve` for diatonic
406			degrees.  ``None`` means "derive from the chord's own colour".
407	"""
408
409	chord: typing.Any
410	beats: float
411	extensions: typing.Tuple[typing.Any, ...] = ()
412	bass: typing.Optional[typing.Union[int, str]] = None
413	inversion: int = 0
414	spread: typing.Optional[str] = None
415	extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None
416
417	def __post_init__ (self) -> None:
418
419		"""Validate beats, extensions, and spread."""
420
421		if self.beats <= 0:
422			raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}")
423
424		for extension in self.extensions:
425			if isinstance(extension, bool) or not (
426				(isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS)
427				or (isinstance(extension, str) and extension in _EXTENSION_NAMES)
428			):
429				known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES))
430				raise ValueError(f"unknown extension {extension!r} — expected one of: {known}")
431
432		if self.spread is not None and self.spread not in _SPREAD_STYLES:
433			raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES)))
434
435	@property
436	def is_concrete (self) -> bool:
437
438		"""True when the chord needs no key context to sound."""
439
440		return not isinstance(self.chord, RomanChord)
441
442	@property
443	def is_decorated (self) -> bool:
444
445		"""True when the span carries any decoration beyond the bare chord."""
446
447		return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None
448
449	def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan":
450
451		"""Return a concrete span: romans resolved, bass resolved to a pitch class."""
452
453		chord = self.chord
454		extension_intervals = self.extension_intervals
455
456		if isinstance(chord, RomanChord):
457			if chord.quality is None and any(isinstance(e, int) for e in self.extensions):
458				extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions)
459			chord = chord.resolve(key_pc, scale)
460
461		bass: typing.Optional[typing.Union[int, str]] = self.bass
462
463		if isinstance(bass, str):
464			if bass == "tonic":
465				bass = key_pc
466			else:
467				bass = subsequence.chords.key_name_to_pc(bass)
468
469		return dataclasses.replace(
470			self,
471			chord = chord,
472			bass = bass,
473			extension_intervals = extension_intervals,
474		)
475
476	def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str:
477
478		"""A printable chord label: roman text when relative, decorated name when concrete."""
479
480		if isinstance(self.chord, RomanChord):
481			if key_pc is None:
482				text = self.chord.label()
483				return text + self._decoration_suffix(resolved=False)
484			return self.resolve(key_pc, scale).label()
485
486		base = str(self.chord.name())
487		return base + self._decoration_suffix(resolved=True)
488
489	def _decoration_suffix (self, resolved: bool) -> str:
490
491		"""The printable decoration tail (extensions and slash bass)."""
492
493		parts = ""
494		numeric = sorted(e for e in self.extensions if isinstance(e, int))
495
496		# 9 implies 7 (and so on up): print only the highest stacked extension.
497		stacked = [e for e in numeric if e in (7, 9, 11, 13)]
498		if stacked:
499			parts += str(stacked[-1])
500
501		for name in (e for e in self.extensions if isinstance(e, str)):
502			parts += name
503
504		if self.bass is not None:
505			if isinstance(self.bass, int):
506				parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12]
507			else:
508				parts += "/" + str(self.bass)
509
510		return parts
511
512	def decorated_intervals (self) -> typing.List[int]:
513
514		"""Semitone offsets of the decorated voicing (before inversion/spread/bass).
515
516		Numeric extensions deepen the chord in its own colour — a minor third
517		gets a minor seventh, a major third a major seventh, a diminished
518		triad a diminished seventh.  Diatonic degrees extended with
519		``extend(...)`` carry pre-computed scale-true intervals instead (so V
520		gets its dominant seventh).  Write ``"G7"``/``"V7"`` when you want the
521		dominant colour on a concrete major chord.
522		"""
523
524		if isinstance(self.chord, RomanChord):
525			raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
526
527		intervals = list(self.chord.intervals())
528
529		sus = [e for e in self.extensions if e in ("sus2", "sus4")]
530		if sus and len(intervals) >= 2:
531			intervals[1] = 2 if sus[0] == "sus2" else 5
532
533		numeric = sorted(e for e in self.extensions if isinstance(e, int))
534
535		if self.extension_intervals is not None:
536			added: typing.List[int] = list(self.extension_intervals)
537		else:
538			added = []
539			third = intervals[1] if len(intervals) >= 2 else None
540			has_seventh = any(i in (9, 10, 11) for i in intervals)
541			stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS]
542
543			if stacked and not has_seventh:
544				if third == 3 and len(intervals) >= 3 and intervals[2] == 6:
545					added.append(9)		# diminished colour
546				elif third == 3:
547					added.append(10)	# minor colour
548				elif third == 4:
549					added.append(11)	# major colour
550				else:
551					added.append(10)	# sus / no third: the dominant-leaning seventh
552
553			for extension in stacked:
554				if extension == 9:
555					added.append(14)
556				elif extension == 11:
557					added.append(17)
558				elif extension == 13:
559					added.append(21)
560
561		if "add9" in self.extensions:
562			added.append(14)
563		if "6" in self.extensions:
564			added.append(9)
565
566		return sorted(set(intervals) | set(added))
567
568	def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]:
569
570		"""MIDI notes of the decorated voicing nearest *root* (concrete spans only).
571
572		Applies, in order: extensions, inversion, spread, then the slash/pedal
573		bass below the voicing.  ``PitchSet`` spans return their absolute
574		pitches (decoration other than ``count`` does not apply).
575		"""
576
577		if isinstance(self.chord, RomanChord):
578			raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
579
580		if isinstance(self.chord, PitchSet):
581			return self.chord.tones(root, inversion=self.inversion, count=count)
582
583		intervals = self.decorated_intervals()
584
585		if self.inversion != 0:
586			intervals = subsequence.voicings.invert_chord(intervals, self.inversion)
587
588		if self.spread == "open" and len(intervals) >= 3:
589			intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:])
590		elif self.spread == "wide" and len(intervals) >= 3:
591			dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)]
592			intervals = sorted(dropped)
593
594		offset = (self.chord.root_pc - root) % 12
595		if offset > 6:
596			offset -= 12
597		effective_root = root + offset
598
599		if count is not None:
600			n = len(intervals)
601			span_octave = max(12, ((max(intervals) // 12) + 1) * 12)
602			pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)]
603		else:
604			pitches = [effective_root + interval for interval in intervals]
605
606		if self.bass is not None and isinstance(self.bass, int):
607			lowest = min(pitches)
608			bass_note = lowest - ((lowest - self.bass) % 12)
609			if bass_note == lowest:
610				bass_note -= 12
611			pitches = [bass_note] + pitches
612
613		return pitches

One chord with a duration and its decoration — the unit of harmonic time.

Decoration (extensions, slash bass, inversion, spread) lives HERE, never on ~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 RomanChord, or a 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 Progression.resolve() for diatonic degrees. None means "derive from the chord's own colour".
ChordSpan( chord: Any, beats: float, extensions: Tuple[Any, ...] = (), bass: Union[int, str, NoneType] = None, inversion: int = 0, spread: Optional[str] = None, extension_intervals: Optional[Tuple[int, ...]] = None)
chord: Any
beats: float
extensions: Tuple[Any, ...] = ()
bass: Union[int, str, NoneType] = None
inversion: int = 0
spread: Optional[str] = None
extension_intervals: Optional[Tuple[int, ...]] = None
is_concrete: bool
435	@property
436	def is_concrete (self) -> bool:
437
438		"""True when the chord needs no key context to sound."""
439
440		return not isinstance(self.chord, RomanChord)

True when the chord needs no key context to sound.

is_decorated: bool
442	@property
443	def is_decorated (self) -> bool:
444
445		"""True when the span carries any decoration beyond the bare chord."""
446
447		return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None

True when the span carries any decoration beyond the bare chord.

def resolve( self, key_pc: int, scale: str = 'ionian') -> ChordSpan:
449	def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan":
450
451		"""Return a concrete span: romans resolved, bass resolved to a pitch class."""
452
453		chord = self.chord
454		extension_intervals = self.extension_intervals
455
456		if isinstance(chord, RomanChord):
457			if chord.quality is None and any(isinstance(e, int) for e in self.extensions):
458				extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions)
459			chord = chord.resolve(key_pc, scale)
460
461		bass: typing.Optional[typing.Union[int, str]] = self.bass
462
463		if isinstance(bass, str):
464			if bass == "tonic":
465				bass = key_pc
466			else:
467				bass = subsequence.chords.key_name_to_pc(bass)
468
469		return dataclasses.replace(
470			self,
471			chord = chord,
472			bass = bass,
473			extension_intervals = extension_intervals,
474		)

Return a concrete span: romans resolved, bass resolved to a pitch class.

def label(self, key_pc: Optional[int] = None, scale: str = 'ionian') -> str:
476	def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str:
477
478		"""A printable chord label: roman text when relative, decorated name when concrete."""
479
480		if isinstance(self.chord, RomanChord):
481			if key_pc is None:
482				text = self.chord.label()
483				return text + self._decoration_suffix(resolved=False)
484			return self.resolve(key_pc, scale).label()
485
486		base = str(self.chord.name())
487		return base + self._decoration_suffix(resolved=True)

A printable chord label: roman text when relative, decorated name when concrete.

def decorated_intervals(self) -> List[int]:
512	def decorated_intervals (self) -> typing.List[int]:
513
514		"""Semitone offsets of the decorated voicing (before inversion/spread/bass).
515
516		Numeric extensions deepen the chord in its own colour — a minor third
517		gets a minor seventh, a major third a major seventh, a diminished
518		triad a diminished seventh.  Diatonic degrees extended with
519		``extend(...)`` carry pre-computed scale-true intervals instead (so V
520		gets its dominant seventh).  Write ``"G7"``/``"V7"`` when you want the
521		dominant colour on a concrete major chord.
522		"""
523
524		if isinstance(self.chord, RomanChord):
525			raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
526
527		intervals = list(self.chord.intervals())
528
529		sus = [e for e in self.extensions if e in ("sus2", "sus4")]
530		if sus and len(intervals) >= 2:
531			intervals[1] = 2 if sus[0] == "sus2" else 5
532
533		numeric = sorted(e for e in self.extensions if isinstance(e, int))
534
535		if self.extension_intervals is not None:
536			added: typing.List[int] = list(self.extension_intervals)
537		else:
538			added = []
539			third = intervals[1] if len(intervals) >= 2 else None
540			has_seventh = any(i in (9, 10, 11) for i in intervals)
541			stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS]
542
543			if stacked and not has_seventh:
544				if third == 3 and len(intervals) >= 3 and intervals[2] == 6:
545					added.append(9)		# diminished colour
546				elif third == 3:
547					added.append(10)	# minor colour
548				elif third == 4:
549					added.append(11)	# major colour
550				else:
551					added.append(10)	# sus / no third: the dominant-leaning seventh
552
553			for extension in stacked:
554				if extension == 9:
555					added.append(14)
556				elif extension == 11:
557					added.append(17)
558				elif extension == 13:
559					added.append(21)
560
561		if "add9" in self.extensions:
562			added.append(14)
563		if "6" in self.extensions:
564			added.append(9)
565
566		return sorted(set(intervals) | set(added))

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.

def tones(self, root: int = 60, count: Optional[int] = None) -> List[int]:
568	def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]:
569
570		"""MIDI notes of the decorated voicing nearest *root* (concrete spans only).
571
572		Applies, in order: extensions, inversion, spread, then the slash/pedal
573		bass below the voicing.  ``PitchSet`` spans return their absolute
574		pitches (decoration other than ``count`` does not apply).
575		"""
576
577		if isinstance(self.chord, RomanChord):
578			raise ValueError("cannot voice a key-relative span — resolve(key=...) it first")
579
580		if isinstance(self.chord, PitchSet):
581			return self.chord.tones(root, inversion=self.inversion, count=count)
582
583		intervals = self.decorated_intervals()
584
585		if self.inversion != 0:
586			intervals = subsequence.voicings.invert_chord(intervals, self.inversion)
587
588		if self.spread == "open" and len(intervals) >= 3:
589			intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:])
590		elif self.spread == "wide" and len(intervals) >= 3:
591			dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)]
592			intervals = sorted(dropped)
593
594		offset = (self.chord.root_pc - root) % 12
595		if offset > 6:
596			offset -= 12
597		effective_root = root + offset
598
599		if count is not None:
600			n = len(intervals)
601			span_octave = max(12, ((max(intervals) // 12) + 1) * 12)
602			pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)]
603		else:
604			pitches = [effective_root + interval for interval in intervals]
605
606		if self.bass is not None and isinstance(self.bass, int):
607			lowest = min(pitches)
608			bass_note = lowest - ((lowest - self.bass) % 12)
609			if bass_note == lowest:
610				bass_note -= 12
611			pitches = [bass_note] + pitches
612
613		return pitches

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).

@dataclasses.dataclass(frozen=True)
class PitchSet:
 80@dataclasses.dataclass(frozen=True)
 81class PitchSet:
 82
 83	"""A nameless sonority — a frozen set of absolute MIDI pitches.
 84
 85	The escape hatch for chords with no root or quality: clusters, spectral
 86	stacks, found objects.  It duck-types ``.tones()`` so every placement verb
 87	and the injected ``chord`` accept it unchanged.  By design it is excluded
 88	from generation and diatonic spice (there is nothing to transpose
 89	diatonically), and a progression containing one loops on exhaustion
 90	rather than falling through to live graph stepping.
 91
 92	Pitches are absolute: ``tones()`` ignores its ``root`` argument — you
 93	chose the register when you chose the pitches.
 94	"""
 95
 96	pitches: typing.Tuple[int, ...]
 97
 98	def __init__ (self, pitches: typing.Iterable[int]) -> None:
 99
100		"""Normalise any iterable of MIDI pitches into a sorted frozen tuple."""
101
102		values = tuple(sorted(int(p) for p in pitches))
103
104		if not values:
105			raise ValueError("PitchSet needs at least one pitch")
106
107		object.__setattr__(self, "pitches", values)
108
109	def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
110
111		"""Return the pitches (absolute — *root* is ignored by design).
112
113		``inversion`` rotates pitches up an octave; ``count`` cycles the set
114		into higher octaves, matching the ``Chord.tones`` contract.
115		"""
116
117		pitches = list(self.pitches)
118
119		if inversion != 0:
120			for _ in range(inversion % len(pitches)):
121				pitches.append(pitches.pop(0) + 12)
122
123		if count is not None:
124			n = len(pitches)
125			return [pitches[i % n] + 12 * (i // n) for i in range(count)]
126
127		return pitches
128
129	def intervals (self) -> typing.List[int]:
130
131		"""Semitone offsets from the lowest pitch (the ``Chord`` protocol)."""
132
133		return [p - self.pitches[0] for p in self.pitches]
134
135	def name (self) -> str:
136
137		"""A readable label for describe() output."""
138
139		return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")"

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.

PitchSet(pitches: Iterable[int])
 98	def __init__ (self, pitches: typing.Iterable[int]) -> None:
 99
100		"""Normalise any iterable of MIDI pitches into a sorted frozen tuple."""
101
102		values = tuple(sorted(int(p) for p in pitches))
103
104		if not values:
105			raise ValueError("PitchSet needs at least one pitch")
106
107		object.__setattr__(self, "pitches", values)

Normalise any iterable of MIDI pitches into a sorted frozen tuple.

pitches: Tuple[int, ...]
def tones( self, root: int = 60, inversion: int = 0, count: Optional[int] = None) -> List[int]:
109	def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
110
111		"""Return the pitches (absolute — *root* is ignored by design).
112
113		``inversion`` rotates pitches up an octave; ``count`` cycles the set
114		into higher octaves, matching the ``Chord.tones`` contract.
115		"""
116
117		pitches = list(self.pitches)
118
119		if inversion != 0:
120			for _ in range(inversion % len(pitches)):
121				pitches.append(pitches.pop(0) + 12)
122
123		if count is not None:
124			n = len(pitches)
125			return [pitches[i % n] + 12 * (i // n) for i in range(count)]
126
127		return pitches

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.

def intervals(self) -> List[int]:
129	def intervals (self) -> typing.List[int]:
130
131		"""Semitone offsets from the lowest pitch (the ``Chord`` protocol)."""
132
133		return [p - self.pitches[0] for p in self.pitches]

Semitone offsets from the lowest pitch (the Chord protocol).

def name(self) -> str:
135	def name (self) -> str:
136
137		"""A readable label for describe() output."""
138
139		return "PitchSet(" + ", ".join(str(p) for p in self.pitches) + ")"

A readable label for describe() output.

def progression( source: Optional[Any] = None, beats: Union[float, List[float]] = 4.0, *, style: Optional[str] = None, bars: int = 8, key: Optional[str] = None, scale: Optional[str] = None, seed: Optional[int] = None, rng: Optional[random.Random] = None, pins: Optional[Dict[int, Any]] = None, end: Optional[Any] = None, avoid: Optional[Sequence[Any]] = None, dominant_7th: bool = True, gravity: float = 1.0, nir_strength: float = 0.5, minor_turnaround_weight: float = 0.0, root_diversity: float = 0.4) -> Progression:
1352def progression (
1353	source: typing.Optional[typing.Any] = None,
1354	beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS,
1355	*,
1356	style: typing.Optional[str] = None,
1357	bars: int = 8,
1358	key: typing.Optional[str] = None,
1359	scale: typing.Optional[str] = None,
1360	seed: typing.Optional[int] = None,
1361	rng: typing.Optional[random.Random] = None,
1362	pins: typing.Optional[typing.Dict[int, typing.Any]] = None,
1363	end: typing.Optional[typing.Any] = None,
1364	avoid: typing.Optional[typing.Sequence[typing.Any]] = None,
1365	dominant_7th: bool = True,
1366	gravity: float = 1.0,
1367	nir_strength: float = 0.5,
1368	minor_turnaround_weight: float = 0.0,
1369	root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY,
1370) -> Progression:
1371
1372	"""Build a :class:`Progression` — the lowercase factory.
1373
1374	Dispatch by argument type: a **list** parses per element (ints where
1375	diatonic, name/roman strings where nominal/chromatic, ``(element,
1376	beats)`` tuples for per-chord durations); a bare **string** names a
1377	preset from the curated table; ``style=`` generates *bars* chords from a
1378	chord-graph walk (requires ``key=``).
1379
1380	Parameters:
1381		source: The element list, preset name, or an existing Progression
1382			(returned unchanged).
1383		beats: Span length per chord — a scalar, or a list cycled per chord
1384			(``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm).
1385		style: A chord-graph style name to generate from (e.g.
1386			``"aeolian_minor"``).
1387		bars: How many chords to generate (style mode only).
1388		key: Key for style generation.
1389		seed: Seed for style generation.  A standalone generated value
1390			without a seed warns — module-level nondeterminism breaks live
1391			reload.
1392		rng: An explicit random stream (overrides ``seed``; used by
1393			engine-mediated calls).
1394		dominant_7th / gravity / nir_strength: Graph-walk parameters,
1395			matching :meth:`Composition.harmony` (style mode only; full
1396			pass-through arrives with ``Progression.generate``).
1397
1398	Example:
1399		```python
1400		verse = subsequence.progression([1, 6, 3, 7])           # i–VI–III–VII in A minor
1401		blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"])
1402		walk  = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3)
1403		```
1404	"""
1405
1406	if style is not None:
1407		if source is not None:
1408			raise ValueError("pass either source or style=, not both")
1409		return Progression.generate(
1410			style = style,
1411			bars = bars,
1412			beats = beats,
1413			key = key,
1414			scale = scale,
1415			seed = seed,
1416			rng = rng,
1417			pins = pins,
1418			end = end,
1419			avoid = avoid,
1420			dominant_7th = dominant_7th,
1421			gravity = gravity,
1422			nir_strength = nir_strength,
1423			minor_turnaround_weight = minor_turnaround_weight,
1424			root_diversity = root_diversity,
1425		)
1426
1427	if isinstance(source, Progression):
1428		return source
1429
1430	if isinstance(source, str):
1431		if source in _PRESETS:
1432			return progression(_PRESETS[source], beats=beats)
1433		raise ValueError(
1434			f"Unknown progression preset {source!r} (the preset table ships in a later release). "
1435			"A progression is a list — pass the chords as elements, e.g. progression([1, 6, 3, 7]) "
1436			"or progression(['Am', 'F', 'C', 'G'])."
1437		)
1438
1439	if source is None:
1440		raise ValueError("progression() needs a source list (or style=...)")
1441
1442	elements = list(source)
1443
1444	if not elements:
1445		raise ValueError("progression list is empty — pass at least one chord")
1446
1447	lengths = _span_lengths(beats, len(elements))
1448
1449	return Progression(spans = tuple(
1450		parse_element(element, beats=lengths[index])
1451		for index, element in enumerate(elements)
1452	))

Build a 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=).

Arguments:
  • 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 Composition.harmony() (style mode only; full pass-through arrives with Progression.generate).
Example:
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)
@dataclasses.dataclass(frozen=True)
class Chord:
122@dataclasses.dataclass(frozen=True)
123class Chord:
124
125	"""
126	Represents a chord as a root pitch class and quality.
127	"""
128
129	root_pc: int
130	quality: str
131
132
133	def intervals (self) -> typing.List[int]:
134
135		"""
136		Return the chord intervals for this chord quality.
137		"""
138
139		if self.quality not in CHORD_INTERVALS:
140			raise ValueError(f"Unknown chord quality: {self.quality}")
141
142		return CHORD_INTERVALS[self.quality]
143
144
145
146	def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
147
148		"""Return MIDI note numbers for chord tones starting from a root.
149
150		Finds the MIDI note corresponding to the chord's root pitch class that is
151		closest to the provided ``root`` argument.
152
153		Parameters:
154			root: MIDI note number (e.g., 60 = middle C) to center the chord around.
155			inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...).
156				Wraps around for values >= number of notes.
157			count: Number of notes to return. When set, the chord intervals cycle
158				into higher octaves until ``count`` notes are produced. When ``None``
159				(default), returns the natural chord tones.
160
161		Returns:
162			List of MIDI note numbers for chord tones
163
164		Example:
165			```python
166			chord = Chord(root_pc=0, quality="major")  # C major
167			chord.tones(root=60)               # [60, 64, 67] - root position around C4
168			chord.tones(root=62)               # [60, 64, 67] - still finds C4 as closest root
169			chord.tones(root=70)               # [72, 76, 79] - finds C5 as closest root
170			```
171		"""
172
173		# Find the MIDI note for self.root_pc that is closest to the requested root.
174		# This handles octaves automatically.
175		offset = (self.root_pc - root) % 12
176		if offset > 6:
177			offset -= 12
178
179		effective_root = root + offset
180
181		intervals = self.intervals()
182
183		if inversion != 0:
184			intervals = subsequence.voicings.invert_chord(intervals, inversion)
185
186		if count is not None:
187			n = len(intervals)
188			return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)]
189
190		return [effective_root + interval for interval in intervals]
191
192
193	def root_note (self, root_midi: int) -> int:
194
195		"""
196		Return the MIDI note number for the chord root nearest to *root_midi*.
197
198		This is equivalent to ``self.tones(root_midi)[0]`` but makes intent
199		explicit when you only need the single root pitch.
200
201		Parameters:
202			root_midi: Reference MIDI note number used to find the closest octave
203			           of this chord's root pitch class.
204
205		Returns:
206			MIDI note number of the chord root.
207
208		Example:
209			```python
210			chord = Chord(root_pc=4, quality="major")  # E major
211			chord.root_note(60)   # → 64  (E4, nearest to C4)
212			chord.root_note(69)   # → 64  (E4, nearest to A4)
213			```
214		"""
215
216		return self.tones(root_midi)[0]
217
218
219	def bass_note (self, root_midi: int, octave_offset: int = -1) -> int:
220
221		"""
222		Return the chord root shifted by a number of octaves.
223
224		Commonly used to produce a bass register note one or two octaves
225		below the chord voicing.
226
227		Parameters:
228			root_midi: Reference MIDI note number (passed to :meth:`root_note`).
229			octave_offset: Octaves to shift; negative moves down (default ``-1``).
230
231		Returns:
232			MIDI note number of the chord root in the target register.
233
234		Example:
235			```python
236			chord = Chord(root_pc=4, quality="major")  # E major
237			chord.bass_note(64)        # → 52  (E3, one octave down from E4)
238			chord.bass_note(64, -2)    # → 40  (E2, two octaves down)
239			```
240		"""
241
242		return self.root_note(root_midi) + (12 * octave_offset)
243
244
245	def name (self) -> str:
246
247		"""
248		Return a human-friendly chord name.
249
250		A registered quality without a suffix prints as ``root(quality)``
251		(e.g. ``"C(quartal)"``) rather than masquerading as a plain major.
252		"""
253
254		root_name = PC_TO_NOTE_NAME[self.root_pc % 12]
255
256		if self.quality not in CHORD_SUFFIX:
257			return f"{root_name}({self.quality})"
258
259		return f"{root_name}{CHORD_SUFFIX[self.quality]}"

Represents a chord as a root pitch class and quality.

Chord(root_pc: int, quality: str)
root_pc: int
quality: str
def intervals(self) -> List[int]:
133	def intervals (self) -> typing.List[int]:
134
135		"""
136		Return the chord intervals for this chord quality.
137		"""
138
139		if self.quality not in CHORD_INTERVALS:
140			raise ValueError(f"Unknown chord quality: {self.quality}")
141
142		return CHORD_INTERVALS[self.quality]

Return the chord intervals for this chord quality.

def tones( self, root: int, inversion: int = 0, count: Optional[int] = None) -> List[int]:
146	def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]:
147
148		"""Return MIDI note numbers for chord tones starting from a root.
149
150		Finds the MIDI note corresponding to the chord's root pitch class that is
151		closest to the provided ``root`` argument.
152
153		Parameters:
154			root: MIDI note number (e.g., 60 = middle C) to center the chord around.
155			inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...).
156				Wraps around for values >= number of notes.
157			count: Number of notes to return. When set, the chord intervals cycle
158				into higher octaves until ``count`` notes are produced. When ``None``
159				(default), returns the natural chord tones.
160
161		Returns:
162			List of MIDI note numbers for chord tones
163
164		Example:
165			```python
166			chord = Chord(root_pc=0, quality="major")  # C major
167			chord.tones(root=60)               # [60, 64, 67] - root position around C4
168			chord.tones(root=62)               # [60, 64, 67] - still finds C4 as closest root
169			chord.tones(root=70)               # [72, 76, 79] - finds C5 as closest root
170			```
171		"""
172
173		# Find the MIDI note for self.root_pc that is closest to the requested root.
174		# This handles octaves automatically.
175		offset = (self.root_pc - root) % 12
176		if offset > 6:
177			offset -= 12
178
179		effective_root = root + offset
180
181		intervals = self.intervals()
182
183		if inversion != 0:
184			intervals = subsequence.voicings.invert_chord(intervals, inversion)
185
186		if count is not None:
187			n = len(intervals)
188			return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)]
189
190		return [effective_root + interval for interval in intervals]

Return MIDI note numbers for chord tones starting from a root.

Finds the MIDI note corresponding to the chord's root pitch class that is closest to the provided root argument.

Arguments:
  • root: MIDI note number (e.g., 60 = middle C) to center the chord around.
  • inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). Wraps around for values >= number of notes.
  • count: Number of notes to return. When set, the chord intervals cycle into higher octaves until count notes are produced. When None (default), returns the natural chord tones.
Returns:

List of MIDI note numbers for chord tones

Example:
chord = Chord(root_pc=0, quality="major")  # C major
chord.tones(root=60)               # [60, 64, 67] - root position around C4
chord.tones(root=62)               # [60, 64, 67] - still finds C4 as closest root
chord.tones(root=70)               # [72, 76, 79] - finds C5 as closest root
def root_note(self, root_midi: int) -> int:
193	def root_note (self, root_midi: int) -> int:
194
195		"""
196		Return the MIDI note number for the chord root nearest to *root_midi*.
197
198		This is equivalent to ``self.tones(root_midi)[0]`` but makes intent
199		explicit when you only need the single root pitch.
200
201		Parameters:
202			root_midi: Reference MIDI note number used to find the closest octave
203			           of this chord's root pitch class.
204
205		Returns:
206			MIDI note number of the chord root.
207
208		Example:
209			```python
210			chord = Chord(root_pc=4, quality="major")  # E major
211			chord.root_note(60)   # → 64  (E4, nearest to C4)
212			chord.root_note(69)   # → 64  (E4, nearest to A4)
213			```
214		"""
215
216		return self.tones(root_midi)[0]

Return the MIDI note number for the chord root nearest to root_midi.

This is equivalent to self.tones(root_midi)[0] but makes intent explicit when you only need the single root pitch.

Arguments:
  • root_midi: Reference MIDI note number used to find the closest octave of this chord's root pitch class.
Returns:

MIDI note number of the chord root.

Example:
chord = Chord(root_pc=4, quality="major")  # E major
chord.root_note(60)   # → 64  (E4, nearest to C4)
chord.root_note(69)   # → 64  (E4, nearest to A4)
def bass_note(self, root_midi: int, octave_offset: int = -1) -> int:
219	def bass_note (self, root_midi: int, octave_offset: int = -1) -> int:
220
221		"""
222		Return the chord root shifted by a number of octaves.
223
224		Commonly used to produce a bass register note one or two octaves
225		below the chord voicing.
226
227		Parameters:
228			root_midi: Reference MIDI note number (passed to :meth:`root_note`).
229			octave_offset: Octaves to shift; negative moves down (default ``-1``).
230
231		Returns:
232			MIDI note number of the chord root in the target register.
233
234		Example:
235			```python
236			chord = Chord(root_pc=4, quality="major")  # E major
237			chord.bass_note(64)        # → 52  (E3, one octave down from E4)
238			chord.bass_note(64, -2)    # → 40  (E2, two octaves down)
239			```
240		"""
241
242		return self.root_note(root_midi) + (12 * octave_offset)

Return the chord root shifted by a number of octaves.

Commonly used to produce a bass register note one or two octaves below the chord voicing.

Arguments:
  • root_midi: Reference MIDI note number (passed to root_note()).
  • octave_offset: Octaves to shift; negative moves down (default -1).
Returns:

MIDI note number of the chord root in the target register.

Example:
chord = Chord(root_pc=4, quality="major")  # E major
chord.bass_note(64)        # → 52  (E3, one octave down from E4)
chord.bass_note(64, -2)    # → 40  (E2, two octaves down)
def name(self) -> str:
245	def name (self) -> str:
246
247		"""
248		Return a human-friendly chord name.
249
250		A registered quality without a suffix prints as ``root(quality)``
251		(e.g. ``"C(quartal)"``) rather than masquerading as a plain major.
252		"""
253
254		root_name = PC_TO_NOTE_NAME[self.root_pc % 12]
255
256		if self.quality not in CHORD_SUFFIX:
257			return f"{root_name}({self.quality})"
258
259		return f"{root_name}{CHORD_SUFFIX[self.quality]}"

Return a human-friendly chord name.

A registered quality without a suffix prints as root(quality) (e.g. "C(quartal)") rather than masquerading as a plain major.

@dataclasses.dataclass
class Groove:
 21@dataclasses.dataclass
 22class Groove:
 23
 24	"""
 25	A timing/velocity template applied to quantized grid positions.
 26
 27	A groove is a repeating pattern of per-step timing offsets and optional
 28	velocity adjustments aligned to a rhythmic grid. Apply it as a post-build
 29	transform with ``p.groove(template)`` to give a pattern its characteristic
 30	feel — swing, shuffle, MPC-style pocket, or anything extracted from an
 31	Ableton ``.agr`` file.
 32
 33	Parameters:
 34		offsets: Timing offset per grid slot, in beats. Repeats cyclically.
 35			Positive values delay the note; negative values push it earlier.
 36		grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes).
 37		velocities: Optional velocity scale per grid slot (1.0 = unchanged).
 38			Repeats cyclically alongside offsets.
 39
 40	Example::
 41
 42		# Ableton-style 57% swing on 16th notes
 43		groove = Groove.swing(percent=57)
 44
 45		# Custom groove with timing and velocity
 46		groove = Groove(
 47			grid=0.25,
 48			offsets=[0.0, +0.02, 0.0, -0.01],
 49			velocities=[1.0, 0.7, 0.9, 0.6],
 50		)
 51	"""
 52
 53	offsets: typing.List[float]
 54	grid: float = 0.25
 55	velocities: typing.Optional[typing.List[float]] = None
 56
 57	def __post_init__ (self) -> None:
 58		if not self.offsets:
 59			raise ValueError("offsets must not be empty")
 60		if self.grid <= 0:
 61			raise ValueError("grid must be positive")
 62		if self.velocities is not None and not self.velocities:
 63			raise ValueError("velocities must not be empty (use None for no velocity adjustment)")
 64
 65	@staticmethod
 66	def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove":
 67
 68		"""
 69		Create a swing groove from a percentage.
 70
 71		50% is straight (no swing). 67% is approximately triplet swing.
 72		57% is a moderate shuffle — the Ableton default.
 73
 74		Parameters:
 75			percent: Swing amount (50–75 is the useful range).
 76			grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths).
 77		"""
 78
 79		if percent < 50.0 or percent > 99.0:
 80			raise ValueError("swing percent must be between 50 and 99")
 81		pair_duration = grid * 2
 82		offset = (percent / 100.0 - 0.5) * pair_duration
 83		return Groove(offsets=[0.0, offset], grid=grid)
 84
 85	@staticmethod
 86	def from_agr (path: str) -> "Groove":
 87
 88		"""
 89		Import timing and velocity data from an Ableton .agr groove file.
 90
 91		An ``.agr`` file is an XML document containing a MIDI clip whose
 92		note positions encode the groove's rhythmic feel. This method reads
 93		those note start times and velocities and converts them into the
 94		``Groove`` dataclass format (per-step offsets and velocity scales).
 95
 96		**What is extracted:**
 97
 98		- ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets
 99		  relative to ideal grid positions.
100		- ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity
101		  scaling (normalised to the highest velocity in the file).
102		- ``TimingAmount`` from the Groove element → pre-scales the timing
103		  offsets (100 = full, 70 = 70% of the groove's timing).
104		- ``VelocityAmount`` from the Groove element → pre-scales velocity
105		  deviation (100 = full groove velocity, 0 = no velocity changes).
106
107		The resulting ``Groove`` reflects the file author's intended
108		strength. Use ``strength=`` when applying to further adjust.
109
110		**What is NOT imported:**
111
112		``RandomAmount`` (use ``p.randomize()`` separately for random
113		jitter) and ``QuantizationAmount`` (not applicable - Subsequence
114		notes are already grid-quantized by construction).
115
116		Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``,
117		``OffVelocity``, ``Probability``) are also ignored.
118
119		Parameters:
120			path: Path to the .agr file.
121		"""
122
123		tree = xml.etree.ElementTree.parse(path)
124		root = tree.getroot()
125
126		# Find the MIDI clip
127		clip = root.find(".//MidiClip")
128		if clip is None:
129			raise ValueError(f"No MidiClip found in {path}")
130
131		# Get clip length
132		current_end = clip.find("CurrentEnd")
133		if current_end is None:
134			raise ValueError(f"No CurrentEnd found in {path}")
135		clip_length = float(current_end.get("Value", "4"))
136
137		# Read Groove Pool blend parameters
138		groove_elem = root.find(".//Groove")
139		timing_amount = 100.0
140		velocity_amount = 100.0
141		if groove_elem is not None:
142			timing_el = groove_elem.find("TimingAmount")
143			if timing_el is not None:
144				timing_amount = float(timing_el.get("Value", "100"))
145			velocity_el = groove_elem.find("VelocityAmount")
146			if velocity_el is not None:
147				velocity_amount = float(velocity_el.get("Value", "100"))
148
149		timing_scale = timing_amount / 100.0
150		velocity_scale = velocity_amount / 100.0
151
152		# Extract note events sorted by time
153		events = clip.findall(".//MidiNoteEvent")
154		if not events:
155			raise ValueError(f"No MidiNoteEvent elements found in {path}")
156
157		times: typing.List[float] = []
158		velocities_raw: typing.List[float] = []
159		for event in events:
160			times.append(float(event.get("Time", "0")))
161			velocities_raw.append(float(event.get("Velocity", "127")))
162
163		# Sort as PAIRS - sorting times alone desynced each offset from its
164		# note's velocity whenever the XML listed events out of time order.
165		paired = sorted(zip(times, velocities_raw))
166		times = [t for t, _ in paired]
167		velocities_raw = [v for _, v in paired]
168
169		note_count = len(times)
170
171		# Infer grid from clip length and note count
172		grid = clip_length / note_count
173
174		# Calculate offsets from ideal grid positions, scaled by TimingAmount
175		offsets: typing.List[float] = []
176		for i, time in enumerate(times):
177			ideal = i * grid
178			offsets.append((time - ideal) * timing_scale)
179
180		# Calculate velocity scales (relative to max velocity in the file),
181		# blended toward 1.0 by VelocityAmount
182		max_vel = max(velocities_raw)
183		has_velocity_variation = any(v != max_vel for v in velocities_raw)
184		groove_velocities: typing.Optional[typing.List[float]] = None
185		if has_velocity_variation and max_vel > 0:
186			raw_scales = [v / max_vel for v in velocities_raw]
187			# velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change)
188			groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales]
189			# If blending has removed all variation, set to None
190			if all(abs(v - 1.0) < 1e-9 for v in groove_velocities):
191				groove_velocities = None
192
193		return Groove(offsets=offsets, grid=grid, velocities=groove_velocities)

A timing/velocity template applied to quantized grid positions.

A groove is a repeating pattern of per-step timing offsets and optional velocity adjustments aligned to a rhythmic grid. Apply it as a post-build transform with p.groove(template) to give a pattern its characteristic feel — swing, shuffle, MPC-style pocket, or anything extracted from an Ableton .agr file.

Arguments:
  • offsets: Timing offset per grid slot, in beats. Repeats cyclically. Positive values delay the note; negative values push it earlier.
  • grid: Grid size in beats (0.25 = 16th notes, 0.5 = 8th notes).
  • velocities: Optional velocity scale per grid slot (1.0 = unchanged). Repeats cyclically alongside offsets.

Example::

    # Ableton-style 57% swing on 16th notes
    groove = Groove.swing(percent=57)

    # Custom groove with timing and velocity
    groove = Groove(
            grid=0.25,
            offsets=[0.0, +0.02, 0.0, -0.01],
            velocities=[1.0, 0.7, 0.9, 0.6],
    )
Groove( offsets: List[float], grid: float = 0.25, velocities: Optional[List[float]] = None)
offsets: List[float]
grid: float = 0.25
velocities: Optional[List[float]] = None
@staticmethod
def swing(percent: float = 57.0, grid: float = 0.25) -> Groove:
65	@staticmethod
66	def swing (percent: float = 57.0, grid: float = 0.25) -> "Groove":
67
68		"""
69		Create a swing groove from a percentage.
70
71		50% is straight (no swing). 67% is approximately triplet swing.
72		57% is a moderate shuffle — the Ableton default.
73
74		Parameters:
75			percent: Swing amount (50–75 is the useful range).
76			grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths).
77		"""
78
79		if percent < 50.0 or percent > 99.0:
80			raise ValueError("swing percent must be between 50 and 99")
81		pair_duration = grid * 2
82		offset = (percent / 100.0 - 0.5) * pair_duration
83		return Groove(offsets=[0.0, offset], grid=grid)

Create a swing groove from a percentage.

50% is straight (no swing). 67% is approximately triplet swing. 57% is a moderate shuffle — the Ableton default.

Arguments:
  • percent: Swing amount (50–75 is the useful range).
  • grid: Grid size in beats (0.25 = 16ths, 0.5 = 8ths).
@staticmethod
def from_agr(path: str) -> Groove:
 85	@staticmethod
 86	def from_agr (path: str) -> "Groove":
 87
 88		"""
 89		Import timing and velocity data from an Ableton .agr groove file.
 90
 91		An ``.agr`` file is an XML document containing a MIDI clip whose
 92		note positions encode the groove's rhythmic feel. This method reads
 93		those note start times and velocities and converts them into the
 94		``Groove`` dataclass format (per-step offsets and velocity scales).
 95
 96		**What is extracted:**
 97
 98		- ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets
 99		  relative to ideal grid positions.
100		- ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity
101		  scaling (normalised to the highest velocity in the file).
102		- ``TimingAmount`` from the Groove element → pre-scales the timing
103		  offsets (100 = full, 70 = 70% of the groove's timing).
104		- ``VelocityAmount`` from the Groove element → pre-scales velocity
105		  deviation (100 = full groove velocity, 0 = no velocity changes).
106
107		The resulting ``Groove`` reflects the file author's intended
108		strength. Use ``strength=`` when applying to further adjust.
109
110		**What is NOT imported:**
111
112		``RandomAmount`` (use ``p.randomize()`` separately for random
113		jitter) and ``QuantizationAmount`` (not applicable - Subsequence
114		notes are already grid-quantized by construction).
115
116		Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``,
117		``OffVelocity``, ``Probability``) are also ignored.
118
119		Parameters:
120			path: Path to the .agr file.
121		"""
122
123		tree = xml.etree.ElementTree.parse(path)
124		root = tree.getroot()
125
126		# Find the MIDI clip
127		clip = root.find(".//MidiClip")
128		if clip is None:
129			raise ValueError(f"No MidiClip found in {path}")
130
131		# Get clip length
132		current_end = clip.find("CurrentEnd")
133		if current_end is None:
134			raise ValueError(f"No CurrentEnd found in {path}")
135		clip_length = float(current_end.get("Value", "4"))
136
137		# Read Groove Pool blend parameters
138		groove_elem = root.find(".//Groove")
139		timing_amount = 100.0
140		velocity_amount = 100.0
141		if groove_elem is not None:
142			timing_el = groove_elem.find("TimingAmount")
143			if timing_el is not None:
144				timing_amount = float(timing_el.get("Value", "100"))
145			velocity_el = groove_elem.find("VelocityAmount")
146			if velocity_el is not None:
147				velocity_amount = float(velocity_el.get("Value", "100"))
148
149		timing_scale = timing_amount / 100.0
150		velocity_scale = velocity_amount / 100.0
151
152		# Extract note events sorted by time
153		events = clip.findall(".//MidiNoteEvent")
154		if not events:
155			raise ValueError(f"No MidiNoteEvent elements found in {path}")
156
157		times: typing.List[float] = []
158		velocities_raw: typing.List[float] = []
159		for event in events:
160			times.append(float(event.get("Time", "0")))
161			velocities_raw.append(float(event.get("Velocity", "127")))
162
163		# Sort as PAIRS - sorting times alone desynced each offset from its
164		# note's velocity whenever the XML listed events out of time order.
165		paired = sorted(zip(times, velocities_raw))
166		times = [t for t, _ in paired]
167		velocities_raw = [v for _, v in paired]
168
169		note_count = len(times)
170
171		# Infer grid from clip length and note count
172		grid = clip_length / note_count
173
174		# Calculate offsets from ideal grid positions, scaled by TimingAmount
175		offsets: typing.List[float] = []
176		for i, time in enumerate(times):
177			ideal = i * grid
178			offsets.append((time - ideal) * timing_scale)
179
180		# Calculate velocity scales (relative to max velocity in the file),
181		# blended toward 1.0 by VelocityAmount
182		max_vel = max(velocities_raw)
183		has_velocity_variation = any(v != max_vel for v in velocities_raw)
184		groove_velocities: typing.Optional[typing.List[float]] = None
185		if has_velocity_variation and max_vel > 0:
186			raw_scales = [v / max_vel for v in velocities_raw]
187			# velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change)
188			groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales]
189			# If blending has removed all variation, set to None
190			if all(abs(v - 1.0) < 1e-9 for v in groove_velocities):
191				groove_velocities = None
192
193		return Groove(offsets=offsets, grid=grid, velocities=groove_velocities)

Import timing and velocity data from an Ableton .agr groove file.

An .agr file is an XML document containing a MIDI clip whose note positions encode the groove's rhythmic feel. This method reads those note start times and velocities and converts them into the Groove dataclass format (per-step offsets and velocity scales).

What is extracted:

  • Time attribute of each MidiNoteEvent → timing offsets relative to ideal grid positions.
  • Velocity attribute of each MidiNoteEvent → velocity scaling (normalised to the highest velocity in the file).
  • TimingAmount from the Groove element → pre-scales the timing offsets (100 = full, 70 = 70% of the groove's timing).
  • VelocityAmount from the Groove element → pre-scales velocity deviation (100 = full groove velocity, 0 = no velocity changes).

The resulting Groove reflects the file author's intended strength. Use strength= when applying to further adjust.

What is NOT imported:

RandomAmount (use p.randomize() separately for random jitter) and QuantizationAmount (not applicable - Subsequence notes are already grid-quantized by construction).

Other MidiNoteEvent fields (Duration, VelocityDeviation, OffVelocity, Probability) are also ignored.

Arguments:
  • path: Path to the .agr file.
class MelodicState:
 24class MelodicState:
 25
 26	"""Persistent melodic context that applies NIR scoring to single-note lines."""
 27
 28
 29	def __init__ (
 30		self,
 31		key: str = "C",
 32		mode: str = "ionian",
 33		low: int = 48,
 34		high: int = 72,
 35		nir_strength: float = 0.5,
 36		chord_weight: float = 0.4,
 37		rest_probability: float = 0.0,
 38		pitch_diversity: float = 0.6,
 39	) -> None:
 40
 41		"""Initialise a melodic state for a given key, mode, and MIDI register.
 42
 43		Parameters:
 44			key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``).
 45			mode: Scale mode name.  Accepts any mode registered with
 46			      :func:`~subsequence.intervals.scale_pitch_classes` (e.g.
 47			      ``"ionian"``, ``"aeolian"``, ``"dorian"``).
 48			low: Lowest MIDI note (inclusive) in the pitch pool.
 49			high: Highest MIDI note (inclusive) in the pitch pool.
 50			nir_strength: 0.0–1.0.  Scales how strongly the NIR rules
 51			    influence candidate scores.  0.0 = uniform; 1.0 = full boost.
 52			chord_weight: 0.0–1.0.  Additive multiplier bonus for candidates
 53			    whose pitch class belongs to the current chord tones.
 54			rest_probability: 0.0–1.0.  Probability of producing a rest
 55			    (returning ``None``) at any given step.
 56			pitch_diversity: 0.0–1.0.  Exponential penalty per recent
 57			    repetition of the same pitch.  Lower values discourage
 58			    repetition more aggressively.
 59		"""
 60
 61		if nir_strength < 0 or nir_strength > 1:
 62			raise ValueError("NIR strength must be between 0 and 1")
 63
 64		if rest_probability < 0 or rest_probability > 1:
 65			raise ValueError("Rest probability must be between 0 and 1")
 66
 67		if pitch_diversity < 0 or pitch_diversity > 1:
 68			raise ValueError("Pitch diversity must be between 0 and 1")
 69
 70		if chord_weight < 0:
 71			raise ValueError("Chord weight must be non-negative")
 72
 73		if low >= high:
 74			raise ValueError("low must be below high")
 75
 76		self.key = key
 77		self.mode = mode
 78		self.low = low
 79		self.high = high
 80		self.nir_strength = nir_strength
 81		self.chord_weight = chord_weight
 82		self.rest_probability = rest_probability
 83		self.pitch_diversity = pitch_diversity
 84
 85		key_pc = subsequence.chords.key_name_to_pc(key)
 86
 87		# Pitch pool: all scale tones within [low, high].
 88		self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes(
 89			key, mode, low=low, high=high
 90		)
 91
 92		# Tonic pitch class for Rule C (closure).
 93		self._tonic_pc: int = key_pc
 94
 95		# History of last N absolute MIDI pitches (capped at 4, same as HarmonicState).
 96		self.history: typing.List[int] = []
 97
 98
 99	def choose_next (
100		self,
101		chord_tones: typing.Optional[typing.List[int]],
102		rng: random.Random,
103	) -> typing.Optional[int]:
104
105		"""Score all pitch-pool candidates and return the chosen pitch, or None for a rest."""
106
107		if self.rest_probability > 0.0 and rng.random() < self.rest_probability:
108			return None
109
110		if not self._pitch_pool:
111			return None
112
113		# Resolve chord tones to pitch classes for fast membership testing.
114		chord_tone_pcs: typing.Set[int] = (
115			{t % 12 for t in chord_tones} if chord_tones else set()
116		)
117
118		scores = [self._score_candidate(p, chord_tone_pcs) for p in self._pitch_pool]
119
120		# Weighted random choice: select using cumulative score as a probability weight.
121		total = sum(scores)
122
123		if total <= 0.0:
124			chosen = rng.choice(self._pitch_pool)
125
126		else:
127			r = rng.uniform(0.0, total)
128			cumulative = 0.0
129			chosen = self._pitch_pool[-1]
130
131			for pitch, score in zip(self._pitch_pool, scores):
132				cumulative += score
133				if r <= cumulative:
134					chosen = pitch
135					break
136
137		# Persist history for the next call (capped at 4 entries).
138		self.history.append(chosen)
139		if len(self.history) > 4:
140			self.history.pop(0)
141
142		return chosen
143
144
145	def _score_candidate (
146		self,
147		candidate: int,
148		chord_tone_pcs: typing.Set[int],
149	) -> float:
150
151		"""Score one candidate pitch using NIR rules, chord weighting, range gravity, and pitch diversity."""
152
153		score = 1.0
154
155		# --- NIR rules (require at least one history note for Realization) ---
156		if self.history:
157			last_note = self.history[-1]
158
159			target_diff = candidate - last_note
160			target_interval = abs(target_diff)
161			target_direction = 1 if target_diff > 0 else -1 if target_diff < 0 else 0
162
163			# Rules A & B require an Implication context (prev -> last -> candidate).
164			if len(self.history) >= 2:
165				prev_note = self.history[-2]
166
167				prev_diff = last_note - prev_note
168				prev_interval = abs(prev_diff)
169				prev_direction = 1 if prev_diff > 0 else -1 if prev_diff < 0 else 0
170
171				# Rule A: Reversal (gap fill) — after a large leap, expect direction change.
172				if prev_interval > 4:
173					if target_direction != prev_direction and target_direction != 0:
174						score += 0.5
175
176					if target_interval < 4:
177						score += 0.3
178
179				# Rule B: Process (continuation) — after a small step, expect more of the same.
180				elif 0 < prev_interval < 3:
181					if target_direction == prev_direction:
182						score += 0.4
183
184					if abs(target_interval - prev_interval) <= 1:
185						score += 0.2
186
187			# Rule C: Closure — the tonic is a cognitively stable landing point.
188			if candidate % 12 == self._tonic_pc:
189				score += 0.2
190
191			# Rule D: Proximity — smaller intervals are generally preferred.
192			if 0 < target_interval <= 3:
193				score += 0.3
194
195			# Scale the entire NIR boost by nir_strength, leaving the base at 1.0.
196			score = 1.0 + (score - 1.0) * self.nir_strength
197
198		# --- Chord tone boost ---
199		if candidate % 12 in chord_tone_pcs:
200			score *= 1.0 + self.chord_weight
201
202		# --- Range gravity: penalise notes far from the centre of [low, high] ---
203		centre = (self.low + self.high) / 2.0
204		half_range = max(1.0, (self.high - self.low) / 2.0)
205		distance_ratio = abs(candidate - centre) / half_range
206		score *= 1.0 - 0.3 * (distance_ratio ** 2)
207
208		# --- Pitch diversity: exponential penalty for recently-heard pitches ---
209		recent_occurrences = sum(1 for h in self.history if h == candidate)
210		score *= self.pitch_diversity ** recent_occurrences
211
212		return max(0.0, score)

Persistent melodic context that applies NIR scoring to single-note lines.

MelodicState( key: str = 'C', mode: str = 'ionian', low: int = 48, high: int = 72, nir_strength: float = 0.5, chord_weight: float = 0.4, rest_probability: float = 0.0, pitch_diversity: float = 0.6)
29	def __init__ (
30		self,
31		key: str = "C",
32		mode: str = "ionian",
33		low: int = 48,
34		high: int = 72,
35		nir_strength: float = 0.5,
36		chord_weight: float = 0.4,
37		rest_probability: float = 0.0,
38		pitch_diversity: float = 0.6,
39	) -> None:
40
41		"""Initialise a melodic state for a given key, mode, and MIDI register.
42
43		Parameters:
44			key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``).
45			mode: Scale mode name.  Accepts any mode registered with
46			      :func:`~subsequence.intervals.scale_pitch_classes` (e.g.
47			      ``"ionian"``, ``"aeolian"``, ``"dorian"``).
48			low: Lowest MIDI note (inclusive) in the pitch pool.
49			high: Highest MIDI note (inclusive) in the pitch pool.
50			nir_strength: 0.0–1.0.  Scales how strongly the NIR rules
51			    influence candidate scores.  0.0 = uniform; 1.0 = full boost.
52			chord_weight: 0.0–1.0.  Additive multiplier bonus for candidates
53			    whose pitch class belongs to the current chord tones.
54			rest_probability: 0.0–1.0.  Probability of producing a rest
55			    (returning ``None``) at any given step.
56			pitch_diversity: 0.0–1.0.  Exponential penalty per recent
57			    repetition of the same pitch.  Lower values discourage
58			    repetition more aggressively.
59		"""
60
61		if nir_strength < 0 or nir_strength > 1:
62			raise ValueError("NIR strength must be between 0 and 1")
63
64		if rest_probability < 0 or rest_probability > 1:
65			raise ValueError("Rest probability must be between 0 and 1")
66
67		if pitch_diversity < 0 or pitch_diversity > 1:
68			raise ValueError("Pitch diversity must be between 0 and 1")
69
70		if chord_weight < 0:
71			raise ValueError("Chord weight must be non-negative")
72
73		if low >= high:
74			raise ValueError("low must be below high")
75
76		self.key = key
77		self.mode = mode
78		self.low = low
79		self.high = high
80		self.nir_strength = nir_strength
81		self.chord_weight = chord_weight
82		self.rest_probability = rest_probability
83		self.pitch_diversity = pitch_diversity
84
85		key_pc = subsequence.chords.key_name_to_pc(key)
86
87		# Pitch pool: all scale tones within [low, high].
88		self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes(
89			key, mode, low=low, high=high
90		)
91
92		# Tonic pitch class for Rule C (closure).
93		self._tonic_pc: int = key_pc
94
95		# History of last N absolute MIDI pitches (capped at 4, same as HarmonicState).
96		self.history: typing.List[int] = []

Initialise a melodic state for a given key, mode, and MIDI register.

Arguments:
  • key: Root note of the key (e.g. "C", "F#", "Bb").
  • mode: Scale mode name. Accepts any mode registered with ~subsequence.intervals.scale_pitch_classes() (e.g. "ionian", "aeolian", "dorian").
  • low: Lowest MIDI note (inclusive) in the pitch pool.
  • high: Highest MIDI note (inclusive) in the pitch pool.
  • nir_strength: 0.0–1.0. Scales how strongly the NIR rules influence candidate scores. 0.0 = uniform; 1.0 = full boost.
  • chord_weight: 0.0–1.0. Additive multiplier bonus for candidates whose pitch class belongs to the current chord tones.
  • rest_probability: 0.0–1.0. Probability of producing a rest (returning None) at any given step.
  • pitch_diversity: 0.0–1.0. Exponential penalty per recent repetition of the same pitch. Lower values discourage repetition more aggressively.
key
mode
low
high
nir_strength
chord_weight
rest_probability
pitch_diversity
history: List[int]
def choose_next( self, chord_tones: Optional[List[int]], rng: random.Random) -> Optional[int]:
 99	def choose_next (
100		self,
101		chord_tones: typing.Optional[typing.List[int]],
102		rng: random.Random,
103	) -> typing.Optional[int]:
104
105		"""Score all pitch-pool candidates and return the chosen pitch, or None for a rest."""
106
107		if self.rest_probability > 0.0 and rng.random() < self.rest_probability:
108			return None
109
110		if not self._pitch_pool:
111			return None
112
113		# Resolve chord tones to pitch classes for fast membership testing.
114		chord_tone_pcs: typing.Set[int] = (
115			{t % 12 for t in chord_tones} if chord_tones else set()
116		)
117
118		scores = [self._score_candidate(p, chord_tone_pcs) for p in self._pitch_pool]
119
120		# Weighted random choice: select using cumulative score as a probability weight.
121		total = sum(scores)
122
123		if total <= 0.0:
124			chosen = rng.choice(self._pitch_pool)
125
126		else:
127			r = rng.uniform(0.0, total)
128			cumulative = 0.0
129			chosen = self._pitch_pool[-1]
130
131			for pitch, score in zip(self._pitch_pool, scores):
132				cumulative += score
133				if r <= cumulative:
134					chosen = pitch
135					break
136
137		# Persist history for the next call (capped at 4 entries).
138		self.history.append(chosen)
139		if len(self.history) > 4:
140			self.history.pop(0)
141
142		return chosen

Score all pitch-pool candidates and return the chosen pitch, or None for a rest.

@dataclasses.dataclass
class Tuning:
 41@dataclasses.dataclass
 42class Tuning:
 43
 44	"""A microtonal tuning system expressed as cent offsets from the unison.
 45
 46	The ``cents`` list contains the cent values for scale degrees 1 through N.
 47	Degree 0 (the unison, 0.0 cents) is always implicit and not stored.
 48	The last entry is typically 1200.0 cents (the octave) for octave-repeating
 49	scales, but any period is supported.
 50
 51	Create a ``Tuning`` from a file or programmatically:
 52
 53	    Tuning.from_scl("meanquar.scl")          # Scala .scl file
 54	    Tuning.from_cents([100, 200, ..., 1200])  # explicit cents
 55	    Tuning.from_ratios([9/8, 5/4, ..., 2])   # frequency ratios
 56	    Tuning.equal(19)                          # 19-tone equal temperament
 57	"""
 58
 59	cents: typing.List[float]
 60	description: str = ""
 61
 62	@property
 63	def size (self) -> int:
 64		"""Number of scale degrees per period (the .scl ``count`` line)."""
 65		return len(self.cents)
 66
 67	@property
 68	def period_cents (self) -> float:
 69		"""Cent span of one period (typically 1200.0 for octave-repeating scales)."""
 70		return self.cents[-1] if self.cents else 1200.0
 71
 72	# ── Factory methods ───────────────────────────────────────────────────────
 73
 74	@classmethod
 75	def from_scl (cls, source: typing.Union[str, os.PathLike]) -> "Tuning":
 76		"""Parse a Scala .scl file.
 77
 78		``source`` is a file path.  Lines beginning with ``!`` are comments.
 79		The first non-comment line is the description.  The second is the
 80		integer count of pitch values.  Each subsequent line is a pitch:
 81
 82		- Contains ``.`` → cents (float).
 83		- Contains ``/`` or is a bare integer → ratio; converted to cents via
 84		  ``1200 × log₂(ratio)``.
 85
 86		Raises ``ValueError`` for malformed files.
 87		"""
 88		with open(source, "r", encoding="utf-8") as fh:
 89			text = fh.read()
 90		return cls._parse_scl_text(text)
 91
 92	@classmethod
 93	def from_scl_string (cls, text: str) -> "Tuning":
 94		"""Parse a Scala .scl file from a string (useful for testing)."""
 95		return cls._parse_scl_text(text)
 96
 97	@classmethod
 98	def _parse_scl_text (cls, text: str) -> "Tuning":
 99		lines = [line.rstrip() for line in text.splitlines()]
100		non_comment: typing.List[str] = [l for l in lines if not l.lstrip().startswith("!")]
101
102		if len(non_comment) < 2:
103			raise ValueError("Malformed .scl: need description + count lines")
104
105		description = non_comment[0].strip()
106
107		try:
108			count = int(non_comment[1].strip())
109		except ValueError:
110			raise ValueError(f"Malformed .scl: expected integer count, got {non_comment[1]!r}")
111
112		pitch_lines = non_comment[2:2 + count]
113
114		if len(pitch_lines) < count:
115			raise ValueError(
116				f"Malformed .scl: expected {count} pitch values, got {len(pitch_lines)}"
117			)
118
119		cents_list: typing.List[float] = []
120		for raw in pitch_lines:
121			# Text after the pitch value is ignored (Scala spec)
122			token = raw.split()[0] if raw.split() else ""
123			cents_list.append(cls._parse_pitch_token(token))
124
125		return cls(cents=cents_list, description=description)
126
127	@staticmethod
128	def _parse_pitch_token (token: str) -> float:
129		"""Convert a single .scl pitch token to cents."""
130		if not token:
131			raise ValueError("Empty pitch token in .scl file")
132		if "." in token:
133			# Cents value
134			return float(token)
135		if "/" in token:
136			# Ratio like 3/2
137			num_str, den_str = token.split("/", 1)
138			ratio = int(num_str) / int(den_str)
139		else:
140			# Bare integer like 2 (interpreted as 2/1)
141			ratio = float(token)
142		if ratio <= 0:
143			raise ValueError(f"Non-positive ratio in .scl: {token!r}")
144		return 1200.0 * math.log2(ratio)
145
146	@classmethod
147	def from_cents (cls, cents: typing.List[float], description: str = "") -> "Tuning":
148		"""Construct a tuning from a list of cent values for degrees 1..N.
149
150		The implicit degree 0 (unison, 0.0 cents) is not included in ``cents``.
151		The last value is typically 1200.0 for an octave-repeating scale.
152		"""
153		return cls(cents=list(cents), description=description)
154
155	@classmethod
156	def from_ratios (cls, ratios: typing.List[float], description: str = "") -> "Tuning":
157		"""Construct a tuning from frequency ratios relative to 1/1.
158
159		Each ratio is converted to cents via ``1200 × log₂(ratio)``.
160		Pass ``2`` or ``2.0`` for the octave (1200 cents).
161		"""
162		cents = [1200.0 * math.log2(r) for r in ratios]
163		return cls(cents=cents, description=description)
164
165	@classmethod
166	def equal (cls, divisions: int = 12, period: float = 1200.0) -> "Tuning":
167		"""Construct an equal-tempered tuning with ``divisions`` equal steps per period.
168
169		``Tuning.equal(12)`` is standard 12-TET (no pitch bend needed).
170		``Tuning.equal(19)`` gives 19-tone equal temperament.
171		"""
172		step = period / divisions
173		cents = [step * i for i in range(1, divisions + 1)]
174		return cls(
175			cents=cents,
176			description=f"{divisions}-tone equal temperament",
177		)
178
179	# ── Core calculation ──────────────────────────────────────────────────────
180
181	def pitch_bend_for_note (
182		self,
183		midi_note: int,
184		reference_note: int = 60,
185		bend_range: float = 2.0,
186	) -> typing.Tuple[int, float]:
187		"""Return ``(nearest_12tet_note, bend_normalized)`` for a MIDI note number.
188
189		The MIDI note number is interpreted as a scale degree relative to
190		``reference_note`` (default 60 = C4, degree 0 of the scale).  The
191		tuning's cent table determines the exact frequency, and the nearest
192		12-TET MIDI note plus a fractional pitch bend corrects the remainder.
193
194		Parameters:
195			midi_note: The MIDI note to tune (0–127).
196			reference_note: MIDI note number that maps to degree 0 of the scale.
197			bend_range: Pitch wheel range in semitones (must match the synth's
198			    pitch-bend range setting).  Default ±2 semitones.
199
200		Returns:
201			A tuple ``(nearest_note, bend_normalized)`` where ``nearest_note``
202			is the integer MIDI note to send and ``bend_normalized`` is the
203			normalised pitch bend value (-1.0 to +1.0).
204		"""
205		if self.size == 0:
206			return midi_note, 0.0
207
208		steps_from_root = midi_note - reference_note
209		degree = steps_from_root % self.size
210		octave = steps_from_root // self.size
211
212		# Cent value for this degree (degree 0 = 0.0, degree k = cents[k-1])
213		degree_cents = 0.0 if degree == 0 else self.cents[degree - 1]
214
215		# Total cents from the root
216		total_cents = octave * self.period_cents + degree_cents
217
218		# Equivalent continuous 12-TET note number (100 cents per semitone)
219		continuous = reference_note + total_cents / 100.0
220
221		nearest = int(round(continuous))
222		nearest = max(0, min(127, nearest))
223
224		offset_semitones = continuous - nearest  # signed, in semitones
225
226		if bend_range <= 0:
227			bend_normalized = 0.0
228		else:
229			bend_normalized = max(-1.0, min(1.0, offset_semitones / bend_range))
230
231		return nearest, bend_normalized

A microtonal tuning system expressed as cent offsets from the unison.

The cents list contains the cent values for scale degrees 1 through N. Degree 0 (the unison, 0.0 cents) is always implicit and not stored. The last entry is typically 1200.0 cents (the octave) for octave-repeating scales, but any period is supported.

Create a Tuning from a file or programmatically:

Tuning.from_scl("meanquar.scl")          # Scala .scl file
Tuning.from_cents([100, 200, ..., 1200])  # explicit cents
Tuning.from_ratios([9/8, 5/4, ..., 2])   # frequency ratios
Tuning.equal(19)                          # 19-tone equal temperament
Tuning(cents: List[float], description: str = '')
cents: List[float]
description: str = ''
size: int
62	@property
63	def size (self) -> int:
64		"""Number of scale degrees per period (the .scl ``count`` line)."""
65		return len(self.cents)

Number of scale degrees per period (the .scl count line).

period_cents: float
67	@property
68	def period_cents (self) -> float:
69		"""Cent span of one period (typically 1200.0 for octave-repeating scales)."""
70		return self.cents[-1] if self.cents else 1200.0

Cent span of one period (typically 1200.0 for octave-repeating scales).

@classmethod
def from_scl(cls, source: Union[str, os.PathLike]) -> Tuning:
74	@classmethod
75	def from_scl (cls, source: typing.Union[str, os.PathLike]) -> "Tuning":
76		"""Parse a Scala .scl file.
77
78		``source`` is a file path.  Lines beginning with ``!`` are comments.
79		The first non-comment line is the description.  The second is the
80		integer count of pitch values.  Each subsequent line is a pitch:
81
82		- Contains ``.`` → cents (float).
83		- Contains ``/`` or is a bare integer → ratio; converted to cents via
84		  ``1200 × log₂(ratio)``.
85
86		Raises ``ValueError`` for malformed files.
87		"""
88		with open(source, "r", encoding="utf-8") as fh:
89			text = fh.read()
90		return cls._parse_scl_text(text)

Parse a Scala .scl file.

source is a file path. Lines beginning with ! are comments. The first non-comment line is the description. The second is the integer count of pitch values. Each subsequent line is a pitch:

  • Contains . → cents (float).
  • Contains / or is a bare integer → ratio; converted to cents via 1200 × log₂(ratio).

Raises ValueError for malformed files.

@classmethod
def from_scl_string(cls, text: str) -> Tuning:
92	@classmethod
93	def from_scl_string (cls, text: str) -> "Tuning":
94		"""Parse a Scala .scl file from a string (useful for testing)."""
95		return cls._parse_scl_text(text)

Parse a Scala .scl file from a string (useful for testing).

@classmethod
def from_cents( cls, cents: List[float], description: str = '') -> Tuning:
146	@classmethod
147	def from_cents (cls, cents: typing.List[float], description: str = "") -> "Tuning":
148		"""Construct a tuning from a list of cent values for degrees 1..N.
149
150		The implicit degree 0 (unison, 0.0 cents) is not included in ``cents``.
151		The last value is typically 1200.0 for an octave-repeating scale.
152		"""
153		return cls(cents=list(cents), description=description)

Construct a tuning from a list of cent values for degrees 1..N.

The implicit degree 0 (unison, 0.0 cents) is not included in cents. The last value is typically 1200.0 for an octave-repeating scale.

@classmethod
def from_ratios( cls, ratios: List[float], description: str = '') -> Tuning:
155	@classmethod
156	def from_ratios (cls, ratios: typing.List[float], description: str = "") -> "Tuning":
157		"""Construct a tuning from frequency ratios relative to 1/1.
158
159		Each ratio is converted to cents via ``1200 × log₂(ratio)``.
160		Pass ``2`` or ``2.0`` for the octave (1200 cents).
161		"""
162		cents = [1200.0 * math.log2(r) for r in ratios]
163		return cls(cents=cents, description=description)

Construct a tuning from frequency ratios relative to 1/1.

Each ratio is converted to cents via 1200 × log₂(ratio). Pass 2 or 2.0 for the octave (1200 cents).

@classmethod
def equal( cls, divisions: int = 12, period: float = 1200.0) -> Tuning:
165	@classmethod
166	def equal (cls, divisions: int = 12, period: float = 1200.0) -> "Tuning":
167		"""Construct an equal-tempered tuning with ``divisions`` equal steps per period.
168
169		``Tuning.equal(12)`` is standard 12-TET (no pitch bend needed).
170		``Tuning.equal(19)`` gives 19-tone equal temperament.
171		"""
172		step = period / divisions
173		cents = [step * i for i in range(1, divisions + 1)]
174		return cls(
175			cents=cents,
176			description=f"{divisions}-tone equal temperament",
177		)

Construct an equal-tempered tuning with divisions equal steps per period.

Tuning.equal(12) is standard 12-TET (no pitch bend needed). Tuning.equal(19) gives 19-tone equal temperament.

def pitch_bend_for_note( self, midi_note: int, reference_note: int = 60, bend_range: float = 2.0) -> Tuple[int, float]:
181	def pitch_bend_for_note (
182		self,
183		midi_note: int,
184		reference_note: int = 60,
185		bend_range: float = 2.0,
186	) -> typing.Tuple[int, float]:
187		"""Return ``(nearest_12tet_note, bend_normalized)`` for a MIDI note number.
188
189		The MIDI note number is interpreted as a scale degree relative to
190		``reference_note`` (default 60 = C4, degree 0 of the scale).  The
191		tuning's cent table determines the exact frequency, and the nearest
192		12-TET MIDI note plus a fractional pitch bend corrects the remainder.
193
194		Parameters:
195			midi_note: The MIDI note to tune (0–127).
196			reference_note: MIDI note number that maps to degree 0 of the scale.
197			bend_range: Pitch wheel range in semitones (must match the synth's
198			    pitch-bend range setting).  Default ±2 semitones.
199
200		Returns:
201			A tuple ``(nearest_note, bend_normalized)`` where ``nearest_note``
202			is the integer MIDI note to send and ``bend_normalized`` is the
203			normalised pitch bend value (-1.0 to +1.0).
204		"""
205		if self.size == 0:
206			return midi_note, 0.0
207
208		steps_from_root = midi_note - reference_note
209		degree = steps_from_root % self.size
210		octave = steps_from_root // self.size
211
212		# Cent value for this degree (degree 0 = 0.0, degree k = cents[k-1])
213		degree_cents = 0.0 if degree == 0 else self.cents[degree - 1]
214
215		# Total cents from the root
216		total_cents = octave * self.period_cents + degree_cents
217
218		# Equivalent continuous 12-TET note number (100 cents per semitone)
219		continuous = reference_note + total_cents / 100.0
220
221		nearest = int(round(continuous))
222		nearest = max(0, min(127, nearest))
223
224		offset_semitones = continuous - nearest  # signed, in semitones
225
226		if bend_range <= 0:
227			bend_normalized = 0.0
228		else:
229			bend_normalized = max(-1.0, min(1.0, offset_semitones / bend_range))
230
231		return nearest, bend_normalized

Return (nearest_12tet_note, bend_normalized) for a MIDI note number.

The MIDI note number is interpreted as a scale degree relative to reference_note (default 60 = C4, degree 0 of the scale). The tuning's cent table determines the exact frequency, and the nearest 12-TET MIDI note plus a fractional pitch bend corrects the remainder.

Arguments:
  • midi_note: The MIDI note to tune (0–127).
  • reference_note: MIDI note number that maps to degree 0 of the scale.
  • bend_range: Pitch wheel range in semitones (must match the synth's pitch-bend range setting). Default ±2 semitones.
Returns:

A tuple (nearest_note, bend_normalized) where nearest_note is the integer MIDI note to send and bend_normalized is the normalised pitch bend value (-1.0 to +1.0).

def between( low: float, high: float, step: Optional[float] = None) -> subsequence.harmonic_rhythm.HarmonicRhythm:
72def between (low: float, high: float, step: typing.Optional[float] = None) -> HarmonicRhythm:
73
74	"""A harmonic rhythm that varies *between* two lengths (in beats).
75
76	Each chord lasts a random length in ``[low, high]``.  Pass ``step`` to snap
77	those lengths to a grid — e.g. ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` gives
78	one, two, or three whole notes, never anything in between.
79
80	Reads aloud the way you'd describe it: "between one and three whole notes,
81	in whole-note steps."
82	"""
83
84	return HarmonicRhythm(low=low, high=high, step=step)

A harmonic rhythm that varies between two lengths (in beats).

Each chord lasts a random length in [low, high]. Pass step to snap those lengths to a grid — e.g. between(WHOLE, 3 * WHOLE, step=WHOLE) gives one, two, or three whole notes, never anything in between.

Reads aloud the way you'd describe it: "between one and three whole notes, in whole-note steps."

def parse_chord(name: str) -> Chord:
383def parse_chord (name: str) -> Chord:
384
385	"""Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`.
386
387	The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed
388	by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished,
389	``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor
390	7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``.  A few common
391	alternates (``min``, ``-``, ``M7``, …) are accepted too.
392
393	Raises ``ValueError`` for anything it can't read, so a typo surfaces at the
394	call site rather than as a silently wrong chord.
395
396	Example:
397		```python
398		parse_chord("Cm7")    # → Chord(root_pc=0, quality="minor_7th")
399		parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th")
400		parse_chord("F#")     # → Chord(root_pc=6, quality="major")
401		```
402	"""
403
404	stripped = name.strip()
405	if not stripped or stripped[0] not in "ABCDEFG":
406		raise ValueError(f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'")
407
408	split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1
409	root_name = stripped[:split]
410	suffix = stripped[split:]
411
412	if root_name not in NOTE_NAME_TO_PC:
413		raise ValueError(f"Cannot parse chord name {name!r} — unknown root {root_name!r}")
414	if suffix not in _SUFFIX_TO_QUALITY:
415		known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key)
416		raise ValueError(f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}")
417
418	return Chord(root_pc=NOTE_NAME_TO_PC[root_name], quality=_SUFFIX_TO_QUALITY[suffix])

Parse a chord name like "Cm7" or "Dbmaj7" into a Chord.

The name is a root note (AG with an optional # or b) followed by a quality suffix: "" major, m minor, dim diminished, +/aug augmented, 7 dominant 7th, maj7 major 7th, m7 minor 7th, m7b5/ø half-diminished 7th, sus2, sus4. A few common alternates (min, -, M7, …) are accepted too.

Raises ValueError for anything it can't read, so a typo surfaces at the call site rather than as a silently wrong chord.

Example:
parse_chord("Cm7")    # → Chord(root_pc=0, quality="minor_7th")
parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th")
parse_chord("F#")     # → Chord(root_pc=6, quality="major")
def register_chord_quality(name: str, intervals: List[int], suffix: Optional[str] = None) -> None:
302def register_chord_quality (
303	name: str,
304	intervals: typing.List[int],
305	suffix: typing.Optional[str] = None,
306) -> None:
307
308	"""Register a custom chord quality for use everywhere chords are used.
309
310	The counterpart to :func:`subsequence.intervals.register_scale` — it opens
311	the quality table so quartal stacks, clusters, and extended chords become
312	first-class symbolic chords: they work in progressions, graphs, voice
313	leading, and ``describe()`` output.
314
315	Built-in qualities (e.g. ``"minor"``) cannot be overwritten.  Custom names
316	may be re-registered freely — live reload re-runs registration on every
317	save, so this must not raise.
318
319	Parameters:
320		name: Quality name (used as ``Chord(root_pc, quality=name)``).
321		intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a
322			quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th).  Must start
323			with 0, ascend strictly, and stay within 0–24 (extensions reach
324			past the octave).
325		suffix: Optional chord-name suffix.  When given, ``parse_chord()``
326			accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so
327			``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")``
328			makes ``"Am9"`` parse from then on.  Must not collide with a
329			built-in suffix.
330
331	Example:
332		```python
333		import subsequence
334
335		subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4")
336		subsequence.parse_chord("Dq4")   # → Chord(root_pc=2, quality="quartal")
337		```
338	"""
339
340	if name in _BUILTIN_QUALITY_NAMES:
341		raise ValueError(
342			f"Cannot overwrite built-in chord quality '{name}'. "
343			"Choose a different name for your custom quality."
344		)
345
346	if not intervals:
347		raise ValueError("intervals must not be empty")
348	if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals):
349		raise ValueError("intervals must be whole numbers (semitone offsets)")
350	if intervals[0] != 0:
351		raise ValueError("intervals must start with 0 (the root)")
352	if any(b <= a for a, b in zip(intervals, intervals[1:])):
353		raise ValueError("intervals must be strictly ascending")
354	if any(i < 0 or i > 24 for i in intervals):
355		raise ValueError("intervals must contain values between 0 and 24")
356
357	if suffix is not None:
358		if suffix in _BUILTIN_SUFFIXES:
359			raise ValueError(
360				f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. "
361				"Choose a different suffix for your custom quality."
362			)
363		if not suffix or suffix[0] in "ABCDEFG#b0123456789":
364			raise ValueError(
365				f"Suffix {suffix!r} would be ambiguous in a chord name — "
366				"it must not be empty or start with a note letter, accidental, or digit"
367			)
368
369	# Re-registration: drop any suffix this quality registered previously, so
370	# renaming a suffix on live reload does not leave a stale alias behind.
371	for old_suffix in [s for s, q in _SUFFIX_TO_QUALITY.items() if q == name and s not in _BUILTIN_SUFFIXES]:
372		del _SUFFIX_TO_QUALITY[old_suffix]
373
374	CHORD_INTERVALS[name] = list(intervals)
375
376	if suffix is not None:
377		CHORD_SUFFIX[name] = suffix
378		_SUFFIX_TO_QUALITY[suffix] = name
379	else:
380		CHORD_SUFFIX.pop(name, None)

Register a custom chord quality for use everywhere chords are used.

The counterpart to subsequence.intervals.register_scale() — it opens the quality table so quartal stacks, clusters, and extended chords become first-class symbolic chords: they work in progressions, graphs, voice leading, and describe() output.

Built-in qualities (e.g. "minor") cannot be overwritten. Custom names may be re-registered freely — live reload re-runs registration on every save, so this must not raise.

Arguments:
  • name: Quality name (used as Chord(root_pc, quality=name)).
  • intervals: Semitone offsets from the root (e.g. [0, 5, 10] for a quartal stack, [0, 3, 7, 10, 14] for a minor 9th). Must start with 0, ascend strictly, and stay within 0–24 (extensions reach past the octave).
  • suffix: Optional chord-name suffix. When given, parse_chord() accepts "A" + suffix and Chord.name() prints it — so register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9") makes "Am9" parse from then on. Must not collide with a built-in suffix.
Example:
import subsequence

subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4")
subsequence.parse_chord("Dq4")   # → Chord(root_pc=2, quality="quartal")
def register_scale( name: str, intervals: List[int], qualities: Optional[List[str]] = None) -> None:
333def register_scale (
334	name: str,
335	intervals: typing.List[int],
336	qualities: typing.Optional[typing.List[str]] = None
337) -> None:
338
339	"""
340	Register a custom scale for use with ``p.snap_to_scale()`` and
341	``scale_pitch_classes()``.
342
343	Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be
344	overwritten.  Custom names may be re-registered freely — live reload
345	re-runs registration on every save, so this must not raise.
346
347	Parameters:
348		name: Scale name (used in ``p.snap_to_scale(key, name)``).  Must not
349			be the name of a built-in scale.
350		intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]``
351			for Hirajōshi). Must be whole numbers, start with 0, ascend
352			strictly, and stay within 0–11.
353		qualities: Optional chord quality per scale degree (e.g.
354			``["minor", "major", "minor", "major", "diminished"]``).
355			Required only if you want to use the scale with
356			``diatonic_chords()`` or ``diatonic_chord_sequence()``.
357
358	Raises:
359		ValueError: If *name* is a built-in scale, or *intervals* /
360			*qualities* fail the rules above.
361
362	Example::
363
364		import subsequence
365
366		subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])
367
368		@comp.pattern(channel=0, length=4)
369		def melody (p):
370			p.note(60, beat=0)
371			p.snap_to_scale("C", "raga_bhairav")
372	"""
373
374	if name in _BUILTIN_SCALE_NAMES:
375		raise ValueError(
376			f"Cannot overwrite built-in scale '{name}'. "
377			"Choose a different name for your custom scale."
378		)
379
380	if not intervals:
381		raise ValueError("intervals must not be empty")
382	if not all(isinstance(i, int) for i in intervals):
383		raise ValueError("intervals must be whole numbers (semitone offsets)")
384	if intervals[0] != 0:
385		raise ValueError("intervals must start with 0")
386	if any(b <= a for a, b in zip(intervals, intervals[1:])):
387		raise ValueError("intervals must be strictly ascending")
388	if any(i < 0 or i > 11 for i in intervals):
389		raise ValueError("intervals must contain values between 0 and 11")
390	if qualities is not None and len(qualities) != len(intervals):
391		raise ValueError(
392			f"qualities length ({len(qualities)}) must match "
393			f"intervals length ({len(intervals)})"
394		)
395
396	INTERVAL_DEFINITIONS[name] = intervals
397	SCALE_MODE_MAP[name] = (name, qualities)

Register a custom scale for use with p.snap_to_scale() and scale_pitch_classes().

Built-in scale names (e.g. "minor", "hirajoshi") cannot be overwritten. Custom names may be re-registered freely — live reload re-runs registration on every save, so this must not raise.

Arguments:
  • name: Scale name (used in p.snap_to_scale(key, name)). Must not be the name of a built-in scale.
  • intervals: Semitone offsets from the root (e.g. [0, 2, 3, 7, 8] for Hirajōshi). Must be whole numbers, start with 0, ascend strictly, and stay within 0–11.
  • qualities: Optional chord quality per scale degree (e.g. ["minor", "major", "minor", "major", "diminished"]). Required only if you want to use the scale with diatonic_chords() or diatonic_chord_sequence().
Raises:
  • ValueError: If name is a built-in scale, or intervals / qualities fail the rules above.

Example::

    import subsequence

    subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11])

    @comp.pattern(channel=0, length=4)
    def melody (p):
            p.note(60, beat=0)
            p.snap_to_scale("C", "raga_bhairav")
def scale_notes( key: str, mode: str = 'ionian', low: int = 60, high: int = 72, count: Optional[int] = None) -> List[int]:
189def scale_notes (
190	key: str,
191	mode: str = "ionian",
192	low: int = 60,
193	high: int = 72,
194	count: typing.Optional[int] = None,
195) -> typing.List[int]:
196
197	"""Return MIDI note numbers for a scale within a pitch range.
198
199	Parameters:
200		key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.).
201		     This acts as a **pitch-class filter only** — it determines which
202		     semitone positions (0–11) are valid members of the scale, but does
203		     not affect which octave notes are drawn from. Notes are selected
204		     starting from ``low`` upward; ``key`` controls *which* notes are
205		     kept, not where the sequence starts. To guarantee the first
206		     returned note is the root, ``low`` must be a MIDI number whose
207		     pitch class matches ``key``. When starting from an arbitrary MIDI
208		     number, derive the key name with
209		     ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``.
210		mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP`
211		      (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``,
212		      ``"major_pentatonic"``). Use :func:`register_scale` for custom scales.
213		low: Lowest MIDI note (inclusive). When ``count`` is set, this is
214		     the starting note from which the scale ascends. **If ``low`` is
215		     not a member of the scale defined by ``key``, it is silently
216		     skipped** and the first returned note will be the next in-scale
217		     pitch above ``low``.
218		high: Highest MIDI note (inclusive). Ignored when ``count`` is set.
219		count: Exact number of notes to return. Notes ascend from ``low``
220		       through successive scale degrees, cycling into higher octaves
221		       as needed. When ``None`` (default), all scale tones between
222		       ``low`` and ``high`` are returned.
223
224	Returns:
225		Sorted list of MIDI note numbers.
226
227	Examples:
228		```python
229		import subsequence
230		import subsequence.constants.midi_notes as notes
231
232		# C major: all tones from middle C to C5
233		subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
234		# → [60, 62, 64, 65, 67, 69, 71, 72]
235
236		# E natural minor (aeolian) across one octave
237		subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3)
238		# → [40, 42, 43, 45, 47, 48, 50, 52]
239
240		# 15 notes of A minor pentatonic ascending from A3
241		subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15)
242		# → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91]
243
244		# Misalignment: key="E" but low=C4 — first note is C, not E
245		subsequence.scale_notes("E", "minor", low=60, count=4)
246		# → [60, 62, 64, 66]  (C D E F# — all in E natural minor, but starts on C)
247
248		# Fix: derive key name from root_pitch so low is always in the scale
249		root_pitch = 64  # E4
250		key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]  # → "E"
251		subsequence.scale_notes(key, "minor", low=root_pitch, count=4)
252		# → [64, 66, 67, 69]  (E F# G A — starts on the root)
253		```
254	"""
255
256	key_pc = subsequence.chords.key_name_to_pc(key)
257	pcs = set(scale_pitch_classes(key_pc, mode))
258
259	if count is not None:
260		if not pcs:
261			return []
262		result: typing.List[int] = []
263		pitch = low
264		while len(result) < count and pitch <= 127:
265			if pitch % 12 in pcs:
266				result.append(pitch)
267			pitch += 1
268		return result
269
270	return [p for p in range(low, high + 1) if p % 12 in pcs]

Return MIDI note numbers for a scale within a pitch range.

Arguments:
  • key: Scale root as a note name ("C", "F#", "Bb", etc.). This acts as a pitch-class filter only — it determines which semitone positions (0–11) are valid members of the scale, but does not affect which octave notes are drawn from. Notes are selected starting from low upward; key controls which notes are kept, not where the sequence starts. To guarantee the first returned note is the root, low must be a MIDI number whose pitch class matches key. When starting from an arbitrary MIDI number, derive the key name with subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12].
  • mode: Scale mode name. Supports all keys of SCALE_MODE_MAP (e.g. "ionian", "dorian", "natural_minor", "major_pentatonic"). Use register_scale() for custom scales.
  • low: Lowest MIDI note (inclusive). When count is set, this is the starting note from which the scale ascends. If low is not a member of the scale defined by key, it is silently skipped and the first returned note will be the next in-scale pitch above low.
  • high: Highest MIDI note (inclusive). Ignored when count is set.
  • count: Exact number of notes to return. Notes ascend from low through successive scale degrees, cycling into higher octaves as needed. When None (default), all scale tones between low and high are returned.
Returns:

Sorted list of MIDI note numbers.

Examples:
import subsequence
import subsequence.constants.midi_notes as notes

# C major: all tones from middle C to C5
subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5)
# → [60, 62, 64, 65, 67, 69, 71, 72]

# E natural minor (aeolian) across one octave
subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3)
# → [40, 42, 43, 45, 47, 48, 50, 52]

# 15 notes of A minor pentatonic ascending from A3
subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15)
# → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91]

# Misalignment: key="E" but low=C4 — first note is C, not E
subsequence.scale_notes("E", "minor", low=60, count=4)
# → [60, 62, 64, 66]  (C D E F# — all in E natural minor, but starts on C)

# Fix: derive key name from root_pitch so low is always in the scale
root_pitch = 64  # E4
key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]  # → "E"
subsequence.scale_notes(key, "minor", low=root_pitch, count=4)
# → [64, 66, 67, 69]  (E F# G A — starts on the root)
def bank_select(bank: int) -> Tuple[int, int]:
178def bank_select (bank: int) -> typing.Tuple[int, int]:
179
180	"""
181	Convert a 14-bit MIDI bank number to (MSB, LSB) for use with
182	``p.program_change()``.
183
184	MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and
185	CC 32 (Bank LSB).  Together they encode a 14-bit bank number in the
186	range 0–16,383:
187
188	    MSB = bank // 128   (upper 7 bits, sent on CC 0)
189	    LSB = bank % 128    (lower 7 bits, sent on CC 32)
190
191	Args:
192		bank: Integer bank number, 0–16,383.  Values outside this range are
193		      clamped.
194
195	Returns:
196		``(msb, lsb)`` tuple, each value in 0–127.
197
198	Example:
199		```python
200		msb, lsb = subsequence.bank_select(128)   # → (1, 0)
201		p.program_change(48, bank_msb=msb, bank_lsb=lsb)
202		```
203	"""
204
205	bank = max(0, min(16383, bank))
206	return bank >> 7, bank & 0x7F

Convert a 14-bit MIDI bank number to (MSB, LSB) for use with p.program_change().

MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and CC 32 (Bank LSB). Together they encode a 14-bit bank number in the range 0–16,383:

MSB = bank // 128   (upper 7 bits, sent on CC 0)
LSB = bank % 128    (lower 7 bits, sent on CC 32)
Arguments:
  • bank: Integer bank number, 0–16,383. Values outside this range are clamped.
Returns:

(msb, lsb) tuple, each value in 0–127.

Example:
msb, lsb = subsequence.bank_select(128)   # → (1, 0)
p.program_change(48, bank_msb=msb, bank_lsb=lsb)