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. A dozen 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 ofghost_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.golden()), Fibonacci cycles whose length the pitch pool chooses (p.fibonacci()), never-repeating two-voice wedges from Recamán's sequence (p.recaman()), 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 viap.swing()(a shortcut forGroove.swing()), randomize, velocity shaping and ramps (p.build_velocity_ramp()), dropout, per-step probability, and polyrhythms via independent pattern lengths. - Melody generation.
p.melody()withMelodicStateapplies 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()andp.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, orbetween(WHOLE, 3 * WHOLE, step=WHOLE)for chords of varying, quantized length. Voicing density,detachedarticulation, 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.sectionto adapt. Conductor signals (LFOs, ramps) shape intensity over time. - Sequences as lists.
p.hit_steps("kick", [0, 4, 8, 12])andp.sequence(steps=..., pitches=..., velocities=...)place rhythms and lines from plain Python lists - the vocabulary the generator and density helpers insequence_utilsall speak. - 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, plusregister_scale()for your own. - Microtonal tuning.
composition.tuning()applies a tuning system globally;p.apply_tuning()overrides per-pattern. Supports Scala.sclfiles, 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, withrng=for an explicit instance — precedencerng>seed> the pattern'sp.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 conditionalp.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 owndrum_note_mapso one named drum hit re-resolves to the right voice on each device — a DRM1 and a General MIDI sampler alike. - Shared project definitions.
load_definitions("project.yaml")reads a small per-project YAML file naming notes, CCs, channels, programs, and NRPNs — the same file the Subsample sampler reads — so both tools use identical names and a renumber is a single edit. - Hardware control. CC input mapping from knobs/faders to
composition.data; patterns read and write the same dict viap.datafor 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 withp.held_notes()and arpeggiates them (p.arpeggio(p.held_notes())), withrelease_msdebounce andlatch. 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=Truefor an ASCII pattern grid showing velocity and sustain - makes legato, detached, and staccato articulations visually distinct at a glance. Addgrid_scale=2to 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(); requirespip 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:
- Discussions: Chat and ask questions at https://github.com/simonholliday/subsequence/discussions
- Issues: Report bugs and request features at https://github.com/simonholliday/subsequence/issues
Package-level exports: Composition, Chord, Groove, MelodicState, Tuning, Motif, Phrase, motif, sentence, period, Degree, ChordTone, Approach, MotifEvent, ControlEvent, Progression, ChordSpan, PitchSet, progression, Cadence, Section, Form, roles, sieve, residual_class, between, parse_chord, register_chord_quality, register_scale, scale_notes, bank_select, Definitions, load_definitions.
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 A dozen 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.golden()``), 54 Fibonacci cycles whose length the pitch pool chooses (``p.fibonacci()``), 55 never-repeating two-voice wedges from Recamán's sequence (``p.recaman()``), 56 Gray-Scott reaction-diffusion patterns (``p.reaction_diffusion()``), 57 Lorenz strange-attractor generation (``p.lorenz()``), exhaustive 58 pitch-subsequence melodies (``p.de_bruijn()``), step-wise melodies 59 with guaranteed pitch diversity (``p.self_avoiding_walk()``), drones 60 and explicit note on/off events (``p.drone()``, ``p.drone_off()``, 61 ``p.silence()``), 62 groove templates (``Groove.swing()``, ``Groove.from_agr()``), swing via 63 ``p.swing()`` (a shortcut for ``Groove.swing()``), randomize, 64 velocity shaping and ramps (``p.build_velocity_ramp()``), dropout, per-step 65 probability, and polyrhythms via independent pattern lengths. 66- **Melody generation.** ``p.melody()`` with ``MelodicState`` applies 67 the Narmour Implication-Realization model to single-note lines: 68 continuation after small steps, reversal after large leaps, chord-tone 69 weighting, range gravity, and pitch-diversity penalty. History persists 70 across bar rebuilds for natural phrase continuity. 71- **Chord parts.** ``comp.chords()`` and ``p.progression()`` play a chord 72 progression — generated from a chord-graph style or given explicitly — at a 73 declared *harmonic rhythm*: a fixed length, a shaped ``[WHOLE, HALF, HALF]`` 74 sequence, or ``between(WHOLE, 3 * WHOLE, step=WHOLE)`` for chords of varying, 75 quantized length. Voicing density, ``detached`` articulation, and a seed for 76 a fixed phrase are all declarative. 77- **Expression.** CC messages/ramps, pitch bend, note-correlated 78 bend/portamento/slide, program changes, SysEx, and OSC output - all 79 from within patterns. 80- **Form and structure.** Musical form as a weighted graph, ordered list, 81 or generator. Patterns read ``p.section`` to adapt. Conductor signals 82 (LFOs, ramps) shape intensity over time. 83- **Sequences as lists.** ``p.hit_steps("kick", [0, 4, 8, 12])`` and 84 ``p.sequence(steps=..., pitches=..., velocities=...)`` place rhythms 85 and lines from plain Python lists - the vocabulary the generator and 86 density helpers in ``sequence_utils`` all speak. 87- **Scales.** ``p.snap_to_scale()`` snaps notes to any 88 scale. ``scale_notes()`` generates a list of MIDI note numbers from 89 a key, mode, and range or note count - useful for arpeggios, Markov 90 chains, and melodic walks. Built-in western and non-western modes, 91 plus ``register_scale()`` for your own. 92- **Microtonal tuning.** ``composition.tuning()`` applies a tuning 93 system globally; ``p.apply_tuning()`` overrides per-pattern. 94 Supports Scala ``.scl`` files, explicit cent lists, frequency ratios, 95 and N-TET equal temperaments. Polyphonic parts use explicit channel 96 rotation so simultaneous notes can carry independent pitch bends 97 without MPE. Compatible with any standard MIDI synthesiser. 98- **Randomness tools.** Weighted choice, no-repeat shuffle, random 99 walk, probability gates. Deterministic seeding makes every decision 100 repeatable: set it composition-wide (``seed=42``) or per generator 101 (``seed=`` on any generator, with ``rng=`` for an explicit instance — 102 precedence ``rng`` > ``seed`` > the pattern's ``p.rng``). See the 103 README "Conventions" section for the API's shared vocabulary. 104- **Pattern transforms.** Legato, detached, fixed gate (``p.duration()``), 105 reverse, time-stretch, rotate, transpose, invert, randomize, and 106 conditional ``p.every()``. 107 108Integration: 109 110- **MIDI clock.** Master (``clock_output()``) or follower 111 (``clock_follow=True``). When multiple inputs are connected, only 112 one may be designated as the master clock source; messages from 113 other inputs are filtered to prevent sync interference. Sync to a 114 DAW or drive hardware. 115- **Latency compensation.** Declare each output device's physical 116 latency (``latency_ms=``); Subsequence delays the faster devices so 117 a mix of hardware and slower software instruments sound together. 118- **MIDI mirroring with per-device drum maps.** Fan a pattern out to 119 extra ``(device, channel)`` destinations; an entry can carry its own 120 ``drum_note_map`` so one named drum hit re-resolves to the right voice 121 on each device — a DRM1 and a General MIDI sampler alike. 122- **Shared project definitions.** ``load_definitions("project.yaml")`` 123 reads a small per-project YAML file naming notes, CCs, channels, 124 programs, and NRPNs — the same file the Subsample sampler reads — so 125 both tools use identical names and a renumber is a single edit. 126- **Hardware control.** CC input mapping from knobs/faders to 127 ``composition.data``; patterns read and write the same dict via 128 ``p.data`` for both external data access and cross-pattern 129 communication. OSC for bidirectional communication with mixers, 130 lighting, visuals. 131- **Live held-note arpeggiator.** ``composition.note_input()`` tracks the 132 notes you hold on a keyboard; a pattern reads them with ``p.held_notes()`` 133 and arpeggiates them (``p.arpeggio(p.held_notes())``), with ``release_ms`` 134 debounce and ``latch``. A performance layer over the deterministic 135 composition - empty when rendering headlessly. 136- **Live coding.** Hot-swap patterns, change tempo, mute/unmute, and 137 tweak parameters during playback via a built-in TCP eval server. 138- **Hotkeys.** Single keystrokes to jump sections, toggle mutes, or 139 fire any action - with optional bar-boundary quantization. 140- **Real-time pattern triggering.** ``composition.trigger()`` generates 141 one-shot patterns in response to sensors, OSC, or any event. 142- **Terminal display.** Live status line (BPM, bar, section, chord). 143 Add ``grid=True`` for an ASCII pattern grid showing velocity and 144 sustain - makes legato, detached, and staccato articulations visually 145 distinct at a glance. 146 Add ``grid_scale=2`` to zoom in horizontally, revealing swing and 147 groove micro-timing. 148- **Web UI Dashboard (Beta).** Enable with ``composition.web_ui()`` to 149 broadcast live composition metadata and visualize piano-roll pattern 150 grids in a reactive HTTP/WebSocket browser dashboard. 151- **Ableton Link.** Industry-standard wireless tempo/phase sync 152 (``comp.link()``; requires ``pip install subsequence[link]``). 153 Any Link-enabled app on the same LAN — Ableton Live, iOS synths, 154 other Subsequence instances — stays in time automatically. 155- **Recording.** Record to standard MIDI file. Render to file without 156 waiting for real-time playback. 157 158Minimal example: 159 160 ```python 161 import subsequence 162 import subsequence.constants.instruments.gm_drums as gm_drums 163 164 comp = subsequence.Composition(bpm=120) 165 166 @comp.pattern(channel=10, beats=4, drum_note_map=gm_drums.GM_DRUM_MAP) 167 def drums (p): 168 (p.hit_steps("kick_1", [0, 4, 8, 12], velocity=100) 169 .hit_steps("snare_1", [4, 12], velocity=90) 170 .hit_steps("hi_hat_closed", range(16), velocity=70)) 171 172 comp.play() 173 ``` 174 175Community and Feedback: 176 177- **Discussions:** Chat and ask questions at https://github.com/simonholliday/subsequence/discussions 178- **Issues:** Report bugs and request features at https://github.com/simonholliday/subsequence/issues 179 180Package-level exports: ``Composition``, ``Chord``, ``Groove``, ``MelodicState``, ``Tuning``, ``Motif``, ``Phrase``, ``motif``, ``sentence``, ``period``, ``Degree``, ``ChordTone``, ``Approach``, ``MotifEvent``, ``ControlEvent``, ``Progression``, ``ChordSpan``, ``PitchSet``, ``progression``, ``Cadence``, ``Section``, ``Form``, ``roles``, ``sieve``, ``residual_class``, ``between``, ``parse_chord``, ``register_chord_quality``, ``register_scale``, ``scale_notes``, ``bank_select``, ``Definitions``, ``load_definitions``. 181""" 182 183import subsequence.cadences 184import subsequence.chords 185import subsequence.forms 186import subsequence.roles 187import subsequence.composition 188import subsequence.definitions 189import subsequence.groove 190import subsequence.harmonic_rhythm 191import subsequence.intervals 192import subsequence.melodic_state 193import subsequence.midi_utils 194import subsequence.motifs 195import subsequence.progressions 196import subsequence.sequence_utils 197import subsequence.tuning 198 199 200Composition = subsequence.composition.Composition 201Motif = subsequence.motifs.Motif 202Phrase = subsequence.motifs.Phrase 203motif = subsequence.motifs.motif 204sentence = subsequence.motifs.sentence 205period = subsequence.motifs.period 206Cadence = subsequence.cadences.Cadence 207Section = subsequence.forms.Section 208Form = subsequence.forms.Form 209Degree = subsequence.motifs.Degree 210ChordTone = subsequence.motifs.ChordTone 211Approach = subsequence.motifs.Approach 212MotifEvent = subsequence.motifs.MotifEvent 213ControlEvent = subsequence.motifs.ControlEvent 214Progression = subsequence.progressions.Progression 215ChordSpan = subsequence.progressions.ChordSpan 216PitchSet = subsequence.progressions.PitchSet 217progression = subsequence.progressions.progression 218Chord = subsequence.chords.Chord 219Groove = subsequence.groove.Groove 220MelodicState = subsequence.melodic_state.MelodicState 221Tuning = subsequence.tuning.Tuning 222between = subsequence.harmonic_rhythm.between 223parse_chord = subsequence.chords.parse_chord 224register_chord_quality = subsequence.chords.register_chord_quality 225register_scale = subsequence.intervals.register_scale 226scale_notes = subsequence.intervals.scale_notes 227bank_select = subsequence.midi_utils.bank_select 228Definitions = subsequence.definitions.Definitions 229load_definitions = subsequence.definitions.load_definitions 230roles = subsequence.roles 231sieve = subsequence.sequence_utils.sieve 232residual_class = subsequence.sequence_utils.residual_class
1264class Composition: 1265 1266 """ 1267 The top-level controller for a musical piece. 1268 1269 The ``Composition`` object manages the global clock (Sequencer), the harmonic 1270 progression (HarmonicState), the song structure (subsequence.form_state.FormState), and all MIDI patterns. 1271 It serves as the main entry point for defining your music. 1272 1273 Typical workflow: 1274 1275 1. Initialize ``Composition`` with BPM and Key. 1276 2. Define harmony and form (optional). 1277 3. Register patterns using the ``@composition.pattern`` decorator. 1278 4. Call ``composition.play()`` to start the music. 1279 """ 1280 1281 def __init__ ( 1282 self, 1283 output_device: typing.Optional[str] = None, 1284 bpm: float = 120, 1285 time_signature: typing.Tuple[int, int] = (4, 4), 1286 key: typing.Optional[str] = None, 1287 scale: typing.Optional[str] = None, 1288 seed: typing.Optional[int] = None, 1289 record: bool = False, 1290 record_filename: typing.Optional[str] = None, 1291 zero_indexed_channels: bool = False, 1292 latency_ms: float = 0.0 1293 ) -> None: 1294 1295 """ 1296 Initialize a new composition. 1297 1298 Parameters: 1299 output_device: Which MIDI output port to use, matched against 1300 ``mido.get_output_names()``. The name is treated as a 1301 pattern: ``*`` stands for any run of characters and ``?`` 1302 for exactly one, matching is case-insensitive, and a name 1303 with no wildcards is simply a substring — so a plain 1304 ``"Scarlett"`` finds the port without typing the rest. 1305 An exact name always wins outright. 1306 1307 Wildcards matter on Linux/ALSA, where names carry the 1308 client and port ids (e.g. 1309 ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``). The 1310 client id — ``16`` here — is handed out in connection order 1311 and moves between reboots or when a virtual port is 1312 recreated, while the port index after it (``0``) stays put. 1313 Wildcard the one that moves and keep the one that does not:: 1314 1315 "*Scarlett 2i4 USB *:0" 1316 1317 Keep that trailing port index. A multi-port interface 1318 reports one name per port, so ``"*U6MIDI Pro*"`` matches 1319 all three ports of a 3-port unit and asks which you meant 1320 at every launch, while ``"*U6MIDI Pro *:0"`` names one for 1321 good. Prefer ``*`` to ``?`` — ``?`` matches a single 1322 character, so a pattern written for ``16:0`` quietly stops 1323 matching once ids reach three digits. To look up the 1324 current names:: 1325 1326 import mido 1327 for n in mido.get_output_names(): print(n) 1328 1329 If ``None``, Subsequence auto-discovers — uses the only 1330 available device, or prompts to choose if several exist. 1331 bpm: Initial tempo in beats per minute (default 120). 1332 time_signature: The metre as ``(beats, unit)``, default ``(4, 4)``. 1333 Sets the bar length everywhere bars matter: ``bars=`` pattern 1334 lengths, ``p.bar``/``p.signal()``, form advancement and 1335 transitions, and pinned-chord bar numbers. 1336 key: The root key of the piece (e.g., "C", "F#", "Bb"). 1337 Required if you plan to use ``harmony()``. 1338 scale: The scale/mode of the piece (e.g. "minor", "dorian", 1339 or any registered scale name). Used to resolve scale 1340 degrees in motifs; defaults to major (ionian) when unset. 1341 seed: An optional integer for deterministic randomness. When set, 1342 every random decision (chord choices, drum probability, etc.) 1343 will be identical on every run. 1344 record: When True, record all MIDI events to a file. 1345 record_filename: Optional filename for the recording (defaults to timestamp). 1346 zero_indexed_channels: When False (default), MIDI channels use 1347 1-based numbering (1-16) matching instrument labelling. 1348 Channel 10 is drums, the way musicians and hardware panels 1349 show it. When True, channels use 0-based numbering (0-15) 1350 matching the raw MIDI protocol. 1351 latency_ms: Physical output latency of the primary device in 1352 milliseconds, for delay compensation (default 0.0, must be 1353 non-negative). Set this when the primary output sounds late 1354 (e.g. a software sampler) so Subsequence delays faster 1355 devices to line everything up. See ``midi_output()`` for 1356 additional devices. 1357 1358 Example: 1359 ```python 1360 comp = subsequence.Composition(bpm=128, key="Eb", seed=123) 1361 ``` 1362 """ 1363 1364 if latency_ms < 0: 1365 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 1366 1367 self.output_device = output_device 1368 self.bpm = bpm 1369 self.time_signature = time_signature 1370 self.key = key 1371 self.scale = scale 1372 self._seed: typing.Optional[int] = seed 1373 self._zero_indexed_channels: bool = zero_indexed_channels 1374 self._output_latency_ms: float = latency_ms 1375 1376 # Determinism plumbing: named-stream derivation state. Build-time 1377 # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so 1378 # adding one call never shifts another's stream; play-time pattern 1379 # streams are name-keyed in _build_pattern_from_pending. 1380 self._freeze_count: int = 0 1381 self._harmony_count: int = 0 1382 self._form_count: int = 0 1383 self._reroll_nonces: typing.Dict[str, int] = {} 1384 self._locked_names: typing.Set[str] = set() 1385 1386 self._sequencer = subsequence.sequencer.Sequencer( 1387 output_device_name = output_device, 1388 initial_bpm = bpm, 1389 time_signature = time_signature, 1390 record = record, 1391 record_filename = record_filename 1392 ) 1393 1394 self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None 1395 self._harmony_cycle_beats: typing.Optional[int] = None 1396 self._harmony_style: typing.Optional[str] = None 1397 # The style (name or ChordGraph) from the most recent style-configuring 1398 # harmony() call — reused by parameter-only re-calls. 1399 self._last_harmony_style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None 1400 self._harmony_reschedule_lookahead: float = 1 1401 self._section_progressions: typing.Dict[str, Progression] = {} 1402 self._bound_progression: typing.Optional[Progression] = None 1403 self._pinned_chords: typing.Dict[int, typing.Any] = {} 1404 self._cadence_requests: typing.Dict[int, str] = {} 1405 self._section_cadences: typing.Dict[str, str] = {} 1406 self._harmony_horizon = _HarmonyHorizon() 1407 # True once the span-walking clock is registered for this playback — 1408 # lets a first mid-playback harmony() call start it exactly once. 1409 self._harmonic_clock_started: bool = False 1410 self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {} 1411 self._energy_map: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 1412 self._form_has_payload: bool = False 1413 self._form_key: typing.Optional[str] = None 1414 self._form_scale: typing.Optional[str] = None 1415 # Cache of section progressions resolved against an effective key/scale 1416 # (key-relative section harmony re-keys per occurrence; resolution is a 1417 # pure function of (content, key, scale), so this is just memoisation). 1418 self._resolved_section_cache: typing.Dict[typing.Tuple[str, typing.Optional[str], typing.Optional[str]], Progression] = {} 1419 self._transitions: typing.List[_Transition] = [] 1420 self._transition_muted: typing.Set[str] = set() 1421 self._pending_patterns: typing.List[_PendingPattern] = [] 1422 # Names of patterns declared by the most recent live-reload exec (added by 1423 # pattern()/layer() as they run); the deletion diff in _apply_source_async 1424 # compares this against the same source's PREVIOUS exec. 1425 self._declared_names: typing.Set[str] = set() 1426 # Per-source declaration history: source label/path → the names it 1427 # declared last time it was exec'd. The deletion diff unregisters only 1428 # names a source used to declare and no longer does — never patterns 1429 # registered by the wrapper script or by another watched source. 1430 self._source_declared: typing.Dict[str, typing.Set[str]] = {} 1431 self._pending_scheduled: typing.List[_PendingScheduled] = [] 1432 self._form_state: typing.Optional[subsequence.form_state.FormState] = None 1433 self._builder_bar: int = 0 1434 self._display: typing.Optional[subsequence.display.Display] = None 1435 self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None 1436 self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None 1437 self._is_live: bool = False 1438 self._running_patterns: typing.Dict[str, typing.Any] = {} 1439 self._input_device: typing.Optional[str] = None 1440 self._input_device_alias: typing.Optional[str] = None 1441 self._clock_follow: bool = False 1442 self._clock_output: bool = False 1443 self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] 1444 self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] 1445 # Held-note input config from note_input() (None = not declared). 1446 self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None 1447 # Additional output devices registered with midi_output() after construction. 1448 self._additional_outputs: typing.List[_AdditionalOutput] = [] 1449 # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) 1450 self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = [] 1451 # Maps alias/name → output device index (populated in _run after all devices are opened). 1452 self._output_device_names: typing.Dict[str, int] = {} 1453 # Maps alias/name → input device index (populated in _run after all input devices are opened). 1454 self._input_device_names: typing.Dict[str, int] = {} 1455 self.data: typing.Dict[str, typing.Any] = {} 1456 self._osc_server: typing.Optional[subsequence.osc.OscServer] = None 1457 self.conductor = subsequence.conductor.Conductor() 1458 self._web_ui_enabled: bool = False 1459 self._web_ui_http_host: str = "127.0.0.1" 1460 self._web_ui_ws_host: str = "127.0.0.1" 1461 self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None 1462 self._link_quantum: typing.Optional[float] = None 1463 1464 # Hotkey state — populated by hotkeys() and hotkey(). 1465 self._hotkeys_enabled: bool = False 1466 self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} 1467 self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] 1468 self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None 1469 1470 # Tuning state — populated by tuning(). 1471 self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning 1472 self._tuning_bend_range: float = 2.0 1473 self._tuning_channels: typing.Optional[typing.List[int]] = None 1474 self._tuning_reference_note: int = 60 1475 self._tuning_exclude_drums: bool = True 1476 1477 def _resolve_device_id (self, device: subsequence.midi_utils.DeviceId) -> int: 1478 """Resolve an output device id (None/int/str) to an integer index. 1479 1480 ``None`` → 0 (primary device). ``int`` → returned as-is. 1481 ``str`` → looked up in ``_output_device_names``; logs a warning and 1482 returns 0 if the name is unknown (called after all devices are opened 1483 in ``_run()``). 1484 """ 1485 if device is None: 1486 return 0 1487 if isinstance(device, int): 1488 return device 1489 idx = self._output_device_names.get(device) 1490 if idx is None: 1491 logger.warning( 1492 f"Unknown output device name '{device}' — routing to device 0. " 1493 f"Available names: {list(self._output_device_names.keys())}" 1494 ) 1495 return 0 1496 return idx 1497 1498 def _resolve_input_device_id (self, device: subsequence.midi_utils.DeviceId) -> typing.Optional[int]: 1499 """Resolve an input device id (None/int/str) to an integer index. 1500 1501 ``None`` → ``None`` (matches any input device — existing behaviour). 1502 ``int`` → returned as-is. ``str`` → looked up in ``_input_device_names``; 1503 logs a warning and returns ``-1`` if the name is unknown — an index no 1504 real device carries, so the mapping matches NOTHING (returning None 1505 here would silently fail OPEN and listen to every device). 1506 Called after all input devices are opened in ``_run()``. 1507 """ 1508 if device is None: 1509 return None 1510 if isinstance(device, int): 1511 return device 1512 idx = self._input_device_names.get(device) 1513 if idx is None: 1514 logger.warning( 1515 f"Unknown input device name '{device}' — mapping will be ignored. " 1516 f"Available names: {list(self._input_device_names.keys())}" 1517 ) 1518 return -1 1519 return idx 1520 1521 def _resolve_pending_devices (self) -> None: 1522 """Resolve name-based device ids on pending patterns now that all output devices are open.""" 1523 for pending in self._pending_patterns: 1524 if isinstance(pending.raw_device, str): 1525 pending.device = self._resolve_device_id(pending.raw_device) 1526 1527 async def _activate_new_pending_patterns (self) -> None: 1528 1529 """Build and schedule any pending patterns whose names are not yet running. 1530 1531 Used by ``LiveReloader._reload_async`` to bring NEW patterns added 1532 in a live reload into rotation mid-flight. Existing patterns 1533 hot-swap via the decorator (their ``_builder_fn`` is replaced in 1534 place); only patterns whose names are not yet in ``_running_patterns`` 1535 need this graduation step. 1536 1537 Newly-scheduled patterns start at the current sequencer pulse — 1538 they'll generate events from now onward, and the next reschedule 1539 will fire at the same offset as their primary cycle. 1540 """ 1541 1542 # Resolve any deferred string-device names against the now-open 1543 # device registry (no-op for int/None devices). 1544 self._resolve_pending_devices() 1545 1546 # Dedupe by name, last declaration wins — re-declaring a pattern in a 1547 # reloaded source must not schedule two copies. 1548 new_by_name: typing.Dict[str, _PendingPattern] = {} 1549 1550 for pending in self._pending_patterns: 1551 if pending.builder_fn.__name__ not in self._running_patterns: 1552 new_by_name[pending.builder_fn.__name__] = pending 1553 1554 new_pending = list(new_by_name.values()) 1555 1556 if not new_pending: 1557 return 1558 1559 current_pulse = self._sequencer.pulse_count 1560 1561 for pending in new_pending: 1562 1563 pattern = self._build_pattern_from_pending(pending, start_pulse = current_pulse) 1564 await self._sequencer.schedule_pattern_repeating(pattern, start_pulse = current_pulse) 1565 self._running_patterns[pending.builder_fn.__name__] = pattern 1566 1567 logger.info(f"Live-reload: scheduled new pattern '{pending.builder_fn.__name__}'") 1568 1569 # Prune graduated (and stale duplicate) declarations: leaving them in 1570 # _pending_patterns resurrected deleted patterns on every later reload. 1571 self._pending_patterns = [ 1572 pending for pending in self._pending_patterns 1573 if pending.builder_fn.__name__ not in self._running_patterns 1574 ] 1575 1576 def _resolve_channel (self, channel: int) -> int: 1577 1578 """ 1579 Convert a user-supplied MIDI channel to the 0-indexed value used internally. 1580 1581 When ``zero_indexed_channels`` is False (default), the channel is 1582 validated as 1-16 and decremented by one. When True (0-indexed), the 1583 channel is validated as 0-15 and returned unchanged. 1584 """ 1585 1586 if self._zero_indexed_channels: 1587 if not 0 <= channel <= 15: 1588 raise ValueError(f"MIDI channel must be 0-15 (zero_indexed_channels=True), got {channel}") 1589 return channel 1590 else: 1591 if not 1 <= channel <= 16: 1592 raise ValueError(f"MIDI channel must be 1-16, got {channel}") 1593 return channel - 1 1594 1595 def _resolve_mirrors ( 1596 self, 1597 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]], 1598 primary: typing.Optional[typing.Tuple[int, int]] = None, 1599 ) -> typing.List[subsequence.pattern.MirrorSpec]: 1600 1601 """ 1602 Validate and normalise a list of mirror destinations. 1603 1604 Each entry is a 2- or 3-element sequence — ``(device_idx, channel)`` or 1605 ``(device_idx, channel, drum_note_map)`` — as a tuple, list, or any such 1606 iterable. ``channel`` is expressed in the user's channel-numbering 1607 convention (1-16 by default, 0-15 when ``zero_indexed_channels=True``); 1608 this method converts it to canonical 0-indexed form and rejects 1609 malformed entries. The optional ``drum_note_map`` is preserved verbatim 1610 so the sequencer can re-resolve mirrored drum names per device. 1611 1612 String device names are NOT supported here; users wanting a named 1613 device should pass the integer index returned from ``midi_output()``. 1614 1615 If ``primary=(device, channel)`` is supplied (canonical 0-indexed 1616 form), a mirror entry whose ``(device, channel)`` matches it triggers a 1617 ``logger.warning`` — this is almost always a user error (every event 1618 would double-fire on the same destination). The optional map is ignored 1619 for this comparison. Skipped when ``primary`` is ``None``, since the 1620 runtime API call site supplies its own check. 1621 """ 1622 1623 if mirrors is None: 1624 return [] 1625 1626 resolved: typing.List[subsequence.pattern.MirrorSpec] = [] 1627 1628 for entry in mirrors: 1629 1630 # Accept any 2- or 3-element iterable (tuple, list, etc.) — config 1631 # files and JSON sources naturally produce lists. Validate shape at 1632 # decoration time so bad inputs surface here instead of producing 1633 # inscrutable failures inside the sequencer. 1634 try: 1635 items = list(entry) 1636 except TypeError: 1637 raise ValueError(f"Mirror entry must be a (device, channel[, drum_note_map]) tuple — got {entry!r}") 1638 1639 if len(items) not in (2, 3): 1640 raise ValueError(f"Mirror entry must have 2 or 3 elements (device, channel[, drum_note_map]) — got {entry!r}") 1641 1642 device = items[0] 1643 channel = items[1] 1644 drum_map = items[2] if len(items) == 3 else None 1645 1646 if not isinstance(device, int) or isinstance(device, bool): 1647 raise ValueError(f"Mirror device must be an integer index — got {type(device).__name__} ({device!r})") 1648 1649 if not isinstance(channel, int) or isinstance(channel, bool): 1650 raise ValueError(f"Mirror channel must be an integer — got {type(channel).__name__} ({channel!r})") 1651 1652 if drum_map is not None and not isinstance(drum_map, dict): 1653 raise ValueError(f"Mirror drum_note_map must be a dict or None — got {type(drum_map).__name__} ({drum_map!r})") 1654 1655 resolved_channel = self._resolve_channel(channel) 1656 1657 if primary is not None and (device, resolved_channel) == primary: 1658 logger.warning( 1659 f"Mirror destination {(device, resolved_channel)} matches the pattern's primary destination " 1660 f"— every event will double-fire on this (device, channel). This is almost " 1661 f"certainly unintended." 1662 ) 1663 1664 resolved_entry: subsequence.pattern.MirrorSpec = ( 1665 (device, resolved_channel) 1666 if drum_map is None 1667 else (device, resolved_channel, drum_map) 1668 ) 1669 resolved.append(resolved_entry) 1670 1671 return resolved 1672 1673 @property 1674 def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: 1675 """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" 1676 return self._harmonic_state 1677 1678 def current_chord (self) -> typing.Optional[typing.Any]: 1679 1680 """The chord sounding at the playhead, or ``None`` without harmony. 1681 1682 Reads the harmony window at the current pulse, so it stays accurate 1683 under variable harmonic rhythm and clock lookahead (the engine's 1684 ``current_chord`` flips *lookahead* beats early — this does not). 1685 Falls back to the engine's chord before playback starts. The chord 1686 may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span 1687 is spiced; it duck-types the ``Chord`` voicing protocol either way. 1688 """ 1689 1690 if not self._harmony_horizon.is_empty: 1691 beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat 1692 chord = self._harmony_horizon.chord_at(beat) 1693 if chord is not None: 1694 return chord 1695 1696 if self._harmonic_state is not None: 1697 return self._harmonic_state.get_current_chord() 1698 1699 return None 1700 1701 def _effective_key_scale ( 1702 self, 1703 section_info: typing.Optional["subsequence.form_state.SectionInfo"], 1704 ) -> typing.Tuple[typing.Optional[str], typing.Optional[str]]: 1705 1706 """Resolve the key and scale in force, by the key-source precedence. 1707 1708 The layered chain, key and scale resolved **independently** so a 1709 section can move the tonic, the mode, or both: 1710 ``Section.key`` > form key (``form(key=)`` / ``Form(key=)``) > 1711 ``Composition.key``, and likewise for scale. This is the one place 1712 the tier order lives; every placement site routes through it so the 1713 section key reaches every compositional element uniformly (the 1714 three-intent model: only *key-relative* content reads this — absolute 1715 content ignores it, chord-relative content tracks the chord). 1716 """ 1717 1718 key: typing.Optional[str] = None 1719 scale: typing.Optional[str] = None 1720 1721 if section_info is not None: 1722 key = section_info.key 1723 scale = section_info.scale 1724 1725 if key is None: 1726 key = self._form_key 1727 if key is None: 1728 key = self.key 1729 1730 if scale is None: 1731 scale = self._form_scale 1732 if scale is None: 1733 scale = self.scale 1734 1735 return key, scale 1736 1737 def _resolve_section_progression ( 1738 self, 1739 info: "subsequence.form_state.SectionInfo", 1740 ) -> typing.Optional[Progression]: 1741 1742 """Resolve a section's bound progression against its effective key/scale. 1743 1744 Concrete progressions (names, ``PitchSet``, frozen captures) are 1745 returned unchanged. Key-relative ones resolve against the section's 1746 effective key+scale — memoised per ``(name, key, scale)`` so a stable 1747 section reuses one realisation and span identity is stable across 1748 ticks. If no key is resolvable at this moment the section is skipped 1749 (returns ``None`` → falls through to the bound/live source) with a 1750 warning; the authoritative check runs at :meth:`play`/:meth:`render`. 1751 """ 1752 1753 raw = self._section_progressions.get(info.name) 1754 1755 if raw is None or raw.is_concrete: 1756 return raw 1757 1758 key, scale = self._effective_key_scale(info) 1759 1760 if key is None: 1761 logger.warning( 1762 "section_chords(%r) is key-relative but no key resolves for this section — " 1763 "skipping (the chords fall through to the live/bound source)", 1764 info.name, 1765 ) 1766 return None 1767 1768 cache_key = (info.name, key, scale) 1769 cached = self._resolved_section_cache.get(cache_key) 1770 1771 if cached is not None: 1772 return cached 1773 1774 try: 1775 resolved = raw.resolve(key, scale or "ionian") 1776 except ValueError as error: 1777 # A degree out of range for the effective scale, or an unknown 1778 # scale — never let it escape the clock callback (that would kill 1779 # harmony for the rest of playback). Skip the section; the _run 1780 # pre-flight catches the common case far earlier. 1781 logger.warning( 1782 "section_chords(%r) cannot resolve against %s %s (%s) — skipping; " 1783 "the chords fall through to the live/bound source", 1784 info.name, key, scale or "ionian", error, 1785 ) 1786 return None 1787 1788 self._resolved_section_cache[cache_key] = resolved 1789 return resolved 1790 1791 @property 1792 def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]: 1793 """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" 1794 return self._form_state 1795 1796 @property 1797 def sequencer (self) -> subsequence.sequencer.Sequencer: 1798 """The underlying ``Sequencer`` instance.""" 1799 return self._sequencer 1800 1801 @property 1802 def running_patterns (self) -> typing.Dict[str, typing.Any]: 1803 """The currently active patterns, keyed by name.""" 1804 return self._running_patterns 1805 1806 @property 1807 def builder_bar (self) -> int: 1808 """Current bar index used by pattern builders.""" 1809 return self._builder_bar 1810 1811 def _require_harmonic_state (self) -> subsequence.harmonic_state.HarmonicState: 1812 """Return the active HarmonicState, raising ValueError if none is configured.""" 1813 if self._harmonic_state is None: 1814 raise ValueError( 1815 "harmony() must be called before this action — " 1816 "no harmonic state has been configured." 1817 ) 1818 return self._harmonic_state 1819 1820 def _coerce_progression (self, source: typing.Any, what: str) -> Progression: 1821 1822 """Coerce a Progression / element list / preset name and resolve it against the key. 1823 1824 Binding freezes one realisation (the value type's identity), so 1825 key-relative content resolves here, at bind time, against the 1826 composition's key and scale. Used by the *global* bound progression 1827 (``harmony(progression=)``) — which is not section-scoped, so it has 1828 nothing to re-key against. 1829 """ 1830 1831 value = source if isinstance(source, Progression) else subsequence.progressions.progression(source) 1832 1833 if not value.is_concrete: 1834 if self.key is None: 1835 raise ValueError( 1836 f"{what} contains key-relative chords (degrees/romans) — " 1837 "set key= on the Composition so they can resolve" 1838 ) 1839 value = value.resolve(self.key, self.scale or "ionian") 1840 1841 return value 1842 1843 def _coerce_section_progression (self, source: typing.Any) -> Progression: 1844 1845 """Coerce a section progression, keeping key-relative content UNRESOLVED. 1846 1847 Section harmony re-keys per occurrence (the section/form/composition 1848 key in force when the section plays), so a key-relative progression is 1849 stored relative and resolved late, in the clock, against the section's 1850 effective key+scale — unlike the global bound progression, which 1851 freezes at bind. Concrete content (chord names, frozen captures, 1852 ``PitchSet``) is already absolute and never moves. 1853 """ 1854 1855 return source if isinstance(source, Progression) else subsequence.progressions.progression(source) 1856 1857 def harmony ( 1858 self, 1859 style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None, 1860 cycle_beats: int = 4, 1861 dominant_7th: bool = True, 1862 gravity: float = 1.0, 1863 nir_strength: float = 0.5, 1864 minor_turnaround_weight: float = 0.0, 1865 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1866 reschedule_lookahead: float = 1, 1867 progression: typing.Optional[typing.Any] = None, 1868 ) -> None: 1869 1870 """ 1871 Configure the harmonic logic and chord change intervals. 1872 1873 Two sources, combinable: a **bound progression** (``progression=`` — a 1874 :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, 1875 or chord names) walked span by span on the global clock; and/or a 1876 **graph style** stepping live chords. With only a progression bound, 1877 it loops on exhaustion; with a style configured too, exhaustion falls 1878 through to live stepping (the frozen-replay bridge). Calling with 1879 neither argument keeps today's default live engine 1880 (``style="functional_major"``). 1881 1882 Parameters: 1883 style: The harmonic style to use. Built-in: "functional_major" 1884 (alias "diatonic_major"), "hooktheory_major" (alias 1885 "pop_major"), "turnaround", "aeolian_minor", 1886 "phrygian_minor", "lydian_major", "dorian_minor", 1887 "chromatic_mediant", "suspended", "mixolydian", "whole_tone", 1888 "diminished". See README for full descriptions. 1889 cycle_beats: How many beats each live chord lasts (default 4). 1890 Bound progressions carry their own harmonic rhythm in their 1891 spans, so this applies to live stepping only. A re-call 1892 during playback takes effect from the next chord boundary; 1893 a FIRST harmony() call mid-playback starts the clock itself. 1894 dominant_7th: Whether to include V7 chords (default True). 1895 gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. 1896 nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement 1897 expectations. 1898 minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. 1899 root_diversity: Root-repetition damping (0.0 to 1.0). Each recent 1900 chord sharing a candidate's root reduces the weight to 40% at 1901 the default (0.4). Set to 1.0 to disable. 1902 reschedule_lookahead: How many beats in advance to calculate the 1903 next chord. 1904 progression: A progression to bind to the global clock. Key- 1905 relative content resolves now, against the composition key 1906 and scale (binding freezes one realisation). 1907 1908 Example: 1909 ```python 1910 # A moody minor progression that changes every 8 beats 1911 comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) 1912 1913 # Manual harmony driving everything — loops forever 1914 comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) 1915 ``` 1916 """ 1917 1918 if style is None and progression is None: 1919 # A parameter-only re-call (gravity=, cycle_beats=, ...) keeps the 1920 # configured style — defaulting unconditionally here would silently 1921 # replace e.g. aeolian_minor with functional_major. 1922 style = self._last_harmony_style if self._last_harmony_style is not None else "functional_major" 1923 1924 if style is not None: 1925 1926 if self.key is None: 1927 raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor") 1928 1929 preserved_history: typing.List[subsequence.chords.Chord] = [] 1930 preserved_current: typing.Optional[subsequence.chords.Chord] = None 1931 1932 if self._harmonic_state is not None: 1933 preserved_history = self._harmonic_state.history.copy() 1934 preserved_current = self._harmonic_state.current_chord 1935 1936 # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call 1937 # gets its own deterministic stream while history and current chord 1938 # are preserved above, and adding a re-call never shifts any other 1939 # consumer's stream. 1940 self._harmony_count += 1 1941 1942 self._harmonic_state = subsequence.harmonic_state.HarmonicState( 1943 key_name = self.key, 1944 graph_style = style, 1945 include_dominant_7th = dominant_7th, 1946 key_gravity_blend = gravity, 1947 nir_strength = nir_strength, 1948 minor_turnaround_weight = minor_turnaround_weight, 1949 root_diversity = root_diversity, 1950 rng = self._stream(f"harmony:{self._harmony_count}") 1951 ) 1952 1953 if preserved_history: 1954 self._harmonic_state.history = preserved_history 1955 if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current): 1956 self._harmonic_state.current_chord = preserved_current 1957 1958 self._harmony_style = style if isinstance(style, str) else None 1959 self._last_harmony_style = style 1960 1961 if progression is not None: 1962 self._bound_progression = self._coerce_progression(progression, "harmony(progression=)") 1963 1964 self._harmony_cycle_beats = cycle_beats 1965 self._harmony_reschedule_lookahead = reschedule_lookahead 1966 1967 # A re-call invalidates whatever the horizon had planned. 1968 self._harmony_horizon.invalidate_future() 1969 1970 # A FIRST harmony() call mid-playback must start the clock itself — 1971 # _run() only schedules clocks for sources it can see at play() time. 1972 # (Re-calls need nothing here: the clock reads its sources through 1973 # getters on every tick.) 1974 loop = self._sequencer._event_loop 1975 1976 if loop is not None and loop.is_running() and not self._harmonic_clock_started: 1977 try: 1978 on_loop = asyncio.get_running_loop() is loop 1979 except RuntimeError: 1980 on_loop = False 1981 1982 if on_loop: 1983 loop.create_task(self._start_harmonic_clock()) 1984 else: 1985 asyncio.run_coroutine_threadsafe(self._start_harmonic_clock(), loop) 1986 1987 async def _start_harmonic_clock (self, bar_beats: typing.Optional[float] = None, clock_lookahead: typing.Optional[float] = None) -> None: 1988 1989 """Register the span-walking harmonic clock (idempotent per playback). 1990 1991 Called from ``_run()`` when a harmony source exists at play time, and 1992 from ``harmony()`` when the FIRST source arrives mid-playback. 1993 ``bar_beats``/``clock_lookahead`` default to a fresh computation for 1994 the mid-playback path; ``_run()`` passes the values it validated. 1995 """ 1996 1997 if self._harmonic_clock_started: 1998 return 1999 2000 self._harmonic_clock_started = True 2001 2002 if bar_beats is None: 2003 bar_beats = float(self.time_signature[0]) 2004 2005 if clock_lookahead is None: 2006 lookaheads = [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] 2007 clock_lookahead = min(bar_beats, max(1.0, float(self._harmony_reschedule_lookahead), float(max(lookaheads, default = 1)))) 2008 2009 def _get_section_progression () -> typing.Optional[typing.Tuple[str, int, int, typing.Optional[Progression]]]: 2010 """Return (section_name, section_index, bars, Progression|None) for the current section, or None. 2011 2012 The progression is resolved against the section's effective 2013 key/scale here (key-relative section harmony re-keys per 2014 occurrence); concrete content passes through unchanged. 2015 """ 2016 if self._form_state is None: 2017 return None 2018 info = self._form_state.get_section_info() 2019 if info is None: 2020 return None 2021 prog = self._resolve_section_progression(info) 2022 return (info.name, info.index, info.bars, prog) 2023 2024 def _resolve_cadence_formula (name: str) -> typing.List[subsequence.chords.Chord]: 2025 """Resolve a cadence formula against the composition key and scale, at plan time.""" 2026 hs = self._harmonic_state 2027 key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else (hs.key_root_pc if hs is not None else 0) 2028 spec = subsequence.cadences.cadence_formula(name) 2029 return [ 2030 subsequence.progressions.resolve_constraint(element, key_pc, self._constraint_scale(), f"cadence {name!r}") 2031 for element in spec.formula 2032 ] 2033 2034 await schedule_harmonic_clock( 2035 sequencer = self._sequencer, 2036 get_harmonic_state = lambda: self._harmonic_state, 2037 horizon = self._harmony_horizon, 2038 bar_beats = bar_beats, 2039 cycle_beats = self._harmony_cycle_beats or 4, 2040 get_cycle_beats = lambda: self._harmony_cycle_beats or 4, 2041 get_bound_progression = lambda: self._bound_progression, 2042 get_section_progression = _get_section_progression, 2043 get_pinned = self._resolve_pin, 2044 cadence_requests = self._cadence_requests, 2045 resolve_cadence = _resolve_cadence_formula, 2046 get_section_cadence = self._section_cadences.get, 2047 reschedule_lookahead = clock_lookahead, 2048 ) 2049 2050 def _constraint_scale (self) -> str: 2051 2052 """The scale that hybrid-constraint ints resolve against. 2053 2054 The composition's own scale when set; otherwise inferred from the 2055 harmony style (``aeolian_minor`` → minor, matching 2056 :meth:`Progression.generate`'s documented inference), falling back 2057 to ionian. Roman strings carry their quality and never need it. 2058 """ 2059 2060 if self.scale is not None: 2061 return self.scale 2062 2063 return subsequence.progressions._STYLE_SCALES.get(self._harmony_style or "", "ionian") 2064 2065 def freeze ( 2066 self, 2067 bars: int, 2068 end: typing.Optional[typing.Any] = None, 2069 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 2070 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 2071 cadence: typing.Optional[str] = None, 2072 ) -> "Progression": 2073 2074 """Capture a chord progression from the live harmony engine. 2075 2076 Runs the harmony engine forward by *bars* chord changes, records each 2077 chord, and returns it as a :class:`Progression` that can be bound to a 2078 form section with :meth:`section_chords`. 2079 2080 The engine state **advances** — successive ``freeze()`` calls produce a 2081 continuing compositional journey so section progressions feel like parts 2082 of a whole rather than isolated islands. 2083 2084 The hybrid constraints compile into the walk: ``end=`` fixes the last 2085 bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` 2086 excludes chords throughout. Specs follow the progression-element 2087 grammar (ints where diatonic, roman/name strings where chromatic) and 2088 resolve against the composition key and scale. A backward 2089 feasibility pass guarantees satisfiability before any chord is drawn; 2090 the forward walk keeps the engine's real history-dependent weighting. 2091 Bar 1 is always the engine's current chord — the journey continues — 2092 so ``pins={1: ...}`` may only name it redundantly. 2093 2094 Parameters: 2095 bars: Number of chords to capture (one per harmony cycle). 2096 end: The chord at the final bar — ``end="V"`` is the cadential 2097 major dominant in minor. 2098 pins: ``{bar: chord}`` — 1-based fiat positions. 2099 avoid: Chords excluded from the walk. 2100 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 2101 ``"fakeout"``, theory aliases accepted) — its formula pins 2102 the final bars, so the walk approaches the close. 2103 Conflicts with ``end=`` or pins on those bars. 2104 2105 Returns: 2106 A :class:`Progression` with the captured chords and trailing 2107 history for NIR continuity. 2108 2109 Raises: 2110 ValueError: If :meth:`harmony` has not been called first, or the 2111 constraints are contradictory or unsatisfiable. 2112 2113 Example:: 2114 2115 composition.harmony(style="functional_major", cycle_beats=4) 2116 verse = composition.freeze(8, end="V") # the verse sets up the chorus 2117 chorus = composition.freeze(4) # next 4 chords, continuing on 2118 composition.section_chords("verse", verse) 2119 composition.section_chords("chorus", chorus) 2120 """ 2121 2122 hs = self._require_harmonic_state() 2123 2124 if bars < 1: 2125 raise ValueError("bars must be at least 1") 2126 2127 if cadence is not None: 2128 pins = subsequence.progressions.cadence_pins(cadence, bars, pins, end) 2129 end = None 2130 2131 scale = self._constraint_scale() 2132 key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc 2133 2134 resolved_pins = { 2135 position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]") 2136 for position, spec in (pins or {}).items() 2137 } 2138 resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None 2139 resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])] 2140 2141 if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: 2142 raise ValueError( 2143 f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " 2144 f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " 2145 "pin a later bar, or use pin_chord() for playback fiat" 2146 ) 2147 2148 # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws 2149 # are independent of every other consumer, so frozen progressions are 2150 # reproducible WITHOUT play() and adding a call cannot shift a 2151 # neighbour's output. Engine state still advances normally — chord 2152 # continuity comes from current_chord/history, randomness from the 2153 # salted stream (swap-and-restore keeps hs.rng for play untouched). 2154 self._freeze_count += 1 2155 stream = self._stream(f"freeze:{self._freeze_count}") 2156 saved_rng = hs.rng 2157 2158 if stream is not None: 2159 hs.rng = stream 2160 2161 try: 2162 # The kernel with the engine's own hooks is draw-for-draw the old 2163 # step() loop when unconstrained — one walk path for both. 2164 def _commit (chosen: subsequence.chords.Chord) -> None: 2165 hs.current_chord = chosen 2166 2167 collected = subsequence.sequence_utils.constrained_walk( 2168 hs.graph, 2169 hs.current_chord, 2170 bars, 2171 rng = hs.rng, 2172 pins = resolved_pins, 2173 end = resolved_end, 2174 avoid = resolved_avoid, 2175 weight_modifier = hs._transition_weight, 2176 before_choice = hs._record_transition_source, 2177 after_choice = _commit, 2178 ) 2179 2180 # Advance past the last captured chord so the next freeze() call or 2181 # live playback does not duplicate it. 2182 hs.step() 2183 2184 finally: 2185 hs.rng = saved_rng 2186 2187 span_beats = float(self._harmony_cycle_beats or 4) 2188 2189 return Progression( 2190 spans = tuple( 2191 subsequence.progressions.ChordSpan(chord = chord, beats = span_beats) 2192 for chord in collected 2193 ), 2194 trailing_history = tuple(hs.history), 2195 ) 2196 2197 def section_chords (self, section_name: str, progression: typing.Any) -> None: 2198 2199 """Bind a :class:`Progression` to a named form section. 2200 2201 Every time *section_name* plays, the harmonic clock walks the 2202 progression's spans instead of calling the live engine. Sections 2203 without a bound progression continue generating live chords. 2204 2205 Accepts a :class:`Progression` value (from :meth:`freeze`, the 2206 ``progression()`` factory, or hand-built) or anything the factory 2207 accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord 2208 names. 2209 2210 **Key-relative content re-keys per occurrence.** A progression 2211 written in degrees or romans is *key-relative* content: it resolves 2212 late, each time the section plays, against that section's effective 2213 key and scale (``Section.key`` > form key > composition key, with 2214 mode following the same chain). So a ``Section(key="A")`` plays the 2215 same numbered progression a tone higher — its chords and its degrees 2216 share one tonic. *Absolute* content — chord names (``"Am"``), 2217 :class:`~subsequence.progressions.PitchSet`, and frozen captures from 2218 :meth:`freeze` — names exact chords and is never transposed by a key. 2219 2220 On exhaustion mid-section the progression loops when no graph style 2221 is configured (and always when it contains a ``PitchSet``); with a 2222 live engine, exhaustion **falls through to live stepping in the 2223 COMPOSITION key** — the live graph engine does not transpose for a 2224 section (a stateful walk does not modulate mid-stream), so a 2225 re-keyed section that runs out of written chords hands off to 2226 composition-key harmony. Bind a full-length progression (or set 2227 ``at_end``/loop intent) if you need the whole section in its key. 2228 2229 Parameters: 2230 section_name: Name of the section as defined in :meth:`form`. 2231 progression: The progression to bind. 2232 2233 Raises: 2234 ValueError: If a graph-based form has been configured and 2235 *section_name* is not one of its sections. List and generator 2236 forms yield names lazily, so they cannot be validated here. 2237 (A key-relative progression with no resolvable key for the 2238 section is caught at :meth:`play`/:meth:`render`, once the 2239 form's keys are known.) 2240 2241 Example:: 2242 2243 composition.section_chords("verse", verse_progression) 2244 composition.section_chords("chorus", [1, 6, 3, 7]) 2245 # "bridge" is not bound — it generates live chords 2246 """ 2247 2248 if ( 2249 self._form_state is not None 2250 and self._form_state._section_bars is not None 2251 and section_name not in self._form_state._section_bars 2252 ): 2253 known = ", ".join(sorted(self._form_state._section_bars)) 2254 raise ValueError( 2255 f"Section '{section_name}' not found in form. " 2256 f"Known sections: {known}" 2257 ) 2258 2259 self._section_progressions[section_name] = self._coerce_section_progression(progression) 2260 self._resolved_section_cache = {} 2261 self._harmony_horizon.invalidate_future() 2262 2263 def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None: 2264 2265 """Force the chord sounding at a bar — fiat over live generation. 2266 2267 Whatever the harmonic source (live walk, bound progression, section 2268 progression) produces for *bar*, the pinned chord overrides it. 2269 Pass ``None`` to remove a pin. 2270 2271 Parameters: 2272 bar: 1-based bar number (the musician count). 2273 chord: A chord name, int degree, roman string, ``Chord``, 2274 ``PitchSet``, or ``None`` to unpin. A **key-relative** spec 2275 (int degree, roman) re-keys like section harmony: it resolves 2276 late, against the effective key of the section sounding at 2277 that bar (so ``pin_chord(8, "V")`` is the dominant of 2278 wherever bar 8 lands). A **concrete** spec (name, ``Chord``, 2279 ``PitchSet``) is absolute and never moves. 2280 2281 Example:: 2282 2283 composition.pin_chord(8, "E7") # the turnaround lands on E7 2284 composition.pin_chord(8, "V") # the dominant of bar 8's section 2285 composition.pin_chord(8, None) # let it walk again 2286 """ 2287 2288 if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 2289 raise ValueError(f"bars are 1-based ints, got {bar!r}") 2290 2291 if chord is None: 2292 self._pinned_chords.pop(bar, None) 2293 else: 2294 # Store the parsed span — relative pins resolve late (per section) 2295 # at the clock; concrete pins are absolute. 2296 span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0])) 2297 2298 if not span.is_concrete: 2299 # Raise early only when no key is resolvable for this bar — the 2300 # bar's own section (sequence forms) may supply one even with no 2301 # composition/form key. 2302 probe_info = self._form_state.section_info_at_bar(bar) if self._form_state is not None else None 2303 probe_key, _ = self._effective_key_scale(probe_info) 2304 if probe_key is None: 2305 raise ValueError( 2306 "pin_chord with a key-relative spec (degree/roman) needs a key — set key= on " 2307 "the Composition, a form key, or a Section.key for that bar (the pin re-keys " 2308 "to the section's effective key)" 2309 ) 2310 2311 self._pinned_chords[bar] = span 2312 2313 self._harmony_horizon.invalidate_future() 2314 2315 def _resolve_pin (self, bar: int) -> typing.Optional[typing.Any]: 2316 2317 """Resolve a stored pin to a chord-like, re-keying relative pins per section. 2318 2319 Concrete pins return their chord directly; key-relative pins resolve 2320 against the effective key of the section sounding now (the clock 2321 reads this as it reaches each bar). Returns ``None`` (no pin / a 2322 relative pin with no resolvable key, warned) so the clock falls 2323 through to its normal source. 2324 """ 2325 2326 span = self._pinned_chords.get(bar) 2327 2328 if span is None: 2329 return None 2330 2331 if span.is_concrete: 2332 return _span_chord(span) 2333 2334 # Key the pin to the section that OWNS this bar (the clock's lookahead 2335 # can project a pin into a later, differently-keyed section while the 2336 # playhead is still earlier) — fall back to the playhead section where 2337 # a per-bar section is not computable (graph/generator forms). 2338 info: typing.Optional["subsequence.form_state.SectionInfo"] = None 2339 if self._form_state is not None: 2340 info = self._form_state.section_info_at_bar(bar) 2341 if info is None: 2342 info = self._form_state.get_section_info() 2343 2344 key, scale = self._effective_key_scale(info) 2345 2346 if key is None: 2347 logger.warning( 2348 "pin_chord(%d, ...) is key-relative but no key resolves for that bar — ignoring the pin", 2349 bar, 2350 ) 2351 return None 2352 2353 return _span_chord(span.resolve(subsequence.chords.key_name_to_pc(key), scale or "ionian")) 2354 2355 def request_cadence (self, cadence: str = "strong", bar: typing.Optional[int] = None) -> None: 2356 2357 """Ask the live engine to approach a cadence arriving at a bar. 2358 2359 The request hook: where :meth:`pin_chord` is fiat, this is a 2360 *steered approach* — at the next chord boundary the clock plans the 2361 remaining changes up to *bar* as a constrained walk through the 2362 engine's real weights, pinned to the cadence formula at the tail 2363 (``"strong"`` arrives V→I, ``"soft"`` IV→I, ``"open"`` IV→V, 2364 ``"fakeout"`` V→vi; theory aliases accepted). The chords still 2365 commit one boundary at a time, so the journey continues through the 2366 close. 2367 2368 One-shot: the request is consumed when planned. Live harmony only — 2369 bound/section progressions are data and cannot be steered; a request 2370 whose bar passes unserved expires with a warning. If the formula is 2371 not walkable from where the harmony stands, the arrival lands by 2372 fiat (loudly). Ask at least a pattern-lookahead ahead: patterns may 2373 already have rendered against the previously planned chord. 2374 2375 Parameters: 2376 cadence: The cadence name. 2377 bar: The 1-based bar the cadence's final chord arrives at 2378 (required; in practice ≥ 2 — bar 1 cannot be approached). 2379 2380 Example:: 2381 2382 composition.request_cadence("open", bar=16) # hang on V at bar 16 2383 """ 2384 2385 spec = subsequence.cadences.cadence_formula(cadence) 2386 2387 if bar is None or not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 2388 raise ValueError(f"request_cadence needs bar= — the 1-based bar the cadence arrives at (got {bar!r})") 2389 2390 self._cadence_requests[bar] = spec.name 2391 self._harmony_horizon.invalidate_future() 2392 2393 def section_cadence (self, section_name: str, cadence: typing.Optional[str] = "strong") -> None: 2394 2395 """Close every pass of a section with a cadence — the standing request. 2396 2397 Each time *section_name* is entered, the clock registers a 2398 :meth:`request_cadence` arriving at the section's final bar, so the 2399 harmony approaches the close as the section ends. Live harmony 2400 only: a section with bound chords (:meth:`section_chords`) is data 2401 and ignores the registration — its closes are written, not steered. 2402 Pass ``None`` to unregister. 2403 2404 Example:: 2405 2406 composition.form([("verse", 8), ("chorus", 8)]) 2407 composition.section_cadence("verse", "open") # every verse hangs on V 2408 composition.section_cadence("chorus", "strong") # every chorus lands home 2409 """ 2410 2411 if cadence is None: 2412 self._section_cadences.pop(section_name, None) 2413 return 2414 2415 spec = subsequence.cadences.cadence_formula(cadence) 2416 self._section_cadences[section_name] = spec.name 2417 2418 def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None: 2419 2420 """Bind a Motif or Phrase to a named form section (per optional part). 2421 2422 Patterns read the binding back with ``p.section_motif(part)`` (or use 2423 the one-call :meth:`phrase_part`); a section with no binding for the 2424 part is silent for that part — bind material or don't, no fallback 2425 guessing. Re-binding is idempotent, so the call is safe in a live 2426 file: re-executing on save is the desired rebind. 2427 2428 Parameters: 2429 section_name: Name of the section as defined in :meth:`form`. 2430 value: A ``Motif`` or ``Phrase`` (anything exposing 2431 ``.length``/``.slice`` places). 2432 part: Optional part label, so one section can carry several 2433 bindings (``"lead"``, ``"bass"``, ...). 2434 2435 Raises: 2436 ValueError: If a graph-based form has been configured and 2437 *section_name* is not one of its sections. 2438 2439 Example:: 2440 2441 composition.section_motifs("verse", verse_line, part="lead") 2442 composition.section_motifs("chorus", chorus_line, part="lead") 2443 """ 2444 2445 if not hasattr(value, "length") or not hasattr(value, "slice"): 2446 raise TypeError( 2447 f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" 2448 ) 2449 2450 if ( 2451 self._form_state is not None 2452 and self._form_state._section_bars is not None 2453 and section_name not in self._form_state._section_bars 2454 ): 2455 known = ", ".join(sorted(self._form_state._section_bars)) 2456 raise ValueError( 2457 f"Section '{section_name}' not found in form. " 2458 f"Known sections: {known}" 2459 ) 2460 2461 self._section_motifs[(section_name, part)] = value 2462 2463 def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: 2464 2465 """ 2466 Register a callback for a sequencer event (e.g., "bar", "start", "stop"). 2467 """ 2468 2469 self._sequencer.on_event(event_name, callback) 2470 2471 2472 # ----------------------------------------------------------------------- 2473 # Hotkey API 2474 # ----------------------------------------------------------------------- 2475 2476 def hotkeys (self, enabled: bool = True) -> None: 2477 2478 """Enable or disable the global hotkey listener. 2479 2480 Must be called **before** :meth:`play` to take effect. When enabled, a 2481 background thread reads single keystrokes from stdin without requiring 2482 Enter. The ``?`` key is always reserved and lists all active bindings. 2483 2484 Hotkeys have zero impact on playback when disabled — the listener 2485 thread is never started. 2486 2487 Args: 2488 enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. 2489 2490 Example:: 2491 2492 composition.hotkeys() 2493 composition.hotkey("a", lambda: composition.form_jump("chorus")) 2494 composition.play() 2495 """ 2496 2497 self._hotkeys_enabled = enabled 2498 2499 2500 def hotkey ( 2501 self, 2502 key: str, 2503 action: typing.Callable[[], None], 2504 quantize: int = 0, 2505 label: typing.Optional[str] = None, 2506 ) -> None: 2507 2508 """Register a single-key shortcut that fires during playback. 2509 2510 The listener must be enabled first with :meth:`hotkeys`. 2511 2512 Most actions — form jumps, ``composition.data`` writes, and 2513 :meth:`tweak` calls — should use ``quantize=0`` (the default). Their 2514 musical effect is naturally delayed to the next pattern rebuild cycle, 2515 which provides automatic musical quantization without extra configuration. 2516 2517 Use ``quantize=N`` for actions where you want an explicit bar-boundary 2518 guarantee, such as :meth:`mute` / :meth:`unmute`. 2519 2520 The ``?`` key is reserved and cannot be overridden. 2521 2522 Args: 2523 key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). 2524 action: Zero-argument callable to execute. 2525 quantize: ``0`` = execute immediately (default). ``N`` = execute 2526 on the next global bar number divisible by *N*. 2527 label: Display name for the ``?`` help listing. Auto-derived from 2528 the function name or lambda body if omitted. 2529 2530 Raises: 2531 ValueError: If ``key`` is the reserved ``?`` character, or if 2532 ``key`` is not exactly one character. 2533 2534 Example:: 2535 2536 composition.hotkeys() 2537 2538 # Immediate — musical effect happens at next pattern rebuild 2539 composition.hotkey("a", lambda: composition.form_jump("chorus")) 2540 composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) 2541 2542 # Explicit 4-bar phrase boundary 2543 composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) 2544 2545 # Named function — label is derived automatically 2546 def drop_to_breakdown (): 2547 composition.form_jump("breakdown") 2548 composition.mute("lead") 2549 2550 composition.hotkey("d", drop_to_breakdown) 2551 2552 composition.play() 2553 """ 2554 2555 if len(key) != 1: 2556 raise ValueError(f"hotkey key must be a single character, got {key!r}") 2557 2558 if key == _HOTKEY_RESERVED: 2559 raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.") 2560 2561 derived = label if label is not None else _derive_label(action) 2562 2563 self._hotkey_bindings[key] = HotkeyBinding( 2564 key = key, 2565 action = action, 2566 quantize = quantize, 2567 label = derived, 2568 ) 2569 2570 2571 def form_jump (self, section_name: str) -> None: 2572 2573 """Jump the form to a named section immediately. 2574 2575 Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the 2576 composition uses graph-mode form (a dict passed to :meth:`form`). 2577 2578 The musical effect is heard at the *next pattern rebuild cycle* — already- 2579 queued MIDI notes are unaffected. This natural delay means ``form_jump`` 2580 is effective without needing explicit quantization. 2581 2582 Args: 2583 section_name: The section to jump to. 2584 2585 Raises: 2586 ValueError: If no form is configured, or the form is not in graph 2587 mode, or *section_name* is unknown. 2588 2589 Example:: 2590 2591 composition.hotkey("c", lambda: composition.form_jump("chorus")) 2592 """ 2593 2594 if self._form_state is None: 2595 raise ValueError("form_jump() requires a form to be configured via composition.form().") 2596 2597 self._form_state.jump_to(section_name) 2598 2599 # The harmony horizon planned against the old section — revoke it. 2600 self._harmony_horizon.invalidate_future() 2601 2602 2603 def form_next (self, section_name: str) -> None: 2604 2605 """Queue the next section — takes effect when the current section ends. 2606 2607 Unlike :meth:`form_jump`, this does not interrupt the current section. 2608 The queued section replaces the automatically pre-decided next section 2609 and takes effect at the natural section boundary. The performer can 2610 change their mind by calling ``form_next`` again before the boundary. 2611 2612 Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the 2613 composition uses graph-mode form (a dict passed to :meth:`form`). 2614 2615 Args: 2616 section_name: The section to queue. 2617 2618 Raises: 2619 ValueError: If no form is configured, or the form is not in graph 2620 mode, or *section_name* is unknown. 2621 2622 Example:: 2623 2624 composition.hotkey("c", lambda: composition.form_next("chorus")) 2625 """ 2626 2627 if self._form_state is None: 2628 raise ValueError("form_next() requires a form to be configured via composition.form().") 2629 2630 self._form_state.queue_next(section_name) 2631 2632 # The harmony horizon planned against the old continuation — revoke it. 2633 self._harmony_horizon.invalidate_future() 2634 2635 2636 def _list_hotkeys (self) -> None: 2637 2638 """Log all active hotkey bindings (triggered by the ``?`` key). 2639 2640 Output appears via the standard logger so it scrolls cleanly above 2641 the :class:`~subsequence.display.Display` status line. 2642 """ 2643 2644 lines = ["Active hotkeys:"] 2645 for key in sorted(self._hotkey_bindings): 2646 b = self._hotkey_bindings[key] 2647 quant_str = "immediate" if b.quantize == 0 else f"quantize={b.quantize}" 2648 lines.append(f" {key} \u2192 {b.label} ({quant_str})") 2649 lines.append(f" ? \u2192 list hotkeys") 2650 logger.info("\n".join(lines)) 2651 2652 2653 def _process_hotkeys (self, bar: int) -> None: 2654 2655 """Drain pending keystrokes and execute due actions. 2656 2657 Called on every ``"bar"`` event by the sequencer when hotkeys are 2658 enabled. Handles both immediate (``quantize=0``) and quantized actions. 2659 2660 Both kinds run here, on the bar-event callback (the event loop): the 2661 keystroke listener thread only enqueues keypresses (``drain()``), it 2662 never executes actions. Immediate (``quantize=0``) bindings fire as soon 2663 as the key is drained; quantized ones wait for their next boundary. 2664 2665 Args: 2666 bar: The current global bar number from the sequencer. 2667 """ 2668 2669 if self._keystroke_listener is None: 2670 return 2671 2672 # Process newly arrived keys. 2673 for key in self._keystroke_listener.drain(): 2674 2675 if key == _HOTKEY_RESERVED: 2676 self._list_hotkeys() 2677 continue 2678 2679 binding = self._hotkey_bindings.get(key) 2680 if binding is None: 2681 continue 2682 2683 if binding.quantize == 0: 2684 # Immediate — execute now (we're on the bar-event callback, 2685 # which is safe for all mutation methods). 2686 try: 2687 binding.action() 2688 logger.info(f"Hotkey '{key}' \u2192 {binding.label}") 2689 except Exception as exc: 2690 logger.warning(f"Hotkey '{key}' action raised: {exc}") 2691 else: 2692 # Defer until the next quantize boundary. 2693 self._pending_hotkey_actions.append( 2694 _PendingHotkeyAction(binding=binding) 2695 ) 2696 2697 # Fire any pending actions whose bar boundary has arrived. 2698 still_pending: typing.List[_PendingHotkeyAction] = [] 2699 2700 for pending in self._pending_hotkey_actions: 2701 if bar % pending.binding.quantize == 0: 2702 try: 2703 pending.binding.action() 2704 logger.info( 2705 f"Hotkey '{pending.binding.key}' \u2192 {pending.binding.label} " 2706 f"(bar {bar})" 2707 ) 2708 except Exception as exc: 2709 logger.warning( 2710 f"Hotkey '{pending.binding.key}' action raised: {exc}" 2711 ) 2712 else: 2713 still_pending.append(pending) 2714 2715 self._pending_hotkey_actions = still_pending 2716 2717 @property 2718 def seed (self) -> typing.Optional[int]: 2719 2720 """ 2721 The composition's random seed, or None when unseeded. 2722 2723 When set, every random decision derives deterministically from this 2724 value through named streams (see ``seed_for()``), so the same script 2725 produces the same music on every run. Assign to set it:: 2726 2727 comp.seed = 42 2728 2729 (Formerly the method ``comp.seed(42)`` — the call form is a hard 2730 break per the pre-1.0 rename policy.) 2731 """ 2732 2733 return self._seed 2734 2735 @seed.setter 2736 def seed (self, value: typing.Optional[int]) -> None: 2737 2738 """Set the composition seed (``comp.seed = 42``).""" 2739 2740 self._seed = value 2741 2742 def _stream_seed (self, name: str) -> typing.Optional[int]: 2743 2744 """ 2745 Derive the effective integer seed for a named random stream. 2746 2747 The derivation is ``zlib.crc32(f"{seed}:{name}")`` — crc32 rather 2748 than ``hash()`` because it is stable across processes — plus the 2749 per-name nonce when ``reroll()`` has been called. Returns None when 2750 the composition is unseeded. 2751 """ 2752 2753 if self._seed is None: 2754 return None 2755 2756 nonce = self._reroll_nonces.get(name, 0) 2757 key = f"{self._seed}:{name}" if nonce == 0 else f"{self._seed}:{name}:{nonce}" 2758 return zlib.crc32(key.encode()) 2759 2760 def _stream (self, name: str) -> typing.Optional[random.Random]: 2761 2762 """A fresh ``random.Random`` for a named stream, or None when unseeded.""" 2763 2764 stream_seed = self._stream_seed(name) 2765 return None if stream_seed is None else random.Random(stream_seed) 2766 2767 def seed_for (self, name: str) -> typing.Optional[int]: 2768 2769 """ 2770 Surface the effective derived seed for a named stream. 2771 2772 Works for pattern names and equally for any name you invent for a 2773 standalone value generator (``seed=composition.seed_for("hook")``), 2774 so its randomness keys off the composition seed without sharing any 2775 other consumer's stream. Reflects ``reroll()`` nonces. Returns None 2776 when the composition is unseeded. 2777 2778 Example: 2779 ```python 2780 hook_seed = composition.seed_for("hook") 2781 ``` 2782 """ 2783 2784 return self._stream_seed(name) 2785 2786 def reroll (self, name: str) -> None: 2787 2788 """ 2789 Deal a named stream a fresh deterministic seed — try a new variation. 2790 2791 Bumps the per-name nonce and prints the new effective seed. The 2792 nonce lives only in this process, so the printed seed is what lets a 2793 variation you like survive a restart: note it down, or ``lock()`` the 2794 name to pin it for the session. Refuses on locked names. 2795 2796 Parameters: 2797 name: The stream name — usually a pattern name. 2798 2799 Example: 2800 ```python 2801 comp.reroll("lead") # prints: reroll('lead') -> effective seed ... 2802 ``` 2803 """ 2804 2805 if name in self._locked_names: 2806 print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first") 2807 return 2808 2809 self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 2810 effective = self._stream_seed(name) 2811 2812 if effective is None: 2813 print(f"reroll('{name}'): composition has no seed - randomness is unseeded") 2814 return 2815 2816 running = self._running_patterns.get(name) 2817 2818 if running is not None and hasattr(running, "_rng"): 2819 running._rng = random.Random(effective) 2820 2821 print(f"reroll('{name}') -> effective seed {effective} (nonce {self._reroll_nonces[name]})") 2822 2823 def lock (self, name: str) -> None: 2824 2825 """ 2826 Pin a named stream: keep its current effective seed and realization. 2827 2828 Engine-side state, so it survives live reload (it is never a builder 2829 swap): a locked pattern re-deals its stream from the same effective 2830 seed on every rebuild, so every cycle realizes identically, and 2831 ``reroll()`` refuses with a message until ``unlock()``. 2832 2833 Parameters: 2834 name: The stream name — usually a pattern name. 2835 """ 2836 2837 self._locked_names.add(name) 2838 2839 def unlock (self, name: str) -> None: 2840 2841 """Release a ``lock()``: the stream runs free and ``reroll()`` works again.""" 2842 2843 self._locked_names.discard(name) 2844 2845 def tuning ( 2846 self, 2847 source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, 2848 *, 2849 cents: typing.Optional[typing.List[float]] = None, 2850 ratios: typing.Optional[typing.List[float]] = None, 2851 equal: typing.Optional[int] = None, 2852 bend_range: float = 2.0, 2853 channels: typing.Optional[typing.List[int]] = None, 2854 reference_note: int = 60, 2855 exclude_drums: bool = True, 2856 ) -> None: 2857 2858 """Set a global microtonal tuning for the composition. 2859 2860 The tuning is applied automatically after each pattern rebuild (before 2861 the pattern is scheduled). Drum patterns (those registered with a 2862 ``drum_note_map``) are excluded by default. 2863 2864 Supply exactly one of the source parameters: 2865 2866 - ``source``: path to a Scala ``.scl`` file. 2867 - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). 2868 - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). 2869 - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). 2870 2871 For polyphonic parts, supply a ``channels`` pool. Notes are spread 2872 across those MIDI channels so each can carry an independent pitch bend. 2873 The synth must be configured to match ``bend_range`` (its pitch-bend range 2874 setting in semitones). 2875 2876 Parameters: 2877 source: Path to a ``.scl`` file. 2878 cents: Cent offsets for scale degrees 1..N. 2879 ratios: Frequency ratios for scale degrees 1..N. 2880 equal: Number of equal divisions of the period. 2881 bend_range: Synth pitch-bend range in semitones (default ±2). 2882 channels: Channel pool for polyphonic rotation. 2883 reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). 2884 exclude_drums: When True (default), skip patterns that have a 2885 ``drum_note_map`` (they use fixed GM pitches, not tuned ones). 2886 2887 Example: 2888 ```python 2889 # Quarter-comma meantone from a Scala file 2890 comp.tuning("meanquar.scl") 2891 2892 # Just intonation from ratios 2893 comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) 2894 2895 # 19-TET, monophonic 2896 comp.tuning(equal=19, bend_range=2.0) 2897 2898 # 31-TET with channel rotation for polyphony (channels 1-6) 2899 comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) 2900 ``` 2901 """ 2902 import subsequence.tuning as _tuning_mod 2903 2904 given = sum(x is not None for x in [source, cents, ratios, equal]) 2905 if given == 0: 2906 raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal") 2907 if given > 1: 2908 raise ValueError("composition.tuning() accepts only one source parameter") 2909 2910 if source is not None: 2911 t = _tuning_mod.Tuning.from_scl(source) 2912 elif cents is not None: 2913 t = _tuning_mod.Tuning.from_cents(cents) 2914 elif ratios is not None: 2915 t = _tuning_mod.Tuning.from_ratios(ratios) 2916 else: 2917 t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] 2918 2919 self._tuning = t 2920 self._tuning_bend_range = bend_range 2921 self._tuning_channels = channels 2922 self._tuning_reference_note = reference_note 2923 self._tuning_exclude_drums = exclude_drums 2924 2925 def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None: 2926 2927 """ 2928 Enable or disable the live terminal dashboard. 2929 2930 When enabled, Subsequence uses a safe logging handler that allows a 2931 persistent status line (BPM, Key, Bar, Section, Chord) to stay at 2932 the bottom of the terminal while logs scroll above it. 2933 2934 Parameters: 2935 enabled: Whether to show the display (default True). 2936 grid: When True, render an ASCII grid visualisation of all 2937 running patterns above the status line. The grid updates 2938 once per bar, showing which steps have notes and at what 2939 velocity. 2940 grid_scale: Horizontal zoom factor for the grid (default 2941 ``1.0``). Higher values add visual columns between 2942 grid steps, revealing micro-timing from swing and groove. 2943 Snapped to the nearest integer internally for uniform 2944 marker spacing. 2945 """ 2946 2947 if enabled: 2948 self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale) 2949 else: 2950 self._display = None 2951 2952 def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: 2953 2954 """ 2955 Enable the realtime Web UI Dashboard. 2956 2957 When enabled, Subsequence instantiates a WebSocket server that broadcasts 2958 the current state, signals, and active patterns (with high-res timing and 2959 note data) to any connected browser clients. 2960 2961 Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` 2962 (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but 2963 broadcasts full composition state, so only do so on a trusted network. 2964 """ 2965 2966 self._web_ui_enabled = True 2967 self._web_ui_http_host = http_host 2968 self._web_ui_ws_host = ws_host 2969 2970 def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None: 2971 2972 """ 2973 Configure a MIDI input device for external sync and MIDI messages. 2974 2975 May be called multiple times to register additional input devices. 2976 The first call sets the primary input (device 0). Subsequent calls 2977 add additional input devices (device 1, 2, …). Only one device may 2978 have ``clock_follow=True``. 2979 2980 Parameters: 2981 device: Which MIDI input port to use, matched against 2982 ``mido.get_input_names()``. Treated as a pattern — ``*`` 2983 and ``?`` are wildcards, matching is case-insensitive, and a 2984 name without wildcards is a substring. See 2985 ``Composition.__init__`` for why a pattern like 2986 ``"*Launchpad *:0"`` survives an ALSA client id changing 2987 between runs. Because a wrong input would desynchronise or 2988 mis-record a performance, a pattern matching nothing raises, 2989 and one matching several asks which you meant rather than 2990 guessing. 2991 clock_follow: If True, Subsequence will slave its clock to incoming 2992 MIDI Ticks. It will also follow MIDI Start/Stop/Continue 2993 commands. Only one device can have this enabled at a time. 2994 name: Optional alias for use with ``cc_map(input_device=…)`` and 2995 ``cc_forward(input_device=…)``. When omitted, the raw device 2996 name is used. 2997 2998 Example: 2999 ```python 3000 # Single controller (unchanged usage) 3001 comp.midi_input("Scarlett 2i4", clock_follow=True) 3002 3003 # Multiple controllers 3004 comp.midi_input("Arturia KeyStep", name="keys") 3005 comp.midi_input("Faderfox EC4", name="faders") 3006 ``` 3007 """ 3008 3009 if clock_follow: 3010 if self.is_clock_following: 3011 raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)") 3012 3013 if self._input_device is None: 3014 # First call: set primary input device (device 0) 3015 self._input_device = device 3016 self._input_device_alias = name 3017 self._clock_follow = clock_follow 3018 else: 3019 # Subsequent calls: register additional input devices 3020 self._additional_inputs.append((device, name, clock_follow)) 3021 3022 def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int: 3023 3024 """ 3025 Register an additional MIDI output device. 3026 3027 The first output device is always the one passed to 3028 ``Composition(output_device=…)`` — that is device 0. 3029 Each call to ``midi_output()`` adds the next device (1, 2, …). 3030 3031 Parameters: 3032 device: Which MIDI output port to add, matched against 3033 ``mido.get_output_names()``. Treated as a pattern — 3034 ``*`` and ``?`` are wildcards, matching is 3035 case-insensitive, and a name without wildcards is a 3036 substring. See ``Composition.__init__`` for the lookup 3037 snippet and why a pattern like ``"*U6MIDI Pro *:0"`` 3038 survives an ALSA client id changing between runs. 3039 name: Optional alias for use with ``pattern(device=…)``, 3040 ``cc_forward(output_device=…)``, etc. When omitted, the raw 3041 device name is used. 3042 latency_ms: Physical output latency of this device in 3043 milliseconds, for delay compensation (default 0.0, must be 3044 non-negative). Set this when the device sounds late (e.g. a 3045 software sampler) so Subsequence delays faster devices to 3046 line everything up. 3047 3048 Returns: 3049 The integer device index assigned (1, 2, 3, …). 3050 3051 Example: 3052 ```python 3053 comp = subsequence.Composition(bpm=120, output_device="MOTU Express") 3054 3055 # Returns 1 — use as device=1 or device="integra" 3056 comp.midi_output("Roland Integra", name="integra") 3057 3058 # A software sampler that sounds 20ms late 3059 comp.midi_output("Subsample", name="sampler", latency_ms=20) 3060 3061 @comp.pattern(channel=1, beats=4, device="integra") 3062 def strings (p): 3063 p.note(60, beat=0) 3064 ``` 3065 """ 3066 3067 if latency_ms < 0: 3068 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 3069 3070 idx = 1 + len(self._additional_outputs) # device 0 is always the primary 3071 self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms)) 3072 return idx 3073 3074 def _warn_if_high_latency (self) -> None: 3075 3076 """Warn if delay compensation adds a large whole-rig latency. 3077 3078 The slowest device defines the alignment point — every faster device is 3079 delayed up to that amount — so a large maximum means the whole rig 3080 responds late to live input. Emitted once at startup. 3081 """ 3082 3083 candidates: typing.List[typing.Tuple[str, float]] = [("primary output", self._output_latency_ms)] 3084 candidates += [(out.alias or out.device, out.latency_ms) for out in self._additional_outputs] 3085 3086 slow_name, max_ms = max(candidates, key=lambda c: c[1]) 3087 3088 if max_ms > _LATENCY_WARN_THRESHOLD_MS: 3089 logger.warning( 3090 "Device latency compensation: '%s' is the slowest at %.0fms, so faster " 3091 "devices are delayed up to %.0fms to stay aligned — live-input feel may suffer.", 3092 slow_name, max_ms, max_ms, 3093 ) 3094 3095 def clock_output (self, enabled: bool = True) -> None: 3096 3097 """ 3098 Send MIDI timing clock to connected hardware. 3099 3100 When enabled, Subsequence acts as a MIDI clock master and sends 3101 standard clock messages on the output port: a Start message (0xFA) 3102 when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), 3103 and a Stop message (0xFC) when playback ends. 3104 3105 This allows hardware synthesizers, drum machines, and effect units to 3106 slave their tempo to Subsequence automatically. 3107 3108 **Note:** Clock output is automatically disabled when ``midi_input()`` 3109 is called with ``clock_follow=True``, to prevent a clock feedback loop. 3110 3111 Parameters: 3112 enabled: Whether to send MIDI clock (default True). 3113 3114 Example: 3115 ```python 3116 comp = subsequence.Composition(bpm=120, output_device="...") 3117 comp.clock_output() # hardware will follow Subsequence tempo 3118 ``` 3119 """ 3120 3121 self._clock_output = enabled 3122 3123 3124 def link (self, quantum: float = 4.0) -> "Composition": 3125 3126 """ 3127 Enable Ableton Link tempo and phase synchronisation. 3128 3129 When enabled, Subsequence joins the local Link session and slaves its 3130 clock to the shared network tempo and beat phase. All other Link-enabled 3131 apps on the same LAN — Ableton Live, iOS synths, other Subsequence 3132 instances — will automatically stay in time. 3133 3134 Playback starts on the next bar boundary aligned to the Link quantum, 3135 so downbeats stay in sync across all participants. 3136 3137 Requires the ``link`` optional extra:: 3138 3139 pip install subsequence[link] 3140 3141 Parameters: 3142 quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. 3143 Change this if your composition uses a different meter. 3144 3145 Example:: 3146 3147 comp = subsequence.Composition(bpm=120, key="C") 3148 comp.link() # join the Link session 3149 comp.play() 3150 3151 # On another machine / instance: 3152 comp2 = subsequence.Composition(bpm=120) 3153 comp2.link() # tempo and phase will lock to comp 3154 comp2.play() 3155 3156 Note: 3157 ``set_bpm()`` proposes the new tempo to the Link network when Link 3158 is active. The network-authoritative tempo is applied on the next 3159 pulse, so there may be a brief lag before the change is visible. 3160 """ 3161 3162 # Eagerly check that aalink is installed — fail early with a clear message. 3163 subsequence.link_clock._require_aalink() 3164 3165 self._link_quantum = quantum 3166 return self 3167 3168 3169 def cc_map ( 3170 self, 3171 cc: int, 3172 data_key: str, 3173 channel: typing.Optional[int] = None, 3174 min_val: float = 0.0, 3175 max_val: float = 1.0, 3176 input_device: subsequence.midi_utils.DeviceId = None, 3177 ) -> None: 3178 3179 """ 3180 Map an incoming MIDI CC to a ``composition.data`` key. 3181 3182 When the composition receives a CC message on the configured MIDI 3183 input port, the value is scaled from the CC range (0–127) to 3184 *[min_val, max_val]* and stored in ``composition.data[data_key]``. 3185 3186 This lets hardware knobs, faders, and expression pedals control live 3187 parameters without writing any callback code. 3188 3189 **Requires** ``midi_input()`` to be called first to open an input port. 3190 3191 Parameters: 3192 cc: MIDI Control Change number (0–127). 3193 data_key: The ``composition.data`` key to write. 3194 channel: If given, only respond to CC messages on this channel. 3195 Uses the same numbering convention as ``pattern()`` (1-16 3196 by default, or 0-15 with ``zero_indexed_channels=True``). 3197 ``None`` matches any channel (default). 3198 min_val: Scaled minimum — written when CC value is 0 (default 0.0). 3199 max_val: Scaled maximum — written when CC value is 127 (default 1.0). 3200 input_device: Only respond to CC messages from this input device 3201 (index or name). ``None`` responds to any input device (default). 3202 3203 Example: 3204 ```python 3205 comp.midi_input("Arturia KeyStep") 3206 comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 3207 comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader 3208 3209 # Multi-device: only listen to CC 74 from the "faders" controller 3210 comp.cc_map(74, "filter", input_device="faders") 3211 ``` 3212 """ 3213 3214 resolved_channel = self._resolve_channel(channel) if channel is not None else None 3215 3216 self._cc_mappings.append({ 3217 'cc': cc, 3218 'data_key': data_key, 3219 'channel': resolved_channel, 3220 'min_val': min_val, 3221 'max_val': max_val, 3222 'input_device': input_device, # resolved to int index in _run() 3223 }) 3224 3225 3226 def note_input ( 3227 self, 3228 channel: typing.Optional[int] = None, 3229 release_ms: float = 30.0, 3230 latch: bool = False, 3231 input_device: subsequence.midi_utils.DeviceId = None, 3232 ) -> None: 3233 3234 """Track notes held on a MIDI keyboard for live arpeggiation. 3235 3236 Incoming note-on/note-off messages build a live "currently held" set 3237 that any pattern reads via ``p.held_notes()`` — typically fed straight 3238 to ``p.arpeggio()``. The composition still authors the rhythm and 3239 motion; the player's hands supply the pitch set. This is a live 3240 *performance* layer over the deterministic, seeded composition: when 3241 rendering headlessly there is no input, so ``p.held_notes()`` is empty 3242 and seeded output is unchanged. 3243 3244 **Requires** ``midi_input()`` to be called first to open an input port. 3245 3246 Parameters: 3247 channel: If given, only track notes on this channel. Uses the same 3248 numbering convention as ``pattern()`` (1-16 by default, or 0-15 3249 with ``zero_indexed_channels=True``). ``None`` tracks any 3250 channel (default). 3251 release_ms: How long (milliseconds) a released note keeps counting 3252 as held. This smooths the momentary all-keys-up gap during a 3253 hand-position change so the arp does not drop to silence. 3254 Default 30.0; set 0.0 to release instantly. Ignored when 3255 ``latch`` is True. 3256 latch: When True, the held set persists after you lift your hands 3257 until you play a new chord (the first key after every key is up 3258 replaces it) — like a hardware arp's latch. 3259 input_device: Only track notes from this input device (index or 3260 name). ``None`` tracks any input device (default). 3261 3262 Example: 3263 ```python 3264 comp.midi_input("Arturia KeyStep") 3265 comp.note_input(channel=1, release_ms=30) 3266 3267 @comp.pattern(channel=6, beats=4) 3268 def arp (p): 3269 p.arpeggio(p.held_notes(), direction="up") # rests when silent 3270 ``` 3271 """ 3272 3273 if self._note_input is not None: 3274 raise RuntimeError("only one note_input source is supported — named multi-source is not yet available") 3275 3276 resolved_channel = self._resolve_channel(channel) if channel is not None else None 3277 3278 self._note_input = { 3279 'channel': resolved_channel, 3280 'release_ms': release_ms, 3281 'latch': latch, 3282 'input_device': input_device, # resolved to int index in _run() 3283 } 3284 3285 3286 @staticmethod 3287 def _make_cc_forward_transform ( 3288 output: typing.Union[str, typing.Callable], 3289 cc: int, 3290 output_channel: typing.Optional[int], 3291 ) -> typing.Callable: 3292 3293 """Build a transform callable from a preset string or user-supplied callable. 3294 3295 The returned callable has signature ``(value: int, channel: int) -> Optional[mido.Message]`` 3296 where ``channel`` is the 0-indexed incoming channel. 3297 """ 3298 3299 import mido as _mido 3300 3301 def _out_ch (incoming: int) -> int: 3302 return output_channel if output_channel is not None else incoming 3303 3304 if callable(output): 3305 if output_channel is None: 3306 return output 3307 def _wrapped (value: int, channel: int) -> typing.Optional[typing.Any]: 3308 msg = output(value, channel) 3309 3310 if msg is None: 3311 return None 3312 3313 # copy() re-channels without rebuilding: reconstructing from 3314 # __dict__ passed 'type' twice and raised TypeError on every 3315 # message, so callable+output_channel never forwarded anything. 3316 return msg.copy(channel=output_channel) 3317 return _wrapped 3318 3319 if output == 'cc': 3320 def _cc_identity (value: int, channel: int) -> typing.Any: 3321 return _mido.Message('control_change', channel=_out_ch(channel), control=cc, value=value) 3322 return _cc_identity 3323 3324 if output.startswith('cc:'): 3325 try: 3326 target_cc = int(output[3:]) 3327 except ValueError: 3328 raise ValueError(f"cc_forward(): invalid preset '{output}' — expected 'cc:N' where N is 0–127") 3329 if not 0 <= target_cc <= 127: 3330 raise ValueError(f"cc_forward(): CC number {target_cc} out of range 0–127") 3331 def _cc_remap (value: int, channel: int) -> typing.Any: 3332 return _mido.Message('control_change', channel=_out_ch(channel), control=target_cc, value=value) 3333 return _cc_remap 3334 3335 if output == 'pitchwheel': 3336 def _pitchwheel (value: int, channel: int) -> typing.Any: 3337 pitch = int(value / 127 * 16383) - 8192 3338 return _mido.Message('pitchwheel', channel=_out_ch(channel), pitch=pitch) 3339 return _pitchwheel 3340 3341 raise ValueError( 3342 f"cc_forward(): unknown preset '{output}'. " 3343 "Use 'cc', 'cc:N' (e.g. 'cc:74'), 'pitchwheel', or a callable." 3344 ) 3345 3346 3347 def cc_forward ( 3348 self, 3349 cc: int, 3350 output: typing.Union[str, typing.Callable], 3351 *, 3352 channel: typing.Optional[int] = None, 3353 output_channel: typing.Optional[int] = None, 3354 mode: str = "instant", 3355 input_device: subsequence.midi_utils.DeviceId = None, 3356 output_device: subsequence.midi_utils.DeviceId = None, 3357 ) -> None: 3358 3359 """ 3360 Forward an incoming MIDI CC to the MIDI output in real-time. 3361 3362 Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` 3363 for use at pattern rebuild time, ``cc_forward()`` routes the signal 3364 directly to the MIDI output — bypassing the pattern cycle entirely. 3365 3366 Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC 3367 number; they operate independently. 3368 3369 Parameters: 3370 cc: Incoming CC number to listen for (0–127). 3371 output: What to send. Either a **preset string**: 3372 3373 - ``"cc"`` — identity forward, same CC number and value. 3374 - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). 3375 - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. 3376 3377 Or a **callable** with signature 3378 ``(value: int, channel: int) -> Optional[mido.Message]``. 3379 Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. 3380 ``channel`` is 0-indexed (the incoming channel). 3381 channel: If given, only respond to CC messages on this channel. 3382 Uses the same numbering convention as ``cc_map()``. 3383 ``None`` matches any channel (default). 3384 output_channel: Override the output channel. ``None`` uses the 3385 incoming channel. Uses the same numbering convention as ``pattern()``. 3386 input_device: Only respond to CC from this input device — an index, 3387 a registered name, or ``None`` for any input (default), the 3388 same convention as ``cc_map()``. 3389 output_device: Send to this output device — an index, a registered 3390 name, or ``None`` for the primary output (default). 3391 mode: Dispatch mode: 3392 3393 - ``"instant"`` *(default)* — send immediately on the MIDI input 3394 callback thread. Lowest latency (~1–5 ms). Instant forwards are 3395 **not** recorded when recording is enabled. 3396 - ``"queued"`` — inject into the sequencer event queue and send at 3397 the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards 3398 **are** recorded when recording is enabled. 3399 3400 Example: 3401 ```python 3402 comp.midi_input("Arturia KeyStep") 3403 3404 # CC 1 → CC 1 (identity, instant) 3405 comp.cc_forward(1, "cc") 3406 3407 # CC 1 → pitch bend on channel 1, queued (recordable) 3408 comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") 3409 3410 # CC 1 → CC 74, custom channel 3411 comp.cc_forward(1, "cc:74", output_channel=2) 3412 3413 # Custom transform — remap CC range 0–127 to CC 74 range 40–100 3414 import subsequence.midi as midi 3415 comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) 3416 3417 # Forward AND map to data simultaneously — both active on the same CC 3418 comp.cc_map(1, "mod_wheel") 3419 comp.cc_forward(1, "cc:74") 3420 ``` 3421 """ 3422 3423 if not 0 <= cc <= 127: 3424 raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") 3425 3426 if mode not in ('instant', 'queued'): 3427 raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'") 3428 3429 resolved_in_channel = self._resolve_channel(channel) if channel is not None else None 3430 resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None 3431 3432 transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) 3433 3434 self._cc_forwards.append({ 3435 'cc': cc, 3436 'channel': resolved_in_channel, 3437 'output_channel': resolved_out_channel, 3438 'mode': mode, 3439 'transform': transform, 3440 'input_device': input_device, # resolved to int index in _run() 3441 'output_device': output_device, # resolved to int index in _run() 3442 }) 3443 3444 3445 def live (self, port: int = 5555) -> None: 3446 3447 """ 3448 Enable the live coding eval server. 3449 3450 This allows you to connect to a running composition using the 3451 ``subsequence.live_client`` REPL and hot-swap pattern code or 3452 modify variables in real-time. 3453 3454 Security: 3455 The server executes arbitrary Python in this process — it is **not** a 3456 sandbox. It binds to localhost only and is opt-in, but any process on 3457 the same machine that can reach the port gains full code execution here. 3458 Do not enable it on shared or multi-user hosts, and never expose the 3459 port to a network. 3460 3461 Parameters: 3462 port: The TCP port to listen on (default 5555). 3463 """ 3464 3465 self._live_server = subsequence.live_server.LiveServer(self, port=port) 3466 self._is_live = True 3467 3468 def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None: 3469 3470 """Watch a Python file and reload it into the composition on every save. 3471 3472 The watched file is exec'd into a namespace with ``composition`` and 3473 ``subsequence`` available. ``@composition.pattern`` decorators inside 3474 the file hot-swap their corresponding running patterns in place; 3475 patterns whose function bodies have been deleted from the file are 3476 unregistered automatically on the next reload (notes stopped, 3477 removed from the running-pattern set). 3478 3479 An **initial synchronous load** happens here — if the file has a 3480 ``SyntaxError`` or doesn't exist at this moment, the exception 3481 propagates so the user knows immediately. Subsequent reloads 3482 happen on the composition's event loop and tolerate transient 3483 errors (logged, skipped). 3484 3485 Call BEFORE ``composition.play()``. Reloads happen on the 3486 composition's event loop, so all mutations are thread-safe. 3487 3488 See the "Live coding via file watching" section of the README for 3489 the recommended wrapper-script + live-file split. 3490 3491 Parameters: 3492 path: Path to the Python file to watch. 3493 poll_interval: Seconds between ``mtime`` polls (default 0.25 s). 3494 3495 Example:: 3496 3497 # live_init.py — runs once 3498 composition = subsequence.Composition(bpm=120, key="E") 3499 composition.harmony(style="aeolian_minor") 3500 composition.watch("live_patterns.py") 3501 composition.play() 3502 """ 3503 3504 # Required for the decorator hot-swap path to fire on re-decoration. 3505 self._is_live = True 3506 3507 # Detect the single-file workflow: if watch() is called from inside 3508 # the very file being watched, the outer Python script execution will 3509 # already register the patterns (the decorators sit at module level 3510 # below ``watch(__file__)``). In that case, _load_initial's re-exec 3511 # would double-register every pattern, so skip it. For the two-file 3512 # workflow (path != caller's __file__) the initial exec is essential 3513 # — it's the only way the watched file's patterns ever reach the 3514 # composition. 3515 caller_file = self._caller_module_file() 3516 self_watch = False 3517 if caller_file is not None: 3518 try: 3519 self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() 3520 except OSError: 3521 self_watch = False 3522 3523 self._live_reloader = subsequence.live_reloader.LiveReloader( 3524 composition = self, 3525 path = path, 3526 poll_interval = poll_interval, 3527 skip_initial_exec = self_watch, 3528 ) 3529 self._live_reloader.start() 3530 3531 @staticmethod 3532 def _caller_module_file () -> typing.Optional[str]: 3533 3534 """Return ``__file__`` of the module that invoked the caller, if available. 3535 3536 Walks one frame up the call stack — the immediate caller is 3537 ``watch()``, so ``f_back`` is the user's code. Returns the 3538 module-level ``__file__`` of that frame's globals; ``None`` when 3539 the caller has no ``__file__`` (REPL, exec'd context, etc.). 3540 """ 3541 3542 frame = inspect.currentframe() 3543 if frame is None or frame.f_back is None or frame.f_back.f_back is None: 3544 return None 3545 # f_back = watch(); f_back.f_back = user code calling watch(). 3546 return frame.f_back.f_back.f_globals.get("__file__") 3547 3548 def load_patterns ( 3549 self, 3550 source: str, 3551 source_label: str = "<string>", 3552 ) -> None: 3553 3554 """Compile and apply a pattern-source string to the composition. 3555 3556 Equivalent to one ``watch()`` reload triggered by save, but with the 3557 source presented in-memory rather than on disk. Useful for web / 3558 REST handlers that accept pattern uploads from a trusted contributor, 3559 or for one-shot session loads with no file backing. 3560 3561 Behaviour mirrors ``watch()``: 3562 3563 * The source is exec'd into a fresh namespace with ``composition`` 3564 and ``subsequence`` in scope. 3565 * ``@composition.pattern`` decorators in the source hot-swap their 3566 corresponding running patterns in place. 3567 * Patterns currently running but **not** declared in the source are 3568 unregistered — the source is treated as the full new truth. 3569 * If the composition is already playing, the swap happens on the 3570 event loop thread; the call blocks until it completes. 3571 * If the composition has not yet called ``play()``, the source runs 3572 on the caller's thread; decorators populate ``_pending_patterns`` 3573 and ``play()`` picks them up in the usual way. 3574 3575 Errors are raised so the caller can act on them: 3576 3577 * ``SyntaxError`` if ``source`` fails to compile. 3578 * The exception raised inside ``exec()`` for any runtime error. 3579 * ``RuntimeError`` if called from inside the composition's own 3580 event loop thread (would deadlock — see Threading below). 3581 3582 In either failure case, existing composition state is preserved — 3583 the diff-and-unregister phase is skipped if exec raised, so a 3584 half-broken upload cannot tear down working patterns. 3585 3586 Threading: 3587 Designed to be called from a thread DIFFERENT from the 3588 composition's event loop — typically a web-handler worker. 3589 Cannot be called from inside the loop itself (a pattern 3590 callback, an asyncio task spawned by the composition). From 3591 there, ``await composition._apply_source_async(...)`` directly. 3592 3593 SECURITY WARNING: ``exec()`` is not sandboxed. The source has full 3594 Python access in this process. Only pass source from trusted 3595 senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, 3596 ``exit``, ``quit``) prevents calls that would stall the event loop; 3597 it is not a security boundary. 3598 3599 Parameters: 3600 source: Python source declaring ``@composition.pattern`` 3601 functions. 3602 source_label: Identifier used in compile errors and tracebacks 3603 (appears as the filename in ``SyntaxError`` and ``__file__``- 3604 style traceback lines). Default ``"<string>"``. 3605 """ 3606 3607 # Required for the decorator hot-swap path to fire on re-decoration. 3608 self._is_live = True 3609 3610 # Compile on the caller's thread so SyntaxError comes back fast, 3611 # before any cross-thread scheduling. 3612 compiled = compile(source, source_label, "exec") 3613 namespace = self._build_live_namespace(source_label = source_label) 3614 3615 loop = self._sequencer._event_loop 3616 3617 if loop is not None and loop.is_running(): 3618 3619 # Refuse to deadlock: calling load_patterns() from inside the 3620 # composition's own event loop (e.g. from a pattern callback or 3621 # an asyncio task spawned by the composition) would have us 3622 # block waiting for a coroutine that can only run when this 3623 # thread yields. Tell the caller exactly what to do instead. 3624 try: 3625 current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() 3626 except RuntimeError: 3627 current_loop = None 3628 3629 if current_loop is loop: 3630 raise RuntimeError( 3631 "load_patterns() cannot be called from inside the composition's " 3632 "event loop thread — it would deadlock waiting for the " 3633 "scheduled coroutine to run on the very thread that's blocked. " 3634 "From a worker thread, call it normally. From an async " 3635 "coroutine already on the loop, " 3636 "`await composition._apply_source_async(compile(source, label, 'exec'), " 3637 "composition._build_live_namespace())` instead." 3638 ) 3639 3640 # Composition is playing — mutation must happen on the loop thread. 3641 # future.result() blocks the caller until the coroutine finishes 3642 # and re-raises any exception it threw. 3643 future = asyncio.run_coroutine_threadsafe( 3644 self._apply_source_async(compiled, namespace, source_key = source_label), 3645 loop = loop, 3646 ) 3647 future.result() 3648 3649 else: 3650 # Pre-play: no event loop yet. Decorators populate 3651 # _pending_patterns; play() graduates them in the usual way. 3652 # Diff-and-unregister is unnecessary here — nothing is running, 3653 # but RECORD what this source declares so a later post-play 3654 # reload under the same label can tear down its deletions. 3655 self._declared_names = set() 3656 exec(compiled, namespace) 3657 self._source_declared[source_label] = set(self._declared_names) 3658 3659 async def _apply_source_async ( 3660 self, 3661 compiled: types.CodeType, 3662 namespace: typing.Dict[str, typing.Any], 3663 source_key: str = "<live>", 3664 ) -> None: 3665 3666 """Execute pre-compiled live source against the running composition. 3667 3668 Runs on the event loop thread. Performs ``exec()``, graduates any 3669 newly-decorated patterns into ``_running_patterns``, then unregisters 3670 any patterns that *this source* declared on its previous exec but no 3671 longer declares (keyed by ``source_key`` — a watched file's path or a 3672 ``load_patterns`` label). Patterns registered by the wrapper script 3673 or by another source are never this source's to tear down. 3674 3675 Raises whatever ``exec()`` raises. When that happens, the diff-and- 3676 unregister phase is skipped — the namespace is incomplete, so any 3677 patterns the source failed to reach would be misinterpreted as 3678 deletions and torn down. 3679 3680 Called from two places: 3681 3682 * ``Composition.load_patterns()`` via ``run_coroutine_threadsafe``. 3683 * ``LiveReloader._reload_async`` directly (already on the loop). 3684 """ 3685 3686 # Track which patterns the source declares this exec. pattern() and 3687 # layer() add their (resolved) names to _declared_names as they run, so 3688 # this covers decorated patterns AND layer()/merged patterns — the latter 3689 # have no module-level callable to match against by name, which is why the 3690 # old namespace-based diff tore layers down on every reload. 3691 self._declared_names = set() 3692 3693 # Bail before any state mutation if exec raises — propagates to 3694 # the caller (load_patterns re-raises; LiveReloader catches + logs). 3695 exec(compiled, namespace) 3696 3697 # Graduate newly-decorated patterns from _pending_patterns into 3698 # _running_patterns so they start firing on the next reschedule. 3699 # Patterns that hot-swapped via the decorator/layer path don't appear 3700 # in _pending_patterns and don't need this step. 3701 await self._activate_new_pending_patterns() 3702 3703 # Detect deletions: a name THIS source declared last time but not this 3704 # time has been removed by the user and should be torn down. (An 3705 # unknown source — first exec — tears down nothing: patterns running 3706 # from the wrapper script or another source are not ours to remove.) 3707 # Decorators/layer() do NOT remove from _running_patterns when a 3708 # definition disappears from the source. 3709 previous = self._source_declared.get(source_key) 3710 3711 if previous is not None: 3712 for name in previous - self._declared_names: 3713 if name in self._running_patterns: 3714 self.unregister(name) 3715 3716 self._source_declared[source_key] = set(self._declared_names) 3717 3718 def _build_live_namespace (self, source_label: str = "<live>") -> typing.Dict[str, typing.Any]: 3719 3720 """Build a fresh namespace dict for exec'ing live source. 3721 3722 Provides ``composition`` (this Composition), ``subsequence`` (the 3723 package), and a safe builtins set with ``help``, ``input``, 3724 ``breakpoint``, ``exit``, ``quit`` blocked. 3725 3726 Also injects two dunder globals that make the single-file live-coding 3727 workflow ergonomic: 3728 3729 * ``__name__ = "__live_reload__"`` — so ``if __name__ == "__main__":`` 3730 blocks in the watched file are *skipped* during live reload. The 3731 same file run directly with ``python my_session.py`` sees 3732 ``__name__ == "__main__"`` and runs setup; saves trigger reload 3733 with ``__name__ == "__live_reload__"``, skipping setup and only 3734 re-running pattern definitions. 3735 * ``__file__ = source_label`` — so ``composition.watch(__file__)`` 3736 and any user code referencing ``__file__`` works inside the live 3737 namespace. Set to the file path for ``LiveReloader``, the 3738 user-supplied ``source_label`` for ``Composition.load_patterns``, 3739 and ``"<live>"`` for ``LiveServer``. 3740 3741 Single source of truth: ``live_reloader`` (file watching), 3742 ``live_server`` (TCP REPL), and ``load_patterns`` (string source) 3743 all call this so live source sees the same environment from any 3744 entry point. 3745 3746 The blocklist prevents calls that would stall the async event loop 3747 running the sequencer. It is **not** a security sandbox — exec'd 3748 code can still do anything Python allows. 3749 3750 Parameters: 3751 source_label: Value to bind to ``__file__`` in the namespace. 3752 Defaults to ``"<live>"``. 3753 """ 3754 3755 import subsequence # local import: this module is imported during subsequence init 3756 3757 safe_builtins = {name: getattr(builtins, name) for name in dir(builtins)} 3758 3759 blocked = {"help", "input", "breakpoint", "exit", "quit"} 3760 3761 for name in blocked: 3762 safe_builtins[name] = _live_blocked(name) 3763 3764 return { 3765 "__builtins__": safe_builtins, 3766 "__name__": "__live_reload__", 3767 "__file__": source_label, 3768 "composition": self, 3769 "subsequence": subsequence, 3770 } 3771 3772 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: 3773 3774 """ 3775 Enable bi-directional Open Sound Control (OSC). 3776 3777 Subsequence will listen for commands (like ``/bpm`` or ``/mute``) and 3778 broadcast its internal state (like ``/chord`` or ``/bar``) over UDP. 3779 3780 Parameters: 3781 receive_port: Port to listen for incoming OSC messages (default 9000). 3782 send_port: Port to send state updates to (default 9001). 3783 send_host: The IP address to send updates to (default "127.0.0.1"). 3784 receive_host: Interface to listen on (default "0.0.0.0" — all 3785 interfaces, so external OSC controllers on the LAN can reach it). 3786 The listener can change tempo, mute patterns, and write data, so on 3787 an untrusted network restrict it with ``receive_host="127.0.0.1"``. 3788 """ 3789 3790 self._osc_server = subsequence.osc.OscServer( 3791 self, 3792 receive_port = receive_port, 3793 send_port = send_port, 3794 send_host = send_host, 3795 receive_host = receive_host 3796 ) 3797 3798 def osc_map (self, address: str, handler: typing.Callable) -> None: 3799 3800 """ 3801 Register a custom OSC handler. 3802 3803 Must be called after :meth:`osc` has been configured. 3804 3805 Parameters: 3806 address: OSC address pattern to match (e.g. ``"/my/param"``). 3807 handler: Callable invoked with ``(address, *args)`` when a 3808 matching message arrives. 3809 3810 Example:: 3811 3812 composition.osc() 3813 3814 def on_intensity (address, value): 3815 composition.data["intensity"] = float(value) 3816 3817 composition.osc_map("/intensity", on_intensity) 3818 """ 3819 3820 if self._osc_server is None: 3821 raise RuntimeError("Call composition.osc() before composition.osc_map()") 3822 3823 self._osc_server.map(address, handler) 3824 3825 def set_bpm (self, bpm: float) -> None: 3826 3827 """ 3828 Instantly change the tempo. 3829 3830 Parameters: 3831 bpm: The new tempo in beats per minute. 3832 3833 When Ableton Link is active, this proposes the new tempo to the Link 3834 network instead of applying it locally. The network-authoritative tempo 3835 is picked up on the next pulse. 3836 """ 3837 3838 self._sequencer.set_bpm(bpm) 3839 3840 if not self.is_clock_following and self._link_quantum is None: 3841 self.bpm = bpm 3842 3843 def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None: 3844 3845 """ 3846 Smoothly ramp the tempo to a target value over a number of bars. 3847 3848 Parameters: 3849 bpm: Target tempo in beats per minute. 3850 bars: Duration of the transition in bars. 3851 shape: Easing curve name. Defaults to ``"linear"``. 3852 ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- 3853 sounding tempo changes. See :mod:`subsequence.easing` for all 3854 available shapes. 3855 3856 Example: 3857 ```python 3858 # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve 3859 comp.target_bpm(140, bars=8, shape="ease_in_out") 3860 ``` 3861 3862 Note: 3863 Ignored while Ableton Link is active — the shared session tempo is 3864 authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. 3865 """ 3866 3867 self._sequencer.set_target_bpm(bpm, bars, shape) 3868 3869 def live_info (self) -> typing.Dict[str, typing.Any]: 3870 3871 """ 3872 Return a dictionary containing the current state of the composition. 3873 3874 Includes BPM, key, current bar, active section, current chord, 3875 running patterns, and custom data. 3876 """ 3877 3878 section_info = None 3879 if self._form_state is not None: 3880 section = self._form_state.get_section_info() 3881 if section is not None: 3882 section_info = { 3883 "name": section.name, 3884 "bar": section.bar, 3885 "bars": section.bars, 3886 "progress": section.progress 3887 } 3888 3889 chord_name = None 3890 sounding_chord = self.current_chord() 3891 if sounding_chord is not None: 3892 chord_name = sounding_chord.name() 3893 3894 pattern_list = [] 3895 channel_offset = 0 if self._zero_indexed_channels else 1 3896 for name, pat in self._running_patterns.items(): 3897 pattern_list.append({ 3898 "name": name, 3899 "channel": pat.channel + channel_offset, 3900 "length": pat.length, 3901 "cycle": pat._cycle_count, 3902 "muted": pat._muted, 3903 "tweaks": dict(pat._tweaks) 3904 }) 3905 3906 return { 3907 "bpm": self._sequencer.current_bpm, 3908 "key": self.key, 3909 "bar": self._builder_bar, 3910 "section": section_info, 3911 "chord": chord_name, 3912 "patterns": pattern_list, 3913 "input_device": self._input_device, 3914 "clock_follow": self.is_clock_following, 3915 "data": self.data 3916 } 3917 3918 def mute (self, name: str) -> None: 3919 3920 """ 3921 Mute a running pattern by name. 3922 3923 The pattern continues to 'run' and increment its cycle count in 3924 the background, but it will not produce any MIDI notes until unmuted. 3925 3926 Parameters: 3927 name: The function name of the pattern to mute. 3928 """ 3929 3930 if name not in self._running_patterns: 3931 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3932 3933 # The performer takes ownership: if a transition's approach window had 3934 # muted this pattern, drop it from that set so the section boundary 3935 # does not silently unmute it ("performer mutes win"). 3936 self._transition_muted.discard(name) 3937 3938 self._running_patterns[name]._muted = True 3939 logger.info(f"Muted pattern: {name}") 3940 3941 def unmute (self, name: str) -> None: 3942 3943 """ 3944 Unmute a previously muted pattern. 3945 """ 3946 3947 if name not in self._running_patterns: 3948 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3949 3950 # Symmetric ownership claim: an explicit unmute means the transition 3951 # machinery should no longer manage this pattern at the boundary. 3952 self._transition_muted.discard(name) 3953 3954 self._running_patterns[name]._muted = False 3955 logger.info(f"Unmuted pattern: {name}") 3956 3957 def unregister (self, name: str) -> None: 3958 3959 """Fully remove a running pattern from rotation. 3960 3961 Unlike ``mute()`` (which keeps the pattern alive but silent), 3962 ``unregister()`` tears the pattern down entirely. It sets 3963 ``pattern._removed = True`` so the sequencer's reschedule loop 3964 skips re-adding it on the next pulse; sends ``note_off`` for any 3965 of the pattern's currently-sounding notes on the primary 3966 destination AND on every mirror destination (so drones and 3967 sustaining notes stop immediately); and removes the entry from 3968 ``_running_patterns`` so it no longer appears in ``live_info()``, 3969 the terminal grid, or any other consumer that enumerates running 3970 patterns. 3971 3972 Already-queued events in the sequencer's event queue play out — 3973 note_offs are paired with their note_ons at queue time, so notes 3974 end at their natural duration; only drones rely on the targeted 3975 ``_stop_pattern_notes`` pass. 3976 3977 Idempotent: silently logs a ``debug`` and returns if the pattern 3978 is already absent. Useful from both the live REPL 3979 (``composition.live()``) and the file watcher 3980 (``composition.watch()``), which calls this for any pattern 3981 removed from the watched file between reloads. 3982 3983 Parameters: 3984 name: Function name of the pattern to remove. 3985 """ 3986 3987 if name not in self._running_patterns: 3988 logger.debug(f"unregister() no-op: pattern '{name}' not running") 3989 return 3990 3991 pattern = self._running_patterns[name] 3992 3993 # Mark for removal first so the reschedule loop sees the flag even if 3994 # it fires concurrently with the note-off pass below. 3995 pattern._removed = True 3996 3997 # Stop sustaining notes (including drones) on every destination this 3998 # pattern outputs to. Fire-and-forget across threads via the event 3999 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 4000 if self._sequencer._event_loop is not None: 4001 asyncio.run_coroutine_threadsafe( 4002 self._sequencer._stop_pattern_notes(pattern), 4003 loop = self._sequencer._event_loop, 4004 ) 4005 4006 def _finalise_removal () -> None: 4007 self._running_patterns.pop(name, None) 4008 4009 # Forget any pending (not-yet-graduated) declaration too, so a 4010 # later live reload cannot resurrect the pattern. 4011 self._pending_patterns = [ 4012 pending for pending in self._pending_patterns 4013 if pending.builder_fn.__name__ != name 4014 ] 4015 4016 logger.info(f"Unregistered pattern: {name}") 4017 4018 # The running-patterns dict is iterated by the display, web UI, and 4019 # reschedule loop on the event loop thread — mutate it there when this 4020 # call arrives from another thread (e.g. the live TCP server). 4021 loop = self._sequencer._event_loop 4022 4023 try: 4024 on_loop = loop is not None and asyncio.get_running_loop() is loop 4025 except RuntimeError: 4026 on_loop = False 4027 4028 if loop is not None and loop.is_running() and not on_loop: 4029 loop.call_soon_threadsafe(_finalise_removal) 4030 else: 4031 _finalise_removal() 4032 4033 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 4034 4035 """ 4036 Add a mirror destination to a running pattern. 4037 4038 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 4039 event the pattern emits will also be sent to ``(device, channel)``, 4040 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 4041 — calling with the same destination twice does not double-fan; calling 4042 again with a different ``drum_note_map`` re-points it in place. 4043 4044 Parameters: 4045 name: Function name of the pattern to mirror. 4046 device: Output device index (the integer returned from 4047 ``midi_output()``; 0 = primary device). 4048 channel: MIDI channel using this composition's numbering convention 4049 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 4050 drum_note_map: Optional per-destination drum map. When set, mirrored 4051 drum hits are re-resolved by name through it, so a named voice 4052 lands on this device's own note number — see the README 4053 "MIDI mirroring" section. 4054 4055 Bandwidth: each mirror adds another full copy of the pattern's events. 4056 See the README "MIDI mirroring" section for the tradeoffs. 4057 """ 4058 4059 if name not in self._running_patterns: 4060 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4061 4062 resolved_channel = self._resolve_channel(channel) 4063 prefix = (device, resolved_channel) 4064 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 4065 4066 pattern = self._running_patterns[name] 4067 4068 # Mirror-to-self check: comparing the (device, channel) prefix against the 4069 # live pattern's resolved destination. Unlike the decorator path this is 4070 # always concrete. 4071 if prefix == (pattern.device, pattern.channel): 4072 logger.warning( 4073 f"Mirror destination {prefix} matches '{name}'s primary destination " 4074 f"— every event will double-fire on this (device, channel). This is almost " 4075 f"certainly unintended." 4076 ) 4077 4078 # Idempotent on (device, channel): replace any existing entry for the same 4079 # destination (so its map can be re-pointed), else append. 4080 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 4081 if existing_index is None: 4082 pattern.mirrors.append(entry) 4083 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 4084 elif pattern.mirrors[existing_index] != entry: 4085 pattern.mirrors[existing_index] = entry 4086 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 4087 else: 4088 logger.debug(f"Mirror already present on {name}: device={device}, channel={resolved_channel}") 4089 4090 def unmirror (self, name: str, device: int, channel: int) -> None: 4091 4092 """ 4093 Remove a single mirror destination from a running pattern. 4094 4095 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 4096 ignored. Idempotent: silently does nothing if the destination is not 4097 currently mirrored. The change applies on the next cycle rebuild. 4098 """ 4099 4100 if name not in self._running_patterns: 4101 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4102 4103 resolved_channel = self._resolve_channel(channel) 4104 prefix = (device, resolved_channel) 4105 4106 pattern = self._running_patterns[name] 4107 4108 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 4109 if len(filtered) != len(pattern.mirrors): 4110 pattern.mirrors[:] = filtered 4111 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 4112 else: 4113 logger.debug(f"unmirror() no-op on {name}: device={device}, channel={resolved_channel} not in mirrors") 4114 4115 def unmirror_all (self, name: str) -> None: 4116 4117 """ 4118 Remove every mirror destination from a running pattern. 4119 """ 4120 4121 if name not in self._running_patterns: 4122 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4123 4124 pattern = self._running_patterns[name] 4125 4126 if pattern.mirrors: 4127 pattern.mirrors.clear() 4128 logger.info(f"All mirrors cleared on pattern: {name}") 4129 4130 def tweak (self, name: str, **kwargs: typing.Any) -> None: 4131 4132 """Override parameters for a running pattern. 4133 4134 Values set here are available inside the pattern's builder 4135 function via ``p.param()``. They persist across rebuilds 4136 until explicitly changed or cleared. Changes take effect 4137 on the next rebuild cycle. 4138 4139 Parameters: 4140 name: The function name of the pattern. 4141 ``**kwargs``: Parameter names and their new values. 4142 4143 Example (from the live REPL):: 4144 4145 composition.tweak("bass", pitches=[48, 52, 55, 60]) 4146 """ 4147 4148 if name not in self._running_patterns: 4149 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4150 4151 self._running_patterns[name]._tweaks.update(kwargs) 4152 logger.info(f"Tweaked pattern '{name}': {list(kwargs.keys())}") 4153 4154 def clear_tweak (self, name: str, *param_names: str) -> None: 4155 4156 """Remove tweaked parameters from a running pattern. 4157 4158 If no parameter names are given, all tweaks for the pattern 4159 are cleared and every ``p.param()`` call reverts to its 4160 default. 4161 4162 Parameters: 4163 name: The function name of the pattern. 4164 *param_names: Specific parameter names to clear. If 4165 omitted, all tweaks are removed. 4166 """ 4167 4168 if name not in self._running_patterns: 4169 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4170 4171 if not param_names: 4172 self._running_patterns[name]._tweaks.clear() 4173 logger.info(f"Cleared all tweaks for pattern '{name}'") 4174 else: 4175 for param_name in param_names: 4176 self._running_patterns[name]._tweaks.pop(param_name, None) 4177 logger.info(f"Cleared tweaks for pattern '{name}': {list(param_names)}") 4178 4179 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 4180 4181 """Return a copy of the current tweaks for a running pattern. 4182 4183 Parameters: 4184 name: The function name of the pattern. 4185 """ 4186 4187 if name not in self._running_patterns: 4188 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4189 4190 return dict(self._running_patterns[name]._tweaks) 4191 4192 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 4193 4194 """ 4195 Register a custom function to run on a repeating beat-based cycle. 4196 4197 Subsequence automatically runs synchronous functions in a thread pool 4198 so they don't block the timing-critical MIDI clock. Async functions 4199 are run directly on the event loop. 4200 4201 Parameters: 4202 fn: The function to call. 4203 cycle_beats: How often to call it (e.g., 4 = every bar). 4204 reschedule_lookahead: How far in advance to schedule the next call. 4205 wait_for_initial: If True, run the function once during startup 4206 and wait for it to complete before playback begins. This 4207 ensures ``composition.data`` is populated before patterns 4208 first build. Implies ``defer=True`` for the repeating 4209 schedule. 4210 defer: If True, skip the pulse-0 fire and defer the first 4211 repeating call to just before the second cycle boundary. 4212 4213 Raises: 4214 RuntimeError: If called after ``play()`` has started — scheduled 4215 tasks register at startup, so a late registration would be 4216 silently ignored otherwise. 4217 """ 4218 4219 if self._sequencer.running: 4220 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 4221 4222 self._pending_scheduled.append(_PendingScheduled(fn, cycle_beats, reschedule_lookahead, wait_for_initial, defer)) 4223 4224 def form ( 4225 self, 4226 sections: typing.Union[ 4227 "subsequence.forms.Form", 4228 typing.List[typing.Any], 4229 typing.Iterator[typing.Tuple[str, int]], 4230 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 4231 ], 4232 loop: bool = False, 4233 start: typing.Optional[str] = None, 4234 at_end: str = "stop", 4235 key: typing.Optional[str] = None, 4236 scale: typing.Optional[str] = None, 4237 ) -> None: 4238 4239 """ 4240 Define the structure (sections) of the composition. 4241 4242 You can define form in four ways: 4243 4244 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of 4245 :class:`~subsequence.forms.Section` values — the payload home 4246 (energy, key per section); editable, navigable. 4247 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples 4248 or Sections (lists coerce — they are the same form). 4249 3. **Graph (Dict)**: dynamic transitions based on weights. 4250 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. 4251 4252 Form-value and list forms are **navigable**: ``form_jump()`` and 4253 ``form_next()`` work on them (the jump lands on the next occurrence 4254 of the name, wrapping). 4255 4256 Re-binding ``form()`` during playback takes effect at the next bar — 4257 the clock reads the current form state on every bar, so the new form 4258 advances from there (its first section plays from its first bar). 4259 4260 Parameters: 4261 sections: The form definition (Form, List, Dict, or Generator). 4262 loop: Sugar for ``at_end="loop"``. 4263 start: The section to start with (Graph mode only). 4264 at_end: What happens when a sequence form runs out — 4265 ``"stop"`` (the form finishes and patterns see no section; 4266 default), ``"hold"`` (the final section repeats until 4267 navigated away from), or ``"loop"`` (start over). Graphs 4268 end via their terminal sections instead. 4269 key: A form-level key — the **form tier** of the key-source 4270 chain (``Section.key`` overrides it; it overrides the 4271 composition key). Re-anchors key-relative content for the 4272 whole form. When *sections* is a ``Form`` value carrying its 4273 own ``key``, that value is used unless this argument overrides. 4274 scale: A form-level scale/mode, paired with ``key``. 4275 4276 Example: 4277 ```python 4278 # A simple pop structure 4279 comp.form([ 4280 ("verse", 8), 4281 ("chorus", 8), 4282 ("verse", 8), 4283 ("chorus", 16) 4284 ]) 4285 4286 # The same structure with payloads, held open at the end 4287 S = subsequence.Section 4288 comp.form(subsequence.Form([ 4289 S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), 4290 ]), at_end="hold") 4291 ``` 4292 """ 4293 4294 # Seed FormState at form() time (per-call salt) so build-time walks — 4295 # the frozen clones form_freeze will take — are deterministic without 4296 # play(); the play-time stream is re-dealt name-keyed in _run(). 4297 self._form_count += 1 4298 4299 self._form_state = subsequence.form_state.FormState( 4300 sections, 4301 loop = loop, 4302 start = start, 4303 rng = self._stream(f"form:{self._form_count}"), 4304 at_end = at_end, 4305 ) 4306 4307 # A Form value carries energy payloads — that counts as an energy 4308 # source for the min_energy registration check in _run(). 4309 self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( 4310 isinstance(sections, list) and any(isinstance(element, subsequence.forms.Section) for element in sections) 4311 ) 4312 4313 # Form-tier key/scale: an explicit argument wins; otherwise a Form 4314 # value's own key/scale seeds the tier. Re-binding the form drops any 4315 # stale per-section resolution cache. 4316 if isinstance(sections, subsequence.forms.Form): 4317 self._form_key = key if key is not None else sections.key 4318 self._form_scale = scale if scale is not None else sections.scale 4319 else: 4320 self._form_key = key 4321 self._form_scale = scale 4322 4323 self._resolved_section_cache = {} 4324 4325 def form_freeze (self, sections: typing.Optional[int] = None) -> "subsequence.forms.Form": 4326 4327 """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. 4328 4329 Walks a **clone** of the live form state — the same RNG state, so the 4330 frozen path is exactly the path the live graph would have played — 4331 and returns it as a Form value: inspect it, edit it 4332 (``path.replace(3, bars=16)``), and rebind it with 4333 ``composition.form(path, at_end=...)``. The live form state is 4334 untouched (rebinding replaces it). 4335 4336 Parameters: 4337 sections: Number of sections to freeze. Without it, the walk 4338 runs until a terminal section; a graph with no terminal 4339 sections requires ``sections=`` explicitly. 4340 4341 Raises: 4342 ValueError: If no graph form is bound (a list form is already a 4343 frozen sequence), the form has already finished, or the walk 4344 cannot terminate. 4345 4346 Example:: 4347 4348 composition.form({...}, start="intro") 4349 path = composition.form_freeze() # the walk, frozen 4350 composition.form(path, at_end="stop") # rebind the editable value 4351 """ 4352 4353 fs = self._form_state 4354 4355 if fs is None or fs._graph is None or fs._section_bars is None: 4356 raise ValueError( 4357 "form_freeze() freezes a graph form's walk — call form() with a dict first " 4358 "(a list form is already a frozen sequence)" 4359 ) 4360 4361 if fs._current is None: 4362 raise ValueError("the form has already finished — nothing left to freeze") 4363 4364 if sections is not None and sections < 1: 4365 raise ValueError("sections must be at least 1") 4366 4367 if sections is None and not fs._terminal_sections: 4368 raise ValueError( 4369 "this graph has no terminal section, so the walk would never end — " 4370 "pass sections=n to bound it" 4371 ) 4372 4373 # Clone the RNG state: the frozen walk reproduces the live form's 4374 # future draws without consuming them. 4375 rng = random.Random() 4376 rng.setstate(fs._rng.getstate()) 4377 4378 walked = [fs._current] 4379 next_name = fs._next_section_name # already decided by the live state 4380 4381 while next_name is not None: 4382 if sections is not None and len(walked) >= sections: 4383 break 4384 if sections is None and len(walked) >= 10000: 4385 raise ValueError( 4386 "form_freeze() walked 10000 sections without reaching a terminal — " 4387 "the terminals look unreachable; pass sections=n to bound the walk" 4388 ) 4389 4390 walked.append(subsequence.forms.Section(name = next_name, bars = fs._section_bars[next_name])) 4391 next_name = None if next_name in fs._terminal_sections else fs._graph.choose_next(next_name, rng) 4392 4393 # Carry the form-tier key/scale onto the frozen value so a freeze → 4394 # rebind round-trip is lossless (an explicit form(key=) on rebind 4395 # still overrides). 4396 return subsequence.forms.Form(walked, key = self._form_key, scale = self._form_scale) 4397 4398 def energy (self, energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]]) -> None: 4399 4400 """Set per-section energy — the arranging dial, as one plain dict. 4401 4402 ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is 4403 the section's level; a ``(start, end)`` tuple interpolates across the 4404 section (a build). Patterns read ``p.energy`` (0.5 when nothing is 4405 configured) and gate themselves, or declare ``min_energy=`` on 4406 ``pattern()`` for automatic muting. 4407 4408 The dict **overrides** any energy payload carried by bound 4409 :class:`~subsequence.forms.Section` values — it is the later, 4410 performance-level dial. Re-calling replaces the whole mapping 4411 (idempotent, live-reload friendly). 4412 4413 Example:: 4414 4415 composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) 4416 """ 4417 4418 validated: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 4419 4420 for name, value in energies.items(): 4421 if isinstance(value, tuple): 4422 if len(value) != 2: 4423 raise ValueError(f"energy ramp for {name!r} must be (start, end), got {value!r}") 4424 start_level, end_level = float(value[0]), float(value[1]) 4425 for level in (start_level, end_level): 4426 if not 0.0 <= level <= 1.0: 4427 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4428 validated[name] = (start_level, end_level) 4429 else: 4430 level = float(value) 4431 if not 0.0 <= level <= 1.0: 4432 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4433 validated[name] = level 4434 4435 self._energy_map = validated 4436 4437 def _current_energy (self, info: typing.Optional[subsequence.form_state.SectionInfo]) -> float: 4438 4439 """Resolve the energy for a section snapshot. 4440 4441 Priority: the ``energy()`` dict (ramps interpolate by section 4442 progress) > the bound Section payload > 0.5. 4443 """ 4444 4445 if info is None: 4446 return 0.5 4447 4448 spec = self._energy_map.get(info.name) 4449 4450 if spec is None: 4451 return info.energy 4452 4453 if isinstance(spec, tuple): 4454 start_level, end_level = spec 4455 4456 # A build reaches its declared end ON the final bar, so the ramp spans 4457 # bar 0 → bar (bars-1). (info.progress is bar/bars, which would top 4458 # out one bar short and never deliver end.) A one-bar section sits at 4459 # the destination level. 4460 span = info.bars - 1 4461 fraction = info.bar / span if span > 0 else 1.0 4462 4463 return start_level + (end_level - start_level) * fraction 4464 4465 return spec 4466 4467 def on_section (self, callback: typing.Callable[..., typing.Any]) -> None: 4468 4469 """Register a callback fired on every section change. 4470 4471 The callback receives the new :class:`~subsequence.form_state.SectionInfo` 4472 (or ``None`` when the form finishes). It fires from the form clock, 4473 one lookahead-beat **early** — in time to affect the new section's 4474 first patterns — and once at play start for the opening section. 4475 4476 Example:: 4477 4478 composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) 4479 """ 4480 4481 self.on_event("section", callback) 4482 4483 def transition ( 4484 self, 4485 before: str, 4486 fill: typing.Optional[typing.Any] = None, 4487 channel: typing.Optional[int] = None, 4488 beat: float = 0.0, 4489 mute: typing.Optional[typing.List[str]] = None, 4490 beats: typing.Optional[float] = None, 4491 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4492 device: subsequence.midi_utils.DeviceId = None, 4493 ) -> None: 4494 4495 """Declare boundary material — the automatic fill or mute, one line. 4496 4497 ``before`` names the incoming section (``"chorus"``), or ``"*"`` for 4498 any *different* section (repeats don't fire it). Two actions, 4499 combinable: 4500 4501 - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last 4502 bar before the boundary, starting at ``beat`` of that bar. Drum 4503 names resolve through ``drum_note_map=`` if given, otherwise the 4504 map is borrowed from a registered pattern on the same channel. 4505 - ``mute=`` (+ ``beats=``): pattern names muted over the approach 4506 and unmuted at the boundary. Muting is **bar-granular** (the 4507 existing rule), so ``beats`` rounds up to whole bars. Performer 4508 mutes win: a pattern you muted yourself stays muted. 4509 4510 Transitions stack — call once per rule. Registration is additive 4511 and idempotent per identical rule. 4512 4513 Example:: 4514 4515 composition.transition(before="*", fill=FILL, channel=10, beat=2.0) 4516 composition.transition(before="drop", mute=["pads"], beats=4) 4517 """ 4518 4519 if fill is None and mute is None: 4520 raise ValueError("transition() needs fill= and/or mute= — it declares what happens at the boundary") 4521 4522 if fill is not None: 4523 if channel is None: 4524 raise ValueError("transition(fill=) needs channel= — the fill must land somewhere") 4525 if not hasattr(fill, "events") or not hasattr(fill, "length"): 4526 raise TypeError(f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}") 4527 4528 if mute is not None and beats is None: 4529 beats = float(self.time_signature[0]) # one bar by default 4530 4531 rule = _Transition( 4532 before = before, 4533 fill = fill, 4534 channel = self._resolve_channel(channel) if channel is not None else None, 4535 beat = float(beat), 4536 mute = list(mute) if mute is not None else None, 4537 beats = beats, 4538 drum_note_map = drum_note_map, 4539 device = device, # resolved at fire time — names aren't known until play() 4540 ) 4541 4542 if rule not in self._transitions: 4543 self._transitions.append(rule) 4544 4545 def _transition_drum_map (self, channel: typing.Optional[int]) -> typing.Optional[typing.Dict[str, int]]: 4546 4547 """Borrow a drum map from a registered pattern on the same channel.""" 4548 4549 if channel is None: 4550 return None 4551 4552 for pending in self._pending_patterns: 4553 if pending.channel == channel and pending.drum_note_map: 4554 return pending.drum_note_map 4555 4556 for running in self._running_patterns.values(): 4557 candidate = getattr(running, "_drum_note_map", None) 4558 if running.channel == channel and candidate: 4559 return typing.cast(typing.Dict[str, int], candidate) 4560 4561 return None 4562 4563 def _fire_fill (self, rule: _Transition, start_pulse: int) -> None: 4564 4565 """Build a transition fill as a one-shot pattern and schedule it.""" 4566 4567 assert rule.fill is not None and rule.channel is not None 4568 4569 drum_map = rule.drum_note_map if rule.drum_note_map is not None else self._transition_drum_map(rule.channel) 4570 4571 pattern = subsequence.pattern.Pattern( 4572 channel = rule.channel, 4573 length = float(rule.fill.length), 4574 device = self._resolve_device_id(rule.device), 4575 ) 4576 4577 harmony_view: typing.Optional[HarmonyView] = None 4578 if not self._harmony_horizon.is_empty: 4579 harmony_view = HarmonyView(self._harmony_horizon, start_pulse / self._sequencer.pulses_per_beat) 4580 4581 # The fill sounds in the outgoing section's final bar, so a degree- 4582 # bearing fill resolves against THAT section's effective key/scale — 4583 # previously it took the composition key, ignoring the section. 4584 fill_section = self._form_state.get_section_info() if self._form_state else None 4585 fill_key, fill_scale = self._effective_key_scale(fill_section) 4586 4587 builder = subsequence.pattern_builder.PatternBuilder( 4588 pattern = pattern, 4589 cycle = 0, 4590 drum_note_map = drum_map, 4591 section = fill_section, 4592 bar = self._builder_bar, 4593 conductor = self.conductor, 4594 rng = self._stream(f"transition:{rule.before}:{start_pulse}") or random.Random(), 4595 tweaks = {}, 4596 default_grid = 16, 4597 data = self.data, 4598 key = fill_key, 4599 scale = fill_scale, 4600 time_signature = self.time_signature, 4601 harmony = harmony_view, 4602 ) 4603 4604 try: 4605 builder.motif(rule.fill) 4606 except Exception: 4607 logger.exception("transition fill failed to build — the boundary plays without it") 4608 return 4609 4610 self._schedule_one_shot(pattern, start_pulse) 4611 4612 def _check_transitions (self, boundary_pulse: int, section_changed: bool) -> None: 4613 4614 """The form clock's boundary hook: fire fills, manage approach mutes. 4615 4616 Called once per bar (lookahead-early, with the bar-line pulse). 4617 Fill rules fire when the current bar is the section's last before a 4618 matching boundary; mute rules close over the approach window 4619 (rounded up to whole bars — muting is bar-granular) and reopen at 4620 the boundary. Performer mutes are never touched. 4621 """ 4622 4623 if section_changed and self._transition_muted: 4624 # The boundary arrived — restore only what we muted ourselves. 4625 for name in self._transition_muted: 4626 running = self._running_patterns.get(name) 4627 if running is not None: 4628 running._muted = False 4629 self._transition_muted.clear() 4630 4631 if not self._transitions or self._form_state is None: 4632 return 4633 4634 info = self._form_state.get_section_info() 4635 4636 if info is None or info.next_section is None: 4637 return 4638 4639 bar_beats = float(self.time_signature[0]) 4640 bars_remaining = info.bars - info.bar 4641 4642 for rule in self._transitions: 4643 4644 if rule.before == "*": 4645 if info.next_section == info.name: 4646 continue # a repeat is not a boundary 4647 elif info.next_section != rule.before: 4648 continue 4649 4650 if rule.fill is not None and bars_remaining == 1: 4651 self._fire_fill(rule, boundary_pulse + int(round(rule.beat * self._sequencer.pulses_per_beat))) 4652 4653 if rule.mute: 4654 window_beats = rule.beats if rule.beats is not None else bar_beats 4655 window_bars = max(1, int((window_beats + bar_beats - 1e-9) // bar_beats)) 4656 4657 if bars_remaining <= window_bars: 4658 for name in rule.mute: 4659 running = self._running_patterns.get(name) 4660 if running is None or name in self._transition_muted: 4661 continue 4662 if running._muted: 4663 continue # the performer's mute — not ours to manage 4664 running._muted = True 4665 self._transition_muted.add(name) 4666 4667 @staticmethod 4668 def _resolve_length ( 4669 beats: typing.Optional[float], 4670 bars: typing.Optional[float], 4671 steps: typing.Optional[float], 4672 step_duration: typing.Optional[float], 4673 default: float = 4.0, 4674 beats_per_bar: int = 4, 4675 ) -> typing.Tuple[float, int]: 4676 4677 """ 4678 Resolve the beat_length and default_grid from the duration parameters. 4679 4680 Two modes: 4681 4682 - **Duration mode** (no ``step_duration``): specify ``beats=`` or ``bars=``. 4683 ``beats=4`` = 4 quarter notes; ``bars=2`` = 8 beats. 4684 - **Step mode** (with ``step_duration``): specify ``steps=`` and ``step_duration=``. 4685 ``steps=6, step_duration=dur.SIXTEENTH`` = 6 sixteenth notes = 1.5 beats. 4686 4687 Constraints: 4688 4689 - ``beats`` and ``bars`` are mutually exclusive. 4690 - ``steps`` requires ``step_duration``; ``step_duration`` requires ``steps``. 4691 - ``steps`` cannot be combined with ``beats`` or ``bars``. 4692 4693 Returns: 4694 (beat_length, default_grid) — beat_length in beats (quarter notes); 4695 default_grid the number of grid steps (16th-notes in beat mode, or the 4696 explicit ``steps`` value directly in step mode). 4697 """ 4698 4699 if beats is not None and bars is not None: 4700 raise ValueError("Specify only one of beats= or bars=") 4701 4702 if steps is not None and (beats is not None or bars is not None): 4703 raise ValueError("steps= cannot be combined with beats= or bars=") 4704 4705 if step_duration is not None and steps is None: 4706 raise ValueError("step_duration= requires steps= (e.g. steps=6, step_duration=dur.SIXTEENTH)") 4707 4708 if steps is not None: 4709 if step_duration is None: 4710 raise ValueError("steps= requires step_duration= (e.g. step_duration=dur.SIXTEENTH)") 4711 return steps * step_duration, int(steps) 4712 4713 if bars is not None: 4714 raw = bars * beats_per_bar 4715 elif beats is not None: 4716 raw = beats 4717 else: 4718 raw = default 4719 4720 return raw, round(raw / subsequence.constants.durations.SIXTEENTH) 4721 4722 def pattern ( 4723 self, 4724 channel: int, 4725 beats: typing.Optional[float] = None, 4726 bars: typing.Optional[float] = None, 4727 steps: typing.Optional[float] = None, 4728 step_duration: typing.Optional[float] = None, 4729 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4730 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4731 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4732 reschedule_lookahead: float = 1, 4733 voice_leading: bool = False, 4734 device: subsequence.midi_utils.DeviceId = None, 4735 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4736 min_energy: typing.Optional[float] = None, 4737 ) -> typing.Callable: 4738 4739 """ 4740 Register a function as a repeating MIDI pattern. 4741 4742 The decorated function will be called once per cycle to 'rebuild' its 4743 content. This allows for generative logic that evolves over time. 4744 4745 Two ways to specify pattern length: 4746 4747 - **Duration mode** (default): use ``beats=`` or ``bars=``. 4748 The grid defaults to sixteenth-note resolution. 4749 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 4750 The grid equals the step count, so ``p.hit_steps()`` indices map 4751 directly to steps. 4752 4753 Parameters: 4754 channel: MIDI channel. By default uses 1-based numbering (1-16). 4755 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 4756 0-based numbering (0-15), matching the raw MIDI protocol, instead. 4757 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 4758 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 4759 steps: Step count for step mode. Requires ``step_duration=``. 4760 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 4761 Requires ``steps=``. 4762 drum_note_map: Optional mapping for drum instruments. 4763 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4764 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 4765 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 4766 14-bit parameter numbers (0–16383). Enables string-based names 4767 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 4768 device-specific dictionary (e.g. Sequential Take 5's 4769 ``Osc1FreqFine`` → 9). 4770 reschedule_lookahead: Beats in advance to compute the next cycle. 4771 voice_leading: If True, chords in this pattern will automatically 4772 use inversions that minimize voice movement. 4773 mirrors: Optional list of additional ``(device, channel)`` destinations 4774 to duplicate every event from this pattern onto. Notes, CCs, pitch 4775 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 4776 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 4777 ``device`` is the integer index returned by ``midi_output()`` (0 = 4778 primary). ``channel`` follows this composition's channel-numbering 4779 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 4780 min_energy: Automatic energy gating — the pattern is silent while 4781 the current section's energy (``composition.energy()`` dict, 4782 or the bound Section payload) is below this threshold. 4783 Composes with ``mute()``: a performer mute always wins. 4784 4785 Example: 4786 ```python 4787 @comp.pattern(channel=1, beats=4) 4788 def chords (p): 4789 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 4790 4791 @comp.pattern(channel=1, bars=2) 4792 def long_phrase (p): 4793 ... 4794 4795 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 4796 def riff (p): 4797 p.sequence(steps=[0, 1, 3, 5], pitches=60) 4798 ``` 4799 """ 4800 4801 channel = self._resolve_channel(channel) 4802 4803 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4804 4805 # Resolve device string name to index if possible now; otherwise store 4806 # the raw DeviceId and resolve it in _run() once all devices are open. 4807 resolved_device: subsequence.midi_utils.DeviceId = device 4808 4809 # Mirror-to-self check is only reliable when the primary device is a 4810 # concrete integer at decoration time. ``None`` resolves to device 0 4811 # downstream, so we treat it as 0 here too. Strings are deferred to 4812 # ``_run()`` and we skip the check for them. 4813 primary: typing.Optional[typing.Tuple[int, int]] 4814 if isinstance(resolved_device, str): 4815 primary = None 4816 else: 4817 primary = (resolved_device if resolved_device is not None else 0, channel) 4818 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4819 4820 def decorator (fn: typing.Callable) -> typing.Callable: 4821 4822 """ 4823 Wrap the builder function and register it as a pending pattern. 4824 During live sessions, hot-swap an existing pattern's builder instead. 4825 """ 4826 4827 # Record this declaration so the live-reload deletion diff knows the 4828 # pattern is still present in the source (see _apply_source_async). 4829 self._declared_names.add(fn.__name__) 4830 4831 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 4832 if self._is_live and fn.__name__ in self._running_patterns: 4833 running = self._running_patterns[fn.__name__] 4834 running._builder_fn = fn 4835 running._wants_chord = _fn_has_parameter(fn, "chord") 4836 logger.info(f"Hot-swapped pattern: {fn.__name__}") 4837 return fn 4838 4839 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 4840 # duplicate means two scheduled copies sharing one stream with 4841 # only one reachable by name. Warn loudly at registration. 4842 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 4843 logger.warning( 4844 f"Duplicate pattern name '{fn.__name__}': both copies will be " 4845 f"scheduled, they share one seeded stream, and only one is " 4846 f"reachable by name — rename one of them." 4847 ) 4848 4849 pending = _PendingPattern( 4850 builder_fn = fn, 4851 channel = channel, # already resolved to 0-indexed 4852 length = beat_length, 4853 default_grid = default_grid, 4854 drum_note_map = drum_note_map, 4855 cc_name_map = cc_name_map, 4856 nrpn_name_map = nrpn_name_map, 4857 reschedule_lookahead = reschedule_lookahead, 4858 voice_leading = voice_leading, 4859 # For int/None: resolve immediately. For str: store 0 as 4860 # placeholder; _resolve_pending_devices() fixes it in _run(). 4861 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 4862 raw_device = resolved_device, 4863 mirrors = resolved_mirrors, 4864 min_energy = min_energy, 4865 ) 4866 4867 self._pending_patterns.append(pending) 4868 4869 return fn 4870 4871 return decorator 4872 4873 def layer ( 4874 self, 4875 *builder_fns: typing.Callable, 4876 channel: int, 4877 beats: typing.Optional[float] = None, 4878 bars: typing.Optional[float] = None, 4879 steps: typing.Optional[float] = None, 4880 step_duration: typing.Optional[float] = None, 4881 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4882 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4883 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4884 reschedule_lookahead: float = 1, 4885 voice_leading: bool = False, 4886 device: subsequence.midi_utils.DeviceId = None, 4887 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4888 ) -> None: 4889 4890 """ 4891 Combine multiple functions into a single MIDI pattern. 4892 4893 This is useful for composing complex patterns out of reusable 4894 building blocks (e.g., a 'kick' function and a 'snare' function). 4895 4896 See ``pattern()`` for the full description of ``beats``, ``bars``, 4897 ``steps``, and ``step_duration``. 4898 4899 Parameters: 4900 builder_fns: One or more pattern builder functions. 4901 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4902 beats: Duration in beats (quarter notes). 4903 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4904 steps: Step count for step mode. Requires ``step_duration=``. 4905 step_duration: Duration of one step in beats. Requires ``steps=``. 4906 drum_note_map: Optional mapping for drum instruments. 4907 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4908 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 4909 parameter numbers. 4910 reschedule_lookahead: Beats in advance to compute the next cycle. 4911 voice_leading: If True, chords use smooth voice leading. 4912 mirrors: Optional list of additional ``(device, channel)`` destinations 4913 to duplicate every event onto. See ``pattern()`` for details. 4914 """ 4915 4916 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4917 4918 # Resolve channel up-front so the mirror-to-self check has the canonical 4919 # primary form to compare against. 4920 resolved_channel = self._resolve_channel(channel) 4921 4922 # See pattern() for the same comment about None / str handling. 4923 primary: typing.Optional[typing.Tuple[int, int]] 4924 if isinstance(device, str): 4925 primary = None 4926 else: 4927 primary = (device if device is not None else 0, resolved_channel) 4928 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4929 4930 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 4931 4932 if wants_chord: 4933 4934 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 4935 4936 for fn in builder_fns: 4937 if _fn_has_parameter(fn, "chord"): 4938 fn(p, chord) 4939 else: 4940 fn(p) 4941 4942 else: 4943 4944 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 4945 4946 for fn in builder_fns: 4947 fn(p) 4948 4949 # Give the merged builder a stable, unique name derived from its 4950 # components so multiple layer() calls don't all register under 4951 # "merged_builder" and collide in _running_patterns (which made 4952 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 4953 # appear in a Python identifier, so this never clashes with a real 4954 # pattern function's name. 4955 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 4956 merged_name = base_name 4957 suffix = 2 4958 4959 # Two layers with the same components (e.g. on different saves of a 4960 # live file) must map to the same names pass-over-pass, while two 4961 # DIFFERENT layers sharing components in one pass must not collide. 4962 while merged_name in self._declared_names: 4963 merged_name = f"{base_name}#{suffix}" 4964 suffix += 1 4965 4966 merged_builder.__name__ = merged_name 4967 4968 # Record the declaration for the live-reload deletion diff, and hot-swap 4969 # in place when this layer is already running so a reload picks up edits 4970 # to the component functions without losing the pattern's cycle count, 4971 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 4972 self._declared_names.add(merged_builder.__name__) 4973 4974 if self._is_live and merged_builder.__name__ in self._running_patterns: 4975 running = self._running_patterns[merged_builder.__name__] 4976 running._builder_fn = merged_builder 4977 running._wants_chord = wants_chord 4978 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 4979 return 4980 4981 pending = _PendingPattern( 4982 builder_fn = merged_builder, 4983 channel = resolved_channel, # already resolved to 0-indexed above 4984 length = beat_length, 4985 default_grid = default_grid, 4986 drum_note_map = drum_note_map, 4987 cc_name_map = cc_name_map, 4988 nrpn_name_map = nrpn_name_map, 4989 reschedule_lookahead = reschedule_lookahead, 4990 voice_leading = voice_leading, 4991 mirrors = resolved_mirrors, 4992 device = 0 if (device is None or isinstance(device, str)) else device, 4993 raw_device = device, 4994 ) 4995 4996 self._pending_patterns.append(pending) 4997 4998 def chords ( 4999 self, 5000 *, 5001 channel: int, 5002 progression: subsequence.progressions.ProgressionSource, 5003 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 5004 bars: typing.Optional[float] = None, 5005 beats: typing.Optional[float] = None, 5006 voicing: subsequence.progressions.VoicingSpec = (3, 4), 5007 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 5008 detached: typing.Optional[float] = None, 5009 root: int = 60, 5010 key: typing.Optional[str] = None, 5011 seed: typing.Optional[int] = None, 5012 device: subsequence.midi_utils.DeviceId = None, 5013 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5014 ) -> subsequence.progressions.Progression: 5015 5016 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 5017 5018 The one-call form of ``p.progression()`` — it registers a pattern on 5019 *channel* that plays *progression* across *bars* (or *beats*), each chord 5020 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 5021 the chords change). It needs no ``composition.harmony()`` call and, with an 5022 explicit chord list or a ``key=``, no composition key either — so a 5023 drums-plus-one-chord-part sketch stays simple. 5024 5025 The progression is realised once, up front, and the same timeline plays every 5026 cycle (a stable phrase). That timeline is returned so you can see exactly what 5027 was chosen — ``print(comp.chords(...))``. 5028 5029 Parameters: 5030 channel: MIDI channel for the chord part. 5031 progression: A chord-graph style name to generate from, or an explicit list 5032 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 5033 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 5034 or ``between(low, high, step=...)``. See ``p.progression()``. 5035 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 5036 composition's time signature. 5037 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 5038 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 5039 detached: Beats of silence before each next chord (``duration = length - detached``). 5040 root: MIDI root the voicings are centred on (e.g. 48 = C3). 5041 key: Key for a generated progression; defaults to the composition key. 5042 seed: Seed for the (otherwise fixed) realisation; defaults to the 5043 composition seed, so the part is reproducible. 5044 device: Optional output-device override. 5045 mirrors: Optional additional ``(device, channel)`` destinations. 5046 5047 Returns: 5048 The realised :class:`~subsequence.progressions.Progression`. 5049 """ 5050 5051 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5052 resolved_channel = self._resolve_channel(channel) 5053 resolved_key = key if key is not None else self.key 5054 5055 rng = random.Random(seed if seed is not None else self._seed) 5056 timeline = subsequence.progressions.realize( 5057 source = progression, 5058 harmonic_rhythm = harmonic_rhythm, 5059 key = resolved_key, 5060 length = beat_length, 5061 rng = rng, 5062 scale = self.scale or "ionian", 5063 ) 5064 5065 captured_root = root 5066 captured_velocity = velocity 5067 captured_detached = detached 5068 captured_voicing = voicing 5069 5070 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5071 5072 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 5073 5074 for chord, start, length in timeline: 5075 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 5076 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 5077 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 5078 5079 # Unique, stable name so multiple chord parts don't collide in 5080 # _running_patterns — including two parts on the SAME channel, which 5081 # get a deterministic #2/#3 suffix in declaration order. 5082 base_name = f"chords@ch{resolved_channel}" 5083 chords_name = base_name 5084 suffix = 2 5085 5086 while chords_name in self._declared_names: 5087 chords_name = f"{base_name}#{suffix}" 5088 suffix += 1 5089 5090 chords_builder.__name__ = chords_name 5091 self._declared_names.add(chords_name) 5092 5093 primary: typing.Optional[typing.Tuple[int, int]] 5094 if isinstance(device, str): 5095 primary = None 5096 else: 5097 primary = (device if device is not None else 0, resolved_channel) 5098 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5099 5100 if self._is_live and chords_builder.__name__ in self._running_patterns: 5101 running = self._running_patterns[chords_builder.__name__] 5102 running._builder_fn = chords_builder 5103 running._wants_chord = False 5104 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 5105 return timeline 5106 5107 pending = _PendingPattern( 5108 builder_fn = chords_builder, 5109 channel = resolved_channel, 5110 length = beat_length, 5111 default_grid = default_grid, 5112 drum_note_map = None, 5113 reschedule_lookahead = 1, 5114 voice_leading = False, 5115 mirrors = resolved_mirrors, 5116 device = 0 if (device is None or isinstance(device, str)) else device, 5117 raw_device = device, 5118 ) 5119 self._pending_patterns.append(pending) 5120 return timeline 5121 5122 def phrase_part ( 5123 self, 5124 *, 5125 channel: int, 5126 part: typing.Optional[str] = None, 5127 root: int = 60, 5128 bars: typing.Optional[float] = None, 5129 beats: typing.Optional[float] = None, 5130 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 5131 fit: typing.Optional[float] = None, 5132 device: subsequence.midi_utils.DeviceId = None, 5133 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5134 ) -> None: 5135 5136 """Declare a part that plays each section's bound Motif/Phrase. 5137 5138 The one-call consumer for :meth:`section_motifs` — it registers a 5139 pattern on *channel* that walks whatever value is bound to the 5140 current section for *part* (stateless position from the cycle 5141 counter, via ``p.phrase()``). A section with no binding for the 5142 part is **silent** for that part — bind material or don't; no 5143 fallback guessing. 5144 5145 Parameters: 5146 channel: MIDI channel for the part. 5147 part: The part label to read from the registry (``None`` = the 5148 unlabelled binding). 5149 root: Register anchor for degree resolution. 5150 bars / beats: Cycle length of the part (defaults to 4 beats); 5151 the phrase is sliced one cycle window at a time. 5152 velocity: Optional override applied to every note. 5153 fit: Passed through (active with the melody engine stage). 5154 device: Optional output-device override. 5155 mirrors: Optional additional ``(device, channel)`` destinations. 5156 5157 Example:: 5158 5159 composition.section_motifs("verse", verse_line, part="lead") 5160 composition.section_motifs("chorus", chorus_line, part="lead") 5161 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 5162 """ 5163 5164 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5165 resolved_channel = self._resolve_channel(channel) 5166 5167 captured_part = part 5168 captured_root = root 5169 captured_velocity = velocity 5170 captured_fit = fit 5171 5172 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5173 5174 """Walk the current section's bound value (silent when unbound).""" 5175 5176 value = p.section_motif(captured_part) 5177 5178 if value is None: 5179 return # unbound section: silence for this part, by design 5180 5181 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 5182 5183 # Unique, stable name so multiple phrase parts don't collide — 5184 # including two parts on the SAME channel (deterministic #2/#3 5185 # suffixes in declaration order, the chords() convention). 5186 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 5187 phrase_name = base_name 5188 suffix = 2 5189 5190 while phrase_name in self._declared_names: 5191 phrase_name = f"{base_name}#{suffix}" 5192 suffix += 1 5193 5194 phrase_builder.__name__ = phrase_name 5195 self._declared_names.add(phrase_name) 5196 5197 primary: typing.Optional[typing.Tuple[int, int]] 5198 if isinstance(device, str): 5199 primary = None 5200 else: 5201 primary = (device if device is not None else 0, resolved_channel) 5202 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5203 5204 if self._is_live and phrase_builder.__name__ in self._running_patterns: 5205 running = self._running_patterns[phrase_builder.__name__] 5206 running._builder_fn = phrase_builder 5207 running._wants_chord = False 5208 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 5209 return 5210 5211 pending = _PendingPattern( 5212 builder_fn = phrase_builder, 5213 channel = resolved_channel, 5214 length = beat_length, 5215 default_grid = default_grid, 5216 drum_note_map = None, 5217 reschedule_lookahead = 1, 5218 voice_leading = False, 5219 mirrors = resolved_mirrors, 5220 device = 0 if (device is None or isinstance(device, str)) else device, 5221 raw_device = device, 5222 ) 5223 self._pending_patterns.append(pending) 5224 5225 def trigger ( 5226 self, 5227 fn: typing.Callable, 5228 channel: int, 5229 beats: typing.Optional[float] = None, 5230 bars: typing.Optional[float] = None, 5231 steps: typing.Optional[float] = None, 5232 step_duration: typing.Optional[float] = None, 5233 quantize: float = 0, 5234 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 5235 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 5236 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 5237 chord: bool = False, 5238 device: subsequence.midi_utils.DeviceId = None, 5239 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5240 ) -> None: 5241 5242 """ 5243 Trigger a one-shot pattern immediately or on a quantized boundary. 5244 5245 This is useful for real-time response to sensors, OSC messages, or other 5246 external events. The builder function is called immediately with a fresh 5247 PatternBuilder, and the generated events are injected into the queue at 5248 the specified quantize boundary. 5249 5250 The builder function has the same API as a ``@composition.pattern`` 5251 decorated function and can use all PatternBuilder methods: ``p.note()``, 5252 ``p.euclidean()``, ``p.arpeggio()``, and so on. 5253 5254 See ``pattern()`` for the full description of ``beats``, ``bars``, 5255 ``steps``, and ``step_duration``. Default is 1 beat. 5256 5257 Parameters: 5258 fn: The pattern builder function (same signature as ``@comp.pattern``). 5259 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 5260 beats: Duration in beats (quarter notes, default 1). 5261 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 5262 steps: Step count for step mode. Requires ``step_duration=``. 5263 step_duration: Duration of one step in beats. Requires ``steps=``. 5264 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 5265 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 5266 constants from ``subsequence.constants.durations``. 5267 drum_note_map: Optional drum name mapping for this pattern. 5268 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 5269 nrpn_name_map: Optional mapping of NRPN parameter names to 5270 14-bit parameter numbers. 5271 chord: If ``True``, the builder function receives the current chord as 5272 a second parameter (same as ``@composition.pattern``). 5273 mirrors: Optional list of additional ``(device, channel)`` destinations 5274 to fire this one-shot onto in parallel with the primary destination. 5275 5276 Example: 5277 ```python 5278 # Immediate single note (channels are 1-16 by default) 5279 composition.trigger( 5280 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 5281 channel=1 5282 ) 5283 5284 # Quantized fill (next bar) — channel 10 is the GM drum channel 5285 import subsequence.constants.durations as dur 5286 composition.trigger( 5287 lambda p: p.euclidean("snare", pulses=7, velocity=90), 5288 channel=10, 5289 drum_note_map=gm_drums.GM_DRUM_MAP, 5290 quantize=dur.WHOLE 5291 ) 5292 5293 # With chord context — the builder receives the chord as a second 5294 # argument when chord=True. 5295 composition.trigger( 5296 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 5297 channel=1, 5298 quantize=dur.QUARTER, 5299 chord=True 5300 ) 5301 ``` 5302 """ 5303 5304 # Resolve channel numbering 5305 resolved_channel = self._resolve_channel(channel) 5306 5307 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 5308 5309 # Resolve device index — for trigger() this is always concrete by call time, 5310 # so the mirror-to-self check has the full primary tuple available. 5311 resolved_device_idx = self._resolve_device_id(device) 5312 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 5313 5314 # Create a temporary Pattern 5315 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 5316 5317 # Resolve the section context once: the one-shot inherits the section's 5318 # effective key/scale (so a triggered degree resolves like everywhere 5319 # else) and a harmony view at the current playhead (so ChordTone / 5320 # Approach resolve too). 5321 trigger_section = self._form_state.get_section_info() if self._form_state else None 5322 trigger_key, trigger_scale = self._effective_key_scale(trigger_section) 5323 5324 trigger_harmony: typing.Optional[HarmonyView] = None 5325 if not self._harmony_horizon.is_empty: 5326 trigger_harmony = HarmonyView(self._harmony_horizon, self._sequencer.pulse_count / self._sequencer.pulses_per_beat) 5327 5328 # Create a PatternBuilder 5329 builder = subsequence.pattern_builder.PatternBuilder( 5330 pattern=pattern, 5331 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 5332 drum_note_map=drum_note_map, 5333 cc_name_map=cc_name_map, 5334 nrpn_name_map=nrpn_name_map, 5335 section=trigger_section, 5336 bar=self._builder_bar, 5337 conductor=self.conductor, 5338 rng=random.Random(), # Fresh random state for each trigger 5339 tweaks={}, 5340 default_grid=default_grid, 5341 data=self.data, 5342 # A one-shot resolves key-relative content against the same 5343 # effective key/scale as the section it fires into (previously 5344 # omitted entirely — degrees raised even in a keyed composition). 5345 key=trigger_key, 5346 scale=trigger_scale, 5347 time_signature=self.time_signature, 5348 held_notes=self._sequencer._held_notes, 5349 harmony=trigger_harmony, 5350 energy=self._current_energy(trigger_section) 5351 ) 5352 5353 # Call the builder function 5354 try: 5355 5356 current_chord = self.current_chord() if chord else None 5357 5358 if current_chord is not None: 5359 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 5360 fn(builder, injected) 5361 5362 else: 5363 fn(builder) 5364 5365 except Exception: 5366 logger.exception("Error in trigger builder — pattern will be silent") 5367 return 5368 5369 # Calculate the start pulse based on quantize 5370 current_pulse = self._sequencer.pulse_count 5371 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 5372 5373 if quantize == 0: 5374 # Immediate: use current pulse 5375 start_pulse = current_pulse 5376 5377 else: 5378 # Quantize to the next multiple of (quantize * pulses_per_beat) 5379 quantize_pulses = int(quantize * pulses_per_beat) 5380 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 5381 5382 self._schedule_one_shot(pattern, start_pulse) 5383 5384 def _schedule_one_shot (self, pattern: subsequence.pattern.Pattern, start_pulse: int) -> None: 5385 5386 """Schedule a one-shot pattern at an absolute pulse, thread-safely.""" 5387 5388 try: 5389 # Probe only: raises RuntimeError when not on the event loop. 5390 asyncio.get_running_loop() 5391 asyncio.create_task(self._sequencer.schedule_pattern(pattern, start_pulse)) 5392 5393 except RuntimeError: 5394 # Not on the event loop — hand the coroutine to the loop thread. 5395 if self._sequencer._event_loop is not None: 5396 asyncio.run_coroutine_threadsafe( 5397 self._sequencer.schedule_pattern(pattern, start_pulse), 5398 loop=self._sequencer._event_loop 5399 ) 5400 else: 5401 logger.warning("trigger() called before playback started; pattern ignored") 5402 5403 @property 5404 def is_clock_following (self) -> bool: 5405 5406 """True if either the primary or any additional device is following external clock.""" 5407 5408 return self._clock_follow or any(cf for _, _, cf in self._additional_inputs) 5409 5410 5411 def play (self) -> None: 5412 5413 """ 5414 Start the composition. 5415 5416 This call blocks until the program is interrupted (e.g., via Ctrl+C). 5417 It initializes the MIDI hardware, launches the background sequencer, 5418 and begins playback. 5419 """ 5420 5421 try: 5422 asyncio.run(self._run()) 5423 5424 except KeyboardInterrupt: 5425 pass 5426 5427 5428 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 5429 5430 """Render the composition to a MIDI file without real-time playback. 5431 5432 Runs the sequencer as fast as possible (no timing delays) and stops 5433 when the first active limit is reached. The result is saved as a 5434 standard MIDI file that can be imported into any DAW. 5435 5436 All patterns, scheduled callbacks, and harmony logic run exactly as 5437 they would during live playback — BPM transitions, generative fills, 5438 and probabilistic gates all work in render mode. The only difference 5439 is that time is simulated rather than wall-clock driven. 5440 5441 Parameters: 5442 bars: Number of bars to render, or ``None`` for no bar limit 5443 (default ``None``). When both *bars* and *max_minutes* are 5444 active, playback stops at whichever limit is reached first. 5445 filename: Output MIDI filename (default ``"render.mid"``). 5446 max_minutes: Safety cap on the length of rendered MIDI in minutes 5447 (default ``60.0``). Pass ``None`` to disable the time 5448 cap — you must then provide an explicit *bars* value. 5449 5450 Raises: 5451 ValueError: If both *bars* and *max_minutes* are ``None``, which 5452 would produce an infinite render. 5453 5454 Examples: 5455 ```python 5456 # Default: renders up to 60 minutes of MIDI content. 5457 composition.render() 5458 5459 # Render exactly 64 bars (time cap still active as backstop). 5460 composition.render(bars=64, filename="demo.mid") 5461 5462 # Render up to 5 minutes of an infinite generative composition. 5463 composition.render(max_minutes=5, filename="five_min.mid") 5464 5465 # Remove the time cap — must supply bars instead. 5466 composition.render(bars=128, max_minutes=None, filename="long.mid") 5467 ``` 5468 """ 5469 5470 if bars is None and max_minutes is None: 5471 raise ValueError( 5472 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 5473 "Passing both as None would produce an infinite render." 5474 ) 5475 5476 self._sequencer.recording = True 5477 self._sequencer.record_filename = filename 5478 self._sequencer.render_mode = True 5479 self._sequencer.render_bars = bars if bars is not None else 0 5480 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 5481 asyncio.run(self._run()) 5482 5483 def _broadcast_osc_status (self, bar: int) -> None: 5484 5485 """ 5486 Send the per-bar OSC status snapshot: bar number, current tempo, 5487 and (when active) the current chord name and form section. 5488 """ 5489 5490 if self._osc_server: 5491 self._osc_server.send("/bar", bar) 5492 self._osc_server.send("/bpm", self._sequencer.current_bpm) 5493 5494 sounding = self.current_chord() 5495 if sounding is not None: 5496 self._osc_server.send("/chord", sounding.name()) 5497 5498 if self._form_state: 5499 info = self._form_state.get_section_info() 5500 if info: 5501 self._osc_server.send("/section", info.name) 5502 5503 async def _run (self) -> None: 5504 5505 """ 5506 Async entry point that schedules all patterns and runs the sequencer. 5507 """ 5508 5509 # 1. Pre-calculate MIDI input indices and configure sequencer clock follow. 5510 if self._input_device is not None: 5511 self._sequencer.input_device_name = self._input_device 5512 self._sequencer.clock_follow = self._clock_follow 5513 self._sequencer.clock_device_idx = 0 5514 5515 if not self._clock_follow: 5516 # Find first additional input that wants to be the clock master. 5517 for idx, (_, _, cf) in enumerate(self._additional_inputs, start=1): 5518 if cf: 5519 self._sequencer.clock_follow = True 5520 self._sequencer.clock_device_idx = idx 5521 break 5522 5523 # Populate input device name mapping early (before opening ports) so we can 5524 # resolve CC mappings to integer device indices immediately. 5525 if self._sequencer.input_device_name: 5526 self._input_device_names[self._sequencer.input_device_name] = 0 5527 if self._input_device_alias is not None: 5528 self._input_device_names[self._input_device_alias] = 0 5529 5530 for idx, (dev_name, alias, _) in enumerate(self._additional_inputs, start=1): 5531 self._input_device_names[dev_name] = idx 5532 if alias: 5533 self._input_device_names[alias] = idx 5534 5535 # 2. Pre-calculate output device names. 5536 if self._sequencer.output_device_name: 5537 self._output_device_names[self._sequencer.output_device_name] = 0 5538 # Primary device (index 0) is open by now (_init_midi_output ran in 5539 # the Sequencer constructor), so its latency can be set safely here. 5540 if self._output_latency_ms: 5541 self._sequencer.set_device_latency(0, self._output_latency_ms) 5542 5543 # 3. Resolve name-based INPUT device ids in cc_map/cc_forward early — the 5544 # input-names map is fully populated above, and the callback thread needs 5545 # integer indices as soon as ports open. OUTPUT names (cc_forward 5546 # output_device=, pattern device=) resolve after the additional outputs 5547 # are opened below; resolving them here matched against a map containing 5548 # only the primary and silently routed everything to device 0. 5549 for mapping in self._cc_mappings: 5550 raw = mapping.get('input_device') 5551 if isinstance(raw, str): 5552 mapping['input_device'] = self._resolve_input_device_id(raw) 5553 for fwd in self._cc_forwards: 5554 raw_in = fwd.get('input_device') 5555 if isinstance(raw_in, str): 5556 fwd['input_device'] = self._resolve_input_device_id(raw_in) 5557 5558 # 4. Share CC input mappings, forwards, and a reference to composition.data 5559 # with the sequencer BEFORE opening the ports. This ensures that any initial 5560 # messages in the OS buffer are correctly mapped as soon as the port opens. 5561 self._sequencer.cc_mappings = self._cc_mappings 5562 self._sequencer.cc_forwards = self._cc_forwards 5563 self._sequencer._composition_data = self.data 5564 5565 # Held-note input: create the tracker and resolve its channel/device 5566 # filter so the callback thread can buffer matching note events. 5567 if self._note_input is not None: 5568 if self._input_device is None and not self._additional_inputs: 5569 raise RuntimeError("note_input() requires a MIDI input — call composition.midi_input(device) first") 5570 raw_dev = self._note_input.get('input_device') 5571 if isinstance(raw_dev, str): 5572 raw_dev = self._resolve_input_device_id(raw_dev) 5573 self._sequencer._note_input_channel = self._note_input['channel'] 5574 self._sequencer._note_input_device = raw_dev 5575 self._sequencer._held_notes = subsequence.held_notes.HeldNotes( 5576 release_ms = self._note_input['release_ms'], 5577 latch = self._note_input['latch'], 5578 ) 5579 5580 # 5. Open MIDI input ports early. Even without a deliberate sleep, opening 5581 # them before pattern building minimizes the window for missed messages. 5582 # Primary input 5583 self._sequencer._open_midi_inputs() 5584 5585 # Additional inputs 5586 for idx, (dev_name, alias, cf) in enumerate(self._additional_inputs, start=1): 5587 # Use the pre-calculated index 5588 callback = self._sequencer._make_input_callback(idx) 5589 open_name, port = subsequence.midi_utils.select_input_device(dev_name, callback) 5590 if open_name and port is not None: 5591 self._sequencer.add_input_device(open_name, port) 5592 else: 5593 logger.warning(f"Could not open additional input device '{dev_name}'") 5594 5595 # 6. Open additional MIDI output devices. 5596 for out in self._additional_outputs: 5597 open_name, port = subsequence.midi_utils.select_output_device(out.device) 5598 if open_name and port is not None: 5599 idx = self._sequencer.add_output_device(open_name, port, out.latency_ms) 5600 self._output_device_names[open_name] = idx 5601 if out.alias is not None: 5602 self._output_device_names[out.alias] = idx 5603 else: 5604 logger.warning(f"Could not open additional output device '{out.device}'") 5605 5606 # Warn if latency compensation adds noticeable whole-rig delay: the 5607 # slowest device defines the alignment point, so every faster device is 5608 # delayed up to that amount and live-input feel suffers. 5609 self._warn_if_high_latency() 5610 5611 # Resolve any name-based output device IDs on patterns that may have been added 5612 # for additional output devices. 5613 self._resolve_pending_devices() 5614 5615 # Resolve cc_forward output-device names now that every output port and 5616 # alias is registered (resolving earlier silently routed to device 0). 5617 for fwd in self._cc_forwards: 5618 raw_out = fwd.get('output_device') 5619 if isinstance(raw_out, str): 5620 fwd['output_device'] = self._resolve_device_id(raw_out) 5621 5622 # Pass clock output flag (suppressed automatically when clock_follow=True). 5623 self._sequencer.clock_output = self._clock_output and not self.is_clock_following 5624 5625 # Create Ableton Link clock if comp.link() was called. 5626 if self._link_quantum is not None: 5627 self._sequencer._link_clock = subsequence.link_clock.LinkClock( 5628 bpm = self.bpm, 5629 quantum = self._link_quantum, 5630 loop = asyncio.get_running_loop(), 5631 ) 5632 5633 # Deal play-time streams. Every stream is NAME-keyed (crc32 of 5634 # "seed:name", see _stream_seed) rather than dealt from one master in 5635 # registration order: adding or removing one consumer can never shift 5636 # another's stream, and patterns added live derive identically in 5637 # _build_pattern_from_pending. When no seed is set, components keep 5638 # their own unseeded RNGs (existing behaviour). 5639 if self._seed is not None: 5640 5641 harmony_stream = self._stream("play:harmony") 5642 if self._harmonic_state is not None and harmony_stream is not None: 5643 self._harmonic_state.rng = harmony_stream 5644 5645 form_stream = self._stream("play:form") 5646 if self._form_state is not None and form_stream is not None: 5647 self._form_state._rng = form_stream 5648 5649 # The clocks fire BEFORE pattern rebuilds at the same pulse, and their 5650 # lookahead is RAISED to the maximum pattern lookahead (never patterns 5651 # clamped down): when a pattern rebuilds for its next cycle, the form 5652 # state and the harmony window already describe that cycle. 5653 bar_beats = float(self.time_signature[0]) 5654 5655 pattern_lookaheads = [pending.reschedule_lookahead for pending in self._pending_patterns] 5656 pattern_lookaheads += [pattern.reschedule_lookahead for pattern in self._running_patterns.values()] 5657 max_pattern_lookahead = max(pattern_lookaheads, default = 1) 5658 5659 clock_lookahead = max(1.0, float(self._harmony_reschedule_lookahead), float(max_pattern_lookahead)) 5660 5661 if clock_lookahead > bar_beats: 5662 logger.warning( 5663 "A pattern's reschedule_lookahead (%.2g beats) exceeds the bar length (%.2g) — " 5664 "the harmony/form clocks fire at most one bar ahead, so that pattern may " 5665 "rebuild before the window covers its cycle start.", 5666 clock_lookahead, bar_beats, 5667 ) 5668 clock_lookahead = bar_beats 5669 5670 # Minimum span >= maximum lookahead: the clock cannot prepare a chord 5671 # boundary that arrives sooner than it fires. Harmonic motion faster 5672 # than this floor stays available at the part level (p.progression), 5673 # where placement is not clock-bound. 5674 def _check_span_floor (progression: typing.Optional[Progression], label: str) -> None: 5675 if progression is None: 5676 return 5677 shortest = min(span.beats for span in progression.spans) 5678 if shortest < clock_lookahead - 1e-9: 5679 raise ValueError( 5680 f"{label}: shortest chord span ({shortest:g} beats) is below the clock " 5681 f"lookahead ({clock_lookahead:g} beats — the largest pattern lookahead). " 5682 "Lengthen the span, lower the pattern lookaheads, or place fast harmony " 5683 "at the part level with p.progression()." 5684 ) 5685 5686 _check_span_floor(self._bound_progression, "harmony(progression=)") 5687 for section_name, section_progression in self._section_progressions.items(): 5688 _check_span_floor(section_progression, f"section_chords({section_name!r})") 5689 5690 # Key-relative section progressions resolve late, per occurrence — so 5691 # verify they WILL resolve now, before playback, rather than surfacing 5692 # a silent skip (or a dead clock) mid-render. For each occurrence's 5693 # effective key+scale: a missing key, or a degree/scale that does not 5694 # resolve, is raised here with an actionable message. 5695 fs = self._form_state 5696 5697 for section_name, section_progression in self._section_progressions.items(): 5698 if section_progression.is_concrete: 5699 continue 5700 5701 # The (key, scale) contexts this section may be resolved against. 5702 contexts: typing.List[typing.Tuple[typing.Optional[str], typing.Optional[str]]] = [] 5703 if fs is not None and fs._sequence is not None and any(s.name == section_name for s in fs._sequence): 5704 for section in fs._sequence: 5705 if section.name != section_name: 5706 continue 5707 ctx = (section.key or self._form_key or self.key, section.scale or self._form_scale or self.scale) 5708 if ctx not in contexts: 5709 contexts.append(ctx) 5710 else: 5711 contexts.append((self._form_key or self.key, self._form_scale or self.scale)) 5712 5713 for ctx_key, ctx_scale in contexts: 5714 if ctx_key is None: 5715 raise ValueError( 5716 f"section_chords({section_name!r}) is key-relative (degrees/romans) but no key " 5717 "resolves for it — set key= on the Composition, a form key (form(key=...)), or " 5718 f"a Section.key on every {section_name!r} section." 5719 ) 5720 try: 5721 section_progression.resolve(ctx_key, ctx_scale or "ionian") 5722 except ValueError as error: 5723 raise ValueError( 5724 f"section_chords({section_name!r}) does not resolve against its effective key " 5725 f"{ctx_key} {ctx_scale or 'ionian'}: {error}" 5726 ) 5727 5728 # min_energy with nothing feeding p.energy is a silent no-op — warn loudly. 5729 energy_gated = [p.builder_fn.__name__ for p in self._pending_patterns if p.min_energy is not None] 5730 5731 if energy_gated and not self._energy_map and not self._form_has_payload: 5732 logger.warning( 5733 f"min_energy is set on {', '.join(energy_gated)} but no energy source is " 5734 "configured — p.energy is always 0.5 (call composition.energy() or bind a " 5735 "Form whose Sections carry energy)" 5736 ) 5737 5738 # The form clock MUST be registered before the harmonic clock: same-pulse 5739 # fixed callbacks fire in registration order (and all fixed callbacks fire 5740 # before callback sequences), and on a section-boundary bar the harmonic 5741 # clock reads the current section (via _get_section_progression) to decide 5742 # whether to walk that section's chords. Registering harmony first would 5743 # make it read the OLD section on every boundary, shifting section_chords() 5744 # replays by one bar and bleeding them across sections. 5745 if self._form_state is not None: 5746 5747 await schedule_form( 5748 sequencer = self._sequencer, 5749 form_state = self._form_state, 5750 reschedule_lookahead = clock_lookahead, 5751 on_bar = self._check_transitions, 5752 # Re-read every bar so a mid-playback form() re-bind advances 5753 # the NEW state instead of the abandoned object. 5754 get_form_state = lambda: self._form_state, 5755 ) 5756 5757 self._harmony_horizon.reset() 5758 self._harmonic_clock_started = False 5759 5760 if self._harmonic_state is not None or self._bound_progression is not None or self._section_progressions: 5761 await self._start_harmonic_clock(bar_beats, clock_lookahead) 5762 5763 # Bar counter - always active so p.bar is available to all builders. 5764 def _advance_builder_bar (pulse: int) -> None: 5765 self._builder_bar += 1 5766 5767 first_bar_pulse = int(self.time_signature[0] * self._sequencer.pulses_per_beat) 5768 5769 await self._sequencer.schedule_callback_repeating( 5770 callback = _advance_builder_bar, 5771 interval_beats = self.time_signature[0], 5772 start_pulse = first_bar_pulse, 5773 # Same raised lookahead as the form/harmony clocks: a pattern 5774 # rebuilding lookahead-early for its next cycle must read the bar 5775 # that cycle starts in, not the previous one. 5776 reschedule_lookahead = clock_lookahead 5777 ) 5778 5779 # Run wait_for_initial=True scheduled functions and block until all complete. 5780 # This ensures composition.data is populated before patterns build. 5781 initial_tasks = [t for t in self._pending_scheduled if t.wait_for_initial] 5782 5783 if initial_tasks: 5784 5785 names = ", ".join(getattr(t.fn, '__name__', repr(t.fn)) for t in initial_tasks) 5786 logger.info(f"Waiting for initial scheduled {'function' if len(initial_tasks) == 1 else 'functions'} before start: {names}") 5787 5788 async def _run_initial (fn: typing.Callable) -> None: 5789 5790 accepts_ctx = _fn_has_parameter(fn, "p") 5791 ctx = ScheduleContext(cycle=0) 5792 5793 try: 5794 if inspect.iscoroutinefunction(fn): 5795 await (fn(ctx) if accepts_ctx else fn()) 5796 else: 5797 loop = asyncio.get_running_loop() 5798 call = (lambda: fn(ctx)) if accepts_ctx else fn 5799 await loop.run_in_executor(None, call) 5800 except Exception as exc: 5801 logger.warning(f"Initial run of {getattr(fn, '__name__', repr(fn))!r} failed: {exc}") 5802 5803 await asyncio.gather(*[_run_initial(t.fn) for t in initial_tasks]) 5804 5805 for pending_task in self._pending_scheduled: 5806 5807 accepts_ctx = _fn_has_parameter(pending_task.fn, "p") 5808 5809 # A wait_for_initial task already ran once as cycle 0 (the blocking 5810 # pre-roll above), so its repeating wrapper starts at cycle 1 — keeping 5811 # ScheduleContext.cycle monotonic across the initial and repeating runs. 5812 wrapped = _make_safe_callback( 5813 pending_task.fn, 5814 accepts_context = accepts_ctx, 5815 start_cycle = 1 if pending_task.wait_for_initial else 0, 5816 ) 5817 5818 # wait_for_initial=True implies defer — no point firing at pulse 0 5819 # after the blocking run just completed. defer=True skips the 5820 # backshift fire so the first repeating call happens one full cycle 5821 # later. 5822 if pending_task.wait_for_initial or pending_task.defer: 5823 start_pulse = int(pending_task.cycle_beats * self._sequencer.pulses_per_beat) 5824 else: 5825 start_pulse = 0 5826 5827 await self._sequencer.schedule_callback_repeating( 5828 callback = wrapped, 5829 interval_beats = pending_task.cycle_beats, 5830 start_pulse = start_pulse, 5831 reschedule_lookahead = pending_task.reschedule_lookahead 5832 ) 5833 5834 # Build Pattern objects from pending registrations. 5835 patterns: typing.List[subsequence.pattern.Pattern] = [] 5836 5837 for i, pending in enumerate(self._pending_patterns): 5838 5839 pattern = self._build_pattern_from_pending(pending) 5840 patterns.append(pattern) 5841 5842 await schedule_patterns( 5843 sequencer = self._sequencer, 5844 patterns = patterns, 5845 start_pulse = 0 5846 ) 5847 5848 # Populate the running patterns dict for live hot-swap and mute/unmute. 5849 for i, pending in enumerate(self._pending_patterns): 5850 name = pending.builder_fn.__name__ 5851 self._running_patterns[name] = patterns[i] 5852 5853 # Everything pending is running now; drop the declarations so a later 5854 # live reload cannot graduate stale copies. 5855 self._pending_patterns = [] 5856 5857 if self._display is not None and not self._sequencer.render_mode: 5858 self._display.start() 5859 self._sequencer.on_event("bar", self._display.update) 5860 self._sequencer.on_event("beat", self._display.update) 5861 5862 if self._live_server is not None: 5863 await self._live_server.start() 5864 5865 if self._osc_server is not None: 5866 await self._osc_server.start() 5867 self._sequencer.osc_server = self._osc_server 5868 self._sequencer.on_event("bar", self._broadcast_osc_status) 5869 5870 # Start keystroke listener if hotkeys are enabled and not in render mode. 5871 if self._hotkeys_enabled and not self._sequencer.render_mode: 5872 self._keystroke_listener = subsequence.keystroke.KeystrokeListener() 5873 self._keystroke_listener.start() 5874 5875 if self._keystroke_listener.active: 5876 # Listener started successfully — register the bar handler 5877 # and show all bindings so the user knows what's available. 5878 self._sequencer.on_event("bar", self._process_hotkeys) 5879 self._list_hotkeys() 5880 # If not active, KeystrokeListener.start() already logged a warning. 5881 5882 if self._web_ui_enabled and not self._sequencer.render_mode: 5883 self._web_ui_server = subsequence.web_ui.WebUI(self, http_host=self._web_ui_http_host, ws_host=self._web_ui_ws_host) 5884 self._web_ui_server.start() 5885 5886 try: 5887 await run_until_stopped(self._sequencer) 5888 finally: 5889 # Tear down every service even if run_until_stopped (or an earlier 5890 # stop) raised, and guard each individually, so one failure can't 5891 # strand the rest — most importantly the keystroke listener's 5892 # terminal restore. 5893 if self._web_ui_server is not None: 5894 try: 5895 self._web_ui_server.stop() 5896 except Exception: 5897 logger.exception("Error stopping web UI") 5898 5899 if self._live_server is not None: 5900 try: 5901 await self._live_server.stop() 5902 except Exception: 5903 logger.exception("Error stopping live server") 5904 5905 if self._live_reloader is not None: 5906 try: 5907 self._live_reloader.stop() 5908 except Exception: 5909 logger.exception("Error stopping live reloader") 5910 5911 if self._osc_server is not None: 5912 try: 5913 await self._osc_server.stop() 5914 except Exception: 5915 logger.exception("Error stopping OSC server") 5916 self._sequencer.osc_server = None 5917 5918 if self._display is not None: 5919 try: 5920 self._display.stop() 5921 except Exception: 5922 logger.exception("Error stopping display") 5923 5924 if self._keystroke_listener is not None: 5925 try: 5926 self._keystroke_listener.stop() 5927 except Exception: 5928 logger.exception("Error stopping keystroke listener") 5929 self._keystroke_listener = None 5930 5931 def _build_pattern_from_pending (self, pending: _PendingPattern, start_pulse: int = 0) -> subsequence.pattern.Pattern: 5932 5933 """ 5934 Create a Pattern from a pending registration using a temporary subclass. 5935 5936 The pattern's play stream is dealt here, keyed by NAME (crc32 of 5937 "seed:name" plus any reroll nonce), so registration order is 5938 irrelevant and a pattern added live gets exactly the stream it would 5939 have had at startup. ``start_pulse`` anchors the first cycle on the 5940 beat axis so the initial build reads the harmony window at the right 5941 place (the sequencer keeps the anchor current on every reschedule). 5942 """ 5943 5944 composition_ref = self 5945 rng = self._stream(pending.builder_fn.__name__) 5946 5947 class _DecoratorPattern (subsequence.pattern.Pattern): 5948 5949 """ 5950 Pattern subclass that delegates to a builder function on each reschedule. 5951 """ 5952 5953 def __init__ (self, pending: _PendingPattern, pattern_rng: typing.Optional[random.Random] = None) -> None: 5954 5955 """ 5956 Initialize the decorator pattern from pending registration details. 5957 """ 5958 5959 super().__init__( 5960 channel = pending.channel, 5961 length = pending.length, 5962 reschedule_lookahead = pending.reschedule_lookahead, 5963 device = pending.device, 5964 mirrors = pending.mirrors, 5965 ) 5966 5967 self._builder_fn = pending.builder_fn 5968 self._drum_note_map = pending.drum_note_map 5969 self._cc_name_map = pending.cc_name_map 5970 self._nrpn_name_map = pending.nrpn_name_map 5971 self._default_grid: int = pending.default_grid 5972 self._wants_chord = _fn_has_parameter(pending.builder_fn, "chord") 5973 self._cycle_count = 0 5974 self._rng = pattern_rng 5975 self._muted = False 5976 self._min_energy = pending.min_energy 5977 self._energy_gated = False 5978 self._voice_leading_state: typing.Optional[subsequence.voicings.VoiceLeadingState] = ( 5979 subsequence.voicings.VoiceLeadingState() if pending.voice_leading else None 5980 ) 5981 self._tweaks: typing.Dict[str, typing.Any] = {} 5982 5983 # Anchor of the cycle being built, on the absolute pulse axis. 5984 # The sequencer updates this on every reschedule; the initial 5985 # value is the pattern's first scheduled start. 5986 self._cycle_start_pulse = start_pulse 5987 5988 self._rebuild() 5989 5990 def _rebuild (self) -> None: 5991 5992 """ 5993 Clear steps and call the builder function to repopulate. 5994 """ 5995 5996 self.steps = {} 5997 self.cc_events = [] 5998 self.osc_events = [] 5999 self.raw_note_events = [] 6000 current_cycle = self._cycle_count 6001 self._cycle_count += 1 6002 6003 # lock(): re-deal the stream from its effective seed every 6004 # rebuild so a locked pattern realizes identically each cycle. 6005 # Checked here (engine-side) so it survives live reload. 6006 if self._builder_fn.__name__ in composition_ref._locked_names: 6007 locked_seed = composition_ref._stream_seed(self._builder_fn.__name__) 6008 if locked_seed is not None: 6009 self._rng = random.Random(locked_seed) 6010 6011 if self._muted: 6012 return 6013 6014 section_info = composition_ref._form_state.get_section_info() if composition_ref._form_state else None 6015 energy = composition_ref._current_energy(section_info) 6016 effective_key, effective_scale = composition_ref._effective_key_scale(section_info) 6017 6018 # Automatic energy gating: below the threshold the pattern is 6019 # silent this cycle (composing with _muted — a performer mute 6020 # always wins). Gate flips log once. 6021 if self._min_energy is not None: 6022 gated = energy < self._min_energy 6023 6024 if gated != self._energy_gated: 6025 state_word = "closed" if gated else "open" 6026 logger.info( 6027 f"Pattern '{self._builder_fn.__name__}': energy gate {state_word} " 6028 f"(energy {energy:.2f}, min_energy {self._min_energy:g})" 6029 ) 6030 self._energy_gated = gated 6031 6032 if gated: 6033 return 6034 6035 # The harmony view for this cycle, anchored at its start beat — 6036 # under variable harmonic rhythm the window, not the engine's 6037 # mutating singleton, is the source of truth. 6038 harmony_view: typing.Optional[HarmonyView] = None 6039 6040 if not composition_ref._harmony_horizon.is_empty: 6041 origin_beat = self._cycle_start_pulse / composition_ref._sequencer.pulses_per_beat 6042 harmony_view = HarmonyView(composition_ref._harmony_horizon, origin_beat) 6043 6044 builder = subsequence.pattern_builder.PatternBuilder( 6045 pattern = self, 6046 cycle = current_cycle, 6047 drum_note_map = self._drum_note_map, 6048 cc_name_map = self._cc_name_map, 6049 nrpn_name_map = self._nrpn_name_map, 6050 section = section_info, 6051 bar = composition_ref._builder_bar, 6052 conductor = composition_ref.conductor, 6053 rng = self._rng, 6054 tweaks = self._tweaks, 6055 default_grid = self._default_grid, 6056 data = composition_ref.data, 6057 # The effective key/scale re-anchors key-relative content 6058 # (degrees, romans, generated material) to the section / 6059 # form / composition tier in force — mode travels too. 6060 key = effective_key, 6061 scale = effective_scale, 6062 time_signature = composition_ref.time_signature, 6063 held_notes = composition_ref._sequencer._held_notes, 6064 harmony = harmony_view, 6065 section_motifs = composition_ref._section_motifs, 6066 energy = energy, 6067 ) 6068 6069 try: 6070 6071 if self._wants_chord: 6072 6073 # The two-parameter convention: the injected chord is 6074 # the cycle-start snapshot from the window (falling 6075 # back to the engine before the clock has run). 6076 chord = harmony_view.chord if harmony_view is not None else ( 6077 composition_ref._harmonic_state.get_current_chord() 6078 if composition_ref._harmonic_state is not None else None 6079 ) 6080 6081 if chord is not None: 6082 injected = _InjectedChord( 6083 chord, 6084 self._voice_leading_state, 6085 next_chord = harmony_view.next_chord if harmony_view is not None else None, 6086 beats_remaining = harmony_view.until_change if harmony_view is not None else None, 6087 ) 6088 self._builder_fn(builder, injected) 6089 else: 6090 self._builder_fn(builder) 6091 6092 else: 6093 self._builder_fn(builder) 6094 6095 except Exception: 6096 # Discard whatever the builder placed before it raised — 6097 # otherwise a half-built pattern plays and the log lies. 6098 self.steps = {} 6099 self.cc_events = [] 6100 self.osc_events = [] 6101 self.raw_note_events = [] 6102 logger.exception("Error in pattern builder '%s' (cycle %d) - pattern will be silent this cycle", self._builder_fn.__name__, current_cycle) 6103 6104 # Auto-apply global tuning if set and not already applied by the builder. 6105 if ( 6106 composition_ref._tuning is not None 6107 and not builder._tuning_applied 6108 and not (composition_ref._tuning_exclude_drums and self._drum_note_map) 6109 ): 6110 import subsequence.tuning as _tuning_mod 6111 _tuning_mod.apply_tuning_to_pattern( 6112 self, 6113 composition_ref._tuning, 6114 bend_range=composition_ref._tuning_bend_range, 6115 channels=composition_ref._tuning_channels, 6116 reference_note=composition_ref._tuning_reference_note, 6117 ) 6118 6119 def on_reschedule (self) -> None: 6120 6121 """ 6122 Rebuild the pattern from the builder function before the next cycle. 6123 """ 6124 6125 self._rebuild() 6126 6127 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:
- Initialize
Compositionwith BPM and Key. - Define harmony and form (optional).
- Register patterns using the
@composition.patterndecorator. - Call
composition.play()to start the music.
1281 def __init__ ( 1282 self, 1283 output_device: typing.Optional[str] = None, 1284 bpm: float = 120, 1285 time_signature: typing.Tuple[int, int] = (4, 4), 1286 key: typing.Optional[str] = None, 1287 scale: typing.Optional[str] = None, 1288 seed: typing.Optional[int] = None, 1289 record: bool = False, 1290 record_filename: typing.Optional[str] = None, 1291 zero_indexed_channels: bool = False, 1292 latency_ms: float = 0.0 1293 ) -> None: 1294 1295 """ 1296 Initialize a new composition. 1297 1298 Parameters: 1299 output_device: Which MIDI output port to use, matched against 1300 ``mido.get_output_names()``. The name is treated as a 1301 pattern: ``*`` stands for any run of characters and ``?`` 1302 for exactly one, matching is case-insensitive, and a name 1303 with no wildcards is simply a substring — so a plain 1304 ``"Scarlett"`` finds the port without typing the rest. 1305 An exact name always wins outright. 1306 1307 Wildcards matter on Linux/ALSA, where names carry the 1308 client and port ids (e.g. 1309 ``"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"``). The 1310 client id — ``16`` here — is handed out in connection order 1311 and moves between reboots or when a virtual port is 1312 recreated, while the port index after it (``0``) stays put. 1313 Wildcard the one that moves and keep the one that does not:: 1314 1315 "*Scarlett 2i4 USB *:0" 1316 1317 Keep that trailing port index. A multi-port interface 1318 reports one name per port, so ``"*U6MIDI Pro*"`` matches 1319 all three ports of a 3-port unit and asks which you meant 1320 at every launch, while ``"*U6MIDI Pro *:0"`` names one for 1321 good. Prefer ``*`` to ``?`` — ``?`` matches a single 1322 character, so a pattern written for ``16:0`` quietly stops 1323 matching once ids reach three digits. To look up the 1324 current names:: 1325 1326 import mido 1327 for n in mido.get_output_names(): print(n) 1328 1329 If ``None``, Subsequence auto-discovers — uses the only 1330 available device, or prompts to choose if several exist. 1331 bpm: Initial tempo in beats per minute (default 120). 1332 time_signature: The metre as ``(beats, unit)``, default ``(4, 4)``. 1333 Sets the bar length everywhere bars matter: ``bars=`` pattern 1334 lengths, ``p.bar``/``p.signal()``, form advancement and 1335 transitions, and pinned-chord bar numbers. 1336 key: The root key of the piece (e.g., "C", "F#", "Bb"). 1337 Required if you plan to use ``harmony()``. 1338 scale: The scale/mode of the piece (e.g. "minor", "dorian", 1339 or any registered scale name). Used to resolve scale 1340 degrees in motifs; defaults to major (ionian) when unset. 1341 seed: An optional integer for deterministic randomness. When set, 1342 every random decision (chord choices, drum probability, etc.) 1343 will be identical on every run. 1344 record: When True, record all MIDI events to a file. 1345 record_filename: Optional filename for the recording (defaults to timestamp). 1346 zero_indexed_channels: When False (default), MIDI channels use 1347 1-based numbering (1-16) matching instrument labelling. 1348 Channel 10 is drums, the way musicians and hardware panels 1349 show it. When True, channels use 0-based numbering (0-15) 1350 matching the raw MIDI protocol. 1351 latency_ms: Physical output latency of the primary device in 1352 milliseconds, for delay compensation (default 0.0, must be 1353 non-negative). Set this when the primary output sounds late 1354 (e.g. a software sampler) so Subsequence delays faster 1355 devices to line everything up. See ``midi_output()`` for 1356 additional devices. 1357 1358 Example: 1359 ```python 1360 comp = subsequence.Composition(bpm=128, key="Eb", seed=123) 1361 ``` 1362 """ 1363 1364 if latency_ms < 0: 1365 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 1366 1367 self.output_device = output_device 1368 self.bpm = bpm 1369 self.time_signature = time_signature 1370 self.key = key 1371 self.scale = scale 1372 self._seed: typing.Optional[int] = seed 1373 self._zero_indexed_channels: bool = zero_indexed_channels 1374 self._output_latency_ms: float = latency_ms 1375 1376 # Determinism plumbing: named-stream derivation state. Build-time 1377 # consumers draw per-call-salted streams (freeze:1, harmony:2, ...) so 1378 # adding one call never shifts another's stream; play-time pattern 1379 # streams are name-keyed in _build_pattern_from_pending. 1380 self._freeze_count: int = 0 1381 self._harmony_count: int = 0 1382 self._form_count: int = 0 1383 self._reroll_nonces: typing.Dict[str, int] = {} 1384 self._locked_names: typing.Set[str] = set() 1385 1386 self._sequencer = subsequence.sequencer.Sequencer( 1387 output_device_name = output_device, 1388 initial_bpm = bpm, 1389 time_signature = time_signature, 1390 record = record, 1391 record_filename = record_filename 1392 ) 1393 1394 self._harmonic_state: typing.Optional[subsequence.harmonic_state.HarmonicState] = None 1395 self._harmony_cycle_beats: typing.Optional[int] = None 1396 self._harmony_style: typing.Optional[str] = None 1397 # The style (name or ChordGraph) from the most recent style-configuring 1398 # harmony() call — reused by parameter-only re-calls. 1399 self._last_harmony_style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None 1400 self._harmony_reschedule_lookahead: float = 1 1401 self._section_progressions: typing.Dict[str, Progression] = {} 1402 self._bound_progression: typing.Optional[Progression] = None 1403 self._pinned_chords: typing.Dict[int, typing.Any] = {} 1404 self._cadence_requests: typing.Dict[int, str] = {} 1405 self._section_cadences: typing.Dict[str, str] = {} 1406 self._harmony_horizon = _HarmonyHorizon() 1407 # True once the span-walking clock is registered for this playback — 1408 # lets a first mid-playback harmony() call start it exactly once. 1409 self._harmonic_clock_started: bool = False 1410 self._section_motifs: typing.Dict[typing.Tuple[str, typing.Optional[str]], typing.Any] = {} 1411 self._energy_map: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 1412 self._form_has_payload: bool = False 1413 self._form_key: typing.Optional[str] = None 1414 self._form_scale: typing.Optional[str] = None 1415 # Cache of section progressions resolved against an effective key/scale 1416 # (key-relative section harmony re-keys per occurrence; resolution is a 1417 # pure function of (content, key, scale), so this is just memoisation). 1418 self._resolved_section_cache: typing.Dict[typing.Tuple[str, typing.Optional[str], typing.Optional[str]], Progression] = {} 1419 self._transitions: typing.List[_Transition] = [] 1420 self._transition_muted: typing.Set[str] = set() 1421 self._pending_patterns: typing.List[_PendingPattern] = [] 1422 # Names of patterns declared by the most recent live-reload exec (added by 1423 # pattern()/layer() as they run); the deletion diff in _apply_source_async 1424 # compares this against the same source's PREVIOUS exec. 1425 self._declared_names: typing.Set[str] = set() 1426 # Per-source declaration history: source label/path → the names it 1427 # declared last time it was exec'd. The deletion diff unregisters only 1428 # names a source used to declare and no longer does — never patterns 1429 # registered by the wrapper script or by another watched source. 1430 self._source_declared: typing.Dict[str, typing.Set[str]] = {} 1431 self._pending_scheduled: typing.List[_PendingScheduled] = [] 1432 self._form_state: typing.Optional[subsequence.form_state.FormState] = None 1433 self._builder_bar: int = 0 1434 self._display: typing.Optional[subsequence.display.Display] = None 1435 self._live_server: typing.Optional[subsequence.live_server.LiveServer] = None 1436 self._live_reloader: typing.Optional[subsequence.live_reloader.LiveReloader] = None 1437 self._is_live: bool = False 1438 self._running_patterns: typing.Dict[str, typing.Any] = {} 1439 self._input_device: typing.Optional[str] = None 1440 self._input_device_alias: typing.Optional[str] = None 1441 self._clock_follow: bool = False 1442 self._clock_output: bool = False 1443 self._cc_mappings: typing.List[typing.Dict[str, typing.Any]] = [] 1444 self._cc_forwards: typing.List[typing.Dict[str, typing.Any]] = [] 1445 # Held-note input config from note_input() (None = not declared). 1446 self._note_input: typing.Optional[typing.Dict[str, typing.Any]] = None 1447 # Additional output devices registered with midi_output() after construction. 1448 self._additional_outputs: typing.List[_AdditionalOutput] = [] 1449 # Additional input devices: (device_name: str, alias: Optional[str], clock_follow: bool) 1450 self._additional_inputs: typing.List[typing.Tuple[str, typing.Optional[str], bool]] = [] 1451 # Maps alias/name → output device index (populated in _run after all devices are opened). 1452 self._output_device_names: typing.Dict[str, int] = {} 1453 # Maps alias/name → input device index (populated in _run after all input devices are opened). 1454 self._input_device_names: typing.Dict[str, int] = {} 1455 self.data: typing.Dict[str, typing.Any] = {} 1456 self._osc_server: typing.Optional[subsequence.osc.OscServer] = None 1457 self.conductor = subsequence.conductor.Conductor() 1458 self._web_ui_enabled: bool = False 1459 self._web_ui_http_host: str = "127.0.0.1" 1460 self._web_ui_ws_host: str = "127.0.0.1" 1461 self._web_ui_server: typing.Optional[subsequence.web_ui.WebUI] = None 1462 self._link_quantum: typing.Optional[float] = None 1463 1464 # Hotkey state — populated by hotkeys() and hotkey(). 1465 self._hotkeys_enabled: bool = False 1466 self._hotkey_bindings: typing.Dict[str, HotkeyBinding] = {} 1467 self._pending_hotkey_actions: typing.List[_PendingHotkeyAction] = [] 1468 self._keystroke_listener: typing.Optional[subsequence.keystroke.KeystrokeListener] = None 1469 1470 # Tuning state — populated by tuning(). 1471 self._tuning: typing.Optional[typing.Any] = None # subsequence.tuning.Tuning 1472 self._tuning_bend_range: float = 2.0 1473 self._tuning_channels: typing.Optional[typing.List[int]] = None 1474 self._tuning_reference_note: int = 60 1475 self._tuning_exclude_drums: bool = True
Initialize a new composition.
Arguments:
output_device: Which MIDI output port to use, matched against
mido.get_output_names(). The name is treated as a pattern:*stands for any run of characters and?for exactly one, matching is case-insensitive, and a name with no wildcards is simply a substring — so a plain"Scarlett"finds the port without typing the rest. An exact name always wins outright.Wildcards matter on Linux/ALSA, where names carry the client and port ids (e.g.
"Scarlett 2i4 USB:Scarlett 2i4 USB MIDI 1 16:0"). The client id —16here — is handed out in connection order and moves between reboots or when a virtual port is recreated, while the port index after it (0) stays put. Wildcard the one that moves and keep the one that does not::"*Scarlett 2i4 USB *:0"Keep that trailing port index. A multi-port interface reports one name per port, so
"*U6MIDI Pro*"matches all three ports of a 3-port unit and asks which you meant at every launch, while"*U6MIDI Pro *:0"names one for good. Prefer*to?—?matches a single character, so a pattern written for16:0quietly stops matching once ids reach three digits. 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).
- time_signature: The metre as
(beats, unit), default(4, 4). Sets the bar length everywhere bars matter:bars=pattern lengths,p.bar/p.signal(), form advancement and transitions, and pinned-chord bar numbers. - 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)
1673 @property 1674 def harmonic_state (self) -> typing.Optional[subsequence.harmonic_state.HarmonicState]: 1675 """The active ``HarmonicState``, or ``None`` if ``harmony()`` has not been called.""" 1676 return self._harmonic_state
The active HarmonicState, or None if harmony() has not been called.
1678 def current_chord (self) -> typing.Optional[typing.Any]: 1679 1680 """The chord sounding at the playhead, or ``None`` without harmony. 1681 1682 Reads the harmony window at the current pulse, so it stays accurate 1683 under variable harmonic rhythm and clock lookahead (the engine's 1684 ``current_chord`` flips *lookahead* beats early — this does not). 1685 Falls back to the engine's chord before playback starts. The chord 1686 may be a decorated wrapper (``Am9``, ``C/G``) when the sounding span 1687 is spiced; it duck-types the ``Chord`` voicing protocol either way. 1688 """ 1689 1690 if not self._harmony_horizon.is_empty: 1691 beat = self._sequencer.pulse_count / self._sequencer.pulses_per_beat 1692 chord = self._harmony_horizon.chord_at(beat) 1693 if chord is not None: 1694 return chord 1695 1696 if self._harmonic_state is not None: 1697 return self._harmonic_state.get_current_chord() 1698 1699 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.
1791 @property 1792 def form_state (self) -> typing.Optional["subsequence.form_state.FormState"]: 1793 """The active ``subsequence.form_state.FormState``, or ``None`` if ``form()`` has not been called.""" 1794 return self._form_state
The active subsequence.form_state.FormState, or None if form() has not been called.
1796 @property 1797 def sequencer (self) -> subsequence.sequencer.Sequencer: 1798 """The underlying ``Sequencer`` instance.""" 1799 return self._sequencer
The underlying Sequencer instance.
1801 @property 1802 def running_patterns (self) -> typing.Dict[str, typing.Any]: 1803 """The currently active patterns, keyed by name.""" 1804 return self._running_patterns
The currently active patterns, keyed by name.
1806 @property 1807 def builder_bar (self) -> int: 1808 """Current bar index used by pattern builders.""" 1809 return self._builder_bar
Current bar index used by pattern builders.
1857 def harmony ( 1858 self, 1859 style: typing.Optional[typing.Union[str, subsequence.chord_graphs.ChordGraph]] = None, 1860 cycle_beats: int = 4, 1861 dominant_7th: bool = True, 1862 gravity: float = 1.0, 1863 nir_strength: float = 0.5, 1864 minor_turnaround_weight: float = 0.0, 1865 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1866 reschedule_lookahead: float = 1, 1867 progression: typing.Optional[typing.Any] = None, 1868 ) -> None: 1869 1870 """ 1871 Configure the harmonic logic and chord change intervals. 1872 1873 Two sources, combinable: a **bound progression** (``progression=`` — a 1874 :class:`Progression` value, an element list like ``[1, 6, 3, "bVII7"]``, 1875 or chord names) walked span by span on the global clock; and/or a 1876 **graph style** stepping live chords. With only a progression bound, 1877 it loops on exhaustion; with a style configured too, exhaustion falls 1878 through to live stepping (the frozen-replay bridge). Calling with 1879 neither argument keeps today's default live engine 1880 (``style="functional_major"``). 1881 1882 Parameters: 1883 style: The harmonic style to use. Built-in: "functional_major" 1884 (alias "diatonic_major"), "hooktheory_major" (alias 1885 "pop_major"), "turnaround", "aeolian_minor", 1886 "phrygian_minor", "lydian_major", "dorian_minor", 1887 "chromatic_mediant", "suspended", "mixolydian", "whole_tone", 1888 "diminished". See README for full descriptions. 1889 cycle_beats: How many beats each live chord lasts (default 4). 1890 Bound progressions carry their own harmonic rhythm in their 1891 spans, so this applies to live stepping only. A re-call 1892 during playback takes effect from the next chord boundary; 1893 a FIRST harmony() call mid-playback starts the clock itself. 1894 dominant_7th: Whether to include V7 chords (default True). 1895 gravity: Key gravity (0.0 to 1.0). High values stay closer to the root chord. 1896 nir_strength: Melodic inertia (0.0 to 1.0). Influences chord movement 1897 expectations. 1898 minor_turnaround_weight: For "turnaround" style, influences major vs minor feel. 1899 root_diversity: Root-repetition damping (0.0 to 1.0). Each recent 1900 chord sharing a candidate's root reduces the weight to 40% at 1901 the default (0.4). Set to 1.0 to disable. 1902 reschedule_lookahead: How many beats in advance to calculate the 1903 next chord. 1904 progression: A progression to bind to the global clock. Key- 1905 relative content resolves now, against the composition key 1906 and scale (binding freezes one realisation). 1907 1908 Example: 1909 ```python 1910 # A moody minor progression that changes every 8 beats 1911 comp.harmony(style="aeolian_minor", cycle_beats=8, gravity=0.4) 1912 1913 # Manual harmony driving everything — loops forever 1914 comp.harmony(progression=subsequence.progression([1, 6, 3, 7])) 1915 ``` 1916 """ 1917 1918 if style is None and progression is None: 1919 # A parameter-only re-call (gravity=, cycle_beats=, ...) keeps the 1920 # configured style — defaulting unconditionally here would silently 1921 # replace e.g. aeolian_minor with functional_major. 1922 style = self._last_harmony_style if self._last_harmony_style is not None else "functional_major" 1923 1924 if style is not None: 1925 1926 if self.key is None: 1927 raise ValueError("Cannot configure harmony without a key - set key in the Composition constructor") 1928 1929 preserved_history: typing.List[subsequence.chords.Chord] = [] 1930 preserved_current: typing.Optional[subsequence.chords.Chord] = None 1931 1932 if self._harmonic_state is not None: 1933 preserved_history = self._harmonic_state.history.copy() 1934 preserved_current = self._harmonic_state.current_chord 1935 1936 # Per-call salted build stream (harmony:1, harmony:2, ...): a re-call 1937 # gets its own deterministic stream while history and current chord 1938 # are preserved above, and adding a re-call never shifts any other 1939 # consumer's stream. 1940 self._harmony_count += 1 1941 1942 self._harmonic_state = subsequence.harmonic_state.HarmonicState( 1943 key_name = self.key, 1944 graph_style = style, 1945 include_dominant_7th = dominant_7th, 1946 key_gravity_blend = gravity, 1947 nir_strength = nir_strength, 1948 minor_turnaround_weight = minor_turnaround_weight, 1949 root_diversity = root_diversity, 1950 rng = self._stream(f"harmony:{self._harmony_count}") 1951 ) 1952 1953 if preserved_history: 1954 self._harmonic_state.history = preserved_history 1955 if preserved_current is not None and self._harmonic_state.graph.get_transitions(preserved_current): 1956 self._harmonic_state.current_chord = preserved_current 1957 1958 self._harmony_style = style if isinstance(style, str) else None 1959 self._last_harmony_style = style 1960 1961 if progression is not None: 1962 self._bound_progression = self._coerce_progression(progression, "harmony(progression=)") 1963 1964 self._harmony_cycle_beats = cycle_beats 1965 self._harmony_reschedule_lookahead = reschedule_lookahead 1966 1967 # A re-call invalidates whatever the horizon had planned. 1968 self._harmony_horizon.invalidate_future() 1969 1970 # A FIRST harmony() call mid-playback must start the clock itself — 1971 # _run() only schedules clocks for sources it can see at play() time. 1972 # (Re-calls need nothing here: the clock reads its sources through 1973 # getters on every tick.) 1974 loop = self._sequencer._event_loop 1975 1976 if loop is not None and loop.is_running() and not self._harmonic_clock_started: 1977 try: 1978 on_loop = asyncio.get_running_loop() is loop 1979 except RuntimeError: 1980 on_loop = False 1981 1982 if on_loop: 1983 loop.create_task(self._start_harmonic_clock()) 1984 else: 1985 asyncio.run_coroutine_threadsafe(self._start_harmonic_clock(), loop)
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"), "hooktheory_major" (alias "pop_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. A re-call during playback takes effect from the next chord boundary; a FIRST harmony() call mid-playback starts the clock itself.
- 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]))
2065 def freeze ( 2066 self, 2067 bars: int, 2068 end: typing.Optional[typing.Any] = None, 2069 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 2070 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 2071 cadence: typing.Optional[str] = None, 2072 ) -> "Progression": 2073 2074 """Capture a chord progression from the live harmony engine. 2075 2076 Runs the harmony engine forward by *bars* chord changes, records each 2077 chord, and returns it as a :class:`Progression` that can be bound to a 2078 form section with :meth:`section_chords`. 2079 2080 The engine state **advances** — successive ``freeze()`` calls produce a 2081 continuing compositional journey so section progressions feel like parts 2082 of a whole rather than isolated islands. 2083 2084 The hybrid constraints compile into the walk: ``end=`` fixes the last 2085 bar ("end on V at bar 8"), ``pins=`` fix any 1-based bar, ``avoid=`` 2086 excludes chords throughout. Specs follow the progression-element 2087 grammar (ints where diatonic, roman/name strings where chromatic) and 2088 resolve against the composition key and scale. A backward 2089 feasibility pass guarantees satisfiability before any chord is drawn; 2090 the forward walk keeps the engine's real history-dependent weighting. 2091 Bar 1 is always the engine's current chord — the journey continues — 2092 so ``pins={1: ...}`` may only name it redundantly. 2093 2094 Parameters: 2095 bars: Number of chords to capture (one per harmony cycle). 2096 end: The chord at the final bar — ``end="V"`` is the cadential 2097 major dominant in minor. 2098 pins: ``{bar: chord}`` — 1-based fiat positions. 2099 avoid: Chords excluded from the walk. 2100 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 2101 ``"fakeout"``, theory aliases accepted) — its formula pins 2102 the final bars, so the walk approaches the close. 2103 Conflicts with ``end=`` or pins on those bars. 2104 2105 Returns: 2106 A :class:`Progression` with the captured chords and trailing 2107 history for NIR continuity. 2108 2109 Raises: 2110 ValueError: If :meth:`harmony` has not been called first, or the 2111 constraints are contradictory or unsatisfiable. 2112 2113 Example:: 2114 2115 composition.harmony(style="functional_major", cycle_beats=4) 2116 verse = composition.freeze(8, end="V") # the verse sets up the chorus 2117 chorus = composition.freeze(4) # next 4 chords, continuing on 2118 composition.section_chords("verse", verse) 2119 composition.section_chords("chorus", chorus) 2120 """ 2121 2122 hs = self._require_harmonic_state() 2123 2124 if bars < 1: 2125 raise ValueError("bars must be at least 1") 2126 2127 if cadence is not None: 2128 pins = subsequence.progressions.cadence_pins(cadence, bars, pins, end) 2129 end = None 2130 2131 scale = self._constraint_scale() 2132 key_pc = subsequence.chords.key_name_to_pc(self.key) if self.key is not None else hs.key_root_pc 2133 2134 resolved_pins = { 2135 position: subsequence.progressions.resolve_constraint(spec, key_pc, scale, f"pins[{position}]") 2136 for position, spec in (pins or {}).items() 2137 } 2138 resolved_end = subsequence.progressions.resolve_constraint(end, key_pc, scale, "end") if end is not None else None 2139 resolved_avoid = [subsequence.progressions.resolve_constraint(spec, key_pc, scale, "avoid") for spec in (avoid or [])] 2140 2141 if 1 in resolved_pins and resolved_pins[1] != hs.current_chord: 2142 raise ValueError( 2143 f"pins[1]={resolved_pins[1].name()} conflicts with the engine's current chord " 2144 f"({hs.current_chord.name()}) — bar 1 of a freeze continues the journey; " 2145 "pin a later bar, or use pin_chord() for playback fiat" 2146 ) 2147 2148 # Per-call salted stream (freeze:1, freeze:2, ...): each call's draws 2149 # are independent of every other consumer, so frozen progressions are 2150 # reproducible WITHOUT play() and adding a call cannot shift a 2151 # neighbour's output. Engine state still advances normally — chord 2152 # continuity comes from current_chord/history, randomness from the 2153 # salted stream (swap-and-restore keeps hs.rng for play untouched). 2154 self._freeze_count += 1 2155 stream = self._stream(f"freeze:{self._freeze_count}") 2156 saved_rng = hs.rng 2157 2158 if stream is not None: 2159 hs.rng = stream 2160 2161 try: 2162 # The kernel with the engine's own hooks is draw-for-draw the old 2163 # step() loop when unconstrained — one walk path for both. 2164 def _commit (chosen: subsequence.chords.Chord) -> None: 2165 hs.current_chord = chosen 2166 2167 collected = subsequence.sequence_utils.constrained_walk( 2168 hs.graph, 2169 hs.current_chord, 2170 bars, 2171 rng = hs.rng, 2172 pins = resolved_pins, 2173 end = resolved_end, 2174 avoid = resolved_avoid, 2175 weight_modifier = hs._transition_weight, 2176 before_choice = hs._record_transition_source, 2177 after_choice = _commit, 2178 ) 2179 2180 # Advance past the last captured chord so the next freeze() call or 2181 # live playback does not duplicate it. 2182 hs.step() 2183 2184 finally: 2185 hs.rng = saved_rng 2186 2187 span_beats = float(self._harmony_cycle_beats or 4) 2188 2189 return Progression( 2190 spans = tuple( 2191 subsequence.progressions.ChordSpan(chord = chord, beats = span_beats) 2192 for chord in collected 2193 ), 2194 trailing_history = tuple(hs.history), 2195 )
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.
- cadence: A cadence name (
"strong"/"soft"/"open"/"fakeout", theory aliases accepted) — its formula pins the final bars, so the walk approaches the close. Conflicts withend=or pins on those bars.
Returns:
A
Progressionwith 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)
2197 def section_chords (self, section_name: str, progression: typing.Any) -> None: 2198 2199 """Bind a :class:`Progression` to a named form section. 2200 2201 Every time *section_name* plays, the harmonic clock walks the 2202 progression's spans instead of calling the live engine. Sections 2203 without a bound progression continue generating live chords. 2204 2205 Accepts a :class:`Progression` value (from :meth:`freeze`, the 2206 ``progression()`` factory, or hand-built) or anything the factory 2207 accepts — an element list like ``[1, 6, 3, "bVII7"]`` or chord 2208 names. 2209 2210 **Key-relative content re-keys per occurrence.** A progression 2211 written in degrees or romans is *key-relative* content: it resolves 2212 late, each time the section plays, against that section's effective 2213 key and scale (``Section.key`` > form key > composition key, with 2214 mode following the same chain). So a ``Section(key="A")`` plays the 2215 same numbered progression a tone higher — its chords and its degrees 2216 share one tonic. *Absolute* content — chord names (``"Am"``), 2217 :class:`~subsequence.progressions.PitchSet`, and frozen captures from 2218 :meth:`freeze` — names exact chords and is never transposed by a key. 2219 2220 On exhaustion mid-section the progression loops when no graph style 2221 is configured (and always when it contains a ``PitchSet``); with a 2222 live engine, exhaustion **falls through to live stepping in the 2223 COMPOSITION key** — the live graph engine does not transpose for a 2224 section (a stateful walk does not modulate mid-stream), so a 2225 re-keyed section that runs out of written chords hands off to 2226 composition-key harmony. Bind a full-length progression (or set 2227 ``at_end``/loop intent) if you need the whole section in its key. 2228 2229 Parameters: 2230 section_name: Name of the section as defined in :meth:`form`. 2231 progression: The progression to bind. 2232 2233 Raises: 2234 ValueError: If a graph-based form has been configured and 2235 *section_name* is not one of its sections. List and generator 2236 forms yield names lazily, so they cannot be validated here. 2237 (A key-relative progression with no resolvable key for the 2238 section is caught at :meth:`play`/:meth:`render`, once the 2239 form's keys are known.) 2240 2241 Example:: 2242 2243 composition.section_chords("verse", verse_progression) 2244 composition.section_chords("chorus", [1, 6, 3, 7]) 2245 # "bridge" is not bound — it generates live chords 2246 """ 2247 2248 if ( 2249 self._form_state is not None 2250 and self._form_state._section_bars is not None 2251 and section_name not in self._form_state._section_bars 2252 ): 2253 known = ", ".join(sorted(self._form_state._section_bars)) 2254 raise ValueError( 2255 f"Section '{section_name}' not found in form. " 2256 f"Known sections: {known}" 2257 ) 2258 2259 self._section_progressions[section_name] = self._coerce_section_progression(progression) 2260 self._resolved_section_cache = {} 2261 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 re-keys per occurrence. A progression
written in degrees or romans is key-relative content: it resolves
late, each time the section plays, against that section's effective
key and scale (Section.key > form key > composition key, with
mode following the same chain). So a Section(key="A") plays the
same numbered progression a tone higher — its chords and its degrees
share one tonic. Absolute content — chord names ("Am"),
~subsequence.progressions.PitchSet, and frozen captures from
freeze() — names exact chords and is never transposed by a key.
On exhaustion mid-section the progression loops when no graph style
is configured (and always when it contains a PitchSet); with a
live engine, exhaustion falls through to live stepping in the
COMPOSITION key — the live graph engine does not transpose for a
section (a stateful walk does not modulate mid-stream), so a
re-keyed section that runs out of written chords hands off to
composition-key harmony. Bind a full-length progression (or set
at_end/loop intent) if you need the whole section in its key.
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.
(A key-relative progression with no resolvable key for the
section is caught at
play()/render(), once the form's keys are known.)
Example::
composition.section_chords("verse", verse_progression)
composition.section_chords("chorus", [1, 6, 3, 7])
# "bridge" is not bound — it generates live chords
2263 def pin_chord (self, bar: int, chord: typing.Optional[typing.Any]) -> None: 2264 2265 """Force the chord sounding at a bar — fiat over live generation. 2266 2267 Whatever the harmonic source (live walk, bound progression, section 2268 progression) produces for *bar*, the pinned chord overrides it. 2269 Pass ``None`` to remove a pin. 2270 2271 Parameters: 2272 bar: 1-based bar number (the musician count). 2273 chord: A chord name, int degree, roman string, ``Chord``, 2274 ``PitchSet``, or ``None`` to unpin. A **key-relative** spec 2275 (int degree, roman) re-keys like section harmony: it resolves 2276 late, against the effective key of the section sounding at 2277 that bar (so ``pin_chord(8, "V")`` is the dominant of 2278 wherever bar 8 lands). A **concrete** spec (name, ``Chord``, 2279 ``PitchSet``) is absolute and never moves. 2280 2281 Example:: 2282 2283 composition.pin_chord(8, "E7") # the turnaround lands on E7 2284 composition.pin_chord(8, "V") # the dominant of bar 8's section 2285 composition.pin_chord(8, None) # let it walk again 2286 """ 2287 2288 if not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 2289 raise ValueError(f"bars are 1-based ints, got {bar!r}") 2290 2291 if chord is None: 2292 self._pinned_chords.pop(bar, None) 2293 else: 2294 # Store the parsed span — relative pins resolve late (per section) 2295 # at the clock; concrete pins are absolute. 2296 span = subsequence.progressions.parse_element(chord, beats = float(self.time_signature[0])) 2297 2298 if not span.is_concrete: 2299 # Raise early only when no key is resolvable for this bar — the 2300 # bar's own section (sequence forms) may supply one even with no 2301 # composition/form key. 2302 probe_info = self._form_state.section_info_at_bar(bar) if self._form_state is not None else None 2303 probe_key, _ = self._effective_key_scale(probe_info) 2304 if probe_key is None: 2305 raise ValueError( 2306 "pin_chord with a key-relative spec (degree/roman) needs a key — set key= on " 2307 "the Composition, a form key, or a Section.key for that bar (the pin re-keys " 2308 "to the section's effective key)" 2309 ) 2310 2311 self._pinned_chords[bar] = span 2312 2313 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, orNoneto unpin. A key-relative spec (int degree, roman) re-keys like section harmony: it resolves late, against the effective key of the section sounding at that bar (sopin_chord(8, "V")is the dominant of wherever bar 8 lands). A concrete spec (name,Chord,PitchSet) is absolute and never moves.
Example::
composition.pin_chord(8, "E7") # the turnaround lands on E7
composition.pin_chord(8, "V") # the dominant of bar 8's section
composition.pin_chord(8, None) # let it walk again
2355 def request_cadence (self, cadence: str = "strong", bar: typing.Optional[int] = None) -> None: 2356 2357 """Ask the live engine to approach a cadence arriving at a bar. 2358 2359 The request hook: where :meth:`pin_chord` is fiat, this is a 2360 *steered approach* — at the next chord boundary the clock plans the 2361 remaining changes up to *bar* as a constrained walk through the 2362 engine's real weights, pinned to the cadence formula at the tail 2363 (``"strong"`` arrives V→I, ``"soft"`` IV→I, ``"open"`` IV→V, 2364 ``"fakeout"`` V→vi; theory aliases accepted). The chords still 2365 commit one boundary at a time, so the journey continues through the 2366 close. 2367 2368 One-shot: the request is consumed when planned. Live harmony only — 2369 bound/section progressions are data and cannot be steered; a request 2370 whose bar passes unserved expires with a warning. If the formula is 2371 not walkable from where the harmony stands, the arrival lands by 2372 fiat (loudly). Ask at least a pattern-lookahead ahead: patterns may 2373 already have rendered against the previously planned chord. 2374 2375 Parameters: 2376 cadence: The cadence name. 2377 bar: The 1-based bar the cadence's final chord arrives at 2378 (required; in practice ≥ 2 — bar 1 cannot be approached). 2379 2380 Example:: 2381 2382 composition.request_cadence("open", bar=16) # hang on V at bar 16 2383 """ 2384 2385 spec = subsequence.cadences.cadence_formula(cadence) 2386 2387 if bar is None or not isinstance(bar, int) or isinstance(bar, bool) or bar < 1: 2388 raise ValueError(f"request_cadence needs bar= — the 1-based bar the cadence arrives at (got {bar!r})") 2389 2390 self._cadence_requests[bar] = spec.name 2391 self._harmony_horizon.invalidate_future()
Ask the live engine to approach a cadence arriving at a bar.
The request hook: where pin_chord() is fiat, this is a
steered approach — at the next chord boundary the clock plans the
remaining changes up to bar as a constrained walk through the
engine's real weights, pinned to the cadence formula at the tail
("strong" arrives V→I, "soft" IV→I, "open" IV→V,
"fakeout" V→vi; theory aliases accepted). The chords still
commit one boundary at a time, so the journey continues through the
close.
One-shot: the request is consumed when planned. Live harmony only — bound/section progressions are data and cannot be steered; a request whose bar passes unserved expires with a warning. If the formula is not walkable from where the harmony stands, the arrival lands by fiat (loudly). Ask at least a pattern-lookahead ahead: patterns may already have rendered against the previously planned chord.
Arguments:
- cadence: The cadence name.
- bar: The 1-based bar the cadence's final chord arrives at (required; in practice ≥ 2 — bar 1 cannot be approached).
Example::
composition.request_cadence("open", bar=16) # hang on V at bar 16
2393 def section_cadence (self, section_name: str, cadence: typing.Optional[str] = "strong") -> None: 2394 2395 """Close every pass of a section with a cadence — the standing request. 2396 2397 Each time *section_name* is entered, the clock registers a 2398 :meth:`request_cadence` arriving at the section's final bar, so the 2399 harmony approaches the close as the section ends. Live harmony 2400 only: a section with bound chords (:meth:`section_chords`) is data 2401 and ignores the registration — its closes are written, not steered. 2402 Pass ``None`` to unregister. 2403 2404 Example:: 2405 2406 composition.form([("verse", 8), ("chorus", 8)]) 2407 composition.section_cadence("verse", "open") # every verse hangs on V 2408 composition.section_cadence("chorus", "strong") # every chorus lands home 2409 """ 2410 2411 if cadence is None: 2412 self._section_cadences.pop(section_name, None) 2413 return 2414 2415 spec = subsequence.cadences.cadence_formula(cadence) 2416 self._section_cadences[section_name] = spec.name
Close every pass of a section with a cadence — the standing request.
Each time section_name is entered, the clock registers a
request_cadence() arriving at the section's final bar, so the
harmony approaches the close as the section ends. Live harmony
only: a section with bound chords (section_chords()) is data
and ignores the registration — its closes are written, not steered.
Pass None to unregister.
Example::
composition.form([("verse", 8), ("chorus", 8)])
composition.section_cadence("verse", "open") # every verse hangs on V
composition.section_cadence("chorus", "strong") # every chorus lands home
2418 def section_motifs (self, section_name: str, value: typing.Any, part: typing.Optional[str] = None) -> None: 2419 2420 """Bind a Motif or Phrase to a named form section (per optional part). 2421 2422 Patterns read the binding back with ``p.section_motif(part)`` (or use 2423 the one-call :meth:`phrase_part`); a section with no binding for the 2424 part is silent for that part — bind material or don't, no fallback 2425 guessing. Re-binding is idempotent, so the call is safe in a live 2426 file: re-executing on save is the desired rebind. 2427 2428 Parameters: 2429 section_name: Name of the section as defined in :meth:`form`. 2430 value: A ``Motif`` or ``Phrase`` (anything exposing 2431 ``.length``/``.slice`` places). 2432 part: Optional part label, so one section can carry several 2433 bindings (``"lead"``, ``"bass"``, ...). 2434 2435 Raises: 2436 ValueError: If a graph-based form has been configured and 2437 *section_name* is not one of its sections. 2438 2439 Example:: 2440 2441 composition.section_motifs("verse", verse_line, part="lead") 2442 composition.section_motifs("chorus", chorus_line, part="lead") 2443 """ 2444 2445 if not hasattr(value, "length") or not hasattr(value, "slice"): 2446 raise TypeError( 2447 f"section_motifs() binds Motif/Phrase values (.length/.slice) — got {type(value).__name__}" 2448 ) 2449 2450 if ( 2451 self._form_state is not None 2452 and self._form_state._section_bars is not None 2453 and section_name not in self._form_state._section_bars 2454 ): 2455 known = ", ".join(sorted(self._form_state._section_bars)) 2456 raise ValueError( 2457 f"Section '{section_name}' not found in form. " 2458 f"Known sections: {known}" 2459 ) 2460 2461 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
MotiforPhrase(anything exposing.length/.sliceplaces). - 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")
2463 def on_event (self, event_name: str, callback: typing.Callable[..., typing.Any]) -> None: 2464 2465 """ 2466 Register a callback for a sequencer event (e.g., "bar", "start", "stop"). 2467 """ 2468 2469 self._sequencer.on_event(event_name, callback)
Register a callback for a sequencer event (e.g., "bar", "start", "stop").
2476 def hotkeys (self, enabled: bool = True) -> None: 2477 2478 """Enable or disable the global hotkey listener. 2479 2480 Must be called **before** :meth:`play` to take effect. When enabled, a 2481 background thread reads single keystrokes from stdin without requiring 2482 Enter. The ``?`` key is always reserved and lists all active bindings. 2483 2484 Hotkeys have zero impact on playback when disabled — the listener 2485 thread is never started. 2486 2487 Args: 2488 enabled: ``True`` (default) to enable hotkeys; ``False`` to disable. 2489 2490 Example:: 2491 2492 composition.hotkeys() 2493 composition.hotkey("a", lambda: composition.form_jump("chorus")) 2494 composition.play() 2495 """ 2496 2497 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;Falseto disable.
Example::
composition.hotkeys()
composition.hotkey("a", lambda: composition.form_jump("chorus"))
composition.play()
2500 def hotkey ( 2501 self, 2502 key: str, 2503 action: typing.Callable[[], None], 2504 quantize: int = 0, 2505 label: typing.Optional[str] = None, 2506 ) -> None: 2507 2508 """Register a single-key shortcut that fires during playback. 2509 2510 The listener must be enabled first with :meth:`hotkeys`. 2511 2512 Most actions — form jumps, ``composition.data`` writes, and 2513 :meth:`tweak` calls — should use ``quantize=0`` (the default). Their 2514 musical effect is naturally delayed to the next pattern rebuild cycle, 2515 which provides automatic musical quantization without extra configuration. 2516 2517 Use ``quantize=N`` for actions where you want an explicit bar-boundary 2518 guarantee, such as :meth:`mute` / :meth:`unmute`. 2519 2520 The ``?`` key is reserved and cannot be overridden. 2521 2522 Args: 2523 key: A single character trigger (e.g. ``"a"``, ``"1"``, ``" "``). 2524 action: Zero-argument callable to execute. 2525 quantize: ``0`` = execute immediately (default). ``N`` = execute 2526 on the next global bar number divisible by *N*. 2527 label: Display name for the ``?`` help listing. Auto-derived from 2528 the function name or lambda body if omitted. 2529 2530 Raises: 2531 ValueError: If ``key`` is the reserved ``?`` character, or if 2532 ``key`` is not exactly one character. 2533 2534 Example:: 2535 2536 composition.hotkeys() 2537 2538 # Immediate — musical effect happens at next pattern rebuild 2539 composition.hotkey("a", lambda: composition.form_jump("chorus")) 2540 composition.hotkey("1", lambda: composition.data.update({"mode": "chill"})) 2541 2542 # Explicit 4-bar phrase boundary 2543 composition.hotkey("s", lambda: composition.mute("drums"), quantize=4) 2544 2545 # Named function — label is derived automatically 2546 def drop_to_breakdown (): 2547 composition.form_jump("breakdown") 2548 composition.mute("lead") 2549 2550 composition.hotkey("d", drop_to_breakdown) 2551 2552 composition.play() 2553 """ 2554 2555 if len(key) != 1: 2556 raise ValueError(f"hotkey key must be a single character, got {key!r}") 2557 2558 if key == _HOTKEY_RESERVED: 2559 raise ValueError(f"'{_HOTKEY_RESERVED}' is reserved for listing active hotkeys.") 2560 2561 derived = label if label is not None else _derive_label(action) 2562 2563 self._hotkey_bindings[key] = HotkeyBinding( 2564 key = key, 2565 action = action, 2566 quantize = quantize, 2567 label = derived, 2568 )
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:
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()
2571 def form_jump (self, section_name: str) -> None: 2572 2573 """Jump the form to a named section immediately. 2574 2575 Delegates to :meth:`subsequence.form_state.FormState.jump_to`. Only works when the 2576 composition uses graph-mode form (a dict passed to :meth:`form`). 2577 2578 The musical effect is heard at the *next pattern rebuild cycle* — already- 2579 queued MIDI notes are unaffected. This natural delay means ``form_jump`` 2580 is effective without needing explicit quantization. 2581 2582 Args: 2583 section_name: The section to jump to. 2584 2585 Raises: 2586 ValueError: If no form is configured, or the form is not in graph 2587 mode, or *section_name* is unknown. 2588 2589 Example:: 2590 2591 composition.hotkey("c", lambda: composition.form_jump("chorus")) 2592 """ 2593 2594 if self._form_state is None: 2595 raise ValueError("form_jump() requires a form to be configured via composition.form().") 2596 2597 self._form_state.jump_to(section_name) 2598 2599 # The harmony horizon planned against the old section — revoke it. 2600 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"))
2603 def form_next (self, section_name: str) -> None: 2604 2605 """Queue the next section — takes effect when the current section ends. 2606 2607 Unlike :meth:`form_jump`, this does not interrupt the current section. 2608 The queued section replaces the automatically pre-decided next section 2609 and takes effect at the natural section boundary. The performer can 2610 change their mind by calling ``form_next`` again before the boundary. 2611 2612 Delegates to :meth:`subsequence.form_state.FormState.queue_next`. Only works when the 2613 composition uses graph-mode form (a dict passed to :meth:`form`). 2614 2615 Args: 2616 section_name: The section to queue. 2617 2618 Raises: 2619 ValueError: If no form is configured, or the form is not in graph 2620 mode, or *section_name* is unknown. 2621 2622 Example:: 2623 2624 composition.hotkey("c", lambda: composition.form_next("chorus")) 2625 """ 2626 2627 if self._form_state is None: 2628 raise ValueError("form_next() requires a form to be configured via composition.form().") 2629 2630 self._form_state.queue_next(section_name) 2631 2632 # The harmony horizon planned against the old continuation — revoke it. 2633 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"))
2717 @property 2718 def seed (self) -> typing.Optional[int]: 2719 2720 """ 2721 The composition's random seed, or None when unseeded. 2722 2723 When set, every random decision derives deterministically from this 2724 value through named streams (see ``seed_for()``), so the same script 2725 produces the same music on every run. Assign to set it:: 2726 2727 comp.seed = 42 2728 2729 (Formerly the method ``comp.seed(42)`` — the call form is a hard 2730 break per the pre-1.0 rename policy.) 2731 """ 2732 2733 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.)
2767 def seed_for (self, name: str) -> typing.Optional[int]: 2768 2769 """ 2770 Surface the effective derived seed for a named stream. 2771 2772 Works for pattern names and equally for any name you invent for a 2773 standalone value generator (``seed=composition.seed_for("hook")``), 2774 so its randomness keys off the composition seed without sharing any 2775 other consumer's stream. Reflects ``reroll()`` nonces. Returns None 2776 when the composition is unseeded. 2777 2778 Example: 2779 ```python 2780 hook_seed = composition.seed_for("hook") 2781 ``` 2782 """ 2783 2784 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")
2786 def reroll (self, name: str) -> None: 2787 2788 """ 2789 Deal a named stream a fresh deterministic seed — try a new variation. 2790 2791 Bumps the per-name nonce and prints the new effective seed. The 2792 nonce lives only in this process, so the printed seed is what lets a 2793 variation you like survive a restart: note it down, or ``lock()`` the 2794 name to pin it for the session. Refuses on locked names. 2795 2796 Parameters: 2797 name: The stream name — usually a pattern name. 2798 2799 Example: 2800 ```python 2801 comp.reroll("lead") # prints: reroll('lead') -> effective seed ... 2802 ``` 2803 """ 2804 2805 if name in self._locked_names: 2806 print(f"reroll('{name}') refused: '{name}' is locked - call unlock('{name}') first") 2807 return 2808 2809 self._reroll_nonces[name] = self._reroll_nonces.get(name, 0) + 1 2810 effective = self._stream_seed(name) 2811 2812 if effective is None: 2813 print(f"reroll('{name}'): composition has no seed - randomness is unseeded") 2814 return 2815 2816 running = self._running_patterns.get(name) 2817 2818 if running is not None and hasattr(running, "_rng"): 2819 running._rng = random.Random(effective) 2820 2821 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 ...
2823 def lock (self, name: str) -> None: 2824 2825 """ 2826 Pin a named stream: keep its current effective seed and realization. 2827 2828 Engine-side state, so it survives live reload (it is never a builder 2829 swap): a locked pattern re-deals its stream from the same effective 2830 seed on every rebuild, so every cycle realizes identically, and 2831 ``reroll()`` refuses with a message until ``unlock()``. 2832 2833 Parameters: 2834 name: The stream name — usually a pattern name. 2835 """ 2836 2837 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.
2845 def tuning ( 2846 self, 2847 source: typing.Optional[typing.Union[str, "os.PathLike"]] = None, 2848 *, 2849 cents: typing.Optional[typing.List[float]] = None, 2850 ratios: typing.Optional[typing.List[float]] = None, 2851 equal: typing.Optional[int] = None, 2852 bend_range: float = 2.0, 2853 channels: typing.Optional[typing.List[int]] = None, 2854 reference_note: int = 60, 2855 exclude_drums: bool = True, 2856 ) -> None: 2857 2858 """Set a global microtonal tuning for the composition. 2859 2860 The tuning is applied automatically after each pattern rebuild (before 2861 the pattern is scheduled). Drum patterns (those registered with a 2862 ``drum_note_map``) are excluded by default. 2863 2864 Supply exactly one of the source parameters: 2865 2866 - ``source``: path to a Scala ``.scl`` file. 2867 - ``cents``: list of cent offsets for degrees 1..N (degree 0 = 0.0 is implicit). 2868 - ``ratios``: list of frequency ratios (e.g., ``[9/8, 5/4, 4/3, 3/2, 2]``). 2869 - ``equal``: integer for N-tone equal temperament (e.g., ``equal=19``). 2870 2871 For polyphonic parts, supply a ``channels`` pool. Notes are spread 2872 across those MIDI channels so each can carry an independent pitch bend. 2873 The synth must be configured to match ``bend_range`` (its pitch-bend range 2874 setting in semitones). 2875 2876 Parameters: 2877 source: Path to a ``.scl`` file. 2878 cents: Cent offsets for scale degrees 1..N. 2879 ratios: Frequency ratios for scale degrees 1..N. 2880 equal: Number of equal divisions of the period. 2881 bend_range: Synth pitch-bend range in semitones (default ±2). 2882 channels: Channel pool for polyphonic rotation. 2883 reference_note: MIDI note mapped to scale degree 0 (default 60 = C4). 2884 exclude_drums: When True (default), skip patterns that have a 2885 ``drum_note_map`` (they use fixed GM pitches, not tuned ones). 2886 2887 Example: 2888 ```python 2889 # Quarter-comma meantone from a Scala file 2890 comp.tuning("meanquar.scl") 2891 2892 # Just intonation from ratios 2893 comp.tuning(ratios=[9/8, 5/4, 4/3, 3/2, 5/3, 15/8, 2]) 2894 2895 # 19-TET, monophonic 2896 comp.tuning(equal=19, bend_range=2.0) 2897 2898 # 31-TET with channel rotation for polyphony (channels 1-6) 2899 comp.tuning("31tet.scl", channels=[0, 1, 2, 3, 4, 5]) 2900 ``` 2901 """ 2902 import subsequence.tuning as _tuning_mod 2903 2904 given = sum(x is not None for x in [source, cents, ratios, equal]) 2905 if given == 0: 2906 raise ValueError("composition.tuning() requires one of: source, cents, ratios, or equal") 2907 if given > 1: 2908 raise ValueError("composition.tuning() accepts only one source parameter") 2909 2910 if source is not None: 2911 t = _tuning_mod.Tuning.from_scl(source) 2912 elif cents is not None: 2913 t = _tuning_mod.Tuning.from_cents(cents) 2914 elif ratios is not None: 2915 t = _tuning_mod.Tuning.from_ratios(ratios) 2916 else: 2917 t = _tuning_mod.Tuning.equal(equal) # type: ignore[arg-type] 2918 2919 self._tuning = t 2920 self._tuning_bend_range = bend_range 2921 self._tuning_channels = channels 2922 self._tuning_reference_note = reference_note 2923 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.sclfile.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
.sclfile. - 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])
2925 def display (self, enabled: bool = True, grid: bool = False, grid_scale: float = 1.0) -> None: 2926 2927 """ 2928 Enable or disable the live terminal dashboard. 2929 2930 When enabled, Subsequence uses a safe logging handler that allows a 2931 persistent status line (BPM, Key, Bar, Section, Chord) to stay at 2932 the bottom of the terminal while logs scroll above it. 2933 2934 Parameters: 2935 enabled: Whether to show the display (default True). 2936 grid: When True, render an ASCII grid visualisation of all 2937 running patterns above the status line. The grid updates 2938 once per bar, showing which steps have notes and at what 2939 velocity. 2940 grid_scale: Horizontal zoom factor for the grid (default 2941 ``1.0``). Higher values add visual columns between 2942 grid steps, revealing micro-timing from swing and groove. 2943 Snapped to the nearest integer internally for uniform 2944 marker spacing. 2945 """ 2946 2947 if enabled: 2948 self._display = subsequence.display.Display(self, grid=grid, grid_scale=grid_scale) 2949 else: 2950 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.
2952 def web_ui (self, http_host: str = "127.0.0.1", ws_host: str = "127.0.0.1") -> None: 2953 2954 """ 2955 Enable the realtime Web UI Dashboard. 2956 2957 When enabled, Subsequence instantiates a WebSocket server that broadcasts 2958 the current state, signals, and active patterns (with high-res timing and 2959 note data) to any connected browser clients. 2960 2961 Both servers bind to localhost by default. Pass ``http_host`` / ``ws_host`` 2962 (e.g. "0.0.0.0") to opt into LAN exposure — the dashboard is read-only but 2963 broadcasts full composition state, so only do so on a trusted network. 2964 """ 2965 2966 self._web_ui_enabled = True 2967 self._web_ui_http_host = http_host 2968 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.
2970 def midi_input (self, device: str, clock_follow: bool = False, name: typing.Optional[str] = None) -> None: 2971 2972 """ 2973 Configure a MIDI input device for external sync and MIDI messages. 2974 2975 May be called multiple times to register additional input devices. 2976 The first call sets the primary input (device 0). Subsequent calls 2977 add additional input devices (device 1, 2, …). Only one device may 2978 have ``clock_follow=True``. 2979 2980 Parameters: 2981 device: Which MIDI input port to use, matched against 2982 ``mido.get_input_names()``. Treated as a pattern — ``*`` 2983 and ``?`` are wildcards, matching is case-insensitive, and a 2984 name without wildcards is a substring. See 2985 ``Composition.__init__`` for why a pattern like 2986 ``"*Launchpad *:0"`` survives an ALSA client id changing 2987 between runs. Because a wrong input would desynchronise or 2988 mis-record a performance, a pattern matching nothing raises, 2989 and one matching several asks which you meant rather than 2990 guessing. 2991 clock_follow: If True, Subsequence will slave its clock to incoming 2992 MIDI Ticks. It will also follow MIDI Start/Stop/Continue 2993 commands. Only one device can have this enabled at a time. 2994 name: Optional alias for use with ``cc_map(input_device=…)`` and 2995 ``cc_forward(input_device=…)``. When omitted, the raw device 2996 name is used. 2997 2998 Example: 2999 ```python 3000 # Single controller (unchanged usage) 3001 comp.midi_input("Scarlett 2i4", clock_follow=True) 3002 3003 # Multiple controllers 3004 comp.midi_input("Arturia KeyStep", name="keys") 3005 comp.midi_input("Faderfox EC4", name="faders") 3006 ``` 3007 """ 3008 3009 if clock_follow: 3010 if self.is_clock_following: 3011 raise ValueError("Only one input device can be configured to follow external clock (clock_follow=True)") 3012 3013 if self._input_device is None: 3014 # First call: set primary input device (device 0) 3015 self._input_device = device 3016 self._input_device_alias = name 3017 self._clock_follow = clock_follow 3018 else: 3019 # Subsequent calls: register additional input devices 3020 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: Which MIDI input port to use, matched against
mido.get_input_names(). Treated as a pattern —*and?are wildcards, matching is case-insensitive, and a name without wildcards is a substring. SeeComposition.__init__for why a pattern like"*Launchpad *:0"survives an ALSA client id changing between runs. Because a wrong input would desynchronise or mis-record a performance, a pattern matching nothing raises, and one matching several asks which you meant rather than guessing. - 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=…)andcc_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")
3022 def midi_output (self, device: str, name: typing.Optional[str] = None, latency_ms: float = 0.0) -> int: 3023 3024 """ 3025 Register an additional MIDI output device. 3026 3027 The first output device is always the one passed to 3028 ``Composition(output_device=…)`` — that is device 0. 3029 Each call to ``midi_output()`` adds the next device (1, 2, …). 3030 3031 Parameters: 3032 device: Which MIDI output port to add, matched against 3033 ``mido.get_output_names()``. Treated as a pattern — 3034 ``*`` and ``?`` are wildcards, matching is 3035 case-insensitive, and a name without wildcards is a 3036 substring. See ``Composition.__init__`` for the lookup 3037 snippet and why a pattern like ``"*U6MIDI Pro *:0"`` 3038 survives an ALSA client id changing between runs. 3039 name: Optional alias for use with ``pattern(device=…)``, 3040 ``cc_forward(output_device=…)``, etc. When omitted, the raw 3041 device name is used. 3042 latency_ms: Physical output latency of this device in 3043 milliseconds, for delay compensation (default 0.0, must be 3044 non-negative). Set this when the device sounds late (e.g. a 3045 software sampler) so Subsequence delays faster devices to 3046 line everything up. 3047 3048 Returns: 3049 The integer device index assigned (1, 2, 3, …). 3050 3051 Example: 3052 ```python 3053 comp = subsequence.Composition(bpm=120, output_device="MOTU Express") 3054 3055 # Returns 1 — use as device=1 or device="integra" 3056 comp.midi_output("Roland Integra", name="integra") 3057 3058 # A software sampler that sounds 20ms late 3059 comp.midi_output("Subsample", name="sampler", latency_ms=20) 3060 3061 @comp.pattern(channel=1, beats=4, device="integra") 3062 def strings (p): 3063 p.note(60, beat=0) 3064 ``` 3065 """ 3066 3067 if latency_ms < 0: 3068 raise ValueError(f"latency_ms must be non-negative — got {latency_ms}") 3069 3070 idx = 1 + len(self._additional_outputs) # device 0 is always the primary 3071 self._additional_outputs.append(_AdditionalOutput(device=device, alias=name, latency_ms=latency_ms)) 3072 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: Which MIDI output port to add, matched against
mido.get_output_names(). Treated as a pattern —*and?are wildcards, matching is case-insensitive, and a name without wildcards is a substring. SeeComposition.__init__for the lookup snippet and why a pattern like"*U6MIDI Pro *:0"survives an ALSA client id changing between runs. - 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)
3095 def clock_output (self, enabled: bool = True) -> None: 3096 3097 """ 3098 Send MIDI timing clock to connected hardware. 3099 3100 When enabled, Subsequence acts as a MIDI clock master and sends 3101 standard clock messages on the output port: a Start message (0xFA) 3102 when playback begins, a Clock tick (0xF8) on every pulse (24 PPQN), 3103 and a Stop message (0xFC) when playback ends. 3104 3105 This allows hardware synthesizers, drum machines, and effect units to 3106 slave their tempo to Subsequence automatically. 3107 3108 **Note:** Clock output is automatically disabled when ``midi_input()`` 3109 is called with ``clock_follow=True``, to prevent a clock feedback loop. 3110 3111 Parameters: 3112 enabled: Whether to send MIDI clock (default True). 3113 3114 Example: 3115 ```python 3116 comp = subsequence.Composition(bpm=120, output_device="...") 3117 comp.clock_output() # hardware will follow Subsequence tempo 3118 ``` 3119 """ 3120 3121 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
3124 def link (self, quantum: float = 4.0) -> "Composition": 3125 3126 """ 3127 Enable Ableton Link tempo and phase synchronisation. 3128 3129 When enabled, Subsequence joins the local Link session and slaves its 3130 clock to the shared network tempo and beat phase. All other Link-enabled 3131 apps on the same LAN — Ableton Live, iOS synths, other Subsequence 3132 instances — will automatically stay in time. 3133 3134 Playback starts on the next bar boundary aligned to the Link quantum, 3135 so downbeats stay in sync across all participants. 3136 3137 Requires the ``link`` optional extra:: 3138 3139 pip install subsequence[link] 3140 3141 Parameters: 3142 quantum: Beat cycle length. ``4.0`` (default) = one bar in 4/4 time. 3143 Change this if your composition uses a different meter. 3144 3145 Example:: 3146 3147 comp = subsequence.Composition(bpm=120, key="C") 3148 comp.link() # join the Link session 3149 comp.play() 3150 3151 # On another machine / instance: 3152 comp2 = subsequence.Composition(bpm=120) 3153 comp2.link() # tempo and phase will lock to comp 3154 comp2.play() 3155 3156 Note: 3157 ``set_bpm()`` proposes the new tempo to the Link network when Link 3158 is active. The network-authoritative tempo is applied on the next 3159 pulse, so there may be a brief lag before the change is visible. 3160 """ 3161 3162 # Eagerly check that aalink is installed — fail early with a clear message. 3163 subsequence.link_clock._require_aalink() 3164 3165 self._link_quantum = quantum 3166 return self
Enable Ableton Link tempo and phase synchronisation.
When enabled, Subsequence joins the local Link session and slaves its clock to the shared network tempo and beat phase. All other Link-enabled apps on the same LAN — Ableton Live, iOS synths, other Subsequence instances — will automatically stay in time.
Playback starts on the next bar boundary aligned to the Link quantum, so downbeats stay in sync across all participants.
Requires the link optional extra::
pip install subsequence[link]
Arguments:
- quantum: Beat cycle length.
4.0(default) = one bar in 4/4 time. Change this if your composition uses a different meter.
Example::
comp = subsequence.Composition(bpm=120, key="C")
comp.link() # join the Link session
comp.play()
# On another machine / instance:
comp2 = subsequence.Composition(bpm=120)
comp2.link() # tempo and phase will lock to comp
comp2.play()
Note:
set_bpm()proposes the new tempo to the Link network when Link is active. The network-authoritative tempo is applied on the next pulse, so there may be a brief lag before the change is visible.
3169 def cc_map ( 3170 self, 3171 cc: int, 3172 data_key: str, 3173 channel: typing.Optional[int] = None, 3174 min_val: float = 0.0, 3175 max_val: float = 1.0, 3176 input_device: subsequence.midi_utils.DeviceId = None, 3177 ) -> None: 3178 3179 """ 3180 Map an incoming MIDI CC to a ``composition.data`` key. 3181 3182 When the composition receives a CC message on the configured MIDI 3183 input port, the value is scaled from the CC range (0–127) to 3184 *[min_val, max_val]* and stored in ``composition.data[data_key]``. 3185 3186 This lets hardware knobs, faders, and expression pedals control live 3187 parameters without writing any callback code. 3188 3189 **Requires** ``midi_input()`` to be called first to open an input port. 3190 3191 Parameters: 3192 cc: MIDI Control Change number (0–127). 3193 data_key: The ``composition.data`` key to write. 3194 channel: If given, only respond to CC messages on this channel. 3195 Uses the same numbering convention as ``pattern()`` (1-16 3196 by default, or 0-15 with ``zero_indexed_channels=True``). 3197 ``None`` matches any channel (default). 3198 min_val: Scaled minimum — written when CC value is 0 (default 0.0). 3199 max_val: Scaled maximum — written when CC value is 127 (default 1.0). 3200 input_device: Only respond to CC messages from this input device 3201 (index or name). ``None`` responds to any input device (default). 3202 3203 Example: 3204 ```python 3205 comp.midi_input("Arturia KeyStep") 3206 comp.cc_map(74, "filter_cutoff") # knob → 0.0–1.0 3207 comp.cc_map(7, "volume", min_val=0, max_val=127) # volume fader 3208 3209 # Multi-device: only listen to CC 74 from the "faders" controller 3210 comp.cc_map(74, "filter", input_device="faders") 3211 ``` 3212 """ 3213 3214 resolved_channel = self._resolve_channel(channel) if channel is not None else None 3215 3216 self._cc_mappings.append({ 3217 'cc': cc, 3218 'data_key': data_key, 3219 'channel': resolved_channel, 3220 'min_val': min_val, 3221 'max_val': max_val, 3222 'input_device': input_device, # resolved to int index in _run() 3223 })
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.datakey 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 withzero_indexed_channels=True).Nonematches 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).
Noneresponds 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")
3226 def note_input ( 3227 self, 3228 channel: typing.Optional[int] = None, 3229 release_ms: float = 30.0, 3230 latch: bool = False, 3231 input_device: subsequence.midi_utils.DeviceId = None, 3232 ) -> None: 3233 3234 """Track notes held on a MIDI keyboard for live arpeggiation. 3235 3236 Incoming note-on/note-off messages build a live "currently held" set 3237 that any pattern reads via ``p.held_notes()`` — typically fed straight 3238 to ``p.arpeggio()``. The composition still authors the rhythm and 3239 motion; the player's hands supply the pitch set. This is a live 3240 *performance* layer over the deterministic, seeded composition: when 3241 rendering headlessly there is no input, so ``p.held_notes()`` is empty 3242 and seeded output is unchanged. 3243 3244 **Requires** ``midi_input()`` to be called first to open an input port. 3245 3246 Parameters: 3247 channel: If given, only track notes on this channel. Uses the same 3248 numbering convention as ``pattern()`` (1-16 by default, or 0-15 3249 with ``zero_indexed_channels=True``). ``None`` tracks any 3250 channel (default). 3251 release_ms: How long (milliseconds) a released note keeps counting 3252 as held. This smooths the momentary all-keys-up gap during a 3253 hand-position change so the arp does not drop to silence. 3254 Default 30.0; set 0.0 to release instantly. Ignored when 3255 ``latch`` is True. 3256 latch: When True, the held set persists after you lift your hands 3257 until you play a new chord (the first key after every key is up 3258 replaces it) — like a hardware arp's latch. 3259 input_device: Only track notes from this input device (index or 3260 name). ``None`` tracks any input device (default). 3261 3262 Example: 3263 ```python 3264 comp.midi_input("Arturia KeyStep") 3265 comp.note_input(channel=1, release_ms=30) 3266 3267 @comp.pattern(channel=6, beats=4) 3268 def arp (p): 3269 p.arpeggio(p.held_notes(), direction="up") # rests when silent 3270 ``` 3271 """ 3272 3273 if self._note_input is not None: 3274 raise RuntimeError("only one note_input source is supported — named multi-source is not yet available") 3275 3276 resolved_channel = self._resolve_channel(channel) if channel is not None else None 3277 3278 self._note_input = { 3279 'channel': resolved_channel, 3280 'release_ms': release_ms, 3281 'latch': latch, 3282 'input_device': input_device, # resolved to int index in _run() 3283 }
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 withzero_indexed_channels=True).Nonetracks 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
latchis 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).
Nonetracks 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
3347 def cc_forward ( 3348 self, 3349 cc: int, 3350 output: typing.Union[str, typing.Callable], 3351 *, 3352 channel: typing.Optional[int] = None, 3353 output_channel: typing.Optional[int] = None, 3354 mode: str = "instant", 3355 input_device: subsequence.midi_utils.DeviceId = None, 3356 output_device: subsequence.midi_utils.DeviceId = None, 3357 ) -> None: 3358 3359 """ 3360 Forward an incoming MIDI CC to the MIDI output in real-time. 3361 3362 Unlike ``cc_map()`` which writes incoming CC values to ``composition.data`` 3363 for use at pattern rebuild time, ``cc_forward()`` routes the signal 3364 directly to the MIDI output — bypassing the pattern cycle entirely. 3365 3366 Both ``cc_map()`` and ``cc_forward()`` may be registered for the same CC 3367 number; they operate independently. 3368 3369 Parameters: 3370 cc: Incoming CC number to listen for (0–127). 3371 output: What to send. Either a **preset string**: 3372 3373 - ``"cc"`` — identity forward, same CC number and value. 3374 - ``"cc:N"`` — forward as CC number N (e.g. ``"cc:74"``). 3375 - ``"pitchwheel"`` — scale 0–127 to -8192..8191 and send as pitch bend. 3376 3377 Or a **callable** with signature 3378 ``(value: int, channel: int) -> Optional[mido.Message]``. 3379 Return a fully formed ``mido.Message`` to send, or ``None`` to suppress. 3380 ``channel`` is 0-indexed (the incoming channel). 3381 channel: If given, only respond to CC messages on this channel. 3382 Uses the same numbering convention as ``cc_map()``. 3383 ``None`` matches any channel (default). 3384 output_channel: Override the output channel. ``None`` uses the 3385 incoming channel. Uses the same numbering convention as ``pattern()``. 3386 input_device: Only respond to CC from this input device — an index, 3387 a registered name, or ``None`` for any input (default), the 3388 same convention as ``cc_map()``. 3389 output_device: Send to this output device — an index, a registered 3390 name, or ``None`` for the primary output (default). 3391 mode: Dispatch mode: 3392 3393 - ``"instant"`` *(default)* — send immediately on the MIDI input 3394 callback thread. Lowest latency (~1–5 ms). Instant forwards are 3395 **not** recorded when recording is enabled. 3396 - ``"queued"`` — inject into the sequencer event queue and send at 3397 the next pulse boundary (~0–20 ms at 120 BPM). Queued forwards 3398 **are** recorded when recording is enabled. 3399 3400 Example: 3401 ```python 3402 comp.midi_input("Arturia KeyStep") 3403 3404 # CC 1 → CC 1 (identity, instant) 3405 comp.cc_forward(1, "cc") 3406 3407 # CC 1 → pitch bend on channel 1, queued (recordable) 3408 comp.cc_forward(1, "pitchwheel", output_channel=1, mode="queued") 3409 3410 # CC 1 → CC 74, custom channel 3411 comp.cc_forward(1, "cc:74", output_channel=2) 3412 3413 # Custom transform — remap CC range 0–127 to CC 74 range 40–100 3414 import subsequence.midi as midi 3415 comp.cc_forward(1, lambda v, ch: midi.cc(74, int(v / 127 * 60) + 40, channel=ch)) 3416 3417 # Forward AND map to data simultaneously — both active on the same CC 3418 comp.cc_map(1, "mod_wheel") 3419 comp.cc_forward(1, "cc:74") 3420 ``` 3421 """ 3422 3423 if not 0 <= cc <= 127: 3424 raise ValueError(f"cc_forward(): cc {cc} out of range 0–127") 3425 3426 if mode not in ('instant', 'queued'): 3427 raise ValueError(f"cc_forward(): mode must be 'instant' or 'queued', got '{mode}'") 3428 3429 resolved_in_channel = self._resolve_channel(channel) if channel is not None else None 3430 resolved_out_channel = self._resolve_channel(output_channel) if output_channel is not None else None 3431 3432 transform = self._make_cc_forward_transform(output, cc, resolved_out_channel) 3433 3434 self._cc_forwards.append({ 3435 'cc': cc, 3436 'channel': resolved_in_channel, 3437 'output_channel': resolved_out_channel, 3438 'mode': mode, 3439 'transform': transform, 3440 'input_device': input_device, # resolved to int index in _run() 3441 'output_device': output_device, # resolved to int index in _run() 3442 })
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 formedmido.Messageto send, orNoneto suppress.channelis 0-indexed (the incoming channel).- channel: If given, only respond to CC messages on this channel.
Uses the same numbering convention as
cc_map().Nonematches any channel (default). - output_channel: Override the output channel.
Noneuses the incoming channel. Uses the same numbering convention aspattern(). - input_device: Only respond to CC from this input device — an index,
a registered name, or
Nonefor any input (default), the same convention ascc_map(). - output_device: Send to this output device — an index, a registered
name, or
Nonefor the primary output (default). 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")
3445 def live (self, port: int = 5555) -> None: 3446 3447 """ 3448 Enable the live coding eval server. 3449 3450 This allows you to connect to a running composition using the 3451 ``subsequence.live_client`` REPL and hot-swap pattern code or 3452 modify variables in real-time. 3453 3454 Security: 3455 The server executes arbitrary Python in this process — it is **not** a 3456 sandbox. It binds to localhost only and is opt-in, but any process on 3457 the same machine that can reach the port gains full code execution here. 3458 Do not enable it on shared or multi-user hosts, and never expose the 3459 port to a network. 3460 3461 Parameters: 3462 port: The TCP port to listen on (default 5555). 3463 """ 3464 3465 self._live_server = subsequence.live_server.LiveServer(self, port=port) 3466 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).
3468 def watch (self, path: typing.Union[str, pathlib.Path], poll_interval: float = 0.25) -> None: 3469 3470 """Watch a Python file and reload it into the composition on every save. 3471 3472 The watched file is exec'd into a namespace with ``composition`` and 3473 ``subsequence`` available. ``@composition.pattern`` decorators inside 3474 the file hot-swap their corresponding running patterns in place; 3475 patterns whose function bodies have been deleted from the file are 3476 unregistered automatically on the next reload (notes stopped, 3477 removed from the running-pattern set). 3478 3479 An **initial synchronous load** happens here — if the file has a 3480 ``SyntaxError`` or doesn't exist at this moment, the exception 3481 propagates so the user knows immediately. Subsequent reloads 3482 happen on the composition's event loop and tolerate transient 3483 errors (logged, skipped). 3484 3485 Call BEFORE ``composition.play()``. Reloads happen on the 3486 composition's event loop, so all mutations are thread-safe. 3487 3488 See the "Live coding via file watching" section of the README for 3489 the recommended wrapper-script + live-file split. 3490 3491 Parameters: 3492 path: Path to the Python file to watch. 3493 poll_interval: Seconds between ``mtime`` polls (default 0.25 s). 3494 3495 Example:: 3496 3497 # live_init.py — runs once 3498 composition = subsequence.Composition(bpm=120, key="E") 3499 composition.harmony(style="aeolian_minor") 3500 composition.watch("live_patterns.py") 3501 composition.play() 3502 """ 3503 3504 # Required for the decorator hot-swap path to fire on re-decoration. 3505 self._is_live = True 3506 3507 # Detect the single-file workflow: if watch() is called from inside 3508 # the very file being watched, the outer Python script execution will 3509 # already register the patterns (the decorators sit at module level 3510 # below ``watch(__file__)``). In that case, _load_initial's re-exec 3511 # would double-register every pattern, so skip it. For the two-file 3512 # workflow (path != caller's __file__) the initial exec is essential 3513 # — it's the only way the watched file's patterns ever reach the 3514 # composition. 3515 caller_file = self._caller_module_file() 3516 self_watch = False 3517 if caller_file is not None: 3518 try: 3519 self_watch = pathlib.Path(caller_file).resolve() == pathlib.Path(path).resolve() 3520 except OSError: 3521 self_watch = False 3522 3523 self._live_reloader = subsequence.live_reloader.LiveReloader( 3524 composition = self, 3525 path = path, 3526 poll_interval = poll_interval, 3527 skip_initial_exec = self_watch, 3528 ) 3529 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
mtimepolls (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()
3548 def load_patterns ( 3549 self, 3550 source: str, 3551 source_label: str = "<string>", 3552 ) -> None: 3553 3554 """Compile and apply a pattern-source string to the composition. 3555 3556 Equivalent to one ``watch()`` reload triggered by save, but with the 3557 source presented in-memory rather than on disk. Useful for web / 3558 REST handlers that accept pattern uploads from a trusted contributor, 3559 or for one-shot session loads with no file backing. 3560 3561 Behaviour mirrors ``watch()``: 3562 3563 * The source is exec'd into a fresh namespace with ``composition`` 3564 and ``subsequence`` in scope. 3565 * ``@composition.pattern`` decorators in the source hot-swap their 3566 corresponding running patterns in place. 3567 * Patterns currently running but **not** declared in the source are 3568 unregistered — the source is treated as the full new truth. 3569 * If the composition is already playing, the swap happens on the 3570 event loop thread; the call blocks until it completes. 3571 * If the composition has not yet called ``play()``, the source runs 3572 on the caller's thread; decorators populate ``_pending_patterns`` 3573 and ``play()`` picks them up in the usual way. 3574 3575 Errors are raised so the caller can act on them: 3576 3577 * ``SyntaxError`` if ``source`` fails to compile. 3578 * The exception raised inside ``exec()`` for any runtime error. 3579 * ``RuntimeError`` if called from inside the composition's own 3580 event loop thread (would deadlock — see Threading below). 3581 3582 In either failure case, existing composition state is preserved — 3583 the diff-and-unregister phase is skipped if exec raised, so a 3584 half-broken upload cannot tear down working patterns. 3585 3586 Threading: 3587 Designed to be called from a thread DIFFERENT from the 3588 composition's event loop — typically a web-handler worker. 3589 Cannot be called from inside the loop itself (a pattern 3590 callback, an asyncio task spawned by the composition). From 3591 there, ``await composition._apply_source_async(...)`` directly. 3592 3593 SECURITY WARNING: ``exec()`` is not sandboxed. The source has full 3594 Python access in this process. Only pass source from trusted 3595 senders. The built-in blocklist (``help``, ``input``, ``breakpoint``, 3596 ``exit``, ``quit``) prevents calls that would stall the event loop; 3597 it is not a security boundary. 3598 3599 Parameters: 3600 source: Python source declaring ``@composition.pattern`` 3601 functions. 3602 source_label: Identifier used in compile errors and tracebacks 3603 (appears as the filename in ``SyntaxError`` and ``__file__``- 3604 style traceback lines). Default ``"<string>"``. 3605 """ 3606 3607 # Required for the decorator hot-swap path to fire on re-decoration. 3608 self._is_live = True 3609 3610 # Compile on the caller's thread so SyntaxError comes back fast, 3611 # before any cross-thread scheduling. 3612 compiled = compile(source, source_label, "exec") 3613 namespace = self._build_live_namespace(source_label = source_label) 3614 3615 loop = self._sequencer._event_loop 3616 3617 if loop is not None and loop.is_running(): 3618 3619 # Refuse to deadlock: calling load_patterns() from inside the 3620 # composition's own event loop (e.g. from a pattern callback or 3621 # an asyncio task spawned by the composition) would have us 3622 # block waiting for a coroutine that can only run when this 3623 # thread yields. Tell the caller exactly what to do instead. 3624 try: 3625 current_loop: typing.Optional[asyncio.AbstractEventLoop] = asyncio.get_running_loop() 3626 except RuntimeError: 3627 current_loop = None 3628 3629 if current_loop is loop: 3630 raise RuntimeError( 3631 "load_patterns() cannot be called from inside the composition's " 3632 "event loop thread — it would deadlock waiting for the " 3633 "scheduled coroutine to run on the very thread that's blocked. " 3634 "From a worker thread, call it normally. From an async " 3635 "coroutine already on the loop, " 3636 "`await composition._apply_source_async(compile(source, label, 'exec'), " 3637 "composition._build_live_namespace())` instead." 3638 ) 3639 3640 # Composition is playing — mutation must happen on the loop thread. 3641 # future.result() blocks the caller until the coroutine finishes 3642 # and re-raises any exception it threw. 3643 future = asyncio.run_coroutine_threadsafe( 3644 self._apply_source_async(compiled, namespace, source_key = source_label), 3645 loop = loop, 3646 ) 3647 future.result() 3648 3649 else: 3650 # Pre-play: no event loop yet. Decorators populate 3651 # _pending_patterns; play() graduates them in the usual way. 3652 # Diff-and-unregister is unnecessary here — nothing is running, 3653 # but RECORD what this source declares so a later post-play 3654 # reload under the same label can tear down its deletions. 3655 self._declared_names = set() 3656 exec(compiled, namespace) 3657 self._source_declared[source_label] = set(self._declared_names)
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
compositionandsubsequencein scope. @composition.patterndecorators 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_patternsandplay()picks them up in the usual way.
Errors are raised so the caller can act on them:
SyntaxErrorifsourcefails to compile.- The exception raised inside
exec()for any runtime error. RuntimeErrorif 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.patternfunctions. - source_label: Identifier used in compile errors and tracebacks
(appears as the filename in
SyntaxErrorand__file__- style traceback lines). Default"<string>".
3772 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: 3773 3774 """ 3775 Enable bi-directional Open Sound Control (OSC). 3776 3777 Subsequence will listen for commands (like ``/bpm`` or ``/mute``) and 3778 broadcast its internal state (like ``/chord`` or ``/bar``) over UDP. 3779 3780 Parameters: 3781 receive_port: Port to listen for incoming OSC messages (default 9000). 3782 send_port: Port to send state updates to (default 9001). 3783 send_host: The IP address to send updates to (default "127.0.0.1"). 3784 receive_host: Interface to listen on (default "0.0.0.0" — all 3785 interfaces, so external OSC controllers on the LAN can reach it). 3786 The listener can change tempo, mute patterns, and write data, so on 3787 an untrusted network restrict it with ``receive_host="127.0.0.1"``. 3788 """ 3789 3790 self._osc_server = subsequence.osc.OscServer( 3791 self, 3792 receive_port = receive_port, 3793 send_port = send_port, 3794 send_host = send_host, 3795 receive_host = receive_host 3796 )
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".
3798 def osc_map (self, address: str, handler: typing.Callable) -> None: 3799 3800 """ 3801 Register a custom OSC handler. 3802 3803 Must be called after :meth:`osc` has been configured. 3804 3805 Parameters: 3806 address: OSC address pattern to match (e.g. ``"/my/param"``). 3807 handler: Callable invoked with ``(address, *args)`` when a 3808 matching message arrives. 3809 3810 Example:: 3811 3812 composition.osc() 3813 3814 def on_intensity (address, value): 3815 composition.data["intensity"] = float(value) 3816 3817 composition.osc_map("/intensity", on_intensity) 3818 """ 3819 3820 if self._osc_server is None: 3821 raise RuntimeError("Call composition.osc() before composition.osc_map()") 3822 3823 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)
3825 def set_bpm (self, bpm: float) -> None: 3826 3827 """ 3828 Instantly change the tempo. 3829 3830 Parameters: 3831 bpm: The new tempo in beats per minute. 3832 3833 When Ableton Link is active, this proposes the new tempo to the Link 3834 network instead of applying it locally. The network-authoritative tempo 3835 is picked up on the next pulse. 3836 """ 3837 3838 self._sequencer.set_bpm(bpm) 3839 3840 if not self.is_clock_following and self._link_quantum is None: 3841 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.
3843 def target_bpm (self, bpm: float, bars: int, shape: str = "linear") -> None: 3844 3845 """ 3846 Smoothly ramp the tempo to a target value over a number of bars. 3847 3848 Parameters: 3849 bpm: Target tempo in beats per minute. 3850 bars: Duration of the transition in bars. 3851 shape: Easing curve name. Defaults to ``"linear"``. 3852 ``"ease_in_out"`` or ``"s_curve"`` are recommended for natural- 3853 sounding tempo changes. See :mod:`subsequence.easing` for all 3854 available shapes. 3855 3856 Example: 3857 ```python 3858 # Accelerate to 140 BPM over the next 8 bars with a smooth S-curve 3859 comp.target_bpm(140, bars=8, shape="ease_in_out") 3860 ``` 3861 3862 Note: 3863 Ignored while Ableton Link is active — the shared session tempo is 3864 authoritative. Use ``set_bpm()`` to propose a tempo to the Link network. 3865 """ 3866 3867 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. Seesubsequence.easingfor 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.
3869 def live_info (self) -> typing.Dict[str, typing.Any]: 3870 3871 """ 3872 Return a dictionary containing the current state of the composition. 3873 3874 Includes BPM, key, current bar, active section, current chord, 3875 running patterns, and custom data. 3876 """ 3877 3878 section_info = None 3879 if self._form_state is not None: 3880 section = self._form_state.get_section_info() 3881 if section is not None: 3882 section_info = { 3883 "name": section.name, 3884 "bar": section.bar, 3885 "bars": section.bars, 3886 "progress": section.progress 3887 } 3888 3889 chord_name = None 3890 sounding_chord = self.current_chord() 3891 if sounding_chord is not None: 3892 chord_name = sounding_chord.name() 3893 3894 pattern_list = [] 3895 channel_offset = 0 if self._zero_indexed_channels else 1 3896 for name, pat in self._running_patterns.items(): 3897 pattern_list.append({ 3898 "name": name, 3899 "channel": pat.channel + channel_offset, 3900 "length": pat.length, 3901 "cycle": pat._cycle_count, 3902 "muted": pat._muted, 3903 "tweaks": dict(pat._tweaks) 3904 }) 3905 3906 return { 3907 "bpm": self._sequencer.current_bpm, 3908 "key": self.key, 3909 "bar": self._builder_bar, 3910 "section": section_info, 3911 "chord": chord_name, 3912 "patterns": pattern_list, 3913 "input_device": self._input_device, 3914 "clock_follow": self.is_clock_following, 3915 "data": self.data 3916 }
Return a dictionary containing the current state of the composition.
Includes BPM, key, current bar, active section, current chord, running patterns, and custom data.
3918 def mute (self, name: str) -> None: 3919 3920 """ 3921 Mute a running pattern by name. 3922 3923 The pattern continues to 'run' and increment its cycle count in 3924 the background, but it will not produce any MIDI notes until unmuted. 3925 3926 Parameters: 3927 name: The function name of the pattern to mute. 3928 """ 3929 3930 if name not in self._running_patterns: 3931 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3932 3933 # The performer takes ownership: if a transition's approach window had 3934 # muted this pattern, drop it from that set so the section boundary 3935 # does not silently unmute it ("performer mutes win"). 3936 self._transition_muted.discard(name) 3937 3938 self._running_patterns[name]._muted = True 3939 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.
3941 def unmute (self, name: str) -> None: 3942 3943 """ 3944 Unmute a previously muted pattern. 3945 """ 3946 3947 if name not in self._running_patterns: 3948 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 3949 3950 # Symmetric ownership claim: an explicit unmute means the transition 3951 # machinery should no longer manage this pattern at the boundary. 3952 self._transition_muted.discard(name) 3953 3954 self._running_patterns[name]._muted = False 3955 logger.info(f"Unmuted pattern: {name}")
Unmute a previously muted pattern.
3957 def unregister (self, name: str) -> None: 3958 3959 """Fully remove a running pattern from rotation. 3960 3961 Unlike ``mute()`` (which keeps the pattern alive but silent), 3962 ``unregister()`` tears the pattern down entirely. It sets 3963 ``pattern._removed = True`` so the sequencer's reschedule loop 3964 skips re-adding it on the next pulse; sends ``note_off`` for any 3965 of the pattern's currently-sounding notes on the primary 3966 destination AND on every mirror destination (so drones and 3967 sustaining notes stop immediately); and removes the entry from 3968 ``_running_patterns`` so it no longer appears in ``live_info()``, 3969 the terminal grid, or any other consumer that enumerates running 3970 patterns. 3971 3972 Already-queued events in the sequencer's event queue play out — 3973 note_offs are paired with their note_ons at queue time, so notes 3974 end at their natural duration; only drones rely on the targeted 3975 ``_stop_pattern_notes`` pass. 3976 3977 Idempotent: silently logs a ``debug`` and returns if the pattern 3978 is already absent. Useful from both the live REPL 3979 (``composition.live()``) and the file watcher 3980 (``composition.watch()``), which calls this for any pattern 3981 removed from the watched file between reloads. 3982 3983 Parameters: 3984 name: Function name of the pattern to remove. 3985 """ 3986 3987 if name not in self._running_patterns: 3988 logger.debug(f"unregister() no-op: pattern '{name}' not running") 3989 return 3990 3991 pattern = self._running_patterns[name] 3992 3993 # Mark for removal first so the reschedule loop sees the flag even if 3994 # it fires concurrently with the note-off pass below. 3995 pattern._removed = True 3996 3997 # Stop sustaining notes (including drones) on every destination this 3998 # pattern outputs to. Fire-and-forget across threads via the event 3999 # loop; ``_stop_pattern_notes`` acquires the queue lock internally. 4000 if self._sequencer._event_loop is not None: 4001 asyncio.run_coroutine_threadsafe( 4002 self._sequencer._stop_pattern_notes(pattern), 4003 loop = self._sequencer._event_loop, 4004 ) 4005 4006 def _finalise_removal () -> None: 4007 self._running_patterns.pop(name, None) 4008 4009 # Forget any pending (not-yet-graduated) declaration too, so a 4010 # later live reload cannot resurrect the pattern. 4011 self._pending_patterns = [ 4012 pending for pending in self._pending_patterns 4013 if pending.builder_fn.__name__ != name 4014 ] 4015 4016 logger.info(f"Unregistered pattern: {name}") 4017 4018 # The running-patterns dict is iterated by the display, web UI, and 4019 # reschedule loop on the event loop thread — mutate it there when this 4020 # call arrives from another thread (e.g. the live TCP server). 4021 loop = self._sequencer._event_loop 4022 4023 try: 4024 on_loop = loop is not None and asyncio.get_running_loop() is loop 4025 except RuntimeError: 4026 on_loop = False 4027 4028 if loop is not None and loop.is_running() and not on_loop: 4029 loop.call_soon_threadsafe(_finalise_removal) 4030 else: 4031 _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.
4033 def mirror (self, name: str, device: int, channel: int, drum_note_map: typing.Optional[typing.Dict[str, int]] = None) -> None: 4034 4035 """ 4036 Add a mirror destination to a running pattern. 4037 4038 Every note, CC, pitch bend, NRPN/RPN, program change, SysEx, and drone 4039 event the pattern emits will also be sent to ``(device, channel)``, 4040 starting from the next cycle rebuild. Idempotent on ``(device, channel)`` 4041 — calling with the same destination twice does not double-fan; calling 4042 again with a different ``drum_note_map`` re-points it in place. 4043 4044 Parameters: 4045 name: Function name of the pattern to mirror. 4046 device: Output device index (the integer returned from 4047 ``midi_output()``; 0 = primary device). 4048 channel: MIDI channel using this composition's numbering convention 4049 (1-16 by default; 0-15 if ``zero_indexed_channels=True``). 4050 drum_note_map: Optional per-destination drum map. When set, mirrored 4051 drum hits are re-resolved by name through it, so a named voice 4052 lands on this device's own note number — see the README 4053 "MIDI mirroring" section. 4054 4055 Bandwidth: each mirror adds another full copy of the pattern's events. 4056 See the README "MIDI mirroring" section for the tradeoffs. 4057 """ 4058 4059 if name not in self._running_patterns: 4060 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4061 4062 resolved_channel = self._resolve_channel(channel) 4063 prefix = (device, resolved_channel) 4064 entry: subsequence.pattern.MirrorSpec = prefix if drum_note_map is None else (device, resolved_channel, drum_note_map) 4065 4066 pattern = self._running_patterns[name] 4067 4068 # Mirror-to-self check: comparing the (device, channel) prefix against the 4069 # live pattern's resolved destination. Unlike the decorator path this is 4070 # always concrete. 4071 if prefix == (pattern.device, pattern.channel): 4072 logger.warning( 4073 f"Mirror destination {prefix} matches '{name}'s primary destination " 4074 f"— every event will double-fire on this (device, channel). This is almost " 4075 f"certainly unintended." 4076 ) 4077 4078 # Idempotent on (device, channel): replace any existing entry for the same 4079 # destination (so its map can be re-pointed), else append. 4080 existing_index = next((idx for idx, e in enumerate(pattern.mirrors) if (e[0], e[1]) == prefix), None) 4081 if existing_index is None: 4082 pattern.mirrors.append(entry) 4083 logger.info(f"Mirror added: {name} -> device={device}, channel={resolved_channel}") 4084 elif pattern.mirrors[existing_index] != entry: 4085 pattern.mirrors[existing_index] = entry 4086 logger.info(f"Mirror updated: {name} -> device={device}, channel={resolved_channel}") 4087 else: 4088 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.
4090 def unmirror (self, name: str, device: int, channel: int) -> None: 4091 4092 """ 4093 Remove a single mirror destination from a running pattern. 4094 4095 Matches on ``(device, channel)`` only — any attached ``drum_note_map`` is 4096 ignored. Idempotent: silently does nothing if the destination is not 4097 currently mirrored. The change applies on the next cycle rebuild. 4098 """ 4099 4100 if name not in self._running_patterns: 4101 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4102 4103 resolved_channel = self._resolve_channel(channel) 4104 prefix = (device, resolved_channel) 4105 4106 pattern = self._running_patterns[name] 4107 4108 filtered = [e for e in pattern.mirrors if (e[0], e[1]) != prefix] 4109 if len(filtered) != len(pattern.mirrors): 4110 pattern.mirrors[:] = filtered 4111 logger.info(f"Mirror removed: {name} -> device={device}, channel={resolved_channel}") 4112 else: 4113 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.
4115 def unmirror_all (self, name: str) -> None: 4116 4117 """ 4118 Remove every mirror destination from a running pattern. 4119 """ 4120 4121 if name not in self._running_patterns: 4122 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4123 4124 pattern = self._running_patterns[name] 4125 4126 if pattern.mirrors: 4127 pattern.mirrors.clear() 4128 logger.info(f"All mirrors cleared on pattern: {name}")
Remove every mirror destination from a running pattern.
4130 def tweak (self, name: str, **kwargs: typing.Any) -> None: 4131 4132 """Override parameters for a running pattern. 4133 4134 Values set here are available inside the pattern's builder 4135 function via ``p.param()``. They persist across rebuilds 4136 until explicitly changed or cleared. Changes take effect 4137 on the next rebuild cycle. 4138 4139 Parameters: 4140 name: The function name of the pattern. 4141 ``**kwargs``: Parameter names and their new values. 4142 4143 Example (from the live REPL):: 4144 4145 composition.tweak("bass", pitches=[48, 52, 55, 60]) 4146 """ 4147 4148 if name not in self._running_patterns: 4149 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4150 4151 self._running_patterns[name]._tweaks.update(kwargs) 4152 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])
4154 def clear_tweak (self, name: str, *param_names: str) -> None: 4155 4156 """Remove tweaked parameters from a running pattern. 4157 4158 If no parameter names are given, all tweaks for the pattern 4159 are cleared and every ``p.param()`` call reverts to its 4160 default. 4161 4162 Parameters: 4163 name: The function name of the pattern. 4164 *param_names: Specific parameter names to clear. If 4165 omitted, all tweaks are removed. 4166 """ 4167 4168 if name not in self._running_patterns: 4169 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4170 4171 if not param_names: 4172 self._running_patterns[name]._tweaks.clear() 4173 logger.info(f"Cleared all tweaks for pattern '{name}'") 4174 else: 4175 for param_name in param_names: 4176 self._running_patterns[name]._tweaks.pop(param_name, None) 4177 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.
4179 def get_tweaks (self, name: str) -> typing.Dict[str, typing.Any]: 4180 4181 """Return a copy of the current tweaks for a running pattern. 4182 4183 Parameters: 4184 name: The function name of the pattern. 4185 """ 4186 4187 if name not in self._running_patterns: 4188 raise ValueError(f"Pattern '{name}' not found. Available: {list(self._running_patterns.keys())}") 4189 4190 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.
4192 def schedule (self, fn: typing.Callable, cycle_beats: int, reschedule_lookahead: int = 1, wait_for_initial: bool = False, defer: bool = False) -> None: 4193 4194 """ 4195 Register a custom function to run on a repeating beat-based cycle. 4196 4197 Subsequence automatically runs synchronous functions in a thread pool 4198 so they don't block the timing-critical MIDI clock. Async functions 4199 are run directly on the event loop. 4200 4201 Parameters: 4202 fn: The function to call. 4203 cycle_beats: How often to call it (e.g., 4 = every bar). 4204 reschedule_lookahead: How far in advance to schedule the next call. 4205 wait_for_initial: If True, run the function once during startup 4206 and wait for it to complete before playback begins. This 4207 ensures ``composition.data`` is populated before patterns 4208 first build. Implies ``defer=True`` for the repeating 4209 schedule. 4210 defer: If True, skip the pulse-0 fire and defer the first 4211 repeating call to just before the second cycle boundary. 4212 4213 Raises: 4214 RuntimeError: If called after ``play()`` has started — scheduled 4215 tasks register at startup, so a late registration would be 4216 silently ignored otherwise. 4217 """ 4218 4219 if self._sequencer.running: 4220 raise RuntimeError("schedule() must be called before play() - scheduled tasks register at startup") 4221 4222 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.datais populated before patterns first build. Impliesdefer=Truefor 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.
4224 def form ( 4225 self, 4226 sections: typing.Union[ 4227 "subsequence.forms.Form", 4228 typing.List[typing.Any], 4229 typing.Iterator[typing.Tuple[str, int]], 4230 typing.Dict[str, typing.Tuple[int, typing.Optional[typing.List[typing.Tuple[str, int]]]]] 4231 ], 4232 loop: bool = False, 4233 start: typing.Optional[str] = None, 4234 at_end: str = "stop", 4235 key: typing.Optional[str] = None, 4236 scale: typing.Optional[str] = None, 4237 ) -> None: 4238 4239 """ 4240 Define the structure (sections) of the composition. 4241 4242 You can define form in four ways: 4243 4244 1. **Form value**: a frozen :class:`~subsequence.forms.Form` of 4245 :class:`~subsequence.forms.Section` values — the payload home 4246 (energy, key per section); editable, navigable. 4247 2. **Sequence (List)**: a fixed order of ``(name, bars)`` tuples 4248 or Sections (lists coerce — they are the same form). 4249 3. **Graph (Dict)**: dynamic transitions based on weights. 4250 4. **Generator**: a Python generator that yields ``(name, bars)`` pairs. 4251 4252 Form-value and list forms are **navigable**: ``form_jump()`` and 4253 ``form_next()`` work on them (the jump lands on the next occurrence 4254 of the name, wrapping). 4255 4256 Re-binding ``form()`` during playback takes effect at the next bar — 4257 the clock reads the current form state on every bar, so the new form 4258 advances from there (its first section plays from its first bar). 4259 4260 Parameters: 4261 sections: The form definition (Form, List, Dict, or Generator). 4262 loop: Sugar for ``at_end="loop"``. 4263 start: The section to start with (Graph mode only). 4264 at_end: What happens when a sequence form runs out — 4265 ``"stop"`` (the form finishes and patterns see no section; 4266 default), ``"hold"`` (the final section repeats until 4267 navigated away from), or ``"loop"`` (start over). Graphs 4268 end via their terminal sections instead. 4269 key: A form-level key — the **form tier** of the key-source 4270 chain (``Section.key`` overrides it; it overrides the 4271 composition key). Re-anchors key-relative content for the 4272 whole form. When *sections* is a ``Form`` value carrying its 4273 own ``key``, that value is used unless this argument overrides. 4274 scale: A form-level scale/mode, paired with ``key``. 4275 4276 Example: 4277 ```python 4278 # A simple pop structure 4279 comp.form([ 4280 ("verse", 8), 4281 ("chorus", 8), 4282 ("verse", 8), 4283 ("chorus", 16) 4284 ]) 4285 4286 # The same structure with payloads, held open at the end 4287 S = subsequence.Section 4288 comp.form(subsequence.Form([ 4289 S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), 4290 ]), at_end="hold") 4291 ``` 4292 """ 4293 4294 # Seed FormState at form() time (per-call salt) so build-time walks — 4295 # the frozen clones form_freeze will take — are deterministic without 4296 # play(); the play-time stream is re-dealt name-keyed in _run(). 4297 self._form_count += 1 4298 4299 self._form_state = subsequence.form_state.FormState( 4300 sections, 4301 loop = loop, 4302 start = start, 4303 rng = self._stream(f"form:{self._form_count}"), 4304 at_end = at_end, 4305 ) 4306 4307 # A Form value carries energy payloads — that counts as an energy 4308 # source for the min_energy registration check in _run(). 4309 self._form_has_payload = isinstance(sections, subsequence.forms.Form) or ( 4310 isinstance(sections, list) and any(isinstance(element, subsequence.forms.Section) for element in sections) 4311 ) 4312 4313 # Form-tier key/scale: an explicit argument wins; otherwise a Form 4314 # value's own key/scale seeds the tier. Re-binding the form drops any 4315 # stale per-section resolution cache. 4316 if isinstance(sections, subsequence.forms.Form): 4317 self._form_key = key if key is not None else sections.key 4318 self._form_scale = scale if scale is not None else sections.scale 4319 else: 4320 self._form_key = key 4321 self._form_scale = scale 4322 4323 self._resolved_section_cache = {}
Define the structure (sections) of the composition.
You can define form in four ways:
- Form value: a frozen
~subsequence.forms.Formof~subsequence.forms.Sectionvalues — the payload home (energy, key per section); editable, navigable. - Sequence (List): a fixed order of
(name, bars)tuples or Sections (lists coerce — they are the same form). - Graph (Dict): dynamic transitions based on weights.
- Generator: a Python generator that yields
(name, bars)pairs.
Form-value and list forms are navigable: form_jump() and
form_next() work on them (the jump lands on the next occurrence
of the name, wrapping).
Re-binding form() during playback takes effect at the next bar —
the clock reads the current form state on every bar, so the new form
advances from there (its first section plays from its first bar).
Arguments:
- sections: The form definition (Form, List, Dict, or Generator).
- loop: Sugar for
at_end="loop". - start: The section to start with (Graph mode only).
- at_end: What happens when a sequence form runs out —
"stop"(the form finishes and patterns see no section; default),"hold"(the final section repeats until navigated away from), or"loop"(start over). Graphs end via their terminal sections instead. - key: A form-level key — the form tier of the key-source
chain (
Section.keyoverrides it; it overrides the composition key). Re-anchors key-relative content for the whole form. When sections is aFormvalue carrying its ownkey, that value is used unless this argument overrides. - scale: A form-level scale/mode, paired with
key.
Example:
# A simple pop structure comp.form([ ("verse", 8), ("chorus", 8), ("verse", 8), ("chorus", 16) ]) # The same structure with payloads, held open at the end S = subsequence.Section comp.form(subsequence.Form([ S("verse", 8, energy=0.5), S("chorus", 8, energy=0.9), ]), at_end="hold")
4325 def form_freeze (self, sections: typing.Optional[int] = None) -> "subsequence.forms.Form": 4326 4327 """Freeze the graph form's walk into an editable :class:`~subsequence.forms.Form`. 4328 4329 Walks a **clone** of the live form state — the same RNG state, so the 4330 frozen path is exactly the path the live graph would have played — 4331 and returns it as a Form value: inspect it, edit it 4332 (``path.replace(3, bars=16)``), and rebind it with 4333 ``composition.form(path, at_end=...)``. The live form state is 4334 untouched (rebinding replaces it). 4335 4336 Parameters: 4337 sections: Number of sections to freeze. Without it, the walk 4338 runs until a terminal section; a graph with no terminal 4339 sections requires ``sections=`` explicitly. 4340 4341 Raises: 4342 ValueError: If no graph form is bound (a list form is already a 4343 frozen sequence), the form has already finished, or the walk 4344 cannot terminate. 4345 4346 Example:: 4347 4348 composition.form({...}, start="intro") 4349 path = composition.form_freeze() # the walk, frozen 4350 composition.form(path, at_end="stop") # rebind the editable value 4351 """ 4352 4353 fs = self._form_state 4354 4355 if fs is None or fs._graph is None or fs._section_bars is None: 4356 raise ValueError( 4357 "form_freeze() freezes a graph form's walk — call form() with a dict first " 4358 "(a list form is already a frozen sequence)" 4359 ) 4360 4361 if fs._current is None: 4362 raise ValueError("the form has already finished — nothing left to freeze") 4363 4364 if sections is not None and sections < 1: 4365 raise ValueError("sections must be at least 1") 4366 4367 if sections is None and not fs._terminal_sections: 4368 raise ValueError( 4369 "this graph has no terminal section, so the walk would never end — " 4370 "pass sections=n to bound it" 4371 ) 4372 4373 # Clone the RNG state: the frozen walk reproduces the live form's 4374 # future draws without consuming them. 4375 rng = random.Random() 4376 rng.setstate(fs._rng.getstate()) 4377 4378 walked = [fs._current] 4379 next_name = fs._next_section_name # already decided by the live state 4380 4381 while next_name is not None: 4382 if sections is not None and len(walked) >= sections: 4383 break 4384 if sections is None and len(walked) >= 10000: 4385 raise ValueError( 4386 "form_freeze() walked 10000 sections without reaching a terminal — " 4387 "the terminals look unreachable; pass sections=n to bound the walk" 4388 ) 4389 4390 walked.append(subsequence.forms.Section(name = next_name, bars = fs._section_bars[next_name])) 4391 next_name = None if next_name in fs._terminal_sections else fs._graph.choose_next(next_name, rng) 4392 4393 # Carry the form-tier key/scale onto the frozen value so a freeze → 4394 # rebind round-trip is lossless (an explicit form(key=) on rebind 4395 # still overrides). 4396 return subsequence.forms.Form(walked, key = self._form_key, scale = self._form_scale)
Freeze the graph form's walk into an editable ~subsequence.forms.Form.
Walks a clone of the live form state — the same RNG state, so the
frozen path is exactly the path the live graph would have played —
and returns it as a Form value: inspect it, edit it
(path.replace(3, bars=16)), and rebind it with
composition.form(path, at_end=...). The live form state is
untouched (rebinding replaces it).
Arguments:
- sections: Number of sections to freeze. Without it, the walk
runs until a terminal section; a graph with no terminal
sections requires
sections=explicitly.
Raises:
- ValueError: If no graph form is bound (a list form is already a frozen sequence), the form has already finished, or the walk cannot terminate.
Example::
composition.form({...}, start="intro")
path = composition.form_freeze() # the walk, frozen
composition.form(path, at_end="stop") # rebind the editable value
4398 def energy (self, energies: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]]) -> None: 4399 4400 """Set per-section energy — the arranging dial, as one plain dict. 4401 4402 ``{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)}`` — a float is 4403 the section's level; a ``(start, end)`` tuple interpolates across the 4404 section (a build). Patterns read ``p.energy`` (0.5 when nothing is 4405 configured) and gate themselves, or declare ``min_energy=`` on 4406 ``pattern()`` for automatic muting. 4407 4408 The dict **overrides** any energy payload carried by bound 4409 :class:`~subsequence.forms.Section` values — it is the later, 4410 performance-level dial. Re-calling replaces the whole mapping 4411 (idempotent, live-reload friendly). 4412 4413 Example:: 4414 4415 composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95}) 4416 """ 4417 4418 validated: typing.Dict[str, typing.Union[float, typing.Tuple[float, float]]] = {} 4419 4420 for name, value in energies.items(): 4421 if isinstance(value, tuple): 4422 if len(value) != 2: 4423 raise ValueError(f"energy ramp for {name!r} must be (start, end), got {value!r}") 4424 start_level, end_level = float(value[0]), float(value[1]) 4425 for level in (start_level, end_level): 4426 if not 0.0 <= level <= 1.0: 4427 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4428 validated[name] = (start_level, end_level) 4429 else: 4430 level = float(value) 4431 if not 0.0 <= level <= 1.0: 4432 raise ValueError(f"energy for {name!r} must be 0.0–1.0, got {value!r}") 4433 validated[name] = level 4434 4435 self._energy_map = validated
Set per-section energy — the arranging dial, as one plain dict.
{"verse": 0.5, "chorus": 0.9, "build": (0.3, 1.0)} — a float is
the section's level; a (start, end) tuple interpolates across the
section (a build). Patterns read p.energy (0.5 when nothing is
configured) and gate themselves, or declare min_energy= on
pattern() for automatic muting.
The dict overrides any energy payload carried by bound
~subsequence.forms.Section values — it is the later,
performance-level dial. Re-calling replaces the whole mapping
(idempotent, live-reload friendly).
Example::
composition.energy({"intro": 0.2, "verse": 0.55, "drop": 0.95})
4467 def on_section (self, callback: typing.Callable[..., typing.Any]) -> None: 4468 4469 """Register a callback fired on every section change. 4470 4471 The callback receives the new :class:`~subsequence.form_state.SectionInfo` 4472 (or ``None`` when the form finishes). It fires from the form clock, 4473 one lookahead-beat **early** — in time to affect the new section's 4474 first patterns — and once at play start for the opening section. 4475 4476 Example:: 4477 4478 composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}")) 4479 """ 4480 4481 self.on_event("section", callback)
Register a callback fired on every section change.
The callback receives the new ~subsequence.form_state.SectionInfo
(or None when the form finishes). It fires from the form clock,
one lookahead-beat early — in time to affect the new section's
first patterns — and once at play start for the opening section.
Example::
composition.on_section(lambda info: print(f"now: {info.name if info else 'end'}"))
4483 def transition ( 4484 self, 4485 before: str, 4486 fill: typing.Optional[typing.Any] = None, 4487 channel: typing.Optional[int] = None, 4488 beat: float = 0.0, 4489 mute: typing.Optional[typing.List[str]] = None, 4490 beats: typing.Optional[float] = None, 4491 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4492 device: subsequence.midi_utils.DeviceId = None, 4493 ) -> None: 4494 4495 """Declare boundary material — the automatic fill or mute, one line. 4496 4497 ``before`` names the incoming section (``"chorus"``), or ``"*"`` for 4498 any *different* section (repeats don't fire it). Two actions, 4499 combinable: 4500 4501 - ``fill=`` (+ ``channel=``, ``beat=``): a Motif played in the last 4502 bar before the boundary, starting at ``beat`` of that bar. Drum 4503 names resolve through ``drum_note_map=`` if given, otherwise the 4504 map is borrowed from a registered pattern on the same channel. 4505 - ``mute=`` (+ ``beats=``): pattern names muted over the approach 4506 and unmuted at the boundary. Muting is **bar-granular** (the 4507 existing rule), so ``beats`` rounds up to whole bars. Performer 4508 mutes win: a pattern you muted yourself stays muted. 4509 4510 Transitions stack — call once per rule. Registration is additive 4511 and idempotent per identical rule. 4512 4513 Example:: 4514 4515 composition.transition(before="*", fill=FILL, channel=10, beat=2.0) 4516 composition.transition(before="drop", mute=["pads"], beats=4) 4517 """ 4518 4519 if fill is None and mute is None: 4520 raise ValueError("transition() needs fill= and/or mute= — it declares what happens at the boundary") 4521 4522 if fill is not None: 4523 if channel is None: 4524 raise ValueError("transition(fill=) needs channel= — the fill must land somewhere") 4525 if not hasattr(fill, "events") or not hasattr(fill, "length"): 4526 raise TypeError(f"fill must be a Motif-like value with .events/.length, got {type(fill).__name__}") 4527 4528 if mute is not None and beats is None: 4529 beats = float(self.time_signature[0]) # one bar by default 4530 4531 rule = _Transition( 4532 before = before, 4533 fill = fill, 4534 channel = self._resolve_channel(channel) if channel is not None else None, 4535 beat = float(beat), 4536 mute = list(mute) if mute is not None else None, 4537 beats = beats, 4538 drum_note_map = drum_note_map, 4539 device = device, # resolved at fire time — names aren't known until play() 4540 ) 4541 4542 if rule not in self._transitions: 4543 self._transitions.append(rule)
Declare boundary material — the automatic fill or mute, one line.
before names the incoming section ("chorus"), or "*" for
any different section (repeats don't fire it). Two actions,
combinable:
fill=(+channel=,beat=): a Motif played in the last bar before the boundary, starting atbeatof that bar. Drum names resolve throughdrum_note_map=if given, otherwise the map is borrowed from a registered pattern on the same channel.mute=(+beats=): pattern names muted over the approach and unmuted at the boundary. Muting is bar-granular (the existing rule), sobeatsrounds up to whole bars. Performer mutes win: a pattern you muted yourself stays muted.
Transitions stack — call once per rule. Registration is additive and idempotent per identical rule.
Example::
composition.transition(before="*", fill=FILL, channel=10, beat=2.0)
composition.transition(before="drop", mute=["pads"], beats=4)
4722 def pattern ( 4723 self, 4724 channel: int, 4725 beats: typing.Optional[float] = None, 4726 bars: typing.Optional[float] = None, 4727 steps: typing.Optional[float] = None, 4728 step_duration: typing.Optional[float] = None, 4729 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4730 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4731 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4732 reschedule_lookahead: float = 1, 4733 voice_leading: bool = False, 4734 device: subsequence.midi_utils.DeviceId = None, 4735 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4736 min_energy: typing.Optional[float] = None, 4737 ) -> typing.Callable: 4738 4739 """ 4740 Register a function as a repeating MIDI pattern. 4741 4742 The decorated function will be called once per cycle to 'rebuild' its 4743 content. This allows for generative logic that evolves over time. 4744 4745 Two ways to specify pattern length: 4746 4747 - **Duration mode** (default): use ``beats=`` or ``bars=``. 4748 The grid defaults to sixteenth-note resolution. 4749 - **Step mode**: use ``steps=`` paired with ``step_duration=``. 4750 The grid equals the step count, so ``p.hit_steps()`` indices map 4751 directly to steps. 4752 4753 Parameters: 4754 channel: MIDI channel. By default uses 1-based numbering (1-16). 4755 Set ``zero_indexed_channels=True`` on the ``Composition`` to use 4756 0-based numbering (0-15), matching the raw MIDI protocol, instead. 4757 beats: Duration in beats (quarter notes). ``beats=4`` = 1 bar. 4758 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). ``bars=2`` = 8 beats. 4759 steps: Step count for step mode. Requires ``step_duration=``. 4760 step_duration: Duration of one step in beats (e.g. ``dur.SIXTEENTH``). 4761 Requires ``steps=``. 4762 drum_note_map: Optional mapping for drum instruments. 4763 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4764 Enables string-based CC names in ``p.cc()`` and ``p.cc_ramp()``. 4765 nrpn_name_map: Optional mapping of NRPN parameter names (strings) to 4766 14-bit parameter numbers (0–16383). Enables string-based names 4767 in ``p.nrpn()`` and ``p.nrpn_ramp()`` — typically a 4768 device-specific dictionary (e.g. Sequential Take 5's 4769 ``Osc1FreqFine`` → 9). 4770 reschedule_lookahead: Beats in advance to compute the next cycle. 4771 voice_leading: If True, chords in this pattern will automatically 4772 use inversions that minimize voice movement. 4773 mirrors: Optional list of additional ``(device, channel)`` destinations 4774 to duplicate every event from this pattern onto. Notes, CCs, pitch 4775 bend, NRPN/RPN bursts, program changes, SysEx, and drone events are 4776 all mirrored; OSC events are not (OSC is not bound to a MIDI port). 4777 ``device`` is the integer index returned by ``midi_output()`` (0 = 4778 primary). ``channel`` follows this composition's channel-numbering 4779 convention. See also ``mirror()`` / ``unmirror()`` for live toggling. 4780 min_energy: Automatic energy gating — the pattern is silent while 4781 the current section's energy (``composition.energy()`` dict, 4782 or the bound Section payload) is below this threshold. 4783 Composes with ``mute()``: a performer mute always wins. 4784 4785 Example: 4786 ```python 4787 @comp.pattern(channel=1, beats=4) 4788 def chords (p): 4789 p.chord([60, 64, 67], beat=0, velocity=80, duration=3.9) 4790 4791 @comp.pattern(channel=1, bars=2) 4792 def long_phrase (p): 4793 ... 4794 4795 @comp.pattern(channel=1, steps=6, step_duration=dur.SIXTEENTH) 4796 def riff (p): 4797 p.sequence(steps=[0, 1, 3, 5], pitches=60) 4798 ``` 4799 """ 4800 4801 channel = self._resolve_channel(channel) 4802 4803 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4804 4805 # Resolve device string name to index if possible now; otherwise store 4806 # the raw DeviceId and resolve it in _run() once all devices are open. 4807 resolved_device: subsequence.midi_utils.DeviceId = device 4808 4809 # Mirror-to-self check is only reliable when the primary device is a 4810 # concrete integer at decoration time. ``None`` resolves to device 0 4811 # downstream, so we treat it as 0 here too. Strings are deferred to 4812 # ``_run()`` and we skip the check for them. 4813 primary: typing.Optional[typing.Tuple[int, int]] 4814 if isinstance(resolved_device, str): 4815 primary = None 4816 else: 4817 primary = (resolved_device if resolved_device is not None else 0, channel) 4818 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4819 4820 def decorator (fn: typing.Callable) -> typing.Callable: 4821 4822 """ 4823 Wrap the builder function and register it as a pending pattern. 4824 During live sessions, hot-swap an existing pattern's builder instead. 4825 """ 4826 4827 # Record this declaration so the live-reload deletion diff knows the 4828 # pattern is still present in the source (see _apply_source_async). 4829 self._declared_names.add(fn.__name__) 4830 4831 # Hot-swap: if we're live and a pattern with this name exists, replace its builder. 4832 if self._is_live and fn.__name__ in self._running_patterns: 4833 running = self._running_patterns[fn.__name__] 4834 running._builder_fn = fn 4835 running._wants_chord = _fn_has_parameter(fn, "chord") 4836 logger.info(f"Hot-swapped pattern: {fn.__name__}") 4837 return fn 4838 4839 # Names key the seeded stream, mutes, tweaks, and reroll/lock — a 4840 # duplicate means two scheduled copies sharing one stream with 4841 # only one reachable by name. Warn loudly at registration. 4842 if any(existing.builder_fn.__name__ == fn.__name__ for existing in self._pending_patterns): 4843 logger.warning( 4844 f"Duplicate pattern name '{fn.__name__}': both copies will be " 4845 f"scheduled, they share one seeded stream, and only one is " 4846 f"reachable by name — rename one of them." 4847 ) 4848 4849 pending = _PendingPattern( 4850 builder_fn = fn, 4851 channel = channel, # already resolved to 0-indexed 4852 length = beat_length, 4853 default_grid = default_grid, 4854 drum_note_map = drum_note_map, 4855 cc_name_map = cc_name_map, 4856 nrpn_name_map = nrpn_name_map, 4857 reschedule_lookahead = reschedule_lookahead, 4858 voice_leading = voice_leading, 4859 # For int/None: resolve immediately. For str: store 0 as 4860 # placeholder; _resolve_pending_devices() fixes it in _run(). 4861 device = 0 if (resolved_device is None or isinstance(resolved_device, str)) else resolved_device, 4862 raw_device = resolved_device, 4863 mirrors = resolved_mirrors, 4864 min_energy = min_energy, 4865 ) 4866 4867 self._pending_patterns.append(pending) 4868 4869 return fn 4870 4871 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=orbars=. The grid defaults to sixteenth-note resolution. - Step mode: use
steps=paired withstep_duration=. The grid equals the step count, sop.hit_steps()indices map directly to steps.
Arguments:
- channel: MIDI channel. By default uses 1-based numbering (1-16).
Set
zero_indexed_channels=Trueon theCompositionto 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). Requiressteps=. - 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()andp.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()andp.nrpn_ramp()— typically a device-specific dictionary (e.g. Sequential Take 5'sOsc1FreqFine→ 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).deviceis the integer index returned bymidi_output()(0 = primary).channelfollows this composition's channel-numbering convention. See alsomirror()/unmirror()for live toggling. - min_energy: Automatic energy gating — the pattern is silent while
the current section's energy (
composition.energy()dict, or the bound Section payload) is below this threshold. Composes withmute(): a performer mute always wins.
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)
4873 def layer ( 4874 self, 4875 *builder_fns: typing.Callable, 4876 channel: int, 4877 beats: typing.Optional[float] = None, 4878 bars: typing.Optional[float] = None, 4879 steps: typing.Optional[float] = None, 4880 step_duration: typing.Optional[float] = None, 4881 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 4882 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 4883 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 4884 reschedule_lookahead: float = 1, 4885 voice_leading: bool = False, 4886 device: subsequence.midi_utils.DeviceId = None, 4887 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 4888 ) -> None: 4889 4890 """ 4891 Combine multiple functions into a single MIDI pattern. 4892 4893 This is useful for composing complex patterns out of reusable 4894 building blocks (e.g., a 'kick' function and a 'snare' function). 4895 4896 See ``pattern()`` for the full description of ``beats``, ``bars``, 4897 ``steps``, and ``step_duration``. 4898 4899 Parameters: 4900 builder_fns: One or more pattern builder functions. 4901 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 4902 beats: Duration in beats (quarter notes). 4903 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 4904 steps: Step count for step mode. Requires ``step_duration=``. 4905 step_duration: Duration of one step in beats. Requires ``steps=``. 4906 drum_note_map: Optional mapping for drum instruments. 4907 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 4908 nrpn_name_map: Optional mapping of NRPN parameter names to 14-bit 4909 parameter numbers. 4910 reschedule_lookahead: Beats in advance to compute the next cycle. 4911 voice_leading: If True, chords use smooth voice leading. 4912 mirrors: Optional list of additional ``(device, channel)`` destinations 4913 to duplicate every event onto. See ``pattern()`` for details. 4914 """ 4915 4916 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, beats_per_bar=self.time_signature[0]) 4917 4918 # Resolve channel up-front so the mirror-to-self check has the canonical 4919 # primary form to compare against. 4920 resolved_channel = self._resolve_channel(channel) 4921 4922 # See pattern() for the same comment about None / str handling. 4923 primary: typing.Optional[typing.Tuple[int, int]] 4924 if isinstance(device, str): 4925 primary = None 4926 else: 4927 primary = (device if device is not None else 0, resolved_channel) 4928 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 4929 4930 wants_chord = any(_fn_has_parameter(fn, "chord") for fn in builder_fns) 4931 4932 if wants_chord: 4933 4934 def merged_builder (p: subsequence.pattern_builder.PatternBuilder, chord: _InjectedChord) -> None: 4935 4936 for fn in builder_fns: 4937 if _fn_has_parameter(fn, "chord"): 4938 fn(p, chord) 4939 else: 4940 fn(p) 4941 4942 else: 4943 4944 def merged_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: # type: ignore[misc] 4945 4946 for fn in builder_fns: 4947 fn(p) 4948 4949 # Give the merged builder a stable, unique name derived from its 4950 # components so multiple layer() calls don't all register under 4951 # "merged_builder" and collide in _running_patterns (which made 4952 # mute/tweak/unregister/live_info reach only the LAST layer). "+" can't 4953 # appear in a Python identifier, so this never clashes with a real 4954 # pattern function's name. 4955 base_name = ("+".join(fn.__name__ for fn in builder_fns) or "layer") + f"@ch{resolved_channel}" 4956 merged_name = base_name 4957 suffix = 2 4958 4959 # Two layers with the same components (e.g. on different saves of a 4960 # live file) must map to the same names pass-over-pass, while two 4961 # DIFFERENT layers sharing components in one pass must not collide. 4962 while merged_name in self._declared_names: 4963 merged_name = f"{base_name}#{suffix}" 4964 suffix += 1 4965 4966 merged_builder.__name__ = merged_name 4967 4968 # Record the declaration for the live-reload deletion diff, and hot-swap 4969 # in place when this layer is already running so a reload picks up edits 4970 # to the component functions without losing the pattern's cycle count, 4971 # tweaks, or mirrors (mirrors the pattern() decorator's hot-swap). 4972 self._declared_names.add(merged_builder.__name__) 4973 4974 if self._is_live and merged_builder.__name__ in self._running_patterns: 4975 running = self._running_patterns[merged_builder.__name__] 4976 running._builder_fn = merged_builder 4977 running._wants_chord = wants_chord 4978 logger.info(f"Hot-swapped layer: {merged_builder.__name__}") 4979 return 4980 4981 pending = _PendingPattern( 4982 builder_fn = merged_builder, 4983 channel = resolved_channel, # already resolved to 0-indexed above 4984 length = beat_length, 4985 default_grid = default_grid, 4986 drum_note_map = drum_note_map, 4987 cc_name_map = cc_name_map, 4988 nrpn_name_map = nrpn_name_map, 4989 reschedule_lookahead = reschedule_lookahead, 4990 voice_leading = voice_leading, 4991 mirrors = resolved_mirrors, 4992 device = 0 if (device is None or isinstance(device, str)) else device, 4993 raw_device = device, 4994 ) 4995 4996 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. Seepattern()for details.
4998 def chords ( 4999 self, 5000 *, 5001 channel: int, 5002 progression: subsequence.progressions.ProgressionSource, 5003 harmonic_rhythm: subsequence.progressions.HarmonicRhythmSpec, 5004 bars: typing.Optional[float] = None, 5005 beats: typing.Optional[float] = None, 5006 voicing: subsequence.progressions.VoicingSpec = (3, 4), 5007 velocity: typing.Union[int, typing.Tuple[int, int]] = subsequence.constants.velocity.DEFAULT_CHORD_VELOCITY, 5008 detached: typing.Optional[float] = None, 5009 root: int = 60, 5010 key: typing.Optional[str] = None, 5011 seed: typing.Optional[int] = None, 5012 device: subsequence.midi_utils.DeviceId = None, 5013 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5014 ) -> subsequence.progressions.Progression: 5015 5016 """Declare a self-contained chord part: a progression at a chosen harmonic rhythm. 5017 5018 The one-call form of ``p.progression()`` — it registers a pattern on 5019 *channel* that plays *progression* across *bars* (or *beats*), each chord 5020 lasting a length drawn from *harmonic_rhythm* (the musical term for how often 5021 the chords change). It needs no ``composition.harmony()`` call and, with an 5022 explicit chord list or a ``key=``, no composition key either — so a 5023 drums-plus-one-chord-part sketch stays simple. 5024 5025 The progression is realised once, up front, and the same timeline plays every 5026 cycle (a stable phrase). That timeline is returned so you can see exactly what 5027 was chosen — ``print(comp.chords(...))``. 5028 5029 Parameters: 5030 channel: MIDI channel for the chord part. 5031 progression: A chord-graph style name to generate from, or an explicit list 5032 of chords (``Chord`` objects or names like ``["Cm7", "Dbmaj7"]``). 5033 harmonic_rhythm: How long each chord lasts — a number, a list of lengths, 5034 or ``between(low, high, step=...)``. See ``p.progression()``. 5035 bars / beats: Length of the part (defaults to 4 beats if neither is given). ``bars`` uses the 5036 composition's time signature. 5037 voicing: Notes per chord — an int, or a ``(low, high)`` range (e.g. ``(3, 4)``). 5038 velocity: MIDI velocity, or a ``(low, high)`` tuple for per-voice humanisation. 5039 detached: Beats of silence before each next chord (``duration = length - detached``). 5040 root: MIDI root the voicings are centred on (e.g. 48 = C3). 5041 key: Key for a generated progression; defaults to the composition key. 5042 seed: Seed for the (otherwise fixed) realisation; defaults to the 5043 composition seed, so the part is reproducible. 5044 device: Optional output-device override. 5045 mirrors: Optional additional ``(device, channel)`` destinations. 5046 5047 Returns: 5048 The realised :class:`~subsequence.progressions.Progression`. 5049 """ 5050 5051 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5052 resolved_channel = self._resolve_channel(channel) 5053 resolved_key = key if key is not None else self.key 5054 5055 rng = random.Random(seed if seed is not None else self._seed) 5056 timeline = subsequence.progressions.realize( 5057 source = progression, 5058 harmonic_rhythm = harmonic_rhythm, 5059 key = resolved_key, 5060 length = beat_length, 5061 rng = rng, 5062 scale = self.scale or "ionian", 5063 ) 5064 5065 captured_root = root 5066 captured_velocity = velocity 5067 captured_detached = detached 5068 captured_voicing = voicing 5069 5070 def chords_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5071 5072 """Replay the realised timeline as block chords each cycle (voicing per chord).""" 5073 5074 for chord, start, length in timeline: 5075 ring = length - captured_detached if (captured_detached and captured_detached < length) else length 5076 voices = subsequence.progressions.resolve_voices(captured_voicing, p.rng) 5077 p.chord(chord, root=captured_root, beat=start, duration=ring, count=voices, velocity=captured_velocity) 5078 5079 # Unique, stable name so multiple chord parts don't collide in 5080 # _running_patterns — including two parts on the SAME channel, which 5081 # get a deterministic #2/#3 suffix in declaration order. 5082 base_name = f"chords@ch{resolved_channel}" 5083 chords_name = base_name 5084 suffix = 2 5085 5086 while chords_name in self._declared_names: 5087 chords_name = f"{base_name}#{suffix}" 5088 suffix += 1 5089 5090 chords_builder.__name__ = chords_name 5091 self._declared_names.add(chords_name) 5092 5093 primary: typing.Optional[typing.Tuple[int, int]] 5094 if isinstance(device, str): 5095 primary = None 5096 else: 5097 primary = (device if device is not None else 0, resolved_channel) 5098 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5099 5100 if self._is_live and chords_builder.__name__ in self._running_patterns: 5101 running = self._running_patterns[chords_builder.__name__] 5102 running._builder_fn = chords_builder 5103 running._wants_chord = False 5104 logger.info(f"Hot-swapped chords: {chords_builder.__name__}") 5105 return timeline 5106 5107 pending = _PendingPattern( 5108 builder_fn = chords_builder, 5109 channel = resolved_channel, 5110 length = beat_length, 5111 default_grid = default_grid, 5112 drum_note_map = None, 5113 reschedule_lookahead = 1, 5114 voice_leading = False, 5115 mirrors = resolved_mirrors, 5116 device = 0 if (device is None or isinstance(device, str)) else device, 5117 raw_device = device, 5118 ) 5119 self._pending_patterns.append(pending) 5120 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 (
Chordobjects or names like["Cm7", "Dbmaj7"]). - harmonic_rhythm: How long each chord lasts — a number, a list of lengths,
or
between(low, high, step=...). Seep.progression(). - bars / beats: Length of the part (defaults to 4 beats if neither is given).
barsuses 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.
5122 def phrase_part ( 5123 self, 5124 *, 5125 channel: int, 5126 part: typing.Optional[str] = None, 5127 root: int = 60, 5128 bars: typing.Optional[float] = None, 5129 beats: typing.Optional[float] = None, 5130 velocity: typing.Optional[typing.Union[int, typing.Tuple[int, int]]] = None, 5131 fit: typing.Optional[float] = None, 5132 device: subsequence.midi_utils.DeviceId = None, 5133 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5134 ) -> None: 5135 5136 """Declare a part that plays each section's bound Motif/Phrase. 5137 5138 The one-call consumer for :meth:`section_motifs` — it registers a 5139 pattern on *channel* that walks whatever value is bound to the 5140 current section for *part* (stateless position from the cycle 5141 counter, via ``p.phrase()``). A section with no binding for the 5142 part is **silent** for that part — bind material or don't; no 5143 fallback guessing. 5144 5145 Parameters: 5146 channel: MIDI channel for the part. 5147 part: The part label to read from the registry (``None`` = the 5148 unlabelled binding). 5149 root: Register anchor for degree resolution. 5150 bars / beats: Cycle length of the part (defaults to 4 beats); 5151 the phrase is sliced one cycle window at a time. 5152 velocity: Optional override applied to every note. 5153 fit: Passed through (active with the melody engine stage). 5154 device: Optional output-device override. 5155 mirrors: Optional additional ``(device, channel)`` destinations. 5156 5157 Example:: 5158 5159 composition.section_motifs("verse", verse_line, part="lead") 5160 composition.section_motifs("chorus", chorus_line, part="lead") 5161 composition.phrase_part(channel=4, part="lead", root=72, bars=2) 5162 """ 5163 5164 beat_length, default_grid = self._resolve_length(beats, bars, None, None, beats_per_bar=self.time_signature[0]) 5165 resolved_channel = self._resolve_channel(channel) 5166 5167 captured_part = part 5168 captured_root = root 5169 captured_velocity = velocity 5170 captured_fit = fit 5171 5172 def phrase_builder (p: subsequence.pattern_builder.PatternBuilder) -> None: 5173 5174 """Walk the current section's bound value (silent when unbound).""" 5175 5176 value = p.section_motif(captured_part) 5177 5178 if value is None: 5179 return # unbound section: silence for this part, by design 5180 5181 p.phrase(value, root=captured_root, velocity=captured_velocity, fit=captured_fit) 5182 5183 # Unique, stable name so multiple phrase parts don't collide — 5184 # including two parts on the SAME channel (deterministic #2/#3 5185 # suffixes in declaration order, the chords() convention). 5186 base_name = f"phrase@{captured_part}@ch{resolved_channel}" if captured_part else f"phrase@ch{resolved_channel}" 5187 phrase_name = base_name 5188 suffix = 2 5189 5190 while phrase_name in self._declared_names: 5191 phrase_name = f"{base_name}#{suffix}" 5192 suffix += 1 5193 5194 phrase_builder.__name__ = phrase_name 5195 self._declared_names.add(phrase_name) 5196 5197 primary: typing.Optional[typing.Tuple[int, int]] 5198 if isinstance(device, str): 5199 primary = None 5200 else: 5201 primary = (device if device is not None else 0, resolved_channel) 5202 resolved_mirrors = self._resolve_mirrors(mirrors, primary=primary) 5203 5204 if self._is_live and phrase_builder.__name__ in self._running_patterns: 5205 running = self._running_patterns[phrase_builder.__name__] 5206 running._builder_fn = phrase_builder 5207 running._wants_chord = False 5208 logger.info(f"Hot-swapped phrase part: {phrase_builder.__name__}") 5209 return 5210 5211 pending = _PendingPattern( 5212 builder_fn = phrase_builder, 5213 channel = resolved_channel, 5214 length = beat_length, 5215 default_grid = default_grid, 5216 drum_note_map = None, 5217 reschedule_lookahead = 1, 5218 voice_leading = False, 5219 mirrors = resolved_mirrors, 5220 device = 0 if (device is None or isinstance(device, str)) else device, 5221 raw_device = device, 5222 ) 5223 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)
5225 def trigger ( 5226 self, 5227 fn: typing.Callable, 5228 channel: int, 5229 beats: typing.Optional[float] = None, 5230 bars: typing.Optional[float] = None, 5231 steps: typing.Optional[float] = None, 5232 step_duration: typing.Optional[float] = None, 5233 quantize: float = 0, 5234 drum_note_map: typing.Optional[typing.Dict[str, int]] = None, 5235 cc_name_map: typing.Optional[typing.Dict[str, int]] = None, 5236 nrpn_name_map: typing.Optional[typing.Dict[str, int]] = None, 5237 chord: bool = False, 5238 device: subsequence.midi_utils.DeviceId = None, 5239 mirrors: typing.Optional[typing.Iterable[subsequence.pattern.MirrorSpec]] = None, 5240 ) -> None: 5241 5242 """ 5243 Trigger a one-shot pattern immediately or on a quantized boundary. 5244 5245 This is useful for real-time response to sensors, OSC messages, or other 5246 external events. The builder function is called immediately with a fresh 5247 PatternBuilder, and the generated events are injected into the queue at 5248 the specified quantize boundary. 5249 5250 The builder function has the same API as a ``@composition.pattern`` 5251 decorated function and can use all PatternBuilder methods: ``p.note()``, 5252 ``p.euclidean()``, ``p.arpeggio()``, and so on. 5253 5254 See ``pattern()`` for the full description of ``beats``, ``bars``, 5255 ``steps``, and ``step_duration``. Default is 1 beat. 5256 5257 Parameters: 5258 fn: The pattern builder function (same signature as ``@comp.pattern``). 5259 channel: MIDI channel (1-16, or 0-15 with ``zero_indexed_channels=True``). 5260 beats: Duration in beats (quarter notes, default 1). 5261 bars: Duration in bars (uses the composition's time signature — 4 beats each in 4/4). 5262 steps: Step count for step mode. Requires ``step_duration=``. 5263 step_duration: Duration of one step in beats. Requires ``steps=``. 5264 quantize: Snap the trigger to a beat boundary: ``0`` = immediate (default), 5265 ``1`` = next beat (quarter note), ``4`` = next bar. Use ``dur.*`` 5266 constants from ``subsequence.constants.durations``. 5267 drum_note_map: Optional drum name mapping for this pattern. 5268 cc_name_map: Optional mapping of CC names to MIDI CC numbers. 5269 nrpn_name_map: Optional mapping of NRPN parameter names to 5270 14-bit parameter numbers. 5271 chord: If ``True``, the builder function receives the current chord as 5272 a second parameter (same as ``@composition.pattern``). 5273 mirrors: Optional list of additional ``(device, channel)`` destinations 5274 to fire this one-shot onto in parallel with the primary destination. 5275 5276 Example: 5277 ```python 5278 # Immediate single note (channels are 1-16 by default) 5279 composition.trigger( 5280 lambda p: p.note(60, beat=0, velocity=100, duration=0.5), 5281 channel=1 5282 ) 5283 5284 # Quantized fill (next bar) — channel 10 is the GM drum channel 5285 import subsequence.constants.durations as dur 5286 composition.trigger( 5287 lambda p: p.euclidean("snare", pulses=7, velocity=90), 5288 channel=10, 5289 drum_note_map=gm_drums.GM_DRUM_MAP, 5290 quantize=dur.WHOLE 5291 ) 5292 5293 # With chord context — the builder receives the chord as a second 5294 # argument when chord=True. 5295 composition.trigger( 5296 lambda p, chord: p.arpeggio(chord.tones(root=60), spacing=dur.SIXTEENTH), 5297 channel=1, 5298 quantize=dur.QUARTER, 5299 chord=True 5300 ) 5301 ``` 5302 """ 5303 5304 # Resolve channel numbering 5305 resolved_channel = self._resolve_channel(channel) 5306 5307 beat_length, default_grid = self._resolve_length(beats, bars, steps, step_duration, default=1.0, beats_per_bar=self.time_signature[0]) 5308 5309 # Resolve device index — for trigger() this is always concrete by call time, 5310 # so the mirror-to-self check has the full primary tuple available. 5311 resolved_device_idx = self._resolve_device_id(device) 5312 resolved_mirrors = self._resolve_mirrors(mirrors, primary=(resolved_device_idx, resolved_channel)) 5313 5314 # Create a temporary Pattern 5315 pattern = subsequence.pattern.Pattern(channel=resolved_channel, length=beat_length, device=resolved_device_idx, mirrors=resolved_mirrors) 5316 5317 # Resolve the section context once: the one-shot inherits the section's 5318 # effective key/scale (so a triggered degree resolves like everywhere 5319 # else) and a harmony view at the current playhead (so ChordTone / 5320 # Approach resolve too). 5321 trigger_section = self._form_state.get_section_info() if self._form_state else None 5322 trigger_key, trigger_scale = self._effective_key_scale(trigger_section) 5323 5324 trigger_harmony: typing.Optional[HarmonyView] = None 5325 if not self._harmony_horizon.is_empty: 5326 trigger_harmony = HarmonyView(self._harmony_horizon, self._sequencer.pulse_count / self._sequencer.pulses_per_beat) 5327 5328 # Create a PatternBuilder 5329 builder = subsequence.pattern_builder.PatternBuilder( 5330 pattern=pattern, 5331 cycle=0, # One-shot patterns don't rebuild, so cycle is always 0 5332 drum_note_map=drum_note_map, 5333 cc_name_map=cc_name_map, 5334 nrpn_name_map=nrpn_name_map, 5335 section=trigger_section, 5336 bar=self._builder_bar, 5337 conductor=self.conductor, 5338 rng=random.Random(), # Fresh random state for each trigger 5339 tweaks={}, 5340 default_grid=default_grid, 5341 data=self.data, 5342 # A one-shot resolves key-relative content against the same 5343 # effective key/scale as the section it fires into (previously 5344 # omitted entirely — degrees raised even in a keyed composition). 5345 key=trigger_key, 5346 scale=trigger_scale, 5347 time_signature=self.time_signature, 5348 held_notes=self._sequencer._held_notes, 5349 harmony=trigger_harmony, 5350 energy=self._current_energy(trigger_section) 5351 ) 5352 5353 # Call the builder function 5354 try: 5355 5356 current_chord = self.current_chord() if chord else None 5357 5358 if current_chord is not None: 5359 injected = _InjectedChord(current_chord, None) # No voice leading for one-shots 5360 fn(builder, injected) 5361 5362 else: 5363 fn(builder) 5364 5365 except Exception: 5366 logger.exception("Error in trigger builder — pattern will be silent") 5367 return 5368 5369 # Calculate the start pulse based on quantize 5370 current_pulse = self._sequencer.pulse_count 5371 pulses_per_beat = subsequence.constants.MIDI_QUARTER_NOTE 5372 5373 if quantize == 0: 5374 # Immediate: use current pulse 5375 start_pulse = current_pulse 5376 5377 else: 5378 # Quantize to the next multiple of (quantize * pulses_per_beat) 5379 quantize_pulses = int(quantize * pulses_per_beat) 5380 start_pulse = ((current_pulse // quantize_pulses) + 1) * quantize_pulses 5381 5382 self._schedule_one_shot(pattern, start_pulse)
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. Usedur.*constants fromsubsequence.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 )
5403 @property 5404 def is_clock_following (self) -> bool: 5405 5406 """True if either the primary or any additional device is following external clock.""" 5407 5408 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.
5411 def play (self) -> None: 5412 5413 """ 5414 Start the composition. 5415 5416 This call blocks until the program is interrupted (e.g., via Ctrl+C). 5417 It initializes the MIDI hardware, launches the background sequencer, 5418 and begins playback. 5419 """ 5420 5421 try: 5422 asyncio.run(self._run()) 5423 5424 except KeyboardInterrupt: 5425 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.
5428 def render (self, bars: typing.Optional[int] = None, filename: str = "render.mid", max_minutes: typing.Optional[float] = 60.0) -> None: 5429 5430 """Render the composition to a MIDI file without real-time playback. 5431 5432 Runs the sequencer as fast as possible (no timing delays) and stops 5433 when the first active limit is reached. The result is saved as a 5434 standard MIDI file that can be imported into any DAW. 5435 5436 All patterns, scheduled callbacks, and harmony logic run exactly as 5437 they would during live playback — BPM transitions, generative fills, 5438 and probabilistic gates all work in render mode. The only difference 5439 is that time is simulated rather than wall-clock driven. 5440 5441 Parameters: 5442 bars: Number of bars to render, or ``None`` for no bar limit 5443 (default ``None``). When both *bars* and *max_minutes* are 5444 active, playback stops at whichever limit is reached first. 5445 filename: Output MIDI filename (default ``"render.mid"``). 5446 max_minutes: Safety cap on the length of rendered MIDI in minutes 5447 (default ``60.0``). Pass ``None`` to disable the time 5448 cap — you must then provide an explicit *bars* value. 5449 5450 Raises: 5451 ValueError: If both *bars* and *max_minutes* are ``None``, which 5452 would produce an infinite render. 5453 5454 Examples: 5455 ```python 5456 # Default: renders up to 60 minutes of MIDI content. 5457 composition.render() 5458 5459 # Render exactly 64 bars (time cap still active as backstop). 5460 composition.render(bars=64, filename="demo.mid") 5461 5462 # Render up to 5 minutes of an infinite generative composition. 5463 composition.render(max_minutes=5, filename="five_min.mid") 5464 5465 # Remove the time cap — must supply bars instead. 5466 composition.render(bars=128, max_minutes=None, filename="long.mid") 5467 ``` 5468 """ 5469 5470 if bars is None and max_minutes is None: 5471 raise ValueError( 5472 "render() requires at least one limit: provide bars=, max_minutes=, or both. " 5473 "Passing both as None would produce an infinite render." 5474 ) 5475 5476 self._sequencer.recording = True 5477 self._sequencer.record_filename = filename 5478 self._sequencer.render_mode = True 5479 self._sequencer.render_bars = bars if bars is not None else 0 5480 self._sequencer.render_max_seconds = max_minutes * 60.0 if max_minutes is not None else None 5481 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
Nonefor no bar limit (defaultNone). 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). PassNoneto 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")
374@dataclasses.dataclass(frozen=True) 375class Motif: 376 377 """ 378 An immutable musical figure: timed note events + control gestures + a length in beats. 379 380 Construct via the classmethods (:meth:`degrees`, :meth:`notes`, 381 :meth:`hits`, :meth:`steps`, :meth:`euclidean`, the control-gesture 382 constructors, or :meth:`from_events`) rather than positionally. 383 ``length`` is explicit — a trailing rest is meaningful. 384 """ 385 386 events: typing.Tuple[MotifEvent, ...] 387 length: float 388 controls: typing.Tuple[ControlEvent, ...] = () 389 fit: typing.Optional[float] = None # placement default for the fit dial; set by generate() 390 391 def __post_init__ (self) -> None: 392 393 """Validate, and normalise both streams to canonical order.""" 394 395 if self.length < 0: 396 raise ValueError(f"Motif length must be non-negative — got {self.length}") 397 398 object.__setattr__(self, "events", tuple(sorted(self.events, key=MotifEvent._sort_key))) 399 object.__setattr__(self, "controls", tuple(sorted(self.controls, key=ControlEvent._sort_key))) 400 401 # ── constructors ──────────────────────────────────────────────────── 402 403 @classmethod 404 def empty (cls) -> "Motif": 405 406 """The empty motif (zero events, zero length) — the identity for ``then``.""" 407 408 return cls(events=(), length=0.0) 409 410 @classmethod 411 def from_events ( 412 cls, 413 events: typing.Iterable[MotifEvent], 414 length: typing.Optional[float] = None, 415 controls: typing.Iterable[ControlEvent] = (), 416 ) -> "Motif": 417 418 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 419 420 events = tuple(events) 421 controls = tuple(controls) 422 423 return cls( 424 events = events, 425 length = _computed_length(events, controls) if length is None else length, 426 controls = controls, 427 ) 428 429 @classmethod 430 def _from_sequence ( 431 cls, 432 pitches: typing.List[PitchSpec], 433 beats: typing.Optional[typing.List[float]], 434 velocities: typing.Any, 435 durations: typing.Any, 436 probabilities: typing.Any, 437 length: typing.Optional[float], 438 ) -> "Motif": 439 440 """Shared core: one event per element, None = rest (slot still advances).""" 441 442 n = len(pitches) 443 onsets = list(beats) if beats is not None else [float(i) for i in range(n)] 444 445 if len(onsets) != n: 446 raise ValueError(f"beats has {len(onsets)} onsets for {n} elements — parallel lists must match") 447 448 velocity_list = _expand("velocities", velocities, n) 449 duration_list = _expand("durations", durations, n) 450 probability_list = _expand("probabilities", probabilities, n) 451 452 events = tuple( 453 MotifEvent( 454 beat = float(onsets[i]), 455 pitch = pitches[i], 456 velocity = velocity_list[i], 457 duration = float(duration_list[i]), 458 probability = float(probability_list[i]), 459 ) 460 for i in range(n) 461 if pitches[i] is not None 462 ) 463 464 return cls( 465 events = events, 466 length = _computed_length(events, ()) if length is None else float(length), 467 ) 468 469 @classmethod 470 def degrees ( 471 cls, 472 degrees: typing.List[typing.Union[int, Degree, None]], 473 beats: typing.Optional[typing.List[float]] = None, 474 velocities: typing.Any = _DEFAULT_VELOCITY, 475 durations: typing.Any = 1.0, 476 probabilities: typing.Any = 1.0, 477 length: typing.Optional[float] = None, 478 ) -> "Motif": 479 480 """ 481 A melody written as 1-based scale degrees, one per beat by default. 482 483 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 484 rest (the beat slot still advances), or :class:`Degree` for octave/ 485 chromatic detail. Resolved against key + scale at placement. 486 Durations default to a full beat (each note holds its slot). 487 """ 488 489 converted: typing.List[PitchSpec] = [] 490 491 for element in degrees: 492 if isinstance(element, int): 493 if element > _MAX_PLAUSIBLE_DEGREE: 494 raise ValueError( 495 f"Degree {element} is implausibly large — scale degrees are 1-based " 496 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 497 ) 498 converted.append(Degree(element)) 499 elif isinstance(element, Degree) or element is None: 500 converted.append(element) 501 else: 502 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 503 504 return cls._from_sequence(converted, beats, velocities, durations, probabilities, length) 505 506 @classmethod 507 def notes ( 508 cls, 509 notes: typing.List[typing.Union[int, None]], 510 beats: typing.Optional[typing.List[float]] = None, 511 velocities: typing.Any = _DEFAULT_VELOCITY, 512 durations: typing.Any = 1.0, 513 probabilities: typing.Any = 1.0, 514 length: typing.Optional[float] = None, 515 ) -> "Motif": 516 517 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 518 519 for element in notes: 520 # bool is a subclass of int, but True/False are never MIDI notes. 521 if isinstance(element, bool) or not (isinstance(element, int) or element is None): 522 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 523 524 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length) 525 526 @classmethod 527 def hits ( 528 cls, 529 pitch: typing.Union[int, str], 530 beats: typing.List[float], 531 length: typing.Optional[float] = None, 532 velocities: typing.Any = _DEFAULT_VELOCITY, 533 durations: typing.Any = 0.1, 534 probabilities: typing.Any = 1.0, 535 ) -> "Motif": 536 537 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 538 539 return cls._from_sequence([pitch] * len(beats), list(beats), velocities, durations, probabilities, length) 540 541 @classmethod 542 def steps ( 543 cls, 544 steps: typing.List[int], 545 pitches: typing.Any, 546 velocities: typing.Any = _DEFAULT_VELOCITY, 547 durations: typing.Any = 0.1, 548 probabilities: typing.Any = 1.0, 549 step_duration: float = 0.25, 550 length: typing.Optional[float] = None, 551 ) -> "Motif": 552 553 """ 554 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 555 grid indices (sixteenths by default), ``pitches`` a scalar or 556 parallel list of MIDI ints or drum names. 557 """ 558 559 n = len(steps) 560 pitch_list = _expand("pitches", pitches, n) 561 onsets = [s * step_duration for s in steps] 562 563 if length is None and n: 564 length = float(math.ceil((max(steps) + 1) * step_duration)) 565 566 return cls._from_sequence(pitch_list, onsets, velocities, durations, probabilities, length) 567 568 @classmethod 569 def euclidean ( 570 cls, 571 pulses: int, 572 steps: int, 573 pitch: typing.Union[int, str], 574 length: float = 4.0, 575 velocities: typing.Any = _DEFAULT_VELOCITY, 576 durations: typing.Any = 0.1, 577 probabilities: typing.Any = 1.0, 578 ) -> "Motif": 579 580 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 581 582 # bool is a subclass of int, but True/False are never MIDI notes. 583 if isinstance(pitch, bool): 584 raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}") 585 586 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 587 # It validates pulses first, so pulses > steps still raises clearly. 588 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 589 590 if steps <= 0: 591 # A grid of zero steps holds no onsets — an empty motif of the given 592 # length, matching pulses=0 on a real grid (the empty-input policy). 593 return cls._from_sequence([], [], velocities, durations, probabilities, length) 594 595 step_duration = length / steps 596 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 597 598 return cls._from_sequence( 599 [pitch] * len(onsets), 600 onsets, 601 velocities, durations, probabilities, length, 602 ) 603 604 @classmethod 605 def preset ( 606 cls, 607 name: str, 608 pitch: typing.Optional[typing.Union[int, str]] = None, 609 length: float = 4.0, 610 velocities: typing.Any = _DEFAULT_VELOCITY, 611 durations: typing.Any = 0.1, 612 probabilities: typing.Any = 1.0, 613 ) -> "Motif": 614 615 """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. 616 617 Looks a curated timeline up in the world-rhythm table (clave family, 618 West-African bell patterns, tresillo/cinquillo, samba) and lays its 619 onsets across *length* beats. Onset positions are exact pulse indices 620 from Toussaint's "The Geometry of Musical Rhythm"; each preset declares 621 its own grid (16 for the clave/4-4 timelines, 12 for the bell 622 patterns) and a default drum voice. 623 624 Parameters: 625 name: A preset name (``KeyError``-style ValueError lists them all). 626 pitch: The voice — a drum name or MIDI int; defaults to the 627 preset's General-MIDI voice (``"claves"``, ``"cowbell"``, 628 ``"side_stick"``, ``"low_conga"``), so it sounds against the 629 standard GM drum map without a ``pitch=``. 630 length: Total beats the cycle spans (4 = one common-time bar). 631 velocities / durations / probabilities: The parallel-list params. 632 633 Returns: 634 A drum/pitched :class:`Motif` of the timeline's onsets. 635 636 Raises: 637 ValueError: If *name* is not a known preset. 638 639 Example: 640 ```python 641 clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" 642 bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse 643 ``` 644 """ 645 646 if name not in _WORLD_RHYTHMS: 647 known = ", ".join(sorted(_WORLD_RHYTHMS)) 648 raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") 649 650 steps, grid, voice = _WORLD_RHYTHMS[name] 651 652 return cls.steps( 653 steps = list(steps), 654 pitches = pitch if pitch is not None else voice, 655 velocities = velocities, 656 durations = durations, 657 probabilities = probabilities, 658 step_duration = length / grid, 659 length = length, 660 ) 661 662 # ── control-gesture constructors (mirror the pattern_midi verbs) ──── 663 664 @classmethod 665 def _control_writes ( 666 cls, 667 signal: ControlSignal, 668 values: typing.List[float], 669 beats: typing.List[float], 670 length: typing.Optional[float], 671 probabilities: typing.Any = 1.0, 672 ) -> "Motif": 673 674 """Shared core for discrete control writes.""" 675 676 if len(values) != len(beats): 677 raise ValueError(f"values has {len(values)} entries for {len(beats)} beats — parallel lists must match") 678 679 probability_list = _expand("probabilities", probabilities, len(values)) 680 681 controls = tuple( 682 ControlEvent(beat=float(beats[i]), signal=signal, start=float(values[i]), probability=float(probability_list[i])) 683 for i in range(len(values)) 684 ) 685 686 return cls( 687 events = (), 688 length = _computed_length((), controls) if length is None else float(length), 689 controls = controls, 690 ) 691 692 @classmethod 693 def _control_ramp ( 694 cls, 695 signal: ControlSignal, 696 start: float, 697 end: float, 698 beat_start: float, 699 beat_end: typing.Optional[float], 700 shape: typing.Union[str, "subsequence.easing.EasingFn"], 701 length: typing.Optional[float], 702 probability: float = 1.0, 703 ) -> "Motif": 704 705 """Shared core for shaped control ramps.""" 706 707 if beat_end is None: 708 if length is None: 709 raise ValueError("A ramp needs beat_end= (or length=, which beat_end defaults to)") 710 beat_end = float(length) 711 712 if beat_end <= beat_start: 713 raise ValueError(f"beat_end ({beat_end}) must be after beat_start ({beat_start})") 714 715 controls = ( 716 ControlEvent( 717 beat = float(beat_start), 718 signal = signal, 719 start = float(start), 720 end = float(end), 721 span = float(beat_end) - float(beat_start), 722 shape = shape, 723 probability = probability, 724 ), 725 ) 726 727 return cls( 728 events = (), 729 length = float(math.ceil(beat_end)) if length is None else float(length), 730 controls = controls, 731 ) 732 733 @classmethod 734 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": 735 736 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 737 738 return cls._control_writes(CC(control), list(values), list(beats), length, probabilities) 739 740 @classmethod 741 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": 742 743 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 744 745 return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability) 746 747 @classmethod 748 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 749 750 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 751 752 return cls._control_writes(PitchBend(), list(values), list(beats), length, probabilities) 753 754 @classmethod 755 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": 756 757 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 758 759 return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability) 760 761 @classmethod 762 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": 763 764 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 765 766 return cls._control_writes(NRPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 767 768 @classmethod 769 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": 770 771 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 772 773 return cls._control_ramp(NRPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 774 775 @classmethod 776 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": 777 778 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 779 780 return cls._control_writes(RPN(parameter, fine=fine, null_reset=null_reset), list(values), list(beats), length, probabilities) 781 782 @classmethod 783 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": 784 785 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 786 787 return cls._control_ramp(RPN(parameter, fine=fine, null_reset=null_reset), start, end, beat_start, beat_end, shape, length, probability) 788 789 @classmethod 790 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 791 792 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 793 794 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities) 795 796 @classmethod 797 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": 798 799 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 800 801 return cls._control_ramp(OSC(address), start, end, beat_start, beat_end, shape, length, probability) 802 803 # ── the algebra ───────────────────────────────────────────────────── 804 805 def then (self, other: "Motif") -> "Motif": 806 807 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 808 809 if not isinstance(other, Motif): 810 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 811 812 return Motif( 813 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 814 length = self.length + other.length, 815 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 816 # fit is a dial, not content: keep ours, inherit the other's when 817 # we have none — join()/tiling folds from empty() (fit=None), and 818 # must not silently strip a generated motif's chord-snapping. 819 fit = self.fit if self.fit is not None else other.fit, 820 ) 821 822 @classmethod 823 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 824 825 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 826 827 result = cls.empty() 828 829 for m in motifs: 830 result = result.then(m) 831 832 return result 833 834 @classmethod 835 def generate ( 836 cls, 837 rhythm: typing.Any, 838 length: typing.Optional[float] = None, 839 scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, 840 contour: typing.Optional[str] = None, 841 end_on: typing.Optional[typing.Union[int, Degree]] = None, 842 cadence: typing.Optional[str] = None, 843 pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, 844 max_pitches: typing.Optional[int] = None, 845 velocities: typing.Any = _DEFAULT_VELOCITY, 846 durations: typing.Any = 0.25, 847 seed: typing.Optional[int] = None, 848 rng: typing.Optional[random.Random] = None, 849 state: typing.Optional[typing.Any] = None, 850 nir_strength: float = 0.5, 851 pitch_diversity: float = 0.6, 852 tessitura_strength: float = 0.6, 853 ) -> "Motif": 854 855 """Generate a melodic motif — rhythm first, pitches walked, a value out. 856 857 The melody engine emitting a value: you give the **rhythm** (an onset 858 list in beats, or another motif whose rhythm to borrow — cross-pattern 859 rhythm reuse is shared values); the engine walks pitches over it 860 through the soft scoring factors (NIR expectation, contour envelope, 861 tessitura regression, diversity), honouring any pins. 862 863 The result emits **scale degrees** (resolved at placement against the 864 composition key/scale), so a generated hook transposes, varies, and 865 develops like a hand-written one. ``scale=`` constrains *candidate 866 choice only*: a name or interval list masks which pitches the walk 867 may use, spelled relative to its best-fit reference (major or minor) 868 — bind it in a composition whose scale matches that family and 869 resolution is exact. An explicit MIDI pitch pool (a list of note 870 numbers) switches to absolute output (the sieve/atonal path). 871 872 Parameters: 873 rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif 874 (its onsets are borrowed). 875 length: Motif length in beats; defaults to the onsets rounded 876 up to a whole 4-beat bar. 877 scale: A scale name, an interval list, or an explicit MIDI 878 pitch pool. ``None`` = the plain seven degrees. 879 contour: Envelope shaping the line's height over its span — 880 ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. 881 end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. 882 Degree semantics: raises with an explicit MIDI pool (pin the 883 exact note instead). 884 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 885 ``"fakeout"``) — the line closes on that cadence's melodic 886 degree (1 for the full closes and the fakeout, 5 for the 887 open half). Sugar for ``end_on=``; conflicts with it, and 888 raises with an explicit MIDI pool like ``end_on=``. 889 pins: ``{position: degree}`` — 1-based note positions (``-1`` = 890 the last, the Python idiom); the engine fills between. With 891 an explicit MIDI pool there are no degrees to read, so each 892 pin is the exact MIDI note to play (``Degree`` pins raise). 893 max_pitches: Cap on distinct pitches (a tight pool is a hook); 894 keeps the most central candidates. 895 velocities / durations: Scalar or per-note list (the parallel- 896 list convention). 897 seed: Seed for the walk (required or warned — module-level 898 nondeterminism breaks live reload). 899 rng: Explicit stream (overrides ``seed``). 900 state: A ``MelodicState`` whose dials, scoring factors, and 901 melodic history seed the walk. It is **copied** — building 902 a value never mutates a module-level live object. The 903 candidate pool is not carried over: it is always rebuilt 904 from ``scale=`` (pass an explicit pool there instead), 905 though the state's key still sets the tonic that the NIR 906 closure rule lands on. 907 nir_strength / pitch_diversity / tessitura_strength: The walk's 908 dials when no ``state`` is given. 909 910 Example: 911 ```python 912 hook = subsequence.Motif.generate( 913 rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", 914 contour="arch", end_on=1, seed=7, 915 ) 916 ``` 917 """ 918 919 import subsequence.melodic_state 920 921 onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm] 922 923 if cadence is not None: 924 if end_on is not None: 925 raise ValueError("cadence= already names the close degree — it conflicts with end_on=") 926 end_on = subsequence.cadences.cadence_formula(cadence).close_degree 927 928 if not onsets: 929 raise ValueError("generate() needs at least one onset — the rhythm comes first") 930 if sorted(onsets) != onsets: 931 raise ValueError("rhythm onsets must ascend") 932 933 if length is None: 934 length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) 935 if onsets[-1] >= length: 936 raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}") 937 938 if rng is None: 939 if seed is None: 940 warnings.warn( 941 "generate() without seed= is nondeterministic — pass seed= so the " 942 "value survives live reload", 943 stacklevel = 2, 944 ) 945 rng = random.Random() 946 else: 947 rng = random.Random(seed) 948 949 # --- The candidate pool ------------------------------------------------ 950 absolute_pool: typing.Optional[typing.List[int]] = None 951 intervals: typing.List[int] 952 953 if scale is None: 954 intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) 955 elif isinstance(scale, str): 956 intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) 957 else: 958 values = [int(v) for v in scale] 959 if values and (min(values) != 0 or max(values) > 11): 960 absolute_pool = sorted(values) # an explicit MIDI pool: absolute output 961 intervals = [] 962 else: 963 intervals = sorted(set(values)) 964 965 # Best-fit reference scale for degree spelling: whichever of major/ 966 # minor contains more of the pool (ties to major). Bound under a 967 # matching composition scale, resolution is exact. 968 if absolute_pool is None: 969 ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) 970 aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) 971 reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian" 972 reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name)) 973 974 # --- The walking state (copied, never mutated in place) ---------------- 975 if state is not None: 976 walker = state.clone() 977 walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a 978 # note, so the walker never rests (and never falls 979 # back to a stuck repeat) — rests come from the rhythm 980 else: 981 walker = subsequence.melodic_state.MelodicState( 982 nir_strength = nir_strength, 983 pitch_diversity = pitch_diversity, 984 tessitura_strength = tessitura_strength, 985 chord_weight = 0.0, # values have no chord context; fit applies at placement 986 ) 987 988 if absolute_pool is not None: 989 walker.set_pool(absolute_pool) 990 else: 991 # Offsets over ~1.5 octaves anchored at 60 — register is decided 992 # at placement (root=), so the anchor is arbitrary and erased. 993 walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19]) 994 995 if max_pitches is not None: 996 if max_pitches < 1: 997 raise ValueError("max_pitches must be at least 1") 998 pool = sorted(walker._pitch_pool) 999 centre = pool[len(pool) // 2] 1000 walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches])) 1001 1002 # --- Pins --------------------------------------------------------------- 1003 resolved_pins: typing.Dict[int, int] = {} 1004 combined = dict(pins or {}) 1005 1006 # cadence=/end_on= name scale DEGREES — meaningless against an explicit 1007 # MIDI pool, where they would silently land as raw (sub-audio) note 1008 # numbers. 1009 if absolute_pool is not None and end_on is not None: 1010 raise ValueError( 1011 "cadence=/end_on= name scale degrees, but this motif uses an " 1012 "explicit MIDI pool — pin the exact closing note instead: " 1013 "pins={-1: <midi note>}" 1014 ) 1015 1016 if end_on is not None: 1017 if -1 in combined or len(onsets) in combined: 1018 raise ValueError("end_on conflicts with a pin on the last note — they name the same position") 1019 combined[-1] = end_on 1020 1021 for pin_position, pin_spec in combined.items(): 1022 if not isinstance(pin_position, int) or isinstance(pin_position, bool): 1023 raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}") 1024 index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position 1025 if not 0 <= index < len(onsets): 1026 raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm") 1027 if absolute_pool is not None: 1028 # A raw int pins the exact MIDI note; a Degree has no meaning 1029 # here (the pool defines no scale to read it against). 1030 if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): 1031 raise ValueError( 1032 f"pin {pin_spec!r} is a scale degree, but this motif uses an " 1033 "explicit MIDI pool — pin the exact MIDI note instead " 1034 "(e.g. pins={-1: 52})" 1035 ) 1036 resolved_pins[index] = int(pin_spec) 1037 else: 1038 degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) 1039 step_index = (degree.step - 1) % len(reference) 1040 carry = (degree.step - 1) // len(reference) 1041 resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma 1042 1043 # --- The walk ----------------------------------------------------------- 1044 envelopes: typing.Dict[str, typing.Callable[[float], float]] = { 1045 "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), 1046 "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), 1047 "ascending": lambda pos: 0.1 + 0.85 * pos, 1048 "descending": lambda pos: 0.95 - 0.85 * pos, 1049 } 1050 1051 if contour is not None and contour not in envelopes: 1052 known = ", ".join(sorted(envelopes)) 1053 raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") 1054 1055 chosen_pitches: typing.List[int] = [] 1056 1057 for index, onset in enumerate(onsets): 1058 1059 if index in resolved_pins: 1060 pitch = resolved_pins[index] 1061 walker.record(pitch) # pins enter the NIR context like chosen notes 1062 else: 1063 span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 1064 target = envelopes[contour](span_position) if contour is not None else None 1065 picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target) 1066 pitch = picked if picked is not None else walker._pitch_pool[0] 1067 1068 chosen_pitches.append(pitch) 1069 1070 # --- Emission ------------------------------------------------------------ 1071 velocity_values = _expand("velocities", velocities, len(onsets)) 1072 duration_values = _expand("durations", durations, len(onsets)) 1073 1074 events = [] 1075 1076 for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): 1077 1078 spec: PitchSpec 1079 1080 if absolute_pool is not None: 1081 spec = pitch 1082 else: 1083 offset = pitch - 60 1084 octave, pc = divmod(offset, 12) 1085 if pc in reference: 1086 spec = Degree(reference.index(pc) + 1, octave = octave) 1087 elif (pc + 1) % 12 in reference and pc + 1 <= 11: 1088 spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1) 1089 else: 1090 spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1) 1091 1092 events.append(MotifEvent( 1093 beat = onset, 1094 pitch = spec, 1095 velocity = velocity_values[index], 1096 duration = float(duration_values[index]), 1097 )) 1098 1099 return cls(events = tuple(events), length = float(length), fit = 0.7) 1100 1101 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 1102 1103 """ 1104 Parallel merge (the spelled form of ``&``): event union, length = max. 1105 1106 No implicit tiling — a short gesture stacked under a long figure 1107 plays once. Phrase operands flatten first. 1108 """ 1109 1110 if isinstance(other, Phrase): 1111 merged = other.flatten() 1112 elif isinstance(other, Motif): 1113 merged = other 1114 else: 1115 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 1116 1117 return Motif( 1118 events = self.events + merged.events, 1119 length = max(self.length, merged.length), 1120 controls = self.controls + merged.controls, 1121 fit = self.fit, 1122 ) 1123 1124 def slice (self, start: float, end: float) -> "Motif": 1125 1126 """ 1127 A window onto the motif, on its own authority: events starting outside 1128 are dropped; durations and ramp spans truncate at the cut (a truncated 1129 ramp ends at its interpolated cut value). Beats shift so the window 1130 starts at 0. 1131 """ 1132 1133 if end <= start: 1134 raise ValueError(f"slice end ({end}) must be after start ({start})") 1135 1136 events = tuple( 1137 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 1138 for e in self.events 1139 if start <= e.beat < end 1140 ) 1141 1142 controls = [] 1143 1144 for c in self.controls: 1145 if not (start <= c.beat < end): 1146 continue 1147 if c.end is not None and c.beat + c.span > end: 1148 kept = end - c.beat 1149 controls.append(dataclasses.replace( 1150 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 1151 )) 1152 else: 1153 controls.append(dataclasses.replace(c, beat=c.beat - start)) 1154 1155 return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit) 1156 1157 def __add__ (self, other: typing.Any) -> "Phrase": 1158 1159 """``a + b`` — sequential: a two-segment Phrase (segmentation preserved).""" 1160 1161 if isinstance(other, Motif): 1162 return Phrase((self, other)) 1163 1164 return NotImplemented 1165 1166 def __mul__ (self, count: int) -> typing.Union["Motif", "Phrase"]: 1167 1168 """``m * n`` — repetition: a Phrase of n segments; ``m * 1`` is ``m``; ``m * 0`` is empty.""" 1169 1170 if not isinstance(count, int): 1171 return NotImplemented 1172 if count < 0: 1173 raise ValueError(f"Repetition count must be non-negative — got {count}") 1174 if count == 0: 1175 return Motif.empty() 1176 if count == 1: 1177 return self 1178 1179 return Phrase((self,) * count) 1180 1181 __rmul__ = __mul__ 1182 1183 def __and__ (self, other: typing.Any) -> "Motif": 1184 1185 """``a & b`` — parallel merge; the spelled form is :meth:`stack`.""" 1186 1187 if isinstance(other, (Motif, Phrase)): 1188 return self.stack(other) 1189 1190 return NotImplemented 1191 1192 # ── transforms (pure; control gestures ride time, ignore pitch) ───── 1193 1194 def reverse (self) -> "Motif": 1195 1196 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 1197 1198 events = tuple( 1199 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 1200 for e in self.events 1201 ) 1202 controls = tuple( 1203 dataclasses.replace( 1204 c, 1205 beat = max(0.0, self.length - c.beat - c.span), 1206 start = c.start if c.end is None else c.end, 1207 end = c.end if c.end is None else c.start, 1208 ) 1209 for c in self.controls 1210 ) 1211 1212 return Motif(events=events, length=self.length, controls=controls, fit=self.fit) 1213 1214 def rotate (self, beats: float) -> "Motif": 1215 1216 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 1217 1218 if self.length == 0: 1219 return self 1220 1221 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 1222 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 1223 1224 return Motif(events=events, length=self.length, controls=controls, fit=self.fit) 1225 1226 def stretch (self, factor: float) -> "Motif": 1227 1228 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 1229 1230 if factor <= 0: 1231 raise ValueError(f"Stretch factor must be positive — got {factor}") 1232 1233 events = tuple( 1234 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 1235 for e in self.events 1236 ) 1237 controls = tuple( 1238 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 1239 for c in self.controls 1240 ) 1241 1242 return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit) 1243 1244 def quantize (self, grid: float) -> "Motif": 1245 1246 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). 1247 1248 An onset exactly midway between grid lines snaps LATER (round half 1249 up) — every midpoint moves the same way, the predictable behaviour 1250 for a musician. (Python's own ``round()`` is half-to-even, which 1251 made exact midpoints snap in alternating directions.) 1252 """ 1253 1254 if grid <= 0: 1255 raise ValueError(f"Quantize grid must be positive — got {grid}") 1256 1257 events = tuple( 1258 dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) 1259 for e in self.events 1260 ) 1261 1262 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1263 1264 def accent (self, beat: float, amount: int = 20) -> "Motif": 1265 1266 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 1267 1268 def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]: 1269 # Clamp both ends: a negative amount (a de-accent) must not store 1270 # a velocity below 1, which MIDI cannot play. 1271 if isinstance(velocity, tuple): 1272 return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount))) 1273 return max(1, min(127, velocity + amount)) 1274 1275 events = tuple( 1276 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 1277 for e in self.events 1278 ) 1279 1280 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1281 1282 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif": 1283 1284 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 1285 1286 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 1287 1288 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1289 1290 def _nudged_pitch (self, pitch: PitchSpec, rng: random.Random) -> PitchSpec: 1291 1292 """One varied pitch: a small melodic nudge that always changes the note. 1293 1294 Degrees move by scale steps, MIDI ints by semitones, chord tones by 1295 index; an Approach's target is nudged. Drum names raise — a varied 1296 drum is a different instrument, not a variation. 1297 """ 1298 1299 if isinstance(pitch, Degree): 1300 steps = [pitch.step + delta for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1] 1301 return dataclasses.replace(pitch, step = rng.choice(steps)) 1302 if isinstance(pitch, ChordTone): 1303 indices = [pitch.index + delta for delta in (-1, 1) if pitch.index + delta >= 1] 1304 return ChordTone(rng.choice(indices), octave = pitch.octave) 1305 if isinstance(pitch, Approach): 1306 nudged = self._nudged_pitch(pitch.target, rng) 1307 if not isinstance(nudged, (int, Degree, ChordTone)): 1308 raise TypeError(f"cannot vary an Approach aimed at {type(nudged).__name__} content") 1309 return Approach(nudged) 1310 if isinstance(pitch, int): 1311 return pitch + rng.choice((-2, -1, 1, 2)) 1312 1313 raise TypeError( 1314 f"vary() moves pitches — {type(pitch).__name__} content cannot vary " 1315 "(a varied drum is a different instrument)" 1316 ) 1317 1318 def vary ( 1319 self, 1320 notes: int = 1, 1321 position: str = "end", 1322 seed: typing.Optional[int] = None, 1323 rng: typing.Optional[random.Random] = None, 1324 keep_contour: bool = False, 1325 ) -> "Motif": 1326 1327 """Replace a few pitches, preserving the rhythm — the smallest variation. 1328 1329 Rhythm, velocities, durations, rests, and control gestures are 1330 untouched; only the chosen notes' pitches move (by a small melodic 1331 nudge: scale steps for degrees, semitones for MIDI ints). 1332 1333 Parameters: 1334 notes: How many pitched notes to vary (clamped to what exists). 1335 position: Which notes — ``"end"`` (the tail, the default), 1336 ``"start"``, or ``"anywhere"`` (drawn from the stream). 1337 seed: Seed for the variation. A standalone vary without a seed 1338 warns — module-level nondeterminism breaks live reload. 1339 rng: An explicit random stream (overrides ``seed``; used by 1340 recipe machinery). 1341 keep_contour: When True, the variation preserves the line's 1342 CSEG — every varied note keeps its rank relations with 1343 every other note, so the melodic shape is identical (the 1344 motif-identity guard). Where no nudge can preserve the 1345 contour, that note stays unchanged — shape wins over 1346 motion. 1347 1348 Example: 1349 ```python 1350 answer = call.vary(notes=1, seed=4) # same figure, new tail note 1351 ``` 1352 """ 1353 1354 if notes < 0: 1355 raise ValueError(f"notes must be at least 0, got {notes}") 1356 if position not in ("end", "start", "anywhere"): 1357 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 1358 1359 if rng is None: 1360 if seed is None: 1361 warnings.warn( 1362 "vary() without seed= is nondeterministic — pass seed= so the " 1363 "value survives live reload", 1364 stacklevel = 2, 1365 ) 1366 rng = random.Random() 1367 else: 1368 rng = random.Random(seed) 1369 1370 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1371 count = min(notes, len(pitched_indices)) 1372 1373 if count == 0: 1374 return self 1375 1376 if position == "end": 1377 chosen = pitched_indices[-count:] 1378 elif position == "start": 1379 chosen = pitched_indices[:count] 1380 else: 1381 chosen = sorted(rng.sample(pitched_indices, count)) 1382 1383 events = list(self.events) 1384 1385 for index in chosen: 1386 if keep_contour: 1387 replacement = self._contour_safe_nudge(events, index, pitched_indices, rng) 1388 if replacement is not None: 1389 events[index] = dataclasses.replace(events[index], pitch = replacement) 1390 else: 1391 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng)) 1392 1393 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) 1394 1395 @staticmethod 1396 def _rank_value (pitch: PitchSpec) -> float: 1397 1398 """A comparable height for contour ranking (uniform content only).""" 1399 1400 if isinstance(pitch, Degree): 1401 return pitch.octave * 7 + pitch.step + 0.4 * pitch.chroma 1402 if isinstance(pitch, ChordTone): 1403 return pitch.octave * 4 + pitch.index 1404 if isinstance(pitch, int): 1405 return float(pitch) 1406 1407 raise TypeError(f"keep_contour needs rankable pitches — {type(pitch).__name__} content has no height") 1408 1409 def _contour_safe_nudge ( 1410 self, 1411 events: typing.List[MotifEvent], 1412 index: int, 1413 pitched_indices: typing.List[int], 1414 rng: random.Random, 1415 ) -> typing.Optional[PitchSpec]: 1416 1417 """A nudge for events[index] that preserves its CSEG rank relations. 1418 1419 Candidates are the usual small nudges, filtered to those keeping the 1420 note's above/below/equal relation to every other pitched note. One 1421 rng draw happens regardless (stream stability); ``None`` means no 1422 candidate preserves the shape — leave the note alone. 1423 """ 1424 1425 pitch = events[index].pitch 1426 1427 if isinstance(pitch, Degree): 1428 candidates: typing.List[PitchSpec] = [ 1429 dataclasses.replace(pitch, step = pitch.step + delta) 1430 for delta in (-2, -1, 1, 2) if pitch.step + delta >= 1 1431 ] 1432 elif isinstance(pitch, int): 1433 candidates = [pitch + delta for delta in (-2, -1, 1, 2)] 1434 else: 1435 raise TypeError(f"keep_contour cannot vary {type(pitch).__name__} content") 1436 1437 original = self._rank_value(pitch) 1438 others = [ 1439 (self._rank_value(events[other].pitch), other) 1440 for other in pitched_indices if other != index 1441 ] 1442 1443 def preserves (candidate: PitchSpec) -> bool: 1444 height = self._rank_value(candidate) 1445 for other_height, _ in others: 1446 before = (original > other_height) - (original < other_height) 1447 after = (height > other_height) - (height < other_height) 1448 if before != after: 1449 return False 1450 return True 1451 1452 surviving = [candidate for candidate in candidates if preserves(candidate)] 1453 1454 # One draw either way, so adding keep_contour never shifts the stream 1455 # consumed by the notes around this one. 1456 draw = rng.random() 1457 1458 if not surviving: 1459 return None 1460 1461 return surviving[int(draw * len(surviving)) % len(surviving)] 1462 1463 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 1464 1465 """Call → response: re-aim the tail to a stable degree. 1466 1467 The classic consequent move — the figure repeats but its last pitched 1468 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1469 or a full ``Degree`` for register control). Everything else — 1470 rhythm, the other pitches, velocities, controls — is untouched. 1471 1472 Degree content only: absolute MIDI has no degrees to re-aim (build 1473 the call with ``motif([...])``), and drums raise. 1474 """ 1475 1476 target = to if isinstance(to, Degree) else Degree(int(to)) 1477 1478 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1479 1480 if not pitched_indices: 1481 return self 1482 1483 last = self.events[pitched_indices[-1]] 1484 1485 if not isinstance(last.pitch, Degree): 1486 raise TypeError( 1487 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1488 "content (build the call with motif([...]) for degree content)" 1489 ) 1490 1491 if isinstance(to, int): 1492 # Keep the call's register: only the step is re-aimed. 1493 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1494 1495 events = list(self.events) 1496 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1497 1498 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit) 1499 1500 def pitched (self, spec: PitchSpec) -> "Motif": 1501 1502 """ 1503 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1504 1505 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1506 tones; any other string is a drum name; ints are MIDI; Degree / 1507 ChordTone / Approach pass through. 1508 """ 1509 1510 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1511 spec = ChordTone(spec) 1512 1513 events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) 1514 1515 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1516 1517 def rhythm (self) -> "Motif": 1518 1519 """ 1520 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1521 1522 Timing, velocities, durations, and probabilities survive; re-pitch 1523 with :meth:`pitched` before placement (placing a skeleton raises). 1524 """ 1525 1526 events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) 1527 1528 return Motif(events=events, length=self.length) 1529 1530 def onsets (self) -> typing.List[float]: 1531 1532 """The note onset beats, in order — ready for rhythm-first generation.""" 1533 1534 return [e.beat for e in self.events] 1535 1536 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1537 1538 """ 1539 Transpose pitched content; the keyword names the unit. 1540 1541 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1542 raises on absolute-MIDI or drum content; ``semitones=`` is the 1543 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1544 on both — a transposed drum name is a different instrument, not a 1545 transposition. 1546 """ 1547 1548 if (steps is None) == (semitones is None): 1549 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1550 1551 def move (pitch: PitchSpec) -> PitchSpec: 1552 1553 if pitch is None: 1554 return None 1555 1556 if isinstance(pitch, Approach): 1557 moved = move(pitch.target) 1558 if not isinstance(moved, (int, Degree, ChordTone)): 1559 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1560 return Approach(moved) 1561 1562 if steps is not None: 1563 if isinstance(pitch, Degree): 1564 return dataclasses.replace(pitch, step=pitch.step + steps) 1565 raise TypeError( 1566 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1567 f"has no degrees (use semitones= for MIDI ints)" 1568 ) 1569 1570 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1571 1572 if isinstance(pitch, int): 1573 return pitch + semitones 1574 if isinstance(pitch, Degree): 1575 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1576 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1577 1578 events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) 1579 1580 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1581 1582 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1583 1584 """ 1585 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1586 degree content around a degree pivot (default: the first note's pitch). 1587 Drum motifs raise. 1588 """ 1589 1590 pitched_events = [e for e in self.events if e.pitch is not None] 1591 1592 if not pitched_events: 1593 return self 1594 1595 first = pitched_events[0].pitch 1596 1597 if pivot is None: 1598 if isinstance(first, int): 1599 pivot = first 1600 elif isinstance(first, Degree): 1601 pivot = first.step 1602 else: 1603 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1604 1605 def mirror (pitch: PitchSpec) -> PitchSpec: 1606 1607 if pitch is None: 1608 return None 1609 if isinstance(pitch, int): 1610 return 2 * pivot - pitch 1611 if isinstance(pitch, Degree): 1612 mirrored = 2 * pivot - pitch.step 1613 if mirrored < 1: 1614 raise ValueError( 1615 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1616 f"raise the pivot or use Degree octaves" 1617 ) 1618 # Reflection around the pivot (read at octave 0) is an isometry, so a 1619 # note's register flips too: a degree an octave above the pivot lands an 1620 # octave below it. Negating octave needs no scale length and leaves 1621 # octave-0 content unchanged. 1622 return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma) 1623 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1624 1625 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events) 1626 1627 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit) 1628 1629 # ── description ───────────────────────────────────────────────────── 1630 1631 def describe (self) -> str: 1632 1633 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1634 1635 notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) 1636 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1637 1638 if self.controls: 1639 gestures = ", ".join(_control_label(c) for c in self.controls) 1640 parts.append(f"controls [{gestures}]") 1641 1642 return " ".join(parts) 1643 1644 def __str__ (self) -> str: 1645 1646 """Printable form (same as :meth:`describe`).""" 1647 1648 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.
403 @classmethod 404 def empty (cls) -> "Motif": 405 406 """The empty motif (zero events, zero length) — the identity for ``then``.""" 407 408 return cls(events=(), length=0.0)
The empty motif (zero events, zero length) — the identity for then.
410 @classmethod 411 def from_events ( 412 cls, 413 events: typing.Iterable[MotifEvent], 414 length: typing.Optional[float] = None, 415 controls: typing.Iterable[ControlEvent] = (), 416 ) -> "Motif": 417 418 """Build a motif from explicit events (power use; length defaults to the next whole beat).""" 419 420 events = tuple(events) 421 controls = tuple(controls) 422 423 return cls( 424 events = events, 425 length = _computed_length(events, controls) if length is None else length, 426 controls = controls, 427 )
Build a motif from explicit events (power use; length defaults to the next whole beat).
469 @classmethod 470 def degrees ( 471 cls, 472 degrees: typing.List[typing.Union[int, Degree, None]], 473 beats: typing.Optional[typing.List[float]] = None, 474 velocities: typing.Any = _DEFAULT_VELOCITY, 475 durations: typing.Any = 1.0, 476 probabilities: typing.Any = 1.0, 477 length: typing.Optional[float] = None, 478 ) -> "Motif": 479 480 """ 481 A melody written as 1-based scale degrees, one per beat by default. 482 483 Elements are ints (1 = tonic, 8 = tonic an octave up), ``None`` for a 484 rest (the beat slot still advances), or :class:`Degree` for octave/ 485 chromatic detail. Resolved against key + scale at placement. 486 Durations default to a full beat (each note holds its slot). 487 """ 488 489 converted: typing.List[PitchSpec] = [] 490 491 for element in degrees: 492 if isinstance(element, int): 493 if element > _MAX_PLAUSIBLE_DEGREE: 494 raise ValueError( 495 f"Degree {element} is implausibly large — scale degrees are 1-based " 496 f"(8 = tonic an octave up). For MIDI note numbers use Motif.notes()." 497 ) 498 converted.append(Degree(element)) 499 elif isinstance(element, Degree) or element is None: 500 converted.append(element) 501 else: 502 raise TypeError(f"Motif.degrees takes ints, Degree, or None — got {type(element).__name__}") 503 504 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).
506 @classmethod 507 def notes ( 508 cls, 509 notes: typing.List[typing.Union[int, None]], 510 beats: typing.Optional[typing.List[float]] = None, 511 velocities: typing.Any = _DEFAULT_VELOCITY, 512 durations: typing.Any = 1.0, 513 probabilities: typing.Any = 1.0, 514 length: typing.Optional[float] = None, 515 ) -> "Motif": 516 517 """A melody written as absolute MIDI note numbers (60 = middle C); ``None`` = rest.""" 518 519 for element in notes: 520 # bool is a subclass of int, but True/False are never MIDI notes. 521 if isinstance(element, bool) or not (isinstance(element, int) or element is None): 522 raise TypeError(f"Motif.notes takes MIDI ints or None — got {type(element).__name__}") 523 524 return cls._from_sequence(list(notes), beats, velocities, durations, probabilities, length)
A melody written as absolute MIDI note numbers (60 = middle C); None = rest.
526 @classmethod 527 def hits ( 528 cls, 529 pitch: typing.Union[int, str], 530 beats: typing.List[float], 531 length: typing.Optional[float] = None, 532 velocities: typing.Any = _DEFAULT_VELOCITY, 533 durations: typing.Any = 0.1, 534 probabilities: typing.Any = 1.0, 535 ) -> "Motif": 536 537 """One pitch (usually a drum name) at a list of beat positions — the ``hit()`` convention.""" 538 539 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.
541 @classmethod 542 def steps ( 543 cls, 544 steps: typing.List[int], 545 pitches: typing.Any, 546 velocities: typing.Any = _DEFAULT_VELOCITY, 547 durations: typing.Any = 0.1, 548 probabilities: typing.Any = 1.0, 549 step_duration: float = 0.25, 550 length: typing.Optional[float] = None, 551 ) -> "Motif": 552 553 """ 554 Grid placement — the ``sequence()`` convention: ``steps`` are 0-based 555 grid indices (sixteenths by default), ``pitches`` a scalar or 556 parallel list of MIDI ints or drum names. 557 """ 558 559 n = len(steps) 560 pitch_list = _expand("pitches", pitches, n) 561 onsets = [s * step_duration for s in steps] 562 563 if length is None and n: 564 length = float(math.ceil((max(steps) + 1) * step_duration)) 565 566 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.
568 @classmethod 569 def euclidean ( 570 cls, 571 pulses: int, 572 steps: int, 573 pitch: typing.Union[int, str], 574 length: float = 4.0, 575 velocities: typing.Any = _DEFAULT_VELOCITY, 576 durations: typing.Any = 0.1, 577 probabilities: typing.Any = 1.0, 578 ) -> "Motif": 579 580 """A euclidean rhythm as a value: *pulses* spread evenly across *steps* over *length* beats.""" 581 582 # bool is a subclass of int, but True/False are never MIDI notes. 583 if isinstance(pitch, bool): 584 raise TypeError(f"Motif.euclidean takes a MIDI int or drum name for pitch — got {pitch!r}") 585 586 # The kernel returns one 0/1 flag per grid step; onsets are the 1s. 587 # It validates pulses first, so pulses > steps still raises clearly. 588 flags = subsequence.sequence_utils.generate_euclidean_sequence(steps=steps, pulses=pulses) 589 590 if steps <= 0: 591 # A grid of zero steps holds no onsets — an empty motif of the given 592 # length, matching pulses=0 on a real grid (the empty-input policy). 593 return cls._from_sequence([], [], velocities, durations, probabilities, length) 594 595 step_duration = length / steps 596 onsets = [i * step_duration for i, flag in enumerate(flags) if flag] 597 598 return cls._from_sequence( 599 [pitch] * len(onsets), 600 onsets, 601 velocities, durations, probabilities, length, 602 )
A euclidean rhythm as a value: pulses spread evenly across steps over length beats.
604 @classmethod 605 def preset ( 606 cls, 607 name: str, 608 pitch: typing.Optional[typing.Union[int, str]] = None, 609 length: float = 4.0, 610 velocities: typing.Any = _DEFAULT_VELOCITY, 611 durations: typing.Any = 0.1, 612 probabilities: typing.Any = 1.0, 613 ) -> "Motif": 614 615 """A named world-rhythm timeline as a value — ``Motif.preset("son_clave_3_2")``. 616 617 Looks a curated timeline up in the world-rhythm table (clave family, 618 West-African bell patterns, tresillo/cinquillo, samba) and lays its 619 onsets across *length* beats. Onset positions are exact pulse indices 620 from Toussaint's "The Geometry of Musical Rhythm"; each preset declares 621 its own grid (16 for the clave/4-4 timelines, 12 for the bell 622 patterns) and a default drum voice. 623 624 Parameters: 625 name: A preset name (``KeyError``-style ValueError lists them all). 626 pitch: The voice — a drum name or MIDI int; defaults to the 627 preset's General-MIDI voice (``"claves"``, ``"cowbell"``, 628 ``"side_stick"``, ``"low_conga"``), so it sounds against the 629 standard GM drum map without a ``pitch=``. 630 length: Total beats the cycle spans (4 = one common-time bar). 631 velocities / durations / probabilities: The parallel-list params. 632 633 Returns: 634 A drum/pitched :class:`Motif` of the timeline's onsets. 635 636 Raises: 637 ValueError: If *name* is not a known preset. 638 639 Example: 640 ```python 641 clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" 642 bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse 643 ``` 644 """ 645 646 if name not in _WORLD_RHYTHMS: 647 known = ", ".join(sorted(_WORLD_RHYTHMS)) 648 raise ValueError(f"Unknown rhythm preset {name!r}. Known presets: {known}.") 649 650 steps, grid, voice = _WORLD_RHYTHMS[name] 651 652 return cls.steps( 653 steps = list(steps), 654 pitches = pitch if pitch is not None else voice, 655 velocities = velocities, 656 durations = durations, 657 probabilities = probabilities, 658 step_duration = length / grid, 659 length = length, 660 )
A named world-rhythm timeline as a value — Motif.preset("son_clave_3_2").
Looks a curated timeline up in the world-rhythm table (clave family, West-African bell patterns, tresillo/cinquillo, samba) and lays its onsets across length beats. Onset positions are exact pulse indices from Toussaint's "The Geometry of Musical Rhythm"; each preset declares its own grid (16 for the clave/4-4 timelines, 12 for the bell patterns) and a default drum voice.
Arguments:
- name: A preset name (
KeyError-style ValueError lists them all). - pitch: The voice — a drum name or MIDI int; defaults to the
preset's General-MIDI voice (
"claves","cowbell","side_stick","low_conga"), so it sounds against the standard GM drum map without apitch=. - length: Total beats the cycle spans (4 = one common-time bar).
- velocities / durations / probabilities: The parallel-list params.
Returns:
A drum/pitched
Motifof the timeline's onsets.
Raises:
- ValueError: If name is not a known preset.
Example:
clave = subsequence.Motif.preset("son_clave_3_2") # GM "claves" bell = subsequence.Motif.preset("bembe", pitch="cowbell") # 12-pulse
733 @classmethod 734 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": 735 736 """Discrete CC writes at beat positions — mirrors ``p.cc()``; names resolve at placement.""" 737 738 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.
740 @classmethod 741 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": 742 743 """A CC value swept ``start`` → ``end`` over a beat range — mirrors ``p.cc_ramp()``.""" 744 745 return cls._control_ramp(CC(control), start, end, beat_start, beat_end, shape, length, probability)
A CC value swept start → end over a beat range — mirrors p.cc_ramp().
747 @classmethod 748 def pitch_bend (cls, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 749 750 """Discrete pitch-bend writes (-1.0 to 1.0) at beat positions — mirrors ``p.pitch_bend()``.""" 751 752 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().
754 @classmethod 755 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": 756 757 """Pitch bend swept ``start`` → ``end`` (-1.0 to 1.0) over a beat range — mirrors ``p.pitch_bend_ramp()``.""" 758 759 return cls._control_ramp(PitchBend(), start, end, beat_start, beat_end, shape, length, probability)
Pitch bend swept start → end (-1.0 to 1.0) over a beat range — mirrors p.pitch_bend_ramp().
761 @classmethod 762 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": 763 764 """Discrete NRPN parameter writes at beat positions — mirrors ``p.nrpn()``.""" 765 766 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().
768 @classmethod 769 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": 770 771 """An NRPN value swept over a beat range — mirrors ``p.nrpn_ramp()``.""" 772 773 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().
775 @classmethod 776 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": 777 778 """Discrete RPN parameter writes at beat positions — mirrors ``p.rpn()``.""" 779 780 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().
782 @classmethod 783 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": 784 785 """An RPN value swept over a beat range — mirrors ``p.rpn_ramp()``.""" 786 787 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().
789 @classmethod 790 def osc (cls, address: str, values: typing.List[float], beats: typing.List[float], length: typing.Optional[float] = None, probabilities: typing.Any = 1.0) -> "Motif": 791 792 """Discrete OSC float sends at beat positions — mirrors ``p.osc()``.""" 793 794 return cls._control_writes(OSC(address), list(values), list(beats), length, probabilities)
Discrete OSC float sends at beat positions — mirrors p.osc().
796 @classmethod 797 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": 798 799 """An OSC float swept over a beat range — mirrors ``p.osc_ramp()``.""" 800 801 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().
805 def then (self, other: "Motif") -> "Motif": 806 807 """Closed sequential concat: glue *other* after this motif into ONE longer motif.""" 808 809 if not isinstance(other, Motif): 810 raise TypeError(f"then() takes a Motif — got {type(other).__name__}") 811 812 return Motif( 813 events = self.events + tuple(dataclasses.replace(e, beat=e.beat + self.length) for e in other.events), 814 length = self.length + other.length, 815 controls = self.controls + tuple(dataclasses.replace(c, beat=c.beat + self.length) for c in other.controls), 816 # fit is a dial, not content: keep ours, inherit the other's when 817 # we have none — join()/tiling folds from empty() (fit=None), and 818 # must not silently strip a generated motif's chord-snapping. 819 fit = self.fit if self.fit is not None else other.fit, 820 )
Closed sequential concat: glue other after this motif into ONE longer motif.
822 @classmethod 823 def join (cls, motifs: typing.Iterable["Motif"]) -> "Motif": 824 825 """Fold a list of motifs into one with ``then`` (empty list → ``Motif.empty()``).""" 826 827 result = cls.empty() 828 829 for m in motifs: 830 result = result.then(m) 831 832 return result
Fold a list of motifs into one with then (empty list → Motif.empty()).
834 @classmethod 835 def generate ( 836 cls, 837 rhythm: typing.Any, 838 length: typing.Optional[float] = None, 839 scale: typing.Optional[typing.Union[str, typing.Sequence[int]]] = None, 840 contour: typing.Optional[str] = None, 841 end_on: typing.Optional[typing.Union[int, Degree]] = None, 842 cadence: typing.Optional[str] = None, 843 pins: typing.Optional[typing.Dict[int, typing.Union[int, Degree]]] = None, 844 max_pitches: typing.Optional[int] = None, 845 velocities: typing.Any = _DEFAULT_VELOCITY, 846 durations: typing.Any = 0.25, 847 seed: typing.Optional[int] = None, 848 rng: typing.Optional[random.Random] = None, 849 state: typing.Optional[typing.Any] = None, 850 nir_strength: float = 0.5, 851 pitch_diversity: float = 0.6, 852 tessitura_strength: float = 0.6, 853 ) -> "Motif": 854 855 """Generate a melodic motif — rhythm first, pitches walked, a value out. 856 857 The melody engine emitting a value: you give the **rhythm** (an onset 858 list in beats, or another motif whose rhythm to borrow — cross-pattern 859 rhythm reuse is shared values); the engine walks pitches over it 860 through the soft scoring factors (NIR expectation, contour envelope, 861 tessitura regression, diversity), honouring any pins. 862 863 The result emits **scale degrees** (resolved at placement against the 864 composition key/scale), so a generated hook transposes, varies, and 865 develops like a hand-written one. ``scale=`` constrains *candidate 866 choice only*: a name or interval list masks which pitches the walk 867 may use, spelled relative to its best-fit reference (major or minor) 868 — bind it in a composition whose scale matches that family and 869 resolution is exact. An explicit MIDI pitch pool (a list of note 870 numbers) switches to absolute output (the sieve/atonal path). 871 872 Parameters: 873 rhythm: Onset beats (``[0, 1, 1.5, 1.75, 2.5]``) or a Motif 874 (its onsets are borrowed). 875 length: Motif length in beats; defaults to the onsets rounded 876 up to a whole 4-beat bar. 877 scale: A scale name, an interval list, or an explicit MIDI 878 pitch pool. ``None`` = the plain seven degrees. 879 contour: Envelope shaping the line's height over its span — 880 ``"arch"``, ``"valley"``, ``"ascending"``, ``"descending"``. 881 end_on: Degree the line must end on — sugar for ``pins={-1: ...}``. 882 Degree semantics: raises with an explicit MIDI pool (pin the 883 exact note instead). 884 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 885 ``"fakeout"``) — the line closes on that cadence's melodic 886 degree (1 for the full closes and the fakeout, 5 for the 887 open half). Sugar for ``end_on=``; conflicts with it, and 888 raises with an explicit MIDI pool like ``end_on=``. 889 pins: ``{position: degree}`` — 1-based note positions (``-1`` = 890 the last, the Python idiom); the engine fills between. With 891 an explicit MIDI pool there are no degrees to read, so each 892 pin is the exact MIDI note to play (``Degree`` pins raise). 893 max_pitches: Cap on distinct pitches (a tight pool is a hook); 894 keeps the most central candidates. 895 velocities / durations: Scalar or per-note list (the parallel- 896 list convention). 897 seed: Seed for the walk (required or warned — module-level 898 nondeterminism breaks live reload). 899 rng: Explicit stream (overrides ``seed``). 900 state: A ``MelodicState`` whose dials, scoring factors, and 901 melodic history seed the walk. It is **copied** — building 902 a value never mutates a module-level live object. The 903 candidate pool is not carried over: it is always rebuilt 904 from ``scale=`` (pass an explicit pool there instead), 905 though the state's key still sets the tonic that the NIR 906 closure rule lands on. 907 nir_strength / pitch_diversity / tessitura_strength: The walk's 908 dials when no ``state`` is given. 909 910 Example: 911 ```python 912 hook = subsequence.Motif.generate( 913 rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", 914 contour="arch", end_on=1, seed=7, 915 ) 916 ``` 917 """ 918 919 import subsequence.melodic_state 920 921 onsets = list(rhythm.onsets()) if hasattr(rhythm, "onsets") else [float(b) for b in rhythm] 922 923 if cadence is not None: 924 if end_on is not None: 925 raise ValueError("cadence= already names the close degree — it conflicts with end_on=") 926 end_on = subsequence.cadences.cadence_formula(cadence).close_degree 927 928 if not onsets: 929 raise ValueError("generate() needs at least one onset — the rhythm comes first") 930 if sorted(onsets) != onsets: 931 raise ValueError("rhythm onsets must ascend") 932 933 if length is None: 934 length = max(4.0, math.ceil((onsets[-1] + 1e-9) / 4.0) * 4.0) 935 if onsets[-1] >= length: 936 raise ValueError(f"the last onset ({onsets[-1]:g}) falls outside length={length:g}") 937 938 if rng is None: 939 if seed is None: 940 warnings.warn( 941 "generate() without seed= is nondeterministic — pass seed= so the " 942 "value survives live reload", 943 stacklevel = 2, 944 ) 945 rng = random.Random() 946 else: 947 rng = random.Random(seed) 948 949 # --- The candidate pool ------------------------------------------------ 950 absolute_pool: typing.Optional[typing.List[int]] = None 951 intervals: typing.List[int] 952 953 if scale is None: 954 intervals = list(subsequence.intervals.scale_pitch_classes(0, "ionian")) 955 elif isinstance(scale, str): 956 intervals = list(subsequence.intervals.scale_pitch_classes(0, scale)) 957 else: 958 values = [int(v) for v in scale] 959 if values and (min(values) != 0 or max(values) > 11): 960 absolute_pool = sorted(values) # an explicit MIDI pool: absolute output 961 intervals = [] 962 else: 963 intervals = sorted(set(values)) 964 965 # Best-fit reference scale for degree spelling: whichever of major/ 966 # minor contains more of the pool (ties to major). Bound under a 967 # matching composition scale, resolution is exact. 968 if absolute_pool is None: 969 ionian = set(subsequence.intervals.scale_pitch_classes(0, "ionian")) 970 aeolian = set(subsequence.intervals.scale_pitch_classes(0, "minor")) 971 reference_name = "minor" if sum(i in aeolian for i in intervals) > sum(i in ionian for i in intervals) else "ionian" 972 reference = list(subsequence.intervals.scale_pitch_classes(0, reference_name)) 973 974 # --- The walking state (copied, never mutated in place) ---------------- 975 if state is not None: 976 walker = state.clone() 977 walker.rest_probability = 0.0 # generate is rhythm-first: every onset gets a 978 # note, so the walker never rests (and never falls 979 # back to a stuck repeat) — rests come from the rhythm 980 else: 981 walker = subsequence.melodic_state.MelodicState( 982 nir_strength = nir_strength, 983 pitch_diversity = pitch_diversity, 984 tessitura_strength = tessitura_strength, 985 chord_weight = 0.0, # values have no chord context; fit applies at placement 986 ) 987 988 if absolute_pool is not None: 989 walker.set_pool(absolute_pool) 990 else: 991 # Offsets over ~1.5 octaves anchored at 60 — register is decided 992 # at placement (root=), so the anchor is arbitrary and erased. 993 walker.set_pool([60 + octave * 12 + interval for octave in (0, 1) for interval in intervals if octave * 12 + interval <= 19]) 994 995 if max_pitches is not None: 996 if max_pitches < 1: 997 raise ValueError("max_pitches must be at least 1") 998 pool = sorted(walker._pitch_pool) 999 centre = pool[len(pool) // 2] 1000 walker.set_pool(sorted(sorted(pool, key = lambda p: (abs(p - centre), p))[:max_pitches])) 1001 1002 # --- Pins --------------------------------------------------------------- 1003 resolved_pins: typing.Dict[int, int] = {} 1004 combined = dict(pins or {}) 1005 1006 # cadence=/end_on= name scale DEGREES — meaningless against an explicit 1007 # MIDI pool, where they would silently land as raw (sub-audio) note 1008 # numbers. 1009 if absolute_pool is not None and end_on is not None: 1010 raise ValueError( 1011 "cadence=/end_on= name scale degrees, but this motif uses an " 1012 "explicit MIDI pool — pin the exact closing note instead: " 1013 "pins={-1: <midi note>}" 1014 ) 1015 1016 if end_on is not None: 1017 if -1 in combined or len(onsets) in combined: 1018 raise ValueError("end_on conflicts with a pin on the last note — they name the same position") 1019 combined[-1] = end_on 1020 1021 for pin_position, pin_spec in combined.items(): 1022 if not isinstance(pin_position, int) or isinstance(pin_position, bool): 1023 raise ValueError(f"pin positions are 1-based ints (or -1 for last), got {pin_position!r}") 1024 index = pin_position - 1 if pin_position >= 1 else len(onsets) + pin_position 1025 if not 0 <= index < len(onsets): 1026 raise ValueError(f"pin position {pin_position} is outside the {len(onsets)}-note rhythm") 1027 if absolute_pool is not None: 1028 # A raw int pins the exact MIDI note; a Degree has no meaning 1029 # here (the pool defines no scale to read it against). 1030 if not isinstance(pin_spec, int) or isinstance(pin_spec, bool): 1031 raise ValueError( 1032 f"pin {pin_spec!r} is a scale degree, but this motif uses an " 1033 "explicit MIDI pool — pin the exact MIDI note instead " 1034 "(e.g. pins={-1: 52})" 1035 ) 1036 resolved_pins[index] = int(pin_spec) 1037 else: 1038 degree = pin_spec if isinstance(pin_spec, Degree) else Degree(int(pin_spec)) 1039 step_index = (degree.step - 1) % len(reference) 1040 carry = (degree.step - 1) // len(reference) 1041 resolved_pins[index] = 60 + reference[step_index] + 12 * (carry + degree.octave) + degree.chroma 1042 1043 # --- The walk ----------------------------------------------------------- 1044 envelopes: typing.Dict[str, typing.Callable[[float], float]] = { 1045 "arch": lambda pos: 0.15 + 0.8 * math.sin(math.pi * pos), 1046 "valley": lambda pos: 0.95 - 0.8 * math.sin(math.pi * pos), 1047 "ascending": lambda pos: 0.1 + 0.85 * pos, 1048 "descending": lambda pos: 0.95 - 0.85 * pos, 1049 } 1050 1051 if contour is not None and contour not in envelopes: 1052 known = ", ".join(sorted(envelopes)) 1053 raise ValueError(f"unknown contour {contour!r} — expected one of: {known}") 1054 1055 chosen_pitches: typing.List[int] = [] 1056 1057 for index, onset in enumerate(onsets): 1058 1059 if index in resolved_pins: 1060 pitch = resolved_pins[index] 1061 walker.record(pitch) # pins enter the NIR context like chosen notes 1062 else: 1063 span_position = index / (len(onsets) - 1) if len(onsets) > 1 else 0.0 1064 target = envelopes[contour](span_position) if contour is not None else None 1065 picked = walker.choose_next(None, rng, beat = onset, position = span_position, contour_target = target) 1066 pitch = picked if picked is not None else walker._pitch_pool[0] 1067 1068 chosen_pitches.append(pitch) 1069 1070 # --- Emission ------------------------------------------------------------ 1071 velocity_values = _expand("velocities", velocities, len(onsets)) 1072 duration_values = _expand("durations", durations, len(onsets)) 1073 1074 events = [] 1075 1076 for index, (onset, pitch) in enumerate(zip(onsets, chosen_pitches)): 1077 1078 spec: PitchSpec 1079 1080 if absolute_pool is not None: 1081 spec = pitch 1082 else: 1083 offset = pitch - 60 1084 octave, pc = divmod(offset, 12) 1085 if pc in reference: 1086 spec = Degree(reference.index(pc) + 1, octave = octave) 1087 elif (pc + 1) % 12 in reference and pc + 1 <= 11: 1088 spec = Degree(reference.index(pc + 1) + 1, octave = octave, chroma = -1) 1089 else: 1090 spec = Degree(reference.index(pc - 1) + 1, octave = octave, chroma = 1) 1091 1092 events.append(MotifEvent( 1093 beat = onset, 1094 pitch = spec, 1095 velocity = velocity_values[index], 1096 duration = float(duration_values[index]), 1097 )) 1098 1099 return cls(events = tuple(events), length = float(length), fit = 0.7)
Generate a melodic motif — rhythm first, pitches walked, a value out.
The melody engine emitting a value: you give the rhythm (an onset list in beats, or another motif whose rhythm to borrow — cross-pattern rhythm reuse is shared values); the engine walks pitches over it through the soft scoring factors (NIR expectation, contour envelope, tessitura regression, diversity), honouring any pins.
The result emits scale degrees (resolved at placement against the
composition key/scale), so a generated hook transposes, varies, and
develops like a hand-written one. scale= constrains candidate
choice only: a name or interval list masks which pitches the walk
may use, spelled relative to its best-fit reference (major or minor)
— bind it in a composition whose scale matches that family and
resolution is exact. An explicit MIDI pitch pool (a list of note
numbers) switches to absolute output (the sieve/atonal path).
Arguments:
- rhythm: Onset beats (
[0, 1, 1.5, 1.75, 2.5]) or a Motif (its onsets are borrowed). - length: Motif length in beats; defaults to the onsets rounded up to a whole 4-beat bar.
- scale: A scale name, an interval list, or an explicit MIDI
pitch pool.
None= the plain seven degrees. - contour: Envelope shaping the line's height over its span —
"arch","valley","ascending","descending". - end_on: Degree the line must end on — sugar for
pins={-1: ...}. Degree semantics: raises with an explicit MIDI pool (pin the exact note instead). - cadence: A cadence name (
"strong"/"soft"/"open"/"fakeout") — the line closes on that cadence's melodic degree (1 for the full closes and the fakeout, 5 for the open half). Sugar forend_on=; conflicts with it, and raises with an explicit MIDI pool likeend_on=. - pins:
{position: degree}— 1-based note positions (-1= the last, the Python idiom); the engine fills between. With an explicit MIDI pool there are no degrees to read, so each pin is the exact MIDI note to play (Degreepins raise). - max_pitches: Cap on distinct pitches (a tight pool is a hook); keeps the most central candidates.
- velocities / durations: Scalar or per-note list (the parallel- list convention).
- seed: Seed for the walk (required or warned — module-level nondeterminism breaks live reload).
- rng: Explicit stream (overrides
seed). - state: A
MelodicStatewhose dials, scoring factors, and melodic history seed the walk. It is copied — building a value never mutates a module-level live object. The candidate pool is not carried over: it is always rebuilt fromscale=(pass an explicit pool there instead), though the state's key still sets the tonic that the NIR closure rule lands on. - nir_strength / pitch_diversity / tessitura_strength: The walk's
dials when no
stateis given.
Example:
hook = subsequence.Motif.generate( rhythm=[0, 1, 1.5, 1.75, 2.5], scale="minor_pentatonic", contour="arch", end_on=1, seed=7, )
1101 def stack (self, other: typing.Union["Motif", "Phrase"]) -> "Motif": 1102 1103 """ 1104 Parallel merge (the spelled form of ``&``): event union, length = max. 1105 1106 No implicit tiling — a short gesture stacked under a long figure 1107 plays once. Phrase operands flatten first. 1108 """ 1109 1110 if isinstance(other, Phrase): 1111 merged = other.flatten() 1112 elif isinstance(other, Motif): 1113 merged = other 1114 else: 1115 raise TypeError(f"stack() takes a Motif or Phrase — got {type(other).__name__}") 1116 1117 return Motif( 1118 events = self.events + merged.events, 1119 length = max(self.length, merged.length), 1120 controls = self.controls + merged.controls, 1121 fit = self.fit, 1122 )
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.
1124 def slice (self, start: float, end: float) -> "Motif": 1125 1126 """ 1127 A window onto the motif, on its own authority: events starting outside 1128 are dropped; durations and ramp spans truncate at the cut (a truncated 1129 ramp ends at its interpolated cut value). Beats shift so the window 1130 starts at 0. 1131 """ 1132 1133 if end <= start: 1134 raise ValueError(f"slice end ({end}) must be after start ({start})") 1135 1136 events = tuple( 1137 dataclasses.replace(e, beat=e.beat - start, duration=min(e.duration, end - e.beat)) 1138 for e in self.events 1139 if start <= e.beat < end 1140 ) 1141 1142 controls = [] 1143 1144 for c in self.controls: 1145 if not (start <= c.beat < end): 1146 continue 1147 if c.end is not None and c.beat + c.span > end: 1148 kept = end - c.beat 1149 controls.append(dataclasses.replace( 1150 c, beat=c.beat - start, span=kept, end=c._value_at(kept / c.span), 1151 )) 1152 else: 1153 controls.append(dataclasses.replace(c, beat=c.beat - start)) 1154 1155 return Motif(events=events, length=end - start, controls=tuple(controls), fit=self.fit)
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.
1194 def reverse (self) -> "Motif": 1195 1196 """Mirror the figure in time; ramps swap direction (a rising sweep falls).""" 1197 1198 events = tuple( 1199 dataclasses.replace(e, beat=max(0.0, self.length - e.beat - e.duration)) 1200 for e in self.events 1201 ) 1202 controls = tuple( 1203 dataclasses.replace( 1204 c, 1205 beat = max(0.0, self.length - c.beat - c.span), 1206 start = c.start if c.end is None else c.end, 1207 end = c.end if c.end is None else c.start, 1208 ) 1209 for c in self.controls 1210 ) 1211 1212 return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
Mirror the figure in time; ramps swap direction (a rising sweep falls).
1214 def rotate (self, beats: float) -> "Motif": 1215 1216 """Shift every onset by *beats*, wrapping modulo the length (spans ride along).""" 1217 1218 if self.length == 0: 1219 return self 1220 1221 events = tuple(dataclasses.replace(e, beat=(e.beat + beats) % self.length) for e in self.events) 1222 controls = tuple(dataclasses.replace(c, beat=(c.beat + beats) % self.length) for c in self.controls) 1223 1224 return Motif(events=events, length=self.length, controls=controls, fit=self.fit)
Shift every onset by beats, wrapping modulo the length (spans ride along).
1226 def stretch (self, factor: float) -> "Motif": 1227 1228 """Scale time by *factor* (2.0 = half-time feel): beats, durations, spans, and length.""" 1229 1230 if factor <= 0: 1231 raise ValueError(f"Stretch factor must be positive — got {factor}") 1232 1233 events = tuple( 1234 dataclasses.replace(e, beat=e.beat * factor, duration=e.duration * factor) 1235 for e in self.events 1236 ) 1237 controls = tuple( 1238 dataclasses.replace(c, beat=c.beat * factor, span=c.span * factor) 1239 for c in self.controls 1240 ) 1241 1242 return Motif(events=events, length=self.length * factor, controls=controls, fit=self.fit)
Scale time by factor (2.0 = half-time feel): beats, durations, spans, and length.
1244 def quantize (self, grid: float) -> "Motif": 1245 1246 """Snap note onsets to the nearest multiple of *grid* beats (control gestures untouched). 1247 1248 An onset exactly midway between grid lines snaps LATER (round half 1249 up) — every midpoint moves the same way, the predictable behaviour 1250 for a musician. (Python's own ``round()`` is half-to-even, which 1251 made exact midpoints snap in alternating directions.) 1252 """ 1253 1254 if grid <= 0: 1255 raise ValueError(f"Quantize grid must be positive — got {grid}") 1256 1257 events = tuple( 1258 dataclasses.replace(e, beat=math.floor(e.beat / grid + 0.5) * grid) 1259 for e in self.events 1260 ) 1261 1262 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
Snap note onsets to the nearest multiple of grid beats (control gestures untouched).
An onset exactly midway between grid lines snaps LATER (round half
up) — every midpoint moves the same way, the predictable behaviour
for a musician. (Python's own round() is half-to-even, which
made exact midpoints snap in alternating directions.)
1264 def accent (self, beat: float, amount: int = 20) -> "Motif": 1265 1266 """Add *amount* velocity to every note at the given beat position (0-based beats).""" 1267 1268 def boost (velocity: typing.Union[int, typing.Tuple[int, int]]) -> typing.Union[int, typing.Tuple[int, int]]: 1269 # Clamp both ends: a negative amount (a de-accent) must not store 1270 # a velocity below 1, which MIDI cannot play. 1271 if isinstance(velocity, tuple): 1272 return (max(1, min(127, velocity[0] + amount)), max(1, min(127, velocity[1] + amount))) 1273 return max(1, min(127, velocity + amount)) 1274 1275 events = tuple( 1276 dataclasses.replace(e, velocity=boost(e.velocity)) if abs(e.beat - beat) < 1e-9 else e 1277 for e in self.events 1278 ) 1279 1280 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
Add amount velocity to every note at the given beat position (0-based beats).
1282 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Motif": 1283 1284 """Replace every note's velocity (an int, or a ``(low, high)`` random range).""" 1285 1286 events = tuple(dataclasses.replace(e, velocity=velocity) for e in self.events) 1287 1288 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
Replace every note's velocity (an int, or a (low, high) random range).
1318 def vary ( 1319 self, 1320 notes: int = 1, 1321 position: str = "end", 1322 seed: typing.Optional[int] = None, 1323 rng: typing.Optional[random.Random] = None, 1324 keep_contour: bool = False, 1325 ) -> "Motif": 1326 1327 """Replace a few pitches, preserving the rhythm — the smallest variation. 1328 1329 Rhythm, velocities, durations, rests, and control gestures are 1330 untouched; only the chosen notes' pitches move (by a small melodic 1331 nudge: scale steps for degrees, semitones for MIDI ints). 1332 1333 Parameters: 1334 notes: How many pitched notes to vary (clamped to what exists). 1335 position: Which notes — ``"end"`` (the tail, the default), 1336 ``"start"``, or ``"anywhere"`` (drawn from the stream). 1337 seed: Seed for the variation. A standalone vary without a seed 1338 warns — module-level nondeterminism breaks live reload. 1339 rng: An explicit random stream (overrides ``seed``; used by 1340 recipe machinery). 1341 keep_contour: When True, the variation preserves the line's 1342 CSEG — every varied note keeps its rank relations with 1343 every other note, so the melodic shape is identical (the 1344 motif-identity guard). Where no nudge can preserve the 1345 contour, that note stays unchanged — shape wins over 1346 motion. 1347 1348 Example: 1349 ```python 1350 answer = call.vary(notes=1, seed=4) # same figure, new tail note 1351 ``` 1352 """ 1353 1354 if notes < 0: 1355 raise ValueError(f"notes must be at least 0, got {notes}") 1356 if position not in ("end", "start", "anywhere"): 1357 raise ValueError(f'position must be "end", "start", or "anywhere" — got {position!r}') 1358 1359 if rng is None: 1360 if seed is None: 1361 warnings.warn( 1362 "vary() without seed= is nondeterministic — pass seed= so the " 1363 "value survives live reload", 1364 stacklevel = 2, 1365 ) 1366 rng = random.Random() 1367 else: 1368 rng = random.Random(seed) 1369 1370 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1371 count = min(notes, len(pitched_indices)) 1372 1373 if count == 0: 1374 return self 1375 1376 if position == "end": 1377 chosen = pitched_indices[-count:] 1378 elif position == "start": 1379 chosen = pitched_indices[:count] 1380 else: 1381 chosen = sorted(rng.sample(pitched_indices, count)) 1382 1383 events = list(self.events) 1384 1385 for index in chosen: 1386 if keep_contour: 1387 replacement = self._contour_safe_nudge(events, index, pitched_indices, rng) 1388 if replacement is not None: 1389 events[index] = dataclasses.replace(events[index], pitch = replacement) 1390 else: 1391 events[index] = dataclasses.replace(events[index], pitch = self._nudged_pitch(events[index].pitch, rng)) 1392 1393 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
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). - keep_contour: When True, the variation preserves the line's CSEG — every varied note keeps its rank relations with every other note, so the melodic shape is identical (the motif-identity guard). Where no nudge can preserve the contour, that note stays unchanged — shape wins over motion.
Example:
answer = call.vary(notes=1, seed=4) # same figure, new tail note
1463 def answer (self, to: typing.Union[int, Degree] = 1) -> "Motif": 1464 1465 """Call → response: re-aim the tail to a stable degree. 1466 1467 The classic consequent move — the figure repeats but its last pitched 1468 note lands home (degree 1 by default; pass ``to=5`` for a half-close, 1469 or a full ``Degree`` for register control). Everything else — 1470 rhythm, the other pitches, velocities, controls — is untouched. 1471 1472 Degree content only: absolute MIDI has no degrees to re-aim (build 1473 the call with ``motif([...])``), and drums raise. 1474 """ 1475 1476 target = to if isinstance(to, Degree) else Degree(int(to)) 1477 1478 pitched_indices = [index for index, event in enumerate(self.events) if event.pitch is not None] 1479 1480 if not pitched_indices: 1481 return self 1482 1483 last = self.events[pitched_indices[-1]] 1484 1485 if not isinstance(last.pitch, Degree): 1486 raise TypeError( 1487 f"answer() re-aims scale degrees — the tail is {type(last.pitch).__name__} " 1488 "content (build the call with motif([...]) for degree content)" 1489 ) 1490 1491 if isinstance(to, int): 1492 # Keep the call's register: only the step is re-aimed. 1493 target = dataclasses.replace(last.pitch, step = int(to), chroma = 0) 1494 1495 events = list(self.events) 1496 events[pitched_indices[-1]] = dataclasses.replace(last, pitch = target) 1497 1498 return Motif(events = tuple(events), length = self.length, controls = self.controls, fit = self.fit)
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.
1500 def pitched (self, spec: PitchSpec) -> "Motif": 1501 1502 """ 1503 Replace every pitch with one spec — a kick rhythm becomes a bass line. 1504 1505 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"`` become chord 1506 tones; any other string is a drum name; ints are MIDI; Degree / 1507 ChordTone / Approach pass through. 1508 """ 1509 1510 if isinstance(spec, str) and spec in _CHORD_TONE_NAMES: 1511 spec = ChordTone(spec) 1512 1513 events = tuple(dataclasses.replace(e, pitch=spec) for e in self.events) 1514 1515 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
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.
1517 def rhythm (self) -> "Motif": 1518 1519 """ 1520 Strip pitches (and control gestures): a reusable rhythmic skeleton. 1521 1522 Timing, velocities, durations, and probabilities survive; re-pitch 1523 with :meth:`pitched` before placement (placing a skeleton raises). 1524 """ 1525 1526 events = tuple(dataclasses.replace(e, pitch=None) for e in self.events) 1527 1528 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).
1530 def onsets (self) -> typing.List[float]: 1531 1532 """The note onset beats, in order — ready for rhythm-first generation.""" 1533 1534 return [e.beat for e in self.events]
The note onset beats, in order — ready for rhythm-first generation.
1536 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Motif": 1537 1538 """ 1539 Transpose pitched content; the keyword names the unit. 1540 1541 ``steps=`` moves scale degrees diatonically (the sequencing move) and 1542 raises on absolute-MIDI or drum content; ``semitones=`` is the 1543 literal chromatic form for MIDI ints and degrees. Drum motifs raise 1544 on both — a transposed drum name is a different instrument, not a 1545 transposition. 1546 """ 1547 1548 if (steps is None) == (semitones is None): 1549 raise ValueError("transpose() takes exactly one of steps= or semitones=") 1550 1551 def move (pitch: PitchSpec) -> PitchSpec: 1552 1553 if pitch is None: 1554 return None 1555 1556 if isinstance(pitch, Approach): 1557 moved = move(pitch.target) 1558 if not isinstance(moved, (int, Degree, ChordTone)): 1559 raise TypeError(f"transpose cannot aim an Approach at {type(moved).__name__} content") 1560 return Approach(moved) 1561 1562 if steps is not None: 1563 if isinstance(pitch, Degree): 1564 return dataclasses.replace(pitch, step=pitch.step + steps) 1565 raise TypeError( 1566 f"transpose(steps=) moves scale degrees — {type(pitch).__name__} content " 1567 f"has no degrees (use semitones= for MIDI ints)" 1568 ) 1569 1570 assert semitones is not None # exactly one of steps/semitones is set (validated above) 1571 1572 if isinstance(pitch, int): 1573 return pitch + semitones 1574 if isinstance(pitch, Degree): 1575 return dataclasses.replace(pitch, chroma=pitch.chroma + semitones) 1576 raise TypeError(f"transpose(semitones=) cannot move {type(pitch).__name__} content") 1577 1578 events = tuple(dataclasses.replace(e, pitch=move(e.pitch)) for e in self.events) 1579 1580 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
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.
1582 def invert (self, pivot: typing.Optional[int] = None) -> "Motif": 1583 1584 """ 1585 Mirror pitches around a pivot: MIDI content around a MIDI pivot, 1586 degree content around a degree pivot (default: the first note's pitch). 1587 Drum motifs raise. 1588 """ 1589 1590 pitched_events = [e for e in self.events if e.pitch is not None] 1591 1592 if not pitched_events: 1593 return self 1594 1595 first = pitched_events[0].pitch 1596 1597 if pivot is None: 1598 if isinstance(first, int): 1599 pivot = first 1600 elif isinstance(first, Degree): 1601 pivot = first.step 1602 else: 1603 raise TypeError(f"invert() cannot derive a pivot from {type(first).__name__} content") 1604 1605 def mirror (pitch: PitchSpec) -> PitchSpec: 1606 1607 if pitch is None: 1608 return None 1609 if isinstance(pitch, int): 1610 return 2 * pivot - pitch 1611 if isinstance(pitch, Degree): 1612 mirrored = 2 * pivot - pitch.step 1613 if mirrored < 1: 1614 raise ValueError( 1615 f"invert() around degree {pivot} sends degree {pitch.step} below the tonic — " 1616 f"raise the pivot or use Degree octaves" 1617 ) 1618 # Reflection around the pivot (read at octave 0) is an isometry, so a 1619 # note's register flips too: a degree an octave above the pivot lands an 1620 # octave below it. Negating octave needs no scale length and leaves 1621 # octave-0 content unchanged. 1622 return dataclasses.replace(pitch, step=mirrored, octave=-pitch.octave, chroma=-pitch.chroma) 1623 raise TypeError(f"invert() cannot mirror {type(pitch).__name__} content") 1624 1625 events = tuple(dataclasses.replace(e, pitch=mirror(e.pitch)) for e in self.events) 1626 1627 return Motif(events=events, length=self.length, controls=self.controls, fit=self.fit)
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.
1631 def describe (self) -> str: 1632 1633 """A readable one-line summary: length, notes (pitch@beat), and control gestures.""" 1634 1635 notes = ", ".join(f"{_pitch_label(e.pitch)}@{e.beat:g}" for e in self.events) 1636 parts = [f"Motif {self.length:g} beats", f"[{notes}]" if notes else "[no notes]"] 1637 1638 if self.controls: 1639 gestures = ", ".join(_control_label(c) for c in self.controls) 1640 parts.append(f"controls [{gestures}]") 1641 1642 return " ".join(parts)
A readable one-line summary: length, notes (pitch@beat), and control gestures.
1782@dataclasses.dataclass(frozen=True) 1783class Phrase: 1784 1785 """ 1786 A sequence of Motifs with segmentation preserved. 1787 1788 Segmentation is the unit of editing — it is what development and 1789 per-region regeneration operate on. ``flatten()`` erases it into one 1790 long Motif. Length is the sum of segment lengths. 1791 1792 A phrase made by :meth:`develop` carries its recipe, so 1793 :meth:`reroll` can regenerate a region; transforms and hand edits 1794 return recipe-less phrases (their notes no longer come from the 1795 recipe, so there is nothing honest to regenerate from). 1796 """ 1797 1798 segments: typing.Tuple[Motif, ...] 1799 recipe: typing.Optional[_PhraseRecipe] 1800 1801 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1802 1803 """Coerce any iterable of Motifs.""" 1804 1805 segments = tuple(segments) 1806 1807 for segment in segments: 1808 if not isinstance(segment, Motif): 1809 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1810 1811 object.__setattr__(self, "segments", segments) 1812 object.__setattr__(self, "recipe", recipe) 1813 1814 @property 1815 def length (self) -> float: 1816 1817 """Total length in beats (sum of segment lengths).""" 1818 1819 return sum(segment.length for segment in self.segments) 1820 1821 @classmethod 1822 def develop ( 1823 cls, 1824 motif: Motif, 1825 bars: int = 8, 1826 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1827 seed: typing.Optional[int] = None, 1828 beats_per_bar: float = 4.0, 1829 ) -> "Phrase": 1830 1831 """Grow a motif into a phrase by a plan — the phrase generator. 1832 1833 ``plan`` follows the standard form. The literal form is a **list of 1834 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1835 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1836 label is a generated contrast unit (the source's rhythm, freshly 1837 re-pitched), a repeated label is a restatement, and *bars* spreads 1838 evenly across the units. A bare string is a **recipe name** from 1839 the curated table — ``plan="call_response"`` (call, answer, call, 1840 varied answer) — reserved for plans whose semantics exceed a label 1841 skeleton. A letter string is not a plan: a sequence of labels is a 1842 sequence, so it is a list. 1843 1844 The result carries its recipe, so :meth:`reroll` can regenerate a 1845 region later. 1846 1847 Parameters: 1848 motif: The source unit (its length must be ``bars / len(units)`` 1849 bars — the plan's units tile the phrase exactly). 1850 bars: Phrase length in bars (must divide evenly by the unit 1851 count). 1852 plan: A list of unit labels, or a recipe name. 1853 seed: Seed for the generated units. Without one, develop() 1854 warns — module-level nondeterminism breaks live reload. 1855 beats_per_bar: Bar size in beats (the value is context-free; 1856 4 is the common-time default). 1857 1858 Example: 1859 ```python 1860 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1861 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1862 ``` 1863 """ 1864 1865 if plan is None: 1866 raise ValueError( 1867 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1868 'or a recipe name (plan="call_response")' 1869 ) 1870 1871 if seed is None: 1872 warnings.warn( 1873 "develop() without seed= is nondeterministic — pass seed= so the " 1874 "value survives live reload", 1875 stacklevel = 2, 1876 ) 1877 1878 # How many units the plan asks for — known before any unit is built, 1879 # so a short motif can tile up to the unit size first. 1880 if isinstance(plan, str): 1881 if plan not in _PHRASE_RECIPES: 1882 known = ", ".join(sorted(_PHRASE_RECIPES)) 1883 hint = "" 1884 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1885 spelled = ", ".join(repr(c) for c in plan) 1886 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1887 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1888 unit_count = _PHRASE_RECIPES[plan][0] 1889 else: 1890 labels = list(plan) 1891 if not labels or not all(isinstance(label, str) and label for label in labels): 1892 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1893 unit_count = len(labels) 1894 1895 source = _tile_source(motif, bars, unit_count, beats_per_bar) 1896 1897 # An unseeded call draws a fresh salt so repeated calls genuinely 1898 # differ, as the warning above promises — interpolating None gave the 1899 # FIXED seed "None:..." and silently returned the same phrase every 1900 # time. 1901 salt = seed if seed is not None else random.randrange(2 ** 32) 1902 1903 if isinstance(plan, str): 1904 units = _PHRASE_RECIPES[plan][1](source, salt) 1905 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1906 else: 1907 generated: typing.Dict[str, Motif] = {labels[0]: source} 1908 for label in labels: 1909 if label not in generated: 1910 generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}")) 1911 units = [generated[label] for label in labels] 1912 stored_plan = tuple(labels) 1913 1914 return cls(units, recipe = _PhraseRecipe( 1915 source = motif, 1916 plan = stored_plan, 1917 bars = bars, 1918 seed = seed, 1919 beats_per_bar = beats_per_bar, 1920 )) 1921 1922 def reroll ( 1923 self, 1924 bar: typing.Optional[int] = None, 1925 bars: typing.Optional[typing.Sequence[int]] = None, 1926 seed: typing.Optional[int] = None, 1927 ) -> "Phrase": 1928 1929 """Regenerate only the named bars — rhythm and boundary pitches kept. 1930 1931 Within each named bar, the first and last pitched notes stay (the 1932 boundary pins) and the interior pitches re-roll from a fresh per-bar 1933 stream salted by ``seed=`` (an unseeded call draws a fresh salt, so 1934 each call genuinely differs); onsets, durations, velocities, rests, 1935 drums, and control gestures are untouched. Segmentation and the 1936 recipe survive, so rerolls compose. 1937 1938 Only a phrase that carries a recipe can reroll — a hand-written or 1939 transformed phrase raises loudly (its notes no longer come from a 1940 generator, so regenerating them would invent music). 1941 1942 Parameters: 1943 bar: A single 1-based bar to reroll. 1944 bars: A list of 1-based bars (the paired plural spelling). 1945 seed: Seed for the new pitches (salted per bar). Without one, 1946 reroll() warns. 1947 1948 Example: 1949 ```python 1950 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 1951 ``` 1952 """ 1953 1954 if self.recipe is None: 1955 raise ValueError( 1956 "this phrase carries no recipe (it was written by hand, or transformed " 1957 "since generation) — reroll() regenerates from a recipe; edit segments " 1958 "with replace(), or rebuild with Phrase.develop()" 1959 ) 1960 1961 if (bar is None) == (bars is None): 1962 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 1963 1964 region = [bar] if bar is not None else list(bars or []) 1965 beats_per_bar = self.recipe.beats_per_bar 1966 total_bars = int(round(self.length / beats_per_bar)) 1967 1968 for number in region: 1969 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 1970 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 1971 1972 if seed is None: 1973 warnings.warn( 1974 "reroll() without seed= is nondeterministic — pass seed= so the " 1975 "value survives live reload", 1976 stacklevel = 2, 1977 ) 1978 1979 # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would 1980 # "re-roll" to the identical pitches every time. 1981 salt = seed if seed is not None else random.randrange(2 ** 32) 1982 1983 windows = [ 1984 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}")) 1985 for number in sorted(set(region)) 1986 ] 1987 1988 new_segments: typing.List[Motif] = [] 1989 offset = 0.0 1990 1991 for segment in self.segments: 1992 1993 events = list(segment.events) 1994 1995 for window_start, window_end, rng in windows: 1996 1997 inside = [ 1998 index for index, event in enumerate(events) 1999 if window_start <= offset + event.beat < window_end 2000 and event.pitch is not None and not isinstance(event.pitch, str) 2001 ] 2002 2003 # Boundary pins: the first and last pitched notes of the bar 2004 # stay; only the interior re-rolls. 2005 for index in inside[1:-1]: 2006 events[index] = dataclasses.replace( 2007 events[index], 2008 pitch = segment._nudged_pitch(events[index].pitch, rng), 2009 ) 2010 2011 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 2012 offset += segment.length 2013 2014 return Phrase(new_segments, recipe = self.recipe) 2015 2016 def flatten (self) -> Motif: 2017 2018 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 2019 2020 return Motif.join(self.segments) 2021 2022 # ── algebra ───────────────────────────────────────────────────────── 2023 2024 def __add__ (self, other: typing.Any) -> "Phrase": 2025 2026 """Append a Motif segment, or concatenate another Phrase's segments.""" 2027 2028 if isinstance(other, Motif): 2029 return Phrase(self.segments + (other,)) 2030 if isinstance(other, Phrase): 2031 return Phrase(self.segments + other.segments) 2032 2033 return NotImplemented 2034 2035 def __radd__ (self, other: typing.Any) -> "Phrase": 2036 2037 """A Motif on the left prepends as a segment.""" 2038 2039 if isinstance(other, Motif): 2040 return Phrase((other,) + self.segments) 2041 2042 return NotImplemented 2043 2044 def __mul__ (self, count: int) -> "Phrase": 2045 2046 """Tile the segments *count* times.""" 2047 2048 if not isinstance(count, int): 2049 return NotImplemented 2050 if count < 0: 2051 raise ValueError(f"Repetition count must be non-negative — got {count}") 2052 2053 return Phrase(self.segments * count) 2054 2055 __rmul__ = __mul__ 2056 2057 def __and__ (self, other: typing.Any) -> Motif: 2058 2059 """Parallel merge is vertical: Phrase operands flatten to Motif first.""" 2060 2061 if isinstance(other, (Motif, Phrase)): 2062 return self.flatten().stack(other) 2063 2064 return NotImplemented 2065 2066 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 2067 2068 """The spelled form of ``&`` — flattens, then merges.""" 2069 2070 return self.flatten().stack(other) 2071 2072 def slice (self, start: float, end: float) -> "Phrase": 2073 2074 """A window; re-segments at the cut points (partial segments are sliced).""" 2075 2076 segments = [] 2077 offset = 0.0 2078 2079 for segment in self.segments: 2080 seg_start, seg_end = offset, offset + segment.length 2081 lo, hi = max(start, seg_start), min(end, seg_end) 2082 if lo < hi: 2083 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 2084 offset = seg_end 2085 2086 return Phrase(segments) 2087 2088 def replace (self, position: int, motif: Motif) -> "Phrase": 2089 2090 """Replace the segment at a 1-based position (musicians count from one).""" 2091 2092 if not 1 <= position <= len(self.segments): 2093 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 2094 2095 segments = list(self.segments) 2096 segments[position - 1] = motif 2097 2098 return Phrase(segments) 2099 2100 # ── transforms: lifted segment-wise, except time-reordering ───────── 2101 2102 def reverse (self) -> "Phrase": 2103 2104 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 2105 2106 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments))) 2107 2108 def rotate (self, beats: float) -> "Phrase": 2109 2110 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 2111 2112 flat = self.flatten().rotate(beats) 2113 segments = [] 2114 offset = 0.0 2115 2116 # Re-segment by onset (events keep their full durations — a note may 2117 # ring past its new segment, exactly as it does on the flat timeline). 2118 for segment in self.segments: 2119 lo, hi = offset, offset + segment.length 2120 segments.append(Motif( 2121 events = tuple( 2122 dataclasses.replace(e, beat=e.beat - lo) 2123 for e in flat.events if lo <= e.beat < hi 2124 ), 2125 length = segment.length, 2126 controls = tuple( 2127 dataclasses.replace(c, beat=c.beat - lo) 2128 for c in flat.controls if lo <= c.beat < hi 2129 ), 2130 )) 2131 offset = hi 2132 2133 return Phrase(segments) 2134 2135 def _lift (self, name: str, *args: typing.Any, **kwargs: typing.Any) -> "Phrase": 2136 2137 """Apply a Motif transform to every segment.""" 2138 2139 return Phrase(tuple(getattr(segment, name)(*args, **kwargs) for segment in self.segments)) 2140 2141 def stretch (self, factor: float) -> "Phrase": 2142 2143 """Scale time in every segment (lengths scale with them).""" 2144 2145 return self._lift("stretch", factor) 2146 2147 def quantize (self, grid: float) -> "Phrase": 2148 2149 """Snap note onsets segment-wise.""" 2150 2151 return self._lift("quantize", grid) 2152 2153 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase": 2154 2155 """Replace every note's velocity, segment-wise.""" 2156 2157 return self._lift("with_velocity", velocity) 2158 2159 def pitched (self, spec: PitchSpec) -> "Phrase": 2160 2161 """Replace every pitch, segment-wise.""" 2162 2163 return self._lift("pitched", spec) 2164 2165 def rhythm (self) -> "Phrase": 2166 2167 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 2168 2169 return self._lift("rhythm") 2170 2171 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 2172 2173 """Transpose every segment (see :meth:`Motif.transpose`).""" 2174 2175 return self._lift("transpose", steps=steps, semitones=semitones) 2176 2177 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 2178 2179 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 2180 2181 if pivot is None: 2182 for segment in self.segments: 2183 for event in segment.events: 2184 if event.pitch is not None: 2185 if isinstance(event.pitch, int): 2186 pivot = event.pitch 2187 elif isinstance(event.pitch, Degree): 2188 pivot = event.pitch.step 2189 break 2190 if pivot is not None: 2191 break 2192 2193 return self._lift("invert", pivot=pivot) 2194 2195 def describe (self) -> str: 2196 2197 """A readable summary: total length and each segment on its own line.""" 2198 2199 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 2200 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 2201 2202 return "\n".join([header] + lines) 2203 2204 def __str__ (self) -> str: 2205 2206 """Printable form (same as :meth:`describe`).""" 2207 2208 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).
1801 def __init__ (self, segments: typing.Iterable[Motif], recipe: typing.Optional[_PhraseRecipe] = None) -> None: 1802 1803 """Coerce any iterable of Motifs.""" 1804 1805 segments = tuple(segments) 1806 1807 for segment in segments: 1808 if not isinstance(segment, Motif): 1809 raise TypeError(f"Phrase segments must be Motifs — got {type(segment).__name__}") 1810 1811 object.__setattr__(self, "segments", segments) 1812 object.__setattr__(self, "recipe", recipe)
Coerce any iterable of Motifs.
1814 @property 1815 def length (self) -> float: 1816 1817 """Total length in beats (sum of segment lengths).""" 1818 1819 return sum(segment.length for segment in self.segments)
Total length in beats (sum of segment lengths).
1821 @classmethod 1822 def develop ( 1823 cls, 1824 motif: Motif, 1825 bars: int = 8, 1826 plan: typing.Optional[typing.Union[typing.Sequence[str], str]] = None, 1827 seed: typing.Optional[int] = None, 1828 beats_per_bar: float = 4.0, 1829 ) -> "Phrase": 1830 1831 """Grow a motif into a phrase by a plan — the phrase generator. 1832 1833 ``plan`` follows the standard form. The literal form is a **list of 1834 unit labels** — ``plan=["a", "a", "a", "b"]``, equivalently 1835 ``["a"] * 3 + ["b"]``: the first label is the given motif, each new 1836 label is a generated contrast unit (the source's rhythm, freshly 1837 re-pitched), a repeated label is a restatement, and *bars* spreads 1838 evenly across the units. A bare string is a **recipe name** from 1839 the curated table — ``plan="call_response"`` (call, answer, call, 1840 varied answer) — reserved for plans whose semantics exceed a label 1841 skeleton. A letter string is not a plan: a sequence of labels is a 1842 sequence, so it is a list. 1843 1844 The result carries its recipe, so :meth:`reroll` can regenerate a 1845 region later. 1846 1847 Parameters: 1848 motif: The source unit (its length must be ``bars / len(units)`` 1849 bars — the plan's units tile the phrase exactly). 1850 bars: Phrase length in bars (must divide evenly by the unit 1851 count). 1852 plan: A list of unit labels, or a recipe name. 1853 seed: Seed for the generated units. Without one, develop() 1854 warns — module-level nondeterminism breaks live reload. 1855 beats_per_bar: Bar size in beats (the value is context-free; 1856 4 is the common-time default). 1857 1858 Example: 1859 ```python 1860 call = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 1861 lead = subsequence.Phrase.develop(call, bars=8, plan="call_response", seed=11) 1862 ``` 1863 """ 1864 1865 if plan is None: 1866 raise ValueError( 1867 'develop() needs a plan= — a list of unit labels (plan=["a", "a", "a", "b"]) ' 1868 'or a recipe name (plan="call_response")' 1869 ) 1870 1871 if seed is None: 1872 warnings.warn( 1873 "develop() without seed= is nondeterministic — pass seed= so the " 1874 "value survives live reload", 1875 stacklevel = 2, 1876 ) 1877 1878 # How many units the plan asks for — known before any unit is built, 1879 # so a short motif can tile up to the unit size first. 1880 if isinstance(plan, str): 1881 if plan not in _PHRASE_RECIPES: 1882 known = ", ".join(sorted(_PHRASE_RECIPES)) 1883 hint = "" 1884 if plan.isalpha() and plan == plan.lower() and len(set(plan)) < len(plan): 1885 spelled = ", ".join(repr(c) for c in plan) 1886 hint = f" A letter string is not a plan — a sequence of labels is a list: plan=[{spelled}]." 1887 raise ValueError(f"Unknown phrase recipe {plan!r}. Known recipes: {known}.{hint}") 1888 unit_count = _PHRASE_RECIPES[plan][0] 1889 else: 1890 labels = list(plan) 1891 if not labels or not all(isinstance(label, str) and label for label in labels): 1892 raise ValueError("plan labels must be non-empty strings, e.g. plan=['a', 'a', 'b']") 1893 unit_count = len(labels) 1894 1895 source = _tile_source(motif, bars, unit_count, beats_per_bar) 1896 1897 # An unseeded call draws a fresh salt so repeated calls genuinely 1898 # differ, as the warning above promises — interpolating None gave the 1899 # FIXED seed "None:..." and silently returned the same phrase every 1900 # time. 1901 salt = seed if seed is not None else random.randrange(2 ** 32) 1902 1903 if isinstance(plan, str): 1904 units = _PHRASE_RECIPES[plan][1](source, salt) 1905 stored_plan: typing.Union[typing.Tuple[str, ...], str] = plan 1906 else: 1907 generated: typing.Dict[str, Motif] = {labels[0]: source} 1908 for label in labels: 1909 if label not in generated: 1910 generated[label] = _contrast_unit(source, random.Random(f"{salt}:unit:{label}")) 1911 units = [generated[label] for label in labels] 1912 stored_plan = tuple(labels) 1913 1914 return cls(units, recipe = _PhraseRecipe( 1915 source = motif, 1916 plan = stored_plan, 1917 bars = bars, 1918 seed = seed, 1919 beats_per_bar = beats_per_bar, 1920 ))
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 labels — plan=["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)
1922 def reroll ( 1923 self, 1924 bar: typing.Optional[int] = None, 1925 bars: typing.Optional[typing.Sequence[int]] = None, 1926 seed: typing.Optional[int] = None, 1927 ) -> "Phrase": 1928 1929 """Regenerate only the named bars — rhythm and boundary pitches kept. 1930 1931 Within each named bar, the first and last pitched notes stay (the 1932 boundary pins) and the interior pitches re-roll from a fresh per-bar 1933 stream salted by ``seed=`` (an unseeded call draws a fresh salt, so 1934 each call genuinely differs); onsets, durations, velocities, rests, 1935 drums, and control gestures are untouched. Segmentation and the 1936 recipe survive, so rerolls compose. 1937 1938 Only a phrase that carries a recipe can reroll — a hand-written or 1939 transformed phrase raises loudly (its notes no longer come from a 1940 generator, so regenerating them would invent music). 1941 1942 Parameters: 1943 bar: A single 1-based bar to reroll. 1944 bars: A list of 1-based bars (the paired plural spelling). 1945 seed: Seed for the new pitches (salted per bar). Without one, 1946 reroll() warns. 1947 1948 Example: 1949 ```python 1950 lead = lead.reroll(bar=7, seed=4) # only bar 7; rhythm + boundaries kept 1951 ``` 1952 """ 1953 1954 if self.recipe is None: 1955 raise ValueError( 1956 "this phrase carries no recipe (it was written by hand, or transformed " 1957 "since generation) — reroll() regenerates from a recipe; edit segments " 1958 "with replace(), or rebuild with Phrase.develop()" 1959 ) 1960 1961 if (bar is None) == (bars is None): 1962 raise ValueError("reroll() takes exactly one of bar= (an int) or bars= (a list)") 1963 1964 region = [bar] if bar is not None else list(bars or []) 1965 beats_per_bar = self.recipe.beats_per_bar 1966 total_bars = int(round(self.length / beats_per_bar)) 1967 1968 for number in region: 1969 if not isinstance(number, int) or isinstance(number, bool) or not 1 <= number <= total_bars: 1970 raise ValueError(f"bar {number!r} is outside this phrase (1–{total_bars})") 1971 1972 if seed is None: 1973 warnings.warn( 1974 "reroll() without seed= is nondeterministic — pass seed= so the " 1975 "value survives live reload", 1976 stacklevel = 2, 1977 ) 1978 1979 # Unseeded rerolls draw a fresh salt — a fixed "None:..." seed would 1980 # "re-roll" to the identical pitches every time. 1981 salt = seed if seed is not None else random.randrange(2 ** 32) 1982 1983 windows = [ 1984 ((number - 1) * beats_per_bar, number * beats_per_bar, random.Random(f"{salt}:reroll:{number}")) 1985 for number in sorted(set(region)) 1986 ] 1987 1988 new_segments: typing.List[Motif] = [] 1989 offset = 0.0 1990 1991 for segment in self.segments: 1992 1993 events = list(segment.events) 1994 1995 for window_start, window_end, rng in windows: 1996 1997 inside = [ 1998 index for index, event in enumerate(events) 1999 if window_start <= offset + event.beat < window_end 2000 and event.pitch is not None and not isinstance(event.pitch, str) 2001 ] 2002 2003 # Boundary pins: the first and last pitched notes of the bar 2004 # stay; only the interior re-rolls. 2005 for index in inside[1:-1]: 2006 events[index] = dataclasses.replace( 2007 events[index], 2008 pitch = segment._nudged_pitch(events[index].pitch, rng), 2009 ) 2010 2011 new_segments.append(Motif(events = tuple(events), length = segment.length, controls = segment.controls)) 2012 offset += segment.length 2013 2014 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 a fresh per-bar
stream salted by seed= (an unseeded call draws a fresh salt, so
each call genuinely differs); 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
2016 def flatten (self) -> Motif: 2017 2018 """Erase segmentation: one long Motif (the monoid homomorphism onto ``then``).""" 2019 2020 return Motif.join(self.segments)
Erase segmentation: one long Motif (the monoid homomorphism onto then).
2066 def stack (self, other: typing.Union[Motif, "Phrase"]) -> Motif: 2067 2068 """The spelled form of ``&`` — flattens, then merges.""" 2069 2070 return self.flatten().stack(other)
The spelled form of & — flattens, then merges.
2072 def slice (self, start: float, end: float) -> "Phrase": 2073 2074 """A window; re-segments at the cut points (partial segments are sliced).""" 2075 2076 segments = [] 2077 offset = 0.0 2078 2079 for segment in self.segments: 2080 seg_start, seg_end = offset, offset + segment.length 2081 lo, hi = max(start, seg_start), min(end, seg_end) 2082 if lo < hi: 2083 segments.append(segment.slice(lo - seg_start, hi - seg_start)) 2084 offset = seg_end 2085 2086 return Phrase(segments)
A window; re-segments at the cut points (partial segments are sliced).
2088 def replace (self, position: int, motif: Motif) -> "Phrase": 2089 2090 """Replace the segment at a 1-based position (musicians count from one).""" 2091 2092 if not 1 <= position <= len(self.segments): 2093 raise IndexError(f"Phrase has {len(self.segments)} segments — position {position} is out of range (1-based)") 2094 2095 segments = list(self.segments) 2096 segments[position - 1] = motif 2097 2098 return Phrase(segments)
Replace the segment at a 1-based position (musicians count from one).
2102 def reverse (self) -> "Phrase": 2103 2104 """Reverse the whole timeline: segments reverse order AND each reverses internally.""" 2105 2106 return Phrase(tuple(segment.reverse() for segment in reversed(self.segments)))
Reverse the whole timeline: segments reverse order AND each reverses internally.
2108 def rotate (self, beats: float) -> "Phrase": 2109 2110 """Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.""" 2111 2112 flat = self.flatten().rotate(beats) 2113 segments = [] 2114 offset = 0.0 2115 2116 # Re-segment by onset (events keep their full durations — a note may 2117 # ring past its new segment, exactly as it does on the flat timeline). 2118 for segment in self.segments: 2119 lo, hi = offset, offset + segment.length 2120 segments.append(Motif( 2121 events = tuple( 2122 dataclasses.replace(e, beat=e.beat - lo) 2123 for e in flat.events if lo <= e.beat < hi 2124 ), 2125 length = segment.length, 2126 controls = tuple( 2127 dataclasses.replace(c, beat=c.beat - lo) 2128 for c in flat.controls if lo <= c.beat < hi 2129 ), 2130 )) 2131 offset = hi 2132 2133 return Phrase(segments)
Rotate the whole timeline modulo the total length, then re-segment at the original boundaries.
2141 def stretch (self, factor: float) -> "Phrase": 2142 2143 """Scale time in every segment (lengths scale with them).""" 2144 2145 return self._lift("stretch", factor)
Scale time in every segment (lengths scale with them).
2147 def quantize (self, grid: float) -> "Phrase": 2148 2149 """Snap note onsets segment-wise.""" 2150 2151 return self._lift("quantize", grid)
Snap note onsets segment-wise.
2153 def with_velocity (self, velocity: typing.Union[int, typing.Tuple[int, int]]) -> "Phrase": 2154 2155 """Replace every note's velocity, segment-wise.""" 2156 2157 return self._lift("with_velocity", velocity)
Replace every note's velocity, segment-wise.
2159 def pitched (self, spec: PitchSpec) -> "Phrase": 2160 2161 """Replace every pitch, segment-wise.""" 2162 2163 return self._lift("pitched", spec)
Replace every pitch, segment-wise.
2165 def rhythm (self) -> "Phrase": 2166 2167 """Strip pitches segment-wise: a phrase-shaped skeleton.""" 2168 2169 return self._lift("rhythm")
Strip pitches segment-wise: a phrase-shaped skeleton.
2171 def transpose (self, steps: typing.Optional[int] = None, semitones: typing.Optional[int] = None) -> "Phrase": 2172 2173 """Transpose every segment (see :meth:`Motif.transpose`).""" 2174 2175 return self._lift("transpose", steps=steps, semitones=semitones)
Transpose every segment (see Motif.transpose()).
2177 def invert (self, pivot: typing.Optional[int] = None) -> "Phrase": 2178 2179 """Mirror pitches in every segment around one pivot (see :meth:`Motif.invert`).""" 2180 2181 if pivot is None: 2182 for segment in self.segments: 2183 for event in segment.events: 2184 if event.pitch is not None: 2185 if isinstance(event.pitch, int): 2186 pivot = event.pitch 2187 elif isinstance(event.pitch, Degree): 2188 pivot = event.pitch.step 2189 break 2190 if pivot is not None: 2191 break 2192 2193 return self._lift("invert", pivot=pivot)
Mirror pitches in every segment around one pivot (see Motif.invert()).
2195 def describe (self) -> str: 2196 2197 """A readable summary: total length and each segment on its own line.""" 2198 2199 header = f"Phrase {self.length:g} beats, {len(self.segments)} segments" 2200 lines = [f" {i + 1}. {segment.describe()}" for i, segment in enumerate(self.segments)] 2201 2202 return "\n".join([header] + lines)
A readable summary: total length and each segment on its own line.
2211def motif ( 2212 degrees: typing.List[typing.Union[int, Degree, None]], 2213 beats: typing.Optional[typing.List[float]] = None, 2214 velocities: typing.Any = _DEFAULT_VELOCITY, 2215 durations: typing.Any = 1.0, 2216 probabilities: typing.Any = 1.0, 2217 length: typing.Optional[float] = None, 2218) -> Motif: 2219 2220 """ 2221 The lowercase shortcut: a melody as 1-based scale degrees. 2222 2223 ``subsequence.motif([5, 6, 5, 3])`` is ``Motif.degrees([5, 6, 5, 3])`` — 2224 relative pitch is the primary form. For absolute MIDI note numbers use 2225 ``Motif.notes([64, 65, 64, 60])``; implausibly large ints here raise so 2226 a pasted MIDI list fails loud instead of squealing octaves up. 2227 """ 2228 2229 return Motif.degrees( 2230 degrees, 2231 beats = beats, 2232 velocities = velocities, 2233 durations = durations, 2234 probabilities = probabilities, 2235 length = length, 2236 )
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.
2239def sentence ( 2240 motif: Motif, 2241 bars: int = 8, 2242 cadence: str = "strong", 2243 seed: typing.Optional[int] = None, 2244 beats_per_bar: float = 4.0, 2245) -> Phrase: 2246 2247 """The classical sentence, as a thin combinator — idea, idea, drive, close. 2248 2249 Four units: the basic idea stated twice (the presentation), a generated 2250 contrast unit (the continuation — the source's rhythm, freshly 2251 re-pitched), and a second contrast unit whose tail lands on the 2252 cadence's close degree (the cadential close). An 8-bar sentence from a 2253 2-bar idea is the textbook proportion; a shorter idea tiles up to the 2254 unit size first. 2255 2256 The melodic side of a cadence only — pair it with the harmonic side 2257 (``prog.cadence()``, ``Progression.generate(cadence=)``, or 2258 ``request_cadence()``) and the two arrive together. 2259 2260 Parameters: 2261 motif: The basic idea (degree content — the close re-aims a degree). 2262 bars: Sentence length (must divide evenly across the 4 units). 2263 cadence: The close — ``"strong"`` lands on 1, ``"open"`` on 5, 2264 ``"soft"``/``"fakeout"`` on 1 (theory aliases accepted). 2265 seed: Seed for the generated continuation units (seed-or-warn). 2266 beats_per_bar: Bar size in beats (context-free; 4 is the default). 2267 2268 Example: 2269 ```python 2270 idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) 2271 verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11) 2272 ``` 2273 """ 2274 2275 spec = subsequence.cadences.cadence_formula(cadence) 2276 2277 if seed is None: 2278 warnings.warn( 2279 "sentence() without seed= is nondeterministic — pass seed= so the " 2280 "value survives live reload", 2281 stacklevel = 2, 2282 ) 2283 2284 source = _tile_source(motif, bars, 4, beats_per_bar) 2285 2286 # Unseeded calls draw a fresh salt (a fixed "None:..." seed would return 2287 # the same sentence every time, belying the warning above). 2288 salt = seed if seed is not None else random.randrange(2 ** 32) 2289 2290 continuation = _contrast_unit(source, random.Random(f"{salt}:sentence:continuation")) 2291 cadential = _contrast_unit(source, random.Random(f"{salt}:sentence:cadential")).answer(to = spec.close_degree) 2292 2293 return Phrase([source, source, continuation, cadential], recipe = _PhraseRecipe( 2294 source = motif, 2295 plan = "sentence", 2296 bars = bars, 2297 seed = seed, 2298 beats_per_bar = beats_per_bar, 2299 cadence = spec.name, 2300 ))
The classical sentence, as a thin combinator — idea, idea, drive, close.
Four units: the basic idea stated twice (the presentation), a generated contrast unit (the continuation — the source's rhythm, freshly re-pitched), and a second contrast unit whose tail lands on the cadence's close degree (the cadential close). An 8-bar sentence from a 2-bar idea is the textbook proportion; a shorter idea tiles up to the unit size first.
The melodic side of a cadence only — pair it with the harmonic side
(prog.cadence(), Progression.generate(cadence=), or
request_cadence()) and the two arrive together.
Arguments:
- motif: The basic idea (degree content — the close re-aims a degree).
- bars: Sentence length (must divide evenly across the 4 units).
- cadence: The close —
"strong"lands on 1,"open"on 5,"soft"/"fakeout"on 1 (theory aliases accepted). - seed: Seed for the generated continuation units (seed-or-warn).
- beats_per_bar: Bar size in beats (context-free; 4 is the default).
Example:
idea = subsequence.motif([5, 6, 5, 3, None, 1, 2, 3]) verse_lead = subsequence.sentence(idea, bars=8, cadence="open", seed=11)
2303def period ( 2304 antecedent: typing.Union[Motif, Phrase], 2305 cadence: str = "strong", 2306 beats_per_bar: float = 4.0, 2307) -> Phrase: 2308 2309 """The classical period, as a thin combinator — question, then answer. 2310 2311 Two halves: the antecedent with its tail re-aimed to the open half-close 2312 (degree 5 — the question), then the same material restated with its tail 2313 on the cadence's close degree (the answer). The two halves differ 2314 exactly at their closes — the open/closed contrast *is* the period. 2315 2316 Deterministic: no notes are generated, only the two tail notes re-aim 2317 (so there is no seed). Vary the consequent yourself for a looser 2318 restatement: ``period(a).reroll(bar=7, seed=4)``. 2319 2320 Parameters: 2321 antecedent: The first half — a Motif, or a Phrase whose segmentation 2322 is kept (only its last segment's tail re-aims). 2323 cadence: The consequent's close — ``"strong"`` lands on 1 (theory 2324 aliases accepted). 2325 beats_per_bar: Bar size in beats, recorded for ``reroll()`` windows. 2326 2327 Example: 2328 ```python 2329 idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8) 2330 lead = subsequence.period(idea) # 16 beats: half-close, then home 2331 ``` 2332 """ 2333 2334 spec = subsequence.cadences.cadence_formula(cadence) 2335 open_degree = subsequence.cadences.cadence_formula("open").close_degree 2336 2337 units = list(antecedent.segments) if isinstance(antecedent, Phrase) else [antecedent] 2338 2339 if not units or sum(unit.length for unit in units) <= 0: 2340 raise ValueError("cannot build a period from an empty antecedent") 2341 2342 tail = units[-1] 2343 2344 antecedent_units = units[:-1] + [tail.answer(to = open_degree)] 2345 consequent_units = units[:-1] + [tail.answer(to = spec.close_degree)] 2346 2347 source = antecedent.flatten() if isinstance(antecedent, Phrase) else antecedent 2348 total_beats = 2 * sum(unit.length for unit in units) 2349 2350 return Phrase(antecedent_units + consequent_units, recipe = _PhraseRecipe( 2351 source = source, 2352 plan = "period", 2353 bars = int(round(total_beats / beats_per_bar)), 2354 seed = None, 2355 beats_per_bar = beats_per_bar, 2356 cadence = spec.name, 2357 ))
The classical period, as a thin combinator — question, then answer.
Two halves: the antecedent with its tail re-aimed to the open half-close (degree 5 — the question), then the same material restated with its tail on the cadence's close degree (the answer). The two halves differ exactly at their closes — the open/closed contrast is the period.
Deterministic: no notes are generated, only the two tail notes re-aim
(so there is no seed). Vary the consequent yourself for a looser
restatement: period(a).reroll(bar=7, seed=4).
Arguments:
- antecedent: The first half — a Motif, or a Phrase whose segmentation is kept (only its last segment's tail re-aims).
- cadence: The consequent's close —
"strong"lands on 1 (theory aliases accepted). - beats_per_bar: Bar size in beats, recorded for
reroll()windows.
Example:
idea = subsequence.motif([3, 4, 5, 1, None, 6, 5, 4], length=8) lead = subsequence.period(idea) # 16 beats: half-close, then home
30@dataclasses.dataclass(frozen=True) 31class Cadence: 32 33 """One cadence formula — a named tail plus its melodic close. 34 35 Attributes: 36 name: The producer name (the primary key in the table). 37 theory_name: The traditional name, for the curious. 38 formula: The chord tail, in progression-element grammar, ending on 39 the arrival chord. 40 close_degree: The scale degree a melody lands on at this cadence 41 (1 for full closes; 5 for the open half — and 1 for the 42 fakeout too: the melody resolves as promised while the 43 harmony swerves, which is the trick of it). 44 """ 45 46 name: str 47 theory_name: str 48 formula: typing.Tuple[typing.Any, ...] 49 close_degree: int
One cadence formula — a named tail plus its melodic close.
Attributes:
- name: The producer name (the primary key in the table).
- theory_name: The traditional name, for the curious.
- formula: The chord tail, in progression-element grammar, ending on the arrival chord.
- close_degree: The scale degree a melody lands on at this cadence (1 for full closes; 5 for the open half — and 1 for the fakeout too: the melody resolves as promised while the harmony swerves, which is the trick of it).
22@dataclasses.dataclass(frozen=True) 23class Section: 24 25 """One section of a form — the payload home. 26 27 Attributes: 28 name: The section name (``"verse"``). 29 bars: Length in bars (≥ 1). 30 energy: The section's energy level (0.0–1.0; the arranging dial). 31 Read by ``p.energy`` and ``min_energy=`` gating; a 32 ``composition.energy()`` dict overrides it (the dict is the 33 later, performance-level dial). 34 key: Optional key override — re-anchors *key-relative* content 35 (degrees, romans, generated material, and key-relative section 36 progressions bound with ``section_chords``) to this section's 37 tonic. *Absolute* content (note names, MIDI pitches, frozen 38 chords) is never moved, and *chord-relative* content 39 (``ChordTone``, ``Approach``) tracks the sounding chord rather 40 than the key — see the three-intent model in the docs. The 41 live graph engine (``harmony(style=...)``) stays in the 42 composition key by design (a stateful walk does not transpose 43 mid-stream). 44 scale: Optional scale/mode override (e.g. ``"minor"``) — moves the 45 mode as well as the tonic, so a section can genuinely change 46 to the relative or parallel minor. Falls back to the form's 47 scale, then the composition's. 48 """ 49 50 name: str 51 bars: int 52 energy: float = 0.5 53 key: typing.Optional[str] = None 54 scale: typing.Optional[str] = None 55 56 def __post_init__ (self) -> None: 57 58 """Validate the payload loudly.""" 59 60 if not isinstance(self.name, str) or not self.name: 61 raise ValueError(f"a section needs a non-empty string name, got {self.name!r}") 62 63 if not isinstance(self.bars, int) or isinstance(self.bars, bool) or self.bars < 1: 64 raise ValueError(f"Section {self.name!r} must last at least 1 bar, got {self.bars!r}") 65 66 if not 0.0 <= float(self.energy) <= 1.0: 67 raise ValueError(f"Section {self.name!r} energy must be 0.0–1.0, got {self.energy!r}")
One section of a form — the payload home.
Attributes:
- name: The section name (
"verse"). - bars: Length in bars (≥ 1).
- energy: The section's energy level (0.0–1.0; the arranging dial).
Read by
p.energyandmin_energy=gating; acomposition.energy()dict overrides it (the dict is the later, performance-level dial). - key: Optional key override — re-anchors key-relative content
(degrees, romans, generated material, and key-relative section
progressions bound with
section_chords) to this section's tonic. Absolute content (note names, MIDI pitches, frozen chords) is never moved, and chord-relative content (ChordTone,Approach) tracks the sounding chord rather than the key — see the three-intent model in the docs. The live graph engine (harmony(style=...)) stays in the composition key by design (a stateful walk does not transpose mid-stream). - scale: Optional scale/mode override (e.g.
"minor") — moves the mode as well as the tonic, so a section can genuinely change to the relative or parallel minor. Falls back to the form's scale, then the composition's.
86@dataclasses.dataclass(frozen=True) 87class Form: 88 89 """A frozen sequence of Sections — the editable, bindable form value. 90 91 List-friendly: the constructor coerces ``("name", bars)`` tuples, so 92 ``Form([("verse", 8), ("chorus", 8)])`` and 93 ``Form([Section("verse", 8), Section("chorus", 8)])`` are the same value. 94 Repetition is Python list arithmetic before construction. 95 96 A form may carry its own ``key``/``scale`` — the **form tier** of the 97 key-source chain (``Section.key`` overrides it; it overrides the 98 composition key). A whole AABA in one key with one section borrowing 99 another is ``Form([...], key="A")`` plus a ``Section(..., key="F")``. 100 """ 101 102 sections: typing.Tuple[Section, ...] 103 key: typing.Optional[str] = None 104 scale: typing.Optional[str] = None 105 106 def __init__ ( 107 self, 108 sections: typing.Iterable[typing.Any], 109 key: typing.Optional[str] = None, 110 scale: typing.Optional[str] = None, 111 ) -> None: 112 113 """Coerce any iterable of Sections / (name, bars) tuples.""" 114 115 coerced = tuple(_coerce_section(element) for element in sections) 116 117 if not coerced: 118 raise ValueError("a Form needs at least one section") 119 120 object.__setattr__(self, "sections", coerced) 121 object.__setattr__(self, "key", key) 122 object.__setattr__(self, "scale", scale) 123 124 @property 125 def bars (self) -> int: 126 127 """Total length in bars.""" 128 129 return sum(section.bars for section in self.sections) 130 131 def __len__ (self) -> int: 132 133 """Number of sections.""" 134 135 return len(self.sections) 136 137 def __iter__ (self) -> typing.Iterator[Section]: 138 139 """Iterate the sections in order.""" 140 141 return iter(self.sections) 142 143 def __add__ (self, other: "Form") -> "Form": 144 145 """Sequential concatenation: ``intro_form + body_form``. 146 147 The **left** operand's form-tier ``key``/``scale`` survives (a single 148 value cannot hold two form keys); the right form's form-tier key is 149 dropped. Per-section ``Section.key``/``scale`` on either side is 150 preserved — the sections concatenate intact. 151 """ 152 153 if not isinstance(other, Form): 154 return NotImplemented 155 156 return Form(self.sections + other.sections, key = self.key, scale = self.scale) 157 158 def replace ( 159 self, 160 slot: int, 161 section: typing.Optional[Section] = None, 162 **changes: typing.Any, 163 ) -> "Form": 164 165 """Replace the section at a 1-based slot — whole, or by field. 166 167 ``form.replace(3, bars=16)`` stretches slot 3; 168 ``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out. 169 """ 170 171 index = _check_slot(slot, len(self.sections)) 172 173 if section is not None and changes: 174 raise ValueError("pass either a Section or field changes, not both") 175 176 if section is None: 177 if not changes: 178 raise ValueError("replace() needs a Section or field changes (bars=, energy=, key=, name=)") 179 section = dataclasses.replace(self.sections[index], **changes) 180 181 return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index + 1:], key = self.key, scale = self.scale) 182 183 def insert (self, slot: int, section: typing.Any) -> "Form": 184 185 """Insert a section *at* a 1-based slot (existing sections shift right). 186 187 ``slot`` may be ``len(form) + 1`` to append. 188 """ 189 190 if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= len(self.sections) + 1: 191 raise ValueError(f"slot {slot!r} is out of range (1–{len(self.sections) + 1})") 192 193 index = slot - 1 194 195 return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index:], key = self.key, scale = self.scale) 196 197 def with_energy (self, energies: typing.Dict[str, float]) -> "Form": 198 199 """Set the energy payload on named sections — ``{"chorus": 0.9}``. 200 201 Every section whose name appears in the mapping takes the new value; 202 naming a section the form does not contain raises. Energy *ramps* 203 (``(start, end)`` tuples) live in ``composition.energy()``, not in 204 the payload — a Section carries one number. 205 """ 206 207 names = {section.name for section in self.sections} 208 209 for name in energies: 210 if name not in names: 211 known = ", ".join(sorted(names)) 212 raise ValueError(f"with_energy: no section named {name!r} in this form (sections: {known})") 213 214 return Form(tuple( 215 dataclasses.replace(section, energy = energies[section.name]) 216 if section.name in energies else section 217 for section in self.sections 218 ), key = self.key, scale = self.scale) 219 220 def describe (self) -> str: 221 222 """A readable one-section-per-line summary.""" 223 224 lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"] 225 226 if self.key is not None or self.scale is not None: 227 lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})") 228 229 bar = 1 230 231 for slot, section in enumerate(self.sections, start = 1): 232 extras = f" energy={section.energy:g}" 233 if section.key is not None: 234 extras += f" key={section.key}" 235 if section.scale is not None: 236 extras += f" scale={section.scale}" 237 lines.append(f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}") 238 bar += section.bars 239 240 return "\n".join(lines) 241 242 def __str__ (self) -> str: 243 244 """Same as :meth:`describe`.""" 245 246 return self.describe()
A frozen sequence of Sections — the editable, bindable form value.
List-friendly: the constructor coerces ("name", bars) tuples, so
Form([("verse", 8), ("chorus", 8)]) and
Form([Section("verse", 8), Section("chorus", 8)]) are the same value.
Repetition is Python list arithmetic before construction.
A form may carry its own key/scale — the form tier of the
key-source chain (Section.key overrides it; it overrides the
composition key). A whole AABA in one key with one section borrowing
another is Form([...], key="A") plus a Section(..., key="F").
106 def __init__ ( 107 self, 108 sections: typing.Iterable[typing.Any], 109 key: typing.Optional[str] = None, 110 scale: typing.Optional[str] = None, 111 ) -> None: 112 113 """Coerce any iterable of Sections / (name, bars) tuples.""" 114 115 coerced = tuple(_coerce_section(element) for element in sections) 116 117 if not coerced: 118 raise ValueError("a Form needs at least one section") 119 120 object.__setattr__(self, "sections", coerced) 121 object.__setattr__(self, "key", key) 122 object.__setattr__(self, "scale", scale)
Coerce any iterable of Sections / (name, bars) tuples.
124 @property 125 def bars (self) -> int: 126 127 """Total length in bars.""" 128 129 return sum(section.bars for section in self.sections)
Total length in bars.
158 def replace ( 159 self, 160 slot: int, 161 section: typing.Optional[Section] = None, 162 **changes: typing.Any, 163 ) -> "Form": 164 165 """Replace the section at a 1-based slot — whole, or by field. 166 167 ``form.replace(3, bars=16)`` stretches slot 3; 168 ``form.replace(3, Section("drop", 16, energy=1.0))`` swaps it out. 169 """ 170 171 index = _check_slot(slot, len(self.sections)) 172 173 if section is not None and changes: 174 raise ValueError("pass either a Section or field changes, not both") 175 176 if section is None: 177 if not changes: 178 raise ValueError("replace() needs a Section or field changes (bars=, energy=, key=, name=)") 179 section = dataclasses.replace(self.sections[index], **changes) 180 181 return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index + 1:], key = self.key, scale = self.scale)
Replace the section at a 1-based slot — whole, or by field.
form.replace(3, bars=16) stretches slot 3;
form.replace(3, Section("drop", 16, energy=1.0)) swaps it out.
183 def insert (self, slot: int, section: typing.Any) -> "Form": 184 185 """Insert a section *at* a 1-based slot (existing sections shift right). 186 187 ``slot`` may be ``len(form) + 1`` to append. 188 """ 189 190 if not isinstance(slot, int) or isinstance(slot, bool) or not 1 <= slot <= len(self.sections) + 1: 191 raise ValueError(f"slot {slot!r} is out of range (1–{len(self.sections) + 1})") 192 193 index = slot - 1 194 195 return Form(self.sections[:index] + (_coerce_section(section),) + self.sections[index:], key = self.key, scale = self.scale)
Insert a section at a 1-based slot (existing sections shift right).
slot may be len(form) + 1 to append.
197 def with_energy (self, energies: typing.Dict[str, float]) -> "Form": 198 199 """Set the energy payload on named sections — ``{"chorus": 0.9}``. 200 201 Every section whose name appears in the mapping takes the new value; 202 naming a section the form does not contain raises. Energy *ramps* 203 (``(start, end)`` tuples) live in ``composition.energy()``, not in 204 the payload — a Section carries one number. 205 """ 206 207 names = {section.name for section in self.sections} 208 209 for name in energies: 210 if name not in names: 211 known = ", ".join(sorted(names)) 212 raise ValueError(f"with_energy: no section named {name!r} in this form (sections: {known})") 213 214 return Form(tuple( 215 dataclasses.replace(section, energy = energies[section.name]) 216 if section.name in energies else section 217 for section in self.sections 218 ), key = self.key, scale = self.scale)
Set the energy payload on named sections — {"chorus": 0.9}.
Every section whose name appears in the mapping takes the new value;
naming a section the form does not contain raises. Energy ramps
((start, end) tuples) live in composition.energy(), not in
the payload — a Section carries one number.
220 def describe (self) -> str: 221 222 """A readable one-section-per-line summary.""" 223 224 lines = [f"Form — {len(self.sections)} sections over {self.bars} bars"] 225 226 if self.key is not None or self.scale is not None: 227 lines.append(f" (form key={self.key or '–'} scale={self.scale or '–'})") 228 229 bar = 1 230 231 for slot, section in enumerate(self.sections, start = 1): 232 extras = f" energy={section.energy:g}" 233 if section.key is not None: 234 extras += f" key={section.key}" 235 if section.scale is not None: 236 extras += f" scale={section.scale}" 237 lines.append(f" {slot}. bars {bar}–{bar + section.bars - 1} {section.name:<10} ({section.bars} bars){extras}") 238 bar += section.bars 239 240 return "\n".join(lines)
A readable one-section-per-line summary.
96@dataclasses.dataclass(frozen=True) 97class Degree: 98 99 """ 100 A scale degree — 1-based, resolved against key + scale at placement. 101 102 Degree 1 is the tonic; 8 is the tonic an octave up (steps may exceed the 103 scale length and resolve into higher octaves). ``octave`` shifts whole 104 octaves; ``chroma`` is a chromatic offset in semitones (+1 = sharpened). 105 """ 106 107 step: int 108 octave: int = 0 109 chroma: int = 0 110 111 def __post_init__ (self) -> None: 112 113 """Validate that the degree is 1-based and plausibly a degree.""" 114 115 if self.step < 1: 116 raise ValueError(f"Degree steps are 1-based (1 = tonic) — got {self.step}")
119@dataclasses.dataclass(frozen=True) 120class ChordTone: 121 122 """ 123 An index into the current chord's tones — 1-based, resolved at placement. 124 125 Accepts an int (1 = root, 2 = third, ...) or one of the names 126 ``"root"`` / ``"third"`` / ``"fifth"`` / ``"seventh"``. ``octave`` 127 shifts whole octaves. 128 """ 129 130 index: int 131 octave: int = 0 132 133 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 134 135 """Normalize a tone name to its 1-based index.""" 136 137 if isinstance(index_or_name, str): 138 if index_or_name not in _CHORD_TONE_NAMES: 139 raise ValueError( 140 f"Unknown chord tone name '{index_or_name}' — " 141 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 142 ) 143 index = _CHORD_TONE_NAMES[index_or_name] 144 else: 145 index = index_or_name 146 147 if index < 1: 148 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 149 150 object.__setattr__(self, "index", index) 151 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.
133 def __init__ (self, index_or_name: typing.Union[int, str], octave: int = 0) -> None: 134 135 """Normalize a tone name to its 1-based index.""" 136 137 if isinstance(index_or_name, str): 138 if index_or_name not in _CHORD_TONE_NAMES: 139 raise ValueError( 140 f"Unknown chord tone name '{index_or_name}' — " 141 f"use one of {sorted(_CHORD_TONE_NAMES)} or a 1-based index" 142 ) 143 index = _CHORD_TONE_NAMES[index_or_name] 144 else: 145 index = index_or_name 146 147 if index < 1: 148 raise ValueError(f"Chord tone indices are 1-based (1 = root) — got {index}") 149 150 object.__setattr__(self, "index", index) 151 object.__setattr__(self, "octave", octave)
Normalize a tone name to its 1-based index.
154@dataclasses.dataclass(frozen=True) 155class Approach: 156 157 """ 158 A half-step approach into a target pitch at the next chord boundary. 159 160 Resolves at placement, one semitone below its target (the leading-tone 161 approach); a ``ChordTone`` target reads the NEXT chord through the 162 harmony window, so the approach lands as the harmony arrives. 163 """ 164 165 target: typing.Union[int, Degree, ChordTone]
A half-step approach into a target pitch at the next chord boundary.
Resolves at placement, one semitone below its target (the leading-tone
approach); a ChordTone target reads the NEXT chord through the
harmony window, so the approach lands as the harmony arrives.
265@dataclasses.dataclass(frozen=True) 266class MotifEvent: 267 268 """ 269 One timed note event inside a Motif. 270 271 ``pitch`` is a specification: an absolute MIDI int, a drum name string, 272 a :class:`Degree`, :class:`ChordTone`, or :class:`Approach` — or None 273 for a pitch-stripped skeleton event (see :meth:`Motif.rhythm`), which 274 must be re-pitched via :meth:`Motif.pitched` before placement. 275 ``velocity`` is an int or a ``(low, high)`` random-range tuple. 276 """ 277 278 beat: float 279 pitch: PitchSpec 280 velocity: typing.Union[int, typing.Tuple[int, int]] = _DEFAULT_VELOCITY 281 duration: float = 0.25 282 probability: float = 1.0 283 284 def __post_init__ (self) -> None: 285 286 """Validate ranges that are wrong at any placement.""" 287 288 if self.duration <= 0: 289 raise ValueError(f"Event duration must be positive — got {self.duration}") 290 if not 0.0 <= self.probability <= 1.0: 291 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 292 293 def _sort_key (self) -> tuple: 294 295 """Canonical ordering key — makes parallel merge order-independent.""" 296 297 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.
300@dataclasses.dataclass(frozen=True) 301class ControlEvent: 302 303 """ 304 One timed control gesture inside a Motif: a discrete write or a shaped ramp. 305 306 A discrete write has ``end=None`` and ``span=0.0``; a ramp interpolates 307 ``start`` → ``end`` over ``span`` beats through the easing ``shape``. 308 Pulse density (``resolution=``) is deliberately not stored here — beats 309 and shapes are music; MIDI traffic density is set at the placement call. 310 """ 311 312 beat: float 313 signal: ControlSignal 314 start: float 315 end: typing.Optional[float] = None 316 span: float = 0.0 317 shape: typing.Union[str, "subsequence.easing.EasingFn"] = "linear" 318 probability: float = 1.0 319 320 def __post_init__ (self) -> None: 321 322 """Validate the discrete/ramp invariants.""" 323 324 if (self.end is None) != (self.span == 0.0): 325 raise ValueError("A ramp needs both end= and span= (a discrete write has neither)") 326 if self.span < 0: 327 raise ValueError(f"Ramp span must be non-negative — got {self.span}") 328 if not 0.0 <= self.probability <= 1.0: 329 raise ValueError(f"Event probability must be 0.0–1.0 — got {self.probability}") 330 331 def _sort_key (self) -> tuple: 332 333 """Canonical ordering key — makes parallel merge order-independent.""" 334 335 end = self.start if self.end is None else self.end 336 return (self.beat, _signal_sort_key(self.signal), self.start, end, self.span, self.probability) 337 338 def _value_at (self, fraction: float) -> float: 339 340 """The interpolated value at a 0–1 fraction through the ramp.""" 341 342 if self.end is None: 343 return self.start 344 345 easing_fn = self.shape if callable(self.shape) else subsequence.easing.get_easing(self.shape) 346 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
start → end 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.
1007@dataclasses.dataclass(frozen=True) 1008class Progression: 1009 1010 """A frozen sequence of :class:`ChordSpan` — the governing harmony value. 1011 1012 Always a realised value: binding it to the clock freezes one realisation; 1013 ``p.progression()`` keeps its breathing behaviour by re-realising a fresh 1014 one each rebuild. Iterating yields ``(chord, start, length)`` 1015 :class:`ChordEvent` tuples (the old ``ChordTimeline`` contract), so 1016 placement loops keep working unchanged. 1017 1018 The governing family supports ``+`` (concatenate) and ``*`` (tile) but 1019 never ``&`` — there is one current chord (P1, the type law). 1020 1021 Attributes: 1022 spans: The chord spans, in order. 1023 trailing_history: Engine continuity metadata set by 1024 :meth:`Composition.freeze` — the NIR history at capture time, 1025 restored on each frozen replay. Empty for hand-built values. 1026 """ 1027 1028 spans: typing.Tuple[ChordSpan, ...] 1029 trailing_history: typing.Tuple[subsequence.chords.Chord, ...] = () 1030 1031 def __post_init__ (self) -> None: 1032 1033 """Normalise span containers to tuples.""" 1034 1035 object.__setattr__(self, "spans", tuple(self.spans)) 1036 object.__setattr__(self, "trailing_history", tuple(self.trailing_history)) 1037 1038 if not self.spans: 1039 raise ValueError("a Progression needs at least one chord span") 1040 1041 # -- queries ------------------------------------------------------------ 1042 1043 @property 1044 def length (self) -> float: 1045 1046 """Total length in beats (the sum of span lengths).""" 1047 1048 return float(sum(span.beats for span in self.spans)) 1049 1050 @property 1051 def is_concrete (self) -> bool: 1052 1053 """True when every span is key-independent (no romans/degrees).""" 1054 1055 return all(span.is_concrete for span in self.spans) 1056 1057 @property 1058 def chords (self) -> typing.Tuple[typing.Any, ...]: 1059 1060 """The bare chords, one per span (concrete progressions only).""" 1061 1062 self._require_concrete("read .chords") 1063 1064 return tuple(span.chord for span in self.spans) 1065 1066 @property 1067 def loops_on_exhaustion (self) -> bool: 1068 1069 """True when the clock must loop rather than fall through to live stepping.""" 1070 1071 return any(isinstance(span.chord, PitchSet) for span in self.spans) 1072 1073 def _require_concrete (self, action: str) -> None: 1074 1075 """Raise with a resolution hint when key-relative spans remain.""" 1076 1077 if not self.is_concrete: 1078 relative = ", ".join(span.label() for span in self.spans if not span.is_concrete) 1079 raise ValueError( 1080 f"cannot {action} on a key-relative progression (contains {relative}) — " 1081 "call .resolve(key=...) first, or bind it where a key is known" 1082 ) 1083 1084 def __iter__ (self) -> typing.Iterator[ChordEvent]: 1085 1086 """Yield ``(chord, start, length)`` events — decorated chords where spiced.""" 1087 1088 self._require_concrete("iterate") 1089 1090 cursor = 0.0 1091 1092 for span in self.spans: 1093 chord = DecoratedChord(span) if span.is_decorated else span.chord 1094 yield ChordEvent(chord=chord, start=cursor, length=span.beats) 1095 cursor += span.beats 1096 1097 def __len__ (self) -> int: 1098 1099 """The number of chord spans.""" 1100 1101 return len(self.spans) 1102 1103 def events (self) -> typing.Tuple[ChordEvent, ...]: 1104 1105 """The realised timeline as a tuple (iteration, materialised).""" 1106 1107 return tuple(self) 1108 1109 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 1110 1111 """Return ``(span, start, end)`` for the span sounding at *beat*. 1112 1113 *beat* wraps modulo the progression length, so the lookup also 1114 serves looped playback. 1115 """ 1116 1117 position = beat % self.length 1118 cursor = 0.0 1119 1120 for span in self.spans: 1121 if cursor <= position < cursor + span.beats: 1122 return span, cursor, cursor + span.beats 1123 cursor += span.beats 1124 1125 final = self.spans[-1] 1126 return final, self.length - final.beats, self.length 1127 1128 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 1129 1130 """Resolve every key-relative span against a key (name or pitch class).""" 1131 1132 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 1133 1134 return dataclasses.replace( 1135 self, 1136 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1137 ) 1138 1139 @classmethod 1140 def generate ( 1141 cls, 1142 style: typing.Union[str, typing.Any] = "functional_major", 1143 bars: int = 8, 1144 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1145 *, 1146 key: typing.Optional[str] = None, 1147 scale: typing.Optional[str] = None, 1148 seed: typing.Optional[int] = None, 1149 rng: typing.Optional[random.Random] = None, 1150 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1151 end: typing.Optional[typing.Any] = None, 1152 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1153 cadence: typing.Optional[str] = None, 1154 dominant_7th: bool = True, 1155 gravity: float = 1.0, 1156 nir_strength: float = 0.5, 1157 minor_turnaround_weight: float = 0.0, 1158 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1159 ) -> "Progression": 1160 1161 """Generate a progression from a chord-graph walk — the hybrid generator. 1162 1163 Full parameter pass-through to the engine (no more throwaway default 1164 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1165 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1166 everywhere. Constraints compile into the walk — a backward 1167 feasibility pass guarantees satisfiability before any chord is 1168 drawn (unsatisfiable constraints raise immediately), then a forward 1169 walk samples through the engine's real history-dependent weights 1170 (NIR, gravity, diversity keep their character). 1171 1172 **Without** ``key=`` the result is key-relative — the walk runs 1173 against a reference tonic and the spans store scale-proof 1174 major-relative romans, so the value prints meaningfully unbound and 1175 resolves wherever it is bound (the walk itself is key-invariant). 1176 **With** ``key=`` the result is concrete. 1177 1178 Parameters: 1179 style: A chord-graph style name (or ``ChordGraph`` instance). 1180 bars: How many chords to generate. 1181 beats: Span length per chord — a scalar, or a list cycled. 1182 key: Key for a concrete result; omit for a key-relative value. 1183 scale: Scale for int constraints' quality inference (e.g. 1184 ``end=1``). Defaults from the style (aeolian_minor → 1185 minor); explicit strings (``"V"``, ``"bVII7"``) never 1186 need it. 1187 seed: Seed for the walk. A standalone generated value without 1188 a seed warns — module-level nondeterminism breaks live 1189 reload. 1190 rng: An explicit random stream (overrides ``seed``). 1191 pins: ``{bar: chord}`` — 1-based; values parse like progression 1192 elements (ints, romans, names, ``Chord``). 1193 end: The chord at the final bar — ``end="V"`` is the cadential 1194 major dominant in minor (a string because it is chromatic; 1195 no int can ask for it). 1196 avoid: Chords excluded from the walk. Naming a chord outside 1197 the style's vocabulary is allowed (trivially satisfied). 1198 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 1199 ``"fakeout"``, theory aliases accepted) — its formula 1200 becomes pins on the final bars, so the walk *approaches* 1201 the close. Conflicts with ``end=`` or pins on those bars. 1202 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1203 root_diversity: The engine parameters, exactly as 1204 :meth:`Composition.harmony` takes them. 1205 1206 Example: 1207 ```python 1208 chorus = subsequence.Progression.generate( 1209 style="aeolian_minor", bars=4, end="V", seed=7, 1210 ) 1211 print(chorus) # romans until bound 1212 ``` 1213 """ 1214 1215 if bars < 1: 1216 raise ValueError("bars must be at least 1") 1217 1218 if cadence is not None: 1219 pins = cadence_pins(cadence, bars, pins, end) 1220 end = None 1221 1222 if rng is None: 1223 if seed is None: 1224 warnings.warn( 1225 "Progression.generate without seed= is nondeterministic — " 1226 "pass seed= so the value survives live reload", 1227 stacklevel = 2, 1228 ) 1229 rng = random.Random() 1230 else: 1231 rng = random.Random(seed) 1232 1233 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1234 relative = key is None 1235 reference = key if key is not None else "C" 1236 1237 state = subsequence.harmonic_state.HarmonicState( 1238 key_name = reference, 1239 graph_style = style, 1240 include_dominant_7th = dominant_7th, 1241 key_gravity_blend = gravity, 1242 nir_strength = nir_strength, 1243 minor_turnaround_weight = minor_turnaround_weight, 1244 root_diversity = root_diversity, 1245 rng = rng, 1246 ) 1247 1248 resolved_pins = { 1249 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1250 for position, spec in (pins or {}).items() 1251 } 1252 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1253 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1254 1255 if 1 in resolved_pins: 1256 if resolved_pins[1] not in state.graph.nodes(): 1257 raise ValueError( 1258 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1259 ) 1260 state.current_chord = resolved_pins[1] 1261 1262 def commit (chosen: subsequence.chords.Chord) -> None: 1263 state.current_chord = chosen 1264 1265 walked = subsequence.sequence_utils.constrained_walk( 1266 state.graph, 1267 state.current_chord, 1268 bars, 1269 rng = state.rng, 1270 pins = resolved_pins, 1271 end = resolved_end, 1272 avoid = resolved_avoid, 1273 weight_modifier = state._transition_weight, 1274 before_choice = state._record_transition_source, 1275 after_choice = commit, 1276 ) 1277 1278 lengths = _span_lengths(beats, bars) 1279 1280 if relative: 1281 return cls(spans = tuple( 1282 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1283 for index, chord in enumerate(walked) 1284 )) 1285 1286 return cls(spans = tuple( 1287 ChordSpan(chord = chord, beats = lengths[index]) 1288 for index, chord in enumerate(walked) 1289 )) 1290 1291 # -- algebra ------------------------------------------------------------ 1292 1293 def __add__ (self, other: "Progression") -> "Progression": 1294 1295 """Concatenate two progressions (the governing ``+``).""" 1296 1297 if not isinstance(other, Progression): 1298 return NotImplemented 1299 1300 return Progression(spans = self.spans + other.spans) 1301 1302 def __mul__ (self, count: int) -> "Progression": 1303 1304 """Tile the spans *count* times.""" 1305 1306 if not isinstance(count, int) or isinstance(count, bool): 1307 return NotImplemented 1308 if count < 1: 1309 raise ValueError("a progression must repeat at least once (n >= 1)") 1310 1311 return Progression(spans = self.spans * count) 1312 1313 def __and__ (self, other: typing.Any) -> "Progression": 1314 1315 """Parallel merge is a type error for governing values — by design.""" 1316 1317 raise TypeError( 1318 "Progressions cannot be merged with & — there is one current chord. " 1319 "Sequence them with +, or give a pattern its own part-level progression." 1320 ) 1321 1322 # -- spice (the five operators) and editing ------------------------------ 1323 1324 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1325 1326 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1327 1328 ``only=`` restricts the spice to the given 1-based chord slots. 1329 """ 1330 1331 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1332 1333 spans = tuple( 1334 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1335 if index in slots else span 1336 for index, span in enumerate(self.spans) 1337 ) 1338 1339 return dataclasses.replace(self, spans=spans) 1340 1341 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1342 1343 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1344 1345 values = [spec] if isinstance(spec, int) else list(spec) 1346 1347 if not values: 1348 raise ValueError("inversions list is empty — pass at least one inversion") 1349 1350 spans = tuple( 1351 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1352 for index, span in enumerate(self.spans) 1353 ) 1354 1355 return dataclasses.replace(self, spans=spans) 1356 1357 def spread (self, style: str) -> "Progression": 1358 1359 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1360 1361 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1362 1363 return dataclasses.replace(self, spans=spans) 1364 1365 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1366 1367 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1368 1369 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A 1370 note name is key-independent, so it resolves to its pitch class right 1371 here; ``"tonic"`` follows the key and stays relative until the 1372 progression is resolved. ``only=`` restricts it to the given 1-based 1373 slots (slash chords rather than a full pedal). 1374 """ 1375 1376 if isinstance(bass, str) and bass != "tonic": 1377 bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now 1378 elif isinstance(bass, int) and not 0 <= bass <= 11: 1379 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1380 1381 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1382 1383 spans = tuple( 1384 dataclasses.replace(span, bass=bass) if index in slots else span 1385 for index, span in enumerate(self.spans) 1386 ) 1387 1388 return dataclasses.replace(self, spans=spans) 1389 1390 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1391 1392 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1393 1394 Modal interchange for key-relative content: the degree re-resolves 1395 against the parallel mode (minor under a major scale and vice 1396 versa). Concrete chords raise — there is nothing relative to borrow. 1397 """ 1398 1399 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1400 1401 spans = list(self.spans) 1402 1403 for index in slots: 1404 chord = spans[index].chord 1405 if not isinstance(chord, RomanChord): 1406 raise ValueError( 1407 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1408 "borrow() needs key-relative content (an int degree or roman)" 1409 ) 1410 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1411 1412 return dataclasses.replace(self, spans=tuple(spans)) 1413 1414 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1415 1416 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1417 1418 index = _check_slot(slot, len(self.spans)) 1419 parsed = parse_element(chord, beats = self.spans[index].beats) 1420 1421 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1422 1423 return dataclasses.replace(self, spans=spans) 1424 1425 def cadence (self, name: str = "strong") -> "Progression": 1426 1427 """Substitute a cadence formula into the tail — the close, named. 1428 1429 The final spans take the formula's chords (``"strong"`` is V→I, 1430 ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — 1431 authentic, plagal, half, deceptive — work as aliases). Each replaced 1432 span keeps its beats; its old chord and decorations go. Formula 1433 chords are key-relative (ints follow the bound scale's qualities, 1434 ``"V"`` is the major dominant by convention), so the tail resolves 1435 wherever the progression is bound — a concrete progression becomes 1436 mixed and resolves its tail at bind time, like any roman content. 1437 1438 Example:: 1439 1440 verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") 1441 # Bound in A minor: Am F Dm E — the half close, hanging on the dominant 1442 1443 Raises: 1444 ValueError: If the cadence name is unknown, or the progression 1445 has fewer spans than the formula. 1446 """ 1447 1448 spec = subsequence.cadences.cadence_formula(name) 1449 count = len(spec.formula) 1450 1451 if len(self.spans) < count: 1452 raise ValueError( 1453 f"cadence({name!r}) substitutes the last {count} chords, but this " 1454 f"progression has only {len(self.spans)}" 1455 ) 1456 1457 tail = tuple( 1458 parse_element(element, beats = span.beats) 1459 for element, span in zip(spec.formula, self.spans[-count:]) 1460 ) 1461 1462 return dataclasses.replace(self, spans = self.spans[:-count] + tail) 1463 1464 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1465 1466 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1467 1468 if isinstance(beats, bool): 1469 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1470 1471 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1472 1473 if not values: 1474 raise ValueError("with_rhythm list is empty — pass at least one length") 1475 1476 spans = tuple( 1477 dataclasses.replace(span, beats = float(values[index % len(values)])) 1478 for index, span in enumerate(self.spans) 1479 ) 1480 1481 return dataclasses.replace(self, spans=spans) 1482 1483 def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": 1484 1485 """Steedman-inspired chord elaboration — approach each chord by fifths. 1486 1487 Implements the heart of Mark Steedman's generative grammar for 1488 jazz/blues chord sequences: every chord is **approached** by a chain 1489 of secondary dominants propagated backward around the cycle of fifths 1490 (Rule 3, "the perfect cadence propagated backward"), carved out of that 1491 chord's own span (Rule 1, metric subdivision). ``depth`` is literally 1492 how many fifth-steps back the chain extends: 1493 1494 - ``depth=0`` — identity (the bare progression). 1495 - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → 1496 ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). 1497 - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). 1498 - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord 1499 is made minor — the ``ii`` of *its own local dominant* (the next 1500 link in the chain), forming a ii–V into that link, not the 1501 target's own ii — and dominants are recoloured by **tritone 1502 substitution** with even odds (Rule 4) for chromatic descents. 1503 This tritone choice is the only nondeterministic part, so ``seed`` 1504 is taken (or warned) at depth ≥ 3. 1505 1506 Its flagship is the 12-bar blues with depth-per-chorus — elaborate a 1507 ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and 1508 tritone subs accumulate. 1509 1510 The progression must be **concrete** (resolved to rooted chords); 1511 the inserted dominants are computed by pitch-class arithmetic. Each 1512 chord keeps its decorations on the final (resolved) sub-span; the 1513 inserted approach chords are bare dominant/minor sevenths. Note that 1514 each span is divided into ``depth + 1`` equal sub-spans, so deep 1515 elaboration of a short harmonic rhythm can drop sub-spans below the 1516 harmony clock's lookahead floor — which raises at ``play()``/ 1517 ``render()`` if the result is bound to the global clock (it is free 1518 of that floor at the part level, ``p.progression()``). 1519 1520 Parameters: 1521 depth: Elaboration depth (≥ 0). 1522 seed: Seed for the depth-≥3 tritone-substitution choices. 1523 1524 Returns: 1525 A new :class:`Progression` with the approach chords inserted. 1526 1527 Raises: 1528 ValueError: If *depth* is negative, the progression is 1529 key-relative, or any span is a rootless 1530 :class:`PitchSet`. 1531 1532 Example: 1533 ```python 1534 blues = subsequence.progression("twelve_bar_blues").resolve("C") 1535 chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout 1536 ``` 1537 """ 1538 1539 if depth < 0: 1540 raise ValueError("elaborate depth must be at least 0") 1541 1542 self._require_concrete("elaborate") 1543 1544 if depth == 0: 1545 return self 1546 1547 for span in self.spans: 1548 if isinstance(span.chord, PitchSet): 1549 raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") 1550 1551 if depth >= 3 and seed is None: 1552 warnings.warn( 1553 "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " 1554 "result survives live reload", 1555 stacklevel = 2, 1556 ) 1557 1558 rng = random.Random(seed) 1559 new_spans: typing.List[ChordSpan] = [] 1560 1561 for span in self.spans: 1562 1563 target_root = span.chord.root_pc 1564 sub_beats = span.beats / (depth + 1) 1565 1566 # The backward cycle-of-fifths chain, furthest-back first: chord j 1567 # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The 1568 # furthest-back (j == depth) is made minor — the ii of its OWN 1569 # local dominant (the next link), forming a ii–V into that link — 1570 # once the chain is long enough (depth >= 2) to spell one. 1571 for j in range(depth, 0, -1): 1572 root = (target_root + 7 * j) % 12 1573 quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" 1574 1575 # Tritone substitution recolours a dominant to the dom7 a 1576 # tritone away (same guide tones, chromatic resolution). 1577 if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: 1578 root = (root + 6) % 12 1579 1580 new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) 1581 1582 # The target keeps its own chord and decorations, on its sub-span. 1583 new_spans.append(dataclasses.replace(span, beats = sub_beats)) 1584 1585 return dataclasses.replace(self, spans = tuple(new_spans)) 1586 1587 # -- description ---------------------------------------------------------- 1588 1589 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1590 1591 """A readable, one-chord-per-line summary. 1592 1593 Key-relative spans print as written (romans/degrees) when unbound, 1594 and as concrete chord names under a *key*. 1595 """ 1596 1597 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1598 1599 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1600 cursor = 0.0 1601 1602 for span in self.spans: 1603 lines.append( 1604 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1605 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1606 ) 1607 cursor += span.beats 1608 1609 return "\n".join(lines) 1610 1611 def __str__ (self) -> str: 1612 1613 """Same as :meth:`describe` with no key bound.""" 1614 1615 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.
1043 @property 1044 def length (self) -> float: 1045 1046 """Total length in beats (the sum of span lengths).""" 1047 1048 return float(sum(span.beats for span in self.spans))
Total length in beats (the sum of span lengths).
1050 @property 1051 def is_concrete (self) -> bool: 1052 1053 """True when every span is key-independent (no romans/degrees).""" 1054 1055 return all(span.is_concrete for span in self.spans)
True when every span is key-independent (no romans/degrees).
1057 @property 1058 def chords (self) -> typing.Tuple[typing.Any, ...]: 1059 1060 """The bare chords, one per span (concrete progressions only).""" 1061 1062 self._require_concrete("read .chords") 1063 1064 return tuple(span.chord for span in self.spans)
The bare chords, one per span (concrete progressions only).
1066 @property 1067 def loops_on_exhaustion (self) -> bool: 1068 1069 """True when the clock must loop rather than fall through to live stepping.""" 1070 1071 return any(isinstance(span.chord, PitchSet) for span in self.spans)
True when the clock must loop rather than fall through to live stepping.
1103 def events (self) -> typing.Tuple[ChordEvent, ...]: 1104 1105 """The realised timeline as a tuple (iteration, materialised).""" 1106 1107 return tuple(self)
The realised timeline as a tuple (iteration, materialised).
1109 def span_at (self, beat: float) -> typing.Tuple[ChordSpan, float, float]: 1110 1111 """Return ``(span, start, end)`` for the span sounding at *beat*. 1112 1113 *beat* wraps modulo the progression length, so the lookup also 1114 serves looped playback. 1115 """ 1116 1117 position = beat % self.length 1118 cursor = 0.0 1119 1120 for span in self.spans: 1121 if cursor <= position < cursor + span.beats: 1122 return span, cursor, cursor + span.beats 1123 cursor += span.beats 1124 1125 final = self.spans[-1] 1126 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.
1128 def resolve (self, key: typing.Union[str, int], scale: str = "ionian") -> "Progression": 1129 1130 """Resolve every key-relative span against a key (name or pitch class).""" 1131 1132 key_pc = key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key) 1133 1134 return dataclasses.replace( 1135 self, 1136 spans = tuple(span.resolve(key_pc, scale) for span in self.spans), 1137 )
Resolve every key-relative span against a key (name or pitch class).
1139 @classmethod 1140 def generate ( 1141 cls, 1142 style: typing.Union[str, typing.Any] = "functional_major", 1143 bars: int = 8, 1144 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1145 *, 1146 key: typing.Optional[str] = None, 1147 scale: typing.Optional[str] = None, 1148 seed: typing.Optional[int] = None, 1149 rng: typing.Optional[random.Random] = None, 1150 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1151 end: typing.Optional[typing.Any] = None, 1152 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1153 cadence: typing.Optional[str] = None, 1154 dominant_7th: bool = True, 1155 gravity: float = 1.0, 1156 nir_strength: float = 0.5, 1157 minor_turnaround_weight: float = 0.0, 1158 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1159 ) -> "Progression": 1160 1161 """Generate a progression from a chord-graph walk — the hybrid generator. 1162 1163 Full parameter pass-through to the engine (no more throwaway default 1164 engines), plus the hybrid constraints: ``pins`` fix chords at 1-based 1165 bars, ``end`` fixes the last bar, ``avoid`` excludes chords 1166 everywhere. Constraints compile into the walk — a backward 1167 feasibility pass guarantees satisfiability before any chord is 1168 drawn (unsatisfiable constraints raise immediately), then a forward 1169 walk samples through the engine's real history-dependent weights 1170 (NIR, gravity, diversity keep their character). 1171 1172 **Without** ``key=`` the result is key-relative — the walk runs 1173 against a reference tonic and the spans store scale-proof 1174 major-relative romans, so the value prints meaningfully unbound and 1175 resolves wherever it is bound (the walk itself is key-invariant). 1176 **With** ``key=`` the result is concrete. 1177 1178 Parameters: 1179 style: A chord-graph style name (or ``ChordGraph`` instance). 1180 bars: How many chords to generate. 1181 beats: Span length per chord — a scalar, or a list cycled. 1182 key: Key for a concrete result; omit for a key-relative value. 1183 scale: Scale for int constraints' quality inference (e.g. 1184 ``end=1``). Defaults from the style (aeolian_minor → 1185 minor); explicit strings (``"V"``, ``"bVII7"``) never 1186 need it. 1187 seed: Seed for the walk. A standalone generated value without 1188 a seed warns — module-level nondeterminism breaks live 1189 reload. 1190 rng: An explicit random stream (overrides ``seed``). 1191 pins: ``{bar: chord}`` — 1-based; values parse like progression 1192 elements (ints, romans, names, ``Chord``). 1193 end: The chord at the final bar — ``end="V"`` is the cadential 1194 major dominant in minor (a string because it is chromatic; 1195 no int can ask for it). 1196 avoid: Chords excluded from the walk. Naming a chord outside 1197 the style's vocabulary is allowed (trivially satisfied). 1198 cadence: A cadence name (``"strong"``/``"soft"``/``"open"``/ 1199 ``"fakeout"``, theory aliases accepted) — its formula 1200 becomes pins on the final bars, so the walk *approaches* 1201 the close. Conflicts with ``end=`` or pins on those bars. 1202 dominant_7th / gravity / nir_strength / minor_turnaround_weight / 1203 root_diversity: The engine parameters, exactly as 1204 :meth:`Composition.harmony` takes them. 1205 1206 Example: 1207 ```python 1208 chorus = subsequence.Progression.generate( 1209 style="aeolian_minor", bars=4, end="V", seed=7, 1210 ) 1211 print(chorus) # romans until bound 1212 ``` 1213 """ 1214 1215 if bars < 1: 1216 raise ValueError("bars must be at least 1") 1217 1218 if cadence is not None: 1219 pins = cadence_pins(cadence, bars, pins, end) 1220 end = None 1221 1222 if rng is None: 1223 if seed is None: 1224 warnings.warn( 1225 "Progression.generate without seed= is nondeterministic — " 1226 "pass seed= so the value survives live reload", 1227 stacklevel = 2, 1228 ) 1229 rng = random.Random() 1230 else: 1231 rng = random.Random(seed) 1232 1233 resolved_scale = scale if scale is not None else _STYLE_SCALES.get(style if isinstance(style, str) else "", "ionian") 1234 relative = key is None 1235 reference = key if key is not None else "C" 1236 1237 state = subsequence.harmonic_state.HarmonicState( 1238 key_name = reference, 1239 graph_style = style, 1240 include_dominant_7th = dominant_7th, 1241 key_gravity_blend = gravity, 1242 nir_strength = nir_strength, 1243 minor_turnaround_weight = minor_turnaround_weight, 1244 root_diversity = root_diversity, 1245 rng = rng, 1246 ) 1247 1248 resolved_pins = { 1249 position: resolve_constraint(spec, state.key_root_pc, resolved_scale, f"pins[{position}]") 1250 for position, spec in (pins or {}).items() 1251 } 1252 resolved_end = resolve_constraint(end, state.key_root_pc, resolved_scale, "end") if end is not None else None 1253 resolved_avoid = [resolve_constraint(spec, state.key_root_pc, resolved_scale, "avoid") for spec in (avoid or [])] 1254 1255 if 1 in resolved_pins: 1256 if resolved_pins[1] not in state.graph.nodes(): 1257 raise ValueError( 1258 f"pins[1]={resolved_pins[1].name()} is not in style {style!r}'s vocabulary" 1259 ) 1260 state.current_chord = resolved_pins[1] 1261 1262 def commit (chosen: subsequence.chords.Chord) -> None: 1263 state.current_chord = chosen 1264 1265 walked = subsequence.sequence_utils.constrained_walk( 1266 state.graph, 1267 state.current_chord, 1268 bars, 1269 rng = state.rng, 1270 pins = resolved_pins, 1271 end = resolved_end, 1272 avoid = resolved_avoid, 1273 weight_modifier = state._transition_weight, 1274 before_choice = state._record_transition_source, 1275 after_choice = commit, 1276 ) 1277 1278 lengths = _span_lengths(beats, bars) 1279 1280 if relative: 1281 return cls(spans = tuple( 1282 ChordSpan(chord = _roman_from_chord(chord, state.key_root_pc), beats = lengths[index]) 1283 for index, chord in enumerate(walked) 1284 )) 1285 1286 return cls(spans = tuple( 1287 ChordSpan(chord = chord, beats = lengths[index]) 1288 for index, chord in enumerate(walked) 1289 ))
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
ChordGraphinstance). - bars: How many chords to generate.
- beats: Span length per chord — a scalar, or a list cycled.
- key: Key for a concrete result; omit for a key-relative value.
- scale: Scale for int constraints' quality inference (e.g.
end=1). Defaults from the style (aeolian_minor → minor); explicit strings ("V","bVII7") never need it. - seed: Seed for the walk. A standalone generated value without a seed warns — module-level nondeterminism breaks live reload.
- rng: An explicit random stream (overrides
seed). - pins:
{bar: chord}— 1-based; values parse like progression elements (ints, romans, names,Chord). - end: The chord at the final bar —
end="V"is the cadential major dominant in minor (a string because it is chromatic; no int can ask for it). - avoid: Chords excluded from the walk. Naming a chord outside the style's vocabulary is allowed (trivially satisfied).
- cadence: A cadence name (
"strong"/"soft"/"open"/"fakeout", theory aliases accepted) — its formula becomes pins on the final bars, so the walk approaches the close. Conflicts withend=or pins on those bars. - 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
1324 def extend (self, *extensions: typing.Any, only: typing.Optional[typing.List[int]] = None) -> "Progression": 1325 1326 """Add chord extensions (``7``/``9``/``11``/``13``/``"sus4"``/...) to every span. 1327 1328 ``only=`` restricts the spice to the given 1-based chord slots. 1329 """ 1330 1331 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1332 1333 spans = tuple( 1334 dataclasses.replace(span, extensions = tuple(dict.fromkeys(span.extensions + extensions))) 1335 if index in slots else span 1336 for index, span in enumerate(self.spans) 1337 ) 1338 1339 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.
1341 def inversions (self, spec: typing.Union[int, typing.List[int]]) -> "Progression": 1342 1343 """Set chord inversions — a single int for all spans, or a list cycled per span.""" 1344 1345 values = [spec] if isinstance(spec, int) else list(spec) 1346 1347 if not values: 1348 raise ValueError("inversions list is empty — pass at least one inversion") 1349 1350 spans = tuple( 1351 dataclasses.replace(span, inversion = int(values[index % len(values)])) 1352 for index, span in enumerate(self.spans) 1353 ) 1354 1355 return dataclasses.replace(self, spans=spans)
Set chord inversions — a single int for all spans, or a list cycled per span.
1357 def spread (self, style: str) -> "Progression": 1358 1359 """Set the voicing spread: ``"close"``, ``"open"`` (drop-2), or ``"wide"``.""" 1360 1361 spans = tuple(dataclasses.replace(span, spread = None if style == "close" else style) for span in self.spans) 1362 1363 return dataclasses.replace(self, spans=spans)
Set the voicing spread: "close", "open" (drop-2), or "wide".
1365 def over (self, bass: typing.Union[int, str], only: typing.Optional[typing.List[int]] = None) -> "Progression": 1366 1367 """Put the progression over a slash/pedal bass — *the* trance/techno move. 1368 1369 *bass* is a pitch class int, a note name (``"G"``), or ``"tonic"``. A 1370 note name is key-independent, so it resolves to its pitch class right 1371 here; ``"tonic"`` follows the key and stays relative until the 1372 progression is resolved. ``only=`` restricts it to the given 1-based 1373 slots (slash chords rather than a full pedal). 1374 """ 1375 1376 if isinstance(bass, str) and bass != "tonic": 1377 bass = subsequence.chords.key_name_to_pc(bass) # note names are key-independent — resolve now 1378 elif isinstance(bass, int) and not 0 <= bass <= 11: 1379 raise ValueError(f"a bass pitch class must be 0–11, got {bass}") 1380 1381 slots = set(range(len(self.spans))) if only is None else {_check_slot(s, len(self.spans)) for s in only} 1382 1383 spans = tuple( 1384 dataclasses.replace(span, bass=bass) if index in slots else span 1385 for index, span in enumerate(self.spans) 1386 ) 1387 1388 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". A
note name is key-independent, so it resolves to its pitch class right
here; "tonic" follows the key and stays relative until the
progression is resolved. only= restricts it to the given 1-based
slots (slash chords rather than a full pedal).
1390 def borrow (self, slot: typing.Union[int, typing.List[int]]) -> "Progression": 1391 1392 """Borrow the chord(s) at the given 1-based slot(s) from the parallel scale. 1393 1394 Modal interchange for key-relative content: the degree re-resolves 1395 against the parallel mode (minor under a major scale and vice 1396 versa). Concrete chords raise — there is nothing relative to borrow. 1397 """ 1398 1399 slots = {_check_slot(s, len(self.spans)) for s in ([slot] if isinstance(slot, int) else slot)} 1400 1401 spans = list(self.spans) 1402 1403 for index in slots: 1404 chord = spans[index].chord 1405 if not isinstance(chord, RomanChord): 1406 raise ValueError( 1407 f"slot {index + 1} holds a concrete chord ({spans[index].label()}) — " 1408 "borrow() needs key-relative content (an int degree or roman)" 1409 ) 1410 spans[index] = dataclasses.replace(spans[index], chord = dataclasses.replace(chord, borrowed = not chord.borrowed)) 1411 1412 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.
1414 def replace (self, slot: int, chord: typing.Any) -> "Progression": 1415 1416 """Replace the chord at a 1-based slot (the span keeps its beats).""" 1417 1418 index = _check_slot(slot, len(self.spans)) 1419 parsed = parse_element(chord, beats = self.spans[index].beats) 1420 1421 spans = self.spans[:index] + (parsed,) + self.spans[index + 1:] 1422 1423 return dataclasses.replace(self, spans=spans)
Replace the chord at a 1-based slot (the span keeps its beats).
1425 def cadence (self, name: str = "strong") -> "Progression": 1426 1427 """Substitute a cadence formula into the tail — the close, named. 1428 1429 The final spans take the formula's chords (``"strong"`` is V→I, 1430 ``"soft"`` IV→I, ``"open"`` IV→V, ``"fakeout"`` V→vi; theory names — 1431 authentic, plagal, half, deceptive — work as aliases). Each replaced 1432 span keeps its beats; its old chord and decorations go. Formula 1433 chords are key-relative (ints follow the bound scale's qualities, 1434 ``"V"`` is the major dominant by convention), so the tail resolves 1435 wherever the progression is bound — a concrete progression becomes 1436 mixed and resolves its tail at bind time, like any roman content. 1437 1438 Example:: 1439 1440 verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open") 1441 # Bound in A minor: Am F Dm E — the half close, hanging on the dominant 1442 1443 Raises: 1444 ValueError: If the cadence name is unknown, or the progression 1445 has fewer spans than the formula. 1446 """ 1447 1448 spec = subsequence.cadences.cadence_formula(name) 1449 count = len(spec.formula) 1450 1451 if len(self.spans) < count: 1452 raise ValueError( 1453 f"cadence({name!r}) substitutes the last {count} chords, but this " 1454 f"progression has only {len(self.spans)}" 1455 ) 1456 1457 tail = tuple( 1458 parse_element(element, beats = span.beats) 1459 for element, span in zip(spec.formula, self.spans[-count:]) 1460 ) 1461 1462 return dataclasses.replace(self, spans = self.spans[:-count] + tail)
Substitute a cadence formula into the tail — the close, named.
The final spans take the formula's chords ("strong" is V→I,
"soft" IV→I, "open" IV→V, "fakeout" V→vi; theory names —
authentic, plagal, half, deceptive — work as aliases). Each replaced
span keeps its beats; its old chord and decorations go. Formula
chords are key-relative (ints follow the bound scale's qualities,
"V" is the major dominant by convention), so the tail resolves
wherever the progression is bound — a concrete progression becomes
mixed and resolves its tail at bind time, like any roman content.
Example::
verse = subsequence.progression(["Am", "F", "C", "G"]).cadence("open")
# Bound in A minor: Am F Dm E — the half close, hanging on the dominant
Raises:
- ValueError: If the cadence name is unknown, or the progression has fewer spans than the formula.
1464 def with_rhythm (self, beats: typing.Union[float, typing.List[float]]) -> "Progression": 1465 1466 """Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.""" 1467 1468 if isinstance(beats, bool): 1469 raise TypeError(f"with_rhythm takes beats or a list of beats, got bool: {beats!r}") 1470 1471 values = [float(beats)] if isinstance(beats, (int, float)) else [float(b) for b in beats] 1472 1473 if not values: 1474 raise ValueError("with_rhythm list is empty — pass at least one length") 1475 1476 spans = tuple( 1477 dataclasses.replace(span, beats = float(values[index % len(values)])) 1478 for index, span in enumerate(self.spans) 1479 ) 1480 1481 return dataclasses.replace(self, spans=spans)
Reshape the harmonic rhythm — a scalar for all spans, or a list cycled per span.
1483 def elaborate (self, depth: int = 1, seed: typing.Optional[int] = None) -> "Progression": 1484 1485 """Steedman-inspired chord elaboration — approach each chord by fifths. 1486 1487 Implements the heart of Mark Steedman's generative grammar for 1488 jazz/blues chord sequences: every chord is **approached** by a chain 1489 of secondary dominants propagated backward around the cycle of fifths 1490 (Rule 3, "the perfect cadence propagated backward"), carved out of that 1491 chord's own span (Rule 1, metric subdivision). ``depth`` is literally 1492 how many fifth-steps back the chain extends: 1493 1494 - ``depth=0`` — identity (the bare progression). 1495 - ``depth=1`` — a secondary dominant before each chord: ``[X]`` → 1496 ``[V7/X, X]`` (e.g. a bar of C becomes G7 C). 1497 - ``depth=2`` — a secondary ii–V: ``[ii/X, V7/X, X]`` (Dm7 G7 C). 1498 - ``depth≥3`` — the chain extends (…V7/V7/X), the furthest-back chord 1499 is made minor — the ``ii`` of *its own local dominant* (the next 1500 link in the chain), forming a ii–V into that link, not the 1501 target's own ii — and dominants are recoloured by **tritone 1502 substitution** with even odds (Rule 4) for chromatic descents. 1503 This tritone choice is the only nondeterministic part, so ``seed`` 1504 is taken (or warned) at depth ≥ 3. 1505 1506 Its flagship is the 12-bar blues with depth-per-chorus — elaborate a 1507 ``"twelve_bar_blues"`` more each chorus and the ii–V turnarounds and 1508 tritone subs accumulate. 1509 1510 The progression must be **concrete** (resolved to rooted chords); 1511 the inserted dominants are computed by pitch-class arithmetic. Each 1512 chord keeps its decorations on the final (resolved) sub-span; the 1513 inserted approach chords are bare dominant/minor sevenths. Note that 1514 each span is divided into ``depth + 1`` equal sub-spans, so deep 1515 elaboration of a short harmonic rhythm can drop sub-spans below the 1516 harmony clock's lookahead floor — which raises at ``play()``/ 1517 ``render()`` if the result is bound to the global clock (it is free 1518 of that floor at the part level, ``p.progression()``). 1519 1520 Parameters: 1521 depth: Elaboration depth (≥ 0). 1522 seed: Seed for the depth-≥3 tritone-substitution choices. 1523 1524 Returns: 1525 A new :class:`Progression` with the approach chords inserted. 1526 1527 Raises: 1528 ValueError: If *depth* is negative, the progression is 1529 key-relative, or any span is a rootless 1530 :class:`PitchSet`. 1531 1532 Example: 1533 ```python 1534 blues = subsequence.progression("twelve_bar_blues").resolve("C") 1535 chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout 1536 ``` 1537 """ 1538 1539 if depth < 0: 1540 raise ValueError("elaborate depth must be at least 0") 1541 1542 self._require_concrete("elaborate") 1543 1544 if depth == 0: 1545 return self 1546 1547 for span in self.spans: 1548 if isinstance(span.chord, PitchSet): 1549 raise ValueError("elaborate needs rooted chords — a PitchSet has no root to approach by fifths") 1550 1551 if depth >= 3 and seed is None: 1552 warnings.warn( 1553 "elaborate(depth>=3) makes tritone-substitution choices — pass seed= so the " 1554 "result survives live reload", 1555 stacklevel = 2, 1556 ) 1557 1558 rng = random.Random(seed) 1559 new_spans: typing.List[ChordSpan] = [] 1560 1561 for span in self.spans: 1562 1563 target_root = span.chord.root_pc 1564 sub_beats = span.beats / (depth + 1) 1565 1566 # The backward cycle-of-fifths chain, furthest-back first: chord j 1567 # sits a fifth above chord j-1's target, i.e. root = X + 7·j. The 1568 # furthest-back (j == depth) is made minor — the ii of its OWN 1569 # local dominant (the next link), forming a ii–V into that link — 1570 # once the chain is long enough (depth >= 2) to spell one. 1571 for j in range(depth, 0, -1): 1572 root = (target_root + 7 * j) % 12 1573 quality = "minor_7th" if (j == depth and depth >= 2) else "dominant_7th" 1574 1575 # Tritone substitution recolours a dominant to the dom7 a 1576 # tritone away (same guide tones, chromatic resolution). 1577 if quality == "dominant_7th" and depth >= 3 and rng.random() < 0.5: 1578 root = (root + 6) % 12 1579 1580 new_spans.append(ChordSpan(chord = subsequence.chords.Chord(root_pc = root, quality = quality), beats = sub_beats)) 1581 1582 # The target keeps its own chord and decorations, on its sub-span. 1583 new_spans.append(dataclasses.replace(span, beats = sub_beats)) 1584 1585 return dataclasses.replace(self, spans = tuple(new_spans))
Steedman-inspired chord elaboration — approach each chord by fifths.
Implements the heart of Mark Steedman's generative grammar for
jazz/blues chord sequences: every chord is approached by a chain
of secondary dominants propagated backward around the cycle of fifths
(Rule 3, "the perfect cadence propagated backward"), carved out of that
chord's own span (Rule 1, metric subdivision). depth is literally
how many fifth-steps back the chain extends:
depth=0— identity (the bare progression).depth=1— a secondary dominant before each chord:[X]→[V7/X, X](e.g. a bar of C becomes G7 C).depth=2— a secondary ii–V:[ii/X, V7/X, X](Dm7 G7 C).depth≥3— the chain extends (…V7/V7/X), the furthest-back chord is made minor — theiiof its own local dominant (the next link in the chain), forming a ii–V into that link, not the target's own ii — and dominants are recoloured by tritone substitution with even odds (Rule 4) for chromatic descents. This tritone choice is the only nondeterministic part, soseedis taken (or warned) at depth ≥ 3.
Its flagship is the 12-bar blues with depth-per-chorus — elaborate a
"twelve_bar_blues" more each chorus and the ii–V turnarounds and
tritone subs accumulate.
The progression must be concrete (resolved to rooted chords);
the inserted dominants are computed by pitch-class arithmetic. Each
chord keeps its decorations on the final (resolved) sub-span; the
inserted approach chords are bare dominant/minor sevenths. Note that
each span is divided into depth + 1 equal sub-spans, so deep
elaboration of a short harmonic rhythm can drop sub-spans below the
harmony clock's lookahead floor — which raises at play()/
render() if the result is bound to the global clock (it is free
of that floor at the part level, p.progression()).
Arguments:
- depth: Elaboration depth (≥ 0).
- seed: Seed for the depth-≥3 tritone-substitution choices.
Returns:
A new
Progressionwith the approach chords inserted.
Raises:
- ValueError: If depth is negative, the progression is
key-relative, or any span is a rootless
PitchSet.
Example:
blues = subsequence.progression("twelve_bar_blues").resolve("C") chorus2 = blues.elaborate(2, seed=4) # ii–V turnarounds throughout
1589 def describe (self, key: typing.Optional[typing.Union[str, int]] = None, scale: str = "ionian") -> str: 1590 1591 """A readable, one-chord-per-line summary. 1592 1593 Key-relative spans print as written (romans/degrees) when unbound, 1594 and as concrete chord names under a *key*. 1595 """ 1596 1597 key_pc = None if key is None else (key if isinstance(key, int) else subsequence.chords.key_name_to_pc(key)) 1598 1599 lines = [f"Progression — {len(self.spans)} chords over {self.length:g} beats"] 1600 cursor = 0.0 1601 1602 for span in self.spans: 1603 lines.append( 1604 f" {cursor:6.2f} … {cursor + span.beats:6.2f} " 1605 f"{span.label(key_pc, scale):<8} ({span.beats:g} beats)" 1606 ) 1607 cursor += span.beats 1608 1609 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.
483@dataclasses.dataclass(frozen=True) 484class ChordSpan: 485 486 """One chord with a duration and its decoration — the unit of harmonic time. 487 488 Decoration (extensions, slash bass, inversion, spread) lives HERE, never 489 on :class:`~subsequence.chords.Chord`: the engine's graph identity stays 490 the bare triad, and the decorated voicing is what patterns hear. 491 492 Attributes: 493 chord: A concrete ``Chord``, a key-relative :class:`RomanChord`, or a 494 :class:`PitchSet`. 495 beats: Span length in beats. 496 extensions: Extension markers — ints (``7``, ``9``, ``11``, ``13``) 497 or names (``"sus2"``, ``"sus4"``, ``"add9"``, ``"6"``). 498 bass: Slash/pedal bass — a pitch class int, a note name, or 499 ``"tonic"`` (resolved against the key at query time). 500 inversion: Chord inversion for the voicing (0 = root position). 501 spread: Voicing spread — ``"close"`` (default), ``"open"`` (drop-2), 502 or ``"wide"`` (drop-2-and-4). 503 extension_intervals: Pre-computed semitone offsets for the 504 extensions, set by :meth:`Progression.resolve` for diatonic 505 degrees. ``None`` means "derive from the chord's own colour". 506 """ 507 508 chord: typing.Any 509 beats: float 510 extensions: typing.Tuple[typing.Any, ...] = () 511 bass: typing.Optional[typing.Union[int, str]] = None 512 inversion: int = 0 513 spread: typing.Optional[str] = None 514 extension_intervals: typing.Optional[typing.Tuple[int, ...]] = None 515 516 def __post_init__ (self) -> None: 517 518 """Validate beats, extensions, and spread.""" 519 520 if self.beats <= 0: 521 raise ValueError(f"a chord span must last at least one beat-fraction, got {self.beats:g}") 522 523 for extension in self.extensions: 524 if isinstance(extension, bool) or not ( 525 (isinstance(extension, int) and extension in _NUMERIC_EXTENSIONS) 526 or (isinstance(extension, str) and extension in _EXTENSION_NAMES) 527 ): 528 known = ", ".join(["7", "9", "11", "13"] + sorted(_EXTENSION_NAMES)) 529 raise ValueError(f"unknown extension {extension!r} — expected one of: {known}") 530 531 if self.spread is not None and self.spread not in _SPREAD_STYLES: 532 raise ValueError(f"unknown spread {self.spread!r} — expected one of: " + ", ".join(sorted(_SPREAD_STYLES))) 533 534 @property 535 def is_concrete (self) -> bool: 536 537 """True when the chord (and any pedal bass) needs no key context to sound. 538 539 A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not 540 concrete until :meth:`resolve` pins it to a key. Note-name basses are 541 resolved to a pitch class eagerly in :meth:`Progression.over`, so they 542 never linger here as strings. 543 """ 544 545 return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str) 546 547 @property 548 def is_decorated (self) -> bool: 549 550 """True when the span carries any decoration beyond the bare chord.""" 551 552 return bool(self.extensions) or self.bass is not None or self.inversion != 0 or self.spread is not None 553 554 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 555 556 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 557 558 chord = self.chord 559 extension_intervals = self.extension_intervals 560 561 if isinstance(chord, RomanChord): 562 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 563 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 564 chord = chord.resolve(key_pc, scale) 565 566 bass: typing.Optional[typing.Union[int, str]] = self.bass 567 568 if isinstance(bass, str): 569 if bass == "tonic": 570 bass = key_pc 571 else: 572 bass = subsequence.chords.key_name_to_pc(bass) 573 574 return dataclasses.replace( 575 self, 576 chord = chord, 577 bass = bass, 578 extension_intervals = extension_intervals, 579 ) 580 581 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 582 583 """A printable chord label: roman text when relative, decorated name when concrete.""" 584 585 if isinstance(self.chord, RomanChord): 586 if key_pc is None: 587 text = self.chord.label() 588 return text + self._decoration_suffix(resolved=False) 589 return self.resolve(key_pc, scale).label() 590 591 base = str(self.chord.name()) 592 return base + self._decoration_suffix(resolved=True) 593 594 def _decoration_suffix (self, resolved: bool) -> str: 595 596 """The printable decoration tail (extensions and slash bass).""" 597 598 parts = "" 599 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 600 601 # 9 implies 7 (and so on up): print only the highest stacked extension. 602 stacked = [e for e in numeric if e in (7, 9, 11, 13)] 603 if stacked: 604 parts += str(stacked[-1]) 605 606 for name in (e for e in self.extensions if isinstance(e, str)): 607 parts += name 608 609 if self.bass is not None: 610 if isinstance(self.bass, int): 611 parts += "/" + subsequence.chords.PC_TO_NOTE_NAME[self.bass % 12] 612 else: 613 parts += "/" + str(self.bass) 614 615 return parts 616 617 def decorated_intervals (self) -> typing.List[int]: 618 619 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 620 621 Numeric extensions deepen the chord in its own colour — a minor third 622 gets a minor seventh, a major third a major seventh, a diminished 623 triad a diminished seventh. Diatonic degrees extended with 624 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 625 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 626 dominant colour on a concrete major chord. 627 """ 628 629 if isinstance(self.chord, RomanChord): 630 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 631 632 intervals = list(self.chord.intervals()) 633 634 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 635 if sus and len(intervals) >= 2: 636 intervals[1] = 2 if sus[0] == "sus2" else 5 637 638 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 639 640 if self.extension_intervals is not None: 641 added: typing.List[int] = list(self.extension_intervals) 642 else: 643 added = [] 644 third = intervals[1] if len(intervals) >= 2 else None 645 has_seventh = any(i in (9, 10, 11) for i in intervals) 646 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 647 648 if stacked and not has_seventh: 649 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 650 added.append(9) # diminished colour 651 elif third == 3: 652 added.append(10) # minor colour 653 elif third == 4: 654 added.append(11) # major colour 655 else: 656 added.append(10) # sus / no third: the dominant-leaning seventh 657 658 for extension in stacked: 659 if extension == 9: 660 added.append(14) 661 elif extension == 11: 662 added.append(17) 663 elif extension == 13: 664 added.append(21) 665 666 if "add9" in self.extensions: 667 added.append(14) 668 if "6" in self.extensions: 669 added.append(9) 670 671 return sorted(set(intervals) | set(added)) 672 673 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 674 675 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 676 677 Applies, in order: extensions, inversion, spread, then the slash/pedal 678 bass below the voicing. ``PitchSet`` spans return their absolute 679 pitches (decoration other than ``count`` does not apply). 680 """ 681 682 if isinstance(self.chord, RomanChord): 683 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 684 685 if isinstance(self.chord, PitchSet): 686 return self.chord.tones(root, inversion=self.inversion, count=count) 687 688 intervals = self.decorated_intervals() 689 690 if self.inversion != 0: 691 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 692 693 if self.spread == "open" and len(intervals) >= 3: 694 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 695 elif self.spread == "wide" and len(intervals) >= 3: 696 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 697 intervals = sorted(dropped) 698 699 offset = (self.chord.root_pc - root) % 12 700 if offset > 6: 701 offset -= 12 702 effective_root = root + offset 703 704 if count is not None: 705 n = len(intervals) 706 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 707 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 708 else: 709 pitches = [effective_root + interval for interval in intervals] 710 711 if self.bass is not None and isinstance(self.bass, int): 712 lowest = min(pitches) 713 bass_note = lowest - ((lowest - self.bass) % 12) 714 if bass_note == lowest: 715 bass_note -= 12 716 pitches = [bass_note] + pitches 717 718 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-relativeRomanChord, or aPitchSet. - 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.Nonemeans "derive from the chord's own colour".
534 @property 535 def is_concrete (self) -> bool: 536 537 """True when the chord (and any pedal bass) needs no key context to sound. 538 539 A ``"tonic"`` pedal bass is key-relative, so a span carrying one is not 540 concrete until :meth:`resolve` pins it to a key. Note-name basses are 541 resolved to a pitch class eagerly in :meth:`Progression.over`, so they 542 never linger here as strings. 543 """ 544 545 return not isinstance(self.chord, RomanChord) and not isinstance(self.bass, str)
True when the chord (and any pedal bass) needs no key context to sound.
A "tonic" pedal bass is key-relative, so a span carrying one is not
concrete until resolve() pins it to a key. Note-name basses are
resolved to a pitch class eagerly in Progression.over(), so they
never linger here as strings.
547 @property 548 def is_decorated (self) -> bool: 549 550 """True when the span carries any decoration beyond the bare chord.""" 551 552 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.
554 def resolve (self, key_pc: int, scale: str = "ionian") -> "ChordSpan": 555 556 """Return a concrete span: romans resolved, bass resolved to a pitch class.""" 557 558 chord = self.chord 559 extension_intervals = self.extension_intervals 560 561 if isinstance(chord, RomanChord): 562 if chord.quality is None and any(isinstance(e, int) for e in self.extensions): 563 extension_intervals = chord.diatonic_extension_intervals(key_pc, scale, self.extensions) 564 chord = chord.resolve(key_pc, scale) 565 566 bass: typing.Optional[typing.Union[int, str]] = self.bass 567 568 if isinstance(bass, str): 569 if bass == "tonic": 570 bass = key_pc 571 else: 572 bass = subsequence.chords.key_name_to_pc(bass) 573 574 return dataclasses.replace( 575 self, 576 chord = chord, 577 bass = bass, 578 extension_intervals = extension_intervals, 579 )
Return a concrete span: romans resolved, bass resolved to a pitch class.
581 def label (self, key_pc: typing.Optional[int] = None, scale: str = "ionian") -> str: 582 583 """A printable chord label: roman text when relative, decorated name when concrete.""" 584 585 if isinstance(self.chord, RomanChord): 586 if key_pc is None: 587 text = self.chord.label() 588 return text + self._decoration_suffix(resolved=False) 589 return self.resolve(key_pc, scale).label() 590 591 base = str(self.chord.name()) 592 return base + self._decoration_suffix(resolved=True)
A printable chord label: roman text when relative, decorated name when concrete.
617 def decorated_intervals (self) -> typing.List[int]: 618 619 """Semitone offsets of the decorated voicing (before inversion/spread/bass). 620 621 Numeric extensions deepen the chord in its own colour — a minor third 622 gets a minor seventh, a major third a major seventh, a diminished 623 triad a diminished seventh. Diatonic degrees extended with 624 ``extend(...)`` carry pre-computed scale-true intervals instead (so V 625 gets its dominant seventh). Write ``"G7"``/``"V7"`` when you want the 626 dominant colour on a concrete major chord. 627 """ 628 629 if isinstance(self.chord, RomanChord): 630 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 631 632 intervals = list(self.chord.intervals()) 633 634 sus = [e for e in self.extensions if e in ("sus2", "sus4")] 635 if sus and len(intervals) >= 2: 636 intervals[1] = 2 if sus[0] == "sus2" else 5 637 638 numeric = sorted(e for e in self.extensions if isinstance(e, int)) 639 640 if self.extension_intervals is not None: 641 added: typing.List[int] = list(self.extension_intervals) 642 else: 643 added = [] 644 third = intervals[1] if len(intervals) >= 2 else None 645 has_seventh = any(i in (9, 10, 11) for i in intervals) 646 stacked = [e for e in numeric if e in _NUMERIC_EXTENSIONS] 647 648 if stacked and not has_seventh: 649 if third == 3 and len(intervals) >= 3 and intervals[2] == 6: 650 added.append(9) # diminished colour 651 elif third == 3: 652 added.append(10) # minor colour 653 elif third == 4: 654 added.append(11) # major colour 655 else: 656 added.append(10) # sus / no third: the dominant-leaning seventh 657 658 for extension in stacked: 659 if extension == 9: 660 added.append(14) 661 elif extension == 11: 662 added.append(17) 663 elif extension == 13: 664 added.append(21) 665 666 if "add9" in self.extensions: 667 added.append(14) 668 if "6" in self.extensions: 669 added.append(9) 670 671 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.
673 def tones (self, root: int = 60, count: typing.Optional[int] = None) -> typing.List[int]: 674 675 """MIDI notes of the decorated voicing nearest *root* (concrete spans only). 676 677 Applies, in order: extensions, inversion, spread, then the slash/pedal 678 bass below the voicing. ``PitchSet`` spans return their absolute 679 pitches (decoration other than ``count`` does not apply). 680 """ 681 682 if isinstance(self.chord, RomanChord): 683 raise ValueError("cannot voice a key-relative span — resolve(key=...) it first") 684 685 if isinstance(self.chord, PitchSet): 686 return self.chord.tones(root, inversion=self.inversion, count=count) 687 688 intervals = self.decorated_intervals() 689 690 if self.inversion != 0: 691 intervals = subsequence.voicings.invert_chord(intervals, self.inversion) 692 693 if self.spread == "open" and len(intervals) >= 3: 694 intervals = sorted(intervals[:-2] + [intervals[-2] - 12] + intervals[-1:]) 695 elif self.spread == "wide" and len(intervals) >= 3: 696 dropped = [i - 12 if position in (len(intervals) - 2, len(intervals) - 4) else i for position, i in enumerate(intervals)] 697 intervals = sorted(dropped) 698 699 offset = (self.chord.root_pc - root) % 12 700 if offset > 6: 701 offset -= 12 702 effective_root = root + offset 703 704 if count is not None: 705 n = len(intervals) 706 span_octave = max(12, ((max(intervals) // 12) + 1) * 12) 707 pitches = [effective_root + intervals[i % n] + span_octave * (i // n) for i in range(count)] 708 else: 709 pitches = [effective_root + interval for interval in intervals] 710 711 if self.bass is not None and isinstance(self.bass, int): 712 lowest = min(pitches) 713 bass_note = lowest - ((lowest - self.bass) % 12) 714 if bass_note == lowest: 715 bass_note -= 12 716 pitches = [bass_note] + pitches 717 718 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).
113@dataclasses.dataclass(frozen=True) 114class PitchSet: 115 116 """A nameless sonority — a frozen set of absolute MIDI pitches. 117 118 The escape hatch for chords with no root or quality: clusters, spectral 119 stacks, found objects. It duck-types ``.tones()`` so every placement verb 120 and the injected ``chord`` accept it unchanged. By design it is excluded 121 from generation and diatonic spice (there is nothing to transpose 122 diatonically), and a progression containing one loops on exhaustion 123 rather than falling through to live graph stepping. 124 125 Pitches are absolute: ``tones()`` ignores its ``root`` argument — you 126 chose the register when you chose the pitches. 127 """ 128 129 pitches: typing.Tuple[int, ...] 130 131 def __init__ (self, pitches: typing.Iterable[int]) -> None: 132 133 """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" 134 135 values = tuple(sorted(int(p) for p in pitches)) 136 137 if not values: 138 raise ValueError("PitchSet needs at least one pitch") 139 140 object.__setattr__(self, "pitches", values) 141 142 def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 143 144 """Return the pitches (absolute — *root* is ignored by design). 145 146 ``inversion`` rotates pitches up an octave; ``count`` cycles the set 147 into higher octaves, matching the ``Chord.tones`` contract. 148 """ 149 150 pitches = list(self.pitches) 151 152 if inversion != 0: 153 for _ in range(inversion % len(pitches)): 154 pitches.append(pitches.pop(0) + 12) 155 156 if count is not None: 157 n = len(pitches) 158 return [pitches[i % n] + 12 * (i // n) for i in range(count)] 159 160 return pitches 161 162 def intervals (self) -> typing.List[int]: 163 164 """Semitone offsets from the lowest pitch (the ``Chord`` protocol).""" 165 166 return [p - self.pitches[0] for p in self.pitches] 167 168 def name (self) -> str: 169 170 """A readable label for describe() output.""" 171 172 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.
131 def __init__ (self, pitches: typing.Iterable[int]) -> None: 132 133 """Normalise any iterable of MIDI pitches into a sorted frozen tuple.""" 134 135 values = tuple(sorted(int(p) for p in pitches)) 136 137 if not values: 138 raise ValueError("PitchSet needs at least one pitch") 139 140 object.__setattr__(self, "pitches", values)
Normalise any iterable of MIDI pitches into a sorted frozen tuple.
142 def tones (self, root: int = 60, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 143 144 """Return the pitches (absolute — *root* is ignored by design). 145 146 ``inversion`` rotates pitches up an octave; ``count`` cycles the set 147 into higher octaves, matching the ``Chord.tones`` contract. 148 """ 149 150 pitches = list(self.pitches) 151 152 if inversion != 0: 153 for _ in range(inversion % len(pitches)): 154 pitches.append(pitches.pop(0) + 12) 155 156 if count is not None: 157 n = len(pitches) 158 return [pitches[i % n] + 12 * (i // n) for i in range(count)] 159 160 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.
1641def progression ( 1642 source: typing.Optional[typing.Any] = None, 1643 beats: typing.Union[float, typing.List[float]] = DEFAULT_SPAN_BEATS, 1644 *, 1645 style: typing.Optional[str] = None, 1646 bars: int = 8, 1647 key: typing.Optional[str] = None, 1648 scale: typing.Optional[str] = None, 1649 seed: typing.Optional[int] = None, 1650 rng: typing.Optional[random.Random] = None, 1651 pins: typing.Optional[typing.Dict[int, typing.Any]] = None, 1652 end: typing.Optional[typing.Any] = None, 1653 avoid: typing.Optional[typing.Sequence[typing.Any]] = None, 1654 cadence: typing.Optional[str] = None, 1655 dominant_7th: bool = True, 1656 gravity: float = 1.0, 1657 nir_strength: float = 0.5, 1658 minor_turnaround_weight: float = 0.0, 1659 root_diversity: float = subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1660) -> Progression: 1661 1662 """Build a :class:`Progression` — the lowercase factory. 1663 1664 Dispatch by argument type: a **list** parses per element (ints where 1665 diatonic, name/roman strings where nominal/chromatic, 1666 ``(element, beats)`` tuples for per-chord durations); a bare **string** names a 1667 preset from the curated table; ``style=`` generates *bars* chords from a 1668 chord-graph walk (requires ``key=``). 1669 1670 Parameters: 1671 source: The element list, preset name, or an existing Progression 1672 (returned unchanged). 1673 beats: Span length per chord — a scalar, or a list cycled per chord 1674 (``beats=[4, 4, 2, 6]`` shapes the harmonic rhythm). 1675 style: A chord-graph style name to generate from (e.g. 1676 ``"aeolian_minor"``). 1677 bars: How many chords to generate (style mode only). 1678 key: Key for style generation. 1679 seed: Seed for style generation. A standalone generated value 1680 without a seed warns — module-level nondeterminism breaks live 1681 reload. 1682 rng: An explicit random stream (overrides ``seed``; used by 1683 engine-mediated calls). 1684 dominant_7th / gravity / nir_strength: Graph-walk parameters, 1685 matching :meth:`Composition.harmony` (style mode only; full 1686 pass-through arrives with ``Progression.generate``). 1687 1688 Example: 1689 ```python 1690 verse = subsequence.progression([1, 6, 3, 7]) # i–VI–III–VII in A minor 1691 blues = subsequence.progression(["I7"] * 4 + ["IV7", "IV7", "I7", "I7", "V7", "IV7", "I7", "I7"]) 1692 walk = subsequence.progression(style="aeolian_minor", key="A", bars=8, seed=3) 1693 ``` 1694 """ 1695 1696 if style is not None: 1697 if source is not None: 1698 raise ValueError("pass either source or style=, not both") 1699 return Progression.generate( 1700 style = style, 1701 bars = bars, 1702 beats = beats, 1703 key = key, 1704 scale = scale, 1705 seed = seed, 1706 rng = rng, 1707 pins = pins, 1708 end = end, 1709 avoid = avoid, 1710 cadence = cadence, 1711 dominant_7th = dominant_7th, 1712 gravity = gravity, 1713 nir_strength = nir_strength, 1714 minor_turnaround_weight = minor_turnaround_weight, 1715 root_diversity = root_diversity, 1716 ) 1717 1718 # Generation-only knobs are meaningless for a concrete source — reject 1719 # them so a musician asking for cadence= or key= on a list gets a usable 1720 # error instead of a silent no-op. 1721 generation_only = { 1722 "bars": bars != 8, 1723 "key": key is not None, 1724 "scale": scale is not None, 1725 "seed": seed is not None, 1726 "rng": rng is not None, 1727 "pins": pins is not None, 1728 "end": end is not None, 1729 "avoid": avoid is not None, 1730 "cadence": cadence is not None, 1731 "dominant_7th": dominant_7th is not True, 1732 "gravity": gravity != 1.0, 1733 "nir_strength": nir_strength != 0.5, 1734 "minor_turnaround_weight": minor_turnaround_weight != 0.0, 1735 "root_diversity": root_diversity != subsequence.harmonic_state.DEFAULT_ROOT_DIVERSITY, 1736 } 1737 passed = [name for name, was_set in generation_only.items() if was_set] 1738 1739 if passed: 1740 raise ValueError( 1741 f"{', '.join(sorted(passed))} only apply when generating with style=. " 1742 "A concrete progression takes these as methods instead — e.g. " 1743 ".cadence('strong') for the close, and the key binds at " 1744 "composition.harmony() / resolve() time." 1745 ) 1746 1747 if isinstance(source, Progression): 1748 return source 1749 1750 if isinstance(source, str): 1751 if source in _PRESETS: 1752 return progression(_PRESETS[source], beats=beats) 1753 known = ", ".join(sorted(_PRESETS)) 1754 raise ValueError( 1755 f"Unknown progression preset {source!r}. Known presets: {known}. " 1756 "Or pass a list — progression([1, 6, 3, 7]) / progression(['Am', 'F', 'C', 'G'])." 1757 ) 1758 1759 if source is None: 1760 raise ValueError("progression() needs a source list (or style=...)") 1761 1762 elements = list(source) 1763 1764 if not elements: 1765 raise ValueError("progression list is empty — pass at least one chord") 1766 1767 lengths = _span_lengths(beats, len(elements)) 1768 1769 return Progression(spans = tuple( 1770 parse_element(element, beats=lengths[index]) 1771 for index, element in enumerate(elements) 1772 ))
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 withProgression.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)
124@dataclasses.dataclass(frozen=True) 125class Chord: 126 127 """ 128 Represents a chord as a root pitch class and quality. 129 """ 130 131 root_pc: int 132 quality: str 133 134 135 def intervals (self) -> typing.List[int]: 136 137 """ 138 Return the chord intervals for this chord quality. 139 """ 140 141 if self.quality not in CHORD_INTERVALS: 142 raise ValueError(f"Unknown chord quality: {self.quality}") 143 144 return CHORD_INTERVALS[self.quality] 145 146 147 148 def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 149 150 """Return MIDI note numbers for chord tones starting from a root. 151 152 Finds the MIDI note corresponding to the chord's root pitch class that is 153 closest to the provided ``root`` argument. 154 155 Parameters: 156 root: MIDI note number (e.g., 60 = middle C) to center the chord around. 157 inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). 158 Wraps around for values >= number of notes. 159 count: Number of notes to return. When set, the chord intervals cycle 160 into higher octaves until ``count`` notes are produced. When ``None`` 161 (default), returns the natural chord tones. 162 163 Returns: 164 List of MIDI note numbers for chord tones 165 166 Example: 167 ```python 168 chord = Chord(root_pc=0, quality="major") # C major 169 chord.tones(root=60) # [60, 64, 67] - root position around C4 170 chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root 171 chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root 172 ``` 173 """ 174 175 # Find the MIDI note for self.root_pc that is closest to the requested root. 176 # This handles octaves automatically. 177 offset = (self.root_pc - root) % 12 178 if offset > 6: 179 offset -= 12 180 181 effective_root = root + offset 182 183 intervals = self.intervals() 184 185 if inversion != 0: 186 intervals = subsequence.voicings.invert_chord(intervals, inversion) 187 188 if count is not None: 189 n = len(intervals) 190 return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] 191 192 return [effective_root + interval for interval in intervals] 193 194 195 def root_note (self, root_midi: int) -> int: 196 197 """ 198 Return the MIDI note number for the chord root nearest to *root_midi*. 199 200 This is equivalent to ``self.tones(root_midi)[0]`` but makes intent 201 explicit when you only need the single root pitch. 202 203 Parameters: 204 root_midi: Reference MIDI note number used to find the closest octave 205 of this chord's root pitch class. 206 207 Returns: 208 MIDI note number of the chord root. 209 210 Example: 211 ```python 212 chord = Chord(root_pc=4, quality="major") # E major 213 chord.root_note(60) # → 64 (E4, nearest to C4) 214 chord.root_note(69) # → 64 (E4, nearest to A4) 215 ``` 216 """ 217 218 return self.tones(root_midi)[0] 219 220 221 def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: 222 223 """ 224 Return the chord root shifted by a number of octaves. 225 226 Commonly used to produce a bass register note one or two octaves 227 below the chord voicing. 228 229 Parameters: 230 root_midi: Reference MIDI note number (passed to :meth:`root_note`). 231 octave_offset: Octaves to shift; negative moves down (default ``-1``). 232 233 Returns: 234 MIDI note number of the chord root in the target register. 235 236 Example: 237 ```python 238 chord = Chord(root_pc=4, quality="major") # E major 239 chord.bass_note(64) # → 52 (E3, one octave down from E4) 240 chord.bass_note(64, -2) # → 40 (E2, two octaves down) 241 ``` 242 """ 243 244 return self.root_note(root_midi) + (12 * octave_offset) 245 246 247 def name (self) -> str: 248 249 """ 250 Return a human-friendly chord name. 251 252 A registered quality without a suffix prints as ``root(quality)`` 253 (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. 254 """ 255 256 root_name = PC_TO_NOTE_NAME[self.root_pc % 12] 257 258 if self.quality not in CHORD_SUFFIX: 259 return f"{root_name}({self.quality})" 260 261 return f"{root_name}{CHORD_SUFFIX[self.quality]}"
Represents a chord as a root pitch class and quality.
135 def intervals (self) -> typing.List[int]: 136 137 """ 138 Return the chord intervals for this chord quality. 139 """ 140 141 if self.quality not in CHORD_INTERVALS: 142 raise ValueError(f"Unknown chord quality: {self.quality}") 143 144 return CHORD_INTERVALS[self.quality]
Return the chord intervals for this chord quality.
148 def tones (self, root: int, inversion: int = 0, count: typing.Optional[int] = None) -> typing.List[int]: 149 150 """Return MIDI note numbers for chord tones starting from a root. 151 152 Finds the MIDI note corresponding to the chord's root pitch class that is 153 closest to the provided ``root`` argument. 154 155 Parameters: 156 root: MIDI note number (e.g., 60 = middle C) to center the chord around. 157 inversion: Chord inversion (0 = root position, 1 = first, 2 = second, ...). 158 Wraps around for values >= number of notes. 159 count: Number of notes to return. When set, the chord intervals cycle 160 into higher octaves until ``count`` notes are produced. When ``None`` 161 (default), returns the natural chord tones. 162 163 Returns: 164 List of MIDI note numbers for chord tones 165 166 Example: 167 ```python 168 chord = Chord(root_pc=0, quality="major") # C major 169 chord.tones(root=60) # [60, 64, 67] - root position around C4 170 chord.tones(root=62) # [60, 64, 67] - still finds C4 as closest root 171 chord.tones(root=70) # [72, 76, 79] - finds C5 as closest root 172 ``` 173 """ 174 175 # Find the MIDI note for self.root_pc that is closest to the requested root. 176 # This handles octaves automatically. 177 offset = (self.root_pc - root) % 12 178 if offset > 6: 179 offset -= 12 180 181 effective_root = root + offset 182 183 intervals = self.intervals() 184 185 if inversion != 0: 186 intervals = subsequence.voicings.invert_chord(intervals, inversion) 187 188 if count is not None: 189 n = len(intervals) 190 return [effective_root + intervals[i % n] + 12 * (i // n) for i in range(count)] 191 192 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
countnotes are produced. WhenNone(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
195 def root_note (self, root_midi: int) -> int: 196 197 """ 198 Return the MIDI note number for the chord root nearest to *root_midi*. 199 200 This is equivalent to ``self.tones(root_midi)[0]`` but makes intent 201 explicit when you only need the single root pitch. 202 203 Parameters: 204 root_midi: Reference MIDI note number used to find the closest octave 205 of this chord's root pitch class. 206 207 Returns: 208 MIDI note number of the chord root. 209 210 Example: 211 ```python 212 chord = Chord(root_pc=4, quality="major") # E major 213 chord.root_note(60) # → 64 (E4, nearest to C4) 214 chord.root_note(69) # → 64 (E4, nearest to A4) 215 ``` 216 """ 217 218 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)
221 def bass_note (self, root_midi: int, octave_offset: int = -1) -> int: 222 223 """ 224 Return the chord root shifted by a number of octaves. 225 226 Commonly used to produce a bass register note one or two octaves 227 below the chord voicing. 228 229 Parameters: 230 root_midi: Reference MIDI note number (passed to :meth:`root_note`). 231 octave_offset: Octaves to shift; negative moves down (default ``-1``). 232 233 Returns: 234 MIDI note number of the chord root in the target register. 235 236 Example: 237 ```python 238 chord = Chord(root_pc=4, quality="major") # E major 239 chord.bass_note(64) # → 52 (E3, one octave down from E4) 240 chord.bass_note(64, -2) # → 40 (E2, two octaves down) 241 ``` 242 """ 243 244 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)
247 def name (self) -> str: 248 249 """ 250 Return a human-friendly chord name. 251 252 A registered quality without a suffix prints as ``root(quality)`` 253 (e.g. ``"C(quartal)"``) rather than masquerading as a plain major. 254 """ 255 256 root_name = PC_TO_NOTE_NAME[self.root_pc % 12] 257 258 if self.quality not in CHORD_SUFFIX: 259 return f"{root_name}({self.quality})" 260 261 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.
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, grid: typing.Optional[float] = None) -> "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 Without ``grid=``, the grid is inferred as ``clip length / note 97 count`` — which assumes the clip plays **exactly one note per grid 98 cell** (the standard shape for a groove clip). A clip with rests or 99 chords breaks that assumption: pass ``grid=`` explicitly (e.g. 100 ``grid=0.25`` for a 16th-note groove) and empty cells keep a neutral 101 offset. A clip whose notes cannot be assigned one-per-cell raises 102 rather than importing a wrong feel. 103 104 **What is extracted:** 105 106 - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets 107 relative to ideal grid positions. 108 - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity 109 scaling (normalised to the highest velocity in the file). 110 - ``TimingAmount`` from the Groove element → pre-scales the timing 111 offsets (100 = full, 70 = 70% of the groove's timing). 112 - ``VelocityAmount`` from the Groove element → pre-scales velocity 113 deviation (100 = full groove velocity, 0 = no velocity changes). 114 115 The resulting ``Groove`` reflects the file author's intended 116 strength. Use ``strength=`` when applying to further adjust. 117 118 **What is NOT imported:** 119 120 ``RandomAmount`` (use ``p.randomize()`` separately for random 121 jitter) and ``QuantizationAmount`` (not applicable - Subsequence 122 notes are already grid-quantized by construction). 123 124 Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, 125 ``OffVelocity``, ``Probability``) are also ignored. 126 127 Parameters: 128 path: Path to the .agr file. 129 grid: Grid size in beats (0.25 = 16th notes). ``None`` (default) 130 infers it from the clip, assuming one note per cell. 131 """ 132 133 tree = xml.etree.ElementTree.parse(path) 134 root = tree.getroot() 135 136 # Find the MIDI clip 137 clip = root.find(".//MidiClip") 138 if clip is None: 139 raise ValueError(f"No MidiClip found in {path}") 140 141 # Get clip length 142 current_end = clip.find("CurrentEnd") 143 if current_end is None: 144 raise ValueError(f"No CurrentEnd found in {path}") 145 clip_length = float(current_end.get("Value", "4")) 146 147 # Read Groove Pool blend parameters 148 groove_elem = root.find(".//Groove") 149 timing_amount = 100.0 150 velocity_amount = 100.0 151 if groove_elem is not None: 152 timing_el = groove_elem.find("TimingAmount") 153 if timing_el is not None: 154 timing_amount = float(timing_el.get("Value", "100")) 155 velocity_el = groove_elem.find("VelocityAmount") 156 if velocity_el is not None: 157 velocity_amount = float(velocity_el.get("Value", "100")) 158 159 timing_scale = timing_amount / 100.0 160 velocity_scale = velocity_amount / 100.0 161 162 # Extract note events sorted by time 163 events = clip.findall(".//MidiNoteEvent") 164 if not events: 165 raise ValueError(f"No MidiNoteEvent elements found in {path}") 166 167 times: typing.List[float] = [] 168 velocities_raw: typing.List[float] = [] 169 for event in events: 170 times.append(float(event.get("Time", "0"))) 171 velocities_raw.append(float(event.get("Velocity", "127"))) 172 173 # Sort as PAIRS - sorting times alone desynced each offset from its 174 # note's velocity whenever the XML listed events out of time order. 175 paired = sorted(zip(times, velocities_raw)) 176 times = [t for t, _ in paired] 177 velocities_raw = [v for _, v in paired] 178 179 note_count = len(times) 180 181 # Infer grid from clip length and note count — valid only for the 182 # one-note-per-cell clip shape (see docstring); grid= overrides. 183 if grid is None: 184 grid = clip_length / note_count 185 186 if grid <= 0: 187 raise ValueError(f"grid must be positive — got {grid}") 188 189 slot_count = max(1, int(round(clip_length / grid))) 190 191 # Bind each note to its NEAREST grid line (robust to rests under an 192 # explicit grid — empty cells keep a neutral offset), refusing 193 # ambiguous clips instead of importing a garbage feel. 194 slot_offsets = [0.0] * slot_count 195 slot_velocities: typing.List[typing.Optional[float]] = [None] * slot_count 196 197 for time, velocity in zip(times, velocities_raw): 198 199 slot = int(round(time / grid)) 200 201 if not 0 <= slot < slot_count: 202 raise ValueError( 203 f"{path}: note at beat {time:g} falls outside the {slot_count}-cell " 204 f"grid (grid={grid:g}, clip length {clip_length:g}) — pass grid= " 205 "matching the clip's note spacing" 206 ) 207 208 if slot_velocities[slot] is not None: 209 raise ValueError( 210 f"{path}: two notes share grid cell {slot} (a chord, or a grid " 211 "coarser than the clip's note spacing) — pass grid= matching the " 212 "clip (e.g. grid=0.25 for 16ths)" 213 ) 214 215 slot_offsets[slot] = (time - slot * grid) * timing_scale 216 slot_velocities[slot] = velocity 217 218 # Calculate velocity scales (relative to max velocity in the file), 219 # blended toward 1.0 by VelocityAmount; empty cells stay neutral (1.0). 220 filled = [v for v in slot_velocities if v is not None] 221 max_vel = max(filled) 222 has_velocity_variation = any(v != max_vel for v in filled) 223 groove_velocities: typing.Optional[typing.List[float]] = None 224 if has_velocity_variation and max_vel > 0: 225 raw_scales = [(v / max_vel) if v is not None else 1.0 for v in slot_velocities] 226 # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) 227 groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] 228 # If blending has removed all variation, set to None 229 if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): 230 groove_velocities = None 231 232 return Groove(offsets=slot_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],
)
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).
85 @staticmethod 86 def from_agr (path: str, grid: typing.Optional[float] = None) -> "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 Without ``grid=``, the grid is inferred as ``clip length / note 97 count`` — which assumes the clip plays **exactly one note per grid 98 cell** (the standard shape for a groove clip). A clip with rests or 99 chords breaks that assumption: pass ``grid=`` explicitly (e.g. 100 ``grid=0.25`` for a 16th-note groove) and empty cells keep a neutral 101 offset. A clip whose notes cannot be assigned one-per-cell raises 102 rather than importing a wrong feel. 103 104 **What is extracted:** 105 106 - ``Time`` attribute of each ``MidiNoteEvent`` → timing offsets 107 relative to ideal grid positions. 108 - ``Velocity`` attribute of each ``MidiNoteEvent`` → velocity 109 scaling (normalised to the highest velocity in the file). 110 - ``TimingAmount`` from the Groove element → pre-scales the timing 111 offsets (100 = full, 70 = 70% of the groove's timing). 112 - ``VelocityAmount`` from the Groove element → pre-scales velocity 113 deviation (100 = full groove velocity, 0 = no velocity changes). 114 115 The resulting ``Groove`` reflects the file author's intended 116 strength. Use ``strength=`` when applying to further adjust. 117 118 **What is NOT imported:** 119 120 ``RandomAmount`` (use ``p.randomize()`` separately for random 121 jitter) and ``QuantizationAmount`` (not applicable - Subsequence 122 notes are already grid-quantized by construction). 123 124 Other ``MidiNoteEvent`` fields (``Duration``, ``VelocityDeviation``, 125 ``OffVelocity``, ``Probability``) are also ignored. 126 127 Parameters: 128 path: Path to the .agr file. 129 grid: Grid size in beats (0.25 = 16th notes). ``None`` (default) 130 infers it from the clip, assuming one note per cell. 131 """ 132 133 tree = xml.etree.ElementTree.parse(path) 134 root = tree.getroot() 135 136 # Find the MIDI clip 137 clip = root.find(".//MidiClip") 138 if clip is None: 139 raise ValueError(f"No MidiClip found in {path}") 140 141 # Get clip length 142 current_end = clip.find("CurrentEnd") 143 if current_end is None: 144 raise ValueError(f"No CurrentEnd found in {path}") 145 clip_length = float(current_end.get("Value", "4")) 146 147 # Read Groove Pool blend parameters 148 groove_elem = root.find(".//Groove") 149 timing_amount = 100.0 150 velocity_amount = 100.0 151 if groove_elem is not None: 152 timing_el = groove_elem.find("TimingAmount") 153 if timing_el is not None: 154 timing_amount = float(timing_el.get("Value", "100")) 155 velocity_el = groove_elem.find("VelocityAmount") 156 if velocity_el is not None: 157 velocity_amount = float(velocity_el.get("Value", "100")) 158 159 timing_scale = timing_amount / 100.0 160 velocity_scale = velocity_amount / 100.0 161 162 # Extract note events sorted by time 163 events = clip.findall(".//MidiNoteEvent") 164 if not events: 165 raise ValueError(f"No MidiNoteEvent elements found in {path}") 166 167 times: typing.List[float] = [] 168 velocities_raw: typing.List[float] = [] 169 for event in events: 170 times.append(float(event.get("Time", "0"))) 171 velocities_raw.append(float(event.get("Velocity", "127"))) 172 173 # Sort as PAIRS - sorting times alone desynced each offset from its 174 # note's velocity whenever the XML listed events out of time order. 175 paired = sorted(zip(times, velocities_raw)) 176 times = [t for t, _ in paired] 177 velocities_raw = [v for _, v in paired] 178 179 note_count = len(times) 180 181 # Infer grid from clip length and note count — valid only for the 182 # one-note-per-cell clip shape (see docstring); grid= overrides. 183 if grid is None: 184 grid = clip_length / note_count 185 186 if grid <= 0: 187 raise ValueError(f"grid must be positive — got {grid}") 188 189 slot_count = max(1, int(round(clip_length / grid))) 190 191 # Bind each note to its NEAREST grid line (robust to rests under an 192 # explicit grid — empty cells keep a neutral offset), refusing 193 # ambiguous clips instead of importing a garbage feel. 194 slot_offsets = [0.0] * slot_count 195 slot_velocities: typing.List[typing.Optional[float]] = [None] * slot_count 196 197 for time, velocity in zip(times, velocities_raw): 198 199 slot = int(round(time / grid)) 200 201 if not 0 <= slot < slot_count: 202 raise ValueError( 203 f"{path}: note at beat {time:g} falls outside the {slot_count}-cell " 204 f"grid (grid={grid:g}, clip length {clip_length:g}) — pass grid= " 205 "matching the clip's note spacing" 206 ) 207 208 if slot_velocities[slot] is not None: 209 raise ValueError( 210 f"{path}: two notes share grid cell {slot} (a chord, or a grid " 211 "coarser than the clip's note spacing) — pass grid= matching the " 212 "clip (e.g. grid=0.25 for 16ths)" 213 ) 214 215 slot_offsets[slot] = (time - slot * grid) * timing_scale 216 slot_velocities[slot] = velocity 217 218 # Calculate velocity scales (relative to max velocity in the file), 219 # blended toward 1.0 by VelocityAmount; empty cells stay neutral (1.0). 220 filled = [v for v in slot_velocities if v is not None] 221 max_vel = max(filled) 222 has_velocity_variation = any(v != max_vel for v in filled) 223 groove_velocities: typing.Optional[typing.List[float]] = None 224 if has_velocity_variation and max_vel > 0: 225 raw_scales = [(v / max_vel) if v is not None else 1.0 for v in slot_velocities] 226 # velocity_scale=1.0 → full groove velocity; 0.0 → all 1.0 (no change) 227 groove_velocities = [1.0 + (s - 1.0) * velocity_scale for s in raw_scales] 228 # If blending has removed all variation, set to None 229 if all(abs(v - 1.0) < 1e-9 for v in groove_velocities): 230 groove_velocities = None 231 232 return Groove(offsets=slot_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).
Without grid=, the grid is inferred as clip length / note
count — which assumes the clip plays exactly one note per grid
cell (the standard shape for a groove clip). A clip with rests or
chords breaks that assumption: pass grid= explicitly (e.g.
grid=0.25 for a 16th-note groove) and empty cells keep a neutral
offset. A clip whose notes cannot be assigned one-per-cell raises
rather than importing a wrong feel.
What is extracted:
Timeattribute of eachMidiNoteEvent→ timing offsets relative to ideal grid positions.Velocityattribute of eachMidiNoteEvent→ velocity scaling (normalised to the highest velocity in the file).TimingAmountfrom the Groove element → pre-scales the timing offsets (100 = full, 70 = 70% of the groove's timing).VelocityAmountfrom 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.
- grid: Grid size in beats (0.25 = 16th notes).
None(default) infers it from the clip, assuming one note per cell.
208class MelodicState: 209 210 """Persistent melodic context that applies NIR scoring to single-note lines.""" 211 212 213 def __init__ ( 214 self, 215 key: typing.Optional[str] = None, 216 mode: typing.Optional[str] = None, 217 low: int = 48, 218 high: int = 72, 219 nir_strength: float = 0.5, 220 chord_weight: float = 0.4, 221 rest_probability: float = 0.0, 222 pitch_diversity: float = 0.6, 223 tessitura_strength: float = 0.0, 224 ) -> None: 225 226 """Initialise a melodic state for a given key, mode, and MIDI register. 227 228 Parameters: 229 key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). 230 When omitted, the state adopts the **composition's** key the 231 first time ``p.melody()`` uses it (falling back to ``"C"``). 232 mode: Scale mode name. Accepts any mode registered with 233 :func:`~subsequence.intervals.scale_pitch_classes` (e.g. 234 ``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted, 235 adopts the composition's scale (falling back to ``"ionian"``). 236 low: Lowest MIDI note (inclusive) in the pitch pool. 237 high: Highest MIDI note (inclusive) in the pitch pool. 238 nir_strength: 0.0–1.0. Scales how strongly the NIR rules 239 influence candidate scores. 0.0 = uniform; 1.0 = full boost. 240 chord_weight: 0.0–1.0. Additive multiplier bonus for candidates 241 whose pitch class belongs to the current chord tones. 242 rest_probability: 0.0–1.0. Probability of producing a rest 243 (returning ``None``) at any given step. 244 pitch_diversity: 0.0–1.0. Exponential penalty per recent 245 repetition of the same pitch. Lower values discourage 246 repetition more aggressively. 247 tessitura_strength: 0.0–1.0. Regression pull toward the centre 248 of the register after the line strays (off by default; the 249 generate path enables it). 250 """ 251 252 if nir_strength < 0 or nir_strength > 1: 253 raise ValueError("NIR strength must be between 0 and 1") 254 255 if rest_probability < 0 or rest_probability > 1: 256 raise ValueError("Rest probability must be between 0 and 1") 257 258 if pitch_diversity < 0 or pitch_diversity > 1: 259 raise ValueError("Pitch diversity must be between 0 and 1") 260 261 if chord_weight < 0 or chord_weight > 1: 262 raise ValueError("Chord weight must be between 0 and 1") 263 264 if tessitura_strength < 0 or tessitura_strength > 1: 265 raise ValueError("Tessitura strength must be between 0 and 1") 266 267 if low >= high: 268 raise ValueError("low must be below high") 269 270 # None defers to the composition (configure_defaults); the fallbacks 271 # keep a bare MelodicState() working standalone. 272 self._explicit_key = key is not None 273 self._explicit_mode = mode is not None 274 self._explicit_pool = False 275 276 self.key = key if key is not None else "C" 277 self.mode = mode if mode is not None else "ionian" 278 self.low = low 279 self.high = high 280 self.nir_strength = nir_strength 281 self.chord_weight = chord_weight 282 self.rest_probability = rest_probability 283 self.pitch_diversity = pitch_diversity 284 self.tessitura_strength = tessitura_strength 285 286 # The soft side of the CHORAL separation — replace or extend freely. 287 self.factors: typing.List[ScoringFactor] = list(DEFAULT_FACTORS) 288 289 self._rebuild_pool() 290 291 # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). 292 self.history: typing.List[int] = [] 293 294 295 def _rebuild_pool (self) -> None: 296 297 """Derive the pitch pool (the one hard constraint) from key/mode/register.""" 298 299 self._tonic_pc: int = subsequence.chords.key_name_to_pc(self.key) 300 301 self._pitch_pool: typing.List[int] = subsequence.intervals.scale_notes( 302 self.key, self.mode, low=self.low, high=self.high 303 ) 304 305 306 def configure_defaults (self, key: typing.Optional[str], mode: typing.Optional[str]) -> None: 307 308 """Adopt the surrounding key/scale where this state left them unset. 309 310 Called by ``p.melody()`` every build. It **tracks** the builder's 311 current key/scale (which is the section's effective key under a form), 312 so a state placed across sections follows each section's key — its 313 melodic *history* is untouched, only the pitch pool and tonic move. 314 An explicit constructor key/scale or an explicit pool always wins and 315 is never overridden. 316 """ 317 318 if self._explicit_pool: 319 return 320 321 changed = False 322 323 # Re-track on every call (not just the first): a persistent state used 324 # across sections must follow the live key, or the first section to 325 # place it would freeze the key forever. 326 if not self._explicit_key and key is not None and key != self.key: 327 self.key = key 328 changed = True 329 330 if not self._explicit_mode and mode is not None and mode != self.mode: 331 self.mode = mode 332 changed = True 333 334 if changed: 335 self._rebuild_pool() 336 337 338 def set_pool (self, pitches: typing.Sequence[int]) -> None: 339 340 """Replace the pitch pool with explicit MIDI pitches — the experimental seam. 341 342 Admits sieve output, non-octave organisations, or any hand-picked 343 pool; key/mode no longer constrain candidates (the tonic pitch 344 class, for Rule C, stays the key's). 345 """ 346 347 pool = sorted(int(p) for p in pitches) 348 349 if not pool: 350 raise ValueError("set_pool() needs at least one pitch") 351 352 self._pitch_pool = pool 353 self._explicit_pool = True 354 self.low = pool[0] 355 self.high = max(pool[-1], pool[0] + 1) 356 357 358 def clone (self) -> "MelodicState": 359 360 """An independent copy — settings, factors, pool, and history. 361 362 Value constructors (``Motif.generate``) copy the state they are 363 given and walk the copy, so a module-level live state is never 364 mutated by building a value. 365 """ 366 367 duplicate = MelodicState( 368 key = self.key if self._explicit_key else None, 369 mode = self.mode if self._explicit_mode else None, 370 low = self.low, 371 high = self.high, 372 nir_strength = self.nir_strength, 373 chord_weight = self.chord_weight, 374 rest_probability = self.rest_probability, 375 pitch_diversity = self.pitch_diversity, 376 tessitura_strength = self.tessitura_strength, 377 ) 378 379 duplicate.key = self.key 380 duplicate.mode = self.mode 381 duplicate._rebuild_pool() 382 383 if self._explicit_pool: 384 duplicate.set_pool(self._pitch_pool) 385 386 duplicate.factors = list(self.factors) 387 duplicate.history = list(self.history) 388 389 return duplicate 390 391 392 def choose_next ( 393 self, 394 chord_tones: typing.Optional[typing.List[int]], 395 rng: random.Random, 396 beat: typing.Optional[float] = None, 397 position: typing.Optional[float] = None, 398 contour_target: typing.Optional[float] = None, 399 ) -> typing.Optional[int]: 400 401 """Score all pitch-pool candidates and return the chosen pitch, or None for a rest. 402 403 ``beat`` (the note's beat within its cycle), ``position`` (0–1 404 through a generated span), and ``contour_target`` (the envelope's 405 height there) thread caller context into the scoring factors. 406 """ 407 408 if self.rest_probability > 0.0 and rng.random() < self.rest_probability: 409 return None 410 411 if not self._pitch_pool: 412 return None 413 414 # Resolve chord tones to pitch classes for fast membership testing. 415 chord_tone_pcs = {t % 12 for t in chord_tones} if chord_tones else set() 416 417 scores = [ 418 self._score_candidate(candidate, chord_tone_pcs, beat=beat, position=position, contour_target=contour_target) 419 for candidate in self._pitch_pool 420 ] 421 422 # Weighted random choice: select using cumulative score as a probability weight. 423 total = sum(scores) 424 425 if total <= 0.0: 426 chosen = rng.choice(self._pitch_pool) 427 428 else: 429 r = rng.uniform(0.0, total) 430 cumulative = 0.0 431 chosen = self._pitch_pool[-1] 432 433 for pitch, score in zip(self._pitch_pool, scores): 434 cumulative += score 435 if r <= cumulative: 436 chosen = pitch 437 break 438 439 self.record(chosen) 440 441 return chosen 442 443 444 def _score_candidate ( 445 self, 446 candidate: int, 447 chord_tone_pcs: typing.Set[int], 448 beat: typing.Optional[float] = None, 449 position: typing.Optional[float] = None, 450 contour_target: typing.Optional[float] = None, 451 ) -> float: 452 453 """Score one candidate: the product of every factor in :attr:`factors`.""" 454 455 ctx = ScoringContext( 456 candidate = candidate, 457 history = tuple(self.history), 458 chord_tone_pcs = frozenset(chord_tone_pcs), 459 tonic_pc = self._tonic_pc, 460 low = self.low, 461 high = self.high, 462 beat = beat, 463 position = position, 464 contour_target = contour_target, 465 ) 466 467 score = 1.0 468 469 for factor in self.factors: 470 score *= factor(self, ctx) 471 472 return max(0.0, score) 473 474 def record (self, pitch: int) -> None: 475 476 """Append a pitch to the melodic history (capped at 4 entries). 477 478 Public so pinned notes — chosen by fiat, not by the walk — still 479 enter the NIR context. 480 """ 481 482 self.history.append(pitch) 483 if len(self.history) > 4: 484 self.history.pop(0)
Persistent melodic context that applies NIR scoring to single-note lines.
213 def __init__ ( 214 self, 215 key: typing.Optional[str] = None, 216 mode: typing.Optional[str] = None, 217 low: int = 48, 218 high: int = 72, 219 nir_strength: float = 0.5, 220 chord_weight: float = 0.4, 221 rest_probability: float = 0.0, 222 pitch_diversity: float = 0.6, 223 tessitura_strength: float = 0.0, 224 ) -> None: 225 226 """Initialise a melodic state for a given key, mode, and MIDI register. 227 228 Parameters: 229 key: Root note of the key (e.g. ``"C"``, ``"F#"``, ``"Bb"``). 230 When omitted, the state adopts the **composition's** key the 231 first time ``p.melody()`` uses it (falling back to ``"C"``). 232 mode: Scale mode name. Accepts any mode registered with 233 :func:`~subsequence.intervals.scale_pitch_classes` (e.g. 234 ``"ionian"``, ``"aeolian"``, ``"dorian"``). When omitted, 235 adopts the composition's scale (falling back to ``"ionian"``). 236 low: Lowest MIDI note (inclusive) in the pitch pool. 237 high: Highest MIDI note (inclusive) in the pitch pool. 238 nir_strength: 0.0–1.0. Scales how strongly the NIR rules 239 influence candidate scores. 0.0 = uniform; 1.0 = full boost. 240 chord_weight: 0.0–1.0. Additive multiplier bonus for candidates 241 whose pitch class belongs to the current chord tones. 242 rest_probability: 0.0–1.0. Probability of producing a rest 243 (returning ``None``) at any given step. 244 pitch_diversity: 0.0–1.0. Exponential penalty per recent 245 repetition of the same pitch. Lower values discourage 246 repetition more aggressively. 247 tessitura_strength: 0.0–1.0. Regression pull toward the centre 248 of the register after the line strays (off by default; the 249 generate path enables it). 250 """ 251 252 if nir_strength < 0 or nir_strength > 1: 253 raise ValueError("NIR strength must be between 0 and 1") 254 255 if rest_probability < 0 or rest_probability > 1: 256 raise ValueError("Rest probability must be between 0 and 1") 257 258 if pitch_diversity < 0 or pitch_diversity > 1: 259 raise ValueError("Pitch diversity must be between 0 and 1") 260 261 if chord_weight < 0 or chord_weight > 1: 262 raise ValueError("Chord weight must be between 0 and 1") 263 264 if tessitura_strength < 0 or tessitura_strength > 1: 265 raise ValueError("Tessitura strength must be between 0 and 1") 266 267 if low >= high: 268 raise ValueError("low must be below high") 269 270 # None defers to the composition (configure_defaults); the fallbacks 271 # keep a bare MelodicState() working standalone. 272 self._explicit_key = key is not None 273 self._explicit_mode = mode is not None 274 self._explicit_pool = False 275 276 self.key = key if key is not None else "C" 277 self.mode = mode if mode is not None else "ionian" 278 self.low = low 279 self.high = high 280 self.nir_strength = nir_strength 281 self.chord_weight = chord_weight 282 self.rest_probability = rest_probability 283 self.pitch_diversity = pitch_diversity 284 self.tessitura_strength = tessitura_strength 285 286 # The soft side of the CHORAL separation — replace or extend freely. 287 self.factors: typing.List[ScoringFactor] = list(DEFAULT_FACTORS) 288 289 self._rebuild_pool() 290 291 # History of last N absolute MIDI pitches (capped at 4, same as HarmonicState). 292 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"). When omitted, the state adopts the composition's key the first timep.melody()uses it (falling back to"C"). - mode: Scale mode name. Accepts any mode registered with
~subsequence.intervals.scale_pitch_classes()(e.g."ionian","aeolian","dorian"). When omitted, adopts the composition's scale (falling back to"ionian"). - 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.
- tessitura_strength: 0.0–1.0. Regression pull toward the centre of the register after the line strays (off by default; the generate path enables it).
306 def configure_defaults (self, key: typing.Optional[str], mode: typing.Optional[str]) -> None: 307 308 """Adopt the surrounding key/scale where this state left them unset. 309 310 Called by ``p.melody()`` every build. It **tracks** the builder's 311 current key/scale (which is the section's effective key under a form), 312 so a state placed across sections follows each section's key — its 313 melodic *history* is untouched, only the pitch pool and tonic move. 314 An explicit constructor key/scale or an explicit pool always wins and 315 is never overridden. 316 """ 317 318 if self._explicit_pool: 319 return 320 321 changed = False 322 323 # Re-track on every call (not just the first): a persistent state used 324 # across sections must follow the live key, or the first section to 325 # place it would freeze the key forever. 326 if not self._explicit_key and key is not None and key != self.key: 327 self.key = key 328 changed = True 329 330 if not self._explicit_mode and mode is not None and mode != self.mode: 331 self.mode = mode 332 changed = True 333 334 if changed: 335 self._rebuild_pool()
Adopt the surrounding key/scale where this state left them unset.
Called by p.melody() every build. It tracks the builder's
current key/scale (which is the section's effective key under a form),
so a state placed across sections follows each section's key — its
melodic history is untouched, only the pitch pool and tonic move.
An explicit constructor key/scale or an explicit pool always wins and
is never overridden.
338 def set_pool (self, pitches: typing.Sequence[int]) -> None: 339 340 """Replace the pitch pool with explicit MIDI pitches — the experimental seam. 341 342 Admits sieve output, non-octave organisations, or any hand-picked 343 pool; key/mode no longer constrain candidates (the tonic pitch 344 class, for Rule C, stays the key's). 345 """ 346 347 pool = sorted(int(p) for p in pitches) 348 349 if not pool: 350 raise ValueError("set_pool() needs at least one pitch") 351 352 self._pitch_pool = pool 353 self._explicit_pool = True 354 self.low = pool[0] 355 self.high = max(pool[-1], pool[0] + 1)
Replace the pitch pool with explicit MIDI pitches — the experimental seam.
Admits sieve output, non-octave organisations, or any hand-picked pool; key/mode no longer constrain candidates (the tonic pitch class, for Rule C, stays the key's).
358 def clone (self) -> "MelodicState": 359 360 """An independent copy — settings, factors, pool, and history. 361 362 Value constructors (``Motif.generate``) copy the state they are 363 given and walk the copy, so a module-level live state is never 364 mutated by building a value. 365 """ 366 367 duplicate = MelodicState( 368 key = self.key if self._explicit_key else None, 369 mode = self.mode if self._explicit_mode else None, 370 low = self.low, 371 high = self.high, 372 nir_strength = self.nir_strength, 373 chord_weight = self.chord_weight, 374 rest_probability = self.rest_probability, 375 pitch_diversity = self.pitch_diversity, 376 tessitura_strength = self.tessitura_strength, 377 ) 378 379 duplicate.key = self.key 380 duplicate.mode = self.mode 381 duplicate._rebuild_pool() 382 383 if self._explicit_pool: 384 duplicate.set_pool(self._pitch_pool) 385 386 duplicate.factors = list(self.factors) 387 duplicate.history = list(self.history) 388 389 return duplicate
An independent copy — settings, factors, pool, and history.
Value constructors (Motif.generate) copy the state they are
given and walk the copy, so a module-level live state is never
mutated by building a value.
392 def choose_next ( 393 self, 394 chord_tones: typing.Optional[typing.List[int]], 395 rng: random.Random, 396 beat: typing.Optional[float] = None, 397 position: typing.Optional[float] = None, 398 contour_target: typing.Optional[float] = None, 399 ) -> typing.Optional[int]: 400 401 """Score all pitch-pool candidates and return the chosen pitch, or None for a rest. 402 403 ``beat`` (the note's beat within its cycle), ``position`` (0–1 404 through a generated span), and ``contour_target`` (the envelope's 405 height there) thread caller context into the scoring factors. 406 """ 407 408 if self.rest_probability > 0.0 and rng.random() < self.rest_probability: 409 return None 410 411 if not self._pitch_pool: 412 return None 413 414 # Resolve chord tones to pitch classes for fast membership testing. 415 chord_tone_pcs = {t % 12 for t in chord_tones} if chord_tones else set() 416 417 scores = [ 418 self._score_candidate(candidate, chord_tone_pcs, beat=beat, position=position, contour_target=contour_target) 419 for candidate in self._pitch_pool 420 ] 421 422 # Weighted random choice: select using cumulative score as a probability weight. 423 total = sum(scores) 424 425 if total <= 0.0: 426 chosen = rng.choice(self._pitch_pool) 427 428 else: 429 r = rng.uniform(0.0, total) 430 cumulative = 0.0 431 chosen = self._pitch_pool[-1] 432 433 for pitch, score in zip(self._pitch_pool, scores): 434 cumulative += score 435 if r <= cumulative: 436 chosen = pitch 437 break 438 439 self.record(chosen) 440 441 return chosen
Score all pitch-pool candidates and return the chosen pitch, or None for a rest.
beat (the note's beat within its cycle), position (0–1
through a generated span), and contour_target (the envelope's
height there) thread caller context into the scoring factors.
474 def record (self, pitch: int) -> None: 475 476 """Append a pitch to the melodic history (capped at 4 entries). 477 478 Public so pinned notes — chosen by fiat, not by the walk — still 479 enter the NIR context. 480 """ 481 482 self.history.append(pitch) 483 if len(self.history) > 4: 484 self.history.pop(0)
Append a pitch to the melodic history (capped at 4 entries).
Public so pinned notes — chosen by fiat, not by the walk — still enter the NIR context.
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
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).
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).
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 via1200 × log₂(ratio).
Raises ValueError for malformed files.
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).
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.
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).
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.
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)wherenearest_noteis the integer MIDI note to send andbend_normalizedis the normalised pitch bend value (-1.0 to +1.0).
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."
385def parse_chord (name: str) -> Chord: 386 387 """Parse a chord name like ``"Cm7"`` or ``"Dbmaj7"`` into a :class:`Chord`. 388 389 The name is a root note (``A``–``G`` with an optional ``#`` or ``b``) followed 390 by a quality suffix: ``""`` major, ``m`` minor, ``dim`` diminished, 391 ``+``/``aug`` augmented, ``7`` dominant 7th, ``maj7`` major 7th, ``m7`` minor 392 7th, ``m7b5``/``ø`` half-diminished 7th, ``sus2``, ``sus4``. A few common 393 alternates (``min``, ``-``, ``M7``, …) are accepted too. 394 395 Raises ``ValueError`` for anything it can't read, so a typo surfaces at the 396 call site rather than as a silently wrong chord. 397 398 Example: 399 ```python 400 parse_chord("Cm7") # → Chord(root_pc=0, quality="minor_7th") 401 parse_chord("Dbmaj7") # → Chord(root_pc=1, quality="major_7th") 402 parse_chord("F#") # → Chord(root_pc=6, quality="major") 403 ``` 404 """ 405 406 stripped = name.strip() 407 if not stripped or stripped[0] not in "ABCDEFG": 408 raise ValueError(f"Cannot parse chord name {name!r} — expected a root like 'C', 'F#', 'Bb' then a quality, e.g. 'Cm7'") 409 410 split = 2 if (len(stripped) > 1 and stripped[1] in "#b") else 1 411 root_name = stripped[:split] 412 suffix = stripped[split:] 413 414 if root_name not in NOTE_NAME_TO_PC: 415 raise ValueError(f"Cannot parse chord name {name!r} — unknown root {root_name!r}") 416 if suffix not in _SUFFIX_TO_QUALITY: 417 known = ", ".join(repr(key) for key in sorted(_SUFFIX_TO_QUALITY) if key) 418 raise ValueError(f"Cannot parse chord name {name!r} — unknown quality {suffix!r}. Known suffixes: {known}") 419 420 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 (A–G 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")
304def register_chord_quality ( 305 name: str, 306 intervals: typing.List[int], 307 suffix: typing.Optional[str] = None, 308) -> None: 309 310 """Register a custom chord quality for use everywhere chords are used. 311 312 The counterpart to :func:`subsequence.intervals.register_scale` — it opens 313 the quality table so quartal stacks, clusters, and extended chords become 314 first-class symbolic chords: they work in progressions, graphs, voice 315 leading, and ``describe()`` output. 316 317 Built-in qualities (e.g. ``"minor"``) cannot be overwritten. Custom names 318 may be re-registered freely — live reload re-runs registration on every 319 save, so this must not raise. 320 321 Parameters: 322 name: Quality name (used as ``Chord(root_pc, quality=name)``). 323 intervals: Semitone offsets from the root (e.g. ``[0, 5, 10]`` for a 324 quartal stack, ``[0, 3, 7, 10, 14]`` for a minor 9th). Must start 325 with 0, ascend strictly, and stay within 0–24 (extensions reach 326 past the octave). 327 suffix: Optional chord-name suffix. When given, ``parse_chord()`` 328 accepts ``"A" + suffix`` and ``Chord.name()`` prints it — so 329 ``register_chord_quality("minor_9th", [0, 3, 7, 10, 14], suffix="m9")`` 330 makes ``"Am9"`` parse from then on. Must not collide with a 331 built-in suffix. 332 333 Example: 334 ```python 335 import subsequence 336 337 subsequence.register_chord_quality("quartal", [0, 5, 10], suffix="q4") 338 subsequence.parse_chord("Dq4") # → Chord(root_pc=2, quality="quartal") 339 ``` 340 """ 341 342 if name in _BUILTIN_QUALITY_NAMES: 343 raise ValueError( 344 f"Cannot overwrite built-in chord quality '{name}'. " 345 "Choose a different name for your custom quality." 346 ) 347 348 if not intervals: 349 raise ValueError("intervals must not be empty") 350 if not all(isinstance(i, int) and not isinstance(i, bool) for i in intervals): 351 raise ValueError("intervals must be whole numbers (semitone offsets)") 352 if intervals[0] != 0: 353 raise ValueError("intervals must start with 0 (the root)") 354 if any(b <= a for a, b in zip(intervals, intervals[1:])): 355 raise ValueError("intervals must be strictly ascending") 356 if any(i < 0 or i > 24 for i in intervals): 357 raise ValueError("intervals must contain values between 0 and 24") 358 359 if suffix is not None: 360 if suffix in _BUILTIN_SUFFIXES: 361 raise ValueError( 362 f"Suffix {suffix!r} is a built-in chord suffix and cannot be reused. " 363 "Choose a different suffix for your custom quality." 364 ) 365 if not suffix or suffix[0] in "ABCDEFG#b0123456789": 366 raise ValueError( 367 f"Suffix {suffix!r} would be ambiguous in a chord name — " 368 "it must not be empty or start with a note letter, accidental, or digit" 369 ) 370 371 # Re-registration: drop any suffix this quality registered previously, so 372 # renaming a suffix on live reload does not leave a stale alias behind. 373 for old_suffix in [s for s, q in _SUFFIX_TO_QUALITY.items() if q == name and s not in _BUILTIN_SUFFIXES]: 374 del _SUFFIX_TO_QUALITY[old_suffix] 375 376 CHORD_INTERVALS[name] = list(intervals) 377 378 if suffix is not None: 379 CHORD_SUFFIX[name] = suffix 380 _SUFFIX_TO_QUALITY[suffix] = name 381 else: 382 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" + suffixandChord.name()prints it — soregister_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")
340def register_scale ( 341 name: str, 342 intervals: typing.List[int], 343 qualities: typing.Optional[typing.List[str]] = None 344) -> None: 345 346 """ 347 Register a custom scale for use with ``p.snap_to_scale()`` and 348 ``scale_pitch_classes()``. 349 350 Built-in scale names (e.g. ``"minor"``, ``"hirajoshi"``) cannot be 351 overwritten. Custom names may be re-registered freely — live reload 352 re-runs registration on every save, so this must not raise. 353 354 Parameters: 355 name: Scale name (used in ``p.snap_to_scale(key, name)``). Must not 356 be the name of a built-in scale. 357 intervals: Semitone offsets from the root (e.g. ``[0, 2, 3, 7, 8]`` 358 for Hirajōshi). Must be whole numbers, start with 0, ascend 359 strictly, and stay within 0–11. 360 qualities: Optional chord quality per scale degree (e.g. 361 ``["minor", "major", "minor", "major", "diminished"]``). 362 Required only if you want to use the scale with 363 ``diatonic_chords()`` or ``diatonic_chord_sequence()``. 364 365 Raises: 366 ValueError: If *name* is a built-in scale, or *intervals* / 367 *qualities* fail the rules above. 368 369 Example:: 370 371 import subsequence 372 373 subsequence.register_scale("raga_bhairav", [0, 1, 4, 5, 7, 8, 11]) 374 375 @comp.pattern(channel=0, length=4) 376 def melody (p): 377 p.note(60, beat=0) 378 p.snap_to_scale("C", "raga_bhairav") 379 """ 380 381 if name in _BUILTIN_SCALE_NAMES: 382 raise ValueError( 383 f"Cannot overwrite built-in scale '{name}'. " 384 "Choose a different name for your custom scale." 385 ) 386 387 if not intervals: 388 raise ValueError("intervals must not be empty") 389 if not all(isinstance(i, int) for i in intervals): 390 raise ValueError("intervals must be whole numbers (semitone offsets)") 391 if intervals[0] != 0: 392 raise ValueError("intervals must start with 0") 393 if any(b <= a for a, b in zip(intervals, intervals[1:])): 394 raise ValueError("intervals must be strictly ascending") 395 if any(i < 0 or i > 11 for i in intervals): 396 raise ValueError("intervals must contain values between 0 and 11") 397 if qualities is not None and len(qualities) != len(intervals): 398 raise ValueError( 399 f"qualities length ({len(qualities)}) must match " 400 f"intervals length ({len(intervals)})" 401 ) 402 403 INTERVAL_DEFINITIONS[name] = intervals 404 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 withdiatonic_chords()ordiatonic_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")
196def scale_notes ( 197 key: str, 198 mode: str = "ionian", 199 low: int = 60, 200 high: int = 72, 201 count: typing.Optional[int] = None, 202) -> typing.List[int]: 203 204 """Return MIDI note numbers for a scale within a pitch range. 205 206 Parameters: 207 key: Scale root as a note name (``"C"``, ``"F#"``, ``"Bb"``, etc.). 208 This acts as a **pitch-class filter only** — it determines which 209 semitone positions (0–11) are valid members of the scale, but does 210 not affect which octave notes are drawn from. Notes are selected 211 starting from ``low`` upward; ``key`` controls *which* notes are 212 kept, not where the sequence starts. To guarantee the first 213 returned note is the root, ``low`` must be a MIDI number whose 214 pitch class matches ``key``. When starting from an arbitrary MIDI 215 number, derive the key name with 216 ``subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12]``. 217 mode: Scale mode name. Supports all keys of :data:`SCALE_MODE_MAP` 218 (e.g. ``"ionian"``, ``"dorian"``, ``"natural_minor"``, 219 ``"major_pentatonic"``). Use :func:`register_scale` for custom scales. 220 low: Lowest MIDI note (inclusive). When ``count`` is set, this is 221 the starting note from which the scale ascends. **If ``low`` is 222 not a member of the scale defined by ``key``, it is silently 223 skipped** and the first returned note will be the next in-scale 224 pitch above ``low``. 225 high: Highest MIDI note (inclusive). Ignored when ``count`` is set. 226 count: Exact number of notes to return. Notes ascend from ``low`` 227 through successive scale degrees, cycling into higher octaves 228 as needed. When ``None`` (default), all scale tones between 229 ``low`` and ``high`` are returned. 230 231 Returns: 232 Sorted list of MIDI note numbers. 233 234 Examples: 235 ```python 236 import subsequence 237 import subsequence.constants.midi_notes as notes 238 239 # C major: all tones from middle C to C5 240 subsequence.scale_notes("C", "ionian", low=notes.C4, high=notes.C5) 241 # → [60, 62, 64, 65, 67, 69, 71, 72] 242 243 # E natural minor (aeolian) across one octave 244 subsequence.scale_notes("E", "aeolian", low=notes.E2, high=notes.E3) 245 # → [40, 42, 43, 45, 47, 48, 50, 52] 246 247 # 15 notes of A minor pentatonic ascending from A3 248 subsequence.scale_notes("A", "minor_pentatonic", low=notes.A3, count=15) 249 # → [57, 60, 62, 64, 67, 69, 72, 74, 76, 79, 81, 84, 86, 88, 91] 250 251 # Misalignment: key="E" but low=C4 — first note is C, not E 252 subsequence.scale_notes("E", "minor", low=60, count=4) 253 # → [60, 62, 64, 66] (C D E F# — all in E natural minor, but starts on C) 254 255 # Fix: derive key name from root_pitch so low is always in the scale 256 root_pitch = 64 # E4 257 key = subsequence.chords.PC_TO_NOTE_NAME[root_pitch % 12] # → "E" 258 subsequence.scale_notes(key, "minor", low=root_pitch, count=4) 259 # → [64, 66, 67, 69] (E F# G A — starts on the root) 260 ``` 261 """ 262 263 key_pc = subsequence.chords.key_name_to_pc(key) 264 pcs = set(scale_pitch_classes(key_pc, mode)) 265 266 if count is not None: 267 if not pcs: 268 return [] 269 result: typing.List[int] = [] 270 pitch = low 271 while len(result) < count and pitch <= 127: 272 if pitch % 12 in pcs: 273 result.append(pitch) 274 pitch += 1 275 return result 276 277 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 fromlowupward;keycontrols which notes are kept, not where the sequence starts. To guarantee the first returned note is the root,lowmust be a MIDI number whose pitch class matcheskey. When starting from an arbitrary MIDI number, derive the key name withsubsequence.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"). Useregister_scale()for custom scales. - low: Lowest MIDI note (inclusive). When
countis set, this is the starting note from which the scale ascends. Iflowis not a member of the scale defined bykey, it is silently skipped and the first returned note will be the next in-scale pitch abovelow. - high: Highest MIDI note (inclusive). Ignored when
countis set. - count: Exact number of notes to return. Notes ascend from
lowthrough successive scale degrees, cycling into higher octaves as needed. WhenNone(default), all scale tones betweenlowandhighare 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)
195def bank_select (bank: int) -> typing.Tuple[int, int]: 196 197 """ 198 Convert a 14-bit MIDI bank number to (MSB, LSB) for use with 199 ``p.program_change()``. 200 201 MIDI bank select uses two control-change messages: CC 0 (Bank MSB) and 202 CC 32 (Bank LSB). Together they encode a 14-bit bank number in the 203 range 0–16,383: 204 205 MSB = bank // 128 (upper 7 bits, sent on CC 0) 206 LSB = bank % 128 (lower 7 bits, sent on CC 32) 207 208 Args: 209 bank: Integer bank number, 0–16,383. Values outside this range are 210 clamped. 211 212 Returns: 213 ``(msb, lsb)`` tuple, each value in 0–127. 214 215 Example: 216 ```python 217 msb, lsb = subsequence.bank_select(128) # → (1, 0) 218 p.program_change(48, bank_msb=msb, bank_lsb=lsb) 219 ``` 220 """ 221 222 bank = max(0, min(16383, bank)) 223 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)
93@dataclasses.dataclass(frozen=True) 94class Definitions: 95 96 """ 97 The name-to-number tables read from a project definitions file. 98 99 One plain ``dict`` per section, always present — an absent or null section 100 is an empty dict. The dicts merge directly into the existing parameters: 101 ``notes`` into ``drum_note_map=``, ``cc`` into ``cc_name_map=``, ``nrpn`` 102 into ``nrpn_name_map=``, while ``channels`` values feed ``channel=`` and 103 ``programs`` values feed ``p.program_change()``. 104 105 The dataclass is frozen (attributes cannot be reassigned) but the dicts 106 themselves are ordinary mutable dicts, so they can be merged and extended 107 freely. 108 109 Example: 110 ```python 111 defs = subsequence.load_definitions("project.yaml") 112 defs.channels["birds"] # 3 113 defs.notes # {"ride_edge_soft": 53, ...} 114 ``` 115 """ 116 117 notes: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 118 cc: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 119 channels: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 120 programs: typing.Dict[str, int] = dataclasses.field(default_factory=dict) 121 nrpn: typing.Dict[str, int] = dataclasses.field(default_factory=dict)
The name-to-number tables read from a project definitions file.
One plain dict per section, always present — an absent or null section
is an empty dict. The dicts merge directly into the existing parameters:
notes into drum_note_map=, cc into cc_name_map=, nrpn
into nrpn_name_map=, while channels values feed channel= and
programs values feed p.program_change().
The dataclass is frozen (attributes cannot be reassigned) but the dicts themselves are ordinary mutable dicts, so they can be merged and extended freely.
Example:
defs = subsequence.load_definitions("project.yaml") defs.channels["birds"] # 3 defs.notes # {"ride_edge_soft": 53, ...}
124def load_definitions (path: typing.Union[str, pathlib.Path]) -> Definitions: 125 126 """ 127 Load and validate a project definitions file. 128 129 Reads the YAML file at ``path`` and returns a :class:`Definitions` whose 130 ``notes`` / ``cc`` / ``channels`` / ``programs`` / ``nrpn`` dicts merge 131 straight into pattern parameters. See the module docstring for the file 132 format, the value ranges, and the shared-vocabulary contract with the 133 Subsample sampler. 134 135 Validation is strict inside the sections listed above and lenient outside 136 them: unknown top-level sections are ignored, while a bad name, a non-whole 137 number (including YAML ``true``/``false``), or an out-of-range value is 138 rejected with an error naming the file, section, and entry. 139 140 Parameters: 141 path: The definitions file, as a path string or ``pathlib.Path``. 142 143 Returns: 144 A :class:`Definitions` with one name-to-number dict per section. 145 146 Raises: 147 ValueError: If the file is missing, unreadable, or not valid YAML; if 148 the top level or a consumed section is not a mapping; or if a name 149 or value inside a consumed section is invalid. File-system and 150 YAML errors are wrapped so this is the only error type raised. 151 152 Example: 153 ```python 154 defs = subsequence.load_definitions("project.yaml") 155 156 @comp.pattern(channel=defs.channels["kit"], drum_note_map=defs.notes) 157 def kit (p): 158 p.hit("ride_edge_soft", beats=[1, 3]) 159 ``` 160 """ 161 162 p = pathlib.Path(path) 163 164 try: 165 with p.open(encoding="utf-8") as fh: 166 raw = yaml.safe_load(fh) 167 except (OSError, yaml.YAMLError) as exc: 168 raise ValueError( 169 f"definitions file {p} could not be read: {exc}" 170 ) from exc 171 172 if raw is None: 173 return Definitions() 174 175 if not isinstance(raw, dict): 176 raise ValueError( 177 f"definitions file {p}: top level must be a mapping of " 178 f"sections (notes:, cc:, …), got {type(raw).__name__}" 179 ) 180 181 tables: typing.Dict[str, typing.Dict[str, int]] = {} 182 183 for section in sorted(CONSUMED_SECTIONS): 184 section_raw = raw.get(section) 185 186 if section_raw is None: 187 continue 188 189 if not isinstance(section_raw, dict): 190 raise ValueError( 191 f"definitions file {p}: section {section!r} must be a " 192 f"mapping of name to number " 193 f"(got {type(section_raw).__name__})" 194 ) 195 196 lo, hi = _SECTION_RANGES[section] 197 table: typing.Dict[str, int] = {} 198 199 for name_raw, value in section_raw.items(): 200 name = str(name_raw) 201 202 if not _NAME_RE.fullmatch(name): 203 raise ValueError( 204 f"definitions file {p}: section {section!r}: name " 205 f"{name!r} must match [a-z][a-z0-9_]* (lowercase " 206 f"letters, digits, underscores — no dots)" 207 ) 208 209 # bool is an int subclass — reject it first so ``x: true`` fails 210 # loudly instead of quietly becoming 1. 211 if isinstance(value, bool) or not isinstance(value, int): 212 raise ValueError( 213 f"definitions file {p}: section {section!r}: " 214 f"{name!r} must be a whole number (got {value!r})" 215 ) 216 217 if not lo <= value <= hi: 218 raise ValueError( 219 f"definitions file {p}: section {section!r}: " 220 f"{name!r} = {value} is outside [{lo}, {hi}]" 221 ) 222 223 table[name] = value 224 225 tables[section] = table 226 227 return Definitions( 228 notes = tables.get("notes", {}), 229 cc = tables.get("cc", {}), 230 channels = tables.get("channels", {}), 231 programs = tables.get("programs", {}), 232 nrpn = tables.get("nrpn", {}), 233 )
Load and validate a project definitions file.
Reads the YAML file at path and returns a Definitions whose
notes / cc / channels / programs / nrpn dicts merge
straight into pattern parameters. See the module docstring for the file
format, the value ranges, and the shared-vocabulary contract with the
Subsample sampler.
Validation is strict inside the sections listed above and lenient outside
them: unknown top-level sections are ignored, while a bad name, a non-whole
number (including YAML true/false), or an out-of-range value is
rejected with an error naming the file, section, and entry.
Arguments:
- path: The definitions file, as a path string or
pathlib.Path.
Returns:
A
Definitionswith one name-to-number dict per section.
Raises:
- ValueError: If the file is missing, unreadable, or not valid YAML; if the top level or a consumed section is not a mapping; or if a name or value inside a consumed section is invalid. File-system and YAML errors are wrapped so this is the only error type raised.
Example:
defs = subsequence.load_definitions("project.yaml") @comp.pattern(channel=defs.channels["kit"], drum_note_map=defs.notes) def kit (p): p.hit("ride_edge_soft", beats=[1, 3])
3076def sieve ( 3077 classes: typing.Sequence[typing.Tuple[int, int]], 3078 hi: int, 3079 lo: int = 0, 3080) -> typing.List[int]: 3081 3082 """Xenakis sieve: the sorted integers in ``[lo, hi)`` in any of the classes. 3083 3084 A sieve (Xenakis's *crible*) is a logical formula over **residual 3085 classes** that denotes a subset of the integers. This primary form takes 3086 a list of ``(modulus, residue)`` pairs and returns their **union** over a 3087 bounded range — every ``x`` in ``[lo, hi)`` with ``x % modulus == residue`` 3088 for at least one class. The integers index *any* ordered parameter, so 3089 one kernel builds custom scales (over 0–11 semitones), non-octave pitch 3090 pools, rhythm grids, and bar-selection masks. 3091 3092 For intersection and complement, compose :func:`residual_class` objects 3093 with ``&``, ``|``, ``~`` and evaluate the result (see :class:`Sieve`). 3094 3095 Parameters: 3096 classes: ``(modulus, residue)`` pairs. ``modulus`` must be ≥ 1; the 3097 residue is taken modulo the modulus. 3098 hi: Exclusive upper bound. 3099 lo: Inclusive lower bound (default 0). 3100 3101 Returns: 3102 The sorted, de-duplicated integers in range that satisfy any class. 3103 3104 Raises: 3105 ValueError: If a modulus is below 1. 3106 3107 Example: 3108 ```python 3109 sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) 3110 # → [0, 2, 4, 5, 7, 9, 11] — the major scale as a sieve 3111 sieve([(2, 0)], hi=12) # → [0, 2, 4, 6, 8, 10] — whole-tone 3112 sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pitch pool 3113 ``` 3114 """ 3115 3116 for modulus, _residue in classes: 3117 if modulus < 1: 3118 raise ValueError(f"sieve modulus must be at least 1 — got {modulus}") 3119 3120 hits = { 3121 x 3122 for x in range(lo, hi) 3123 for modulus, residue in classes 3124 if x % modulus == residue % modulus 3125 } 3126 3127 return sorted(hits)
Xenakis sieve: the sorted integers in [lo, hi) in any of the classes.
A sieve (Xenakis's crible) is a logical formula over residual
classes that denotes a subset of the integers. This primary form takes
a list of (modulus, residue) pairs and returns their union over a
bounded range — every x in [lo, hi) with x % modulus == residue
for at least one class. The integers index any ordered parameter, so
one kernel builds custom scales (over 0–11 semitones), non-octave pitch
pools, rhythm grids, and bar-selection masks.
For intersection and complement, compose residual_class() objects
with &, |, ~ and evaluate the result (see Sieve).
Arguments:
- classes:
(modulus, residue)pairs.modulusmust be ≥ 1; the residue is taken modulo the modulus. - hi: Exclusive upper bound.
- lo: Inclusive lower bound (default 0).
Returns:
The sorted, de-duplicated integers in range that satisfy any class.
Raises:
- ValueError: If a modulus is below 1.
Example:
sieve([(12, 0), (12, 2), (12, 4), (12, 5), (12, 7), (12, 9), (12, 11)], hi=12) # → [0, 2, 4, 5, 7, 9, 11] — the major scale as a sieve sieve([(2, 0)], hi=12) # → [0, 2, 4, 6, 8, 10] — whole-tone sieve([(5, 0), (7, 1)], lo=60, hi=96) # a non-octave pitch pool
3187def residual_class (modulus: int, residue: int) -> Sieve: 3188 3189 """A single residual class ``{x : x % modulus == residue}`` as a :class:`Sieve`. 3190 3191 The atom of sieve algebra (Xenakis's notation ``modulus @ residue``). 3192 Combine with ``&`` ``|`` ``~`` and call :meth:`Sieve.evaluate`. 3193 """ 3194 3195 if modulus < 1: 3196 raise ValueError(f"residual-class modulus must be at least 1 — got {modulus}") 3197 3198 reduced = residue % modulus 3199 3200 return Sieve(lambda x: x % modulus == reduced)
A single residual class {x : x % modulus == residue} as a Sieve.
The atom of sieve algebra (Xenakis's notation modulus @ residue).
Combine with & | ~ and call Sieve.evaluate().